diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bdd591f7..099d0a49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ on: permissions: actions: read contents: read + packages: read jobs: build: @@ -15,11 +16,10 @@ jobs: uses: actions/checkout@main with: submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} - name: Set up Node.js uses: actions/setup-node@main with: - node-version: 'latest' + node-version: '22' cache: 'npm' # https://github.com/puppeteer/puppeteer/issues/13595#issuecomment-2640309385 # https://github.com/puppeteer/puppeteer/blob/cdfbde6247fbe286c31a91ac62ffed6f2f801ee4/.github/workflows/ci.yml#L140 @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: npm install env: - NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN || github.token }} - name: Install Puppeteer Chrome run: | node node_modules/puppeteer/install.mjs @@ -42,14 +42,32 @@ jobs: version: ${{ github.ref_name }} keep-v: "false" # If set to "true", will not remove any 'v' prefix from the version number. ignore-semver-check: "false" # If set to "true", will not check if the version number is a valid semver version. + - name: Run WK3 regressions + run: npm run test:wk3 - name: Build scripts run: npm run build - name: Generate modules run: npm run build:args env: PUPPETEER_CACHE_DIR: ${{ runner.temp }}/puppeteer - - name: Verify Egern module - run: test -s dist/iRingo.WeatherKit.yaml + - name: Build WK3 scripts and tagged modules + run: | + if [ "${{ github.ref_type }}" = tag ]; then + npm run build:wk3 -- --version "${GITHUB_REF_NAME#v}" + else + npm run build:wk3 + fi + - name: Verify Egern and WK3 artifacts + run: | + test -s dist/iRingo.WeatherKit.yaml + for platform in egern surge loon stash quantumultx; do + for stage in request response; do test -s "dist/wk3-$platform-$stage.bundle.js"; done + done + if [ "${{ github.ref_type }}" = tag ]; then + for extension in yaml sgmodule lpx stoverride snippet; do + test -s "dist/iRingo.WeatherKit.WK3.$extension" + done + fi - name: Upload artifact uses: actions/upload-artifact@master with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3054d5c4..2d38322d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,7 @@ on: permissions: actions: read contents: read + packages: read jobs: debug: diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 14e87a73..8f37c715 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -6,6 +6,7 @@ on: permissions: actions: read contents: read + packages: read jobs: build: @@ -15,17 +16,16 @@ jobs: uses: actions/checkout@main with: submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} ref: dev - name: Set up Node.js uses: actions/setup-node@main with: - node-version: 'latest' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm install env: - NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN || github.token }} - name: Build run: npm run dev - name: Upload artifact diff --git a/.github/workflows/draft.yml b/.github/workflows/draft.yml index b3f51aea..0d65a877 100644 --- a/.github/workflows/draft.yml +++ b/.github/workflows/draft.yml @@ -8,6 +8,7 @@ on: permissions: actions: read contents: write + packages: read jobs: build: @@ -21,10 +22,19 @@ jobs: uses: actions/download-artifact@master with: name: artifact + - name: Extract current release notes + shell: python + run: | + import pathlib, re + text = pathlib.Path("CHANGELOG.md").read_text() + section = re.split(r"(?m)^# v(?=\d)", text, maxsplit=2) + notes = "# v" + section[1] if len(section) > 1 else text + notes = re.sub(r"\n---\s*\Z", "", notes).strip() + "\n" + pathlib.Path("RELEASE-NOTES.md").write_text(notes) - name: Publish Draft uses: softprops/action-gh-release@v2 with: - body_path: CHANGELOG.md + body_path: RELEASE-NOTES.md token: ${{ secrets.GITHUB_TOKEN }} files: | rulesets/* diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index a867161b..4a4beb20 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -10,6 +10,7 @@ on: permissions: actions: read contents: write + packages: read jobs: build: @@ -23,10 +24,19 @@ jobs: uses: actions/download-artifact@master with: name: artifact + - name: Extract current release notes + shell: python + run: | + import pathlib, re + text = pathlib.Path("CHANGELOG.md").read_text() + section = re.split(r"(?m)^# v(?=\d)", text, maxsplit=2) + notes = "# v" + section[1] if len(section) > 1 else text + notes = re.sub(r"\n---\s*\Z", "", notes).strip() + "\n" + pathlib.Path("RELEASE-NOTES.md").write_text(notes) - name: Publish Pre-Release uses: softprops/action-gh-release@v2 with: - body_path: CHANGELOG.md + body_path: RELEASE-NOTES.md token: ${{ secrets.GITHUB_TOKEN }} files: | rulesets/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8a7d2a4..f6b44f3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: permissions: actions: read contents: write + packages: read jobs: build: @@ -21,10 +22,19 @@ jobs: uses: actions/download-artifact@master with: name: artifact + - name: Extract current release notes + shell: python + run: | + import pathlib, re + text = pathlib.Path("CHANGELOG.md").read_text() + section = re.split(r"(?m)^# v(?=\d)", text, maxsplit=2) + notes = "# v" + section[1] if len(section) > 1 else text + notes = re.sub(r"\n---\s*\Z", "", notes).strip() + "\n" + pathlib.Path("RELEASE-NOTES.md").write_text(notes) - name: Publish Release uses: softprops/action-gh-release@v2 with: - body_path: CHANGELOG.md + body_path: RELEASE-NOTES.md token: ${{ secrets.GITHUB_TOKEN }} files: | rulesets/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 067da420..c42bf5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,201 @@ -### 🆕 New Features - * none +# v3.3.8 — WK3 全算法空气质量、雨雪分钟与原生预警 -### 🛠️ Bug Fixes - * 修复 WeatherKit Response 处理范围未同时受用户设置和 URL `dataSets` 约束的问题:现在只处理两者的交集,避免地图天气小组件只请求当前天气与空气质量时扩展处理其他数据集。 - * 修复和风天气历史空气质量接口路径多余斜杠的问题,确保请求使用正确的 `/v7/historical/air` 路径。 +本次更新相对 **v3.3.7**。Egern 测试 11 的欧盟名称与色带已由用户实机确认;其他具体能力的验证范围见下文。 -### 🔣 Dependencies - * 将 FlatBuffer Root 处理器迁移至已发布的 `@nsnanocat/flatbuffer-root@1.0.0`,移除仓库内重复实现。 +## 更新内容 -### ‼️ Breaking Changes - * none +- **空气质量完整转换:**接通 11 种内置算法、跨标准转换、缺失 AQ 补入及污染物单位模式;保留其他天气数据槽与未知结构保护。AQ 按上游 dev 独立处理,不再被普通天气的大陆范围挡住;已有 Apple 污染物优先使用,缺失时再查询所选来源。 +- **欧盟原生标尺:**EAQI 输出无版本 `EU.EAQI`;标尺请求去除数字版本并移除旧 ETag 条件,使用 Apple 原生资源,撤去对 200 响应的本地展示覆盖,解决已验收测试版的“欧洲”名称与色带分段问题。 +- **指数与标尺配套:**补齐加拿大 AQHI 回退标尺,修正无版本 AQHI 身份、UBA 显示范围及相关单位/算法映射;保留既有加拿大计算和超量程修正。昨日趋势注明来源,彩云按上游流程比较其自身今日/昨日数据,不跨标准硬比较。 +- **雨雪分钟:**参考上游 dev 扩展雪、强雨及间歇降水枚举;未命名轻雪文字可省略并保留分钟数值和雪摘要。修复和风分钟坐标、`fxTime` 起点和 AQ 通用元数据类型;分钟有效期同步为 10 分钟,避免分段处理修改原始摘要。 +- **原生预警:**统一摘要/详情标题规范化,支持上游红橙黄蓝等级,修复高速公路等重复“预警”后缀。Apple 泛称“恶劣天气”在同地区、同等级、同发布时间且唯一候选时补全具体事件;保留原 UUID,全组匹配后才切换共享详情链接。 +- **模块参数与诊断:**保留 23 项设置和全部内置算法,今日来源默认内置算法、默认欧盟 EAQI;Egern/Surge/Loon/Stash 的指数来源和算法选项统一中文,旧内部值继续兼容;Quantumult X 无参数面板时同步模块默认值,二进制透传限制不变。模块和参数简介精简,摘要补全/详情转换分开记录。 +- **文档:**首页改为相对上游最新正式版的功能说明、默认参数和边界;补充模块 README、实验雷达实现路径与合成预览图。二维雷达仍在独立实验分支,不作为本次发行能力。 -### 🔄 Other Changes - * 从 BoxJs 设置中隐藏仅供插件参数使用的 `Storage` 选项,保留运行时配置行为。 - * 补充数据集交集、用户设置过滤、FlatBuffer 根槽映射和 QWeather 接口路径的回归测试。 +## 安装与升级 + +使用对应平台文件名含 `.WK3.` 的发行附件。停用旧普通模块或测试普通模块,更新整个模块并核对已保存参数;独立实验雷达模块可保留。Egern、Surge、Loon、Stash 的模块自动加载同版本 JS,不可互换脚本。 + +默认今日来源是“iRingo内置算法”(`Calculate`),算法是 `EU_EAQI`,原始标准替换目标仍为 `HJ6332012`。改变算法不会绕过替换目标;原数据为美标等标准时,按需把对应标准加入目标。和风 API 需要可用主机与令牌。 + +## 验证与保留限制 + +- 用户已确认 Egern 测试 11 的欧盟名称与色带修复可用。正式候选五平台 10 份 JS 与 5 份模块均构建成功;候选 Egern 业务代码与该测试版一致,仅移除测试标识。AQ、雨雪及预警的代码回放不等同于所有客户端、地区和天气条件的逐项实机验收。 +- WK3 当前、逐日、逐小时天气仍透传;Quantumult X 二进制天气仍不转换,Shadowrocket 暂无本机 WK3 模块。 +- 台北/澳门是否能补入取决于原污染物及所选来源;彩云 TW 白名单、和风历史地区限制等仍保留。没有来源数据时不伪造结果。 +- 预警失配、歧义、时间不符或未知结构保留原数据;泛称匹配为有约束推断。轻雪数值编码通过不等于每种轻雪文字及地图显示均已手机验收。 +- 原生一小时雷达的无数据斜线、物理雨强、多色与大陆预测技巧仍在开发,未并入本次普通天气发行。 + +--- + +# v3.3.7 — 修复 Surge WK3 二进制回传与天气增强无效果 + +修复 Surge 普通模块在已有脚本执行记录时仍无法正常显示天气增强数据的问题。用户已确认 `3.3.6-test-surge-binary.1` 测试版通过,本版纳入同一项原生字节回传修复。 + +## 修复内容 + +- **Surge 使用原生二进制回传:**启用 `binary-body-mode` 时,将 ArrayBuffer、Uint8Array 及其他 TypedArray 的准确字节范围作为 `Uint8Array` 返回给 Surge。避免原先转换成字符串后,被宿主按 UTF-8 再编码而损坏 WK3 FlatBuffer。 +- **保留原天气数据:**修复覆盖需要改写的 AQ、分钟雨与预警响应,也覆盖海外等应保留原数据的响应。文本结果继续使用字符串;Loon、Stash、Egern、Quantumult X 的现有输出方式保持不变。 +- **补充宿主接口回归:**检查高位字节、请求/响应输出、子数组边界和文本透传;纠正旧测试先将字符串按字符码恢复再解码、因而未覆盖宿主 UTF-8 编码行为的问题。 +- 23 项模块参数、默认来源、空气质量算法及原有回退处理保持不变。 + +## 实机验证 + +用户确认 Surge 测试版通过。真实抓包中五份完整 WK3 响应均可直接正常解码,不再出现二进制转字符串后的 UTF-8 二次编码;四份国内/香港响应的彩云美标 AQI 与同批数据源一致,三份大陆响应已写入彩云分钟雨;另有匹配预警的标题、发布机构及 Apple 原生详情入口。海外响应保留原来源。 + +本轮分钟雨样本均为零降水,预警详情正文未在抓包中点击验证;这些具体边界不扩展为逐项界面验收。抓包及个人信息不纳入发行附件。 + +## 下载与升级 + +| 客户端 | 正式模块 | +| --- | --- | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.sgmodule) | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.yaml) | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.lpx) | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.stoverride) | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.snippet) | + +Surge 用户先停用 `3.3.6-test-surge-binary.1` 或旧普通模块,安装/更新整个正式模块,确认五类脚本均引用 v3.3.7,再核对已保存的个人参数。MITM 需启用 `weatherkit.apple.com` 并信任证书。独立雷达模块可继续启用;请勿同时启用两个普通天气模块。 + +本次沿用五平台发行构建,模块自动加载各自同版本的 JS,无需手动导入脚本,也不能跨平台交换脚本。 + +## 保留的限制 + +- WK3 当前、逐日及逐小时天气继续保留 Apple 数据。 +- Quantumult X 的 WK3 二进制天气响应继续直通,Shadowrocket 暂无本机 WK3 模块。 +- 预警仅补全可严格匹配的 Apple 原事件;第三方数据源失败或结构不兼容时保留原数据。 +- 部分城市 UV 防晒时段建议可能显示英文,既有问题本次未修改。 +- 本次发行只包含普通天气模块的 Surge 修复,实验雷达继续在独立分支维护。 + +--- + +# v3.3.6 — 大陆缺失空气质量补入与 Loon、预警兼容修复 + +合入 `wk3/ios27-wk3-review` 的已验收修复,保留中文独立提交。用户已确认 Loon 测试版 2 可用,并确认 Egern 测试版的大陆缺 AQ 补入生效;Surge、Stash 本轮通过实际压缩脚本及抓包回放,尚无对应设备反馈。 + +## 更新内容 + +- **大陆缺 AQ 自动补入:**Egern、Surge、Loon、Stash 共用已验证的美标编码器。当 WK3 原响应缺少 AQ 子表、请求和设置启用 `airQuality`、天气替换范围命中大陆、今日来源为 `ColorfulCloudsUS` 且指数替换目标非空时,补入完整美标指数、污染物、单位、来源和同源昨日等级比较。已有 AQ 继续按原始标准与替换目标处理;数据源不可用或数据不完整时保留原响应。原预警及其他天气数据保留。 +- **Loon 原生字节回写:**ArrayBuffer、TypedArray 按准确字节范围作为 `Uint8Array` 回传,避免二进制转字符串导致天气不可用;文本响应保持字符串。 +- **保留 Apple availability:**合并 Apple 原有能力并补齐模块能力,保留未知字段、错误状态及语言响应头,不再用固定列表覆盖或把错误强制改成 200。 +- **非 Egern 预警兼容:**Surge、Loon、Stash、Quantumult X 构建兼容普通响应头的大小写,预警定位符采用局部 Base64 编解码,减少对宿主 API 的依赖。保留原 Apple 事件 UUID、严格唯一匹配和载荷校验;Egern 预警实现保持原样。Quantumult X 的修复限于预警详情请求,二进制响应仍直通。 +- 保留五平台共享核心、23 项模块参数、US 默认、日志等级与脱敏;没有新增依赖或缓存层。 + +## 模块下载与升级 + +安装文件名含 `.WK3.` 的对应平台模块;同次发行的十份 JS 为模块自动加载的依赖,无需单独导入。附件中的旧上游模块不是 WK3 推荐入口。 + +| 客户端 | 本次模块 | 设置与支持范围 | +| --- | --- | --- | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.yaml) | 原生模块参数面板;大陆补 AQ 已有测试版实机确认,预警实现不变。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.sgmodule) | 模块参数;支持 WK3 AQ、分钟雨、昨日比较及匹配预警补全。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.lpx) | 插件参数面板;已验收测试版 2 的字节回写、缺 AQ 和预警修复纳入正式版。 | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本 argument 的 JSON,其他四条通过 YAML 锚点共享。 | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.snippet) | 无模块参数面板;请求/文本路径可用,WK3 二进制响应直通,不执行 AQ、分钟雨或预警摘要改写。 | + +停用测试模块或其他冲突天气模块,再安装或更新对应正式模块;不要同时启用测试版和正式版。五类脚本分别负责 availability 响应、空气标尺请求、预警网页请求、原生预警详情请求、天气响应。MITM 需启用 `weatherkit.apple.com` 并信任证书。下载后脚本在代理 App 本机执行,Apple、彩云、和风数据仍需联网。 + +默认今日/昨日指数来源为 `ColorfulCloudsUS`,指数替换目标 `HJ6332012`,内置算法 `WAQI_InstantCast_US`(仅来源选 `Calculate` 时运行);分钟雨 `ColorfulClouds`、预警 `QWeatherWeb`、天气范围 `CN`、日志 `INFO`、单次请求超时 8000ms。已有个人参数不会被强制重置。空指数替换目标禁用大陆缺 AQ 补入;选择 `WeatherKit`、算法 `None` 或非美标来源不会触发这条补入路径。 + +查看 `WeatherKit.api.weather.response` 日志可确认 AQ 执行过程;成功补入包含 `WK3 missing AQ patch`,availability 日志不代表天气补入已经运行。 + +## 已知问题与限制 + +- **保留 UV 英文建议问题:**部分城市紫外线卡片的防晒时段建议显示英文,例如 `Use sun protection 11:00–16:00.`,标题和等级仍为中文。本次没有修改该文案或语言设置。现有代码核查及历史成都抓包回放未发现 UV 产品被改写;客户端本地化回退是候选解释,根因尚未证实。 +- WK3 当前、逐日、逐小时天气仍保留 Apple 数据;参数中的天气来源选择不能启用这些 WK3 替换功能。分钟雨只处理已验证结构。 +- 预警仅补全可严格匹配的 Apple 原有事件,不凭空新增;第三方网页变化或失配时保留原信息。未实现上游预警 API 来源。 +- Quantumult X 二进制限制如上;Shadowrocket 暂无本机 WK3 模块。 + +## 验证 + +测试分支 [Build 42123745628](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42123745628) 完整回归 **359/359 通过、0 失败、0 跳过**,旧入口及五平台构建、模块生成和归档成功。四款支持二进制改写的平台均完成真实缺 AQ 抓包与模拟数据源回放,原天气 arena 和预警保留;回放中的 AQI 87 为模拟值,不代表城市实时读数。标签发布另行运行完整回归与构建。 + +--- + +# v3.3.5 — 修复代理软件内脚本日志为空 + +针对用户在 Egern、Loon 实机反馈的“脚本有执行记录,日志正文仍为空”,将五平台共用日志出口统一为 `console.log`。v3.3.4 优先调用 `console.info/debug/warn/error`;这些方法存在不代表客户端会把它们收进脚本日志,因此原来的方法存在性回退不足以保证日志可见。 + +- 保留 INFO/DEBUG/WARN/ERROR/OFF 等应用层过滤与敏感数据遮蔽,不改变算法、参数默认、天气处理或安全回退。 +- 每条消息先格式化、脱敏并合成一个字符串,再调用一次 `console.log`,兼容 Loon 以单个主参数记录日志的接口。 +- Egern、Loon 的正式压缩 bundle 在同样的诊断环境下,默认 INFO 从 0 条 `console.log` 变为 4 条;五平台日志及平台回归 33/33 通过,修复分支完整 CI 测试和构建成功。 +- 请更新模块,让脚本地址切到 v3.3.5,确认设备中 `LogLevel` 为 INFO/DEBUG/ALL;旧脚本的历史执行记录不会补写日志;旧发行版保留。 +- 这是日志兼容修复,尚待用户实机确认恢复;不能把模拟输出当作 Egern/Loon 实机日志已修复的结论。QX 二进制透传、WK3 当前/日/小时天气透传等限制不变。 + +--- + +# v3.3.4 — 空气算法、配置语义与平台代理修复 + +本次针对 v3.3.3 与上游/PR #107 对比中复现的问题作定点修复,不覆盖旧标签。参考移植的算法代码保留 Horatio、PR #107 `831852f` 和 Apache-2.0 来源说明;未整套引入其 JSON 协商、缓存或额外依赖。 + +## 修复内容 + +- **算法与单位:**美标计算按污染物精度截断但保留物理浓度;PM10 超量程 604→605 的 AQI 从 500 连续到 501。修正 ppb/ppm 同单位转换、CN25 零值、加拿大 AQHI 单位一致性与四舍五入;缺少必要输入、未知算法及负分项不再伪装为有效结果。CO 仅在明确 mg/m³ 时转为 µg/m³,重复调用不放大、不修改原对象。 +- **配置语义:**空 Replace 禁用指数替换,不被自动补入 HJ6332012;昨日来源不随今日来源暗改。算法 None、WeatherKit 与未知来源不默认计算;候选失败或指数未改变不重查昨日、不覆盖 Apple。已知比较依开关保留,未知比较只按显式来源补齐,WK3 继续限制同源同标准。 +- **平台代理:**修正 Surge/Loon/Stash 响应阶段 `$done` 直接返回响应修改对象,请求阶段保留合成响应包装。修复 Surge 空数组参数;实际 `$argument` 控制请求超时与日志,而非只读取环境默认。 +- **模块设置:**名称、类型、选项标签与帮助对照上游整理,保持 23 参数和 US 默认;保留本机兼容选项并注明范围。Stash 五脚本共用一个可编辑 argument;未支持的 WK3 天气/API 预警不伪装可用。 +- **执行日志:**五平台共用 INFO 开始/结束及原有处理摘要,DEBUG/ALL 查看更多过程;OFF 静默。兼容只提供 console.log 的客户端,格式化时遮蔽令牌及完整私人设置。 +- **验证:**将实际串行回归接入 CI,再构建旧入口与五平台发行产物;每份模块必须含五条正确平台/版本脚本引用,避免零匹配假通过。修复分支 [Build 42037645244](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42037645244) **338/338 通过、0 跳过**,构建和归档成功。 + +## 默认与升级 + +默认仍为今日/昨日 `ColorfulCloudsUS`、内置算法 `WAQI_InstantCast_US`(只在 Calculate 时使用)、分钟彩云、预警 QWeatherWeb、INFO、单次 8000ms。已保存参数不会被强制重置,更新模块后请核对自己的选项。 + +使用本 Fork Release 附件中名称含 `.WK3.` 的模块;Egern/Surge/Loon/Stash/QX 均引用对应版本平台脚本。最新入口:[Egern](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml)、[Surge](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule)、[Loon](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx)、[Stash](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride)、[Quantumult X](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet)。保留 v3.3.3 便于回退;现用 Gist 未修改。 + +## 保留的边界 + +- v3.3.3 Egern 用户反馈正常;**本补丁尚无新设备验收**,其他平台代理仍仅模拟/脚本回放。 +- WK3 当前/逐日/逐小时天气仍透传,未知结构不猜测写入;QX 二进制仍透传,Shadowrocket 无本机 WK3 模块。 +- 12 个选项包含 None;WK3 不保证所有算法目标可编码。InstantCast 单组浓度计算不等同于 EPA NowCast 时间加权,也不自动提供加拿大 AQHI 的三小时均值。 +- 已被来源错误标为 µg/m³ 的 CO 无法仅凭单位判断原值应否放大,不凭猜测修正。跨来源/算法昨日比较不伪造结果。 + +--- + +# v3.3.3 — WK3 本机处理与多平台代理统一架构 + +基于 NSRingo/WeatherKit v3.3.2,保留上游 WK2 入口与中文独立提交历史,增加 iOS 27 WK3 本机处理。Egern、Surge、Loon、Stash、Quantumult X 共用 `src/wk3/` 业务核心、参数与路由,客户端只适配运行时。 + +## 更新内容 + +- WK3 空气质量有界替换:支持已验证的国标转彩云美标及同标准更新,香港 Apple 缺 AQ 时按条件补入彩云美标;保留其他数据槽与未知字段。 +- 增加已验证结构的分钟降水、同源同标准昨日空气等级比较;来源或结构不兼容时保持原始数据。 +- 严格匹配 Apple 原有预警事件后补全原生详情,保留原事件 ID;不能匹配时不伪造预警。 +- 内置空气质量提供上游 12 个选项(包含 `None`),默认 **`WAQI_InstantCast_US`**;仅当今日指数来源选为 `Calculate` 时执行本地算法。今日、昨日来源默认仍为 **`ColorfulCloudsUS`**。 +- 统一五平台构建:版本化发行生成 10 份独立 JS 和 5 份 WK3 模块;Egern 的 23 项参数、五类脚本、七条分流规则保留。五类路由同源,排除 `wk3_` 原生预警标识的重复匹配。 +- 修复 Fork CI 的缺失 checkout token、包读取令牌回退和 Node latest 漂移,固定 Node 22;无新增依赖。 + +## 下载与安装 + +推荐使用本次发行中**文件名包含 `.WK3.`** 的模块,不要用旧版模块来测试 WK3。 + +| 客户端 | 模块下载 | 验证范围 | +| --- | --- | --- | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.yaml) | 本机接口与合成回放通过;本次 Rollup 产物尚无设备验收。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.sgmodule) | 参数、字节回放及实际脚本合成 AQI 60→87 通过;尚无设备验收。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.lpx) | 参数与二进制脚本模拟通过;尚无设备验收。 | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.stoverride) | 配置、参数及二进制脚本模拟通过;尚无设备验收。 | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.snippet) | 仅请求/文本路径;**WK3 二进制天气透传,不转换**,无交互参数界面。 | + +先停用冲突的旧天气模块,再安装对应客户端模块;模块会下载同版本、对应平台的 JS。不要跨平台交换脚本。现用 Gist 没有被替换,旧 Gist 用户不会被自动升级。发行包保留的上游非 WK3 产物属于旧入口,不是本次 WK3 推荐安装项。 + +## 默认配置 + +- 今日/昨日空气指数:`ColorfulCloudsUS`;原始标准替换范围 `HJ6332012`。 +- 本地计算算法:`WAQI_InstantCast_US`,仅 `Calculate` 来源时使用。 +- 分钟降水:`ColorfulClouds`;预警:`QWeatherWeb`,可选 `WeatherKit` 原样透传。 +- WK2 天气来源:`ColorfulClouds`;逐日/逐小时替换默认均关闭。 +- 替换范围 `CN`,香港缺 AQ 有专用条件;日志 `INFO`、单次请求超时默认 8000 毫秒。设备已保存参数不会被默认值强制覆盖。 + +## 已知限制 + +- **WK3 当前、逐日、逐小时天气尚无经真实样本验证的 codec,仍保持 Apple 原始数据**;选择 WK2 天气来源不能解锁这些 WK3 替换能力。 +- Quantumult X 的 WK3 AQ、分钟雨等二进制修改不支持;Shadowrocket 没有 WK3 本机模块。 +- 新发行产物没有各客户端实机验收;原 Gist 的历史设备确认不能替代本版验证。预警补全依赖严格唯一匹配,网络及第三方服务也可能不可用。 +- 12 个算法选项不代表所有目标标准都可写入 WK3。无法确认的跨标准转换会透传;AQHI 配置及单位换算限制沿用上游实现。 + +## 验证记录 + +- 共享核心与平台测试 **106/106** 通过,发行构建测试 **2/2** 通过,分组执行避免本机并发打包超时。 +- `72229ed` 的 GitHub Build [42004403531](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42004403531) 通过安装、旧入口构建、WK3 构建、产物检查及归档;CI 构建成功不等于客户端实机通过。 +- 此次架构迁移保留 26 个协议、算法、来源文件的内容不变,更新导入和构建路径;CLI 与资源命名保持兼容。 diff --git a/README.md b/README.md index 0a7b8332..642c6927 100644 --- a/README.md +++ b/README.md @@ -1 +1,101 @@ -#  iRingo: 🌤 WeatherKit +# WeatherKit-WK3 + +让代理在设备本机处理 Apple 天气的 WK3 数据:空气质量、雨雪分钟预报、昨日对比和原生预警详情。**当前、逐日和逐小时天气继续使用 Apple 原始数据。** + +[模块安装与参数说明](modules/README.md) · [发行记录](CHANGELOG.md) · [上游项目](https://github.com/NSRingo/WeatherKit) · [实验雷达地图](#一小时降水雷达地图开发中) + +## 相比上游正式版 + +对比基线为 [NSRingo/WeatherKit v3.3.2](https://github.com/NSRingo/WeatherKit/releases/tag/v3.3.2)(截至 2026-11-08 的最新非预发行版)。本 Fork 以该版本为基础;部分 WK3 模型和处理方式参考上游 **dev**,不把上游测试版已有能力宣称为本项目独创。 + +| 项目 | 本 Fork 的差异与支持范围 | +| --- | --- | +| WK3 本机处理 | 提供 Egern、Surge、Loon、Stash 的 WK3 专用构建;共用业务核心,保留未处理的数据槽。Quantumult X 二进制天气仍透传。 | +| 空气质量 | 按上游 dev 的独立 AQ 入口处理,不套用普通天气的大陆范围;优先使用 Apple 已有污染物,缺失时查询所选来源。支持完整 AQ 转换、缺失补入和单位转换。 | +| 内置算法 | 11 种计算算法及 `None`;包括欧盟 EAQI、UBA、美标/国标 InstantCast、加拿大/香港 AQHI 和风险算法。保留本 Fork 对加拿大单位、指数取整及超量程连续性的修正。 | +| 欧盟标尺 | 与上游 dev 一样使用无版本 `EU.EAQI`,请求 Apple 原生标尺;不再用本地色带覆盖名称与渐变。 | +| 雨雪分钟预报 | WK3 分钟数据编码与缺失补入,参考上游 dev 的雨雪枚举;修复和风坐标、预报起点及有效期处理。轻雪未命名文字条件可省略,分钟强度和雪摘要保留。 | +| 预警详情 | 补全 Apple 已有事件,保留原 UUID,匹配后进入 Apple 原生详情页面;支持上游定义的红橙黄蓝等级。泛称“恶劣天气”在同地区页面、同等级、同发布时间且候选唯一时推断具体事件。 | +| 客户端兼容 | Egern 原生 `ctx`,Surge/Loon 原生字节回传,其他平台独立适配;错误或未知结构保留原数据。 | +| 参数与日志 | Egern/Surge/Loon/Stash 共用中文指数来源与算法选项、简短说明,兼容旧内部值;分别记录预警摘要、详情转换和回退原因,敏感配置不明文输出。 | + +上游 dev 与本 Fork 的实现并非完全相同。已有 AQ 的完整替换、事件 UUID 关联、宿主二进制返回等应以本仓库实现及平台说明为准。 + +## 安装 + +使用文件名包含 **`.WK3.`** 的发行附件,不要与旧 Rewrite 模块或测试模块重复启用。模块会自动加载同一版本、同一客户端的脚本,无需单独导入 JS。 + +| 客户端 | 下载 | 说明 | +| --- | --- | --- | +| Egern | [WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) | 参数面板;本轮原生欧盟标尺测试版已获用户实机确认。 | +| Surge | [WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) | 模块参数;原生二进制回传已有实机验证。 | +| Loon | [WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) | 插件参数;二进制及缺 AQ 补入已有测试版实机验证。 | +| Stash | [WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本的 `argument`,其他四条共享;当前以脚本回放验证为主。 | +| Quantumult X | [WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet) | 请求/文本路径可用;**WK3 二进制天气不转换**,无模块参数面板。 | + +启用 MITM 并信任证书。普通模块处理 `weatherkit.apple.com`;独立雷达模块另行安装。Shadowrocket 暂无经过验证的本机 WK3 模块。 + +## 默认参数与使用要点 + +以下为可配置 WK3 模块的默认值,**更新不一定覆盖设备上已保存的选项**。Egern/Surge/Loon/Stash 的指数来源与算法显示中文,括号内为内部值。 + +| 设置 | 默认值 | 作用 | +| --- | --- | --- | +| 今日空气指数来源 | iRingo内置算法(`Calculate`) | 使用污染物本地计算;也可选彩云美标、彩云国标或和风。 | +| 内置算法 | 欧盟 EAQI(`EU_EAQI`) | 11 种算法加 `None`;仅选择内置算法来源时使用。 | +| 指数替换目标 | `HJ6332012` | **原始标准**,不是目标标准;空值或 `[]` 停用指数替换。 | +| 今日污染物来源 | 彩云(`ColorfulClouds`) | 已有 Apple 污染物优先保留,缺失时才取此来源。 | +| 污染物单位 | 目标 `[]`、模式 `Scale` | 默认不额外转换;配置目标后按标准或指定单位处理。 | +| 昨日指数来源 | 彩云美标(`ColorfulCloudsUS`) | 直接同源比较;今日显示标准不同时,按上游流程比较该来源自己的今日/昨日趋势,并标注来源。 | +| 昨日污染物来源 | 和风(`QWeather`) | 内置算法计算昨日值所需的来源;须有真实历史浓度。 | +| 指数改变时重算已有趋势 | `false` | 保留有效原比较;未知趋势在转换时尝试补齐。 | +| 国标强制显示主要污染物 | `true` | 不超过 50 时也显示主要污染物。 | +| 允许内置指数超标 | `true` | 不取消输入与线格式范围检查;超出可编码范围时保留原响应。 | +| 下一小时雨雪来源 | 彩云(`ColorfulClouds`) | 单点分钟预报,不是二维雷达地图。 | +| 预警来源 | 和风网页(`QWeatherWeb`) | 严格匹配 Apple 已有事件,不凭空增加预警。 | +| 普通天气范围/逐日逐小时替换 | `CN`/`false`、`false` | WK3 当前、逐日、逐小时数据仍透传。 | +| 网络超时/日志 | 8000 毫秒/`INFO` | 单次来源请求超时;`OFF` 关闭日志。 | + +**算法切换:**先选“iRingo内置算法”,再选算法。已有 AQ 的原始标准须命中替换目标;如果 Apple 已返回美标,只有 `HJ6332012` 不会替换它,可按需加入 `EPA_NowCast`。不要把修改算法选项误认为无条件覆盖所有地区和标准。 + +**地区与来源:**AQ 不要求改国家码。台北/澳门有 Apple 污染物时可直接计算;缺数据时必须选真正支持该地区的来源。上游彩云 AQ 列表为 CN/HK/MO,TW 缺数据应选和风并配置有效 API;不承诺默认彩云对所有台北响应补入。来源失败不伪造有效数据。当前预警和分钟增强的普通地区范围仍有大陆限制。 + +**昨日比较:**有历史浓度才可重算算法。彩云历史接口给出的类别不能反推 UBA/EAQI/AQHI 的昨日浓度;无数据则趋势未知,今日指数仍可用。不同标准绝不直接比较数值。Quantumult X 无参数注入时使用同一套模块默认值,但其 WK3 二进制响应始终不转换。 + +完整 23 项参数见 [模块 README](modules/README.md)。 + +## 一小时降水雷达地图(开发中) + +![一小时雷达外推实验预览与实现路径](images/radar-preview.png) + +**这是独立实验功能,不包含在普通 WK3 模块中,也不是本次正式发行的新能力。** 当前阶段目标为中国大陆。上图使用离线沙箱的合成平移回波,展示时间序列和架构,非实时天气、非雨强预测,也不是 Apple 天气实机截图。 + +实现路径: + +1. 读取 RainViewer 公开历史雷达与覆盖信息;免费历史源不直接提供未来预测。 +2. 在用户自己的 VPS 上解码调色板、拼接邻域,用最近多帧估计运动并平流外推,计算未来约一小时的瓦片。 +3. 编码成 Apple 地图可读取的 KTX/ASTC 纹理,经用户自己的 Cloudflare Worker 提供受校验的接口。 +4. 独立代理模块仅替换匹配的雷达帧;普通 AQ、预警、分钟雨仍在代理本机处理,不绕行 VPS。 + +| 阶段 | 当前进度 | +| --- | --- | +| 数据解码、拼图、运动估计、外推、纹理编码 | 已实现;离线沙箱可以复跑真实计算链路。 | +| 历史/未来帧交付、缓存复用、排队状态及错误回退 | 已实现并做网关/Worker/代理回放测试。 | +| Apple 地图原生交付 | 历史和未来实验纹理已有手机接收证据;已显示实验回波,但**不等于覆盖与预测质量验收**。 | +| 无数据斜线与局部未知区域 | 尚未解决最终显示闭环;不伪造覆盖,不把缺测当无雨。 | +| 多色与物理雨强 | 当前交付仍为二值回波,不能解释为 mm/h;标定和显示待完成。 | +| 中国大陆真实预测技巧与多客户端体验 | 尚未完成多独立天气过程验收;已有诊断中仍有退化,不宣称一小时准确预报。 | + +实验源码位于 [`feat/cloudflare-radar`](https://github.com/patrickyanxxxxx/WeatherKit-WK3/tree/feat/cloudflare-radar)。目标是**用户自部署 Worker+自有 VPS**,没有公共默认后端;不把任何个人演示服务当产品默认。当前离线沙箱主要验证 Surge 宿主,其他客户端仍需对应设备验收。 + +## 限制、安全与开发 + +- 预警失配、候选歧义、缺失原发布时间或未知结构时保留原链接;泛称匹配是受约束推断,不是单靠标题精确识别。 +- 未命名轻雪文字条件省略时可能连带省略其他文字段;分钟数值和雪摘要保留。编码通过不等于每种天气都已获手机显示验收。 +- 部分城市 UV 防晒时段建议可能仍为英文;不改语言或伪造当前天气。 +- 来源令牌只在设备参数配置;不提交抓包、位置日志、Apple 认证或个人后端凭据。 +- `src/wk3/` 为共享业务,`src/platform/` 为宿主适配;参数见 `src/wk3/parameters.mjs`,平台模块由模板统一生成。 +- `npm run test:wk3` 运行回归;`npm run build:wk3 -- --outdir dist` 生成脚本;带 `--version 3.3.8` 生成同版本五模块。构建不会自动创建标签或发行版。 +- 保留上游及 Horatio/PR #107 等来源与 Apache-2.0 署名。上游私有包需要相应 GitHub Packages 读取权限;依赖安装失败不能当作测试通过。 + +发行差异统一放在 [CHANGELOG](CHANGELOG.md) 和每次 Release 正文,首页不再堆叠逐版更新记录。 diff --git a/images/radar-preview.png b/images/radar-preview.png new file mode 100644 index 00000000..24ce4ab5 Binary files /dev/null and b/images/radar-preview.png differ diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 00000000..56449f85 --- /dev/null +++ b/modules/README.md @@ -0,0 +1,94 @@ +# WK3 模块:安装与参数 + +[返回项目首页](../README.md) · [完整发行记录](../CHANGELOG.md) + +推荐从 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest) 安装文件名包含 **`.WK3.`** 的对应客户端模块。模块引用同一版本的两个平台专用 JS,下载脚本由客户端完成,运行和数据转换在设备本机执行;第三方数据仍需要联网获取。不要跨客户端混用 JS,也不要同时启用正式普通模块与测试普通模块。 + +## 下载与配置入口 + +| 客户端 | 正式模块 | 参数入口 | +| --- | --- | --- | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) | 模块设置面板;空气指数来源及算法显示中文,旧内部值仍兼容。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) | 模块参数;指数来源与算法和 Egern 共用中文值。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) | 插件参数面板;指数来源与算法和 Egern 共用中文选项。 | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本 `argument` 中的 JSON,其余四条共享。 | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet) | 无交互参数面板;请求/文本路径可用,WK3 二进制天气始终透传。 | + +启用 MITM 并信任证书,确认 `weatherkit.apple.com` 被处理。升级整个模块,不只替换某一条脚本;客户端可能保留旧参数,更新后请核对来源、算法和替换目标。独立实验雷达模块可与普通模块并用,但本目录的普通模块不包含二维雷达计算。 + +旧 `iRingo.WeatherKit.Rewrite.*` 是上游兼容入口,不是本 Fork 的 WK3 本机推荐模块。仓库中的 `iRingo.WeatherKit.WK3.yaml` 为生成配置,正式安装优先使用发行附件。 + +## 23 项参数 + +下表列出模块生成默认值,内部键名保留以便 Surge/Stash 配置及问题定位。Egern/Surge/Loon/Stash 共用中文指数来源与算法选项,脚本入口还原为对应内部值,旧内部值仍可使用。Quantumult X 无参数面板,脚本使用同一套默认值。 + +| 参数 | 默认值 | 用途 | +| --- | --- | --- | +| `DataSets` | airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、locationInfo、news、historicalComparisons、weatherAlerts、weatherChanges | 选择数据集;WK3 仅改 AQ、分钟降水、预警,其余透传。空值/`[]` 关闭处理。 | +| `Weather.Replace` | `CN` | 普通天气处理范围;WK3 AQ 独立处理,不受此项限制。 | +| `Weather.Provider` | `ColorfulClouds` | WK2 天气来源;不改变 WK3 当前、逐日、逐小时天气。 | +| `Weather.ReplaceDaily` | `false` | WK2 逐日替换开关。 | +| `Weather.ReplaceHourly` | `false` | WK2 逐小时替换开关。 | +| `WeatherAlerts.Provider` | `QWeatherWeb` | 和风网页补全 Apple 已有预警;`WeatherKit` 保留原数据。 | +| `NextHour.Provider` | `ColorfulClouds` | 单点下一小时雨雪分钟来源,也可选 `QWeather`/`WeatherKit`。 | +| `AirQuality.Current.Index.Provider` | `Calculate` | 今日指数来源:内置算法、`ColorfulCloudsUS`、`ColorfulCloudsCN` 或 `QWeather`。 | +| `AirQuality.Current.Index.Replace` | `HJ6332012` | 要替换的**原始标准**;逗号分隔,空值/`[]` 不替换。 | +| `AirQuality.Current.Index.ForceCNPrimaryPollutants` | `true` | 国标不超过 50 时仍显示主要污染物。 | +| `AirQuality.Current.Pollutants.Provider` | `ColorfulClouds` | 原 Apple 污染物缺失时的补全来源,可选和风。 | +| `AirQuality.Current.Pollutants.Units.Replace` | `[]` | 要转换单位的标准;默认不额外转换。 | +| `AirQuality.Current.Pollutants.Units.Mode` | `Scale` | 标准单位、指定单位或强制单位;`None` 保留来源单位。 | +| `AirQuality.Comparison.ReplaceWhenCurrentChange` | `false` | 指数变化后是否重算已有有效趋势。 | +| `AirQuality.Comparison.Yesterday.IndexProvider` | `ColorfulCloudsUS` | 昨日指数来源;比较逻辑见下文。 | +| `AirQuality.Comparison.Yesterday.PollutantsProvider` | `QWeather` | 昨日内置计算需要的真实污染物来源。 | +| `AirQuality.Calculate.Algorithm` | `EU_EAQI` | 内置算法选择,见下表。 | +| `AirQuality.Calculate.AllowOverRange` | `true` | 允许内置国标/美标超过 500;不绕过有效性与编码范围校验。 | +| `API.ColorfulClouds.Token` | 空 | 自有彩云令牌;空值使用原有公开令牌。 | +| `API.QWeather.Host` | `devapi.qweather.com` | 按和风账户提供的 API 主机配置。 | +| `API.QWeather.Token` | 空 | 使用和风 API 时填写;和风网页预警不需要。 | +| `LogLevel` | `INFO` | `DEBUG`/`ALL` 显示更多细节,`OFF` 静默。 | +| `HTTP_TIMEOUT_MS` | `8000` | 单次脚本网络请求超时,单位毫秒;宿主脚本总超时另计。 | + +### 内置算法 + +| 内部值 | 显示含义 | +| --- | --- | +| `None` | 不转换 | +| `UBA` | 德国 LQI | +| `EU_EAQI` | 欧盟 EAQI | +| `WAQI_InstantCast_US` | 美标 InstantCast | +| `WAQI_InstantCast_CN` | 国标 InstantCast(2012) | +| `WAQI_InstantCast_CN_25_DRAFT` | 国标 InstantCast(2025 草案,非正式标准) | +| `CA_AQHI` | 加拿大 AQHI | +| `HK_AQHI` | 香港 AQHI | +| `AQHI_Multi_CN` | AQHI-Multi(中国) | +| `AQHI_Multi_CN_HK` | AQHI-Multi(中国+香港) | +| `CN_DEATH_AQHI` | 中国健康风险 AQHI | +| `CN_DEATH_HK_AQHI` | 中国+香港健康风险 AQHI | + +只有今日来源选择 `Calculate` 才执行上表算法;四平台配置均显示“iRingo内置算法”。已有 Apple 污染物优先保留,缺失时才取所选来源。 + +### 替换目标和地区 + +替换目标描述输入而非输出。例如原响应为 `EPA_NowCast` 时,只填 `HJ6332012` 不会转成欧盟;按需加入 `EPA_NowCast`、`EU.EAQI`、`UBA`、`CA.AQHI`、`HK.AQHI`、`CN.AQHI`。空目标必须继续保持关闭效果,不会自动补进所有标准。 + +台北/澳门不再被普通天气地区判断拦住,但数据源本身仍有支持范围。Apple 污染物存在时可本地计算;台北原响应缺失且 `country=TW` 时,彩云上游 AQ 白名单不含 TW,应选支持 TW 的和风并配置有效 API。请求失败或缺少必要污染物时保留原数据,不更改国家码。 + +### 昨日比较 + +- 相同来源、标准及算法的两天数据比较类别趋势。 +- 今日显示标准与昨日选择不同时,彩云美标/国标来源可按上游流程比较**彩云自己的今日与昨日**;趋势来源会写入署名和日志。不是把欧盟类别与美标类别硬比较。 +- 内置昨日重算需要实际历史污染物。彩云只给昨日类别时,不能据此重算其他算法;和风历史受地区、账户及接口可用性限制。 +- `false` 保留有效原趋势;来源失败只使比较未知,不应阻断今日指数。 + +## 如何看日志 + +在 `WeatherKit.api.weather.response` 查看 `WK3 AQ patch`、`WK3 missing AQ patch`、`WK3 nextHour patch`、`WK3 alerts summary/details`。`WK3 alerts titles` 提供有限的标题对照,不含 UUID、URL 或坐标载荷。 + +`[buildSettings] Settings: [REDACTED]` 是敏感设置脱敏,不是报错;`✅ response` 只表示执行结束,不代表每项转换都成功。EAQI 标尺使用 Apple 原生无版本资源,脚本不再合成这条色带。旧测试构建标识不会出现在正式脚本。 + +## 功能边界 + +- WK3 AQ、雨雪分钟和原预警补全可用,其他天气产品继续保留原数据。 +- 泛称预警是同地区、同等级、精确时间、唯一候选约束下的推断;缺项或歧义不转详情,事件 UUID 不重生成。 +- 轻雪的未命名文字条件可省略,数值曲线是否正确显示仍取决于具体客户端和天气结构。 +- 一小时二维雷达地图另见 [首页实验进度](../README.md#一小时降水雷达地图开发中),本次普通模块发行不启用实验后端。 diff --git a/modules/iRingo.WeatherKit.WK3.lpx b/modules/iRingo.WeatherKit.WK3.lpx new file mode 100644 index 00000000..cd2c05c0 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.lpx @@ -0,0 +1,46 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = 3.3.8 + +[Argument] +DataSets = input,"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges",tag=[数据集],desc=选择要处理的数据;留空或 [] 全部保留原数据。 +Weather.Replace = input,"CN",tag=[天气] 替换范围,desc=普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。 +Weather.Provider = select,"ColorfulClouds","WeatherKit","QWeather",tag=[天气] 数据源,desc=选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。 +Weather.ReplaceDaily = switch,false,tag=[天气] 替换逐日预报,desc=是否替换逐日预报;仅 WK2 生效。 +Weather.ReplaceHourly = switch,false,tag=[天气] 替换逐小时预报,desc=是否替换逐小时预报;仅 WK2 生效。 +WeatherAlerts.Provider = select,"QWeatherWeb","WeatherKit",tag=[天气预警] 数据源,desc=和风网页补全原生预警;选择 WeatherKit 保留原数据。 +NextHour.Provider = select,"ColorfulClouds","WeatherKit","QWeather",tag=[未来一小时降水强度] 数据源,desc=选择未来一小时雨雪分钟数据来源。 +AirQuality.Current.Index.Provider = select,"iRingo内置算法","彩云天气(美标)","彩云天气(国标)","和风天气",tag=[今日空气指数] 数据源,desc=选择指数来源:iRingo内置算法、彩云或和风。 +AirQuality.Current.Index.Replace = input,"HJ6332012",tag=[今日空气指数] 替换目标,desc=填写要替换的原始标准,逗号分隔;留空或 [] 不替换。 +AirQuality.Current.Index.ForceCNPrimaryPollutants = switch,true,tag=[今日空气指数] 强制主要污染物,desc=国标指数不超过 50 时,仍显示主要污染物。 +AirQuality.Current.Pollutants.Provider = select,"ColorfulClouds","QWeather",tag=[今日污染物] 数据源,desc=已有 Apple 污染物优先保留;缺失时使用此来源。 +AirQuality.Current.Pollutants.Units.Replace = input,"[]",tag=[今日污染物 - 单位转换] 替换目标,desc=需要转换单位的空气质量标准;留空或 [] 不转换。 +AirQuality.Current.Pollutants.Units.Mode = select,"Scale","None","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb",tag=[今日污染物 - 单位转换] 模式,desc=Scale 跟随标准,Force 强制转换,None 保留原单位。 +AirQuality.Comparison.ReplaceWhenCurrentChange = switch,false,tag=[空气质量 - 对比昨日] 变化时替换,desc=今日指数变化时,是否重新计算昨日对比。 +AirQuality.Comparison.Yesterday.IndexProvider = select,"彩云天气(美标)","iRingo内置算法","彩云天气(国标)","和风天气",tag=[昨日空气指数] 数据源,desc=选择昨日指数来源;仅同源、同标准的数据可比较。 +AirQuality.Comparison.Yesterday.PollutantsProvider = select,"QWeather",tag=[昨日污染物] 数据源,desc=内置算法计算昨日指数所需的污染物来源。 +AirQuality.Calculate.Algorithm = select,"欧盟EAQI","不转换","德国LQI","美标InstantCast","国标InstantCast(2012)","国标InstantCast(2025草案)","加拿大AQHI","香港AQHI","AQHI-Multi(中国)","AQHI-Multi(中国+香港)","中国健康风险AQHI","中国+香港健康风险AQHI",tag=[iRingo内置算法] 算法,desc=使用污染物本地计算指数;None 不转换。 +AirQuality.Calculate.AllowOverRange = switch,true,tag=[iRingo内置算法] 允许指数超标,desc=允许内置美标、国标指数超过 500。 +API.ColorfulClouds.Token = input,"",tag=[API] 彩云天气令牌,desc=自有彩云令牌;留空使用内置公开令牌。 +API.QWeather.Host = input,"devapi.qweather.com",tag=[API] 和风天气主机,desc=和风 API 主机名;使用和风来源时填写。 +API.QWeather.Token = input,"",tag=[API] 和风天气令牌,desc=和风 API 令牌;网页预警不需要。 +LogLevel = select,"INFO","OFF","ERROR","WARN","DEBUG","ALL",tag=[调试] 日志等级,desc=默认 INFO;DEBUG 显示细节,OFF 关闭日志。 +HTTP_TIMEOUT_MS = input,"8000",tag=[网络] 单次请求超时(毫秒),desc=单次网络请求超时,默认 8000 毫秒。 + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP +DOMAIN,tether.edge.apple,REJECT-DROP +AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP + +[Script] +http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/ requires-body=1, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-response.bundle.js, tag=WeatherKit.api.availability.response, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/ script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-request.bundle.js, tag=WeatherKit.api.airQualityScale.request, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$) script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-request.bundle.js, tag=WeatherKit.api.weatherAlerts.html.request, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$) script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-request.bundle.js, tag=WeatherKit.api.weatherAlerts.api.request, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-response.bundle.js, tag=WeatherKit.api.weather.response, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] + +[MITM] +hostname = weatherkit.apple.com diff --git a/modules/iRingo.WeatherKit.WK3.sgmodule b/modules/iRingo.WeatherKit.WK3.sgmodule new file mode 100644 index 00000000..87144990 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.sgmodule @@ -0,0 +1,23 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = 3.3.8 +#!arguments = DataSets:"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges",Weather.Replace:"CN",Weather.Provider:"ColorfulClouds",Weather.ReplaceDaily:"false",Weather.ReplaceHourly:"false",WeatherAlerts.Provider:"QWeatherWeb",NextHour.Provider:"ColorfulClouds",AirQuality.Current.Index.Provider:"iRingo内置算法",AirQuality.Current.Index.Replace:"HJ6332012",AirQuality.Current.Index.ForceCNPrimaryPollutants:"true",AirQuality.Current.Pollutants.Provider:"ColorfulClouds",AirQuality.Current.Pollutants.Units.Replace:"[]",AirQuality.Current.Pollutants.Units.Mode:"Scale",AirQuality.Comparison.ReplaceWhenCurrentChange:"false",AirQuality.Comparison.Yesterday.IndexProvider:"彩云天气(美标)",AirQuality.Comparison.Yesterday.PollutantsProvider:"QWeather",AirQuality.Calculate.Algorithm:"欧盟EAQI",AirQuality.Calculate.AllowOverRange:"true",API.ColorfulClouds.Token:"",API.QWeather.Host:"devapi.qweather.com",API.QWeather.Token:"",LogLevel:"INFO",HTTP_TIMEOUT_MS:"8000" +#!arguments-desc = DataSets: [数据集];选择要处理的数据;留空或 [] 全部保留原数据。\nWeather.Replace: [天气] 替换范围;普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。\nWeather.Provider: [天气] 数据源;选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。\nWeather.ReplaceDaily: [天气] 替换逐日预报;是否替换逐日预报;仅 WK2 生效。\nWeather.ReplaceHourly: [天气] 替换逐小时预报;是否替换逐小时预报;仅 WK2 生效。\nWeatherAlerts.Provider: [天气预警] 数据源;和风网页补全原生预警;选择 WeatherKit 保留原数据。\nNextHour.Provider: [未来一小时降水强度] 数据源;选择未来一小时雨雪分钟数据来源。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源;选择指数来源:iRingo内置算法、彩云或和风。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标;填写要替换的原始标准,逗号分隔;留空或 [] 不替换。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物;国标指数不超过 50 时,仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源;已有 Apple 污染物优先保留;缺失时使用此来源。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标;需要转换单位的空气质量标准;留空或 [] 不转换。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式;Scale 跟随标准,Force 强制转换,None 保留原单位。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换;今日指数变化时,是否重新计算昨日对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源;选择昨日指数来源;仅同源、同标准的数据可比较。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源;内置算法计算昨日指数所需的污染物来源。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法;使用污染物本地计算指数;None 不转换。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标;允许内置美标、国标指数超过 500。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌;自有彩云令牌;留空使用内置公开令牌。\nAPI.QWeather.Host: [API] 和风天气主机;和风 API 主机名;使用和风来源时填写。\nAPI.QWeather.Token: [API] 和风天气令牌;和风 API 令牌;网页预警不需要。\nLogLevel: [调试] 日志等级;默认 INFO;DEBUG 显示细节,OFF 关闭日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒);单次网络请求超时,默认 8000 毫秒。 + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP,extended-matching +DOMAIN,tether.edge.apple,REJECT-DROP,extended-matching +AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP + +[Script] +WeatherKit.api.availability.response = type=http-response, pattern=^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/, requires-body=1, engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-response.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.airQualityScale.request = type=http-request, pattern=^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/, engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-request.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.weatherAlerts.html.request = type=http-request, pattern="^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)", engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-request.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.weatherAlerts.api.request = type=http-request, pattern="^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)", engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-request.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.weather.response = type=http-response, pattern=^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/, requires-body=1, binary-body-mode=1, engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-response.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" + +[MITM] +hostname = %APPEND% weatherkit.apple.com diff --git a/modules/iRingo.WeatherKit.WK3.snippet b/modules/iRingo.WeatherKit.WK3.snippet new file mode 100644 index 00000000..1de2010a --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.snippet @@ -0,0 +1,26 @@ +#!name = WeatherKit-Local-WK3 +#!desc = WK3 请求与文本处理;二进制天气透传。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = 3.3.8 +# QuantumultX 本地脚本不传模块参数;使用与其他 WK3 模块相同的默认值,二进制响应仍透传。 + +#[filter_local] +host, weather-analytics-events.apple.com, reject +host-suffix, tthr.apple.com, reject +host, tether.edge.apple, reject + +#[rewrite_local] +# WeatherKit.api.availability.response +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/ url script-response-body https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-response.bundle.js +# WeatherKit.api.airQualityScale.request +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/ url script-request-header https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-request.bundle.js +# WeatherKit.api.weatherAlerts.html.request +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$) url script-echo-response https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-request.bundle.js +# WeatherKit.api.weatherAlerts.api.request +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$) url script-echo-response https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-request.bundle.js +# WeatherKit.api.weather.response (binary response requires device validation) +^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/ url script-response-body https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-response.bundle.js + +#[mitm] +hostname = weatherkit.apple.com diff --git a/modules/iRingo.WeatherKit.WK3.stoverride b/modules/iRingo.WeatherKit.WK3.stoverride new file mode 100644 index 00000000..80d7acd6 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.stoverride @@ -0,0 +1,77 @@ +name: WeatherKit-Local-WK3 +desc: 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +version: "3.3.8" +rules: + - DOMAIN,weather-analytics-events.apple.com,REJECT-DROP + - DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP + - DOMAIN,tether.edge.apple,REJECT-DROP + - AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP +# Stash 无插件参数选择面板;编辑首个 argument 的 JSON,YAML 锚点会将设置共享到全部五组脚本。 +# DataSets: [数据集];选择要处理的数据;留空或 [] 全部保留原数据。 +# Weather.Replace: [天气] 替换范围;普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。 +# Weather.Provider: [天气] 数据源;选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。 +# Weather.ReplaceDaily: [天气] 替换逐日预报;是否替换逐日预报;仅 WK2 生效。 +# Weather.ReplaceHourly: [天气] 替换逐小时预报;是否替换逐小时预报;仅 WK2 生效。 +# WeatherAlerts.Provider: [天气预警] 数据源;和风网页补全原生预警;选择 WeatherKit 保留原数据。 +# NextHour.Provider: [未来一小时降水强度] 数据源;选择未来一小时雨雪分钟数据来源。 +# AirQuality.Current.Index.Provider: [今日空气指数] 数据源;选择指数来源:iRingo内置算法、彩云或和风。 +# AirQuality.Current.Index.Replace: [今日空气指数] 替换目标;填写要替换的原始标准,逗号分隔;留空或 [] 不替换。 +# AirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物;国标指数不超过 50 时,仍显示主要污染物。 +# AirQuality.Current.Pollutants.Provider: [今日污染物] 数据源;已有 Apple 污染物优先保留;缺失时使用此来源。 +# AirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标;需要转换单位的空气质量标准;留空或 [] 不转换。 +# AirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式;Scale 跟随标准,Force 强制转换,None 保留原单位。 +# AirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换;今日指数变化时,是否重新计算昨日对比。 +# AirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源;选择昨日指数来源;仅同源、同标准的数据可比较。 +# AirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源;内置算法计算昨日指数所需的污染物来源。 +# AirQuality.Calculate.Algorithm: [iRingo内置算法] 算法;使用污染物本地计算指数;None 不转换。 +# AirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标;允许内置美标、国标指数超过 500。 +# API.ColorfulClouds.Token: [API] 彩云天气令牌;自有彩云令牌;留空使用内置公开令牌。 +# API.QWeather.Host: [API] 和风天气主机;和风 API 主机名;使用和风来源时填写。 +# API.QWeather.Token: [API] 和风天气令牌;和风 API 令牌;网页预警不需要。 +# LogLevel: [调试] 日志等级;默认 INFO;DEBUG 显示细节,OFF 关闭日志。 +# HTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒);单次网络请求超时,默认 8000 毫秒。 +http: + mitm: + - weatherkit.apple.com + script: + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/' + name: WeatherKit.api.availability.response + type: response + require-body: true + argument: &wk3-arguments '{"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"iRingo内置算法","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"false","AirQuality.Comparison.Yesterday.IndexProvider":"彩云天气(美标)","AirQuality.Comparison.Yesterday.PollutantsProvider":"QWeather","AirQuality.Calculate.Algorithm":"欧盟EAQI","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"}' + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/' + name: WeatherKit.api.airQualityScale.request + type: request + argument: *wk3-arguments + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' + name: WeatherKit.api.weatherAlerts.html.request + type: request + argument: *wk3-arguments + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)' + name: WeatherKit.api.weatherAlerts.api.request + type: request + argument: *wk3-arguments + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/' + name: WeatherKit.api.weather.response + type: response + require-body: true + binary-mode: true + argument: *wk3-arguments +script-providers: + WeatherKit.api.availability.response: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-response.bundle.js + interval: 86400 + WeatherKit.api.airQualityScale.request: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-request.bundle.js + interval: 86400 + WeatherKit.api.weatherAlerts.html.request: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-request.bundle.js + interval: 86400 + WeatherKit.api.weatherAlerts.api.request: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-request.bundle.js + interval: 86400 + WeatherKit.api.weather.response: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-response.bundle.js + interval: 86400 diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml new file mode 100644 index 00000000..cd6d168f --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -0,0 +1,259 @@ +name: 'WeatherKit-WK3' +description: '空气质量、原生预警与雨雪分钟增强。' +author: WeatherKit-WK3 contributors +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +rules: +- and: + match: + - domain_suffix: + match: weatherkit.apple.com + - protocol: + match: QUIC + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - ip_cidr: + match: 139.178.128.0/18 + no_resolve: true + - ip_cidr: + match: 144.178.0.0/19 + no_resolve: true + - ip_cidr: + match: 144.178.36.0/22 + no_resolve: true + - ip_cidr: + match: 144.178.48.0/20 + no_resolve: true + - ip_cidr: + match: 17.0.0.0/8 + no_resolve: true + - ip_cidr: + match: 192.35.50.0/24 + no_resolve: true + - ip_cidr: + match: 198.183.17.0/24 + no_resolve: true + - ip_cidr: + match: 205.180.175.0/24 + no_resolve: true + - ip_cidr6: + match: 2403:300::/32 + no_resolve: true + - ip_cidr6: + match: 2620:149::/32 + no_resolve: true + - ip_cidr6: + match: 2a01:b740::/32 + no_resolve: true + - ip_cidr: + match: 63.92.224.0/19 + no_resolve: true + - ip_cidr: + match: 65.199.22.0/23 + no_resolve: true + - protocol: + match: UDP + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - asn: + match: '714' + no_resolve: true + - asn: + match: '6185' + no_resolve: true + - protocol: + match: QUIC + policy: REJECT-DROP +- domain_suffix: + match: weatherkit.apple.com + policy: DIRECT +- domain: + match: weather-analytics-events.apple.com + policy: REJECT-DROP +- domain_suffix: + match: tthr.apple.com + policy: REJECT-DROP +- domain: + match: tether.edge.apple + policy: REJECT-DROP +compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"iRingo内置算法","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"false","AirQuality.Comparison.Yesterday.IndexProvider":"彩云天气(美标)","AirQuality.Comparison.Yesterday.PollutantsProvider":"QWeather","AirQuality.Calculate.Algorithm":"欧盟EAQI","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} +compat_arguments_desc: "DataSets: [数据集]\n 选择要处理的数据;留空或 [] 全部保留原数据。\nWeather.Replace: [天气] 替换范围\n 普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。\nWeather.Provider: [天气] 数据源\n 选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n 是否替换逐日预报;仅 WK2 生效。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n 是否替换逐小时预报;仅 WK2 生效。\nWeatherAlerts.Provider: [天气预警] 数据源\n 和风网页补全原生预警;选择 WeatherKit 保留原数据。\nNextHour.Provider: [未来一小时降水强度] 数据源\n 选择未来一小时雨雪分钟数据来源。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 选择指数来源:iRingo内置算法、彩云或和风。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标\n 填写要替换的原始标准,逗号分隔;留空或 [] 不替换。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物\n 国标指数不超过 50 时,仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 已有 Apple 污染物优先保留;缺失时使用此来源。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标\n 需要转换单位的空气质量标准;留空或 [] 不转换。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式\n Scale 跟随标准,Force 强制转换,None 保留原单位。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换\n 今日指数变化时,是否重新计算昨日对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 选择昨日指数来源;仅同源、同标准的数据可比较。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 内置算法计算昨日指数所需的污染物来源。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法\n 使用污染物本地计算指数;None 不转换。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标\n 允许内置美标、国标指数超过 500。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌\n 自有彩云令牌;留空使用内置公开令牌。\nAPI.QWeather.Host: [API] 和风天气主机\n 和风 API 主机名;使用和风来源时填写。\nAPI.QWeather.Token: [API] 和风天气令牌\n 和风 API 令牌;网页预警不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO;DEBUG 显示细节,OFF 关闭日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 单次网络请求超时,默认 8000 毫秒。" +env_schema: {"DataSets":{"name":"[数据集]","description":"选择要处理的数据;留空或 [] 全部保留原数据。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"是否替换逐日预报;仅 WK2 生效。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"是否替换逐小时预报;仅 WK2 生效。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"和风网页补全原生预警;选择 WeatherKit 保留原数据。","default_value":"QWeatherWeb","options":["WeatherKit","QWeatherWeb"]},"NextHour.Provider":{"name":"[未来一小时降水强度] 数据源","description":"选择未来一小时雨雪分钟数据来源。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"选择指数来源:iRingo内置算法、彩云或和风。","default_value":"iRingo内置算法","options":["iRingo内置算法","彩云天气(美标)","彩云天气(国标)","和风天气"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换目标","description":"填写要替换的原始标准,逗号分隔;留空或 [] 不替换。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制主要污染物","description":"国标指数不超过 50 时,仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"已有 Apple 污染物优先保留;缺失时使用此来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[今日污染物 - 单位转换] 替换目标","description":"需要转换单位的空气质量标准;留空或 [] 不转换。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[今日污染物 - 单位转换] 模式","description":"Scale 跟随标准,Force 强制转换,None 保留原单位。","default_value":"Scale","options":["None","Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[空气质量 - 对比昨日] 变化时替换","description":"今日指数变化时,是否重新计算昨日对比。","default_value":"false","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"选择昨日指数来源;仅同源、同标准的数据可比较。","default_value":"彩云天气(美标)","options":["iRingo内置算法","彩云天气(美标)","彩云天气(国标)","和风天气"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"内置算法计算昨日指数所需的污染物来源。","default_value":"QWeather","options":["QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[iRingo内置算法] 算法","description":"使用污染物本地计算指数;None 不转换。","default_value":"欧盟EAQI","options":["不转换","德国LQI","欧盟EAQI","美标InstantCast","国标InstantCast(2012)","国标InstantCast(2025草案)","加拿大AQHI","香港AQHI","AQHI-Multi(中国)","AQHI-Multi(中国+香港)","中国健康风险AQHI","中国+香港健康风险AQHI"]},"AirQuality.Calculate.AllowOverRange":{"name":"[iRingo内置算法] 允许指数超标","description":"允许内置美标、国标指数超过 500。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云天气令牌","description":"自有彩云令牌;留空使用内置公开令牌。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风天气主机","description":"和风 API 主机名;使用和风来源时填写。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风天气令牌","description":"和风 API 令牌;网页预警不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO;DEBUG 显示细节,OFF 关闭日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"单次网络请求超时,默认 8000 毫秒。","default_value":"8000"}} +scriptings: +- http_response: + name: WeatherKit.api.availability.response + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/availability/' + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-response.bundle.js + timeout: 30 + body_required: true + binary_body: false + max_size: 2097152 + update_interval: 86400 +- http_request: + name: WeatherKit.api.airQualityScale.request + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/airQualityScale/' + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-request.bundle.js + timeout: 30 + body_required: false + binary_body: false + update_interval: 86400 +- http_request: + name: WeatherKit.api.weatherAlerts.html.request + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-request.bundle.js + timeout: 30 + body_required: false + binary_body: false + update_interval: 86400 +- http_request: + name: WeatherKit.api.weatherAlerts.api.request + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)' + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-request.bundle.js + timeout: 30 + body_required: false + binary_body: false + update_interval: 86400 +- http_response: + name: WeatherKit.api.weather.response + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[23]/weather/' + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-response.bundle.js + timeout: 30 + body_required: true + binary_body: true + max_size: 2097152 + update_interval: 86400 + +mitm: + hostnames: + includes: + - weatherkit.apple.com diff --git a/package.json b/package.json index ab06a09e..d9838760 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "deploy:workers": "npx wrangler deploy --minify -c wrangler.workers.jsonc", "deploy:vercel": "npx vercel deploy --prod", "build": "rollup -c", + "build:wk3": "node scripts/build-wk3-release.mjs --outdir dist", + "test:wk3": "node --test --test-concurrency=1 tests/*.test.mjs tests/wk3/*.test.mjs", "build:dev": "rollup -c rollup.dev.config.mjs", "build:dev-args": "arguments-builder build -c arguments-builder.dev.config.ts", "sgmoudle:dev": "sgmoudle dev", diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs new file mode 100644 index 00000000..be78b5a0 --- /dev/null +++ b/scripts/build-egern-module.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {localParameters,parameterDescription} from '../src/wk3/parameters.mjs'; +import {groups} from '../src/wk3/routes.mjs'; +import {displayModuleOption} from '../src/wk3/option-values.mjs'; +import {release} from './build-wk3-modules.mjs'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +export const localScriptGroups = groups.map(g=>({ + type: `http_${g.stage}`, name: `WeatherKit.api.${g.name}`, + match: g.pattern.replace('^https?:','^https:').replaceAll('\\/', '/'), + body: Boolean(g.body), binary: Boolean(g.binary), file: g.stage, +})); +export function renderLocalModule({version} = {}){ + const template=fs.readFileSync(path.join(root,'template/egern.wk3.handlebars'),'utf8'); + const q=JSON.stringify; + const compat=Object.fromEntries(localParameters.map(p=>[p.key,displayModuleOption(p.key,p.value)])); + const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:parameterDescription(p),default_value:displayModuleOption(p.key,p.value),...(p.boolean?{options:['true','false']}:p.options?{options:p.options.map(value=>displayModuleOption(p.key,value))}:{})}])); + const description=localParameters.map(p=>`${p.key}: ${p.name}\n ${parameterDescription(p)}`).join('\n'); + // JSON objects are valid YAML. Quote individual substitution values using + // YAML single quotes: literal double quotes in array syntax remain safe. + let env=' env:\n';for(const p of localParameters)env+=` ${p.key}: '{{{${p.key}}}}'\n`; + if(version && !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw Error('Specify an explicit release version'); + const base=version ? release(version) : 'https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw'; + const scriptName=g=>version ? `wk3-egern-${g.file}.bundle.js` : `weatherkit-local-${g.file}.js?v=upstream-groups-1`; + const scripts=localScriptGroups.map(g=>`- ${g.type}:\n name: ${g.name}\n${env} match: '${g.match}'\n script_url: ${base}/${scriptName(g)}\n timeout: 30\n body_required: ${g.body}\n binary_body: ${g.binary}\n${g.body?' max_size: 2097152\n':''} update_interval: 86400\n`).join(''); + return template.replace('__WK3_COMPAT_ARGUMENTS__',()=>q(compat)).replace('__WK3_ARGUMENT_DESCRIPTION__',()=>q(description)).replace('__WK3_ENV_SCHEMA__',()=>q(schema)).replace('__WK3_SCRIPTINGS__',()=>scripts.trimEnd()); +} +if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ + const dest=process.argv[2]||path.join(root,'modules/iRingo.WeatherKit.WK3.yaml');fs.mkdirSync(path.dirname(dest),{recursive:true});const text=renderLocalModule();fs.writeFileSync(dest,text);console.log(dest,Buffer.byteLength(text),'bytes',localParameters.length,'parameters'); +} diff --git a/scripts/build-egern.mjs b/scripts/build-egern.mjs new file mode 100644 index 00000000..d6fb8fd7 --- /dev/null +++ b/scripts/build-egern.mjs @@ -0,0 +1,75 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; +import { rollup } from "rollup"; +import nodeResolve from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const runtimePath = path.join(root, "src/wk3/runtime.mjs"); + +function egernPlugin() { + const virtual = new Map([ + ["wk3:runtime", "export const webFetch = __egernRuntime.webFetch;"], + [path.join(root, "src/wk3/utils/context.mjs"), "export const requestContext = undefined;"], + [path.join(root, "src/wk3/utils/fetch.mjs"), "export const fetch = __egernRuntime.fetch;"], + ...["request", "response"].map(stage => [`wk3:entry:${stage}`, ` +import { handle${stage === "request" ? "Request" : "Response"} as handler } from ${JSON.stringify(path.join(root, `src/wk3/entry/${stage}.mjs`))}; +import { setLogEnv, logExecution } from ${JSON.stringify(path.join(root, "src/wk3/utils/Console.mjs"))}; +export function execute(ctx) { + setLogEnv(ctx.env); + return logExecution(handler, ctx, "${stage}"); +}`]), + ]); + return { + name: "egern-native-runtime", + resolveId(source, importer) { + if (source === "wk3:runtime" || source.startsWith("wk3:entry:")) return source; + if (!importer || !/(?:^|\/)\b(?:Console|context|fetch)\.mjs$/.test(source)) return null; + const resolved = path.resolve(path.dirname(importer), source); + return virtual.has(resolved) ? resolved : null; + }, + load(id) { + if (id === "wk3:runtime") return virtual.get(id); + return virtual.get(id) ?? null; + }, + transform(code) { + return { code: code.replace(/\bglobalThis\.fetch\b/g, "__egernRuntime.webFetch"), map: null }; + }, + }; +} + +async function bundle(input, name, minify) { + const plugins = [egernPlugin(), nodeResolve({ browser: true, preferBuiltins: false })]; + if (minify) plugins.push(terser({ maxWorkers: 1 })); + const build = await rollup({ input, plugins, onwarn(warning, warn) { warn(warning); } }); + try { + const generated = await build.generate({ format: "iife", name, inlineDynamicImports: true, sourcemap: false }); + return generated.output.find(file => file.type === "chunk").code; + } finally { + await build.close(); + } +} + +export async function buildEgern({ outdir, minify = true, filePrefix = "" } = {}) { + const runtime = await bundle(runtimePath, "__egernSupport", minify); + const results = {}; + for (const name of ["request", "response"]) { + const input = path.join(root, `src/wk3/entry/${name}.mjs`); + const business = await bundle(`wk3:entry:${name}`, "__egernBusiness", minify); + const code = `// Generated for Egern native ctx API. No runtime imports.\nexport default async function(ctx) {\n${runtime}\nconst __egernRuntime = __egernSupport.createRuntime(ctx);\n${business}\nreturn __egernBusiness.execute(ctx);\n}\n`; + const inputs = [input]; + results[name] = { code, bytes: Buffer.byteLength(code), inputs }; + if (outdir) { + await mkdir(outdir, { recursive: true }); + await writeFile(path.join(outdir, `${filePrefix}${name}.bundle.js`), code); + } + } + return results; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const outdir = path.resolve(process.argv[2] || path.join(root, "dist/egern")); + const results = await buildEgern({ outdir }); + for (const [name, result] of Object.entries(results)) console.log(`${path.join(outdir, `${name}.bundle.js`)} ${result.bytes} bytes`); +} diff --git a/scripts/build-wk3-modules.mjs b/scripts/build-wk3-modules.mjs new file mode 100644 index 00000000..2da12031 --- /dev/null +++ b/scripts/build-wk3-modules.mjs @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; +import { groups } from '../src/wk3/routes.mjs'; +import { displayModuleOption } from '../src/wk3/option-values.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const parameters = localParameters; +export const platforms = { surge: 'sgmodule', loon: 'lpx', stash: 'stoverride', quantumultx: 'snippet' }; +export const release = version => `https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v${version}`; +export { groups } from '../src/wk3/routes.mjs'; +const label = group => `WeatherKit.api.${group.name}`; +const script = (group, platform) => `wk3-${platform}-${group.stage}.bundle.js`; +const url = (group, version, platform) => `${release(version)}/${script(group, platform)}`; +const surgeArgument = parameters.map(({ key }) => `${key}="{{{${key}}}}"`).join('&'); +const loonArgument = `[${parameters.map(({ key }) => `{${key}}`).join(',')}]`; +const expand = (text, replacements) => { + for (const [key, value] of Object.entries(replacements)) text = text.replaceAll(`__WK3_${key}__`, value); + if (/__WK3_[A-Z_]+__/.test(text)) throw new Error('Unrendered WK3 template placeholder'); + return text; +}; +const defaults = parameters.map(({ key, value }) => `${key}:${JSON.stringify(displayModuleOption(key, value))}`).join(','); +const descriptions = parameters.map(parameter => `${parameter.key}: ${parameter.name};${parameterDescription(parameter)}`.replaceAll('\n', ' ')); +const description = descriptions.join('\\n'); +const loonArguments = parameters.map(parameter => { + const { key, value, boolean, options, name } = parameter; + const kind = boolean ? `switch,${value}` : options ? `select,${[value, ...options.filter(v => v !== value)].map(v => JSON.stringify(displayModuleOption(key, v))).join(',')}` : `input,${JSON.stringify(value)}`; + return `${key} = ${kind},tag=${name},desc=${parameterDescription(parameter).replaceAll(/[\r\n,]/g, ',')}`; +}).join('\n'); +const quoteYaml = text => `'${text.replaceAll("'", "''")}'`; +const stashArgument = quoteYaml(JSON.stringify(Object.fromEntries(parameters.map(({ key, value }) => [key, displayModuleOption(key, value)])))); + +export function renderWK3Module(platform, version) { + if (!Object.hasOwn(platforms, platform)) throw new Error(`Unsupported platform: ${platform}`); + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error('Specify an explicit release version, e.g. 3.3.2'); + const template = fs.readFileSync(path.join(root, `template/wk3.${platform}.handlebars`), 'utf8'); + const rows = groups.map((group, index) => { + const tag = label(group), target = url(group, version, platform); + if (platform === 'surge') return `${tag} = type=http-${group.stage}, pattern=${group.pattern.includes('(?:') ? `"${group.pattern}"` : group.pattern}${group.body ? ', requires-body=1' : ''}${group.binary ? ', binary-body-mode=1' : ''}, engine=webview, script-path=${target}, argument=${surgeArgument}`; + if (platform === 'loon') return `http-${group.stage} ${group.pattern}${group.body ? ' requires-body=1,' : ''}${group.binary ? ' binary-body-mode=1,' : ''} script-path=${target}, tag=${tag}, argument=${loonArgument}`; + if (platform === 'stash') return ` - match: ${quoteYaml(group.pattern)}\n name: ${tag}\n type: ${group.stage}${group.body ? '\n require-body: true' : ''}${group.binary ? '\n binary-mode: true' : ''}\n argument: ${index === 0 ? `&wk3-arguments ${stashArgument}` : '*wk3-arguments'}`; + const action = group.echo ? 'script-echo-response' : group.stage === 'request' ? 'script-request-header' : 'script-response-body'; + return `# ${tag}${group.binary ? ' (binary response requires device validation)' : ''}\n${group.pattern} url ${action} ${target}`; + }).join('\n'); + const providers = groups.map(group => ` ${label(group)}:\n url: ${url(group, version, platform)}\n interval: 86400`).join('\n'); + return expand(template, { VERSION: version, SCRIPTS: rows, PROVIDERS: providers, ARGUMENTS: platform === 'loon' ? loonArguments : defaults, ARGUMENT_DESC: description, ARGUMENT_COMMENTS: descriptions.map(line => `# ${line}`).join('\n') }); +} + +export function buildWK3Modules({ outdir, version } = {}) { + if (!outdir) throw new Error('Specify --outdir to avoid changing existing modules'); + const outputs = {}; + for (const [platform, extension] of Object.entries(platforms)) { + const file = path.join(outdir, `iRingo.WeatherKit.WK3.${extension}`); + const text = renderWK3Module(platform, version); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + outputs[platform] = file; + } + return outputs; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const readFlag = name => { const index = process.argv.indexOf(name); return index === -1 ? undefined : process.argv[index + 1]; }; + const outputs = buildWK3Modules({ outdir: readFlag('--outdir'), version: readFlag('--version') }); + for (const [platform, file] of Object.entries(outputs)) console.log(`${platform}: ${file}`); +} diff --git a/scripts/build-wk3-platform.mjs b/scripts/build-wk3-platform.mjs new file mode 100644 index 00000000..1d3edef4 --- /dev/null +++ b/scripts/build-wk3-platform.mjs @@ -0,0 +1,92 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; +import { rollup } from "rollup"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const platforms = new Set(["surge", "loon", "stash", "quantumultx"]); +function runtimePlugin() { + const virtual = new Map([ + ["wk3:runtime", 'let runtime; export const setRuntime=value=>{runtime=value}; export const webFetch=(...args)=>runtime.webFetch(...args); export const runtimeFetch=(...args)=>runtime.fetch(...args);'], + [path.join(root, "src/wk3/utils/context.mjs"), "export const requestContext=undefined;"], + [path.join(root, "src/wk3/utils/fetch.mjs"), 'export { runtimeFetch as fetch } from "wk3:runtime";'], + ]); + return { + name: "wk3-platform-runtime", + resolveId(source, importer) { + if (source === "wk3:runtime") return "wk3:runtime"; + if (!importer || !/(?:^|\/)(?:Console|context|fetch)\.mjs$/.test(source)) return null; + const resolved = path.resolve(path.dirname(importer), source); + return virtual.has(resolved) ? resolved : null; + }, + load(id) { return virtual.get(id) ?? null; }, + transform(code, id) { + const original = code; + const alertCompat = JSON.stringify(path.join(root, "src/platform/wk3-alert-compat.mjs")); + if (id === path.join(root, "src/wk3/class/WeatherAlerts.mjs")) { + code = `import { readAlertHeader } from ${alertCompat};\n${code.replace('typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"]', 'readAlertHeader(headers, "content-type")')}`; + } + if (id === path.join(root, "src/wk3/class/NativeAlertDetails.mjs")) { + code = `import { encodeAlertBase64, decodeAlertBase64 } from ${alertCompat};\n${code.replace('btoa(String.fromCharCode(...bytes))', 'encodeAlertBase64(bytes)').replace('atob(encoded.replace', 'decodeAlertBase64(encoded.replace')}`; + } + if (code.includes("globalThis.fetch") && id !== "wk3:runtime") { + code = `import { webFetch as __wk3WebFetch } from "wk3:runtime";\n${code.replace(/\bglobalThis\.fetch\b/g, "__wk3WebFetch")}`; + } + return code === original ? null : { code, map: null }; + }, + }; +} + +export async function buildPlatform(platform, { outdir, minify = true } = {}) { + if (!platforms.has(platform)) throw new TypeError(`Unverified platform: ${platform}`); + const files = {}; + for (const [name, handlerName] of [["request", "handleRequest"], ["response", "handleResponse"]]) { + const entry = `virtual:wk3-${name}`; + const plugin = { + name: "wk3-platform-entry", + resolveId(id) { return id === entry ? id : null; }, + load(id) { + if (id !== entry) return null; + return `import { setRuntime } from "wk3:runtime"; +import { runPlatform } from ${JSON.stringify(path.join(root, "src/platform/wk3-adapter.mjs"))}; +import { ${handlerName} } from ${JSON.stringify(path.join(root, `src/wk3/entry/${name}.mjs`))}; +import { createRuntime } from ${JSON.stringify(path.join(root, "src/wk3/runtime.mjs"))}; +import { setLogEnv, logExecution } from ${JSON.stringify(path.join(root, "src/wk3/utils/Console.mjs"))}; +const __bytes=v=>typeof v==="string"?Uint8Array.from(v,c=>c.charCodeAt(0)&255).buffer:v instanceof ArrayBuffer?v:ArrayBuffer.isView(v)?v.buffer.slice(v.byteOffset,v.byteOffset+v.byteLength):new Uint8Array(v).buffer; +runPlatform(async ctx=>{ + setLogEnv(ctx.env); + const runtime=createRuntime({env:ctx.env,http:globalThis.$httpClient?{ + get:(u,o)=>new Promise((r,j)=>$httpClient.get({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))), + post:(u,o)=>new Promise((r,j)=>$httpClient.post({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))) + }:{ + get:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>__bytes(x.body)}}, + post:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>new TextEncoder().encode(x.body).buffer}} + }}); + setRuntime(runtime); + return logExecution(${handlerName}, ctx, "${name}").catch(() => undefined); +},"${name}",globalThis,"${platform === "quantumultx" ? "Quantumult X" : platform}");`; + }, + }; + const plugins = [plugin, runtimePlugin(), nodeResolve({ browser: true, preferBuiltins: false })]; + if (minify) plugins.push(terser({ maxWorkers: 1 })); + const build = await rollup({ input: entry, plugins }); + try { + const generated = await build.generate({ format: "iife", name: "__WK3", inlineDynamicImports: true, sourcemap: false }); + const chunk = generated.output.find(file => file.type === "chunk"); + const code = chunk.code.replace(/\nexport \{[^}]*\};?\s*$/, "\n"); + files[name] = { code, bytes: Buffer.byteLength(code) }; + if (outdir) { await mkdir(outdir, { recursive: true }); await writeFile(path.join(outdir, `wk3-${platform}-${name}.bundle.js`), code); } + } finally { await build.close(); } + } + return files; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const platform = process.argv[2]; + if (!platform) throw new Error(`Usage: node scripts/build-wk3-platform.mjs <${[...platforms].join("|")}> [outdir]`); + const outdir = path.resolve(process.argv[3] ?? path.join(root, "dist/platforms", platform)); + const files = await buildPlatform(platform, { outdir }); + for (const [name, file] of Object.entries(files)) console.log(`${path.join(outdir, `wk3-${platform}-${name}.bundle.js`)} ${file.bytes} bytes`); +} diff --git a/scripts/build-wk3-release.mjs b/scripts/build-wk3-release.mjs new file mode 100644 index 00000000..33f8924b --- /dev/null +++ b/scripts/build-wk3-release.mjs @@ -0,0 +1,30 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildEgern } from "./build-egern.mjs"; +import { renderLocalModule } from "./build-egern-module.mjs"; +import { buildPlatform } from "./build-wk3-platform.mjs"; +import { buildWK3Modules, platforms, renderWK3Module } from "./build-wk3-modules.mjs"; +import { writeFile } from "node:fs/promises"; + +export async function buildWK3Release({ outdir, version } = {}) { + if (!outdir) throw new Error("Specify --outdir"); + if (version) { + renderWK3Module("surge", version); + renderLocalModule({ version }); + } + const egern = await buildEgern({ outdir, filePrefix: "wk3-egern-" }); + for (const platform of Object.keys(platforms)) await buildPlatform(platform, { outdir }); + if (version) { + buildWK3Modules({ outdir, version }); + await writeFile(path.join(outdir, "iRingo.WeatherKit.WK3.yaml"), renderLocalModule({ version })); + } + return { scripts: Object.keys(platforms).length * 2 + Object.keys(egern).length, modules: version ? Object.keys(platforms).length + 1 : 0 }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const flag = name => { const index = process.argv.indexOf(name); return index < 0 ? undefined : process.argv[index + 1]; }; + const outdir = flag("--outdir"); + if (!outdir) throw new Error("Usage: node scripts/build-wk3-release.mjs --outdir dist [--version 3.3.2]"); + const result = await buildWK3Release({ outdir: path.resolve(outdir), version: flag("--version") }); + console.log(`WK3: ${result.scripts} scripts, ${result.modules} modules`); +} diff --git a/src/class/AirQuality.mjs b/src/class/AirQuality.mjs index b5c4b770..56336df7 100644 --- a/src/class/AirQuality.mjs +++ b/src/class/AirQuality.mjs @@ -15,7 +15,8 @@ export default class AirQuality { const multiplier = 10 ** decimals; // 向上取整 - return Math.ceil(a * multiplier) / multiplier; + const scaled = a * multiplier; + return Math.ceil(scaled - Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) / multiplier; } /** @@ -70,8 +71,9 @@ export default class AirQuality { const today = Number(todayCategoryIndex); const yesterday = Number(yesterdayCategoryIndex); - // -1、0 和 null 都表示“无可用等级”,不能参与好转/恶化比较。 - if (![today, yesterday].every(categoryIndex => Number.isFinite(categoryIndex) && categoryIndex > 0)) { + // -1、0、空值与非整数均不能参与好转/恶化比较。 + if ([todayCategoryIndex, yesterdayCategoryIndex].some(value => value === null || typeof value === "boolean" || String(value).trim() === "") || + ![today, yesterday].every(value => Number.isInteger(value) && value > 0)) { Console.error("CompareCategoryIndexes", "categoryIndex无效"); return UNKNOWN; } else { @@ -104,7 +106,7 @@ export default class AirQuality { Console.info("ConvertUnits", `Converting ${pollutant.pollutantType}`); const amount = AirQuality.ConvertUnit(pollutant.amount, pollutant.units, pollutantScale.units, stpConversionFactors?.[pollutant.pollutantType] || -1, pollutantScale?.stpConversionFactor || -1); - if (amount < 0) { + if (!Number.isFinite(amount) || amount < 0) { Console.error( "ConvertUnits", `无法转换${pollutant.pollutantType}的单位`, @@ -132,32 +134,18 @@ export default class AirQuality { * @param {any} airQuality - 空气质量数据对象 * @returns {any} 单位修复后的空气质量数据对象 */ + // CO unit repair adapted from Horatio's Apache-2.0 PR #107 (831852f). + // A value already labeled µg/m³ cannot safely be distinguished from a mislabeled raw mg/m³ value. static FixPollutantsUnits(airQuality) { - Console.info("☑️ FixPollutantsUnits"); - switch (airQuality?.metadata?.providerName) { - case "和风天气": - case "QWeather": { - airQuality.pollutants = airQuality?.pollutants?.map(pollutant => { - switch (pollutant.pollutantType) { - case "CO": { - const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; - pollutant.amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3); - break; - } - default: - break; - } - return pollutant; - }); - break; - } - default: { - Console.info(`Provider ${airQuality?.metadata?.providerName} is no need to fix.`); - break; - } - } - Console.info("✅ FixPollutantsUnits"); - return airQuality; + Console.debug("☑️ FixPollutantsUnits"); + if (!["和风天气", "QWeather"].includes(airQuality?.metadata?.providerName) || !Array.isArray(airQuality?.pollutants)) return airQuality; + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = airQuality.pollutants.map(pollutant => { + if (pollutant?.pollutantType !== "CO" || pollutant.units !== mgm3) return pollutant; + const amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3); + return amount < 0 ? pollutant : { ...pollutant, amount, units: ugm3 }; + }); + return { ...airQuality, pollutants }; } static #GetStpConversionFactors(airQuality) { @@ -175,12 +163,16 @@ export default class AirQuality { static Pollutants2AQI(airQuality, Settings, options = {}) { Console.info("☑️ Pollutants2AQI"); - const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm; + const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm ?? "UBA"; const forcePrimaryPollutant = options?.forcePrimaryPollutant ?? Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants; const allowOverRange = options?.allowOverRange ?? Settings?.AirQuality?.Calculate?.AllowOverRange; const stpConversionFactors = AirQuality.#GetStpConversionFactors(airQuality); - const scale = AirQuality.Config.Scales[algorithm] ?? AirQuality.Config.Scales.UBA; + const scale = Object.hasOwn(AirQuality.Config.Scales, algorithm ?? "") ? AirQuality.Config.Scales[algorithm] : undefined; + if (algorithm !== "None" && !scale) { + Console.error("Pollutants2AQI", `Unsupported algorithm: ${algorithm}`); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: airQuality?.pollutants }; + } switch (algorithm) { case "None": { @@ -196,7 +188,7 @@ export default class AirQuality { case "WAQI_InstantCast_US": { // PollutantsToInstantCastUS Console.info("☑️ PollutantsToInstantCastUS", `allowOverRange: ${allowOverRange}`); - airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange }); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange, concentrationSteps: { PM2_5: 0.1, PM10: 1, OZONE: 0.1, NO2: 1, SO2: 1, CO: 100 } }); Console.info("✅ PollutantsToInstantCastUS"); break; } @@ -225,6 +217,7 @@ export default class AirQuality { // PollutantsToAQHI Console.info("☑️ PollutantsToAQHI"); airQuality = AirQuality.#PollutantsToAQHI(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; airQuality = { ...airQuality, index: airQuality.categoryIndex, @@ -236,6 +229,7 @@ export default class AirQuality { case "AQHI_Multi_CN_HK": { Console.info("☑️ PollutantsToAQHIMulti"); airQuality = AirQuality.#PollutantsToAQHIMulti(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; airQuality = { ...airQuality, index: airQuality.categoryIndex, @@ -268,8 +262,7 @@ export default class AirQuality { const getScale = scaleName => { const scales = AirQuality.Config.Scales; switch (scaleName) { - case scales.CN_AQHI.weatherKitScale.name: - return scales.CN_AQHI; + case scales.HK_AQHI.weatherKitScale.name: return scales.HK_AQHI; case scales.CA_AQHI.weatherKitScale.name: @@ -486,40 +479,47 @@ export default class AirQuality { * @returns {number} * 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。 */ - static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits) { + static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits, step) { if (!pollutantScale) { Console.warn("PollutantsToIndexes", `标准未定义污染物${pollutantType},index将标记为-1`); return -1; } + if (!Number.isFinite(pollutant.amount) || pollutant.amount < 0 || pollutant.units !== pollutantScale.units) return -1; + const scaled = step ? pollutant.amount / step : pollutant.amount; + const amount = step ? Math.floor(scaled + Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) * step : pollutant.amount; const { add, subtract, multiply, divide } = SimplePrecisionMath; Console.debug(`${pollutantType}: ${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units}`); const minValidAmount = pollutantScale.ranges.min.amounts[0]; - if (pollutant.amount < minValidAmount) { + if (amount < minValidAmount) { Console.error("PollutantsToIndexes", `${pollutantType}的含量无效:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}需要 >= ${minValidAmount}`); return pollutantScale.ranges.min.indexes[0]; } - const isOverRange = pollutant.amount > pollutantScale.ranges.max.amounts[1]; + const isOverRange = amount > pollutantScale.ranges.max.amounts[1]; if (isOverRange) { Console.warn("PollutantsToIndexes", `检测到爆表指数!${pollutantType}:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}`); Console.warn("PollutantsToIndexes", "注意身体!"); } - const { indexes, amounts } = isOverRange + const range = isOverRange ? pollutantScale.ranges.max : pollutantScale.ranges.value.find(({ amounts }) => { const [minAmount, maxAmount] = amounts; - return AirQuality.#CeilByPrecision(pollutant.amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(pollutant.amount, maxAmount) <= maxAmount; + return AirQuality.#CeilByPrecision(amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(amount, maxAmount) <= maxAmount; }); + if (!range) return -1; + const { indexes, amounts } = range; Console.debug(`indexes: ${JSON.stringify(indexes)}`, `amounts: ${JSON.stringify(amounts)}`); const [minIndex, maxIndex] = indexes; const [minAmount, maxAmount] = amounts; - const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(pollutant.amount, minAmount)), pollutant.amount > maxAmount ? maxIndex : minIndex); - return index; + if (minIndex === maxIndex) return minIndex; + const concentration = isOverRange ? amount : Math.max(minAmount, Math.min(maxAmount, amount)); + const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(concentration, minAmount)), minIndex); + return Number.isFinite(index) && index >= 0 ? index : -1; } /** @@ -549,11 +549,11 @@ export default class AirQuality { * 每种标准污染物对应的 index 列表。 * - 若标准所需污染物缺失,返回该污染物 index=-1。 */ - static #PollutantsToIndexes(pollutants = [], pollutantScales) { + static #PollutantsToIndexes(pollutants = [], pollutantScales, concentrationSteps) { return pollutants.map(pollutant => { - const index = AirQuality.#ComputePollutantIndex(pollutant, pollutant.pollutantType, pollutantScales?.[pollutant.pollutantType], AirQuality.Config.Units.Friendly); - pollutant.index = index; - return pollutant; + const converted = { ...pollutant }; + converted.index = AirQuality.#ComputePollutantIndex(converted, converted.pollutantType, pollutantScales?.[converted.pollutantType], AirQuality.Config.Units.Friendly, concentrationSteps?.[converted.pollutantType]); + return converted; }); } @@ -650,10 +650,13 @@ export default class AirQuality { newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants); } - newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants); + newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants, options.concentrationSteps); Console.debug("PollutantsToAirQuality", `convertedPollutants: ${JSON.stringify(newPollutants)}`); const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories); + if (!Number.isFinite(primaryPollutant.index) || primaryPollutant.index < 0 || primaryPollutant.categoryIndex < 1) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: newPollutants }; + } const maxIndex = scale?.weatherKitScale?.maxIndex; const index = allowOverRange || !Number.isFinite(maxIndex) ? Math.round(primaryPollutant.index) : Math.min(Math.round(primaryPollutant.index), maxIndex); Console.info("✅ PollutantsToAirQuality"); @@ -684,6 +687,13 @@ export default class AirQuality { * @param {{ stpConversionFactors?: object }} [options] * @returns {object} 标准化 airQuality 对象 */ + // Required-input rule adapted from Horatio's Apache-2.0 PR #107 (831852f). + static #HasRiskInputs(pollutants, scale) { + const present = new Set(pollutants.filter(p => p && Number.isFinite(p.amount) && p.amount >= 0 && p.units === scale.pollutants[p.pollutantType]?.units).map(p => p.pollutantType)); + const pmTypes = ["PM10", "PM2_5"].filter(type => scale.pollutants[type]); + return pmTypes.some(type => present.has(type)) && Object.keys(scale.pollutants).every(type => pmTypes.includes(type) || present.has(type)); + } + static #PollutantsToAQHI(pollutants, scale, options = {}) { Console.info("☑️ PollutantsToAQHI"); @@ -694,16 +704,19 @@ export default class AirQuality { } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; // 先做单位转换(目标单位均为 µg/m³) - const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } // 按 pollutantType 索引浓度 const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); if (!p) return null; - if (p.units !== ugm3) { + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { Console.warn("PollutantsToAQHI", `${type} 单位不是 µg/m³(${p.units}),跳过`); return null; } @@ -750,7 +763,9 @@ export default class AirQuality { }); // 将 %AR 映射到 1–11 的 categoryIndex (即 AQHI 指数) - const categoryIndex = AirQuality.CategoryIndex(totalAR, scale.categories); + const categoryIndex = scale.indexScaleFactor + ? Math.max(1, Math.min(11, Math.round(totalAR * scale.indexScaleFactor))) + : AirQuality.CategoryIndex(totalAR, scale.categories); // 以最大 %AR 对应的污染物作为主污染物 const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 }); @@ -777,13 +792,16 @@ export default class AirQuality { } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; - const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); if (!p) return null; - if (p.units !== ugm3) { + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { Console.warn("PollutantsToAQHIMulti", `${type} 单位不是 µg/m³(${p.units}),跳过`); return null; } @@ -1858,7 +1876,7 @@ export default class AirQuality { categories: { significantIndex: 3, // 轻度污染 ranges: [ - { categoryIndex: 1, indexes: [1, 50] }, // 优 + { categoryIndex: 1, indexes: [0, 50] }, // 优 { categoryIndex: 2, indexes: [51, 100] }, // 良 { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 @@ -2045,15 +2063,17 @@ export default class AirQuality { { categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] }, ], }, - // β 系数(回归系数),浓度单位 µg/m³ + // β 系数:NO2、O3 使用 ppb,PM2.5 使用 µg/m³。 + // %AR × 10 / 10.4, then round to 1–10 (11 means 10+). + indexScaleFactor: 10 / 10.4, betas: { NO2: 0.000871, O3: 0.000537, PM2_5: 0.000487, }, pollutants: { - NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: -1 }, - OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: -1 }, + NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.880472698306222 }, + OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.96189649169881 }, PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, }, }, @@ -2279,8 +2299,8 @@ export default class AirQuality { Console.info("☑️ ConvertUnit"); const friendlyUnits = AirQuality.Config.Units.Friendly; Console.info(`Convert ${amount} from ${friendlyUnits[from]} with ${fromStpConversionFactor} to ${friendlyUnits[to]} with ${toStpConversionFactor}`); - if (amount < 0) { - Console.error("ConvertUnit", `Amount ${amount} < 0`); + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + Console.error("ConvertUnit", `Amount ${amount} is invalid`); return -1; } @@ -2297,16 +2317,6 @@ export default class AirQuality { const isPpxFrom = ppx.includes(from); const isPpxTo = ppx.includes(to); const isBothPpx = isPpxFrom && isPpxTo; - if (isBothPpx && fromStpConversionFactor !== toStpConversionFactor) { - if (fromStpConversionFactor <= 0 || toStpConversionFactor <= 0) { - Console.error("ConvertUnit", "无效的STP conversion factor", `fromStpConversionFactor: ${fromStpConversionFactor}`, `toStpConversionFactor: ${toStpConversionFactor}`); - return -1; - } - - const intermediate = AirQuality.ConvertUnit(numAmount, from, ugm3, fromStpConversionFactor); - return AirQuality.ConvertUnit(intermediate, ugm3, to, -1, toStpConversionFactor); - } - if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) { Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`); return -1; diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs new file mode 100644 index 00000000..6d94bcab --- /dev/null +++ b/src/platform/wk3-adapter.mjs @@ -0,0 +1,120 @@ +import { localParameters } from "../wk3/parameters.mjs"; + +export async function runPlatform(handler, stage, globals = globalThis, platformOverride) { + const platform = platformOverride ?? (typeof globals.$platform === "string" ? globals.$platform : "generic"); + const request = globals.$request ?? {}; + const sourceResponse = globals.$response ?? {}; + const headers = value => { + const result = {}; + if (value && typeof value.forEach === "function") value.forEach((v, k) => { result[k] = v; }); + else for (const [key, v] of Object.entries(value ?? {})) if (typeof v !== "function") result[key] = v; + return result; + }; + const bytesFromBinaryString = value => Uint8Array.from(value, char => char.charCodeAt(0) & 255); + const binaryStringFromBytes = value => { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let out = ""; + for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.subarray(i, i + 8192)); + return out; + }; + const statusOf = response => { + const raw = response.status ?? response.statusCode ?? 200; + if (typeof raw === "number") return raw; + const match = String(raw).match(/\b(\d{3})\b/); + return match ? Number(match[1]) : 200; + }; + const argument = globals.$argument; + let argumentValues = {}; + const parameterKeys = localParameters.map(parameter => parameter.key); + const assignArgument = value => { + if (Array.isArray(value)) { + const booleanKeys = new Set(localParameters.filter(parameter => parameter.boolean).map(parameter => parameter.key)); + for (let i = 0; i < Math.min(value.length, parameterKeys.length); i++) if (value[i] !== undefined) argumentValues[parameterKeys[i]] = booleanKeys.has(parameterKeys[i]) ? value[i] === true || value[i] === "true" : value[i]; + } else if (value && typeof value === "object") Object.assign(argumentValues, value); + }; + if (argument && typeof argument === "object") assignArgument(argument); + else if (typeof argument === "string") { + try { assignArgument(JSON.parse(argument)); } catch { + const query = argument.match(/(?:^|&)\s*([^=&]+)\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^&]*))/g); + if (query?.length) { + for (const field of query) { + const match = field.replace(/^&/, "").match(/^([^=]+)=(?:\"([^\"]*)\"|'([^']*)'|([^&]*))$/); + if (match) { + const key = decodeURIComponent(match[1]).trim(); + const raw = match[2] ?? match[3] ?? match[4] ?? ""; + argumentValues[key] = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1).split(",").map(v => v.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean) : raw === "true" ? true : raw === "false" ? false : decodeURIComponent(raw); + } + } + } else { + const pairs = []; + let start = 0, depth = 0, quote = ""; + for (let i = 0; i <= argument.length; i++) { + const char = argument[i]; + if (quote) { if (char === quote && argument[i - 1] !== "\\\\") quote = ""; } + else if (char === "\"" || char === "'") quote = char; + else if (char === "[" || char === "{") depth++; + else if (char === "]" || char === "}") depth--; + else if ((char === "," || i === argument.length) && depth === 0) { pairs.push(argument.slice(start, i)); start = i + 1; } + } + for (const pair of pairs) { + const index = pair.indexOf(":"); + if (index <= 0) continue; + const key = pair.slice(0, index).trim(); + const raw = pair.slice(index + 1).trim(); + if (raw.startsWith("[") && raw.endsWith("]")) { + const values = raw.slice(1, -1).split(",").map(value => value.trim()).filter(Boolean); + argumentValues[key] = values.map(value => value.replace(/^['\"]|['\"]$/g, "")); + } + else if (raw === "true" || raw === "false") argumentValues[key] = raw === "true"; + else argumentValues[key] = raw.replace(/^(?:\"([\\s\\S]*)\"|'([\\s\\S]*)')$/, (_, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted); + } + } + } + } + const ctx = { + env: { ...(platform === "Quantumult X" ? Object.fromEntries(localParameters.map(p => [p.key, p.value])) : {}), ...(globals.$environment ?? globals.$env ?? {}), ...argumentValues }, + request: { ...request, headers: headers(request.headers) }, + respond(value) { return value; }, + }; + if (stage === "response") { + const body = sourceResponse.bodyBytes ?? sourceResponse.body ?? ""; + const contentType = headers(sourceResponse.headers)["content-type"] ?? headers(sourceResponse.headers)["Content-Type"] ?? ""; + const isBinary = /flatbuffer|protobuf|octet-stream/i.test(contentType); + const binaryInput = typeof body === "string" ? (isBinary ? bytesFromBinaryString(body) : new TextEncoder().encode(body)) : body instanceof ArrayBuffer ? new Uint8Array(body) : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : body; + const binary = binaryInput instanceof Uint8Array ? binaryInput : new TextEncoder().encode(String(body)); + ctx.response = { + status: statusOf(sourceResponse), headers: headers(sourceResponse.headers), + body, + bodyBytes: isBinary ? binary : undefined, + async text() { return new TextDecoder().decode(binary); }, + async arrayBuffer() { return binary.slice().buffer; }, + }; + } + const done = globals.$done; + if (stage === "response" && platform === "Quantumult X" && /(?:flatbuffer|protobuf|octet-stream)/i.test(headers(sourceResponse.headers)["content-type"] ?? headers(sourceResponse.headers)["Content-Type"] ?? "")) return done?.(); + let result; + try { result = await handler(ctx); } + catch (error) { (globals.console ?? console).error(error); result = undefined; } + if (typeof done !== "function") return result; + if (result === undefined || result === null) return done(); + // Request URL rewrites are not synthetic HTTP responses. + if (stage === "request" && typeof result.url === "string") return done(result); + const bodyToPlatform = body => { + const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) + : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : null; + if (!bytes) return body; + // Surge and Loon binary-body-mode consume Uint8Array; strings are text. + return platform === "surge" || platform === "loon" ? bytes : binaryStringFromBytes(bytes); + }; + const response = { + status: platform === "Quantumult X" ? `HTTP/1.1 ${result.status ?? 200} ${result.statusText ?? "OK"}` : result.status ?? 200, + headers: { ...headers(result.headers) }, body: bodyToPlatform(result.body ?? ""), + }; + for (const key of Object.keys(response.headers)) if (key.toLowerCase() === "content-length" || key.toLowerCase() === "content-encoding" || key.toLowerCase() === "transfer-encoding") delete response.headers[key]; + if (platform === "Quantumult X") { + delete response.statusText; + return done(response); + } + // Request scripts synthesize a response; response scripts return modifications directly. + return done(stage === "request" ? { response } : response); +} diff --git a/src/platform/wk3-alert-compat.mjs b/src/platform/wk3-alert-compat.mjs new file mode 100644 index 00000000..ece4ac38 --- /dev/null +++ b/src/platform/wk3-alert-compat.mjs @@ -0,0 +1,34 @@ +// Imported only by the four-platform builder; shared/Egern alerts stay native. +export function readAlertHeader(headers, name) { + if (typeof headers?.get === "function") return headers.get(name); + const key = Object.keys(headers ?? {}).find(key => key.toLowerCase() === name.toLowerCase()); + return key === undefined ? undefined : headers[key]; +} + +const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +export function encodeAlertBase64(bytes) { + let output = ""; + for (let i = 0; i < bytes.length; i += 3) { + const value = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + output += alphabet[value >>> 18] + alphabet[(value >>> 12) & 63] + + (i + 1 < bytes.length ? alphabet[(value >>> 6) & 63] : "=") + + (i + 2 < bytes.length ? alphabet[value & 63] : "="); + } + return output; +} + +// NativeAlertDetails already validates base64url and adds standard padding. +// Its existing re-encode check still rejects noncanonical unused bits. +export function decodeAlertBase64(value) { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new TypeError("Invalid alert base64"); + let output = "", buffer = 0, bits = 0; + for (const character of value.replace(/=+$/, "")) { + buffer = (buffer << 6) | alphabet.indexOf(character); + bits += 6; + if (bits >= 8) { + bits -= 8; + output += String.fromCharCode((buffer >>> bits) & 255); + } + } + return output; +} diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 2eb95155..b0b6697a 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -423,15 +423,23 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); - // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; - - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Step3. 目标列表决定是否替换指数;污染物补齐不应覆盖有效的 Apple 指数。 + const hasUsableIndex = value => value?.metadata && !value.metadata.temporarilyUnavailable && Number.isFinite(value.index) && value.index >= 0 && Number.isInteger(value.categoryIndex) && value.categoryIndex > 0 && typeof value.scale === "string" && value.scale.length > 0; + const targets = Settings?.AirQuality?.Current?.Index?.Replace ?? []; + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider ?? "Calculate"; + const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; + const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!hasUsableIndex(airQuality) && needPollutants)); + const candidateIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : undefined; + const indexAvailable = needInjectIndex && !!hasUsableIndex(candidateIndex); + const injectedIndex = indexAvailable ? candidateIndex : airQuality; + + // Step4. 仅在有效指数实际变化且允许替换时重算;未知对比可独立补齐。 + const indexChanged = indexAvailable && ["index", "scale", "categoryIndex"].some(key => airQuality?.[key] !== injectedIndex[key]); const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = needInjectIndex && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const previousDayComparison = indexChanged && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + const currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 @@ -442,7 +450,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const providers = [ ...(weatherKitMetadata?.providerName && !weatherKitMetadata.temporarilyUnavailable ? [weatherKitMetadata.providerName] : []), ...(needPollutants && pollutantMetadata?.providerName && !pollutantMetadata.temporarilyUnavailable ? [`污染物:${pollutantMetadata.providerName}`] : []), - ...(needInjectIndex && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), + ...(indexAvailable && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), ...(needInjectComparison && comparisonMetadata?.providerName && !comparisonMetadata.temporarilyUnavailable ? [`对比昨日:\n${comparisonMetadata.providerName}`] : []), ]; @@ -452,13 +460,14 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison airQuality = { ...airQuality, - ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? injectedIndex : {}), + ...(indexAvailable ? injectedIndex : {}), metadata: { ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), + ...(indexAvailable ? { temporarilyUnavailable: false } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings) ?? [], previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); @@ -728,11 +737,13 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Console.info("✅ InjectComparison"); return comparisonAirQuality; } - case "ColorfulCloudsUS": - default: { + case "ColorfulCloudsUS": { const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider) ? airQuality?.categoryIndex : undefined); Console.info("✅ InjectComparison"); return comparisonAirQuality; } + case "WeatherKit": + default: + return { previousDayComparison: UNKNOWN }; } } diff --git a/src/process/Response.mjs b/src/process/Response.mjs index 941e2a17..e5287372 100644 --- a/src/process/Response.mjs +++ b/src/process/Response.mjs @@ -367,15 +367,23 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); - // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; - - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Step3. 目标列表决定是否替换指数;污染物补齐不应覆盖有效的 Apple 指数。 + const hasUsableIndex = value => value?.metadata && !value.metadata.temporarilyUnavailable && Number.isFinite(value.index) && value.index >= 0 && Number.isInteger(value.categoryIndex) && value.categoryIndex > 0 && typeof value.scale === "string" && value.scale.length > 0; + const targets = Settings?.AirQuality?.Current?.Index?.Replace ?? []; + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider ?? "Calculate"; + const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; + const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!hasUsableIndex(airQuality) && needPollutants)); + const candidateIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : undefined; + const indexAvailable = needInjectIndex && !!hasUsableIndex(candidateIndex); + const injectedIndex = indexAvailable ? candidateIndex : airQuality; + + // Step4. 仅在有效指数实际变化且允许替换时重算;未知对比可独立补齐。 + const indexChanged = indexAvailable && ["index", "scale", "categoryIndex"].some(key => airQuality?.[key] !== injectedIndex[key]); const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = needInjectIndex && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const previousDayComparison = indexChanged && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + const currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 @@ -386,7 +394,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const providers = [ ...(weatherKitMetadata?.providerName && !weatherKitMetadata.temporarilyUnavailable ? [weatherKitMetadata.providerName] : []), ...(needPollutants && pollutantMetadata?.providerName && !pollutantMetadata.temporarilyUnavailable ? [`污染物:${pollutantMetadata.providerName}`] : []), - ...(needInjectIndex && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), + ...(indexAvailable && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), ...(needInjectComparison && comparisonMetadata?.providerName && !comparisonMetadata.temporarilyUnavailable ? [`对比昨日:\n${comparisonMetadata.providerName}`] : []), ]; @@ -396,13 +404,14 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison airQuality = { ...airQuality, - ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? injectedIndex : {}), + ...(indexAvailable ? injectedIndex : {}), metadata: { ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), + ...(indexAvailable ? { temporarilyUnavailable: false } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings) ?? [], previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); @@ -672,11 +681,13 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Console.info("✅ InjectComparison"); return comparisonAirQuality; } - case "ColorfulCloudsUS": - default: { + case "ColorfulCloudsUS": { const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider) ? airQuality?.categoryIndex : undefined); Console.info("✅ InjectComparison"); return comparisonAirQuality; } + case "WeatherKit": + default: + return { previousDayComparison: UNKNOWN }; } } diff --git a/src/wk3/class/AirQuality.mjs b/src/wk3/class/AirQuality.mjs new file mode 100644 index 00000000..ddf9a0e1 --- /dev/null +++ b/src/wk3/class/AirQuality.mjs @@ -0,0 +1,2435 @@ +import { Console } from "../utils/index.mjs"; +import SimplePrecisionMath from "./SimplePrecisionMath.mjs"; + +export default class AirQuality { + static Name = "AirQuality"; + static Version = "3.1.0"; + static Author = "Virgil Clyne & Wordless Echo"; + + // Code by Claude + static #CeilByPrecision(a, b) { + // 获取 b 的小数位数 + const decimals = (b.toString().split(".")[1] || "").length; + + // 根据小数位数计算倍数 + const multiplier = 10 ** decimals; + + // 向上取整 + const scaled = a * multiplier; + return Math.ceil(scaled - Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) / multiplier; + } + + /** + * 根据 AQI 数值定位其所属的分类等级(categoryIndex)。 + * + * 作用: + * - 在给定的 categories.ranges 中查找 index 落入的区间; + * - 返回该区间对应的 categoryIndex,供主污染物判断与展示层分级使用。 + * + * @param {number} index + * 待判定的 AQI 指数值。 + * + * @param {{ + * ranges?: Array<{ categoryIndex: number, indexes: [number, number] }> + * }} categories + * 分类配置对象。 + * - ranges[*].indexes 为 [min, max] 区间; + * - 使用 #CeilByPrecision 处理小数精度后再进行区间比较。 + * + * @returns {number} + * 命中的 categoryIndex;若 categories 无效或未命中区间,返回 -1。 + */ + + static CategoryIndex(index, categories) { + Console.debug("☑️ CategoryIndex", `index: ${index}`); + + const failedCategoryIndex = -1; + + if (!categories?.ranges || !Array.isArray(categories.ranges) || categories.ranges.length === 0) { + Console.debug("categories:", categories); + Console.error("GetPrimaryPollutant", "categories无效"); + return failedCategoryIndex; + } + + const range = categories.ranges.find(({ indexes }) => { + const [min, max] = indexes; + return AirQuality.#CeilByPrecision(index, min) >= min && AirQuality.#CeilByPrecision(index, max) <= max; + }); + if (!range) { + Console.error("CategoryIndex", `找不到index: ${index}对应的category`); + return failedCategoryIndex; + } + + Console.debug("✅ CategoryIndex", `categoryIndex: ${range.categoryIndex}`); + return range.categoryIndex; + } + + static CompareCategoryIndexes(todayCategoryIndex, yesterdayCategoryIndex) { + Console.debug("☑️ CompareCategoryIndexes", `todayCategoryIndex: ${todayCategoryIndex}`, `yesterdayCategoryIndex: ${yesterdayCategoryIndex}`); + + const { UNKNOWN, SAME, WORSE, BETTER } = AirQuality.Config.CompareCategoryIndexes; + const today = Number(todayCategoryIndex); + const yesterday = Number(yesterdayCategoryIndex); + if ([todayCategoryIndex, yesterdayCategoryIndex].some(value => value === null || typeof value === "boolean" || String(value).trim() === "") || + ![today, yesterday].every(value => Number.isInteger(value) && value > 0)) { + Console.error("CompareCategoryIndexes", "categoryIndex无效"); + return UNKNOWN; + } else { + const diff = today - yesterday; + if (diff === 0) { + Console.debug("✅ CompareCategoryIndexes"); + return SAME; + } else if (diff > 0) { + Console.debug("✅ CompareCategoryIndexes"); + return WORSE; + } else { + Console.debug("✅ CompareCategoryIndexes"); + return BETTER; + } + } + } + + static ConvertUnits(pollutants, stpConversionFactors, pollutantScales) { + Console.debug("☑️ ConvertUnits"); + + const friendlyUnits = AirQuality.Config.Units.Friendly; + + const convertedPollutants = pollutants.map(pollutant => { + const pollutantScale = pollutantScales[pollutant.pollutantType]; + + if (!pollutantScale) { + Console.debug("ConvertUnits", `No scale for ${pollutant.pollutantType}, skip`); + return pollutant; + } + + Console.debug("ConvertUnits", `Converting ${pollutant.pollutantType}`); + const amount = AirQuality.ConvertUnit(pollutant.amount, pollutant.units, pollutantScale.units, stpConversionFactors?.[pollutant.pollutantType] || -1, pollutantScale?.stpConversionFactor || -1); + if (!Number.isFinite(amount) || amount < 0) { + Console.error( + "ConvertUnits", + `无法转换${pollutant.pollutantType}的单位`, + `${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units} (STP conversion factor: ${stpConversionFactors?.[pollutant.pollutantType] || -1}) -> ${amount} ${friendlyUnits[pollutantScale.units] ?? pollutantScale.units} (STP conversion factor: ${pollutantScale?.stpConversionFactor || -1})`, + ); + return pollutant; + } + + Console.debug("ConvertUnits", `Converted ${pollutant.pollutantType}: ${amount} ${friendlyUnits[pollutantScale.units] ?? pollutantScale.units}`); + return { ...pollutant, amount, units: pollutantScale.units }; + }); + Console.debug("✅ ConvertUnits"); + return convertedPollutants; + } + + /** + * 修复污染物单位。 + * + * 说明: + * - 这是空气质量数据修复的统一入口(当前聚焦污染物单位修复); + * - 当前仅处理和风天气(QWeather)CO 单位场景:其 CO 原始单位为 mg/m3, + * 需转换为 WeatherKit 使用的 µg/m3; + * - 后续同类修复请统一收敛到本方法内,必要时可将本方法升级为 FixAirQuality。 + * + * @param {any} airQuality - 空气质量数据对象 + * @returns {any} 单位修复后的空气质量数据对象 + */ + // CO unit repair adapted from Horatio's Apache-2.0 PR #107 (831852f). + // A value already labeled µg/m³ cannot safely be distinguished from a mislabeled raw mg/m³ value. + static FixPollutantsUnits(airQuality) { + Console.debug("☑️ FixPollutantsUnits"); + if (!["和风天气", "QWeather"].includes(airQuality?.metadata?.providerName) || !Array.isArray(airQuality?.pollutants)) return airQuality; + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = airQuality.pollutants.map(pollutant => { + if (pollutant?.pollutantType !== "CO" || pollutant.units !== mgm3) return pollutant; + const amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3); + return amount < 0 ? pollutant : { ...pollutant, amount, units: ugm3 }; + }); + return { ...airQuality, pollutants }; + } + + static #GetStpConversionFactors(airQuality) { + Console.debug("☑️ GetStpConversionFactors"); + + switch (airQuality?.metadata?.providerName) { + case "和风天气": + case "BreezoMeter": + default: { + Console.debug("✅ GetStpConversionFactors", `STP conversion factors for ${airQuality?.metadata?.providerName}: US`); + return AirQuality.Config.STP_ConversionFactors.US; + } + } + } + + static Pollutants2AQI(airQuality, Settings, options = {}) { + Console.debug("☑️ Pollutants2AQI"); + const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm ?? "UBA"; + const forcePrimaryPollutant = options?.forcePrimaryPollutant ?? Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants; + const allowOverRange = options?.allowOverRange ?? Settings?.AirQuality?.Calculate?.AllowOverRange; + + const stpConversionFactors = AirQuality.#GetStpConversionFactors(airQuality); + const scale = Object.hasOwn(AirQuality.Config.Scales, algorithm ?? "") ? AirQuality.Config.Scales[algorithm] : undefined; + if (algorithm !== "None" && !scale) { + Console.error("Pollutants2AQI", `Unsupported algorithm: ${algorithm}`); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: airQuality?.pollutants }; + } + + switch (algorithm) { + case "None": { + return airQuality; + } + case "EU_EAQI": { + // PollutantsToEAQI + Console.debug("☑️ PollutantsToEAQI"); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange: false }); + Console.debug("✅ PollutantsToEAQI"); + break; + } + case "WAQI_InstantCast_US": { + // PollutantsToInstantCastUS + Console.debug("☑️ PollutantsToInstantCastUS", `allowOverRange: ${allowOverRange}`); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange, concentrationSteps: { PM2_5: 0.1, PM10: 1, OZONE: 0.1, NO2: 1, SO2: 1, CO: 100 } }); + Console.debug("✅ PollutantsToInstantCastUS"); + break; + } + case "WAQI_InstantCast_CN": + case "WAQI_InstantCast_CN_25_DRAFT": { + // PollutantsToInstantCastCN12 / PollutantsToInstantCastCN25 / #PollutantsToInstantCastCN + Console.debug("☑️ PollutantsToInstantCastCN", `allowOverRange: ${allowOverRange}`, `forcePrimaryPollutant: ${forcePrimaryPollutant}`); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange }); + + const isNotAvailable = !forcePrimaryPollutant && airQuality.index <= 50; + if (isNotAvailable) { + Console.info("PollutantsToInstantCastCN", `Max index of pollutants ${airQuality.primaryPollutant} = ${airQuality.index} is <= 50, primaryPollutant will be set to NOT_AVAILABLE.`); + } + + airQuality = { + ...airQuality, + primaryPollutant: isNotAvailable ? "NOT_AVAILABLE" : airQuality.primaryPollutant, + }; + Console.debug("✅ PollutantsToInstantCastCN"); + break; + } + case "CN_DEATH_AQHI": + case "CA_AQHI": + case "HK_AQHI": + case "CN_DEATH_HK_AQHI": { + airQuality = AirQuality.#PollutantsToAQHI(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; + airQuality = { ...airQuality, index: airQuality.categoryIndex }; + break; + } + case "AQHI_Multi_CN": + case "AQHI_Multi_CN_HK": { + airQuality = AirQuality.#PollutantsToAQHIMulti(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; + airQuality = { ...airQuality, index: airQuality.categoryIndex }; + break; + } + case "UBA": + default: { + // PollutantsToUBA + Console.debug("☑️ PollutantsToUBA"); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange: true }); + airQuality = { + ...airQuality, + index: airQuality.categoryIndex, + }; + Console.debug("✅ PollutantsToUBA"); + break; + } + } + + Console.debug("✅ Pollutants2AQI"); + return airQuality; + } + + static ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) { + const unitsMode = Settings?.AirQuality?.Current?.Pollutants?.Units?.Mode || "Scale"; + Console.debug("☑️ ConvertPollutants", `mode: ${unitsMode}`); + + const getScale = scaleName => { + const scales = AirQuality.Config.Scales; + switch (scaleName) { + case scales.HJ6332012.weatherKitScale.name: + return scales.WAQI_InstantCast_CN; + case scales.EPA_NowCast.weatherKitScale.name: + return scales.WAQI_InstantCast_US; + case scales.EU_EAQI.weatherKitScale.name: + return scales.EU_EAQI; + case scales.CA_AQHI.weatherKitScale.name: + return scales.CA_AQHI; + case scales.HK_AQHI.weatherKitScale.name: + return scales.HK_AQHI; + case scales.CN_DEATH_AQHI.weatherKitScale.name: + return scales.CN_DEATH_AQHI; + default: + return scales[scaleName]; + } + }; + + const getPollutantScales = pollutantScales => { + const { ugm3, ppb } = AirQuality.Config.Units.WeatherKit; + const { US, EU } = AirQuality.Config.STP_ConversionFactors; + const ugm3Scale = { + OZONE: { units: ugm3, stpConversionFactor: -1 }, + NO2: { units: ugm3, stpConversionFactor: -1 }, + SO2: { units: ugm3, stpConversionFactor: -1 }, + CO: { units: ugm3, stpConversionFactor: -1 }, + C6H6: { units: ugm3, stpConversionFactor: -1 }, + NH3: { units: ugm3, stpConversionFactor: -1 }, + NO: { units: ugm3, stpConversionFactor: -1 }, + }; + const usPpbScale = { + OZONE: { units: ppb, stpConversionFactor: US.OZONE }, + NO2: { units: ppb, stpConversionFactor: US.NO2 }, + SO2: { units: ppb, stpConversionFactor: US.SO2 }, + CO: { units: ppb, stpConversionFactor: US.CO }, + C6H6: { units: ppb, stpConversionFactor: US.C6H6 }, + NH3: { units: ppb, stpConversionFactor: US.NH3 }, + NO: { units: ppb, stpConversionFactor: US.NO }, + }; + const euPpbScale = { + OZONE: { units: ppb, stpConversionFactor: EU.OZONE }, + NO2: { units: ppb, stpConversionFactor: EU.NO2 }, + SO2: { units: ppb, stpConversionFactor: EU.SO2 }, + CO: { units: ppb, stpConversionFactor: EU.CO }, + C6H6: { units: ppb, stpConversionFactor: EU.C6H6 }, + NH3: { units: ppb, stpConversionFactor: EU.NH3 }, + NO: { units: ppb, stpConversionFactor: EU.NO }, + }; + + switch (unitsMode) { + case "ugm3": + return { ...ugm3Scale, ...pollutantScales }; + case "EU_ppb": + return { ...euPpbScale, ...pollutantScales }; + case "US_ppb": + return { ...usPpbScale, ...pollutantScales }; + case "Force_ugm3": + return ugm3Scale; + case "Force_EU_ppb": + return euPpbScale; + case "Force_US_ppb": + return usPpbScale; + case "Scale": + default: + return pollutantScales; + } + }; + + const replaceUnits = Settings?.AirQuality?.Current?.Pollutants?.Units?.Replace ?? []; + const isIndexInjected = needInjectIndex && injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable; + const scaleName = AirQuality.GetNameFromScale(isIndexInjected ? injectedIndex?.scale : airQuality?.scale); + + const pollutants = injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable ? injectedPollutants.pollutants : airQuality?.pollutants; + Console.debug("✅ ConvertPollutants"); + + if (unitsMode === "None") return pollutants; + + if (replaceUnits.includes(scaleName)) { + if (isIndexInjected && Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && unitsMode === "Scale") { + Console.info("ConvertPollutants", `Use pollutants from iRingo`); + return injectedIndex.pollutants; + } else { + const scale = getScale(scaleName); + if (!scale) { + Console.error("ConvertPollutants", `Unsupported scale name: ${scaleName}`); + return pollutants; + } + + return AirQuality.ConvertUnits(pollutants, AirQuality.#GetStpConversionFactors(airQuality), getPollutantScales(scale.pollutants)); + } + } else { + return pollutants; + } + } + + static ToWeatherKitScale = ({ name, version }) => (version ? `${name}.${version}` : name); + static GetNameFromScale(scale) { + if (!scale) return ""; + Console.debug("☑️ GetNameFromScale", `scale: ${scale}`); + + if (Object.values(AirQuality.Config.Scales).some(entry => entry.weatherKitScale?.name === scale)) return scale; + const lastDotIndex = scale.lastIndexOf("."); + if (lastDotIndex === -1) { + Console.warn("GetNameFromScale", `无法找到${scale}的版本号`); + return scale; + } + + const scaleName = scale.substring(0, lastDotIndex); + Console.debug("✅ GetNameFromScale", `scaleName: ${scaleName}`); + return scaleName; + } + + /** + * 根据“昨日对比指数来源”和当前空气质量 scale,选择用于昨日对比计算的算法。 + * - Calculate: 直接采用用户配置算法 + * - QWeather / ColorfulCloudsCN: 固定使用国标 InstantCast + * - WeatherKit: 根据 scale 自动映射到内置算法 + * @param {any} airQuality - 当前空气质量对象(用于读取 scale) + * @param {any} Settings - 全局设置对象 + * @returns {string} 选中的算法名;无法匹配时返回空字符串 + */ + static chooseAlogrithm(airQuality, Settings) { + Console.debug("☑️ chooseAlogrithm", `currentIndexProvider: ${Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider}`); + + switch (Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider) { + case "Calculate": { + Console.debug("✅ chooseAlogrithm", `algorithm: ${Settings?.AirQuality?.Calculate?.Algorithm}`); + return Settings?.AirQuality?.Calculate?.Algorithm; + } + case "QWeather": + case "ColorfulCloudsCN": { + Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_CN`); + return "WAQI_InstantCast_CN"; + } + case "WeatherKit": { + const currentScale = AirQuality.GetNameFromScale(airQuality?.scale); + const scales = AirQuality.Config.Scales; + + if (currentScale === scales.HJ6332012.weatherKitScale.name) { + Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_CN`); + return "WAQI_InstantCast_CN"; + } else if (currentScale === scales.EPA_NowCast.weatherKitScale.name) { + Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_US`); + return "WAQI_InstantCast_US"; + } else { + const supportedScales = { [scales.EU_EAQI.weatherKitScale.name]: "EU_EAQI", [scales.UBA.weatherKitScale.name]: "UBA" }; + if (Object.hasOwn(supportedScales, currentScale)) { + Console.debug("✅ chooseAlogrithm", `algorithm: ${supportedScales[currentScale]}`); + return supportedScales[currentScale]; + } + + Console.error("chooseAlogrithm", "没有找到合适的内置算法"); + return ""; + } + } + default: { + Console.error("chooseAlogrithm", "未知的currentIndexProvider"); + return ""; + } + } + } + + /** + * 为 providerName 附加空气质量标准描述。 + * @param {any} airQuality - 空气质量对象 + * @param {any} Settings - 全局设置对象 + * @returns {string} 带标准描述的 providerName + */ + static appendScaleToProviderName(airQuality, Settings) { + const fullProviderName = airQuality?.metadata?.providerName; + if (!fullProviderName) return ""; + + const parts = fullProviderName.split("\n"); + const providerName = parts[0].trim(); + const suffix = parts.length > 1 ? `\n${parts.slice(1).join("\n")}` : ""; + + let scaleDesc = ""; + switch (providerName) { + case "和风天气": + scaleDesc = "(国标,12年2月版)"; + break; + case "彩云天气": { + const useUsa = airQuality?.scale === AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.EPA_NowCast.weatherKitScale); + scaleDesc = `(${useUsa ? "美标,18年9月版" : "国标,12年2月版"})`; + break; + } + case "iRingo": + switch (Settings?.AirQuality?.Calculate?.Algorithm) { + case "EU_EAQI": + scaleDesc = " (ETC HE Report 2024/17)"; + break; + case "WAQI_InstantCast_US": + scaleDesc = " (WAQI InstantCast with EPA-454/B-24-002)"; + break; + case "WAQI_InstantCast_CN": + scaleDesc = " (InstantCast with HJ 633—2012)"; + break; + case "WAQI_InstantCast_CN_25_DRAFT": + scaleDesc = " (InstantCast with HJ 633 2025 DRAFT)"; + break; + case "CN_DEATH_AQHI": + scaleDesc = " (China CDC Death Risk AQHI)"; + break; + case "CA_AQHI": + scaleDesc = " (InstantCast with CA AQHI)"; + break; + case "HK_AQHI": + scaleDesc = " (InstantCast with HK AQHI)"; + break; + case "CN_DEATH_HK_AQHI": + scaleDesc = " (China CDC Death Risk + HK AQHI)"; + break; + case "AQHI_Multi_CN": + scaleDesc = " (AQHI-Multi for CN)"; + break; + case "AQHI_Multi_CN_HK": + scaleDesc = " (AQHI-Multi for CN+HK)"; + break; + case "UBA": + default: + scaleDesc = " (FB001846)"; + break; + } + break; + } + return `${providerName}${scaleDesc}${suffix}`; + } + + /** + * 计算单个污染物在目标标准下的 index。 + * + * @param {{pollutantType: string, amount: number, units: string}} pollutant + * 单个污染物数据。 + * + * @param {string} pollutantType + * 污染物类型(如 OZONE/PM2_5/NO2)。 + * + * @param {{ + * units: string, + * ranges: { + * min: { indexes: [number, number], amounts: [number, number] }, + * max: { indexes: [number, number], amounts: [number, number] }, + * value: Array<{ indexes: [number, number], amounts: [number, number] }> + * } + * }} pollutantScale + * 该污染物在目标标准下的分段阈值与单位。 + * + * @param {Record} friendlyUnits + * 友好单位映射表(用于日志展示)。 + * + * @returns {number} + * 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。 + */ + static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits, step) { + if (!pollutantScale) { + Console.info("PollutantsToIndexes", `当前标准不使用污染物${pollutantType}计算指数,保留浓度并将index标记为-1`); + return -1; + } + + if (!Number.isFinite(pollutant.amount) || pollutant.amount < 0 || pollutant.units !== pollutantScale.units) return -1; + const scaled = step ? pollutant.amount / step : pollutant.amount; + const amount = step ? Math.floor(scaled + Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) * step : pollutant.amount; + const { add, subtract, multiply, divide } = SimplePrecisionMath; + + Console.debug(`${pollutantType}: ${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units}`); + + const minValidAmount = pollutantScale.ranges.min.amounts[0]; + if (amount < minValidAmount) { + Console.error("PollutantsToIndexes", `${pollutantType}的含量无效:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}需要 >= ${minValidAmount}`); + return pollutantScale.ranges.min.indexes[0]; + } + + const isOverRange = amount > pollutantScale.ranges.max.amounts[1]; + if (isOverRange) { + Console.warn("PollutantsToIndexes", `检测到爆表指数!${pollutantType}:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}`); + Console.warn("PollutantsToIndexes", "注意身体!"); + } + + const range = isOverRange + ? pollutantScale.ranges.max + : pollutantScale.ranges.value.find(({ amounts }) => { + const [minAmount, maxAmount] = amounts; + return AirQuality.#CeilByPrecision(amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(amount, maxAmount) <= maxAmount; + }); + if (!range) return -1; + const { indexes, amounts } = range; + Console.debug("indexes:", indexes, "amounts:", amounts); + + const [minIndex, maxIndex] = indexes; + const [minAmount, maxAmount] = amounts; + if (minIndex === maxIndex) return minIndex; + const concentration = isOverRange ? amount : Math.max(minAmount, Math.min(maxAmount, amount)); + const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(concentration, minAmount)), minIndex); + return Number.isFinite(index) && index >= 0 ? index : -1; + } + + /** + * 将污染物浓度映射为对应标准下的污染物指数(index)。 + * + * 作用: + * 1) 按 pollutantScales 定义,逐个污染物查找浓度区间; + * 2) 使用 #ComputePollutantIndex 计算单污染物 index; + * 3) 返回用于后续主污染物判定的一组 { pollutantType, index }。 + * + * @param {Array<{pollutantType: string, amount: number, units: string}>} pollutants + * 污染物列表。 + * - amount 的单位由 units 字段表示(如 µg/m³、mg/m³、ppb、ppm)。 + * - 进入该方法前应已与 pollutantScales 的目标单位对齐。 + * + * @param {Record + * } + * }>} pollutantScales + * 指标标准定义,包含每种污染物的单位及分段阈值。 + * + * @returns {Array<{pollutantType: string, index: number}>} + * 每种标准污染物对应的 index 列表。 + * - 若标准所需污染物缺失,返回该污染物 index=-1。 + */ + static #PollutantsToIndexes(pollutants, pollutantScales, concentrationSteps) { + return pollutants.map(pollutant => { + const converted = { ...pollutant }; + converted.index = AirQuality.#ComputePollutantIndex(converted, converted.pollutantType, pollutantScales?.[converted.pollutantType], AirQuality.Config.Units.Friendly, concentrationSteps?.[converted.pollutantType]); + return converted; + }); + } + + static PollutantsToIndexes(pollutants, scaleName, options = {}) { + const scale = AirQuality.Config.Scales[scaleName]; + if (!scale) { + Console.error("PollutantsToIndexes", `Unsupported scale: ${scaleName}`); + return []; + } + const stpConversionFactors = options?.stpConversionFactors; + const sourcePollutants = Array.isArray(pollutants) ? pollutants.map(pollutant => ({ ...pollutant })) : []; + const convertedPollutants = stpConversionFactors + ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) + : sourcePollutants; + return AirQuality.#PollutantsToIndexes(convertedPollutants, scale.pollutants); + } + + static PrimaryPollutant(pollutants, categories) { + Console.debug("☑️ PrimaryPollutant"); + const failedPollutant = { pollutantType: "NOT_AVAILABLE", index: -1, categoryIndex: -1 }; + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + Console.debug("pollutants:", pollutants); + Console.error("PrimaryPollutant", "pollutants无效"); + return failedPollutant; + } + + const indexesWithCategory = pollutants + .filter(({ index }) => Number.isFinite(index) && index >= 0) + .map(pollutant => ({ + ...pollutant, + categoryIndex: AirQuality.CategoryIndex(pollutant.index, categories), + })) + .filter(({ categoryIndex }) => Number.isFinite(categoryIndex) && categoryIndex >= 0) + .sort((a, b) => b.index - a.index); + + if (indexesWithCategory.length === 0) { + Console.debug("pollutants:", pollutants, "indexesWithCategory:", indexesWithCategory); + Console.error("PrimaryPollutant", "找不到有效的index"); + return failedPollutant; + } + + const maxCategoryIndex = indexesWithCategory.reduce((max, { categoryIndex }) => (categoryIndex > max ? categoryIndex : max), Number.NEGATIVE_INFINITY); + const primaryPollutants = indexesWithCategory.filter(({ categoryIndex }) => categoryIndex === maxCategoryIndex); + + if (primaryPollutants.length > 1) { + Console.info("PrimaryPollutant", `检测到多个同级别的污染物,按最高指数选择主污染物,maxCategoryIndex:${maxCategoryIndex}`); + primaryPollutants.map(({ pollutantType, index }) => Console.info("GetPrimaryPollutants", `${pollutantType}:${index}`)); + } + + const primaryPollutant = primaryPollutants[0]; + Console.debug("✅ PrimaryPollutant", "primaryPollutant:", primaryPollutant); + return primaryPollutant; + } + + /** + * 将污染物浓度转换为统一 airQuality 结构。 + * + * 作用: + * 1) 按 scale 定义将污染物转换到目标单位(可选); + * 2) 计算各污染物指数并确定主污染物; + * 3) 产出 WeatherKit 风格 airQuality,并按需执行 index 上限裁剪。 + * + * @param {Array<{pollutantType: string, amount: number, units: string}>} pollutants + * 原始污染物数组。 + * - amount 的物理单位由每个元素的 units 字段决定(如 µg/m³、mg/m³、ppb、ppm)。 + * + * @param {{ + * weatherKitScale: {name: string, version: string, maxIndex?: number}, + * pollutants: Record, + * categories: {significantIndex: number, ranges: Array<{categoryIndex: number, indexes: number[]}>} + * }} scale + * AQI 标准配置。 + * - scale.pollutants[*].units 为目标单位。 + * - scale.weatherKitScale.maxIndex 为可选 index 上限。 + * + * @param {{ + * stpConversionFactors?: Record, + * allowOverRange?: boolean + * }} [options] + * 计算选项。 + * - stpConversionFactors:单位换算因子表(无量纲)。提供时会先执行单位转换。 + * - allowOverRange:是否允许 index 超出 maxIndex;默认 true。 + * + * @returns {{ + * index: number, + * isSignificant: boolean, + * categoryIndex: number, + * pollutants: Array<{pollutantType: string, amount: number, units: string}>, + * metadata: {providerName: string, temporarilyUnavailable: boolean}, + * primaryPollutant: string, + * scale: string + * } | {metadata: {providerName: string, temporarilyUnavailable: boolean}}} + * 返回标准化 airQuality;当输入无效时返回 temporarilyUnavailable 结果。 + */ + static #PollutantsToAirQuality(pollutants, scale, options = {}) { + Console.debug("☑️ PollutantsToAirQuality"); + const stpConversionFactors = options?.stpConversionFactors; + const allowOverRange = options?.allowOverRange ?? true; + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + Console.debug("pollutants:", pollutants); + Console.error("PollutantsToAirQuality", "pollutants无效"); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; + } + + let newPollutants = []; + if (stpConversionFactors) { + newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants); + } + + newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants, options.concentrationSteps); + Console.debug("PollutantsToAirQuality", "convertedPollutants:", newPollutants); + + const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories); + if (!Number.isFinite(primaryPollutant.index) || primaryPollutant.index < 0 || primaryPollutant.categoryIndex < 1) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: newPollutants }; + } + const maxIndex = scale?.weatherKitScale?.maxIndex; + const index = allowOverRange || !Number.isFinite(maxIndex) ? Math.round(primaryPollutant.index) : Math.min(Math.round(primaryPollutant.index), maxIndex); + Console.debug("✅ PollutantsToAirQuality"); + return { + index, + isSignificant: primaryPollutant.categoryIndex >= scale.categories.significantIndex, + categoryIndex: primaryPollutant.categoryIndex, + pollutants: newPollutants, + metadata: { providerName: "iRingo", temporarilyUnavailable: false }, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + } + + // Required-input rule adapted from Horatio's Apache-2.0 PR #107 (831852f). + static #HasRiskInputs(pollutants, scale) { + const present = new Set(pollutants.filter(p => p && Number.isFinite(p.amount) && p.amount >= 0 && p.units === scale.pollutants[p.pollutantType]?.units).map(p => p.pollutantType)); + const pmTypes = ["PM10", "PM2_5"].filter(type => scale.pollutants[type]); + return pmTypes.some(type => present.has(type)) && Object.keys(scale.pollutants).every(type => pmTypes.includes(type) || present.has(type)); + } + + static #PollutantsToAQHI(pollutants, scale, options = {}) { + Console.info("☑️ PollutantsToAQHI"); + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); + Console.error("PollutantsToAQHI", "pollutants无效"); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; + } + + const stpConversionFactors = options?.stpConversionFactors; + + // 先做单位转换(目标单位均为 µg/m³) + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } + + // 按 pollutantType 索引浓度 + const getAmount = type => { + const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); + if (!p) return null; + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { + Console.warn("PollutantsToAQHI", `${type} 单位不是 µg/m³(${p.units}),跳过`); + return null; + } + return p.amount; + }; + + const { betas } = scale; + const ar = (beta, concentration) => { + if (concentration === null || concentration < 0) return 0; + return (Math.exp(beta * concentration) - 1) * 100; + }; + + const arNO2 = ar(betas.NO2 || 0, getAmount("NO2")); + const arSO2 = ar(betas.SO2 || 0, getAmount("SO2")); + const arO3 = ar(betas.O3 || 0, getAmount("OZONE")); + const arPM10 = ar(betas.PM10 || 0, getAmount("PM10")); + const arPM2_5 = ar(betas.PM2_5 || 0, getAmount("PM2_5")); + const arPM = Math.max(arPM10, arPM2_5); + + const totalAR = arNO2 + arSO2 + arO3 + arPM; + Console.info("PollutantsToAQHI", `%AR: NO2=${arNO2.toFixed(4)}, SO2=${arSO2.toFixed(4)}, O3=${arO3.toFixed(4)}, PM=${arPM.toFixed(4)} (PM10=${arPM10.toFixed(4)}, PM2.5=${arPM2_5.toFixed(4)}), total=${totalAR.toFixed(4)}`); + + // 设置每个污染物的内部 index 为它的 %AR 值 + convertedPollutants.forEach(p => { + switch (p.pollutantType) { + case "NO2": + p.index = arNO2; + break; + case "SO2": + p.index = arSO2; + break; + case "OZONE": + p.index = arO3; + break; + case "PM10": + p.index = arPM10; + break; + case "PM2_5": + p.index = arPM2_5; + break; + default: + p.index = -1; + } + }); + + // 将 %AR 映射到 1–11 的 categoryIndex (即 AQHI 指数) + const categoryIndex = scale.indexScaleFactor + ? Math.max(1, Math.min(11, Math.round(totalAR * scale.indexScaleFactor))) + : AirQuality.CategoryIndex(totalAR, scale.categories); + + // 以最大 %AR 对应的污染物作为主污染物 + const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 }); + + Console.info("✅ PollutantsToAQHI", `AQHI: ${categoryIndex}, primaryPollutant: ${primaryPollutant.pollutantType}`); + return { + index: totalAR, + isSignificant: categoryIndex >= scale.categories.significantIndex, + categoryIndex, + pollutants: convertedPollutants, + metadata: { providerName: "iRingo", temporarilyUnavailable: false }, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + } + + static #PollutantsToAQHIMulti(pollutants, scale, options = {}) { + Console.info("☑️ PollutantsToAQHIMulti"); + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); + Console.error("PollutantsToAQHIMulti", "pollutants无效"); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; + } + + const stpConversionFactors = options?.stpConversionFactors; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } + + const getAmount = type => { + const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); + if (!p) return null; + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { + Console.warn("PollutantsToAQHIMulti", `${type} 单位不是 µg/m³(${p.units}),跳过`); + return null; + } + return p.amount; + }; + + const { beta, weights } = scale; + const contributions = { + OZONE: (weights.O3 || 0) * Math.max(getAmount("OZONE") || 0, 0), + NO2: (weights.NO2 || 0) * Math.max(getAmount("NO2") || 0, 0), + PM10: (weights.PM10 || 0) * Math.max(getAmount("PM10") || 0, 0), + PM2_5: (weights.PM2_5 || 0) * Math.max(getAmount("PM2_5") || 0, 0), + SO2: (weights.SO2 || 0) * Math.max(getAmount("SO2") || 0, 0), + }; + // When risk(PM 10) > 1.2 * risk(PM 2.5) + const usePM10 = contributions.PM10 > 0 && (contributions.PM2_5 <= 0 || contributions.PM10 > contributions.PM2_5 * 1.2); + const pmContribution = usePM10 ? contributions.PM10 : contributions.PM2_5; + const totalWeighted = contributions.OZONE + contributions.NO2 + contributions.SO2 + pmContribution; + const totalRisk = (beta || 0) * totalWeighted; + + Console.info( + "PollutantsToAQHIMulti", + `risk: O3=${contributions.OZONE.toFixed(4)}, NO2=${contributions.NO2.toFixed(4)}, PM10=${contributions.PM10.toFixed(4)}, PM2.5=${contributions.PM2_5.toFixed(4)}, usePM10=${usePM10}, SO2=${contributions.SO2.toFixed(4)}, totalWeighted=${totalWeighted.toFixed(4)}, totalRisk=${totalRisk.toFixed(4)}`, + ); + + convertedPollutants.forEach(p => { + switch (p.pollutantType) { + case "OZONE": + p.index = contributions.OZONE; + break; + case "NO2": + p.index = contributions.NO2; + break; + case "PM10": + p.index = contributions.PM10; + break; + case "PM2_5": + p.index = contributions.PM2_5; + break; + case "SO2": + p.index = contributions.SO2; + break; + default: + p.index = -1; + } + }); + + const categoryIndex = AirQuality.CategoryIndex(totalRisk, scale.categories); + const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 }); + + Console.info("✅ PollutantsToAQHIMulti", `AQHI: ${categoryIndex}, primaryPollutant: ${primaryPollutant.pollutantType}`); + return { + index: totalRisk, + isSignificant: categoryIndex >= scale.categories.significantIndex, + categoryIndex, + pollutants: convertedPollutants, + metadata: { providerName: "iRingo", temporarilyUnavailable: false }, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + } + + static Config = { + Scales: { + /** + * LQI (DE) - Der Luftqualitätsindex (LQI) des Umweltbundesamtes + * Germany Air Quality Index + * [Der Luftqualitätsindex - LQI | Umweltbundesamt]{@link https://www.umweltbundesamt.de/themen/luft/luftqualitaet/der-luftqualitaetsindex-lqi} + * [Überarbeitung des UBA LQI nach der Herausgabe der WHO AQG 2021]{@link https://www.umweltbundesamt.de/system/files/medien/11850/publikationen/08_2025_uug.pdf} + */ + UBA: { + weatherKitScale: { + name: "UBA", + version: "2414", + }, + // Indexes below for calculation only, not for display + categories: { + significantIndex: 4, // schlecht + ranges: [ + { categoryIndex: 1, indexes: [0, 0.99] }, // sehr gut + { categoryIndex: 2, indexes: [1, 1.99] }, // gut + { categoryIndex: 3, indexes: [2, 2.99] }, // mäßig + { categoryIndex: 4, indexes: [3, 3.99] }, // schlecht + { categoryIndex: 5, indexes: [4, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + pollutants: { + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 10] }, + max: { indexes: [3, 3.99], amounts: [61, 100] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 10] }, // sehr gut + { indexes: [1, 1.99], amounts: [11, 30] }, // gut + { indexes: [2, 2.99], amounts: [31, 60] }, // mäßig + { indexes: [3, 3.99], amounts: [61, 100] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [101, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 9] }, + max: { indexes: [3, 3.99], amounts: [55, 90] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 9] }, // sehr gut + { indexes: [1, 1.99], amounts: [10, 27] }, // gut + { indexes: [2, 2.99], amounts: [28, 54] }, // mäßig + { indexes: [3, 3.99], amounts: [55, 90] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [91, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 5] }, + max: { indexes: [3, 3.99], amounts: [31, 50] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 5] }, // sehr gut + { indexes: [1, 1.99], amounts: [6, 15] }, // gut + { indexes: [2, 2.99], amounts: [16, 30] }, // mäßig + { indexes: [3, 3.99], amounts: [31, 50] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [51, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 24] }, + max: { indexes: [3, 3.99], amounts: [145, 240] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 24] }, // sehr gut + { indexes: [1, 1.99], amounts: [25, 72] }, // gut + { indexes: [2, 2.99], amounts: [73, 144] }, // mäßig + { indexes: [3, 3.99], amounts: [145, 240] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [241, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 10] }, + max: { indexes: [3, 3.99], amounts: [61, 100] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 10] }, // sehr gut + { indexes: [1, 1.99], amounts: [11, 30] }, // gut + { indexes: [2, 2.99], amounts: [31, 60] }, // mäßig + { indexes: [3, 3.99], amounts: [61, 100] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [101, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + }, + }, + /** + * EAQI (EU) - European Air Quality Index + * [ETC HE Report 2024/17: EEA´s revision of the European air quality index bands]{@link https://www.eionet.europa.eu/etcs/etc-he/products/etc-he-products/etc-he-reports/etc-he-report-2024-17-eeas-revision-of-the-european-air-quality-index-bands} + */ + EU_EAQI: { + weatherKitScale: { + name: "EU.EAQI", + maxIndex: 60, + }, + // Indexes below for calculation only, not for display + categories: { + significantIndex: 4, // Poor + ranges: [ + { categoryIndex: 1, indexes: [0, 9] }, // Good + { categoryIndex: 2, indexes: [10, 19] }, // Fair + { categoryIndex: 3, indexes: [20, 29] }, // Moderate + { categoryIndex: 4, indexes: [30, 39] }, // Poor + { categoryIndex: 5, indexes: [40, 49] }, // Very Poor + { categoryIndex: 6, indexes: [50, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + pollutants: { + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 5] }, + max: { indexes: [40, 49], amounts: [91, 140] }, + value: [ + { indexes: [0, 9], amounts: [0, 5] }, // Good + { indexes: [10, 19], amounts: [6, 15] }, // Fair + { indexes: [20, 29], amounts: [16, 50] }, // Moderate + { indexes: [30, 39], amounts: [51, 90] }, // Poor + { indexes: [40, 49], amounts: [91, 140] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [141, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 15] }, + max: { indexes: [40, 49], amounts: [196, 270] }, + value: [ + { indexes: [0, 9], amounts: [0, 15] }, // Good + { indexes: [10, 19], amounts: [16, 45] }, // Fair + { indexes: [20, 29], amounts: [46, 120] }, // Moderate + { indexes: [30, 39], amounts: [121, 195] }, // Poor + { indexes: [40, 49], amounts: [196, 270] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [271, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 60] }, + max: { indexes: [40, 49], amounts: [161, 180] }, + value: [ + { indexes: [0, 9], amounts: [0, 60] }, // Good + { indexes: [10, 19], amounts: [61, 100] }, // Fair + { indexes: [20, 29], amounts: [101, 120] }, // Moderate + { indexes: [30, 39], amounts: [121, 160] }, // Poor + { indexes: [40, 49], amounts: [161, 180] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [181, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 10] }, + max: { indexes: [40, 49], amounts: [101, 150] }, + value: [ + { indexes: [0, 9], amounts: [0, 10] }, // Good + { indexes: [10, 19], amounts: [11, 25] }, // Fair + { indexes: [20, 29], amounts: [26, 60] }, // Moderate + { indexes: [30, 39], amounts: [61, 100] }, // Poor + { indexes: [40, 49], amounts: [101, 150] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [151, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 20] }, + max: { indexes: [40, 49], amounts: [191, 275] }, + value: [ + { indexes: [0, 9], amounts: [0, 20] }, // Good + { indexes: [10, 19], amounts: [21, 40] }, // Fair + { indexes: [20, 29], amounts: [41, 125] }, // Moderate + { indexes: [30, 39], amounts: [126, 190] }, // Poor + { indexes: [40, 49], amounts: [191, 275] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [276, Number.MAX_VALUE] }, // Extremely Poor + ], + }, + }, + }, + }, + /** + * China AQI standard. + * [环境空气质量指数(AQI)技术规定(试行)]{@link https://www.mee.gov.cn/ywgz/fgbz/bz/bzwb/jcffbz/201203/t20120302_224166.shtml} + */ + HJ6332012: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, 500] }, // 严重污染 + ], + }, + pollutants: { + SO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [2101, 2620] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 475] }, + { indexes: [151, 200], amounts: [476, 800] }, + { indexes: [201, 300], amounts: [801, 1600] }, + { indexes: [301, 400], amounts: [1601, 2100] }, + { indexes: [401, 500], amounts: [2101, 2620] }, + ], + }, + }, + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [151, 200], amounts: [651, 800] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + // 二氧化硫(SO2)1小时平均浓度高于800 ug/m3的,不再进行其空气质量分指数计算,二氧化硫(SO2)空气质量分指数按24小时平均浓度计算的分指数报告。 + ], + }, + }, + NO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 40] }, + max: { indexes: [401, 500], amounts: [751, 940] }, + value: [ + { indexes: [0, 50], amounts: [0, 40] }, + { indexes: [51, 100], amounts: [41, 80] }, + { indexes: [101, 150], amounts: [81, 180] }, + { indexes: [151, 200], amounts: [181, 280] }, + { indexes: [201, 300], amounts: [281, 565] }, + { indexes: [301, 400], amounts: [566, 750] }, + { indexes: [401, 500], amounts: [751, 940] }, + ], + }, + }, + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + ], + }, + }, + PM10_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + ], + }, + }, + CO_24H: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 2] }, + max: { indexes: [401, 500], amounts: [49, 60] }, + value: [ + { indexes: [0, 50], amounts: [0, 2] }, + { indexes: [51, 100], amounts: [3, 4] }, + { indexes: [101, 150], amounts: [5, 14] }, + { indexes: [151, 200], amounts: [15, 24] }, + { indexes: [201, 300], amounts: [25, 36] }, + { indexes: [301, 400], amounts: [37, 48] }, + { indexes: [401, 500], amounts: [49, 60] }, + ], + }, + }, + CO: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5] }, + max: { indexes: [401, 500], amounts: [121, 150] }, + value: [ + { indexes: [0, 50], amounts: [0, 5] }, + { indexes: [51, 100], amounts: [6, 10] }, + { indexes: [101, 150], amounts: [11, 35] }, + { indexes: [151, 200], amounts: [36, 60] }, + { indexes: [201, 300], amounts: [61, 90] }, + { indexes: [301, 400], amounts: [91, 120] }, + { indexes: [401, 500], amounts: [121, 150] }, + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + ], + }, + }, + OZONE_8H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [201, 300], amounts: [266, 800] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 160] }, + { indexes: [101, 150], amounts: [161, 215] }, + { indexes: [151, 200], amounts: [216, 265] }, + { indexes: [201, 300], amounts: [266, 800] }, + // 臭氧(O3)8小时平均浓度值高于800 ug/m3的,不再进行其空气质量分指数计算,臭氧(O3)空气质量分指数按1小时平均浓度计算的分指数报告。 + ], + }, + }, + PM2_5_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, + { indexes: [51, 100], amounts: [36, 75] }, + { indexes: [101, 150], amounts: [76, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + ], + }, + }, + }, + }, + /** + * China AQI standard (2025 draft). + * [关于公开征求《环境空气质量标准(征求意见稿)》等3项生态环境标准意见的通知]{@link https://www.mee.gov.cn/xxgk2018/xxgk/xxgk06/202512/t20251215_1137858.html} + */ + HJ6332025_DRAFT: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [1, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, 500] }, // 严重污染 + ], + }, + pollutants: { + SO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [2101, 2620] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 475] }, + { indexes: [151, 200], amounts: [476, 800] }, + { indexes: [201, 300], amounts: [801, 1600] }, + { indexes: [301, 400], amounts: [1601, 2100] }, + { indexes: [401, 500], amounts: [2101, 2620] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + // SO2 1小时平均浓度值高于800 μg/m3的,IAQI按照200计。 + { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + ], + }, + }, + NO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 40] }, + max: { indexes: [401, 500], amounts: [751, 940] }, + value: [ + { indexes: [0, 50], amounts: [0, 40] }, + { indexes: [51, 100], amounts: [41, 80] }, + { indexes: [101, 150], amounts: [81, 180] }, + { indexes: [151, 200], amounts: [181, 280] }, + { indexes: [201, 300], amounts: [281, 565] }, + { indexes: [301, 400], amounts: [566, 750] }, + { indexes: [401, 500], amounts: [751, 940] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + ], + }, + }, + CO_24H: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 2] }, + max: { indexes: [401, 500], amounts: [49, 60] }, + value: [ + { indexes: [0, 50], amounts: [0, 2] }, + { indexes: [51, 100], amounts: [3, 4] }, + { indexes: [101, 150], amounts: [5, 14] }, + { indexes: [151, 200], amounts: [15, 24] }, + { indexes: [201, 300], amounts: [25, 36] }, + { indexes: [301, 400], amounts: [37, 48] }, + { indexes: [401, 500], amounts: [49, 60] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + CO: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5] }, + max: { indexes: [401, 500], amounts: [121, 150] }, + value: [ + { indexes: [0, 50], amounts: [0, 5] }, + { indexes: [51, 100], amounts: [6, 10] }, + { indexes: [101, 150], amounts: [11, 35] }, + { indexes: [151, 200], amounts: [36, 60] }, + { indexes: [201, 300], amounts: [61, 90] }, + { indexes: [301, 400], amounts: [91, 120] }, + { indexes: [401, 500], amounts: [121, 150] }, + ], + }, + }, + OZONE_8H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [300, 300], amounts: [801, Number.POSITIVE_INFINITY] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 160] }, + { indexes: [101, 150], amounts: [161, 215] }, + { indexes: [151, 200], amounts: [216, 265] }, + { indexes: [201, 300], amounts: [266, 800] }, + // O3 8小时平均浓度值高于800 μg/m3的,IAQI按照300计。 + { indexes: [300, 300], amounts: [801, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + ], + }, + }, + PM10_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 120] }, + { indexes: [101, 150], amounts: [121, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + ], + }, + }, + PM2_5_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 30] }, + { indexes: [51, 100], amounts: [31, 60] }, + { indexes: [101, 150], amounts: [61, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + ], + }, + }, + }, + }, + /** + * US AQI standard, not equal to NowCast. + * [Technical Assistance Document for the Reporting of Daily Air Quality – the Air Quality Index (AQI) (EPA 454/B-24-002, May 2024)]{@link https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf} + */ + EPA_NowCast: { + weatherKitScale: { + name: "EPA_NowCast", + version: "2414", + }, + categories: { + significantIndex: 3, // Unhealthy for Sensitive Groups + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // Good + { categoryIndex: 2, indexes: [51, 100] }, // Moderate + { categoryIndex: 3, indexes: [101, 150] }, // Unhealthy for Sensitive Groups + { categoryIndex: 4, indexes: [151, 200] }, // Unhealthy + { categoryIndex: 5, indexes: [201, 300] }, // Very Unhealthy + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + pollutants: { + OZONE_8H: { + units: "PARTS_PER_MILLION", + stpConversionFactor: 1.96189649169881, + ranges: { + min: { indexes: [0, 50], amounts: [0, 0.054] }, + max: { indexes: [201, 300], amounts: [0.106, 0.2] }, + value: [ + { indexes: [0, 50], amounts: [0, 0.054] }, // Good + { indexes: [51, 100], amounts: [0.055, 0.07] }, // Moderate + { indexes: [101, 150], amounts: [0.071, 0.085] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [0.086, 0.105] }, // Unhealthy + { indexes: [201, 300], amounts: [0.106, 0.2] }, // Very Unhealthy + // Note 2: + // 8-hour O3 values do not define higher AQI values (≥ 301). + // AQI values of 301 or higher are calculated with 1-hour O3 concentrations. + { indexes: [301, Number.POSITIVE_INFINITY], amounts: [0.201, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + OZONE: { + units: "PARTS_PER_MILLION", + stpConversionFactor: 1.96189649169881, + ranges: { + min: { indexes: [101, 150], amounts: [0.125, 0.164] }, + max: { indexes: [301, 500], amounts: [0.405, 0.604] }, + value: [ + // Note 1: + // Areas are generally required to report the AQI based on 8-hour O3 values. However, + // there are a small number of areas where an AQI based on 1-hour O3 values would be more precautionary. + // In these cases, in addition to calculating the 8-hour O3 index value, + // the 1-hour O3 value may be calculated, and the maximum of the two values reported. + { indexes: [101, 150], amounts: [0.125, 0.164] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [0.165, 0.204] }, // Unhealthy + { indexes: [201, 300], amounts: [0.205, 0.404] }, // Very Unhealthy + { indexes: [301, 500], amounts: [0.405, 0.604] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [0.605, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM2_5_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 9] }, + max: { indexes: [301, 500], amounts: [225.5, 325.4] }, + value: [ + { indexes: [0, 50], amounts: [0, 9] }, // Good + { indexes: [51, 100], amounts: [9.1, 35.4] }, // Moderate + { indexes: [101, 150], amounts: [35.5, 55.4] }, // Unhealthy for Sensitive Groups + // Note 3 of PM2.5 can be found in EPA 454/B-18-007: + // If a different SHL for PM2.5 is promulgated, these numbers will change accordingly. + // + // It is believed that they forgot to remove this note + { indexes: [151, 200], amounts: [55.5, 125.4] }, // Unhealthy + { indexes: [201, 300], amounts: [125.5, 225.4] }, // Very Unhealthy + { indexes: [301, 500], amounts: [225.5, 325.4] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [325.5, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM10_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 54] }, + max: { indexes: [301, 500], amounts: [425, 604] }, + value: [ + { indexes: [0, 50], amounts: [0, 54] }, // Good + { indexes: [51, 100], amounts: [55, 154] }, // Moderate + { indexes: [101, 150], amounts: [155, 254] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [255, 354] }, // Unhealthy + { indexes: [201, 300], amounts: [355, 424] }, // Very Unhealthy + { indexes: [301, 500], amounts: [425, 604] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [605, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + CO_8H: { + units: "PARTS_PER_MILLION", + stpConversionFactor: 1.14491990608754, + ranges: { + min: { indexes: [0, 50], amounts: [0, 4.4] }, + max: { indexes: [301, 500], amounts: [30.5, 50.4] }, + value: [ + { indexes: [0, 50], amounts: [0, 4.4] }, // Good + { indexes: [51, 100], amounts: [4.5, 9.4] }, // Moderate + { indexes: [101, 150], amounts: [9.5, 12.4] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [12.5, 15.4] }, // Unhealthy + { indexes: [201, 300], amounts: [15.5, 30.4] }, // Very Unhealthy + { indexes: [301, 500], amounts: [30.5, 50.4] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50.5, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + SO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 2.618396263625692, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [301, 500], amounts: [605, 1004] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, // Good + { indexes: [51, 100], amounts: [36, 75] }, // Moderate + { indexes: [101, 150], amounts: [76, 185] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [186, 304] }, // Unhealthy + // Note 3 of SO2: + // 1-hour SO2 values do not define higher AQI values (≥ 200). + // AQI values of 200 or greater are calculated with 24-hour SO2 concentrations. + // + // It is believed that they forgot to remove this note + { indexes: [201, 300], amounts: [305, 604] }, // Very Unhealthy + { indexes: [301, 500], amounts: [605, 1004] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1005, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + NO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.880472698306222, + ranges: { + min: { indexes: [0, 50], amounts: [0, 53] }, + max: { indexes: [301, 500], amounts: [1250, 2049] }, + value: [ + { indexes: [0, 50], amounts: [0, 53] }, // Good + { indexes: [51, 100], amounts: [54, 100] }, // Moderate + { indexes: [101, 150], amounts: [101, 360] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [361, 649] }, // Unhealthy + { indexes: [201, 300], amounts: [650, 1249] }, // Very Unhealthy + { indexes: [301, 500], amounts: [1250, 2049] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [2050, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + }, + }, + /** + * WAQI InstantCast in EPA_NowCast. Basically is a method to calculate indexes based on 1-hour pollutants. + * [A Beginner's Guide to Air Quality Instant-Cast and Now-Cast.]{@link https://aqicn.org/faq/2015-03-15/air-quality-nowcast-a-beginners-guide/} + * [Ozone AQI Scale update]{@link https://aqicn.org/faq/2016-08-10/ozone-aqi-scale-update/} + */ + WAQI_InstantCast_US: { + weatherKitScale: { + name: "EPA_NowCast", + version: "2414", + maxIndex: 500, + }, + categories: { + significantIndex: 3, // Unhealthy for Sensitive Groups + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // Good + { categoryIndex: 2, indexes: [51, 100] }, // Moderate + { categoryIndex: 3, indexes: [101, 150] }, // Unhealthy for Sensitive Groups + { categoryIndex: 4, indexes: [151, 200] }, // Unhealthy + { categoryIndex: 5, indexes: [201, 300] }, // Very Unhealthy + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + // Eventually we will convert it to ppb + pollutants: { + // [Ozone AQI Scale update](https://aqicn.org/faq/2016-08-10/ozone-aqi-scale-update/) + OZONE: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.96189649169881, + ranges: { + min: { indexes: [0, 50], amounts: [0, 62.5] }, + max: { indexes: [301, 500], amounts: [405.1, 605] }, + value: [ + { indexes: [0, 50], amounts: [0, 62.5] }, // Good + { indexes: [51, 100], amounts: [62.6, 101.5] }, // Moderate + { indexes: [101, 150], amounts: [101.6, 152.5] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [152.6, 205] }, // Unhealthy + { indexes: [201, 300], amounts: [205.1, 405] }, // Very Unhealthy + { indexes: [301, 500], amounts: [405.1, 605] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [605.1, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 9] }, + max: { indexes: [301, 500], amounts: [225.5, 325.4] }, + value: [ + { indexes: [0, 50], amounts: [0, 9] }, // Good + { indexes: [51, 100], amounts: [9.1, 35.4] }, // Moderate + { indexes: [101, 150], amounts: [35.5, 55.4] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [55.5, 125.4] }, // Unhealthy + { indexes: [201, 300], amounts: [125.5, 225.4] }, // Very Unhealthy + { indexes: [301, 500], amounts: [225.5, 325.4] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [325.5, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 54] }, + max: { indexes: [301, 500], amounts: [425, 604] }, + value: [ + { indexes: [0, 50], amounts: [0, 54] }, // Good + { indexes: [51, 100], amounts: [55, 154] }, // Moderate + { indexes: [101, 150], amounts: [155, 254] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [255, 354] }, // Unhealthy + { indexes: [201, 300], amounts: [355, 424] }, // Very Unhealthy + { indexes: [301, 500], amounts: [425, 604] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [605, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + CO: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.144511152104645, + ranges: { + min: { indexes: [0, 50], amounts: [0, 4400] }, + max: { indexes: [301, 500], amounts: [30401, 50400] }, + value: [ + { indexes: [0, 50], amounts: [0, 4400] }, // Good + { indexes: [51, 100], amounts: [4401, 9400] }, // Moderate + { indexes: [101, 150], amounts: [9401, 12400] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [12401, 15400] }, // Unhealthy + { indexes: [201, 300], amounts: [15401, 30400] }, // Very Unhealthy + { indexes: [301, 500], amounts: [30401, 50400] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50401, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + SO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 2.618396263625692, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [301, 500], amounts: [605, 1004] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, // Good + { indexes: [51, 100], amounts: [36, 75] }, // Moderate + { indexes: [101, 150], amounts: [76, 185] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [186, 304] }, // Unhealthy + { indexes: [201, 300], amounts: [305, 604] }, // Very Unhealthy + { indexes: [301, 500], amounts: [605, 1004] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1005, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + NO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.880472698306222, + ranges: { + min: { indexes: [0, 50], amounts: [0, 53] }, + max: { indexes: [301, 500], amounts: [1250, 2049] }, + value: [ + { indexes: [0, 50], amounts: [0, 53] }, // Good + { indexes: [51, 100], amounts: [54, 100] }, // Moderate + { indexes: [101, 150], amounts: [101, 360] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [361, 649] }, // Unhealthy + { indexes: [201, 300], amounts: [650, 1249] }, // Very Unhealthy + { indexes: [301, 500], amounts: [1250, 2049] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [2050, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + }, + }, + /** + * WAQI InstantCast in HJ6332012. + * Use 24-hour SO2 when > 800, 24-hour PM10 and PM2.5 as 1-hour. + * 1-hour for others. + */ + WAQI_InstantCast_CN: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + maxIndex: 500, + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // 严重污染 + ], + }, + pollutants: { + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [401, 500], amounts: [2101, 2620] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + { indexes: [201, 300], amounts: [801, 1600] }, + { indexes: [301, 400], amounts: [1601, 2100] }, + { indexes: [401, 500], amounts: [2101, 2620] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [2602, Number.POSITIVE_INFINITY] }, + ], + }, + }, + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [3841, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [601, Number.POSITIVE_INFINITY] }, + ], + }, + }, + CO: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5000] }, + max: { indexes: [401, 500], amounts: [120001, 150000] }, + value: [ + { indexes: [0, 50], amounts: [0, 5000] }, + { indexes: [51, 100], amounts: [5001, 10000] }, + { indexes: [101, 150], amounts: [10001, 35000] }, + { indexes: [151, 200], amounts: [35001, 60000] }, + { indexes: [201, 300], amounts: [60001, 90000] }, + { indexes: [301, 400], amounts: [90001, 120000] }, + { indexes: [401, 500], amounts: [120001, 150000] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [150001, Number.POSITIVE_INFINITY] }, + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1201, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, + { indexes: [51, 100], amounts: [36, 75] }, + { indexes: [101, 150], amounts: [76, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [501, Number.POSITIVE_INFINITY] }, + ], + }, + }, + }, + }, + WAQI_InstantCast_CN_25_DRAFT: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + maxIndex: 500, + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // 严重污染 + ], + }, + pollutants: { + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + // SO2 1小时平均浓度值高于800 μg/m3的,IAQI按照200计。 + { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [3841, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + CO: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5000] }, + max: { indexes: [401, 500], amounts: [120001, 150000] }, + value: [ + { indexes: [0, 50], amounts: [0, 5000] }, + { indexes: [51, 100], amounts: [5001, 10000] }, + { indexes: [101, 150], amounts: [10001, 35000] }, + { indexes: [151, 200], amounts: [35001, 60000] }, + { indexes: [201, 300], amounts: [60001, 90000] }, + { indexes: [301, 400], amounts: [90001, 120000] }, + { indexes: [401, 500], amounts: [120001, 150000] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [150001, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1201, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 120] }, + { indexes: [101, 150], amounts: [121, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [601, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 30] }, + { indexes: [51, 100], amounts: [31, 60] }, + { indexes: [101, 150], amounts: [61, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [501, Number.POSITIVE_INFINITY] }, + ], + }, + }, + }, + }, +HK_AQHI: { + weatherKitScale: { + name: "HK.AQHI", + }, + categories: { + significantIndex: 7, // 高 (AQHI 7 及以上) + ranges: [ + { categoryIndex: 1, indexes: [0, 1.87] }, + { categoryIndex: 2, indexes: [1.88, 3.73] }, + { categoryIndex: 3, indexes: [3.74, 5.6] }, + { categoryIndex: 4, indexes: [5.61, 7.46] }, + { categoryIndex: 5, indexes: [7.47, 9.33] }, + { categoryIndex: 6, indexes: [9.34, 11.2] }, + { categoryIndex: 7, indexes: [11.21, 12.81] }, + { categoryIndex: 8, indexes: [12.82, 14.94] }, + { categoryIndex: 9, indexes: [14.95, 17.08] }, + { categoryIndex: 10, indexes: [17.09, 19.21] }, + { categoryIndex: 11, indexes: [19.22, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µg/m³ + betas: { + NO2: 0.0004462559, + SO2: 0.0001393235, + O3: 0.0004888034, + PM10: 0.0002821751, + PM2_5: 0.0002180567, + }, + // pollutants 仅声明目标单位(均为 µg/m³),供 ConvertUnits 使用 + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM10: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + /** + * Canada Air Quality Health Index (AQHI). + * [10.1016/j.atmosenv.2024.120473]{@link https://doi.org/10.1016/j.atmosenv.2024.120473} + */ + CA_AQHI: { + weatherKitScale: { + name: "CA.AQHI", + }, + categories: { + significantIndex: 7, // High Health Risk (AQHI > 7) + ranges: [ + { categoryIndex: 1, indexes: [0, 1.04] }, + { categoryIndex: 2, indexes: [1.05, 2.08] }, + { categoryIndex: 3, indexes: [2.09, 3.12] }, + { categoryIndex: 4, indexes: [3.13, 4.16] }, + { categoryIndex: 5, indexes: [4.17, 5.2] }, + { categoryIndex: 6, indexes: [5.21, 6.24] }, + { categoryIndex: 7, indexes: [6.25, 7.28] }, + { categoryIndex: 8, indexes: [7.29, 8.32] }, + { categoryIndex: 9, indexes: [8.33, 9.36] }, + { categoryIndex: 10, indexes: [9.37, 10.4] }, + { categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数:NO2、O3 使用 ppb,PM2.5 使用 µg/m³。 + // %AR × 10 / 10.4, then round to 1–10 (11 means 10+). + indexScaleFactor: 10 / 10.4, + betas: { + NO2: 0.000871, + O3: 0.000537, + PM2_5: 0.000487, + }, + pollutants: { + NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.880472698306222 }, + OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.96189649169881 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + /** + * China Air Quality Health Index (AQHI). + * [10.1016/j.atmosenv.2024.120473]{@link https://doi.org/10.1016/j.atmosenv.2024.120473} + */ + CN_DEATH_AQHI: { + weatherKitScale: { + name: "CN.AQHI", + }, + categories: { + significantIndex: 6, // 四级 (AQHI 6 及以上) + // Categories in AQHI: 一级:0~1,二级:2~3.5,三级:3.6~5,四级:6~10,五级:11 + ranges: [ + { categoryIndex: 1, indexes: [0, 2.004] }, + { categoryIndex: 2, indexes: [2.005, 4.008] }, + // 3: 4.009~6.012 + { categoryIndex: 3, indexes: [4.009, 7.018] }, + // 4: 6.013~8.016 + { categoryIndex: 4, indexes: [7.018, 8.016] }, + { categoryIndex: 5, indexes: [8.017, 10.02] }, + { categoryIndex: 6, indexes: [10.021, 12.024] }, + { categoryIndex: 7, indexes: [12.025, 14.028] }, + { categoryIndex: 8, indexes: [14.029, 16.032] }, + { categoryIndex: 9, indexes: [16.033, 18.036] }, + { categoryIndex: 10, indexes: [18.037, 20.04] }, + { categoryIndex: 11, indexes: [20.041, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µg/m³ + betas: { + NO2: 0.0009, + SO2: 0.00059, + O3: 0.00024, + PM2_5: 0.00022, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + CN_DEATH_HK_AQHI: { + weatherKitScale: { + name: "CN.AQHI", + }, + categories: { + significantIndex: 6, // 四级 (AQHI 6 及以上) + // Categories in AQHI: 一级:0~1,二级:2~3.5,三级:3.6~5,四级:6~10,五级:11 + ranges: [ + { categoryIndex: 1, indexes: [0, 2.004] }, + { categoryIndex: 2, indexes: [2.005, 4.008] }, + // 3: 4.009~6.012 + { categoryIndex: 3, indexes: [4.009, 7.018] }, + // 4: 6.013~8.016 + { categoryIndex: 4, indexes: [7.018, 8.016] }, + { categoryIndex: 5, indexes: [8.017, 10.02] }, + { categoryIndex: 6, indexes: [10.021, 12.024] }, + { categoryIndex: 7, indexes: [12.025, 14.028] }, + { categoryIndex: 8, indexes: [14.029, 16.032] }, + { categoryIndex: 9, indexes: [16.033, 18.036] }, + { categoryIndex: 10, indexes: [18.037, 20.04] }, + { categoryIndex: 11, indexes: [20.041, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µg/m³ + betas: { + NO2: 0.0009, + SO2: 0.00059, + O3: 0.00024, + PM10: 0.0002821751, + PM2_5: 0.0002180567, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM10: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + AQHI_Multi_CN: { + weatherKitScale: { + name: "CA.AQHI", + }, + categories: { + significantIndex: 7, + // 以 WHO AQG 2021: PM2.5 24h=15, O3=100, NO2=25, SO2=40 对应的风险值校准 AQHI=3。 + ranges: [ + { categoryIndex: 1, indexes: [0, 5.8453] }, + { categoryIndex: 2, indexes: [5.8454, 11.6906] }, + { categoryIndex: 3, indexes: [11.6907, 17.536] }, + { categoryIndex: 4, indexes: [17.5361, 23.3812] }, + { categoryIndex: 5, indexes: [23.3813, 29.2265] }, + { categoryIndex: 6, indexes: [29.2266, 35.0718] }, + { categoryIndex: 7, indexes: [35.0719, 40.9171] }, + { categoryIndex: 8, indexes: [40.9172, 46.7624] }, + { categoryIndex: 9, indexes: [46.7625, 52.6077] }, + { categoryIndex: 10, indexes: [52.6078, 58.453] }, + { categoryIndex: 11, indexes: [58.4531, Number.POSITIVE_INFINITY] }, + ], + }, + beta: 0.369991, + weights: { + O3: 0.271273, + NO2: 0.390781, + PM2_5: 0.120648, + SO2: 0.217224, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + AQHI_Multi_CN_HK: { + weatherKitScale: { + name: "CA.AQHI", + }, + categories: { + significantIndex: 7, + // 以 WHO AQG 2021: PM2.5 24h=15, O3=100, NO2=25, SO2=40 对应的风险值校准 AQHI=3。 + ranges: [ + { categoryIndex: 1, indexes: [0, 5.8453] }, + { categoryIndex: 2, indexes: [5.8454, 11.6906] }, + { categoryIndex: 3, indexes: [11.6907, 17.536] }, + { categoryIndex: 4, indexes: [17.5361, 23.3812] }, + { categoryIndex: 5, indexes: [23.3813, 29.2265] }, + { categoryIndex: 6, indexes: [29.2266, 35.0718] }, + { categoryIndex: 7, indexes: [35.0719, 40.9171] }, + { categoryIndex: 8, indexes: [40.9172, 46.7624] }, + { categoryIndex: 9, indexes: [46.7625, 52.6077] }, + { categoryIndex: 10, indexes: [52.6078, 58.453] }, + { categoryIndex: 11, indexes: [58.4531, Number.POSITIVE_INFINITY] }, + ], + }, + beta: 0.369991, + weights: { + O3: 0.271273, + NO2: 0.390781, + // Risk from HK AQHI: HK_AQHI_RR(PM10) / HK_AQHI_RR(PM2.5) * AQHI_MULTI_CN_RR(PM2.5) + PM10: 0.156123, + PM2_5: 0.120648, + SO2: 0.217224, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM10: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + }, + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + }, + CompareCategoryIndexes: { + BETTER: "BETTER", + SAME: "SAME", + UNKNOWN: "UNKNOWN", + WORSE: "WORSE", + }, + Units: { + WeatherKit: { + ugm3: "MICROGRAMS_PER_CUBIC_METER", + mgm3: "MILLIGRAMS_PER_CUBIC_METER", + ppb: "PARTS_PER_BILLION", + ppm: "PARTS_PER_MILLION", + }, + Friendly: { + MICROGRAMS_PER_CUBIC_METER: "μg/m3", + MILLIGRAMS_PER_CUBIC_METER: "mg/m3", + PARTS_PER_BILLION: "ppb", + PARTS_PER_MILLION: "ppm", + }, + }, + /** + * Standard Conditions for Temperature and Pressure + * [Ozone AQI: Using concentrations in milligrams or ppb?]{@link https://aqicn.org/faq/2015-09-06/ozone-aqi-using-concentrations-in-milligrams-or-ppb/} + * [Understanding Units of Measurement - Terrie K. Boguski, P.E. (CHSR)]{@link https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileid/14285} + * + * (amount * 12.187 * molecularWeight) / (temperatureInCelsius + 273.15) + * + * - 12.187 is the inverse of gas constant. + * - 273.15 is the 0 celsius in kelvin. + * - temperatureInCelsius is 25 in US, 20 in EU. + */ + STP_ConversionFactors: { + US: { + OZONE: 1.96189649169881, // 47.997 g/mol + SO2: 2.618396263625692, // 64.058 g/mol + NO2: 1.880472698306222, // 46.005 g/mol + CO: 1.14491990608754, // 28.01 g/mol + C6H6: 3.192940861982224, // 78.114 g/mol + NH3: 0.696148908267651, // 17.031 g/mol + NO: 1.226507201073285, // 30.006 g/mol + }, + EU: { + OZONE: 1.995358823128092, // 47.997 g/mol + SO2: 2.663055930411053, // 64.058 g/mol + NO2: 1.912546256182842, // 46.005 g/mol + CO: 1.164447791233157, // 28.01 g/mol + C6H6: 3.247400027289784, // 78.114 g/mol + NH3: 0.708022503837626, // 17.031 g/mol + NO: 1.247426648473478, // 30.006 g/mol + }, + }, + }; + + static ConvertUnit(amount, from, to, fromStpConversionFactor = -1, toStpConversionFactor = -1) { + Console.debug("☑️ ConvertUnit"); + const friendlyUnits = AirQuality.Config.Units.Friendly; + Console.debug("Convert", amount, "from", friendlyUnits[from], "with", fromStpConversionFactor, "to", friendlyUnits[to], "with", toStpConversionFactor); + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + Console.error("ConvertUnit", `Amount ${amount} is invalid`); + return -1; + } + + const numAmount = Number(amount.toFixed(15)); + + const { ugm3, mgm3, ppb, ppm } = AirQuality.Config.Units.WeatherKit; + const ppx = [ppb, ppm]; + const units = [...ppx, ugm3, mgm3]; + if (!units.includes(from) || !units.includes(to)) { + Console.error("ConvertUnit", "不支持的单位", `from: ${from}`, `to: ${to}`); + return -1; + } + + const isPpxFrom = ppx.includes(from); + const isPpxTo = ppx.includes(to); + const isBothPpx = isPpxFrom && isPpxTo; + if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) { + Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`); + return -1; + } + + if (!isBothPpx && isPpxTo && toStpConversionFactor <= 0) { + Console.error("ConvertUnit", `toStpConversionFactor ${toStpConversionFactor} <= 0`); + return -1; + } + + const { multiply, divide } = SimplePrecisionMath; + switch (from) { + case ppm: + switch (to) { + case ppm: + Console.debug("✅ ConvertUnit"); + return numAmount; + case ppb: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 1000); + case mgm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, fromStpConversionFactor); + case ugm3: { + const inPpb = AirQuality.ConvertUnit(numAmount, from, ppb); + Console.debug("✅ ConvertUnit"); + return multiply(inPpb, fromStpConversionFactor); + } + default: + return -1; + } + case ppb: + switch (to) { + case ppb: + Console.debug("✅ ConvertUnit"); + return numAmount; + case ppm: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 0.001); + case mgm3: { + const inPpm = AirQuality.ConvertUnit(numAmount, from, ppm); + Console.debug("✅ ConvertUnit"); + return multiply(inPpm, fromStpConversionFactor); + } + case ugm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, fromStpConversionFactor); + default: + return -1; + } + case mgm3: + switch (to) { + case mgm3: + Console.debug("✅ ConvertUnit"); + return numAmount; + case ugm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 1000); + case ppm: + Console.debug("✅ ConvertUnit"); + return divide(numAmount, toStpConversionFactor); + case ppb: { + const inUgM3 = AirQuality.ConvertUnit(numAmount, from, ugm3); + Console.debug("✅ ConvertUnit"); + return divide(inUgM3, toStpConversionFactor); + } + default: + return -1; + } + case ugm3: + switch (to) { + case ugm3: + Console.debug("✅ ConvertUnit"); + return numAmount; + case mgm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 0.001); + case ppm: { + const inMgM3 = AirQuality.ConvertUnit(numAmount, from, mgm3); + Console.debug("✅ ConvertUnit"); + return divide(inMgM3, toStpConversionFactor); + } + case ppb: + Console.debug("✅ ConvertUnit"); + return divide(numAmount, toStpConversionFactor); + default: + return -1; + } + default: + return -1; + } + } +} diff --git a/src/wk3/class/AirQualityScale.mjs b/src/wk3/class/AirQualityScale.mjs new file mode 100644 index 00000000..1e9affef --- /dev/null +++ b/src/wk3/class/AirQualityScale.mjs @@ -0,0 +1,994 @@ +/** + * AirQualityScale + * 提供空气质量标尺(AQI Scale)的本地构建能力, + * 用于替代 WeatherKit /api/v1/airQualityScale 接口返回。 + * + * 支持的标尺: + * - HJ6332012.* — 中国国标 AQI + * - EPA_NowCast.* — 美国 EPA AQI + * - HK.AQHI — 香港 AQHI(可带数字版本) + * - CA.AQHI — 加拿大 AQHI(可带数字版本) + * - CN.AQHI — 中国 AQHI(可带数字版本) + * - EU.EAQI.* — 欧洲 EAQI + * - UBA.* — 德国 LQI + * + * 语言键说明: + * zh-Hans-CN 简体中文 + * zh-Hant-HK 繁体中文 + * en-US 英文(默认回退) + */ + +import { Console } from "../utils/index.mjs"; + +// ─── 多语言字符串常量 ────────────────────────────────────────────────────────── + +/** 空气质量标尺通用标签 */ +const SCALE_DISPLAY_LABEL = { + "zh-Hant-HK": "空氣質素", + "zh-Hant-TW": "空氣品質", + "zh-Hans-CN": "空气质量", + "en-US": "Air Quality", +}; + +// ─── HJ6332012 中国国标 AQI ──────────────────────────────────────────────────── + +const HJ6332012 = { + displayName: { + "zh-Hans-CN": "空气质量指数", + "zh-Hant-HK": "空氣質量指數", + "en-US": "Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "AQI", + "zh-Hant-HK": "AQI", + "en-US": "AQI", + }, + longDisplayName: { + "zh-Hans-CN": "中国 (HJ 633—2012)", + "zh-Hant-HK": "中國 (HJ 633—2012)", + "en-US": "China (HJ 633—2012)", + }, + categories: [ + { + range: [0, 50], + glyph: "aqi.low", + colors: ["#00E400"], + categoryName: { + "zh-Hans-CN": "优", + "zh-Hant-HK": "優", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "空气质量令人满意,基本无空气污染。", + "zh-Hant-HK": "空氣質量令人滿意,基本無空氣污染。", + "en-US": "Air quality is satisfactory, and air pollution poses little or no risk.", + }, + }, + { + range: [51, 100], + glyph: "aqi.low", + colors: ["#FFFF00"], + categoryName: { + "zh-Hans-CN": "良", + "zh-Hant-HK": "良", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "空气质量可接受,但某些污染物可能对极少数异常敏感人群健康有较弱影响。", + "zh-Hant-HK": "空氣質量可接受,但某些污染物可能對極少數異常敏感人群健康有較弱影響。", + "en-US": "Air quality is acceptable. However, there may be a risk for some people, particularly those who are unusually sensitive to air pollution.", + }, + }, + { + range: [101, 150], + glyph: "aqi.medium", + colors: ["#FF7E00"], + categoryName: { + "zh-Hans-CN": "轻度污染", + "zh-Hant-HK": "輕度污染", + "en-US": "Unhealthy for Sensitive Groups", + }, + recommendation: { + "zh-Hans-CN": "敏感人群症状有轻度加剧,健康人群出现刺激症状。建议儿童、老年人及心脏病、呼吸系统疾病患者应减少长时间、高强度的户外锻炼。", + "zh-Hant-HK": "敏感人群症狀有輕度加劇,健康人群出現刺激症狀。建議兒童、老年人及心臟病、呼吸系統疾病患者應減少長時間、高強度的戶外鍛煉。", + "en-US": "Members of sensitive groups may experience health effects. The general public is less likely to be affected. Children, the elderly, and people with heart or respiratory diseases should reduce prolonged or heavy outdoor exertion.", + }, + }, + { + range: [151, 200], + glyph: "aqi.high", + colors: ["#FF0000"], + categoryName: { + "zh-Hans-CN": "中度污染", + "zh-Hant-HK": "中度污染", + "en-US": "Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "进一步加剧敏感人群症状,可能对心脏和呼吸系统有影响。儿童、老年人及心脏病、呼吸系统疾病患者应避免长时间、高强度的户外锻炼,一般人群适量减少户外运动。", + "zh-Hant-HK": "進一步加劇敏感人群症狀,可能對心臟和呼吸系統有影響。兒童、老年人及心臟病、呼吸系統疾病患者應避免長時間、高強度的戶外鍛煉,一般人群適量減少戶外運動。", + "en-US": + "Some members of the general public may experience health effects; members of sensitive groups may experience more serious health effects. Children, the elderly, and people with heart or respiratory diseases should avoid prolonged or heavy outdoor exertion. Everyone else should reduce outdoor exertion.", + }, + }, + { + range: [201, 300], + glyph: "aqi.high", + colors: ["#99004C"], + categoryName: { + "zh-Hans-CN": "重度污染", + "zh-Hant-HK": "重度污染", + "en-US": "Very Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "心脏病和肺病患者症状显著加剧,运动耐受力降低,健康人群普遍出现症状。儿童、老年人和病人应停留在室内,避免体力消耗,一般人群应避免户外活动。", + "zh-Hant-HK": "心臟病和肺病症狀顯著加劇,運動耐受力降低,健康人群普遍出現症狀。兒童、老年人和病人應停留在室內,避免體力消耗,一般人群應避免戶外活動。", + "en-US": "Health warnings of emergency conditions. The entire population is more likely to be affected. Children, the elderly, and the ill should stay indoors. Everyone else should avoid outdoor exertion.", + }, + }, + { + range: [301, 500], + glyph: "aqi.high", + colors: ["#7E0023"], + categoryName: { + "zh-Hans-CN": "严重污染", + "zh-Hant-HK": "嚴重污染", + "en-US": "Hazardous", + }, + recommendation: { + "zh-Hans-CN": "健康人群运动耐受力降低,有明显强烈症状,提前出现某些疾病。儿童、老年人和病人应当留在室内,避免体力消耗。一般人群应避免户外活动。", + "zh-Hant-HK": "健康人群運動耐受力降低,有明顯強烈症狀,提前出現某些疾病。兒童、老年人和病人應當留在室內,避免體力消耗。一般人群應避免戶外活動。", + "en-US": "Health alert: everyone may experience more serious health effects. Children, the elderly, and the ill should remain indoors. Everyone else should avoid outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 0, color: "#00E400" }, + { location: 50, color: "#00E400" }, + { location: 51, color: "#FFFF00" }, + { location: 100, color: "#FFFF00" }, + { location: 101, color: "#FF7E00" }, + { location: 150, color: "#FF7E00" }, + { location: 151, color: "#FF0000" }, + { location: 200, color: "#FF0000" }, + { location: 201, color: "#99004C" }, + { location: 300, color: "#99004C" }, + { location: 301, color: "#7E0023" }, + ], + }, +}; + +// ─── EPA_NowCast 美国 AQI ────────────────────────────────────────────────────── + +const EPA_NOWCAST = { + displayName: { + "zh-Hans-CN": "空气质量指数", + "zh-Hant-HK": "空氣質量指數", + "en-US": "Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "AQI", + "zh-Hant-HK": "AQI", + "en-US": "AQI", + }, + longDisplayName: { + "zh-Hans-CN": "美国 (EPA NowCast)", + "zh-Hant-HK": "美國 (EPA NowCast)", + "en-US": "United States (EPA NowCast)", + }, + categories: [ + { + range: [0, 50], + glyph: "aqi.low", + colors: ["#00E400"], + categoryName: { + "zh-Hans-CN": "优", + "zh-Hant-HK": "優", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "空气质量令人满意,基本无空气污染。", + "zh-Hant-HK": "空氣質量令人滿意,基本無空氣污染。", + "en-US": "Air quality is satisfactory, and air pollution poses little or no risk.", + }, + }, + { + range: [51, 100], + glyph: "aqi.low", + colors: ["#FFFF00"], + categoryName: { + "zh-Hans-CN": "良", + "zh-Hant-HK": "良", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "空气质量可接受,但某些污染物可能对极少数异常敏感人群健康有较弱影响。", + "zh-Hant-HK": "空氣質量可接受,但某些污染物可能對極少數異常敏感人群健康有較弱影響。", + "en-US": "Air quality is acceptable. However, there may be a risk for some people, particularly those who are unusually sensitive to air pollution.", + }, + }, + { + range: [101, 150], + glyph: "aqi.medium", + colors: ["#FF7E00"], + categoryName: { + "zh-Hans-CN": "敏感人群不健康", + "zh-Hant-HK": "敏感人群不健康", + "en-US": "Unhealthy for Sensitive Groups", + }, + recommendation: { + "zh-Hans-CN": "敏感人群症状有轻度加剧,健康人群出现刺激症状。建议儿童、老年人及心脏病、呼吸系统疾病患者应减少户外活动。", + "zh-Hant-HK": "敏感人群症狀有輕度加劇,健康人群出現刺激症狀。建議兒童、老年人及心臟病、呼吸系統疾病患者應減少戶外活動。", + "en-US": "Members of sensitive groups may experience health effects. The general public is less likely to be affected.", + }, + }, + { + range: [151, 200], + glyph: "aqi.high", + colors: ["#FF0000"], + categoryName: { + "zh-Hans-CN": "不健康", + "zh-Hant-HK": "不健康", + "en-US": "Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "进一步加剧敏感人群症状,可能对心脏和呼吸系统有影响。一般人群应减少户外活动。", + "zh-Hant-HK": "進一步加劇敏感人群症狀,可能對心臟和呼吸系統有影響。一般人群應減少戶外活動。", + "en-US": "Some members of the general public may experience health effects; members of sensitive groups may experience more serious health effects.", + }, + }, + { + range: [201, 300], + glyph: "aqi.high", + colors: ["#99004C"], + categoryName: { + "zh-Hans-CN": "非常不健康", + "zh-Hant-HK": "非常不健康", + "en-US": "Very Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "健康警告:所有人都可能出现更严重的健康影响。", + "zh-Hant-HK": "健康警告:所有人都可能出現更嚴重的健康影響。", + "en-US": "Health alert: everyone may experience more serious health effects.", + }, + }, + { + range: [301, 500], + glyph: "aqi.high", + colors: ["#7E0023"], + categoryName: { + "zh-Hans-CN": "危险", + "zh-Hant-HK": "危險", + "en-US": "Hazardous", + }, + recommendation: { + "zh-Hans-CN": "健康警告:所有人都可能出现严重的健康影响。应避免所有户外活动。", + "zh-Hant-HK": "健康警告:所有人都可能出現嚴重的健康影響。應避免所有戶外活動。", + "en-US": "Health warning of emergency conditions: everyone is more likely to be affected. Avoid all outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 0, color: "#00E400" }, + { location: 50, color: "#00E400" }, + { location: 51, color: "#FFFF00" }, + { location: 100, color: "#FFFF00" }, + { location: 101, color: "#FF7E00" }, + { location: 150, color: "#FF7E00" }, + { location: 151, color: "#FF0000" }, + { location: 200, color: "#FF0000" }, + { location: 201, color: "#99004C" }, + { location: 300, color: "#99004C" }, + { location: 301, color: "#7E0023" }, + ], + }, +}; + +// ─── EU EAQI 欧盟 ────────────────────────────────────────────────────────────── + +const EU_EAQI = { + displayName: { + "zh-Hans-CN": "欧盟空气质量指数", + "zh-Hant-HK": "歐盟空氣質量指數", + "en-US": "European Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "EAQI", + "zh-Hant-HK": "EAQI", + "en-US": "EAQI", + }, + longDisplayName: { + "zh-Hans-CN": "欧盟 (EAQI)", + "zh-Hant-HK": "歐盟 (EAQI)", + "en-US": "European Union (EAQI)", + }, + // Display indexes match AirQuality.Config.Scales.EU_EAQI (2024/17), + // including the sixth category. They are not a generic 0..100 scale. + categories: [ + { + range: [0, 9], + glyph: "aqi.low", + colors: ["#1E88E5"], + categoryName: { + "zh-Hans-CN": "好", + "zh-Hant-HK": "好", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "享受户外活动。", + "zh-Hant-HK": "享受戶外活動。", + "en-US": "Enjoy your usual outdoor activities.", + }, + }, + { + range: [10, 19], + glyph: "aqi.low", + colors: ["#00C853"], + categoryName: { + "zh-Hans-CN": "尚可", + "zh-Hant-HK": "尚可", + "en-US": "Fair", + }, + recommendation: { + "zh-Hans-CN": "享受户外活动。", + "zh-Hant-HK": "享受戶外活動。", + "en-US": "Enjoy your usual outdoor activities.", + }, + }, + { + range: [20, 29], + glyph: "aqi.medium", + colors: ["#FFEB3B"], + categoryName: { + "zh-Hans-CN": "中等", + "zh-Hant-HK": "中等", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应考虑减少户外活动。", + "zh-Hant-HK": "敏感人群應考慮減少戶外活動。", + "en-US": "Sensitive individuals should consider reducing outdoor exertion.", + }, + }, + { + range: [30, 39], + glyph: "aqi.high", + colors: ["#FF9800"], + categoryName: { + "zh-Hans-CN": "差", + "zh-Hant-HK": "差", + "en-US": "Poor", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应减少户外活动。", + "zh-Hant-HK": "敏感人群應減少戶外活動。", + "en-US": "Sensitive groups should reduce outdoor exertion.", + }, + }, + { + range: [40, 49], + glyph: "aqi.high", + colors: ["#F44336"], + categoryName: { + "zh-Hans-CN": "非常差", + "zh-Hant-HK": "非常差", + "en-US": "Very Poor", + }, + recommendation: { + "zh-Hans-CN": "所有人应减少户外活动。", + "zh-Hant-HK": "所有人應減少戶外活動。", + "en-US": "Everyone should reduce outdoor exertion.", + }, + }, + { + range: [50, 60], + glyph: "aqi.high", + colors: ["#7E0023"], + categoryName: { + "zh-Hans-CN": "极差", + "zh-Hant-HK": "極差", + "en-US": "Extremely Poor", + }, + recommendation: { + "zh-Hans-CN": "所有人应减少户外体力消耗,敏感人群应避免户外体力消耗。", + "zh-Hant-HK": "所有人應減少戶外體力消耗,敏感人群應避免戶外體力消耗。", + "en-US": "Everyone should reduce outdoor physical exertion. Sensitive groups should avoid outdoor physical exertion.", + }, + }, + ], + gradient: { + // Dense interpolated stops avoid category-wide plateaus even when a + // renderer draws the supplied colors as short discrete segments. + stops: Array.from({ length: 121 }, (_, i) => { + const colors = ["1E88E5", "00C853", "FFEB3B", "FF9800", "F44336", "7E0023"]; + const position = i / 24, band = Math.min(4, Math.floor(position)), fraction = position - band; + const channels = [0, 2, 4].map(offset => { + const a = Number.parseInt(colors[band].slice(offset, offset + 2), 16); + const b = Number.parseInt(colors[band + 1].slice(offset, offset + 2), 16); + return Math.round(a + (b - a) * fraction).toString(16).padStart(2, "0"); + }); + return { location: i / 2, color: `#${channels.join("")}`.toUpperCase() }; + }), + }, +}; + +// ─── UBA 德国 LQI ────────────────────────────────────────────────────────────── + +const UBA = { + displayName: { + "zh-Hans-CN": "德国空气质量指数", + "zh-Hant-HK": "德國空氣質量指數", + "en-US": "German Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "LQI", + "zh-Hant-HK": "LQI", + "en-US": "LQI", + }, + longDisplayName: { + "zh-Hans-CN": "德国 (UBA LQI)", + "zh-Hant-HK": "德國 (UBA LQI)", + "en-US": "Germany (UBA LQI)", + }, + categories: [ + { + range: [0, 0.99], + glyph: "aqi.low", + colors: ["#00C853"], + categoryName: { + "zh-Hans-CN": "非常好", + "zh-Hant-HK": "非常好", + "en-US": "Very Good", + }, + recommendation: { + "zh-Hans-CN": "可以自由进行户外活动。", + "zh-Hant-HK": "可以自由進行戶外活動。", + "en-US": "You can freely engage in outdoor activities.", + }, + }, + { + range: [1, 1.99], + glyph: "aqi.low", + colors: ["#64DD17"], + categoryName: { + "zh-Hans-CN": "好", + "zh-Hant-HK": "好", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "可以自由进行户外活动。", + "zh-Hant-HK": "可以自由進行戶外活動。", + "en-US": "You can usually engage in outdoor activities without restriction.", + }, + }, + { + range: [2, 2.99], + glyph: "aqi.medium", + colors: ["#FFEB3B"], + categoryName: { + "zh-Hans-CN": "中等", + "zh-Hant-HK": "中等", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应减少户外活动。", + "zh-Hant-HK": "敏感人群應減少戶外活動。", + "en-US": "Sensitive individuals should reduce outdoor exertion.", + }, + }, + { + range: [3, 3.99], + glyph: "aqi.high", + colors: ["#FF9800"], + categoryName: { + "zh-Hans-CN": "差", + "zh-Hant-HK": "差", + "en-US": "Poor", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应避免户外活动。", + "zh-Hant-HK": "敏感人群應避免戶外活動。", + "en-US": "Sensitive groups should avoid outdoor exertion.", + }, + }, + { + range: [4, 4], + glyph: "aqi.high", + colors: ["#F44336"], + categoryName: { + "zh-Hans-CN": "非常差", + "zh-Hant-HK": "非常差", + "en-US": "Very Poor", + }, + recommendation: { + "zh-Hans-CN": "所有人应减少户外活动。", + "zh-Hant-HK": "所有人應減少戶外活動。", + "en-US": "Everyone should reduce outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 1, color: "#00C853" }, + { location: 2, color: "#64DD17" }, + { location: 3, color: "#FFEB3B" }, + { location: 4, color: "#FF9800" }, + { location: 5, color: "#F44336" }, + ], + }, +}; + +// ─── HK AQHI ─────────────────────────────────────────────────────────────────── + +const HK_AQHI = { + displayName: { + "zh-Hant-HK": "AQHI (HK)", + "zh-Hans-CN": "AQHI (HK)", + "en-US": "AQHI (HK)", + }, + shortDisplayName: { + "zh-Hant-HK": "AQHI", + "zh-Hans-CN": "AQHI", + "en-US": "AQHI", + }, + longDisplayName: { + "zh-Hant-HK": "香港 (AQHI)", + "zh-Hans-CN": "香港 (AQHI)", + "en-US": "Hong Kong (AQHI)", + }, + categories: [ + // 低 (1-3) + { + range: [1, 3], + glyph: "aqi.low", + colors: ["#04DE71", "#04DE71", "#58E156"], + categoryName: { + "zh-Hant-HK": "低", + "zh-Hans-CN": "低", + "en-US": "Low", + }, + recommendation: { + "zh-Hant-HK": "可如常活動。", + "zh-Hans-CN": "可如常活动。", + "en-US": "No response action is required.", + }, + }, + // 中 (4-6) + { + range: [4, 6], + glyph: "aqi.medium", + colors: ["#FFE620", "#FFBE10", "#FF9500"], + categoryName: { + "zh-Hant-HK": "中", + "zh-Hans-CN": "中", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hant-HK": "一般可如常活動,但個別出現症狀的人士應考慮減少戶外體力消耗。", + "zh-Hans-CN": "一般可如常活动,但個別出现症状的人士应考虑减少户外体力消耗。", + "en-US": "No response action is normally required. Individuals who are experiencing symptoms are advised to consider reducing outdoor physical exertion.", + }, + }, + // 高 (7) + { + range: [7, 7], + glyph: "aqi.high", + colors: ["#FA114F"], + categoryName: { + "zh-Hant-HK": "高", + "zh-Hans-CN": "高", + "en-US": "High", + }, + recommendation: { + "zh-Hant-HK": "心臟病或呼吸系統疾病患者、兒童及長者應減少戶外體力消耗。", + "zh-Hans-CN": "心脏病或呼吸系统疾病患者、儿童及长者应减少户外体力消耗。", + "en-US": "People with existing heart or respiratory illnesses, Children and the elderly are advised to reduce outdoor physical exertion.", + }, + }, + // 甚高 (8-10) + { + range: [8, 10], + glyph: "aqi.high", + colors: ["#D11343", "#A91537", "#80172B"], + categoryName: { + "zh-Hant-HK": "甚高", + "zh-Hans-CN": "甚高", + "en-US": "Very High", + }, + recommendation: { + "zh-Hant-HK": "心臟病或呼吸系統疾病患者、兒童及長者應盡量減少戶外體力消耗。", + "zh-Hans-CN": "心脏病或呼吸系统疾病患者、儿童及长者应尽量减少户外体力消耗。", + "en-US": "People with existing heart or respiratory illnesses, Children and the elderly are advised to reduce to the minimum outdoor physical exertion.", + }, + }, + // 严重 (11) + { + range: [11, 11], + glyph: "aqi.high", + colors: ["#80172B"], + categoryName: { + "zh-Hant-HK": "嚴重", + "zh-Hans-CN": "严重", + "en-US": "Serious", + }, + recommendation: { + "zh-Hant-HK": "心臟病或呼吸系統疾病患者、兒童及長者應避免戶外體力消耗。", + "zh-Hans-CN": "心脏病或呼吸系统疾病患者、儿童及长者应避免户外体力消耗。", + "en-US": "People with existing heart or respiratory illnesses, Children and the elderly are advised to avoid outdoor physical exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 1, color: "#04DE71" }, + { location: 2.5, color: "#04DE71" }, + { location: 4, color: "#FFE620" }, + { location: 6, color: "#FF9500" }, + { location: 7, color: "#FA114F" }, + { location: 8, color: "#D11343" }, + { location: 9.5, color: "#80172B" }, + ], + }, +}; + +// ─── CA AQHI ─────────────────────────────────────────────────────────────────── + +/** Canada AQHI health-risk bands: 1–3 low, 4–6 moderate, 7–10 high, 10+ very high. + * Source: Health Canada, Air Quality Health Index categories and health messages: + * https://www.canada.ca/en/environment-climate-change/services/air-quality-health-index/understanding-messages.html + * AirQuality.Config.Scales.CA_AQHI encodes 10+ as category/index 11. The two + * AQHI_Multi_CN algorithms also emit CA.AQHI upstream, sharing this display + * scale; the index provider's metadata continues to identify their algorithm. + * These are display-index ranges, not the internal excess-risk calculation. + */ +const CA_AQHI = { + displayName: { + "zh-Hans-CN": "空气质量健康指数", + "zh-Hant-HK": "空氣質素健康指數", + "en-US": "Air Quality Health Index", + }, + shortDisplayName: { + "zh-Hans-CN": "AQHI", + "zh-Hant-HK": "AQHI", + "en-US": "AQHI", + }, + longDisplayName: { + "zh-Hans-CN": "加拿大 (AQHI)", + "zh-Hant-HK": "加拿大 (AQHI)", + "en-US": "Canada (AQHI)", + }, + categories: [ + { + range: [1, 3], + glyph: "aqi.low", + colors: ["#00CCFF", "#0099CC", "#006699"], + categoryName: { + "zh-Hans-CN": "低健康风险", + "zh-Hant-HK": "低健康風險", + "en-US": "Low Health Risk", + }, + recommendation: { + "zh-Hans-CN": "适宜进行户外活动。", + "zh-Hant-HK": "適宜進行戶外活動。", + "en-US": "Enjoy your usual outdoor activities.", + }, + }, + { + range: [4, 6], + glyph: "aqi.medium", + colors: ["#FFFF00", "#FFCC00", "#FF9900"], + categoryName: { + "zh-Hans-CN": "中等健康风险", + "zh-Hant-HK": "中等健康風險", + "en-US": "Moderate Health Risk", + }, + recommendation: { + "zh-Hans-CN": "敏感人群如出现症状,应考虑减少或调整剧烈户外活动。一般人群如无症状,可如常活动。", + "zh-Hant-HK": "敏感人群如出現症狀,應考慮減少或調整劇烈戶外活動。一般人群如無症狀,可如常活動。", + "en-US": "At-risk people should consider reducing or rescheduling strenuous outdoor activities if experiencing symptoms. Others may continue usual activities unless experiencing symptoms.", + }, + }, + { + range: [7, 10], + glyph: "aqi.high", + colors: ["#FF6666", "#FF0000", "#CC0000", "#990000"], + categoryName: { + "zh-Hans-CN": "高健康风险", + "zh-Hant-HK": "高健康風險", + "en-US": "High Health Risk", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应减少或调整剧烈户外活动,儿童及长者也应适当减少活动。一般人群如出现症状,应考虑减少剧烈户外活动。", + "zh-Hant-HK": "敏感人群應減少或調整劇烈戶外活動,兒童及長者也應適當減少活動。一般人群如出現症狀,應考慮減少劇烈戶外活動。", + "en-US": "At-risk people should reduce or reschedule strenuous outdoor activities; children and seniors should also take it easy. Others should consider reducing strenuous outdoor activities if experiencing symptoms.", + }, + }, + { + range: [11, 11], + glyph: "aqi.high", + colors: ["#660000"], + categoryName: { + "zh-Hans-CN": "极高健康风险", + "zh-Hant-HK": "極高健康風險", + "en-US": "Very High Health Risk", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应避免剧烈户外活动,儿童及长者也应避免户外体力消耗。一般人群应减少或调整剧烈户外活动。", + "zh-Hant-HK": "敏感人群應避免劇烈戶外活動,兒童及長者也應避免戶外體力消耗。一般人群應減少或調整劇烈戶外活動。", + "en-US": "At-risk people should avoid strenuous outdoor activities; children and seniors should also avoid outdoor physical exertion. Others should reduce or reschedule strenuous outdoor activities.", + }, + }, + ], + gradient: { + stops: ["#00CCFF", "#0099CC", "#006699", "#FFFF00", "#FFCC00", "#FF9900", "#FF6666", "#FF0000", "#CC0000", "#990000", "#660000"] + .map((color, index) => ({ location: index + 1, color })), + }, +}; + +// ─── CN AQHI ─────────────────────────────────────────────────────────────────── + +const CN_AQHI = { + displayName: { + "zh-Hans-CN": "AQHI (CN)", + }, + shortDisplayName: { + "zh-Hans-CN": "AQHI", + }, + longDisplayName: { + "zh-Hans-CN": "中国 (AQHI)", + }, + categories: [ + { + range: [1, 1], + glyph: "aqi.low", + colors: ["#2094FA"], + categoryName: { "zh-Hans-CN": "极低" }, + recommendation: { "zh-Hans-CN": "适宜进行户外活动。" }, + }, + { + range: [2, 3], + glyph: "aqi.low", + colors: ["#04DE71", "#CCFF66"], + categoryName: { "zh-Hans-CN": "低" }, + recommendation: { "zh-Hans-CN": "正常进行户外活动。心肺疾病患者可遵照医嘱进行身体锻炼。" }, + }, + { + range: [4, 5], + glyph: "aqi.medium", + colors: ["#FFE620", "#FF9500"], + categoryName: { "zh-Hans-CN": "中" }, + recommendation: { "zh-Hans-CN": "心肺疾病患者应减少长时间、高强度的户外活动,并遵照医嘱进行身体锻炼。" }, + }, + { + range: [6, 10], + glyph: "aqi.high", + colors: ["#FA114F", "#DC1346", "#BD143D", "#9F1634", "#80172B"], + categoryName: { "zh-Hans-CN": "高" }, + recommendation: { "zh-Hans-CN": "心肺疾病患者、老人、儿童及孕妇应尽量减少户外活动。一般人群应减少长时间、高强度的户外活动。" }, + }, + { + range: [11, 11], + glyph: "aqi.high", + colors: ["#80172B"], + categoryName: { "zh-Hans-CN": "极高" }, + recommendation: { "zh-Hans-CN": "心肺疾病患者、老人、儿童及孕妇应避免户外活动。一般人群应尽量减少户外活动。" }, + }, + ], + gradient: { + stops: [ + { location: 1, color: "#2094FA" }, + { location: 2, color: "#04DE71" }, + { location: 3.5, color: "#CCFF66" }, + { location: 4, color: "#FFE620" }, + { location: 6, color: "#FA114F" }, + { location: 8, color: "#BD143D" }, + { location: 10, color: "#80172B" }, + ], + }, +}; + +// ─── 工具函数 ────────────────────────────────────────────────────────────────── + +/** + * 从三键 i18n map 中根据语言标签获取字符串。 + * @param {Record} map + * @param {string} lang + * @returns {string} + */ +function i18n(map, lang) { + return map[lang] ?? (lang === "zh-Hant-TW" ? map["zh-Hant-HK"] : undefined) ?? map["en-US"] ?? ""; +} + +/** + * 将请求语言映射为响应中的地区语言标签。 + * @param {string} language + * @returns {"zh-HK"|"zh-CN"|"zh-TW"|"en"} + */ +function normalizeScaleLanguage(language) { + if (/^zh-Hant-HK$/i.test(language)) return "zh-HK"; + if (/^zh-Hant/i.test(language)) return "zh-TW"; + if (/^zh/i.test(language)) return "zh-CN"; + return "en"; +} + +function parseScaleIdentity(scaleName) { + // Only a trailing numeric component is a version. AQHI and EAQI are part + // of the standard's name, including when the algorithm emits no version. + const value = `${scaleName || ""}`; + const match = /^(.*)\.(\d+)$/.exec(value); + return { name: match ? match[1] : value, version: match ? Number(match[2]) : 1 }; +} + +// ─── AirQualityScale 类 ──────────────────────────────────────────────────────── + +export default class AirQualityScale { + /** + * 将 Apple WeatherKit BCP47 语言标签规范化为三键形式。 + * @param {string} language + * @returns {"zh-Hans-CN"|"zh-Hant-HK"|"zh-Hant-TW"|"en-US"} + */ + static normalizeLanguage(language) { + if (/zh-Hant-HK/i.test(language)) return "zh-Hant-HK"; + if (/^zh-Hant/i.test(language)) return "zh-Hant-TW"; + if (/^zh/i.test(language)) return "zh-Hans-CN"; + return "en-US"; + } + + /** + * 统一入口:根据 scaleName 构建对应的标尺响应。 + * @param {string} language - 请求的语言标签 + * @param {string} scaleName - 标尺名称,如 "HJ6332012.2604"、"EPA_NowCast.2604" + * @returns {{ status: number, headers: Record, body: string } | null} + */ + static buildScale(language, scaleName) { + Console.debug("☑️ AirQualityScale.buildScale", `language: ${language}`, `scaleName: ${scaleName}`); + + const scaleBaseName = parseScaleIdentity(scaleName).name; + Console.debug(`scaleBaseName: ${scaleBaseName}`); + + let result = null; + switch (scaleBaseName) { + case "HJ6332012": + case "HJ6332025_DRAFT": + result = AirQualityScale.#buildGenericScale(language, scaleName, HJ6332012); + break; + case "EPA_NowCast": + result = AirQualityScale.#buildGenericScale(language, scaleName, EPA_NOWCAST); + break; + case "EU.EAQI": + case "EAQI": + result = AirQualityScale.#buildGenericScale(language, scaleName, EU_EAQI); + break; + case "UBA": + result = AirQualityScale.#buildGenericScale(language, scaleName, UBA); + break; + case "HK.AQHI": + result = AirQualityScale.#buildAQHIScale(language, scaleName, HK_AQHI); + break; + case "CA.AQHI": + result = AirQualityScale.#buildAQHIScale(language, scaleName, CA_AQHI); + break; + case "CN.AQHI": + result = AirQualityScale.#buildAQHIScale(language, scaleName, CN_AQHI); + break; + default: + Console.warn("AirQualityScale.buildScale", `未知的 scale: ${scaleName},透传到 Apple`); + result = null; + break; + } + + Console.debug("✅ AirQualityScale.buildScale", result ? "已构建本地响应" : "透传"); + return result; + } + + /** + * 构建通用 AQI 标尺(HJ6332012、EPA_NowCast、EU_EAQI、UBA)。 + * @param {string} language + * @param {string} scaleName + * @param {object} scaleDef - 标尺定义常量 + * @returns {{ status: number, headers: Record, body: string }} + */ + static #buildGenericScale(language, scaleName, scaleDef) { + const normalizeLang = AirQualityScale.normalizeLanguage(language); + const scaleIdentity = parseScaleIdentity(scaleName); + + const categories = []; + for (const band of scaleDef.categories) { + const [min, max] = band.range; + // 对于整数范围(AQI 0-50, 51-100 等),每个 category 一个条目 + // 对于小数范围(UBA 0-0.99, 1-1.99 等),也每个 category 一个条目 + categories.push({ + categoryNumber: categories.length + 1, + // UBA's public index is the category (1..5), while its + // pollutant interpolation uses the internal 0..4 ranges. + range: scaleDef === UBA ? [categories.length + 1, categories.length + 1] + : [min, max === Number.POSITIVE_INFINITY ? 9999 : max], + color: band.colors[0], + categoryName: i18n(band.categoryName, normalizeLang), + recommendation: i18n(band.recommendation, normalizeLang), + glyph: band.glyph, + }); + } + + const scale = { + name: scaleIdentity.name, + displayName: i18n(scaleDef.displayName, normalizeLang), + shortDisplayName: i18n(scaleDef.shortDisplayName, normalizeLang), + longDisplayName: i18n(scaleDef.longDisplayName, normalizeLang), + displayLabel: i18n(SCALE_DISPLAY_LABEL, normalizeLang), + language: normalizeScaleLanguage(language), + version: scaleIdentity.version, + aqi: { + numerical: scaleDef !== EU_EAQI, + ascending: true, + range: scaleDef === UBA ? [1, 5] : scaleDef === EU_EAQI ? [0, 60] : [0, 500], + categories, + gradient: scaleDef.gradient, + }, + }; + + return { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store, max-age=0", + }, + body: JSON.stringify(scale), + }; + } + + /** + * 构建 AQHI 标尺(HK、CA、CN)。 + * @param {string} language + * @param {string} scaleName + * @param {object} scaleDef - AQHI 标尺定义常量 + * @returns {{ status: number, headers: Record, body: string }} + */ + static #buildAQHIScale(language, scaleName, scaleDef) { + const normalizeLang = AirQualityScale.normalizeLanguage(language); + const lang = normalizeLang === "zh-Hant-TW" ? "zh-Hant-HK" : normalizeLang; + const scaleIdentity = parseScaleIdentity(scaleName); + + const categories = []; + for (const band of scaleDef.categories) { + const [min, max] = band.range; + for (let idx = min; idx <= max; idx++) { + categories.push({ + categoryNumber: idx, + range: [idx, idx], + color: band.colors[idx - min], + categoryName: i18n(band.categoryName, lang), + recommendation: i18n(band.recommendation, lang), + glyph: band.glyph, + }); + } + } + + const scale = { + name: scaleIdentity.name, + displayName: i18n(scaleDef.displayName, lang), + shortDisplayName: i18n(scaleDef.shortDisplayName, lang), + longDisplayName: i18n(scaleDef.longDisplayName, lang), + displayLabel: i18n(SCALE_DISPLAY_LABEL, normalizeLang), + language: normalizeScaleLanguage(language), + version: scaleIdentity.version, + aqi: { + numerical: true, + ascending: true, + range: [1, 11], + categories, + gradient: scaleDef.gradient, + }, + }; + + return { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store, max-age=0", + }, + body: JSON.stringify(scale), + }; + } +} diff --git a/src/wk3/class/ColorfulClouds.mjs b/src/wk3/class/ColorfulClouds.mjs new file mode 100644 index 00000000..b100db7d --- /dev/null +++ b/src/wk3/class/ColorfulClouds.mjs @@ -0,0 +1,692 @@ +import { Console, fetch } from "../utils/index.mjs"; +import AirQuality from "./AirQuality.mjs"; +import ForecastNextHour from "./ForecastNextHour.mjs"; +import Weather from "./Weather.mjs"; + +export default class ColorfulClouds { + constructor(parameters, token) { + this.Name = "ColorfulClouds"; + this.Version = "4.1.0"; + Console.debug(`🟧 ${this.Name} v${this.Version}`); + this.endpoint = `https://api.caiyunapp.com/v2.6/${token}/${parameters.longitude},${parameters.latitude}`; + this.headers = { Referer: "https://caiyunapp.com/" }; + this.version = parameters.version; + this.language = parameters.language; + this.latitude = parameters.latitude; + this.longitude = parameters.longitude; + this.country = parameters.country; + } + + #cache = { + realtimePromise: null, + yesterdayHourly: undefined, + }; + + #Config = { + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + }, + Availability: { + Minutely: [ + "CN", + "HK", + "MO", + "TW", + "IT", + "LT", + "MT", + "FR", + "SK", + "NO", + "BY", + "IS", + "CZ", + "SI", + "DE", + "ES", + "UA", + "DK", + "PL", + "FI", + "SE", + "HR", + "RU", + "RO", + "PT", + "EE", + "RS", + "AT", + "GR", + "HU", + "FJ", + "GU", + "MH", + "NC", + "TR", + "BH", + "SA", + "ID", + "IR", + "SG", + "OM", + "PH", + "IN", + "KH", + "CY", + "MY", + "VN", + "KW", + "TH", + "KR", + "KP", + "CA", + "BS", + "KY", + "MX", + "PA", + "MQ", + "CU", + "BM", + "PR", + "CW", + "GP", + "NI", + "BR", + "GF", + "CO", + "GY", + "PY", + "AR", + ], + AirQuality: ["CN", "HK", "MO"], + }, + }; + + async #RealTime() { + Console.debug("☑️ RealTime"); + + if (!this.#cache.realtimePromise) { + const request = { + url: `${this.endpoint}/realtime`, + headers: this.headers, + }; + this.#cache.realtimePromise = fetch(request) + .then(response => JSON.parse(response?.body ?? "{}")) + .then(body => { + if (body?.status === "ok" && body?.result?.realtime?.status === "ok") { + return body; + } + throw Error(JSON.stringify(body ?? {})); + }) + .catch(error => { + Console.error(`RealTime: ${error}`); + this.#cache.realtimePromise = null; + return {}; + }); + } else { + Console.debug("✅ RealTime", "Using cache promise"); + } + return this.#cache.realtimePromise; + } + + async Minutely() { + Console.debug("☑️ Minutely"); + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.Minutely.includes(this.country)) { + Console.warn("Minutely", `Unsupported country: ${this.country}`); + return; + } + + const request = { + url: `${this.endpoint}/minutely?unit=metric:v2`, + headers: this.headers, + }; + let forecastNextHour; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.status) { + case "ok": + switch (body?.result?.minutely?.status) { + case "ok": { + const timeStamp = (Date.now() / 1000) | 0; + const metadata = { + attributionUrl: "https://www.caiyunapp.com/h5", + expireTime: timeStamp + ForecastNextHour.ExpirationInterval, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: body?.location?.[0], + longitude: body?.location?.[1], + + providerName: "彩云天气", + readTime: timeStamp, + reportedTime: body?.server_time, + temporarilyUnavailable: false, + sourceType: "MODELED", + }; + body.result.minutely.probability = body.result.minutely.probability.map(probability => Math.round(probability * 100)); + let minuteStemp = new Date(body?.server_time * 1000).setSeconds(0, 0); + minuteStemp = minuteStemp.valueOf() / 1000 - 60; + forecastNextHour = { + metadata: metadata, + condition: [], + forecastEnd: 0, + forecastStart: minuteStemp, + minutes: body?.result?.minutely?.precipitation_2h?.map((precipitationIntensity, index) => { + const minute = { + perceivedPrecipitationIntensity: 0, + precipitationChance: 0, + precipitationIntensity: precipitationIntensity, + startTime: minuteStemp + 60 * index, + }; + if (index < 30) minute.precipitationChance = body?.result?.minutely?.probability?.[0]; + else if (index < 60) minute.precipitationChance = body?.result?.minutely?.probability?.[1]; + else if (index < 90) minute.precipitationChance = body?.result?.minutely?.probability?.[2]; + else minute.precipitationChance = body?.result?.minutely?.probability?.[3]; + return minute; + }), + summary: [], + }; + forecastNextHour.minutes.length = Math.min(85, forecastNextHour.minutes.length); + forecastNextHour.forecastEnd = minuteStemp + 60 * forecastNextHour.minutes.length; + forecastNextHour.minutes = ForecastNextHour.Minute(forecastNextHour.minutes, body?.result?.minutely?.description, "mmph"); + forecastNextHour.summary = ForecastNextHour.Summary(forecastNextHour.minutes); + forecastNextHour.condition = ForecastNextHour.Condition(forecastNextHour.summary); + break; + } + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify({ status: body?.result?.minutely?.status, reason: body?.result?.minutely })); + } + break; + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify(body ?? {})); + } + } catch (error) { + Console.error(`Minutely: ${error}`); + } finally { + //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); + Console.debug("✅ Minutely"); + } + return forecastNextHour; + } + + async #Hourly(hourlysteps = 273, begin = undefined) { + Console.debug("☑️ Hourly", `hourlysteps: ${hourlysteps}`, `begin: ${begin}`); + const request = { + url: `${this.endpoint}/hourly?hourlysteps=${hourlysteps}`, + headers: this.headers, + }; + if (begin) request.url += `&begin=${begin}`; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.status) { + case "ok": + switch (body?.result?.hourly?.status) { + case "ok": { + Console.debug("✅ Hourly"); + return body; + } + case "error": + case undefined: + throw Error(JSON.stringify({ status: body?.result?.hourly?.status, reason: body?.result?.hourly })); + } + break; + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify(body ?? {})); + } + } catch (error) { + Console.error(`Hourly: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(this.airQuality, null, 2)}`); + Console.debug("✅ Hourly"); + } + return {}; + } + + async Daily(dailysteps = 10, begin = undefined) { + Console.debug("☑️ Daily"); + const request = { + url: `${this.endpoint}/daily?dailysteps=${dailysteps}`, + headers: this.headers, + }; + if (begin) request.url += `&begin=${begin}`; + let forecastDaily; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.status) { + case "ok": + switch (body?.result?.daily?.status) { + case "ok": { + const timeStamp = (Date.now() / 1000) | 0; + const metadata = { + attributionUrl: "https://www.caiyunapp.com/h5", + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, + latitude: body?.location?.[0], + longitude: body?.location?.[1], + + providerName: "彩云天气", + readTime: timeStamp, + reportedTime: body?.server_time, + temporarilyUnavailable: false, + sourceType: "STATION", + }; + forecastDaily = { + metadata: metadata, + days: [], + }; + for (let i = 0; i < dailysteps; i++) { + const timeStamp = (new Date(body?.result?.daily?.skycon?.[i]?.date).getTime() / 1000) | 0; + forecastDaily.days.push({ + forecastStart: timeStamp, + forecastEnd: timeStamp + 24 * 3600, // 24 hours + conditionCode: Weather.ConvertWeatherCode(body?.result?.daily?.skycon?.[i]?.value), + humidityMax: Math.round(body?.result?.daily?.humidity?.[i]?.max * 100), + humidityMin: Math.round(body?.result?.daily?.humidity?.[i]?.min * 100), + // maxUvIndex: Weather.ConvertDSWRF(body?.result?.daily?.dswrf?.[i]?.max), // ConvertDSWRF 转换不准确 + // moonPhase: "", // Not given + // moonrise: body?.result?.daily?.astro?.[i].sunset.time, // Not given + // moonset: body?.result?.daily?.astro?.[i].sunrise.time, // Not given + precipitationAmount: body?.result?.daily?.precipitation?.[i]?.avg, + // precipitationAmountByType: [], // Not given + precipitationChance: body?.result?.daily?.precipitation?.[i]?.probability, + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // solarMidnight: 0, // Not given + // solarNoon: 0, // Not given + //sunrise: body?.result?.daily?.astro?.[i].sunrise.time, // 未转换 + // sunriseAstronomical: 0, // Not given + // sunriseCivil: 0, // Not given + // sunriseNautical: 0, // Not given + //sunset: body?.result?.daily?.astro?.[i].sunset.time, // 未转换 + // sunsetAstronomical: 0, // Not given + // sunsetCivil: 0, // Not given + // sunsetNautical: 0, // Not given + temperatureMax: body?.result?.daily?.temperature?.[i]?.max, + // temperatureMaxTime: 0, // Not given + temperatureMin: body?.result?.daily?.temperature?.[i]?.min, + // temperatureMinTime: 0, // Not given + visibilityMax: body?.result?.daily?.visibility?.[i]?.max * 1000, + visibilityMin: body?.result?.daily?.visibility?.[i]?.min * 1000, + // windGustSpeedMax: 0, // Not given + windSpeedAvg: body?.result?.daily?.wind?.[i]?.avg?.speed, + windSpeedMax: body?.result?.daily?.wind?.[i]?.max?.speed, + daytimeForecast: { + forecastStart: timeStamp + 8 * 3600, // 8 hours + forecastEnd: timeStamp + 8 * 3600 + 12 * 3600, // 8 + 12 hours + cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(body?.result?.daily?.skycon_08h_20h?.[i]?.value), + // humidityMax: Math.round(body?.result?.daily?.humidity?.[i]?.max * 100), // Not given + // humidityMin: Math.round(body?.result?.daily?.humidity?.[i]?.min * 100), // Not given + precipitationAmount: body?.result?.daily?.precipitation_08h_20h?.[i]?.avg, + // precipitationAmountByType: [], // Not given + precipitationChance: body?.result?.daily?.precipitation_08h_20h?.[i]?.probability, + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + temperatureMax: body?.result?.daily?.temperature_08h_20h?.[i]?.max, + temperatureMin: body?.result?.daily?.temperature_08h_20h?.[i]?.min, + // visibilityMax: body?.result?.daily?.visibility?.[i]?.max * 1000, // Not given + // visibilityMin: body?.result?.daily?.visibility?.[i]?.min * 1000, // Not given + windDirection: body?.result?.daily?.wind_08h_20h?.[i]?.avg?.direction, + // windGustSpeedMax: 0, // Not given + windSpeed: body?.result?.daily?.wind_08h_20h?.[i]?.avg?.speed, + windSpeedMax: body?.result?.daily?.wind_08h_20h?.[i]?.max?.speed, + }, + overnightForecast: { + forecastStart: timeStamp + 20 * 3600, // 20 hours + forecastEnd: timeStamp + 20 * 3600 + 12 * 3600, // 20 + 12 hours + cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(body?.result?.daily?.skycon_20h_32h?.[i]?.value), + // humidityMax: Math.round(body?.result?.daily?.humidity?.[i]?.max * 100), // Not given + // humidityMin: Math.round(body?.result?.daily?.humidity?.[i]?.min * 100), // Not given + precipitationAmount: body?.result?.daily?.precipitation_20h_32h?.[i]?.avg, + // precipitationAmountByType: [], // Not given + precipitationChance: body?.result?.daily?.precipitation_20h_32h?.[i]?.probability, + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + temperatureMax: body?.result?.daily?.temperature_20h_32h?.[i]?.max, + temperatureMin: body?.result?.daily?.temperature_20h_32h?.[i]?.min, + // visibilityMax: body?.result?.daily?.visibility?.[i]?.max * 1000, // Not given + // visibilityMin: body?.result?.daily?.visibility?.[i]?.min * 1000, // Not given + windDirection: body?.result?.daily?.wind_20h_32h?.[i]?.avg?.direction, + // windGustSpeedMax: 0, // Not given + windSpeed: body?.result?.daily?.wind_20h_32h?.[i]?.avg?.speed, + windSpeedMax: body?.result?.daily?.wind_20h_32h?.[i]?.max?.speed, + }, + }); + } + break; + } + case "error": + case undefined: + throw Error(JSON.stringify({ status: body?.result?.daily?.status, reason: body?.result?.daily })); + } + break; + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify(body ?? {})); + } + } catch (error) { + Console.error(`Daily: ${error}`); + } finally { + //Console.debug(`Daily: ${JSON.stringify(Daily, null, 2)}`); + Console.debug("✅ Daily"); + } + return forecastDaily; + } + + #Metadata(reportedTime, location = [this.latitude, this.longitude], temporarilyUnavailable = false) { + const timeStamp = Math.trunc(Date.now() / 1000); + const [latitude, longitude] = location; + return { + longitude, + providerName: "彩云天气", + reportedTime: reportedTime ?? timeStamp, + latitude, + expireTime: timeStamp + 60 * 60, + attributionUrl: "https://www.caiyunapp.com/h5", + temporarilyUnavailable, + readTime: timeStamp, + sourceType: "MODELED", + }; + } + + /** + * 创建 WeatherKit 格式的污染物对象 + * @link https://docs.caiyunapp.com/weather-api/v2/v2.6/1-realtime.html + * @param {Object} pollutantsObj - 污染物对象 + * @param {String} [scale] - AQI 标准(如 HJ6332012) + * @returns {Object} 修复后的污染物对象 + */ + #CreatePollutants(pollutantsObj = {}, scale = "") { + Console.debug("☑️ CreatePollutants"); + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = Object.entries(pollutantsObj) + .map(([name, amount]) => { + switch (name) { + case "co": + return { + amount: AirQuality.ConvertUnit(amount ?? -1, mgm3, ugm3), + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + index: scale === "HJ6332012" ? pollutantsObj.co_iaqi_chn : undefined, + }; + case "no": + case "no2": + case "so2": + case "o3": + case "nox": + case "pm25": + case "pm10": + return { + amount: amount ?? -1, + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + index: scale === "HJ6332012" ? pollutantsObj[`${name}_iaqi_chn`] : undefined, + }; + default: + return null; + } + }) + .filter(Boolean); + + Console.debug("✅ CreatePollutants"); + return pollutants; + } + + /** + * 获取当前空气质量并转换为 WeatherKit 风格结构。 + * + * 逻辑概览: + * 1) 校验国家/地区是否支持空气质量; + * 2) 拉取 realtime 数据并做可用性检查; + * 3) 构建通用空气质量基础结构(metadata/pollutants); + * 4) 按 useUsa 选择 US 或 CN 口径生成 index、categoryIndex 与 primaryPollutant。 + * + * @param {boolean} [useUsa=true] + * 是否使用美国 AQI 口径。 + * - true: 使用 `EPA_NowCast`(usa); + * - false: 使用 `HJ6332012`(chn)。 + * + * @param {boolean} [forcePrimaryPollutant=true] + * 在 CN 口径下是否强制展示主污染物。 + * - true: 始终展示计算得到的主污染物; + * - false: 当主污染物 index <= 50 时返回 `NOT_AVAILABLE`。 + * + * @returns {Promise<{ + * metadata: any, + * pollutants: Array<{amount: number, pollutantType: string, units: string}>, + * previousDayComparison: string, + * categoryIndex?: number, + * index?: number, + * isSignificant?: boolean, + * primaryPollutant?: string, + * scale?: string + * }>} + * 成功时返回完整空气质量对象;不可用时返回 temporarilyUnavailable 的降级对象。 + */ + async CurrentAirQuality(useUsa = true, forcePrimaryPollutant = true) { + Console.debug("☑️ CurrentAirQuality"); + // 统一失败兜底对象,任一关键步骤失败时直接返回。 + const failedAirQuality = { + metadata: this.#Metadata(undefined, undefined, true), + pollutants: [], + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + + // 可用性判断:当前数据源不支持该国家/地区时直接返回兜底结果。 + if (!this.#Config.Availability.AirQuality.includes(this.country)) { + Console.warn("CurrentAirQuality", `Unsupported country: ${this.country}`); + return failedAirQuality; + } + + // 拉取实时数据(内部已带缓存)。 + const realtime = await this.#RealTime(); + + // realtime 主体缺失,视为不可用。 + if (!realtime.result) { + Console.error("CurrentAirQuality", "无法获取realtime数据"); + return failedAirQuality; + } + + // 彩云在不支持位置时 usa 描述为空字符串。 + if (realtime.result.realtime.air_quality.description.usa === "") { + Console.error("CurrentAirQuality", `不支持的位置`); + return failedAirQuality; + } + + // 构建与算法无关的基础空气质量结构。 + const particularAirQuality = { + metadata: this.#Metadata(realtime.result.realtime.air_quality.obs_time, realtime.location), + pollutants: this.#CreatePollutants(realtime.result.realtime.air_quality, useUsa ? undefined : "HJ6332012"), + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + + // US 口径:直接使用 usa AQI,并按 EPA_NowCast 分类。 + if (useUsa) { + const scale = AirQuality.Config.Scales.WAQI_InstantCast_US; + const index = realtime.result.realtime.air_quality.aqi.usa; + const categoryIndex = AirQuality.CategoryIndex(index, scale.categories); + const indexedPollutants = AirQuality.PollutantsToIndexes(particularAirQuality.pollutants, "WAQI_InstantCast_US", { stpConversionFactors: AirQuality.Config.STP_ConversionFactors.US }); + const primaryPollutant = AirQuality.PrimaryPollutant(indexedPollutants, scale.categories); + + const airQuality = { + ...particularAirQuality, + pollutants: indexedPollutants, + categoryIndex, + index, + isSignificant: categoryIndex >= scale.categories.significantIndex, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + Console.debug("✅ CurrentAirQuality"); + return airQuality; + } else { + // CN 口径:使用 chn AQI,并基于分污染物 IAQI 判定主污染物。 + const scale = AirQuality.Config.Scales.HJ6332012; + const index = realtime.result.realtime.air_quality.aqi.chn; + const categoryIndex = AirQuality.CategoryIndex(index, scale.categories); + + const primaryPollutant = AirQuality.PrimaryPollutant(particularAirQuality.pollutants, scale.categories); + // 当不强制展示主污染物且整体空气质量较好(<=50)时,主污染物置为不可用。 + const isNotAvailable = !forcePrimaryPollutant && primaryPollutant.index <= 50; + if (isNotAvailable) { + Console.info("CurrentAirQuality", `Max index of pollutants ${primaryPollutant.pollutantType} = ${primaryPollutant.index} is <= 50, primaryPollutant will be set to NOT_AVAILABLE.`); + } + + const airQuality = { + ...particularAirQuality, + categoryIndex, + index, + isSignificant: categoryIndex >= scale.categories.significantIndex, + primaryPollutant: isNotAvailable ? "NOT_AVAILABLE" : primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + Console.debug("✅ CurrentAirQuality"); + return airQuality; + } + } + + async CurrentWeather() { + Console.debug("☑️ CurrentWeather"); + const realtime = await this.#RealTime(); + if (!realtime.result) { + Console.error("CurrentWeather", "无法获取realtime数"); + return { + metadata: this.#Metadata(undefined, undefined, true), + }; + } + + Console.debug("✅ CurrentWeather"); + return { + metadata: this.#Metadata(realtime.result.server_time, realtime.location), + cloudCover: Math.round(realtime.result.realtime.cloudrate * 100), + conditionCode: Weather.ConvertWeatherCode(realtime.result.realtime.skycon), + humidity: Math.round(realtime.result.realtime.humidity * 100), + // uvIndex: Weather.ConvertDSWRF(body?.result?.realtime?.dswrf), // ConvertDSWRF 转换不准确 + perceivedPrecipitationIntensity: realtime.result.realtime.precipitation.local.intensity, + pressure: realtime.result.realtime.pressure / 100, + temperature: realtime.result.realtime.temperature, + temperatureApparent: realtime.result.realtime.apparent_temperature, + visibility: realtime.result.realtime.visibility * 1000, + windDirection: realtime.result.realtime.wind.direction, + windSpeed: realtime.result.realtime.wind.speed, + }; + } + + /** + * 预取昨日小时数据,供 YesterdayAirQuality 使用 + * 与 WeatherKit fetch 并发,减少总体延迟 + * @returns {Promise} 昨日小时数据 Promise + */ + async prefetchYesterdayHourly() { + Console.debug("☑️ prefetchYesterdayHourly"); + this.#cache.yesterdayHourly = this.#Hourly(1, Math.trunc((Date.now() - 864e5) / 1000)); + const result = await this.#cache.yesterdayHourly; + Console.debug("✅ prefetchYesterdayHourly"); + return result; + } + + async YesterdayAirQuality(useUsa = true) { + Console.debug("☑️ YesterdayAirQuality"); + + const yesterdayHourly = await (this.#cache.yesterdayHourly ?? this.#Hourly(1, Math.trunc((Date.now() - 864e5) / 1000))); + const scale = useUsa ? AirQuality.Config.Scales.EPA_NowCast : AirQuality.Config.Scales.HJ6332012; + const particularAirQuality = { + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + pollutants: [], + primaryPollutant: "NOT_AVAILABLE", + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + + if (!yesterdayHourly.result) { + Console.error("YesterdayAirQuality", "无法获取hourly数据"); + return { + ...particularAirQuality, + categoryIndex: -1, + metadata: this.#Metadata(undefined, undefined, true), + }; + } + + const { usa, chn } = yesterdayHourly.result.hourly.air_quality.aqi[0].value; + if (usa === 0 && chn === 0) { + Console.warn("YesterdayAirQuality", "usa和chn的AQI值都为0,不支持的位置?"); + } + + const index = useUsa ? usa : chn; + const categoryIndex = AirQuality.CategoryIndex(index, scale.categories); + const isSignificant = categoryIndex >= scale.categories.significantIndex; + Console.debug(`index: ${index}`); + + Console.debug("✅ YesterdayAirQuality", `categoryIndex: ${categoryIndex}`); + return { + ...particularAirQuality, + index, + categoryIndex, + isSignificant, + metadata: this.#Metadata(yesterdayHourly.result.server_time, yesterdayHourly.location), + }; + } + + async ForecastHourly(hourlysteps, begin) { + Console.debug("☑️ ForecastHourly"); + const hourly = await this.#Hourly(hourlysteps, begin); + if (!hourly.result) { + Console.error("ForecastHourly", "Failed to get hourly data"); + return { + metadata: this.#Metadata(undefined, undefined, true), + }; + } + + Console.debug("✅ ForecastHourly"); + 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, + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(hourly.result.hourly.skycon[i].value), + // daylight: false, // Not given + forecastStart: (new Date(hourly.result.hourly.skycon[i].datetime).getTime() / 1000) | 0, + humidity: Math.round(hourly.result.hourly.humidity[i].value * 100), + // perceivedPrecipitationIntensity: "", // Not given + precipitationAmount: hourly.result.hourly.precipitation[i].value, + precipitationChance: hourly.result.hourly.precipitation[i].probability, + // precipitationIntensity: 0, // Not given + // precipitationType: "", // Not given + pressure: hourly.result.hourly.pressure[i].value / 100, + // pressureTrend: "", // Not given + // snowfallAmount: 0, // Not given + // snowfallIntensity: 0, // Not given + temperature: hourly.result.hourly.temperature[i].value, + temperatureApparent: hourly.result.hourly.apparent_temperature[i].value, + // temperatureDewPoint: 0, // Not given + // uvIndex: 0, // Not given + visibility: hourly.result.hourly.visibility[i].value * 1000, + windDirection: hourly.result.hourly.wind[i].direction, + // windGust: 0, // Not given + windSpeed: hourly.result.hourly.wind[i].speed, + })), + }; + } +} diff --git a/src/wk3/class/FlatBufferRootProcessor.mjs b/src/wk3/class/FlatBufferRootProcessor.mjs new file mode 100644 index 00000000..e1196ace --- /dev/null +++ b/src/wk3/class/FlatBufferRootProcessor.mjs @@ -0,0 +1,480 @@ +/** + * Vendored from NSRingo/WeatherKit v3.3.1, packages/flatbuffer-root (Apache-2.0). + * https://github.com/NSRingo/WeatherKit/blob/47fa3f2c1fa4807558f3fb0050286ff4e8a08c5b/packages/flatbuffer-root/src/FlatBufferRootProcessor.mjs + * Local change: use the fork's Console utility instead of @nsnanocat/util. + */ +import { Console } from "../utils/index.mjs"; +import { Builder, ByteBuffer } from "flatbuffers"; + +const ROOT_OFFSET_SIZE = 4; +const VTABLE_HEADER_SIZE = 4; +const VTABLE_ENTRY_SIZE = 2; +const OFFSET_FIELD_SIZE = 4; +const MAX_SCALAR_ALIGNMENT = 8; + +/** + * @typedef {object} SlotBinary + * @property {Uint8Array} bytes 二进制 arena / Binary arena. + * @property {number} tablePosition 产品表相对 arena 起点的位置 / Product table position relative to the arena start. + */ + +/** + * @typedef {object} RootSlotFailure + * @property {number} id 根 slot ID / Root slot ID. + * @property {Error} error 读取失败 / Read failure. + */ + +/** + * @typedef {object} RootSlotDictionary + * @property {number} slotCount 根表声明的 slot 数量 / Slot count declared by the root table. + * @property {number} presentSlotCount 根表中实际存在的 slot 数量 / Number of physically present root slots. + * @property {Map} slots 有效 slot 字典 / Valid slot dictionary. + * @property {RootSlotFailure[]} failures 可隔离的 slot 读取失败 / Isolated slot read failures. + */ + +/** + * @typedef {object} FlatBufferRootCodec + * @property {new () => {__init(position: number, byteBuffer: ByteBuffer): unknown}} tableClass 产品表生成类 / Generated product table class. + * @property {(table: unknown) => unknown} decode 产品表解码器 / Product table decoder. + * @property {(builder: Builder, json: unknown) => number} encode 产品表编码器 / Product table encoder. + */ + +/** + * @typedef {object} FlatBufferRootProcessorOptions + * @property {string} name 处理器名称和日志前缀 / Processor name and log prefix. + * @property {Function} rootClass FlatBuffers 生成的根表类 / Generated FlatBuffers root table class. + * @property {Readonly>} codecs 按根 accessor 名称注册的 codec / Codecs keyed by root accessor name. + * @property {readonly string[]} configurableRootNames 受业务开关控制的根名称 / Root names controlled by business settings. + */ + +/** + * 使用生成的根表类配置 FlatBuffer 根 slot 编解码流程。 + * Configure FlatBuffer root-slot encoding and decoding with a generated root table class. + */ +export class FlatBufferRootProcessor { + #name; + #fields; + #fieldsById; + #fieldsByName; + #configurableRootNames; + + /** + * 创建并验证根表处理器配置。 + * Create and validate a root table processor configuration. + * @param {FlatBufferRootProcessorOptions} options - 根表模型、codec 和运行期策略 / Root model, codecs, and runtime policy. + */ + constructor(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("FlatBufferRootProcessor options must be an object"); + + try { + if (typeof options.name !== "string" || options.name.length === 0) throw new TypeError("FlatBufferRootProcessor name must be a non-empty string"); + if (typeof options.rootClass !== "function" || !options.rootClass.prototype || typeof options.rootClass.prototype.__init !== "function") { + throw new TypeError(`${options.name} rootClass must be a generated FlatBuffers table class`); + } + if (!options.codecs || typeof options.codecs !== "object" || Array.isArray(options.codecs)) { + throw new TypeError(`${options.name} codecs must be an object`); + } + if (!Array.isArray(options.configurableRootNames)) throw new TypeError(`${options.name} configurableRootNames must be an array`); + + const rootNames = Object.getOwnPropertyNames(options.rootClass.prototype).filter(name => !["constructor", "__init"].includes(name)); + for (const rootName of rootNames) { + if (typeof options.rootClass.prototype[rootName] !== "function") throw new TypeError(`${options.name} root accessor ${rootName} must be a function`); + const suffix = `${rootName[0].toUpperCase()}${rootName.slice(1)}`; + if (typeof options.rootClass[`add${suffix}`] !== "function") throw new TypeError(`${options.name} root accessor ${rootName} has no matching add${suffix}`); + } + + const rootNameSet = new Set(rootNames); + const codecs = new Map(); + for (const rootName of Object.keys(options.codecs)) { + if (!rootNameSet.has(rootName)) throw new RangeError(`${options.name} codec ${rootName} is not a root accessor`); + const codec = options.codecs[rootName]; + if (!codec || typeof codec !== "object" || Array.isArray(codec)) throw new TypeError(`${options.name} codec ${rootName} must be an object`); + if (typeof codec.tableClass !== "function" || !codec.tableClass.prototype || typeof codec.tableClass.prototype.__init !== "function") { + throw new TypeError(`${options.name} codec ${rootName} tableClass must be a generated FlatBuffers table class`); + } + if (typeof codec.decode !== "function") throw new TypeError(`${options.name} codec ${rootName} must provide decode`); + if (typeof codec.encode !== "function") throw new TypeError(`${options.name} codec ${rootName} must provide encode`); + codecs.set(rootName, Object.freeze({ decode: codec.decode, encode: codec.encode, tableClass: codec.tableClass })); + } + + const configurableRootNames = new Set(); + for (const rootName of options.configurableRootNames) { + if (typeof rootName !== "string" || rootName.length === 0) throw new TypeError(`${options.name} configurableRootNames must contain non-empty strings`); + if (!rootNameSet.has(rootName)) throw new RangeError(`${options.name} configurable root ${rootName} is not a root accessor`); + if (configurableRootNames.has(rootName)) throw new RangeError(`${options.name} configurable root ${rootName} is duplicated`); + configurableRootNames.add(rootName); + } + + const fields = rootNames.map((name, id) => Object.freeze({ codec: codecs.get(name), id, name })); + this.#name = options.name; + this.#fields = Object.freeze(fields); + this.#fieldsById = new Map(fields.map(field => [field.id, field])); + this.#fieldsByName = new Map(fields.map(field => [field.name, field])); + this.#configurableRootNames = configurableRootNames; + } catch (error) { + Console.error(`${typeof options.name === "string" && options.name ? options.name : "FlatBufferRootProcessor"}.configure`, error); + throw error; + } + } + + /** + * 根据处理器配置过滤受业务开关控制的根名称。 + * Filter root names controlled by the processor's business configuration. + * @param {string[]} requestedRootNames - 原请求中的根名称 / Root names from the original request. + * @param {string[]} enabledRootNames - 当前启用的根名称 / Currently enabled root names. + * @returns {string[]} 保持原顺序的过滤结果 / Filtered names preserving their original order. + */ + filterRootNames(requestedRootNames, enabledRootNames) { + try { + this.#assertStringArray(requestedRootNames, "requestedRootNames"); + this.#assertStringArray(enabledRootNames, "enabledRootNames"); + const enabled = new Set(enabledRootNames); + return requestedRootNames.filter(rootName => !this.#configurableRootNames.has(rootName) || enabled.has(rootName)); + } catch (error) { + Console.error(`${this.#name}.filterRootNames`, error); + throw error; + } + } + + /** + * 按物理 slot 顺序解码选中的根产品。 + * Decode selected root products in physical slot order. + * @param {ByteBuffer} byteBuffer - 原始 FlatBuffer / Source FlatBuffer. + * @param {string[]} [rootNames=[]] - 要解码的根名称 / Root names to decode. + * @returns {Record} 已成功解码的根对象 / Successfully decoded root objects. + */ + decode(byteBuffer, rootNames = []) { + try { + this.#assertStringArray(rootNames, "rootNames"); + } catch (error) { + Console.error(`${this.#name}.decode`, error); + throw error; + } + + const requested = new Set(rootNames); + const json = {}; + let dictionary; + try { + dictionary = this.#readRootSlots(byteBuffer); + } catch (error) { + Console.error(`${this.#name}.decode`, error); + throw error; + } + const slotReport = this.#logSlotDictionary("decode", dictionary); + + const presentIds = new Set([...dictionary.slots.keys(), ...dictionary.failures.map(failure => failure.id)]); + const unknownRequested = [...requested].filter(name => { + const field = this.#fieldsByName.get(name); + return !field || !field.codec; + }); + const missing = [...requested].filter(name => { + const field = this.#fieldsByName.get(name); + if (!field) return false; + return Boolean(field.codec) && !presentIds.has(field.id); + }); + const failures = dictionary.failures + .filter(({ id }) => { + const field = this.#fieldsById.get(id); + if (!field) return false; + return Boolean(field.codec) && requested.has(field.name); + }) + .map(({ id, error }) => `${this.#slotLabel(id)}(${error.message})`); + let parsed = 0; + + for (const [id, frame] of [...dictionary.slots].sort(([left], [right]) => left - right)) { + const field = this.#fieldsById.get(id); + if (!field || !field.codec || !requested.has(field.name)) continue; + + try { + const table = new field.codec.tableClass().__init(frame.tablePosition, new ByteBuffer(frame.bytes)); + json[field.name] = field.codec.decode(table); + parsed++; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${this.#slotLabel(id)}(${message})`); + } + } + + const unknown = slotReport.unknownLabels; + const message = `${this.#name}.decode.parse:已知 ${slotReport.knownCount}/${dictionary.presentSlotCount},解析 ${parsed}/${dictionary.presentSlotCount},失败 ${failures.length}/${dictionary.presentSlotCount},未知 ${unknown.length}/${dictionary.presentSlotCount};失败=[${failures.join(", ")}];未知=[${unknown.join(", ")}];请求未知=[${unknownRequested.join(", ")}];缺失=[${missing.join(", ")}]`; + if (failures.length || unknown.length || unknownRequested.length) Console.warn(message); + else Console.debug(message); + return json; + } + + /** + * 独立编译 patch slot,并与原始根表合并组装。 + * Compile patch slots independently and merge them into the source root table. + * @param {ByteBuffer | undefined} [byteBuffer=undefined] - 原始 FlatBuffer;省略时创建新根表 / Source FlatBuffer; omit to create a new root table. + * @param {Record} [patch={}] - 按根名称组织的实际修改 / Actual changes keyed by root name. + * @returns {Uint8Array} 完整 FlatBuffer / Complete FlatBuffer. + */ + encode(byteBuffer = undefined, patch = {}) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + const error = new TypeError(`${this.#name}.encode patch must be an object`); + Console.error(`${this.#name}.encode`, error); + throw error; + } + + const patchKeys = Object.keys(patch); + const compiled = new Map(); + const failures = []; + const unknown = []; + let known = 0; + + for (const name of patchKeys) { + const field = this.#fieldsByName.get(name); + if (!field || !field.codec) { + unknown.push(name); + continue; + } + known++; + + try { + const data = patch[name]; + if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError(`${name} must be an object`); + + const builder = new Builder(); + const tableOffset = field.codec.encode(builder, data); + if (!Number.isSafeInteger(tableOffset) || tableOffset <= 0) throw new Error(`${name} codec did not produce a table`); + builder.finish(tableOffset); + + const bytes = builder.asUint8Array(); + const frameBuffer = new ByteBuffer(bytes); + const tablePosition = frameBuffer.position() + frameBuffer.readUint32(frameBuffer.position()); + compiled.set(field.id, { bytes, tablePosition }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${this.#slotLabel(field.id)}(${message})`); + } + } + + const compileMessage = `${this.#name}.encode.compile:已知 ${known}/${patchKeys.length},编译 ${compiled.size}/${patchKeys.length},失败 ${failures.length}/${patchKeys.length},未知 ${unknown.length}/${patchKeys.length};失败=[${failures.join(", ")}];未知=[${unknown.join(", ")}]`; + if (failures.length || unknown.length) Console.warn(compileMessage); + else Console.debug(compileMessage); + + let sourceDictionary; + if (byteBuffer !== undefined) { + try { + sourceDictionary = this.#readRootSlots(byteBuffer); + } catch (error) { + Console.error(`${this.#name}.encode`, error); + throw error; + } + } else { + sourceDictionary = { failures: [], presentSlotCount: 0, slotCount: 0, slots: new Map() }; + } + this.#logSlotDictionary("encode", sourceDictionary); + + if (byteBuffer !== undefined && compiled.size === 0) { + return byteBuffer.bytes().subarray(byteBuffer.position(), byteBuffer.capacity()); + } + + const slots = new Map(sourceDictionary.slots); + for (const [id, frame] of compiled) slots.set(id, frame); + + try { + const rawBody = this.#writeRootSlots( + { + failures: [], + presentSlotCount: slots.size, + slotCount: sourceDictionary.slotCount, + slots, + }, + this.#fields.length, + ); + Console.debug(`${this.#name}.encode.assemble:输出 ${slots.size} 个 slot,替换 ${compiled.size} 个 slot`); + return rawBody; + } catch (error) { + Console.error(`${this.#name}.encode`, error); + throw error; + } + } + + /** + * 校验字符串数组 API 契约。 + * Validate a string-array API contract. + * @param {unknown} value - 待校验值 / Value to validate. + * @param {string} label - 参数名称 / Parameter name. + * @returns {void} + */ + #assertStringArray(value, label) { + if (!Array.isArray(value) || value.some(item => typeof item !== "string")) throw new TypeError(`${this.#name}.${label} must be an array of strings`); + } + + /** + * 顺序扫描根 vtable,并让所有有效 slot 共享原始 arena。 + * Scan the root vtable sequentially while sharing the source arena across valid slots. + * @param {ByteBuffer} source - 原始 FlatBuffer / Source FlatBuffer. + * @returns {RootSlotDictionary} 根 slot 字典 / Root slot dictionary. + */ + #readRootSlots(source) { + if (!source || typeof source.position !== "function" || typeof source.capacity !== "function" || typeof source.bytes !== "function") { + throw new TypeError(`${this.#name} source must be a FlatBuffers ByteBuffer`); + } + + const sourceStart = source.position(); + const sourceEnd = source.capacity(); + this.#ensureRange(sourceStart, ROOT_OFFSET_SIZE, sourceStart, sourceEnd, "root offset"); + + const rootPosition = sourceStart + source.readUint32(sourceStart); + const rootTable = this.#inspectTable(source, rootPosition, sourceStart, sourceEnd, "root table"); + const slotCount = (rootTable.vtableLength - VTABLE_HEADER_SIZE) / VTABLE_ENTRY_SIZE; + const bytes = source.bytes().subarray(sourceStart, sourceEnd); + const slots = new Map(); + const failures = []; + let presentSlotCount = 0; + + for (let id = 0; id < slotCount; id++) { + const fieldOffset = source.readUint16(rootTable.vtablePosition + VTABLE_HEADER_SIZE + id * VTABLE_ENTRY_SIZE); + if (fieldOffset === 0) continue; + presentSlotCount++; + + try { + if (fieldOffset < ROOT_OFFSET_SIZE || fieldOffset + OFFSET_FIELD_SIZE > rootTable.objectLength) { + throw new Error(`root slot ${id} has an invalid field offset: ${fieldOffset}`); + } + + const fieldPosition = rootPosition + fieldOffset; + this.#ensureRange(fieldPosition, OFFSET_FIELD_SIZE, sourceStart, sourceEnd, `root slot ${id}`); + const relativeOffset = source.readUint32(fieldPosition); + if (relativeOffset === 0) throw new Error(`root slot ${id} has a null table offset`); + + const tablePosition = fieldPosition + relativeOffset; + this.#inspectTable(source, tablePosition, sourceStart, sourceEnd, `root slot ${id} table`); + slots.set(id, { bytes, tablePosition: tablePosition - sourceStart }); + } catch (error) { + failures.push({ id, error: error instanceof Error ? error : new Error(String(error)) }); + } + } + + return { failures, presentSlotCount, slotCount, slots }; + } + + /** + * 将唯一 arena 嵌入一次,并重新组装根表。 + * Embed each unique arena once and reassemble the root table. + * @param {RootSlotDictionary} dictionary - 待组装字典 / Dictionary to assemble. + * @param {number} schemaSlotCount - 当前模型的根 slot 数 / Root slot count of the current model. + * @returns {Uint8Array} 完整 FlatBuffer / Complete FlatBuffer. + */ + #writeRootSlots(dictionary, schemaSlotCount) { + if (!dictionary || !(dictionary.slots instanceof Map)) throw new TypeError("FlatBuffer root slot dictionary must contain a slots Map"); + if (!Number.isSafeInteger(dictionary.slotCount) || dictionary.slotCount < 0) throw new RangeError(`Invalid FlatBuffer root slot count: ${dictionary.slotCount}`); + if (!Number.isSafeInteger(schemaSlotCount) || schemaSlotCount < 0) throw new RangeError(`Invalid FlatBuffer schema slot count: ${schemaSlotCount}`); + + const builder = new Builder(); + const arenaOffsets = new Map(); + const tableOffsets = new Map(); + let slotCount = Math.max(dictionary.slotCount, schemaSlotCount); + + for (const [id, frame] of [...dictionary.slots].sort(([left], [right]) => left - right)) { + if (!Number.isSafeInteger(id) || id < 0) throw new RangeError(`Invalid FlatBuffer root slot: ${id}`); + if (!frame || !(frame.bytes instanceof Uint8Array)) throw new TypeError(`FlatBuffer root slot ${id} must contain Uint8Array bytes`); + if (!Number.isSafeInteger(frame.tablePosition)) throw new RangeError(`Invalid FlatBuffer root slot ${id} table position: ${frame.tablePosition}`); + + const frameBuffer = new ByteBuffer(frame.bytes); + this.#inspectTable(frameBuffer, frame.tablePosition, 0, frame.bytes.length, `root slot ${id} table`); + + let arenaStartOffset = arenaOffsets.get(frame.bytes); + if (arenaStartOffset === undefined) { + builder.startVector(1, frame.bytes.length, MAX_SCALAR_ALIGNMENT); + for (let index = frame.bytes.length - 1; index >= 0; index--) builder.writeInt8(frame.bytes[index]); + arenaStartOffset = builder.endVector() - OFFSET_FIELD_SIZE; + arenaOffsets.set(frame.bytes, arenaStartOffset); + } + + tableOffsets.set(id, arenaStartOffset - frame.tablePosition); + slotCount = Math.max(slotCount, id + 1); + } + + builder.startObject(slotCount); + for (const [id, offset] of [...tableOffsets].sort(([left], [right]) => left - right)) builder.addFieldOffset(id, offset, 0); + const root = builder.endObject(); + builder.finish(root); + return builder.asUint8Array(); + } + + /** + * 校验 table 与 vtable 边界。 + * Validate table and vtable boundaries. + * @param {ByteBuffer} source - FlatBuffer / FlatBuffer. + * @param {number} tablePosition - table 位置 / Table position. + * @param {number} sourceStart - arena 起点 / Arena start. + * @param {number} sourceEnd - arena 终点 / Arena end. + * @param {string} label - 错误标签 / Error label. + * @returns {{objectLength: number, vtableLength: number, vtablePosition: number}} table 元数据 / Table metadata. + */ + #inspectTable(source, tablePosition, sourceStart, sourceEnd, label) { + this.#ensureRange(tablePosition, ROOT_OFFSET_SIZE, sourceStart, sourceEnd, label); + const vtablePosition = tablePosition - source.readInt32(tablePosition); + this.#ensureRange(vtablePosition, VTABLE_HEADER_SIZE, sourceStart, sourceEnd, `${label} vtable`); + + const vtableLength = source.readUint16(vtablePosition); + const objectLength = source.readUint16(vtablePosition + VTABLE_ENTRY_SIZE); + if (vtableLength < VTABLE_HEADER_SIZE || vtableLength % VTABLE_ENTRY_SIZE !== 0) { + throw new Error(`${label} has an invalid vtable length: ${vtableLength}`); + } + if (objectLength < ROOT_OFFSET_SIZE) throw new Error(`${label} has an invalid object length: ${objectLength}`); + + this.#ensureRange(vtablePosition, vtableLength, sourceStart, sourceEnd, `${label} vtable`); + this.#ensureRange(tablePosition, objectLength, sourceStart, sourceEnd, label); + return { objectLength, vtableLength, vtablePosition }; + } + + /** + * 校验二进制读取范围。 + * Validate a binary read range. + * @param {number} position - 起点 / Start position. + * @param {number} length - 长度 / Length. + * @param {number} start - arena 起点 / Arena start. + * @param {number} end - arena 终点 / Arena end. + * @param {string} label - 错误标签 / Error label. + * @returns {void} + */ + #ensureRange(position, length, start, end, label) { + if (!Number.isSafeInteger(position) || !Number.isSafeInteger(length) || position < start || length < 0 || position + length > end) { + throw new Error(`${label} is outside the FlatBuffer`); + } + } + + /** + * 汇总 slot 字典读取结果。 + * Summarize a root-slot dictionary read. + * @param {"encode" | "decode"} operation - 操作名称 / Operation name. + * @param {RootSlotDictionary} dictionary - 根 slot 字典 / Root slot dictionary. + * @returns {{knownCount: number, unknownLabels: string[]}} 后续解析统计 / Statistics for the parse phase. + */ + #logSlotDictionary(operation, dictionary) { + const ids = [...dictionary.slots.keys(), ...dictionary.failures.map(failure => failure.id)].sort((left, right) => left - right); + const knownCount = ids.filter(id => { + const field = this.#fieldsById.get(id); + if (!field) return false; + return Boolean(field.codec); + }).length; + const unknownLabels = ids + .filter(id => { + const field = this.#fieldsById.get(id); + return !field || !field.codec; + }) + .map(id => this.#slotLabel(id)); + const failureLabels = dictionary.failures.map(({ id, error }) => `${this.#slotLabel(id)}(${error.message})`); + const message = `${this.#name}.${operation}.slots:已知 ${knownCount}/${dictionary.presentSlotCount},读取 ${dictionary.slots.size}/${dictionary.presentSlotCount},失败 ${failureLabels.length}/${dictionary.presentSlotCount},未知 ${unknownLabels.length}/${dictionary.presentSlotCount};失败=[${failureLabels.join(", ")}];未知=[${unknownLabels.join(", ")}]`; + if (failureLabels.length || unknownLabels.length) Console.warn(message); + else Console.debug(message); + return { knownCount, unknownLabels }; + } + + /** + * 将 slot ID 格式化为模型名称或物理编号。 + * Format a slot ID as a model name or physical identifier. + * @param {number} id - 根 slot ID / Root slot ID. + * @returns {string} 日志标签 / Log label. + */ + #slotLabel(id) { + const field = this.#fieldsById.get(id); + return field ? `${field.name}#${id}` : `slot#${id}`; + } +} + +export default FlatBufferRootProcessor; diff --git a/src/wk3/class/ForecastNextHour.mjs b/src/wk3/class/ForecastNextHour.mjs new file mode 100644 index 00000000..4a4ad76f --- /dev/null +++ b/src/wk3/class/ForecastNextHour.mjs @@ -0,0 +1,616 @@ +import { Console } from "../utils/index.mjs"; + +export default class ForecastNextHour { + Name = "ForecastNextHour"; + Version = "v1.6.3"; + Author = "iRingo"; + + // Match upstream dev b4a75db: next-hour products expire after ten minutes. + static ExpirationInterval = 10 * 60; + + static #Configs = { + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + }, + WeatherCondition: { + 晴朗: "CLEAR", + 雨夹雪: "SLEET", + 小雨: "DRIZZLE", + 下雨: "RAIN", + 中雨: "RAIN", + 大雨: "HEAVY_RAIN", + 小雪: "FLURRIES", + 下雪: "SNOW", + 中雪: "SNOW", + 大雪: "HEAVY_SNOW", + 冰雹: "HAIL", + }, + PrecipitationType: { + 晴朗: "CLEAR", + 雨夹雪: "SLEET", + rain: "RAIN", + 雨: "RAIN", + snow: "SNOW", + 雪: "SNOW", + 冰雹: "HAIL", + }, + Precipitation: { + Level: { + INVALID: -1, + NO: 0, + LIGHT: 1, + MODERATE: 2, + HEAVY: 3, + EXTREME: 4, + }, + Range: { + /** + * [降水强度 | 彩云天气 API]{@link https://docs.caiyunapp.com/weather-api/v2/v2.6/tables/precip.html} + */ + radar: { + NO: [0, 0.031], + LIGHT: [0.031, 0.25], + MODERATE: [0.25, 0.35], + HEAVY: [0.35, 0.48], + EXTREME: [0.48, Number.MAX_VALUE], + }, + mmph: { + NO: [0, 0.08], + LIGHT: [0.08, 3.44], + MODERATE: [3.44, 11.33], + HEAVY: [11.33, 51.3], + EXTREME: [51.3, Number.MAX_VALUE], + }, + precipitation: { + NO: [0, 0.01], + LIGHT: [0.01, 0.6], + MODERATE: [0.6, 1.65], + HEAVY: [1.65, 8.0], + EXTREME: [8.0, 205.0], + }, + }, + }, + }; + + static WeatherCondition(sentence) { + Console.debug("☑️ WeatherCondition", `sentence: ${sentence}`); + let weatherCondition = "CLEAR"; + Object.keys(ForecastNextHour.#Configs.WeatherCondition).forEach(key => { + if (sentence.includes(key)) weatherCondition = ForecastNextHour.#Configs.WeatherCondition[key]; + }); + Console.debug(`✅ WeatherCondition: ${weatherCondition}`); + return weatherCondition; + } + + // 根据描述文本猜测降水类型 + static PrecipitationType(sentence) { + Console.debug("☑️ PrecipitationType", `sentence: ${sentence}`); + let precipitationType = "CLEAR"; + Object.keys(ForecastNextHour.#Configs.PrecipitationType).forEach(key => { + if (sentence.includes(key)) precipitationType = ForecastNextHour.#Configs.PrecipitationType[key]; + }); + Console.debug(`✅ PrecipitationType: ${precipitationType}`); + return precipitationType; + } + + static Minute(minutes = [], description = "", units = "mmph") { + Console.debug("☑️ Minute"); + const precipitationType = ForecastNextHour.PrecipitationType(description); + // refer: https://docs.caiyunapp.com/weather-api/v2/v2.6/tables/precip.html + + minutes = minutes.map((minute, _i) => { + // 根据precipitationIntensity来猜测生成perceivedPrecipitationIntensity + minute.precipitationIntensity = Math.trunc(minute.precipitationIntensity * 1000000) / 1000000; + minute.perceivedPrecipitationIntensity = ForecastNextHour.#ConvertPrecipitationIntensity(minute.precipitationIntensity, units); + // 然后根据perceivedPrecipitationIntensity和precipitationChance来猜测生成condition和summaryCondition + if (minute.perceivedPrecipitationIntensity > 2) { + // 大雨,强烈感知 + switch (precipitationType) { + case "RAIN": + minute.condition = "HEAVY_RAIN"; + break; + case "SNOW": + minute.condition = "HEAVY_SNOW"; + break; + default: + minute.condition = precipitationType; + break; + } + + minute.summaryCondition = precipitationType; + minute.clear = false; + } else if (minute.perceivedPrecipitationIntensity > 1) { + // 中雨,明显感知 + switch (precipitationType) { + case "RAIN": + minute.condition = "RAIN"; + break; + case "SNOW": + minute.condition = "SNOW"; + break; + default: + minute.condition = precipitationType; + break; + } + minute.summaryCondition = precipitationType; + minute.clear = false; + } else if (minute.perceivedPrecipitationIntensity > 0.1) { + // ❓ perceivedPrecipitationIntensity 小于 0.1, 苹果天气显示为无降水 + // 小雨,可以感知到 + switch (precipitationType) { + case "RAIN": + minute.condition = "DRIZZLE"; + break; + case "SNOW": + minute.condition = "FLURRIES"; + break; + default: + minute.condition = precipitationType; + break; + } + minute.summaryCondition = precipitationType; + minute.clear = false; + } else if (minute.perceivedPrecipitationIntensity > 0) { + // 可能降水 + switch (precipitationType) { + case "RAIN": + minute.condition = "POSSIBLE_DRIZZLE"; + break; + case "SNOW": + minute.condition = "POSSIBLE_FLURRIES"; + break; + default: + minute.condition = `POSSIBLE_${precipitationType}`; + break; + } + minute.summaryCondition = precipitationType; + minute.clear = false; + } else { + minute.condition = "CLEAR"; + minute.summaryCondition = "CLEAR"; + minute.clear = true; + } + //Console.debug(`minutes[${i}]`, JSON.stringify(minute, null, 2)); + return minute; + }); + + Console.debug("✅ Minute"); + return minutes; + } + + static Summary(minutes = []) { + Console.debug("☑️ Summary"); + const Summaries = []; + let Summary = { + condition: "CLEAR", + startTime: 0, + precipitationChance: 0, + precipitationIntensity: 0, + maxCondition: "", + clear: true, + }; + let conditionsCount = {}; + const Length = Math.min(71, minutes.length); + + // 根据当前段内每分钟的 conditions 频次,判定最具代表性的条件 + const getRepresentativeCondition = counts => { + const heavyRain = (counts["HEAVY_RAIN"] || 0) + (counts["HEAVY_SNOW"] || 0); + const moderateRain = (counts["RAIN"] || 0) + (counts["SNOW"] || 0); + const lightRain = (counts["DRIZZLE"] || 0) + (counts["FLURRIES"] || 0); + const possibleRain = (counts["POSSIBLE_DRIZZLE"] || 0) + (counts["POSSIBLE_FLURRIES"] || 0); + + const totalMinutes = Object.values(counts).reduce((a, b) => a + b, 0); + // 阈值:大雨至少需要累计 12 分钟(总时长的 20%),但若该段很短则按 20% 折算,且最少 3 分钟 + const threshold = Math.max(3, Math.min(12, Math.round(totalMinutes * 0.2))); + + if (heavyRain >= threshold) { + return counts["HEAVY_RAIN"] ? "HEAVY_RAIN" : "HEAVY_SNOW"; + } + if (heavyRain + moderateRain >= threshold) { + return counts["RAIN"] ? "RAIN" : "SNOW"; + } + if (heavyRain + moderateRain + lightRain >= threshold) { + return counts["DRIZZLE"] ? "DRIZZLE" : "FLURRIES"; + } + if (heavyRain + moderateRain + lightRain + possibleRain > 0) { + return counts["POSSIBLE_DRIZZLE"] ? "POSSIBLE_DRIZZLE" : "POSSIBLE_FLURRIES"; + } + return "CLEAR"; + }; + + for (let i = 0; i < Length; i++) { + const minute = minutes[i]; + const previousMinute = minutes[i - 1]; + + // 统计计数 + conditionsCount[minute.condition] = (conditionsCount[minute.condition] || 0) + 1; + + switch (i) { + case 0: // 第一个 + Summary.startTime = minute.startTime; + Summary.condition = minute.summaryCondition; + Summary.precipitationChance = minute.precipitationChance; + Summary.precipitationIntensity = minute.precipitationIntensity; + Summary.clear = minute.clear; + if (Length === 1) { + Summary.endTime = 0; + Summary.maxCondition = getRepresentativeCondition(conditionsCount); + Summaries.push({ ...Summary }); + } + break; + case Length - 1: // 最后一个 + Summary.endTime = 0; + Summary.clear = minute.clear; + Summary.maxCondition = getRepresentativeCondition(conditionsCount); + Console.debug(`Summaries[${i}]`, JSON.stringify({ ...minute, ...Summary }, null, 2)); + Summaries.push({ ...Summary }); + break; + default: // 中间 + if (minute.summaryCondition !== previousMinute.summaryCondition) { + // 扣除属于新段的当前 minute 统计值 + conditionsCount[minute.condition]--; + if (conditionsCount[minute.condition] === 0) delete conditionsCount[minute.condition]; + + Summary.endTime = minute.startTime; + Summary.maxCondition = getRepresentativeCondition(conditionsCount); + Console.debug(`Summaries[${i}]`, JSON.stringify({ ...previousMinute, ...Summary }, null, 2)); + Summaries.push({ ...Summary }); + + // 开始新的summary + conditionsCount = {}; + conditionsCount[minute.condition] = 1; + Summary = { + startTime: minute.startTime, + condition: minute.summaryCondition, + precipitationChance: minute.precipitationChance, + precipitationIntensity: minute.precipitationIntensity, + maxCondition: "", + clear: minute.clear, + }; + } else { + // 条件相同,更新最大值 + Summary.precipitationChance = Math.max(Summary.precipitationChance, minute.precipitationChance); + Summary.precipitationIntensity = Math.max(Summary.precipitationIntensity, minute.precipitationIntensity); + } + break; + } + } + Console.debug(`Summaries: ${JSON.stringify(Summaries, null, 2)}`); + Console.debug("✅ Summary"); + return Summaries; + } + + static Condition(summaries = []) { + Console.debug("☑️ Condition"); + const Conditions = []; + if (summaries.length > 4) { + summaries = summaries.slice(0, 4); + if (summaries[3]) { + summaries[3] = { ...summaries[3], endTime: 0 }; + } + } + // 先通过 summaries 定基调 + switch (summaries.map(summary => summary.clear).join("|")) { + case "true": { + // 全程 CLEAR, 无降水, 是 CLEAR + const CLEAR = summaries[0]; // CLEAR 时期 + // CLEAR 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR.maxCondition, + endCondition: CLEAR.maxCondition, + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR.startTime, + endTime: 0, // CLEAR 期间 + }); + break; + } + case "false": { + // 全程 RAIN, 有降水, 是 CONSTANT + const CONSTANT = summaries[0]; // CONSTANT 时期 + // CONSTANT 期间显示为 CONSTANT + Conditions.push({ + beginCondition: CONSTANT.maxCondition, + endCondition: CONSTANT.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: CONSTANT.startTime, // CONSTANT 期间 + endTime: 0, // CONSTANT 期间 + }); + break; + } + case "true|false": { + // 先 CLEAR 后降水, 是 START + const CLEAR = summaries[0]; // START 时期 + const START = summaries[1]; // CONSTANT 时期 + // CLEAR 期间显示为 START + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "START", + parameters: [{ date: START.startTime, type: "FIRST_AT" }], // 降水开始时 + startTime: CLEAR.startTime, // CLEAR 期间 + endTime: CLEAR.endTime, // CLEAR 期间 + }); + // START 期间显示为 CONSTANT + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: START.startTime, // START 期间 + endTime: 0, // START 期间 + }); + break; + } + case "false|true": { + // 先降水后 CLEAR, 是 STOP + const STOP = summaries[0]; // STOP 时期 + const CLEAR = summaries[1]; // CLEAR 时期 + // STOP 期间显示为 STOP + Conditions.push({ + beginCondition: STOP.maxCondition, + endCondition: STOP.maxCondition, + forecastToken: "STOP", + parameters: [{ date: STOP.endTime, type: "FIRST_AT" }], // 降水结束时 + startTime: STOP.startTime, // STOP 期间 + endTime: STOP.endTime, // STOP 期间 + }); + // CLEAR 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR.maxCondition, + endCondition: CLEAR.maxCondition, + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR.startTime, // CLEAR 期间 + endTime: 0, // CLEAR 期间 + }); + break; + } + case "false|true|false": { + // 先降水后 CLEAR, 再降水, 是 STOP_START + const STOP = summaries[0]; // STOP 时期 + const CLEAR = summaries[1]; // START 时期 + const START = summaries[2]; // START 时期 + // STOP 期间显示为 STOP_START + Conditions.push({ + beginCondition: STOP.maxCondition, // 第一次降水降水开始时 + endCondition: START.maxCondition, // 第一次降水降水结束时 + forecastToken: "STOP_START", + parameters: [ + { date: STOP.endTime, type: "FIRST_AT" }, // 第一次降水结束时 + { date: START.startTime, type: "SECOND_AT" }, // 第二次降水开始时 + ], + startTime: STOP.startTime, // STOP 期间 + endTime: STOP.endTime, // STOP 期间 + }); + // CLEAR 期间显示为 START + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "START", + parameters: [{ date: START.startTime, type: "FIRST_AT" }], + startTime: CLEAR.startTime, // CLEAR 期间 + endTime: CLEAR.endTime, // CLEAR 期间 + }); + // START 期间显示为 CONSTANT + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: START.startTime, // START 期间 + endTime: 0, // START 期间 + }); + break; + } + case "true|false|true": { + // 先 CLEAR 后降水, 再 CLEAR, 是 START_STOP + const CLEAR1 = summaries[0]; // START_STOP 时期 + const STOP = summaries[1]; // STOP 时期 + const CLEAR2 = summaries[2]; // CLEAR 时期 + // CLEAR1 期间显示为 START_STOP + Conditions.push({ + beginCondition: STOP.maxCondition, // STOP 的开始天气 + endCondition: STOP.maxCondition, // STOP 的结束天气 + forecastToken: "START_STOP", + parameters: [ + { date: STOP.startTime, type: "FIRST_AT" }, + { date: STOP.endTime, type: "SECOND_AT" }, + ], + startTime: CLEAR1.startTime, // CLEAR1 期间 + endTime: CLEAR1.endTime, // CLEAR1 期间 + }); + // STOP 期间显示为 STOP + Conditions.push({ + beginCondition: STOP.maxCondition, // STOP 的开始天气 + endCondition: STOP.maxCondition, // STOP 的结束天气 + forecastToken: "STOP", + parameters: [{ date: STOP.endTime, type: "FIRST_AT" }], + startTime: STOP.startTime, // STOP 期间 + endTime: STOP.endTime, // STOP 期间 + }); + // CLEAR2 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR2.maxCondition, // CLEAR2 时期 + endCondition: CLEAR2.maxCondition, // CLEAR2 时期 + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR2.startTime, // CLEAR2 期间 + endTime: 0, // CLEAR2 期间 + }); + break; + } + case "false|true|false|true": { + // 先降水后 CLEAR, 再降水再 CLEAR, 是 STOP_START + START_STOP + const STOP1 = summaries[0]; // STOP 时期 1 + const CLEAR1 = summaries[1]; // CLEAR 时期 1 + const STOP2 = summaries[2]; // STOP 时期 2 + const CLEAR2 = summaries[3]; // CLEAR 时期 2 + // STOP1 期间显示为 STOP_START + Conditions.push({ + beginCondition: STOP1.maxCondition, // STOP1 的开始天气 + endCondition: STOP2.maxCondition, // STOP2 的开始天气 + forecastToken: "STOP_START", + parameters: [ + { date: STOP1.endTime, type: "FIRST_AT" }, // STOP1 结束 + { date: STOP2.startTime, type: "SECOND_AT" }, // STOP2 开始 + ], + startTime: STOP1.startTime, // STOP1 期间 + endTime: STOP1.endTime, // STOP1 期间 + }); + // CLEAR1 期间显示为 START_STOP + Conditions.push({ + beginCondition: STOP2.maxCondition, // STOP 时期 2 + endCondition: STOP2.maxCondition, // STOP 时期 2 + forecastToken: "START_STOP", + parameters: [ + { date: STOP2.startTime, type: "FIRST_AT" }, // STOP2 开始 + { date: STOP2.endTime, type: "SECOND_AT" }, // STOP2 结束 + ], + startTime: CLEAR1.startTime, // CLEAR1 期间 + endTime: CLEAR1.endTime, // CLEAR1 期间 + }); + // STOP2 期间显示为 STOP + Conditions.push({ + beginCondition: STOP2.maxCondition, // STOP 时期 + endCondition: STOP2.maxCondition, // STOP 时期 + forecastToken: "STOP", + parameters: [{ date: STOP2.endTime, type: "FIRST_AT" }], + startTime: STOP2.startTime, // STOP2 期间 + endTime: STOP2.endTime, // STOP2 期间 + }); + // CLEAR2 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR2.maxCondition, // CLEAR 时期 + endCondition: CLEAR2.maxCondition, // CLEAR 时期 + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR2.startTime, // CLEAR2 期间 + endTime: 0, // CLEAR2 期间 + }); + break; + } + case "true|false|true|false": { + // 先 CLEAR 后降水, 再 CLEAR 再降水, 是 START_STOP + STOP_START + const CLEAR1 = summaries[0]; // CLEAR1 时期 + const START1 = summaries[1]; // START1 时期 + const CLEAR2 = summaries[2]; // CLEAR2 时期 + const START2 = summaries[3]; // START2 时期 + // CLEAR1 期间显示为 START_STOP + Conditions.push({ + beginCondition: START1.maxCondition, // START1 的开始天气 + endCondition: START1.maxCondition, // START1 的结束天气 + forecastToken: "START_STOP", + parameters: [ + { date: START1.startTime, type: "FIRST_AT" }, // CLEAR1 结束 + { date: START1.endTime, type: "SECOND_AT" }, // CLEAR2 开始 + ], + startTime: CLEAR1.startTime, // CLEAR1 期间 + endTime: CLEAR1.endTime, // CLEAR1 期间 + }); + // START1 期间显示为 STOP_START + Conditions.push({ + beginCondition: START1.maxCondition, // START1 的结束天气 + endCondition: START2.maxCondition, // START2 的开始天气 + forecastToken: "STOP_START", + parameters: [ + { date: START1.endTime, type: "FIRST_AT" }, // START1 结束 + { date: START2.startTime, type: "SECOND_AT" }, // START2 开始 + ], + startTime: START1.startTime, // START1 期间 + endTime: START1.endTime, // START1 期间 + }); + // CLEAR2 期间显示为 START + Conditions.push({ + beginCondition: START2.maxCondition, // START2.beginCondition, // START2 的开始天气 + endCondition: START2.maxCondition, // START2 的开始天气 + forecastToken: "START", + parameters: [{ date: START2.startTime, type: "FIRST_AT" }], + startTime: CLEAR2.startTime, // CLEAR2 期间 + endTime: CLEAR2.endTime, // CLEAR2 期间 + }); + // START2 期间显示为 CONSTANT + Conditions.push({ + beginCondition: START2.maxCondition, // START2 时期 + endCondition: START2.maxCondition, // START2 时期 + forecastToken: "CONSTANT", + parameters: [], + startTime: START2.startTime, // START2 期间 + endTime: 0, // START2 期间 + }); + break; + } + default: { + // 兜底逻辑:如果前面的模式都匹配不上,则寻找是否有降水段 + const rainSummary = summaries.find(summary => !summary.clear); + if (rainSummary) { + Conditions.push({ + beginCondition: rainSummary.maxCondition, + endCondition: rainSummary.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: rainSummary.startTime, + endTime: 0, + }); + } else if (summaries.length > 0) { + const CLEAR = summaries[0]; + Conditions.push({ + beginCondition: CLEAR.maxCondition, + endCondition: CLEAR.maxCondition, + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR.startTime, + endTime: 0, + }); + } + break; + } + } + Console.debug(`Conditions: ${JSON.stringify(Conditions, null, 2)}`); + Console.debug("✅ Condition"); + return Conditions; + } + + static #ConvertPrecipitationIntensity(precipitationIntensity, units = "mmph") { + //Console.debug("☑️ ConvertPrecipitationIntensity"); + //Console.debug(`precipitationIntensity: ${precipitationIntensity}`, `units: ${units}`); + const Range = ForecastNextHour.#Configs.Precipitation.Range[units]; + let perceivedPrecipitationIntensity = 0; + + if (precipitationIntensity === 0) { + // 无降水 + perceivedPrecipitationIntensity = 0; + } else if (precipitationIntensity > Range.NO[0] && precipitationIntensity <= Range.NO[1]) { + // 轻微降水,可能感知不到 + perceivedPrecipitationIntensity = 0; // 轻微降水通常感知不到 + } else if (precipitationIntensity > Range.LIGHT[0] && precipitationIntensity <= Range.LIGHT[1]) { + // 小雨,可以感知到 + // 根据强度计算感知强度,在0-1之间 + perceivedPrecipitationIntensity = Math.min(1, (precipitationIntensity - Range.LIGHT[0]) / (Range.LIGHT[1] - Range.LIGHT[0])); + } else if (precipitationIntensity > Range.MODERATE[0] && precipitationIntensity <= Range.MODERATE[1]) { + // 中雨,明显感知 + // 根据强度计算感知强度,在1-2之间 + perceivedPrecipitationIntensity = 1 + Math.min(1, (precipitationIntensity - Range.MODERATE[0]) / (Range.MODERATE[1] - Range.MODERATE[0])); + } else if (precipitationIntensity > Range.HEAVY[0]) { + // 大雨,强烈感知 + // 根据强度计算感知强度,在2-3之间 + perceivedPrecipitationIntensity = 2 + Math.min(1, (precipitationIntensity - Range.HEAVY[0]) / (Range.HEAVY[1] - Range.HEAVY[0])); + } + + // 使用Math.trunc保留一位小数(性能最快,截断不四舍五入) + perceivedPrecipitationIntensity = Math.trunc(perceivedPrecipitationIntensity * 1000) / 1000; + + //Console.debug(`perceivedPrecipitationIntensity: ${perceivedPrecipitationIntensity}`); + //Console.debug(`✅ ConvertPrecipitationIntensity`); + return perceivedPrecipitationIntensity; + } +} diff --git a/src/wk3/class/NativeAlertDetails.mjs b/src/wk3/class/NativeAlertDetails.mjs new file mode 100644 index 00000000..18234d44 --- /dev/null +++ b/src/wk3/class/NativeAlertDetails.mjs @@ -0,0 +1,118 @@ +import WeatherAlerts from "./WeatherAlerts.mjs"; + +const PREFIX = "wk3_"; +const MAX_IDS = 16384, MAX_JSON_BYTES = 12000, MAX_ALERTS = 32; +const REGION = /^[a-zA-Z0-9][a-zA-Z0-9_-]*-\d{9}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Recognizes the reserved namespace, including malformed payloads, so callers + * can reject them locally instead of forwarding them to Apple. */ +export function isNativeAlertDetailsID(ids) { + return typeof ids === "string" && ids.startsWith(PREFIX); +} + +function languageTag(value) { + if (typeof value !== "string" || value.length > 64 || !/^[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*$/.test(value)) throw new TypeError("Invalid alert language"); + return value; +} +function exactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) throw new TypeError("Invalid native alert payload shape"); +} +function validatePayload(payload) { + exactKeys(payload, ["region", "alerts"]); + if (typeof payload.region !== "string" || payload.region.length > 128 || !REGION.test(payload.region)) throw new TypeError("Invalid QWeather region"); + if (!Array.isArray(payload.alerts) || !payload.alerts.length || payload.alerts.length > MAX_ALERTS) throw new RangeError("Invalid native alert count"); + const ids = new Set(); + for (const alert of payload.alerts) { + exactKeys(alert, ["id", "description", "issuedTime", "severity"]); + if (typeof alert.id !== "string" || !UUID.test(alert.id) || ids.has(alert.id)) throw new TypeError("Invalid or duplicate Apple UUID"); + ids.add(alert.id); + if (typeof alert.description !== "string" || !alert.description.trim() || alert.description.length > 512) throw new TypeError("Invalid alert description"); + if (!Number.isInteger(alert.issuedTime) || alert.issuedTime <= 0 || alert.issuedTime > 0xffffffff) throw new TypeError("Invalid alert issuedTime"); + if (!Number.isInteger(alert.severity) || alert.severity < 0 || alert.severity > 255) throw new TypeError("Invalid WK3 severity"); + } + return payload; +} +function uuidFromBytes(bytes) { + if (!(Array.isArray(bytes) || bytes instanceof Uint8Array) || bytes.length !== 16 || !Array.from(bytes).every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255)) throw new TypeError("Expected 16 Apple UUID bytes"); + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} +function base64url(bytes) { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Build once from the original/enriched WK3 summaries; assign this SAME URL to + * the collection and every summary. The payload contains no location/auth data + * beyond the public region identifier and the minimum event matching fields. */ +export function buildNativeAlertDetailsURL({ identifier, language = "zh-CN", alerts, timezone }) { + languageTag(language); + if (timezone !== undefined && (typeof timezone !== "string" || !/^[A-Za-z0-9_+/-]{1,128}$/.test(timezone))) throw new TypeError("Invalid alert timezone"); + if (!Array.isArray(alerts) || !alerts.length || alerts.length > MAX_ALERTS) throw new RangeError("Invalid native alert count"); + const payload = validatePayload({ region: identifier, alerts: alerts.map(alert => ({ + id: uuidFromBytes(alert?.id?.bytes), + description: alert.description, + issuedTime: alert.issuedTime, + severity: alert.severity, + })) }); + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + if (bytes.length > MAX_JSON_BYTES) throw new RangeError("Native alert payload too long"); + const ids = PREFIX + base64url(bytes); + if (ids.length > MAX_IDS) throw new RangeError("Native alert ids too long"); + const url = new URL("https://weatherkit.apple.com/alertDetails/index.html"); + url.searchParams.set("lang", language); + url.searchParams.set("ids", ids); + url.searchParams.set("party", "qweather"); + if (timezone !== undefined) url.searchParams.set("timezone", timezone); + return url.toString(); +} +function parseIDs(ids) { + if (!isNativeAlertDetailsID(ids) || ids.length > MAX_IDS) throw new TypeError("Invalid native alert ids"); + const encoded = ids.slice(PREFIX.length); + if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) throw new TypeError("Invalid native alert base64url"); + const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - encoded.length % 4) % 4)); + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); + if (bytes.length > MAX_JSON_BYTES || base64url(bytes) !== encoded) throw new TypeError("Invalid native alert encoding"); + return validatePayload(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); +} +function titleParts(value) { + if (typeof value !== "string") return null; + const text = value.normalize("NFKC").trim().toLowerCase().replace(/\s+/g, "").replace(/[。.]+$/, ""); + const match = text.match(/^(.+?)(?:(蓝|黄|橙|红)色)?预警(?:信号)?$/); + // Some official event names already end in “预警”, before the colored + // warning suffix (e.g. 高速公路灾害性天气预警红色预警信号). + return match ? { text: match[1].replace(/(?:预警(?:信号)?)+$/, ""), color: match[2] } : { text, color: undefined }; +} +/** Only the generic Apple title observed in the device diagnostic. */ +export function isGenericAlertTitle(value) { + return typeof value === "string" && value.normalize("NFKC").replace(/\s+/g, "").replace(/[。.]+$/, "") === "恶劣天气"; +} +export function sameAlertTitle(original, source) { + const a = titleParts(original), b = titleParts(source); + return Boolean(a?.text && b?.text && a.text === b.text && (!a.color || !b.color || a.color === b.color)); +} + +/** Returns only the requested original events, in their original order. Source + * order never establishes identity. Missing/ambiguous/unverified matches throw + * atomically; the route should return an empty/error result, never fake details. + * fetchImpl receives only WeatherAlerts' public HTML request (no Apple headers). + * This is a bounded, public locator, not a signed token or authorization grant. */ +export async function resolveNativeAlertDetails(ids, language = "zh-CN", fetchImpl = globalThis.fetch) { + const payload = parseIDs(ids); + languageTag(language); + // MINOR/MODERATE are paired locally; EXTREME/SEVERE are explicitly + // defined by upstream @nsringo/weatherkit 2.0.5 (dev b4a75db). + const severities = { 1: "extreme", 2: "severe", 3: "moderate", 4: "minor" }; + if (payload.alerts.some(alert => !Object.hasOwn(severities, alert.severity))) throw new TypeError("Unverified WK3 severity"); + const sources = await WeatherAlerts.DetailsForQWeather(payload.region, language, fetchImpl); + if (!Array.isArray(sources)) throw new TypeError("Invalid QWeather details"); + const matches = payload.alerts.map(alert => { + const candidates = sources.filter(source => source && source.severity === severities[alert.severity] + && sameAlertTitle(alert.description, source.description) + && typeof source.issuedTime === "string" && Date.parse(source.issuedTime) === alert.issuedTime * 1000); + if (candidates.length !== 1) throw new Error("Missing or ambiguous native alert match"); + return candidates[0]; + }); + if (new Set(matches).size !== matches.length) throw new Error("Ambiguous shared native alert match"); + return matches.map((source, index) => ({ ...source, id: payload.alerts[index].id, detailsUrl: `#${payload.alerts[index].id}` })); +} diff --git a/src/wk3/class/QWeather.mjs b/src/wk3/class/QWeather.mjs new file mode 100644 index 00000000..60a31439 --- /dev/null +++ b/src/wk3/class/QWeather.mjs @@ -0,0 +1,891 @@ +import AirQuality from "../class/AirQuality.mjs"; +import { Console, fetch } from "../utils/index.mjs"; +import ForecastNextHour from "./ForecastNextHour.mjs"; +import Weather from "./Weather.mjs"; + +export default class QWeather { + constructor(parameters, token, host = "devapi.qweather.com") { + this.Name = "QWeather"; + this.Version = "5.1.0"; + Console.debug(`🟧 ${this.Name} v${this.Version}`); + this.endpoint = `https://${host}`; + this.headers = { "X-QW-Api-Key": token }; + this.version = parameters.version; + this.language = parameters.language; + this.latitude = parameters.latitude; + this.longitude = parameters.longitude; + this.country = parameters.country; + } + + #cache = { + airQualityCurrentPromise: null, + historicalAir: null, + }; + + #Config = { + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm2p5: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + na: "NOT_AVAILABLE", + undefined: "NOT_AVAILABLE", + null: "NOT_AVAILABLE", + }, + Units: { + "μg/m3": "MICROGRAMS_PER_CUBIC_METER", + "mg/m3": "MILLIGRAMS_PER_CUBIC_METER", + ppb: "PARTS_PER_BILLION", + ppm: "PARTS_PER_MILLION", + }, + Availability: { + Minutely: ["CN", "HK", "MO"], + AirQuality: ["AD", "BE", "BG", "CA", "CN", "HR", "CZ", "DK", "FI", "FR", "DE", "GI", "GR", "HK", "HU", "IE", "JP", "KR", "LV", "LT", "MO", "MT", "NL", "MK", "NO", "PL", "PT", "RO", "RS", "SG", "SK", "SI", "ES", "SE", "CH", "TW", "TH", "GB", "US"], + }, + }; + + static async GetLocationsGrid(qweatherCache, setCache) { + Console.debug("☑️ GetLocationsGrid"); + const locationsGrid = qweatherCache?.locationsGrid; + // cache within 30 days + if (locationsGrid?.lastUpdated && locationsGrid.lastUpdated + 30 * 24 * 60 * 60 * 1000 > Date.now()) { + Console.debug("✅ GetLocationsGrid", "Cache found!"); + return locationsGrid.data; + } + + Console.info("⚠️ GetLocationsGrid", "Cache not found or stale, fetching..."); + try { + const response = await fetch({ + headers: locationsGrid?.etag ? { "If-None-Match": locationsGrid?.etag } : undefined, + url: "https://raw.githubusercontent.com/NSRingo/QWeather-Location-Grid/refs/heads/main/data/qweather-china-city-list-grid.json", + }); + + if (response.status === 304) { + Console.debug("✅ GetLocationsGrid", "Cache not modified"); + setCache({ ...qweatherCache, locationsGrid: { ...locationsGrid, lastUpdated: Date.now() } }); + return locationsGrid.data; + } + + const newLocationsGrid = JSON.parse(response.body); + const etag = typeof response.headers?.get === "function" ? response.headers.get("ETag") : response.headers?.etag || response.headers?.ETag; + setCache({ + ...qweatherCache, + locationsGrid: { etag, lastUpdated: Date.now(), data: newLocationsGrid }, + }); + Console.debug("✅ GetLocationsGrid"); + return newLocationsGrid; + } catch (error) { + Console.error("GetLocationsGrid error:", error); + return locationsGrid?.data || null; + } + } + + // Codes by Claude AI + static GetLocationInfo(locationsGrid, latitude, longitude) { + Console.debug("☑️ GetLocationInfo"); + + if (!locationsGrid) { + Console.warn("GetLocationInfo", "No locationsGrid provided"); + return null; + } + + const { gridSize, grid } = locationsGrid; + + // Haversine距离计算 + const distance = (lat1, lng1, lat2, lng2) => { + const R = 6371; // 地球半径(km) + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + }; + + const getNearbyGridKeys = (lat, lng, radius = 2) => { + const centerX = Math.floor(lng / gridSize); + const centerY = Math.floor(lat / gridSize); + const keys = []; + + for (let dx = -radius; dx <= radius; dx++) { + for (let dy = -radius; dy <= radius; dy++) { + keys.push(`${centerX + dx},${centerY + dy}`); + } + } + + return keys; + }; + + const findNearestFast = (lat, lng) => { + const keys = getNearbyGridKeys(lat, lng, 2); + let nearest = null; + let minDist = Number.POSITIVE_INFINITY; + + for (const key of keys) { + const locations = grid[key]; + if (!locations) continue; + + for (const loc of locations) { + const dist = distance(lat, lng, loc.latitude, loc.longitude); + if (dist < minDist) { + minDist = dist; + nearest = loc; + } + } + } + + return nearest; + }; + + const nearest = findNearestFast(latitude, longitude); + Console.debug("✅ GetLocationInfo"); + return nearest; + } + + async GeoAPI(path = "city/lookup") { + Console.debug("☑️ GeoAPI"); + const request = { + url: `${this.endpoint}/geo/v2/${path}?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let metadata; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": + metadata = { + attributionUrl: body?.location?.[0]?.fxLink, + latitude: body?.location?.[0]?.lat, + longitude: body?.location?.[0]?.lon, + providerName: "和风天气", + locationID: body?.location?.[0]?.id, + }; + break; + default: + throw Error(body?.code); + } + } catch (error) { + Console.error(`GeoAPI: ${error}`); + } finally { + Console.debug("metadata:", metadata); + Console.debug("✅ GeoAPI"); + } + return metadata; + } + + async WeatherNow() { + Console.debug("☑️ WeatherNow"); + const request = { + url: `${this.endpoint}/v7/weather/now?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let currentWeather; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + currentWeather = { + metadata: { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: (new Date(body?.now?.pubTime).getTime() / 1000) | 0, + temporarilyUnavailable: false, + sourceType: "STATION", + }, + cloudCover: Number.parseInt(body?.now?.cloud, 10), + conditionCode: Weather.ConvertWeatherCode(body?.now?.text), + humidity: Number.parseInt(body?.now?.humidity, 10), + perceivedPrecipitationIntensity: Number.parseFloat(body?.now?.precip), + pressure: Number.parseFloat(body?.now?.pressure), + temperature: Number.parseFloat(body?.now?.temp), + temperatureApparent: Number.parseFloat(body?.now?.feelsLike), + temperatureDewPoint: Number.parseFloat(body?.now?.dew), + visibility: Number.parseFloat(body?.now?.vis) * 1000, + windDirection: Number.parseInt(body?.now?.wind360, 10), + windSpeed: Number.parseFloat(body?.now?.windSpeed), + }; + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`WeatherNow: ${error}`); + } finally { + //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); + Console.debug("✅ WeatherNow"); + } + return currentWeather; + } + + async #AirQualityCurrent() { + Console.debug("☑️ AirQualityCurrent"); + + if (!this.#cache.airQualityCurrentPromise) { + const request = { + url: `${this.endpoint}/airquality/v1/current/${this.latitude}/${this.longitude}`, + headers: this.headers, + }; + this.#cache.airQualityCurrentPromise = fetch(request) + .then(response => JSON.parse(response?.body ?? "{}")) + .then(body => { + if (body?.error === undefined) { + return body; + } + throw Error(JSON.stringify(body?.error, null, 2)); + }) + .catch(error => { + Console.error(`AirQualityCurrent: ${error}`); + this.#cache.airQualityCurrentPromise = null; + return {}; + }); + } else { + Console.debug("✅ AirQualityCurrent", "Using cache promise"); + } + return this.#cache.airQualityCurrentPromise; + } + + async Minutely() { + Console.debug("☑️ Minutely"); + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.Minutely.includes(this.country)) { + Console.warn("Minutely", `Unsupported country: ${this.country}`); + return; + } + + const request = { + url: `${this.endpoint}/v7/minutely/5m?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let forecastNextHour; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + // updateTime is publication time; fxTime defines the forecast grid. + const minuteStemp = new Date(body?.minutely?.[0]?.fxTime).valueOf() / 1000; + forecastNextHour = { + metadata: { + attributionUrl: body?.fxLink, + expireTime: timeStamp + ForecastNextHour.ExpirationInterval, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: Number(this.latitude), + longitude: Number(this.longitude), + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: timeStamp, + temporarilyUnavailable: false, + sourceType: "MODELED", + }, + condition: [], + forecastEnd: 0, + forecastStart: minuteStemp, + minutes: body?.minutely + ?.map((minutely, _index) => { + const minute = { + perceivedPrecipitationIntensity: 0, + precipitationChance: 0, + precipitationIntensity: Number.parseFloat(minutely.precip) * 12, + startTime: new Date(minutely.fxTime) / 1000, + }; + let minutes = [{ ...minute }, { ...minute }, { ...minute }, { ...minute }, { ...minute }]; + minutes = minutes.map((minute, index) => { + minute.startTime = minute.startTime + index * 60; + return minute; + }); + return minutes; + }) + .flat(Number.POSITIVE_INFINITY), + summary: [], + }; + forecastNextHour.minutes.length = Math.min(85, forecastNextHour.minutes.length); + forecastNextHour.forecastEnd = minuteStemp + 60 * forecastNextHour.minutes.length; + forecastNextHour.minutes = ForecastNextHour.Minute(forecastNextHour.minutes, body?.summary, "mmph"); + forecastNextHour.summary = ForecastNextHour.Summary(forecastNextHour.minutes); + forecastNextHour.condition = ForecastNextHour.Condition(forecastNextHour.summary); + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`Minutely: ${error}`); + } finally { + //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); + Console.debug("✅ Minutely"); + } + return forecastNextHour; + } + + async Hourly(hours = 168) { + Console.debug("☑️ Hourly", `host: ${this.host}`); + const request = { + url: `${this.endpoint}/v7/weather/${hours}h?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let forecastHourly; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + forecastHourly = { + metadata: { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: new Date(body?.updateTime), + temporarilyUnavailable: false, + sourceType: "STATION", + }, + hours: body?.hourly?.map(hourly => { + return { + cloudCover: Number.parseInt(hourly?.cloud, 10), + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(hourly?.text), + // daylight: false, // Not given + forecastStart: (new Date(hourly?.fxTime).getTime() / 1000) | 0, + humidity: Number.parseInt(hourly?.humidity, 10), + // perceivedPrecipitationIntensity: "", // Not given + precipitationAmount: Number.parseFloat(hourly?.precip), + precipitationChance: Number.parseInt(hourly?.pop, 10), + precipitationIntensity: Number.parseInt(hourly?.precip, 10), + // precipitationType: "", // Not given + pressure: Number.parseFloat(hourly?.pressure), + // pressureTrend: "", // Not given + // snowfallAmount: 0, // Not given + // snowfallIntensity: 0, // Not given + temperature: Number.parseFloat(hourly?.temp), + // temperatureApparent: 0, // Not given + temperatureDewPoint: Number.parseFloat(hourly?.dew), + // uvIndex: 0, // Not given + // visibility: 0, // Not given + windDirection: Number.parseInt(hourly?.wind360, 10), + // windGust: 0, // Not given + windSpeed: Number.parseFloat(hourly?.windSpeed), + }; + }), + }; + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`Hourly: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(forecastHourly, null, 2)}`); + Console.debug("✅ Hourly"); + } + return forecastHourly; + } + + async Daily(days = 10) { + Console.debug("☑️ Daily", `host: ${this.host}`); + const request = { + url: `${this.endpoint}/v7/weather/${days}d?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let forecastDaily; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + const metadata = { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: new Date(body?.updateTime), + temporarilyUnavailable: false, + sourceType: "STATION", + }; + const timezoneOffset = metadata.reportedTime.getTimezoneOffset(); + forecastDaily = { + metadata: metadata, + days: body?.daily?.map(daily => { + const timeStamp = ((Date.parse(daily?.fxDate) / 1000) | 0) + timezoneOffset * 60; // 本地转 Unix 时间戳 + return { + forecastStart: timeStamp, + forecastEnd: timeStamp + 24 * 3600, // 24 hours + // conditionCode: Weather.ConvertWeatherCode(daily?.textDay), // Not given (用白天数据代替) + // humidity 用一整天的数据代替 + // humidityMax: daily?.humidity, // Not Accurate + // humidityMin: daily?.humidity, // Not Accurate + maxUvIndex: Number.parseInt(daily?.uvIndex, 10), + moonPhase: Weather.ConvertMoonPhase(daily?.moonPhase), + moonrise: this.#ConvertTimeStamp(daily?.fxDate, daily?.moonrise), + moonset: this.#ConvertTimeStamp(daily?.fxDate, daily?.moonset), + precipitationAmount: Number.parseFloat(daily?.precip), + // precipitationAmountByType: [], // Not given + // precipitationChance: 0, // Not given + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // solarMidnight: 0, // Not given + // solarNoon: 0, // Not given + sunrise: this.#ConvertTimeStamp(daily?.fxDate, daily?.sunrise), + // sunriseAstronomical: 0, // Not given + // sunriseCivil: 0, // Not given + // sunriseNautical: 0, // Not given + sunset: this.#ConvertTimeStamp(daily?.fxDate, daily?.sunset), + // sunsetAstronomical: 0, // Not given + // sunsetCivil: 0, // Not given + // sunsetNautical: 0, // Not given + temperatureMax: Number.parseFloat(daily?.tempMax), + // temperatureMaxTime: 0, // Not given + temperatureMin: Number.parseFloat(daily?.tempMin), + // temperatureMinTime: 0, // Not given + // visibilityMax: 0, // Not given + // visibilityMin: 0, // Not given + // windGustSpeedMax: 0, // Not given + windSpeedAvg: (Number.parseFloat(daily?.windSpeedDay) * 7 + Number.parseFloat(daily?.windSpeedNight) * 17) / 24, // 加权平均:白天7小时,晚上17小时 + // windSpeedMax: 0, // Not given + daytimeForecast: { + forecastStart: timeStamp + 7 * 3600, // 7 hours + forecastEnd: timeStamp + 7 * 3600 + 12 * 3600, // 7 + 12 hours + // cloudCover: 0, // Not given + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(daily?.textDay), + // humidity 用一整天的数据代替 + // humidityMax: daily?.humidity, // Not Accurate + // humidityMin: daily?.humidity, // Not Accurate + precipitationAmount: Number.parseFloat(daily?.precip), + // precipitationAmountByType: [], // Not given + // precipitationChance: 0, // Not given + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // temperatureMax: 0, // Not given + // temperatureMin: 0, // Not given + // visibility 用一整天的数据代替 + // visibilityMax: 0, // Not given + // visibilityMin: 0, // Not given + windDirection: Number.parseInt(daily?.wind360Day, 10), + // windGustSpeedMax: 0, // Not given + windSpeed: Number.parseFloat(daily?.windSpeedDay), + // windSpeedMax: 0, // Not given + }, + overnightForecast: { + forecastStart: timeStamp + 19 * 3600, // 19 hours + forecastEnd: timeStamp + 19 * 3600 + 12 * 3600, // 19 + 12 hours + // cloudCover: 0, // Not given + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(daily?.textNight), + // humidity 用一整天的数据代替 + // humidityMax: daily?.humidity, // Not Accurate + // humidityMin: daily?.humidity, // Not Accurate + precipitationAmount: Number.parseFloat(daily?.precip), + // precipitationAmountByType: [], // Not given + // precipitationChance: 0, // Not given + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // temperatureMax: 0, // Not given + // temperatureMin: 0, // Not given + // visibility 用一整天的数据代替 + // visibilityMax: 0, // Not given + // visibilityMin: 0, // Not given + windDirection: Number.parseInt(daily?.wind360Night, 10), + // windGustSpeedMax: 0, // Not given + windSpeed: Number.parseFloat(daily?.windSpeedNight), + // windSpeedMax: 0, // Not given + }, + }; + }), + }; + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`Daily: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(forecastDaily, null, 2)}`); + Console.debug("✅ Daily"); + } + return forecastDaily; + } + + async #HistoricalAir(locationID = new Number(), date) { + if (!date) { + const d = new Date(Date.now() - 86400000); + date = d.getFullYear() + String(d.getMonth() + 1).padStart(2, "0") + String(d.getDate()).padStart(2, "0"); + } + Console.debug("☑️ HistoricalAir", `locationID: ${locationID}`, `date: ${date}`); + const request = { + url: `${this.endpoint}/v7/historical/air?location=${locationID}&date=${date}`, + headers: this.headers, + }; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + return body; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`HistoricalAir: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); + Console.debug("✅ HistoricalAir"); + } + return {}; + } + + #Metadata(attributionUrl = `https://www.qweather.com/`, sourceType = "MODELED", temporarilyUnavailable = false) { + const timeStamp = Math.trunc(Date.now() / 1000); + return { + longitude: Number(this.longitude), + providerName: "和风天气", + + reportedTime: timeStamp, + latitude: Number(this.latitude), + expireTime: timeStamp + 60 * 60, + attributionUrl, + temporarilyUnavailable, + readTime: timeStamp, + sourceType, + }; + } + + /** + * 创建 WeatherKit 格式污染物对象(airquality/v1/current 数据源)。 + * @link https://dev.qweather.com/docs/airquality/ + * @param {Array<{ + * code: string, + * concentration: { value: number, unit: string }, + * subIndexes?: Array<{ code: string, aqi: number }> + * }>} pollutantsObj - 原始污染物数组。 + * @param {string} [scaleCode] - 目标指数口径 code(如 cn-mee / us-epa)。 + * @returns {Array<{ + * pollutantType: string, + * amount: number, + * units: string, + * index?: number + * }>} 转换后的污染物数组。 + */ + #CreatePollutants(pollutantsObj, scaleCode) { + Console.debug("☑️ CreatePollutants"); + Console.debug("pollutantsObj:", pollutantsObj); + + // TODO: what is ppmC? https://dev.qweather.com/docs/resource/air-info/#pollutants + const pollutants = pollutantsObj + .filter(pollutant => pollutant.concentration.unit !== "ppmC") + .map(({ code, concentration, subIndexes = [] }) => { + const { value, unit } = concentration; + const normalizedUnit = unit.replace("³", "3"); + const pollutantType = this.#Config.Pollutants[code]; + const indexObj = subIndexes.find(subIndex => subIndex.code === scaleCode); + if (scaleCode && !indexObj) Console.warn("CreatePollutants", `No index for ${pollutantType} was found for required scale`); + + const friendlyUnits = AirQuality.Config.Units.Friendly; + const { ugm3, mgm3, ppb, ppm } = AirQuality.Config.Units.WeatherKit; + switch (normalizedUnit) { + case friendlyUnits.MILLIGRAMS_PER_CUBIC_METER: + return { pollutantType, amount: AirQuality.ConvertUnit(value, mgm3, ugm3), units: ugm3, index: scaleCode ? (indexObj?.aqi ?? -1) : undefined }; + case friendlyUnits.PARTS_PER_MILLION: + return { pollutantType, amount: AirQuality.ConvertUnit(value, ppm, ppb), units: ppb, index: scaleCode ? (indexObj?.aqi ?? -1) : undefined }; + default: + return { pollutantType, amount: value, units: this.#Config.Units[normalizedUnit], index: scaleCode ? (indexObj?.aqi ?? -1) : undefined }; + } + }); + + Console.debug("✅ CreatePollutants"); + return pollutants; + } + + /** + * 创建 WeatherKit 格式污染物对象(v7/air/now 与 historical/air 数据源)。 + * @link https://dev.qweather.com/docs/resource/unit/ + * @param {Object} pollutantsObj - v7 接口返回的污染物键值对象。 + * @returns {Array<{amount: number, pollutantType: string, units: string}>} 转换后的污染物数组。 + */ + #CreatePollutantsV7(pollutantsObj) { + Console.debug("☑️ CreatePollutantsV7"); + Console.debug("pollutantsObj:", pollutantsObj); + + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = Object.entries(pollutantsObj) + .map(([name, amount]) => { + const parsedAmount = Number.parseFloat(amount); + switch (name) { + case "co": + return { + amount: AirQuality.ConvertUnit(parsedAmount ?? -1, mgm3, ugm3), + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + }; + case "no": + case "no2": + case "so2": + case "o3": + case "nox": + case "pm25": + case "pm10": + return { + amount: parsedAmount ?? -1, + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + }; + default: + return null; + } + }) + .filter(Boolean); + + Console.debug("✅ CreatePollutantsV7"); + return pollutants; + } + + async CurrentAirQuality(forcePrimaryPollutant = true) { + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.AirQuality.includes(this.country)) { + Console.warn("CurrentAirQuality", `Unsupported country: ${this.country}`); + return { + metadata: this.#Metadata(`https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, undefined, true), + pollutants: [], + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + } + + const findSupportedIndex = indexes => { + Console.debug("☑️ findSupportedIndex"); + + const supportedCodes = ["cn-mee", "cn-mee-1h", "eu-eea", "us-epa", "us-epa-nc"]; + for (const index of indexes) { + if (supportedCodes.includes(index.code)) { + Console.debug("✅ indexCodeToScale", `index.code: ${index.code}`); + return index; + } + } + + return {}; + }; + + const indexCodeToScale = code => { + Console.debug("☑️ indexCodeToScale", `code: ${code}`); + + const { HJ6332012, EPA_NowCast, EU_EAQI } = AirQuality.Config.Scales; + switch (code) { + // We don't need calcualtion so they are same + case "cn-mee": + case "cn-mee-1h": + Console.debug("✅ indexCodeToScale", "HJ6332012"); + return HJ6332012; + case "us-epa": + case "us-epa-nc": + Console.debug("✅ indexCodeToScale", "EPA_NowCast"); + return EPA_NowCast; + case "eu-eea": + Console.debug("✅ indexCodeToScale", "EU_EAQI"); + return EU_EAQI; + default: + Console.error("indexCodeToScale", "不支持的code"); + return {}; + } + }; + + Console.debug("☑️ CurrentAirQuality"); + const airQualityCurrent = await this.#AirQualityCurrent(); + if (!Array.isArray(airQualityCurrent.pollutants)) { + Console.error("AirQuality", "Failed to get current air quality data."); + return { + metadata: this.#Metadata( + // TODO: &lang=zh + `https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, + undefined, + true, + ), + pollutants: [], + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + } + + const supportedIndex = findSupportedIndex(airQualityCurrent.indexes); + const scale = indexCodeToScale(supportedIndex?.code); + + const particularAirQuality = { + metadata: this.#Metadata( + // TODO: &lang=zh + `https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, + ), + pollutants: this.#CreatePollutants(airQualityCurrent.pollutants, supportedIndex?.code), + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + + if (!supportedIndex?.code || !scale?.categories) { + Console.error("AirQuality", "No supported index found", `airQualityCurrent.indexes[].code = ${JSON.stringify(airQualityCurrent.indexes?.map(({ code }) => code))}`); + return { + ...particularAirQuality, + index: -1, + isSignificant: false, + categoryIndex: -1, + primaryPollutant: "NOT_AVAILABLE", + scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale), + }; + } + + const categoryIndex = Number.parseInt(supportedIndex.level, 10); + const apiPrimaryPollutant = this.#Config.Pollutants[supportedIndex.primaryPollutant?.code] || "NOT_AVAILABLE"; + Console.debug(`apiPrimaryPollutant: ${apiPrimaryPollutant}`); + + if (!forcePrimaryPollutant && apiPrimaryPollutant === "NOT_AVAILABLE") { + Console.info("CurrentAirQuality", "Max index of pollutants is <= 50, primaryPollutant will be NOT_AVAILABLE."); + } + + const airQuality = { + metadata: this.#Metadata( + // TODO: &lang=zh + `https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, + ), + categoryIndex, + index: supportedIndex.aqi, + isSignificant: categoryIndex >= scale.categories.significantIndex, + ...particularAirQuality, + primaryPollutant: apiPrimaryPollutant, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + + if (airQuality.primaryPollutant === "NOT_AVAILABLE") { + const calculatedPrimaryPollutant = AirQuality.PrimaryPollutant(particularAirQuality.pollutants, scale.categories); + const isNotAvailable = !forcePrimaryPollutant && calculatedPrimaryPollutant.index <= 50; + if (isNotAvailable) { + Console.info("CurrentAirQuality", `Max index of pollutants ${calculatedPrimaryPollutant.pollutantType} = ${calculatedPrimaryPollutant.index} is <= 50, primaryPollutant will be set to NOT_AVAILABLE.`); + } + if (!isNotAvailable) airQuality.primaryPollutant = calculatedPrimaryPollutant.pollutantType; + } + + Console.debug("✅ CurrentAirQuality"); + return airQuality; + } + + async prefetchYesterdayAirQuality(locationInfo) { + Console.debug("☑️ prefetchYesterdayAirQuality"); + if (locationInfo && locationInfo.id && locationInfo.id.length === 9) { + this.#cache.historicalAir = this.#HistoricalAir(locationInfo.id); + const result = await this.#cache.historicalAir; + Console.debug("✅ prefetchYesterdayAirQuality"); + return result; + } + Console.warn("prefetchYesterdayAirQuality", "Invalid locationInfo"); + return null; + } + + async YesterdayAirQuality(locationInfo) { + Console.debug("☑️ YesterdayAirQuality", "locationInfo:", locationInfo); + const failedAirQuality = { + metadata: this.#Metadata(undefined, undefined, true), + categoryIndex: -1, + pollutants: [], + }; + + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.AirQuality.includes(this.country)) { + Console.warn("YesterdayAirQuality", `Unsupported country: ${this.country}`); + return failedAirQuality; + } + + // Some locationID at Hong Kong and Macau with length 9 is supported + if (!locationInfo || locationInfo.iso === "TW" || locationInfo.id.length !== 9) { + Console.error("YesterdayAirQuality", "Unsupported location"); + return failedAirQuality; + } + + const historicalAir = await (this.#cache.historicalAir ?? this.#HistoricalAir(locationInfo.id)); + if (!historicalAir.airHourly) { + Console.error("YesterdayAirQuality", `Failed to get HistoricalAir(${locationInfo.id})`); + return failedAirQuality; + } + + const hour = new Date().getHours(); + const categoryIndex = Number.parseInt(historicalAir.airHourly[hour].level, 10); + const index = Number.parseInt(historicalAir.airHourly[hour].aqi, 10); + const pollutants = this.#CreatePollutantsV7(historicalAir.airHourly[hour]); + Console.debug(`hour: ${hour}`, `index: ${index}`); + + Console.debug("✅ YesterdayAirQuality", "pollutants:", pollutants, "categoryIndex:", categoryIndex); + return { + metadata: this.#Metadata(historicalAir.fxLink), + categoryIndex, + index, + pollutants, + primaryPollutant: this.#Config.Pollutants[historicalAir.airHourly[hour].primary] || "NOT_AVAILABLE", + scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale), + }; + } + + #ConvertTimeStamp(fxDate, time) { + const dateTime = `${fxDate}T${time}:00+08:00`; + return (new Date(dateTime).getTime() / 1000) | 0; + } +} diff --git a/src/wk3/class/SimplePrecisionMath.mjs b/src/wk3/class/SimplePrecisionMath.mjs new file mode 100644 index 00000000..1c7d4379 --- /dev/null +++ b/src/wk3/class/SimplePrecisionMath.mjs @@ -0,0 +1,29 @@ +export default class SimplePrecisionMath { + /** + * 乘法 + */ + static multiply(a, b, _decimals = 15) { + return a * b; + } + + /** + * 除法 + */ + static divide(a, b, _decimals = 15) { + return b === 0 ? 0 : a / b; + } + + /** + * 加法 + */ + static add(a, b, _decimals = 15) { + return a + b; + } + + /** + * 减法 + */ + static subtract(a, b, _decimals = 15) { + return a - b; + } +} diff --git a/src/wk3/class/WK3Alerts.mjs b/src/wk3/class/WK3Alerts.mjs new file mode 100644 index 00000000..2da3bbce --- /dev/null +++ b/src/wk3/class/WK3Alerts.mjs @@ -0,0 +1,273 @@ +import { Builder, ByteBuffer } from "flatbuffers"; +import WeatherAlerts from "./WeatherAlerts.mjs"; +import { buildNativeAlertDetailsURL, sameAlertTitle, isGenericAlertTitle } from "./NativeAlertDetails.mjs"; + +/** Verified against paired Apple v3 JSON / WK3 b66 responses (2026-11-04): + * Shanghai wind alert and Miami Coastal Flood Statement. NOT a WK2 codec. + * Root slot 9; collection: metadata=0, detailsUrl=1, alerts=2. + * Area-name slot 2 and slots >=22 of summaries remain UNVERIFIED. + * Severity EXTREME/SEVERE follows upstream @nsringo/weatherkit 2.0.5, + * dev b4a75db / published dev bundle 2026-11-07 19:05:32. + * Other enums remain observed-only. Unknown raw integers round-trip. + */ +export const WK3_ALERTS_ROOT_SLOT = 9; +export const WK3_ALERT_ENUMS = { + severity: { UNKNOWN: 0, EXTREME: 1, SEVERE: 2, MODERATE: 3, MINOR: 4 }, + significance: { UNKNOWN: 0, STATEMENT: 10 }, + urgency: { UNKNOWN: 0, EXPECTED: 2 }, + certainty: { UNKNOWN: 0, LIKELY: 2 }, + importance: { LOW: 2 }, + responses: { MONITOR: 7 }, +}; +const TEXT = { attributionUrl: 3, countryCode: 4, description: 5, token: 6, detailsUrl: 12, phenomenon: 13, source: 16, eventSource: 17 }; +const TIMES = { effectiveTime: 7, expireTime: 8, issuedTime: 9, eventOnsetTime: 10, eventEndTime: 11 }; +const ENUMS = { severity: 14, significance: 15, urgency: 18, certainty: 19, importance: 20 }; +const SOURCE = Symbol("WK3Alerts.originalArena"); +const uint = (v, max = 0xffffffff) => { + if (!Number.isInteger(v) || v < 0 || v > max) throw new RangeError("Expected unsigned wire integer"); + return v; +}; +function input(bytes) { + if (bytes instanceof ByteBuffer) return bytes.bytes().subarray(bytes.position()); + if (!(bytes instanceof Uint8Array)) throw new TypeError("Expected Uint8Array or ByteBuffer"); + return bytes; +} +function reader(bytes) { + const b = input(bytes), v = new DataView(b.buffer, b.byteOffset, b.byteLength); + const check = (p, n) => { if (!Number.isInteger(p) || p < 0 || p + n > b.length) throw new RangeError("Invalid WK3 offset"); }; + const u16 = p => { check(p, 2); return v.getUint16(p, true); }; + const u32 = p => { check(p, 4); return v.getUint32(p, true); }; + const indirect = p => { const q = p + u32(p); check(q, 4); return q; }; + const field = (t, i) => { + check(t, 4); const vt = t - v.getInt32(t, true), n = u16(vt), size = u16(vt + 2); + check(vt, n); check(t, size); + if (n < 4 || n % 2 || size < 4) throw new RangeError("Invalid WK3 table"); + const o = 4 + i * 2 < n ? u16(vt + 4 + i * 2) : 0; + if (o && (o < 4 || o >= size)) throw new RangeError("Invalid WK3 field"); + return o ? t + o : 0; + }; + const string = p => { + if (!p) return null; const q = indirect(p), n = u32(q); check(q + 4, n + 1); + if (b[q + 4 + n]) throw new RangeError("Invalid WK3 string"); + return new TextDecoder("utf-8", { fatal: true }).decode(b.subarray(q + 4, q + 4 + n)); + }; + const vector = (p, width) => { if (!p) return { start: 0, length: 0 }; const q = indirect(p), length = u32(q); check(q + 4, length * width); return { start: q + 4, length }; }; + return { b, v, check, u16, u32, indirect, field, string, vector }; +} +function collection(r) { const root = r.indirect(0), f = r.field(root, WK3_ALERTS_ROOT_SLOT); return f ? r.indirect(f) : 0; } +function summary(r, t) { + const data = {}; + const id = r.field(t, 0); + if (id) { + const f = r.field(r.indirect(id), 0), vector = r.vector(f, 1); + if (vector.length !== 16) throw new RangeError("Expected 16 UUID bytes"); + data.id = { bytes: Array.from(r.b.subarray(vector.start, vector.start + 16)) }; + } else data.id = null; + const area = r.field(t, 1); data.areaId = r.string(area); + for (const [k, i] of Object.entries(TEXT)) data[k] = r.string(r.field(t, i)); + for (const [k, i] of Object.entries(TIMES)) { const p = r.field(t, i); data[k] = p ? r.u32(p) : 0; } + for (const [k, i] of Object.entries(ENUMS)) { const p = r.field(t, i); data[k] = p ? r.b[p] : 0; } + const responses = r.vector(r.field(t, 21), 1); + data.responses = Array.from(r.b.subarray(responses.start, responses.start + responses.length)); + return data; +} +/** Read raw numeric enums, never WK2 symbolic mappings. Opaque source is kept + * for codec encoding. The input must not be mutated after decoding. */ +export function decodeWK3Alerts(bytes) { + const r = reader(bytes), c = collection(r); if (!c) return null; + const vector = r.vector(r.field(c, 2), 4); + const data = { detailsUrl: r.string(r.field(c, 1)), alerts: Array.from({ length: vector.length }, (_, i) => summary(r, r.indirect(vector.start + i * 4))) }; + Object.defineProperty(data, SOURCE, { value: r.b, enumerable: true }); + return data; +} +function hasUnverifiedFields(r, t) { + const vt = t - r.v.getInt32(t, true), count = (r.u16(vt) - 4) / 2; + const idField = r.field(t, 0); + if (idField) { + const id = r.indirect(idField), idVtable = id - r.v.getInt32(id, true), idCount = (r.u16(idVtable) - 4) / 2; + if (Array.from({ length: Math.max(0, idCount - 1) }, (_, i) => r.field(id, i + 1)).some(Boolean)) return true; + } + return Boolean(r.field(t, 2)) || Array.from({ length: Math.max(0, count - 22) }, (_, i) => r.field(t, i + 22)).some(Boolean); +} +function encodeSummary(data) { + const b = new Builder(), strings = {}; + for (const [k, i] of Object.entries({ areaId: 1, ...TEXT })) { + if (data[k] != null) { if (typeof data[k] !== "string") throw new TypeError(`Expected string ${k}`); strings[i] = b.createString(data[k]); } + } + let id = 0; + if (data.id) { + if (!Array.isArray(data.id.bytes) || data.id.bytes.length !== 16) throw new TypeError("Expected UUID byte array"); + b.startVector(1, 16, 1); for (const byte of [...data.id.bytes].reverse()) b.addInt8(uint(byte, 255)); + const vector = b.endVector(); b.startObject(1); b.addFieldOffset(0, vector, 0); id = b.endObject(); + } + if (!Array.isArray(data.responses)) throw new TypeError("Expected raw responses array"); + b.startVector(1, data.responses.length, 1); + for (const response of [...data.responses].reverse()) b.addInt8(uint(response, 255)); + const responses = b.endVector(); + b.startObject(22); b.addFieldOffset(0, id, 0); + for (const [i, offset] of Object.entries(strings)) b.addFieldOffset(Number(i), offset, 0); + for (const [k, i] of Object.entries(TIMES)) b.addFieldInt32(i, uint(data[k]), 0); + for (const [k, i] of Object.entries(ENUMS)) b.addFieldInt8(i, uint(data[k], 255), 0); + b.addFieldOffset(21, responses, 0); b.finish(b.endObject()); return b.asUint8Array(); +} +/** Replace existing summaries only. Never invent IDs, remove/add/reorder alerts, + * rebuild the weather root, touch AQ, or discard unverified summary fields. + * Unchanged data returns the identical input view; failures throw atomically. + * Use in a try/catch and retain rawBody on failure. */ +export function patchWK3Alerts(bytes, alerts, options = {}) { + const r = reader(bytes), c = collection(r); + if (!c) { if (alerts?.length) throw new RangeError("Apple response has no alerts collection"); return r.b; } + const vector = r.vector(r.field(c, 2), 4); + if (!Array.isArray(alerts) || alerts.length !== vector.length) throw new RangeError("Cannot add/remove WK3 alerts"); + const replacements = []; + for (let i = 0; i < alerts.length; i++) { + const p = vector.start + i * 4, t = r.indirect(p), before = summary(r, t), after = alerts[i]; + if (!after || JSON.stringify(after.id) !== JSON.stringify(before.id)) throw new TypeError("Cannot change WK3 alert identity/order"); + const known = new Set(["id", "areaId", ...Object.keys(TEXT), ...Object.keys(TIMES), ...Object.keys(ENUMS), "responses"]); + if (Object.keys(after).some(k => !known.has(k))) throw new TypeError("Unverified WK3 alert field"); + if (JSON.stringify(after) === JSON.stringify(before)) continue; + if (hasUnverifiedFields(r, t)) throw new RangeError("Unverified WK3 summary fields; preserve original"); + replacements.push({ p, bytes: encodeSummary(after) }); + } + let out = r.b; + for (const replacement of replacements) { + const start = Math.ceil(out.length / 8) * 8, next = new Uint8Array(start + replacement.bytes.length); + next.set(out); next.set(replacement.bytes, start); + new DataView(next.buffer).setUint32(replacement.p, start + new DataView(replacement.bytes.buffer, replacement.bytes.byteOffset).getUint32(0, true) - replacement.p, true); + out = next; + } + if (options.detailsUrl !== undefined) { + const p = r.field(c, 1); + if (!p || typeof options.detailsUrl !== "string") throw new TypeError("Expected existing collection URL field"); + if (options.detailsUrl !== r.string(p)) { + const value = new TextEncoder().encode(options.detailsUrl), start = Math.ceil(out.length / 4) * 4; + const next = new Uint8Array(start + value.length + 5); next.set(out); next.set(value, start + 4); + const v = new DataView(next.buffer); v.setUint32(start, value.length, true); v.setUint32(p, start - p, true); out = next; + } + } + return out; +} +/** Conservative provider enrichment: keeps Apple identity, URLs and all + * non-empty fields; unsupported symbolic enums are not guessed or written. + * Caller supplies unambiguously matched source records in Apple array order. */ +export function enrichWK3Alerts(original, matchedSources) { + if (!Array.isArray(matchedSources) || original.alerts.length !== matchedSources.length) throw new RangeError("Expected one matched source per Apple alert"); + return original.alerts.map((alert, i) => { + const source = matchedSources[i]; if (!source) return alert; + const out = { ...alert }; + if (source.description) out.description = source.description; + for (const key of ["source", "areaId", "phenomenon", "token"]) if (!out[key] && source[key]) out[key] = source[key]; + for (const key of Object.keys(TIMES)) { + if (out[key] || source[key] == null) continue; + const time = typeof source[key] === "number" ? source[key] : Date.parse(source[key]) / 1000; + if (Number.isInteger(time) && time > 0 && time <= 0xffffffff) out[key] = time; + } + if (!out.eventEndTime && out.expireTime) out.eventEndTime = out.expireTime; + if (!out.eventOnsetTime && out.effectiveTime) out.eventOnsetTime = out.effectiveTime; + for (const key of Object.keys(ENUMS)) { + if (out[key]) continue; + const token = String(source[key] ?? "").toUpperCase(), value = WK3_ALERT_ENUMS[key][token]; + if (value !== undefined) out[key] = value; + } + return out; + }); +} +/** FlatBufferRootProcessor-compatible codec. Requires a decoded source arena; + * not a from-scratch weather/schema encoder. Root accessor must be slot 9. */ +export class WK3AlertsTable { __init(position, bb) { this.bb_pos = position; this.bb = bb; return this; } } +export const wk3AlertsCodec = { + tableClass: WK3AlertsTable, + decode(table) { + // The processor passes an arena with an arbitrary child table position. + const source = table.bb.bytes(), wrapper = new Uint8Array(40 + source.length); wrapper.set(source, 40); + const v = new DataView(wrapper.buffer); + v.setUint32(0, 28, true); v.setUint16(4, 24, true); v.setUint16(6, 8, true); v.setUint16(26, 4, true); + v.setInt32(28, 24, true); v.setUint32(32, 40 + table.bb_pos - 32, true); + return decodeWK3Alerts(wrapper); + }, + encode(builder, data) { + if (!data?.[SOURCE]) throw new TypeError("WK3 alerts codec requires decoded source arena"); + const bytes = patchWK3Alerts(data[SOURCE], data.alerts, { detailsUrl: data.detailsUrl }), r = reader(bytes), c = collection(r); + builder.prep(8, bytes.length); builder.pad(bytes.length); + builder.dataBuffer().bytes().set(bytes, builder.space); + return builder.offset() - c; + }, +}; + +/** No API credential required. Complements Apple's existing CN alerts only; + * returns the original view on missing collection/page, ambiguity or failure. + * Details link is opt-in: enable ONLY after routing /api/v1/weatherAlerts region + * identifiers to WeatherAlerts.DetailsForQWeather(). Otherwise keep QWeather URL. + */ +export async function patchWK3QWeatherAlerts(bytes, { language = "zh-CN", timezone, fetchImpl = globalThis.fetch, rewriteDetails = false, report = {} } = {}) { + const raw = input(bytes); + Object.assign(report, { total: 0, matched: 0, summariesUpdated: 0, detailsConverted: false, reason: "no-alerts", unmatched: {}, titleMismatches: [] }); + let stage = "decode-failed"; + try { + const original = decodeWK3Alerts(raw); + if (!original?.alerts.length) return raw; + report.total = original.alerts.length; + report.reason = "unsupported-page-url"; + const identifier = WeatherAlerts.ParseWeatherAlertPageURL(original.detailsUrl); + if (!identifier) return raw; + stage = "page-fetch-failed"; + const extracted = await WeatherAlerts.FetchWeatherAlertPage(identifier, language, fetchImpl); + report.reason = "empty-page"; + if (!extracted.alerts.length) return raw; + stage = "page-build-failed"; + const context = { identifier, language, countryCode: "CN", eventSource: "CN", attributionUrl: original.detailsUrl }; + const sources = WeatherAlerts.Build(extracted, context), used = new Set(); + const unmatched = reason => { report.unmatched[reason] = (report.unmatched[reason] || 0) + 1; return null; }; + const matched = original.alerts.map(alert => { + // An observed generic Apple title carries no event name. It may + // resolve only to a named, unique source with exact severity/time; + // never use array order or discard used candidates to force a match. + const generic = isGenericAlertTitle(alert.description); + if (generic && !alert.issuedTime) return unmatched("missing-issued-time"); + let candidates = sources.map((source, index) => ({ source, index })) + .filter(({ source }) => generic + ? typeof source.description === "string" && source.description.trim() && !isGenericAlertTitle(source.description) + : sameAlertTitle(alert.description, source.description)); + if (!candidates.length) { + // Public warning titles only: no URL, UUID, coordinates or credentials. + const title = value => typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 320) : null; + if (report.titleMismatches.length < 4) report.titleMismatches.push({ + appleTitle: title(alert.description), + pageTitles: extracted.alerts.slice(0, 8).map(source => title(source.description)), + parsedTitles: sources.slice(0, 8).map(source => title(source.description)), + }); + return unmatched("title-mismatch"); + } + if (![WK3_ALERT_ENUMS.severity.EXTREME, WK3_ALERT_ENUMS.severity.SEVERE, WK3_ALERT_ENUMS.severity.MODERATE, WK3_ALERT_ENUMS.severity.MINOR].includes(alert.severity)) return unmatched("unverified-severity"); + candidates = candidates.filter(({ source }) => WK3_ALERT_ENUMS.severity[String(source.severity).toUpperCase()] === alert.severity); + if (!candidates.length) return unmatched("severity-mismatch"); + candidates = candidates.filter(({ source }) => !alert.issuedTime || Date.parse(source.issuedTime) / 1000 === alert.issuedTime); + if (!candidates.length) return unmatched("issued-time-mismatch"); + if (candidates.length !== 1 || used.has(candidates[0].index)) return unmatched("ambiguous-match"); + used.add(candidates[0].index); return candidates[0].source; + }); + report.matched = matched.filter(Boolean).length; + report.reason = "unmatched-alerts"; + if (!report.matched) return raw; + let alerts = enrichWK3Alerts(original, matched); + // Source publication platform is not the issuer. With an exact match, + // prefer the observed page's actual issuing meteorological office. + alerts = alerts.map((alert, i) => matched[i] && /^(国家预警信息发布中心|QWeather|和风天气)$/.test(alert.source ?? "") ? { ...alert, source: matched[i].source } : alert); + const summariesUpdated = alerts.filter((alert, i) => JSON.stringify(alert) !== JSON.stringify(original.alerts[i])).length; + const options = {}; + let detailsConverted = false; + if (rewriteDetails && report.matched === report.total) { + // All events must match before replacing the shared URL. + stage = "details-build-failed"; + const url = buildNativeAlertDetailsURL({ identifier, language, timezone, alerts }); + alerts = alerts.map(alert => ({ ...alert, detailsUrl: url })); options.detailsUrl = url; + detailsConverted = true; + } + stage = "summary-patch-failed"; + const patched = patchWK3Alerts(raw, alerts, options); + // Publish successful counts only after the atomic patch completes. + Object.assign(report, { summariesUpdated, detailsConverted, + reason: detailsConverted ? "converted" : rewriteDetails ? "unmatched-alerts" : "details-disabled" }); + return patched; + } catch { report.reason = stage; return raw; } +} diff --git a/src/wk3/class/WK3Comparison.mjs b/src/wk3/class/WK3Comparison.mjs new file mode 100644 index 00000000..6f567ab8 --- /dev/null +++ b/src/wk3/class/WK3Comparison.mjs @@ -0,0 +1,126 @@ +/** WK3 b66 comparison enums verified with paired Apple v3 JSON/FlatBuffer. + * 2026-11-04: US San Francisco + Chicago BETTER=4; Denver WORSE=2; + * Toronto BETTER=4 independently. AQ index/category/scale/provider/reportedTime + * matched in each pair. Earlier paired samples verified UNKNOWN=0, SAME=3. + * These are NOT WK2 ComparisonTrend values. Private responses are not fixtures. + */ +export const WK3_COMPARISON = { UNKNOWN: 0, WORSE: 2, SAME: 3, BETTER: 4 }; + +// Menu standards with ascending category numbers. Versions are deliberately +// allowlisted: a future version may change category meanings. Only the observed +// CN/US pair is compatible across versions; other standards require exact scale. +const MENU_STANDARDS = new Set([ + "HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", + "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", + "ES.MITECO", "SG.NEA", "NAQI", "DAQI", "HK.AQHI", "CN.AQHI", +]); +const ALGORITHM_STANDARDS = new Map([ + ["UBA", "UBA"], ["EU_EAQI", "EU.EAQI"], + ["WAQI_InstantCast_US", "EPA_NowCast"], ["WAQI_InstantCast_CN", "HJ6332012"], + ["WAQI_InstantCast_CN_25_DRAFT", "HJ6332012"], + ["CA_AQHI", "CA.AQHI"], ["HK_AQHI", "HK.AQHI"], + ["CN_DEATH_AQHI", "CN.AQHI"], ["CN_DEATH_HK_AQHI", "CN.AQHI"], + ["AQHI_Multi_CN", "CA.AQHI"], ["AQHI_Multi_CN_HK", "CA.AQHI"], +]); +function standard(scale) { + if (typeof scale !== "string") return null; + // These algorithms intentionally emit versionless names upstream. + if (["EU.EAQI", "HK.AQHI", "CA.AQHI", "CN.AQHI"].includes(scale)) return scale; + const match = /^(.*)\.(2414|2604)$/.exec(scale); + return match && MENU_STANDARDS.has(match[1]) ? match[1] : null; +} +function compatibleScales(current, yesterday) { + const name = standard(current); + return name && name === standard(yesterday) + && (current === yesterday || ["HJ6332012", "EPA_NowCast"].includes(name)); +} +function validAQ(data, calculated = false) { + const name = standard(data?.scale); + if (!data?.metadata || data.metadata.temporarilyUnavailable || !name) return false; + const six = ["HJ6332012", "EPA_NowCast"].includes(name); + // For menu standards without local category tables, trust the source's + // category number (as upstream does), bounded by WK3's signed int8 field. + const maxCategory = six || name === "EU.EAQI" ? 6 : name === "UBA" ? 5 : name.endsWith(".AQHI") ? 11 : 127; + if (!Number.isInteger(data.categoryIndex) || data.categoryIndex < 1 || data.categoryIndex > maxCategory) return false; + if (data.index === undefined) return true; + if (!Number.isInteger(data.index) || data.index < 0 || data.index > 32767) return false; + if (six) { + // Calculations may explicitly allow an AQI above 500; its category is 6. + if (!calculated && data.index > 500) return false; + const category = data.index <= 50 ? 1 : data.index <= 100 ? 2 : data.index <= 150 ? 3 : data.index <= 200 ? 4 : data.index <= 300 ? 5 : 6; + return category === data.categoryIndex; + } + // UBA and AQHI conversions expose categoryIndex as index. EAQI exposes its + // 0..60 display index, not the pollutant concentration or an AQHI risk sum. + if (calculated && (name === "UBA" || name.endsWith(".AQHI"))) return data.index === data.categoryIndex; + if (calculated && name === "EU.EAQI") return Math.min(6, Math.floor(data.index / 10) + 1) === data.categoryIndex; + return true; +} +function matchingSource(currentProvider, yesterdayProvider, scale) { + if (typeof currentProvider !== "string" || !currentProvider.trim() || currentProvider !== yesterdayProvider) return false; + // A bare Calculate does not identify either its source or its algorithm. + // Response must supply already computed pairs and explicit identities, e.g. + // Calculate:QWeather:UBA. Never turn a category-only history into pollutants. + if (currentProvider.startsWith("Calculate")) { + const parts = currentProvider.split(":"); + return parts.length === 3 && parts[0] === "Calculate" && parts[1].trim().length > 0 + && ALGORITHM_STANDARDS.get(parts[2]) === standard(scale); + } + const expected = { ColorfulCloudsUS: "EPA_NowCast", ColorfulCloudsCN: "HJ6332012", QWeather: "HJ6332012" }[currentProvider]; + return !expected || expected === standard(scale); +} + +/** Compare categories (not numeric AQIs) only within one explicitly identified + * provider AND standard. Equal categories mean SAME even if AQI numbers differ. + * Unknown/mismatched/unavailable data returns the verified WK3 UNKNOWN value. + * Provider identity must describe the actual index source, not a display label. + * Calculated pairs must already use the same source AND algorithm on both days. + */ +export function computeWK3Comparison(current, yesterday, { currentProvider, yesterdayProvider } = {}) { + const calculated = typeof currentProvider === "string" && currentProvider.startsWith("Calculate:"); + if (!validAQ(current, calculated) || !validAQ(yesterday, calculated) || !compatibleScales(current.scale, yesterday.scale) + || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return WK3_COMPARISON.UNKNOWN; + if (current.categoryIndex < yesterday.categoryIndex) return WK3_COMPARISON.BETTER; + if (current.categoryIndex > yesterday.categoryIndex) return WK3_COMPARISON.WORSE; + return WK3_COMPARISON.SAME; +} + +/** Return a new AQ object with a raw WK3 previousDayComparison, leaving index, + * pollutants, metadata, and input untouched. Never compare today's provider A + * with yesterday's provider B, nor fetch B's current AQ as a substitute for A. + * + * sources is the existing environments object ({ colorfulClouds, qWeather }); + * yesterday optionally supplies already fetched/converted data or its Promise. + * QWeather locationInfo must be supplied by the caller (reuse locationsGrid). + * No logging of exceptions: provider errors may contain private URLs/locations. + * Any failure clears a potentially stale/CN comparison to UNKNOWN, not the AQ. + */ +export async function injectWK3Comparison(current, { + currentProvider, yesterdayProvider = currentProvider, sources = {}, yesterday, locationInfo, +} = {}) { + const result = { ...current, previousDayComparison: WK3_COMPARISON.UNKNOWN }; + try { + // Attach a rejection handler even when mismatched inputs skip fetching. + const prefetched = yesterday === undefined ? undefined : Promise.resolve(yesterday); + prefetched?.catch(() => {}); + const calculated = typeof currentProvider === "string" && currentProvider.startsWith("Calculate:"); + if (!validAQ(current, calculated) || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return result; + let previous = prefetched; + if (previous === undefined) { + switch (yesterdayProvider) { + case "ColorfulCloudsUS": + case "ColorfulCloudsCN": + previous = sources.colorfulClouds?.YesterdayAirQuality(yesterdayProvider === "ColorfulCloudsUS"); + break; + case "QWeather": + if (locationInfo) previous = sources.qWeather?.YesterdayAirQuality(locationInfo); + break; + // WeatherKit or calculated data requires explicitly supplied yesterday. + } + } + result.previousDayComparison = computeWK3Comparison(current, await previous, { currentProvider, yesterdayProvider }); + } catch { + // Do not affect otherwise usable current AQ if provider acquisition fails. + } + return result; +} diff --git a/src/wk3/class/WK3NextHour.mjs b/src/wk3/class/WK3NextHour.mjs new file mode 100644 index 00000000..c29bfd37 --- /dev/null +++ b/src/wk3/class/WK3NextHour.mjs @@ -0,0 +1,222 @@ +import { ByteBuffer } from "flatbuffers"; +import { Metadata } from "../proto/apple/wk2.js"; + +/** Apple WK3 b66, paired original JSON/FlatBuffer responses (2026-11-04). + * Root 5; nextHour[0..5] = metadata, condition[], summary[], start, end, minutes[]. + * Unlike WK2, summary.condition is an enum vector. Only singleton vectors are + * verified: u8 vs u16 element width cannot be distinguished from those samples; + * both have identical singleton bytes. Multi-element vectors fail closed. + * Miami / Manchester / Atlanta confirmed nonzero minutes and summaries, tokens + * START/STOP/START_STOP/CONSTANT, FIRST_AT/SECOND_AT parameters. No WK2 enum reuse. + * Raw codec preserves numeric enums; symbolic provider conversion fails closed. + * Unknown fields require arena preservation by the caller, NOT decode/reencode. + */ +export const WK3_NEXT_HOUR_ROOT_SLOT = 5; +export const WK3_NEXT_HOUR_ENUMS = { + // Explicit WK3 exports from NSRingo dev b4a75db / @nsringo/weatherkit 2.0.5, + // VirgilClyne response.dev.bundle.js (2026-11-07 19:05:32). Not WK2 arithmetic. + // Snow members are upstream definitions, not newly device-verified captures. + // FLURRIES / POSSIBLE_FLURRIES have no numeric aliases; conversion may omit + // the complete descriptive condition vector after validating its source. + condition: { CLEAR: 1, SLEET: 4, POSSIBLE_SLEET: 5, HEAVY_RAIN: 6, RAIN: 7, DRIZZLE: 8, POSSIBLE_DRIZZLE: 9, HEAVY_SNOW: 10, SNOW: 11 }, + precipitation: { CLEAR: 1, RAIN: 2, SNOW: 3, SLEET: 4, HAIL: 5, MIXED: 7 }, + forecastToken: { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, STOP_START: 5, CONSTANT: 6 }, + parameterType: { FIRST_AT: 1, SECOND_AT: 2 }, + sourceType: { MODELED: 2, STATION: 3 }, +}; +const uint = (n = 0, max = 0xffffffff) => { + if (!Number.isInteger(n) || n < 0 || n > max) throw new RangeError("WK3 unsigned integer out of range"); + return n; +}; +const byte = n => uint(n, 255); +const float = (n = 0) => { + if (typeof n !== "number" || !Number.isFinite(Math.fround(n)) || n < 0) throw new TypeError("WK3 nonnegative finite float32 required"); + return n; +}; +const chance = n => uint(n, 100); +function range(bb, p, length) { + if (!Number.isSafeInteger(p) || p < 0 || !Number.isSafeInteger(length) || length < 0 || p + length > bb.capacity()) throw new RangeError("Truncated WK3 nextHour"); +} +class Table { + __init(p, bb) { this.bb_pos = p; this.bb = bb; return this; } + check(slots) { + range(this.bb, this.bb_pos, 4); + const v = this.bb_pos - this.bb.readInt32(this.bb_pos); + range(this.bb, v, 4); + const length = this.bb.readUint16(v), size = this.bb.readUint16(v + 2); + if (length < 4 || length % 2 || (length - 4) / 2 > slots || size < 4) throw new TypeError("Unsupported WK3 nextHour table layout"); + range(this.bb, v, length); range(this.bb, this.bb_pos, size); + for (let i = 4; i < length; i += 2) { + const o = this.bb.readUint16(v + i); + if (o && (o < 4 || o >= size)) throw new RangeError("Invalid WK3 field offset"); + } + return this; + } + field(i, width = 4) { + const o = this.bb.__offset(this.bb_pos, 4 + 2 * i); + if (!o) return 0; + const v = this.bb_pos - this.bb.readInt32(this.bb_pos); + if (o + width > this.bb.readUint16(v + 2)) throw new RangeError("Invalid WK3 scalar width"); + range(this.bb, this.bb_pos + o, width); return this.bb_pos + o; + } + scalar(i, kind = "readUint32") { + const p = this.field(i, kind === "readUint8" ? 1 : 4); + return p ? this.bb[kind](p) : 0; + } + child(i) { + const p = this.field(i); if (!p) return null; + const target = p + this.bb.readUint32(p); range(this.bb, target, 4); + return new Table().__init(target, this.bb); + } + vector(i, bytes = false) { + const f = this.field(i); if (!f) return []; + const p = f + this.bb.readUint32(f); range(this.bb, p, 4); + const n = this.bb.readUint32(p); + if (bytes && n !== 1) throw new TypeError("Unverified WK3 multi-condition summary vector"); + range(this.bb, p + 4, n * (bytes ? 1 : 4)); + return Array.from({ length: n }, (_, k) => { + if (bytes) return this.bb.readUint8(p + 4 + k); + const f = p + 4 + k * 4, target = f + this.bb.readUint32(f); range(this.bb, target, 4); + return new Table().__init(target, this.bb); + }); + } +} +export class WK3NextHourTable extends Table {} +function metadataDecode(t) { + if (!t) return null; + t.check(11); + const m = new Metadata().__init(t.bb_pos, t.bb); + return { attributionUrl: m.attributionUrl(), expireTime: m.expireTime(), language: m.language(), latitude: m.latitude(), longitude: m.longitude(), providerLogo: m.providerLogo(), providerName: m.providerName(), readTime: m.readTime(), reportedTime: m.reportedTime(), temporarilyUnavailable: m.temporarilyUnavailable(), sourceType: m.sourceType() & 255 }; +} +function parameterDecode(t) { t.check(2); return { type: t.scalar(0, "readUint8"), date: t.scalar(1) }; } +function conditionDecode(t) { + t.check(6); + return { startTime: t.scalar(0), endTime: t.scalar(1), forecastToken: t.scalar(2, "readUint8"), beginCondition: t.scalar(3, "readUint8"), endCondition: t.scalar(4, "readUint8"), parameters: t.vector(5).map(parameterDecode) }; +} +function summaryDecode(t) { + t.check(5); + return { startTime: t.scalar(0), endTime: t.scalar(1), condition: t.vector(2, true), precipitationChance: t.scalar(3, "readUint8"), precipitationIntensity: t.scalar(4, "readFloat32") }; +} +function minuteDecode(t) { + t.check(4); + return { startTime: t.scalar(0), precipitationChance: t.scalar(1, "readUint8"), precipitationIntensity: t.scalar(2, "readFloat32"), perceivedPrecipitationIntensity: t.scalar(3, "readFloat32") }; +} +export function decodeNextHour(table) { + const t = new WK3NextHourTable().__init(table.bb_pos, table.bb).check(6); + return { metadata: metadataDecode(t.child(0)), condition: t.vector(1).map(conditionDecode), summary: t.vector(2).map(summaryDecode), forecastStart: t.scalar(3), forecastEnd: t.scalar(4), minutes: t.vector(5).map(minuteDecode) }; +} +export function decodeWK3NextHour(bytes) { + const bb = bytes instanceof ByteBuffer ? bytes : new ByteBuffer(bytes), start = bb.position(); + range(bb, start, 4); + const root = new Table().__init(start + bb.readUint32(start), bb); + // Root may have arbitrary future slots; only inspect root 5. + root.check(32765); + const t = root.child(WK3_NEXT_HOUR_ROOT_SLOT); + return t ? decodeNextHour(t) : null; +} +function vector(b, entries, bytes = false) { + if (bytes && entries.length !== 1) throw new TypeError("Unverified WK3 multi-condition summary vector"); + b.startVector(bytes ? 1 : 4, entries.length, bytes ? 1 : 4); + for (let i = entries.length - 1; i >= 0; i--) bytes ? b.addInt8(byte(entries[i])) : b.addOffset(entries[i]); + return b.endVector(); +} +function string(b, value) { + if (value == null) return 0; + if (typeof value !== "string") throw new TypeError("WK3 metadata string required"); + return b.createString(value); +} +function metadataEncode(b, m) { + if (!m) return 0; + const signedFloat = n => { if (typeof n !== "number" || !Number.isFinite(Math.fround(n))) throw new TypeError("WK3 metadata finite float required"); return n; }; + return Metadata.createMetadata(b, string(b, m.attributionUrl), uint(m.expireTime), string(b, m.language), signedFloat(m.latitude ?? 0), signedFloat(m.longitude ?? 0), string(b, m.providerLogo), string(b, m.providerName), uint(m.readTime), uint(m.reportedTime), m.temporarilyUnavailable ?? false, byte(m.sourceType)); +} +function parameterEncode(b, p) { + const type = byte(p.type), date = uint(p.date); + b.startObject(2); b.addFieldInt8(0, type, 0); b.addFieldInt32(1, date, 0); return b.endObject(); +} +function conditionEncode(b, c) { + const start = uint(c.startTime), end = uint(c.endTime), token = byte(c.forecastToken), begin = byte(c.beginCondition), finish = byte(c.endCondition); + const parameters = vector(b, (c.parameters ?? []).map(p => parameterEncode(b, p))); + b.startObject(6); b.addFieldInt32(0, start, 0); b.addFieldInt32(1, end, 0); b.addFieldInt8(2, token, 0); b.addFieldInt8(3, begin, 0); b.addFieldInt8(4, finish, 0); b.addFieldOffset(5, parameters, 0); return b.endObject(); +} +function summaryEncode(b, s) { + if (!Array.isArray(s.condition)) throw new TypeError("WK3 summary.condition must be an enum array, not a WK2 scalar"); + const start = uint(s.startTime), end = uint(s.endTime), probability = chance(s.precipitationChance), intensity = float(s.precipitationIntensity), conditions = vector(b, s.condition, true); + b.startObject(5); b.addFieldInt32(0, start, 0); b.addFieldInt32(1, end, 0); b.addFieldOffset(2, conditions, 0); b.addFieldInt8(3, probability, 0); b.addFieldFloat32(4, intensity, 0); return b.endObject(); +} +function minuteEncode(b, m) { + const start = uint(m.startTime), probability = chance(m.precipitationChance), intensity = float(m.precipitationIntensity), perceived = float(m.perceivedPrecipitationIntensity); + b.startObject(4); b.addFieldInt32(0, start, 0); b.addFieldInt8(1, probability, 0); b.addFieldFloat32(2, intensity, 0); b.addFieldFloat32(3, perceived, 0); return b.endObject(); +} +/** Return a CHILD offset. Caller finishes/appends/merges root 5 atomically. */ +export function encodeNextHour(b, data) { + const start = uint(data.forecastStart), end = uint(data.forecastEnd), metadata = metadataEncode(b, data.metadata); + const conditions = vector(b, (data.condition ?? []).map(c => conditionEncode(b, c))); + const summaries = vector(b, (data.summary ?? []).map(s => summaryEncode(b, s))); + const minutes = vector(b, (data.minutes ?? []).map(m => minuteEncode(b, m))); + b.startObject(6); b.addFieldOffset(0, metadata, 0); b.addFieldOffset(1, conditions, 0); b.addFieldOffset(2, summaries, 0); b.addFieldInt32(3, start, 0); b.addFieldInt32(4, end, 0); b.addFieldOffset(5, minutes, 0); return b.endObject(); +} +export const nextHourCodec = { tableClass: WK3NextHourTable, decode: decodeNextHour, encode: encodeNextHour }; + +function mapEnum(group, name) { + const dictionary = WK3_NEXT_HOUR_ENUMS[group]; + if (typeof name !== "string" || !Object.hasOwn(dictionary, name)) throw new TypeError(`Unsupported WK3 nextHour ${group}: ${name}`); + return dictionary[name]; +} +function requiredNumber(n, validate) { + if (typeof n !== "number") throw new TypeError("Missing provider nextHour number"); + return validate(n); +} +/** Convert ColorfulClouds.Minutely()/QWeather.Minutely() normalized output. + * Their precipitationIntensity is mm/h and precipitationChance is percent. + * Caller MUST explicitly attest units; no conversion of raw five-minute totals, + * probability fractions, radar dBZ, or guessing conditions from text here. + * Existing provider perceived intensity/condition/summary logic is not rederived. + * FLURRIES/POSSIBLE_FLURRIES have no WK3 numeric definition. After validating + * every source condition, omit the WHOLE descriptive vector if either occurs; + * retain verified SNOW summaries and actual minute values without enum guesses. + * Other unsupported enums, missing data, or inconsistent timelines still throw; + * caller catches and keeps the ORIGINAL child arena (including unknown fields). + * Optional report.omittedSnowConditions counts affected source segments. + */ +export function providerToWK3NextHour(data, { intensityUnit, chanceUnit, report = {} } = {}) { + if (intensityUnit !== "mm/h" || chanceUnit !== "percent") throw new TypeError("Explicit mm/h and percent units required"); + if (!data?.metadata?.providerName || data.metadata.temporarilyUnavailable) throw new TypeError("Missing available nextHour provider metadata"); + if (!Array.isArray(data.minutes) || !data.minutes.length || !Array.isArray(data.condition) || !data.condition.length || !Array.isArray(data.summary) || !data.summary.length) throw new TypeError("Missing nextHour minutes/condition/summary"); + const forecastStart = requiredNumber(data.forecastStart, uint), forecastEnd = requiredNumber(data.forecastEnd, uint); + const minutes = data.minutes.map((m, i) => { + const startTime = requiredNumber(m.startTime, uint); + if (startTime !== forecastStart + i * 60) throw new TypeError("Noncontiguous nextHour minutes"); + return { startTime, precipitationChance: requiredNumber(m.precipitationChance, chance), precipitationIntensity: requiredNumber(m.precipitationIntensity, float), perceivedPrecipitationIntensity: requiredNumber(m.perceivedPrecipitationIntensity, float) }; + }); + if (forecastEnd !== forecastStart + minutes.length * 60) throw new TypeError("Inconsistent nextHour forecastEnd"); + const segment = s => ({ startTime: requiredNumber(s.startTime, uint), endTime: s.endTime == null ? 0 : requiredNumber(s.endTime, uint) }); + const summary = data.summary.map(s => ({ ...segment(s), condition: (Array.isArray(s.condition) ? s.condition : [s.condition]).map(c => mapEnum("precipitation", c)), precipitationChance: requiredNumber(s.precipitationChance, chance), precipitationIntensity: requiredNumber(s.precipitationIntensity, float) })); + if (summary.some(s => s.condition.length !== 1)) throw new TypeError("Unverified WK3 multi-condition summary vector"); + let omittedSnowConditions = 0; + const condition = data.condition.map(c => { + if (!Array.isArray(c.parameters)) throw new TypeError("Missing condition parameters"); + // Validate tokens/parameters and both endpoints even if another endpoint + // is an unnamed snow description. Omission must never hide bad input. + const times = segment(c), forecastToken = mapEnum("forecastToken", c.forecastToken); + const parameters = c.parameters.map(p => ({ type: mapEnum("parameterType", p.type), date: requiredNumber(p.date, uint) })); + let omitted = false; + const conditionValue = name => { + if (name === "FLURRIES" || name === "POSSIBLE_FLURRIES") { omitted = true; return null; } + return mapEnum("condition", name); + }; + const beginCondition = conditionValue(c.beginCondition), endCondition = conditionValue(c.endCondition); + if (omitted) omittedSnowConditions++; + return { ...times, forecastToken, beginCondition, endCondition, parameters }; + }); + for (const segments of [summary, condition]) { + for (let i = 0; i < segments.length; i++) { + const s = segments[i], next = segments[i + 1]; + if (s.startTime !== (i === 0 ? forecastStart : segments[i - 1].endTime) || (next ? s.endTime !== next.startTime || s.endTime <= s.startTime || s.endTime >= forecastEnd : s.endTime !== 0)) throw new TypeError("Inconsistent nextHour segment timeline"); + } + } + if (omittedSnowConditions && !summary.some(s => s.condition[0] === WK3_NEXT_HOUR_ENUMS.precipitation.SNOW)) throw new TypeError("Unnamed snow conditions require a SNOW summary"); + const metadata = { ...data.metadata, sourceType: mapEnum("sourceType", data.metadata.sourceType) }; + report.omittedSnowConditions = omittedSnowConditions; + return { metadata, forecastStart, forecastEnd, minutes, summary, condition: omittedSnowConditions ? [] : condition }; +} diff --git a/src/wk3/class/Weather.mjs b/src/wk3/class/Weather.mjs new file mode 100644 index 00000000..b0c00a12 --- /dev/null +++ b/src/wk3/class/Weather.mjs @@ -0,0 +1,199 @@ +import { Console } from "../utils/index.mjs"; + +export default class Weather { + static Name = "Weather"; + static Version = "0.3.0"; + static Author = "Virgil Clyne & 001"; + + /** + * 将 DSWRF(W/m²)估算为 UV Index(整数) + * @param {number} dswrf - 向下短波辐射通量 + * @param {number} k - UV 占比系数,可选,默认 0.04 + * @returns {number} UV Index(四舍五入为整数) + */ + static ConvertDSWRF(dswrf, k = 0.04) { + Console.debug("☑️ ConvertDSWRF"); + const uvIndex = Math.round((Math.max(dswrf, 0) * k) / 0.025); // 估算 UV Index + //Console.debug(`UV Index: ${uvIndex}`); + Console.debug("✅ ConvertDSWRF"); + // 限制结果在 0~11,并四舍五入为整数 + return Math.min(uvIndex, 11); + } + + /** + * 将新的气象数组合并到原始气象数组 + * @param {array} to - 原始气象数组 + * @param {array} from - 新的气象数组 + * @returns {array} 原始气象数组 + */ + static mergeForecast(to = [], from = []) { + let i = 0, + j = 0; + while (i < to.length) { + const forecastStart = to[i].forecastStart; + const newForecastStart = j < from.length ? from[j].forecastStart : Number.POSITIVE_INFINITY; + + if (forecastStart === newForecastStart) { + //Console.debug(`${i}: ${newForecastStart} -> ${forecastStart}`); + // 原地把 from[j] 的字段合入 to[i](A 冲突字段保留 or 被覆盖,看你需要) + if (Object.hasOwn(from[j], "daytimeForecast")) from[j].daytimeForecast = { ...to[i].daytimeForecast, ...from[j].daytimeForecast }; + if (Object.hasOwn(from[j], "overnightForecast")) from[j].overnightForecast = { ...to[i].overnightForecast, ...from[j].overnightForecast }; + if (Object.hasOwn(from[j], "restOfDayForecast")) from[j].restOfDayForecast = { ...to[i].restOfDayForecast, ...from[j].restOfDayForecast }; + Object.assign(to[i], from[j]); // 或者:Object.assign(to[i], {/* 自定义映射 */}) + i++; + j++; + } else if (newForecastStart < forecastStart) { + //Console.debug(`${j}: ${newForecastStart} -> X`); + j++; // 让 from 追上 to + } else { + //Console.debug(`${i}: X -> ${forecastStart}`); + i++; // to 无匹配,保留 to[i] + } + } + return to; // 可选:返回同一个引用 + } + + static ConvertWeatherCode(skycon) { + switch (skycon) { + // 晴天 + case "晴": + case "CLEAR_DAY": + case "CLEAR_NIGHT": + return "CLEAR"; + + // 多云相关 + case "多云": + case "PARTLY_CLOUDY_DAY": + case "PARTLY_CLOUDY_NIGHT": + return "PARTLY_CLOUDY"; + case "少云": + return "MOSTLY_CLEAR"; + case "晴间多云": + return "PARTLY_CLOUDY"; + case "阴": + case "CLOUDY": + return "CLOUDY"; + + // 风相关 + case "WIND": + return "WINDY"; + + // 雾霾相关 + case "薄雾": + case "雾": + case "浓雾": + case "强浓雾": + case "大雾": + case "特强浓雾": + case "FOG": + return "FOGGY"; + case "霾": + case "中度霾": + case "重度霾": + case "严重霾": + case "LIGHT_HAZE": + case "MODERATE_HAZE": + case "HEAVY_HAZE": + return "HAZE"; + + // 沙尘相关(Apple 缺失 DUST/SAND 定义,暂用 HAZE 代替) + case "扬沙": + case "浮尘": + case "沙尘暴": + case "强沙尘暴": + case "DUST": + case "SAND": + return "HAZE"; + + // 降雨相关 + case "小雨": + case "毛毛雨/细雨": + case "LIGHT_RAIN": + return "DRIZZLE"; + case "雨": + case "阵雨": + case "中雨": + case "小到中雨": + case "MODERATE_RAIN": + return "RAIN"; + case "大雨": + case "中到大雨": + return "HEAVY_RAIN"; + case "暴雨": + case "大暴雨": + case "强降雨": + case "特大暴雨": + case "大到暴雨": + case "暴雨到大暴雨": + case "大暴雨到特大暴雨": + case "极端降雨": + case "HEAVY_RAIN": + return "HEAVY_RAIN"; + case "雷阵雨": + case "强雷阵雨": + case "STORM_RAIN": + case "雷阵雨伴有冰雹": + return "THUNDERSTORMS"; + + // 降雪相关 + case "小雪": + case "LIGHT_SNOW": + return "FLURRIES"; + case "雪": + case "阵雪": + case "中雪": + case "小到中雪": + case "MODERATE_SNOW": + return "SNOW"; + case "大雪": + case "中到大雪": + case "HEAVY_SNOW": + return "HEAVY_SNOW"; + case "暴雪": + case "大到暴雪": + case "STORM_SNOW": + return "BLIZZARD"; + + // 雨雪混合 + case "雨夹雪": + case "雨雪天气": + case "阵雨夹雪": + case "冻雨": + return "FREEZING_DRIZZLE"; + + // 温度相关 + case "热": + case "冷": + + // 未知 + case "未知": + default: + Console.debug(`skycon: ${skycon}`); + return null; + } + } + + static ConvertMoonPhase(moonPhase) { + switch (moonPhase) { + case "新月": + return "NEW"; + case "蛾眉月": + return "WAXING_CRESCENT"; + case "上弦月": + return "FIRST_QUARTER"; + case "盈凸月": + return "WAXING_GIBBOUS"; + case "满月": + return "FULL"; + case "亏凸月": + return "WANING_GIBBOUS"; + case "下弦月": + return "THIRD_QUARTER"; + case "残月": + return "WANING_CRESCENT"; + default: + Console.debug(`moonPhase: ${moonPhase}`); + return null; + } + } +} diff --git a/src/wk3/class/WeatherAlerts.mjs b/src/wk3/class/WeatherAlerts.mjs new file mode 100644 index 00000000..13680f98 --- /dev/null +++ b/src/wk3/class/WeatherAlerts.mjs @@ -0,0 +1,333 @@ +/** Pure subset adapted from NSRingo/WeatherKit v3.3.2 (Apache-2.0). + * REST details JSON only: do not pass its lowercase enum tokens to WK3 wire. + * Existing provider classes intentionally remain unchanged. */ +export default class WeatherAlerts { + /** No Apple authorization/cookies are forwarded to QWeather. fetchImpl may + * be the fork's fetch wrapper; both standard Response and body responses work. + * Routing must intercept region identifiers before proxying to Apple. */ + static async FetchWeatherAlertPage(identifier, language = "zh-CN", fetchImpl = globalThis.fetch) { + const url = WeatherAlerts.BuildWeatherAlertPageURL(identifier, language); + const response = await fetchImpl(url.toString(), { redirect: "error", headers: { Accept: "text/html" } }); + const status = response.status ?? response.statusCode; + if (status !== 200) throw new Error("QWeather alert page unavailable"); + const headers = response.headers; + const contentType = typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"]; + if (!/text\/html/i.test(contentType ?? "")) throw new TypeError("Expected QWeather HTML"); + const html = typeof response.text === "function" ? await response.text() : response.body; + if (typeof html !== "string" || new TextEncoder().encode(html).length > 2 * 1024 * 1024) throw new RangeError("Invalid QWeather page size"); + return WeatherAlerts.ExtractWeatherAlertPage(html); + } + + static async DetailsForQWeather(identifier, language = "zh-CN", fetchImpl = globalThis.fetch) { + const extracted = await WeatherAlerts.FetchWeatherAlertPage(identifier, language, fetchImpl); + return WeatherAlerts.Build(extracted, { identifier, language, attributionUrl: WeatherAlerts.BuildWeatherAlertPageURL(identifier, language), countryCode: "CN", eventSource: "CN" }); + } + + static Build(extracted, context) { + const contextAreaId = context.identifier.match(/-(\d+)$/)?.[1]; + return extracted.alerts.map((alert, precedence) => { + const uid = WeatherAlerts.#StableUUID(`${context.identifier}:${alert.identifier ?? precedence}`); + const messages = []; + for (const text of [alert.message, alert.standard, alert.guidelines?.filter(Boolean).join("\n")]) { + if (text) messages.push({ language: context.language, text }); + } + if (!messages.length && alert.description) messages.push({ language: context.language, text: alert.description }); + const responses = WeatherAlerts.#BuildResponses(alert.guidelines, alert.responses); + const areaId = alert.areaId || contextAreaId; + const areaName = alert.areaName || extracted.areaName; + const effectiveTime = alert.effectiveTime ?? alert.issuedTime; + const expireTime = alert.expireTime ?? "9999-12-31T23:59:59Z"; + const eventOnsetTime = alert.eventOnsetTime ?? effectiveTime; + const eventEndTime = alert.eventEndTime ?? (alert.expireTime ? expireTime : undefined); + const source = alert.source || extracted.source || ""; + const importance = alert.importance || WeatherAlerts.#ImportanceFromSeverity(alert.severity); + const phenomenon = alert.phenomenon; + const description = WeatherAlerts.#NormalizeWeatherAlertTitle(alert.description, alert.eventName); + return { + id: uid, + ...(areaId ? { areaId } : {}), + ...(areaName ? { areaName } : {}), + attributionURL: context.attributionUrl.toString(), + certainty: alert.certainty || "unknown", + countryCode: context.countryCode ?? "", + description, + detailsUrl: `#${uid}`, + effectiveTime, + ...(eventEndTime ? { eventEndTime } : {}), + ...(eventOnsetTime ? { eventOnsetTime } : {}), + eventSource: context.eventSource ?? context.countryCode ?? "", + expireTime, + issuedTime: alert.issuedTime, + ...(importance ? { importance } : {}), + messages, + name: "WeatherAlert", + ...(phenomenon ? { phenomenon } : {}), + precedence, + responses, + ...(alert.significance ? { significance: alert.significance } : {}), + reportedAt: alert.reportedAt, + severity: alert.severity, + source, + ...(alert.token ? { token: alert.token } : {}), + urgency: alert.urgency || "unknown", + }; + }); + } + + static ParseWeatherAlertPageURL(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "www.qweather.com") return undefined; + if (url.search !== "?from=AppleWeatherService" || url.hash) return undefined; + return decodeURIComponent(url.pathname).match(/^\/{1,2}(?:en\/)?severe-weather\/([^/]+)\.html$/)?.[1]; + } catch { + return undefined; + } + } + + static BuildWeatherAlertPageURL(identifier, language = "zh-CN", includeAppleSource = true) { + identifier = identifier?.trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*-\d+$/.test(identifier ?? "")) throw new TypeError("Invalid QWeather location identifier"); + const url = new URL("https://www.qweather.com"); + url.pathname = language.toLowerCase().startsWith("en") ? `/en/severe-weather/${identifier}.html` : `/severe-weather/${identifier}.html`; + if (includeAppleSource) url.searchParams.set("from", "AppleWeatherService"); + return url; + } + + static BuildAppleAlertDetailsURL(identifier, language = "zh-CN") { + return `https://weatherkit.apple.com/alertDetails/index.html?lang=${encodeURIComponent(language)}&ids=${encodeURIComponent(identifier)}&party=qweather`; + } + + static ExtractWeatherAlertPage(html) { + const sourceHtml = String(html ?? ""); + let city = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /]*class=["'][^"']*c-submenu__location[^"']*["'][^>]*>([\s\S]*?)<\/h1>/i); + if (!city) { + const titleText = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /([\s\S]*?)<\/title>/i); + city = titleText.replace(/\s*(?:severe weather warning|灾害预警|天气预警).*$/i, "").trim(); + } + const administration = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /<span[^>]*class=["'][^"']*c-submenu__location-adm[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); + const dataSource = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /<a[^>]*class=["'][^"']*data-source__txt[^"']*["'][^>]*>([\s\S]*?)<\/a>/i) + .replace(/^(?:预警数据来源|Warning data source)\s*[::]\s*/i, "") + .trim(); + const starts = Array.from(sourceHtml.matchAll(/<div[^>]*class=["']([^"']*\bc-city-warning-events\b[^"']*)["'][^>]*>/gi), match => ({ + index: match.index, + contentStart: match.index + match[0].length, + className: match[1], + })); + const alerts = []; + + for (const [index, start] of starts.entries()) { + let end = index + 1 < starts.length ? starts[index + 1].index : sourceHtml.length; + const nearby = sourceHtml.indexOf('<div class="c-city-warning-around">', start.contentStart); + if (nearby !== -1 && nearby < end) end = nearby; + const block = sourceHtml.slice(start.contentStart, end); + const description = WeatherAlerts.#FirstWeatherAlertPageMatch(block, /<h3[^>]*>([\s\S]*?)<\/h3>/i); + const issueText = WeatherAlerts.#FirstWeatherAlertPageMatch(block, /<p[^>]*>\s*((?:Issue\s+date|发布\s*日期)\s*[::][\s\S]*?)<\/p>/i) + .replace(/^(?:Issue\s+date|发布\s*日期)\s*[::]\s*/i, "") + .trim(); + const message = WeatherAlerts.#FirstWeatherAlertPageMatch(block, /<p[^>]*class=["'][^"']*warning-events__txt[^"']*["'][^>]*>([\s\S]*?)<\/p>/i); + const standardBlock = block.match(/<div[^>]*class=["'][^"']*warning-explain[^"']*["'][^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? ""; + const standard = WeatherAlerts.#FirstWeatherAlertPageMatch(standardBlock, /<h4[^>]*>[\s\S]*?<\/h4>\s*<p[^>]*>([\s\S]*?)<\/p>/i); + const guideBlock = block.match(/<div[^>]*class=["'][^"']*warning-defense__txt[^"']*["'][^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? ""; + const guidelines = Array.from(guideBlock.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi), match => + WeatherAlerts.#DecodeWeatherAlertPageHTML(match[1]) + .replace(/^\s*\d+[.、]\s*/, "") + .trim(), + ).filter(Boolean); + const normalizedIssueText = issueText && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(issueText) ? `${issueText.replace(" ", "T")}+08:00` : issueText; + const issueDate = new Date(normalizedIssueText); + if ((!description && !message) || Number.isNaN(issueDate.getTime())) continue; + const headline = description || message; + const parsedHeadline = WeatherAlerts.#ParseWeatherAlertPageHeadline(headline); + const source = parsedHeadline.source || dataSource || "QWeather"; + + const severitySource = `${start.className} ${description}`.toLowerCase(); + let severity = "unknown"; + switch (true) { + case /warning--red|红色|\bred\b/.test(severitySource): + severity = "extreme"; + break; + case /warning--orange|橙色|\borange\b/.test(severitySource): + severity = "severe"; + break; + case /warning--yellow|黄色|\byellow\b/.test(severitySource): + severity = "moderate"; + break; + case /warning--blue|蓝色|\bblue\b/.test(severitySource): + severity = "minor"; + break; + } + + alerts.push({ + description: headline, + ...(parsedHeadline.eventName ? { eventName: parsedHeadline.eventName } : {}), + guidelines, + issuedTime: issueDate.toISOString(), + message, + reportedAt: issueDate.toISOString(), + severity, + source, + standard, + }); + } + + return { + alerts, + areaName: city || administration, + source: alerts.find(alert => alert?.source)?.source || dataSource || "QWeather", + }; + } + + static #StableUUID(value) { + const words = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35]; + for (let index = 0; index < words.length; index++) { + for (let offset = 0; offset < value.length; offset++) { + words[index] ^= value.charCodeAt(offset) + index; + words[index] = Math.imul(words[index], 0x01000193); + } + } + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + for (let index = 0; index < words.length; index++) view.setUint32(index * 4, words[index]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + static #BuildResponses(guidelines, preferredResponses = []) { + const responses = WeatherAlerts.#NormalizeResponses(preferredResponses); + if (responses.length) return responses; + + const inferredResponses = []; + for (const guideline of guidelines ?? []) { + const response = WeatherAlerts.#ResponseFromGuideline(guideline); + if (response && !inferredResponses.includes(response)) inferredResponses.push(response); + } + return inferredResponses.length ? inferredResponses : (guidelines?.length ? ["monitor"] : []); + } + + static #ImportanceFromSeverity(severity) { + switch (String(severity ?? "").trim().toLowerCase()) { + case "extreme": + case "severe": + return "high"; + case "minor": + return "low"; + case "moderate": + default: + return "normal"; + } + } + + static #NormalizeWeatherAlertTitle(description, eventName = "") { + const title = String(description ?? "").trim(); + const fallback = WeatherAlerts.#TrimWeatherAlertTitle(eventName); + if (!title) return fallback; + + const chinese = title.match(/^.+?(?:发布|更新|变更)\s*[::]?\s*(.+)$/); + if (chinese?.[1]) return WeatherAlerts.#TrimWeatherAlertTitle(chinese[1]); + + const issued = title.match(/^(.+?)\s+issued\b\s*[::]?\s*(.+)$/i); + if (issued?.[1] && issued?.[2]) { + const titleBeforeIssued = WeatherAlerts.#TrimWeatherAlertTitle(issued[1]); + const fallbackMatchesPrefix = Boolean(fallback) && WeatherAlerts.#NormalizeAlertMatchText(fallback) === WeatherAlerts.#NormalizeAlertMatchText(titleBeforeIssued); + return fallbackMatchesPrefix ? fallback : WeatherAlerts.#FormatTranslatedEnglishAlertTitle(issued[2]) || fallback; + } + + const issues = title.match(/^.+?\s+issues?\b\s*[::]?\s*(.+)$/i); + return issues?.[1] ? WeatherAlerts.#FormatTranslatedEnglishAlertTitle(issues[1]) || fallback : WeatherAlerts.#TrimWeatherAlertTitle(title) || fallback; + } + + static #FirstWeatherAlertPageMatch(source, pattern, fallback = "") { + const match = source.match(pattern); + return match ? WeatherAlerts.#DecodeWeatherAlertPageHTML(match[1]) : fallback; + } + + static #DecodeWeatherAlertPageHTML(value) { + const normalized = String(value ?? "") + .replace(/<br\s*\/?\s*>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/ | /gi, " ") + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number))) + .replace(/&#x([0-9a-f]+);/gi, (_, number) => String.fromCodePoint(Number.parseInt(number, 16))); + return normalized.replace(/"/gi, '"').replace(/'/gi, "'").replace(/</gi, "<").replace(/>/gi, ">").replace(/&/gi, "&") + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + static #ParseWeatherAlertPageHeadline(description) { + const title = String(description ?? "").trim(); + const chinese = title.match(/^(.+?)发布\s*[::]?\s*(.+)$/); + if (chinese?.[1] && chinese?.[2]) return { eventName: chinese[2].trim(), source: chinese[1].trim() }; + + const cap = title.match(/^(.+?)\s+issued\b[\s\S]*\s+by\s+(.+)$/i); + if (cap?.[1] && cap?.[2]) return { eventName: cap[1].trim(), source: cap[2].trim() }; + + const issued = title.match(/^(.+?)\s+issued\b\s*[::]?\s*(.+)$/i); + if (issued?.[1] && issued?.[2]) { + const context = issued[2].trim(); + const capContext = /^(?:for\b|\d{1,2}[/:.-]|\p{L}+\s+(?:\d{1,2}\b|at\b|until\b))/iu.test(context); + return capContext ? { eventName: issued[1].trim(), source: "" } : { eventName: context, source: issued[1].trim() }; + } + + const issues = title.match(/^(.+?)\s+issues?\b\s*[::]?\s*(.+)$/i); + if (issues?.[1] && issues?.[2]) return { eventName: issues[2].trim(), source: issues[1].trim() }; + return { eventName: title, source: "" }; + } + + static #NormalizeResponses(responses) { + const normalized = []; + for (const response of responses ?? []) { + const token = String(response ?? "").trim(); + if (token && !normalized.includes(token)) normalized.push(token); + } + return normalized; + } + + static #ResponseFromGuideline(guideline) { + const text = String(guideline ?? "").trim().toLowerCase(); + if (!text) return null; + const compact = text.replace(/\s+/g, ""); + + if (/撤离|疏散|转移|离开/.test(text) || compact.includes("evacuat")) return "evacuate"; + if (/就地|躲避|避难|避险|避风|室内|躲到|进入室内|待在室内/.test(text) || compact.includes("takeshelter") || compact.includes("seekshelter")) return "shelter"; + if (/执行|实施|预案|计划/.test(text) || compact.includes("execute") || compact.includes("carryout") || compact.includes("implement")) return "execute"; + if (/准备|防范|防护|备好|做好.*准备/.test(text) || compact.includes("prepare") || compact.includes("preparations")) return "prepare"; + if (/远离|避免|不要|切勿|勿|别/.test(text) || compact.includes("avoid") || compact.includes("stayaway") || compact.includes("keepaway") || compact.includes("donot") || compact.includes("dont")) return "avoid"; + if (/密切关注|持续关注|关注|留意|监测|观察|跟踪/.test(text) || compact.includes("monitor") || compact.includes("watch") || compact.includes("followup")) return "monitor"; + if (/评估|检查/.test(text) || compact.includes("assess") || compact.includes("inspect")) return "assess"; + if (/解除|恢复正常/.test(text) || compact.includes("allclear")) return "allClear"; + if (/无需|不需|无须/.test(text) || compact.includes("none")) return "none"; + return null; + } + + static #TrimWeatherAlertTitle(title) { + return String(title ?? "") + .trim() + .replace(/\s*[\[【][^\]】]+[\]】]\s*$/u, "") + .replace(/\s*[。..]+\s*$/gu, "") + .replace(/预警信号$/u, "预警") + .replace(/預警信號$/u, "預警"); + } + + static #NormalizeAlertMatchText(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, "") + .replace(/预警信号|预警|警报|报告/g, ""); + } + + static #FormatTranslatedEnglishAlertTitle(title) { + return WeatherAlerts.#TrimWeatherAlertTitle(title) + .replace(/^(?:a|an|the)\s+/i, "") + .replace(/\b\p{L}/gu, character => character.toUpperCase()); + } + +} diff --git a/src/wk3/class/WeatherKit2.mjs b/src/wk3/class/WeatherKit2.mjs new file mode 100644 index 00000000..e77cd4dd --- /dev/null +++ b/src/wk3/class/WeatherKit2.mjs @@ -0,0 +1,1028 @@ +/** + * Product codecs ported from NSRingo/WeatherKit v3.3.2 (Apache-2.0). + * https://github.com/NSRingo/WeatherKit/blob/d3a218e2203d6d5e02630a0e41e0dd089be32d0f/src/class/WeatherKit2.mjs + * Local changes: use bundled models/root processor and expose standalone WK3 codecs. + */ +import { FlatBufferRootProcessor } from "./FlatBufferRootProcessor.mjs"; +import * as WK2 from "../proto/apple/wk2.js"; +import database from "../function/database.mjs"; + +/** + * 使用 Weather 根模型和产品 codec 配置通用根表处理器。 + * Configure the generic root-table processor with the Weather model and product codecs. + */ +const WeatherKit2 = new FlatBufferRootProcessor({ + codecs: { + airQuality: { decode: decodeAirQuality, encode: encodeAirQuality, tableClass: WK2.AirQuality }, + currentWeather: { decode: decodeCurrentWeather, encode: encodeCurrentWeather, tableClass: WK2.CurrentWeatherData }, + forecastDaily: { decode: decodeForecastDaily, encode: encodeForecastDaily, tableClass: WK2.DailyForecastData }, + forecastHourly: { decode: decodeForecastHourly, encode: encodeForecastHourly, tableClass: WK2.HourlyForecastData }, + forecastNextHour: { decode: decodeForecastNextHour, encode: encodeForecastNextHour, tableClass: WK2.NextHourForecastData }, + historicalComparisons: { decode: decodeHistoricalComparisons, encode: encodeHistoricalComparisons, tableClass: WK2.HistoricalComparison }, + locationInfo: { decode: decodeLocationInfo, encode: encodeLocationInfo, tableClass: WK2.LocationInfo }, + news: { decode: decodeNews, encode: encodeNews, tableClass: WK2.News }, + weatherAlerts: { decode: decodeWeatherAlerts, encode: encodeWeatherAlerts, tableClass: WK2.WeatherAlertCollectionData }, + weatherChanges: { decode: decodeWeatherChanges, encode: encodeWeatherChanges, tableClass: WK2.WeatherChanges }, + }, + configurableRootNames: database.WeatherKit.Settings.DataSets, + name: "WeatherKit2", + rootClass: WK2.Weather, +}); + +export default WeatherKit2; + +// Standalone product-table codecs for the WK3 response patcher. +export { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour }; + +function encodeAirQuality(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const pollutantsOffset = WK2.AirQuality.createPollutantsVector( + builder, + data.pollutants.map(p => WK2.Pollutant.createPollutant(builder, WK2.PollutantType[p.pollutantType], p.amount, WK2.UnitType[p.units])), + ); + const scaleOffset = builder.createString(data.scale); + return WK2.AirQuality.createAirQuality(builder, metadataOffset, data.categoryIndex, data.index, data.isSignificant, pollutantsOffset, WK2.ComparisonTrend[data.previousDayComparison], WK2.PollutantType[data.primaryPollutant], scaleOffset); +} + +function encodeCurrentWeather(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const precipitationAmountNext1hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext1hByTypeVector( + builder, + data.precipitationAmountNext1hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + const precipitationAmountNext24hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext24hByTypeVector( + builder, + data.precipitationAmountNext24hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + const precipitationAmountNext6hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext6hByTypeVector( + builder, + data.precipitationAmountNext6hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + const precipitationAmountPrevious1hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious1hByTypeVector( + builder, + data.precipitationAmountPrevious1hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + const precipitationAmountPrevious24hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious24hByTypeVector( + builder, + data.precipitationAmountPrevious24hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + const precipitationAmountPrevious6hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious6hByTypeVector( + builder, + data.precipitationAmountPrevious6hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + return WK2.CurrentWeatherData.createCurrentWeatherData( + builder, + metadataOffset, + data.asOf, + data.cloudCover, + data.cloudCoverLowAltPct, + data.cloudCoverMidAltPct, + data.cloudCoverHighAltPct, + WK2.WeatherCondition[data.conditionCode], + data.daylight, + data.humidity, + data.perceivedPrecipitationIntensity, + data.precipitationAmount1h, + data.precipitationAmount6h, + data.precipitationAmount24h, + data.precipitationAmountNext1h, + data.precipitationAmountNext6h, + data.precipitationAmountNext24h, + precipitationAmountNext1hByTypeOffset, + precipitationAmountNext6hByTypeOffset, + precipitationAmountNext24hByTypeOffset, + precipitationAmountPrevious1hByTypeOffset, + precipitationAmountPrevious6hByTypeOffset, + precipitationAmountPrevious24hByTypeOffset, + data.precipitationIntensity, + data.pressure, + WK2.PressureTrend[data.pressureTrend], + data.snowfallAmount1h, + data.snowfallAmount6h, + data.snowfallAmount24h, + data.snowfallAmountNext1h, + data.snowfallAmountNext6h, + data.snowfallAmountNext24h, + data.temperature, + data.temperatureApparent, + data.unknown34, + data.temperatureDewPoint, + data.uvIndex, + data.visibility, + data.windDirection, + data.windGust, + data.windSpeed, + ); +} + +function encodeForecastDaily(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const daysOffsets = data.days.map(day => { + const Offsets = {}; + Offsets.precipitationAmountByTypeOffest = WK2.DayWeatherConditions.createPrecipitationAmountByTypeVector( + builder, + day.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + if (day.daytimeForecast) { + Offsets.daytimeForecastPrecipitationAmountByTypeOffest = WK2.DayPartForecast.createPrecipitationAmountByTypeVector( + builder, + day.daytimeForecast.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + Offsets.daytimeForecastOffset = WK2.DayPartForecast.createDayPartForecast( + builder, + day.daytimeForecast.forecastStart, + day.daytimeForecast.forecastEnd, + day.daytimeForecast.cloudCover, + day.daytimeForecast.cloudCoverLowAltPct, + day.daytimeForecast.cloudCoverMidAltPct, + day.daytimeForecast.cloudCoverHighAltPct, + WK2.WeatherCondition[day.daytimeForecast.conditionCode], + day.daytimeForecast.humidity, + day.daytimeForecast.humidityMax, + day.daytimeForecast.humidityMin, + day.daytimeForecast.precipitationAmount, + Offsets.daytimeForecastPrecipitationAmountByTypeOffest, + day.daytimeForecast.precipitationChance, + WK2.PrecipitationType[day.daytimeForecast.precipitationType], + day.daytimeForecast.snowfallAmount, + day.daytimeForecast.temperatureMax, + day.daytimeForecast.temperatureMin, + day.daytimeForecast.visibilityMax, + day.daytimeForecast.visibilityMin, + day.daytimeForecast.windDirection, + day.daytimeForecast.windGustSpeedMax, + day.daytimeForecast.windSpeed, + day.daytimeForecast.windSpeedMax, + day.daytimeForecast.precipitationIntensityMax, + day.daytimeForecast.perceivedPrecipitationIntensityMax, + day.daytimeForecast.uvIndexMin, + day.daytimeForecast.uvIndexMax, + day.daytimeForecast.temperatureApparentMin, + day.daytimeForecast.temperatureApparentMax, + day.daytimeForecast.daylight, + ); + } + if (day.overnightForecast) { + Offsets.overnightForecastPrecipitationAmountByTypeOffest = WK2.DayPartForecast.createPrecipitationAmountByTypeVector( + builder, + day.overnightForecast.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + Offsets.overnightForecastOffset = WK2.DayPartForecast.createDayPartForecast( + builder, + day.overnightForecast.forecastStart, + day.overnightForecast.forecastEnd, + day.overnightForecast.cloudCover, + day.overnightForecast.cloudCoverLowAltPct, + day.overnightForecast.cloudCoverMidAltPct, + day.overnightForecast.cloudCoverHighAltPct, + WK2.WeatherCondition[day.overnightForecast.conditionCode], + day.overnightForecast.humidity, + day.overnightForecast.humidityMax, + day.overnightForecast.humidityMin, + day.overnightForecast.precipitationAmount, + Offsets.overnightForecastPrecipitationAmountByTypeOffest, + day.overnightForecast.precipitationChance, + WK2.PrecipitationType[day.overnightForecast.precipitationType], + day.overnightForecast.snowfallAmount, + day.overnightForecast.temperatureMax, + day.overnightForecast.temperatureMin, + day.overnightForecast.visibilityMax, + day.overnightForecast.visibilityMin, + day.overnightForecast.windDirection, + day.overnightForecast.windGustSpeedMax, + day.overnightForecast.windSpeed, + day.overnightForecast.windSpeedMax, + day.overnightForecast.precipitationIntensityMax, + day.overnightForecast.perceivedPrecipitationIntensityMax, + day.overnightForecast.uvIndexMin, + day.overnightForecast.uvIndexMax, + day.overnightForecast.temperatureApparentMin, + day.overnightForecast.temperatureApparentMax, + day.overnightForecast.daylight, + ); + } + if (day.restOfDayForecast) { + Offsets.restOfDayForecastPrecipitationAmountByTypeOffest = WK2.DayPartForecast.createPrecipitationAmountByTypeVector( + builder, + day.restOfDayForecast.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + Offsets.restOfDayForecastOffset = WK2.DayPartForecast.createDayPartForecast( + builder, + day.restOfDayForecast.forecastStart, + day.restOfDayForecast.forecastEnd, + day.restOfDayForecast.cloudCover, + day.restOfDayForecast.cloudCoverLowAltPct, + day.restOfDayForecast.cloudCoverMidAltPct, + day.restOfDayForecast.cloudCoverHighAltPct, + WK2.WeatherCondition[day.restOfDayForecast.conditionCode], + day.restOfDayForecast.humidity, + day.restOfDayForecast.humidityMax, + day.restOfDayForecast.humidityMin, + day.restOfDayForecast.precipitationAmount, + Offsets.restOfDayForecastPrecipitationAmountByTypeOffest, + day.restOfDayForecast.precipitationChance, + WK2.PrecipitationType[day.restOfDayForecast.precipitationType], + day.restOfDayForecast.snowfallAmount, + day.restOfDayForecast.temperatureMax, + day.restOfDayForecast.temperatureMin, + day.restOfDayForecast.visibilityMax, + day.restOfDayForecast.visibilityMin, + day.restOfDayForecast.windDirection, + day.restOfDayForecast.windGustSpeedMax, + day.restOfDayForecast.windSpeed, + day.restOfDayForecast.windSpeedMax, + day.restOfDayForecast.precipitationIntensityMax, + day.restOfDayForecast.perceivedPrecipitationIntensityMax, + day.restOfDayForecast.uvIndexMin, + day.restOfDayForecast.uvIndexMax, + day.restOfDayForecast.temperatureApparentMin, + day.restOfDayForecast.temperatureApparentMax, + day.restOfDayForecast.daylight, + ); + } + WK2.DayWeatherConditions.startDayWeatherConditions(builder); + WK2.DayWeatherConditions.addForecastStart(builder, day.forecastStart); + WK2.DayWeatherConditions.addForecastEnd(builder, day.forecastEnd); + WK2.DayWeatherConditions.addConditionCode(builder, WK2.WeatherCondition[day.conditionCode]); + WK2.DayWeatherConditions.addHumidityMax(builder, day.humidityMax); + WK2.DayWeatherConditions.addHumidityMin(builder, day.humidityMin); + WK2.DayWeatherConditions.addMaxUvIndex(builder, day.maxUvIndex); + WK2.DayWeatherConditions.addMoonPhase(builder, WK2.MoonPhase[day.moonPhase]); + WK2.DayWeatherConditions.addMoonrise(builder, day.moonrise); + WK2.DayWeatherConditions.addMoonset(builder, day.moonset); + WK2.DayWeatherConditions.addPrecipitationAmount(builder, day.precipitationAmount); + WK2.DayWeatherConditions.addPrecipitationAmountByType(builder, Offsets.precipitationAmountByTypeOffest); + WK2.DayWeatherConditions.addPrecipitationChance(builder, day.precipitationChance); + WK2.DayWeatherConditions.addPrecipitationType(builder, WK2.PrecipitationType[day.precipitationType]); + WK2.DayWeatherConditions.addSnowfallAmount(builder, day.snowfallAmount); + WK2.DayWeatherConditions.addSolarMidnight(builder, day.solarMidnight); + WK2.DayWeatherConditions.addSolarNoon(builder, day.solarNoon); + WK2.DayWeatherConditions.addSunrise(builder, day.sunrise); + WK2.DayWeatherConditions.addSunriseCivil(builder, day.sunriseCivil); + WK2.DayWeatherConditions.addSunriseNautical(builder, day.sunriseNautical); + WK2.DayWeatherConditions.addSunriseAstronomical(builder, day.sunriseAstronomical); + WK2.DayWeatherConditions.addSunset(builder, day.sunset); + WK2.DayWeatherConditions.addSunsetCivil(builder, day.sunsetCivil); + WK2.DayWeatherConditions.addSunsetNautical(builder, day.sunsetNautical); + WK2.DayWeatherConditions.addSunsetAstronomical(builder, day.sunsetAstronomical); + WK2.DayWeatherConditions.addTemperatureMax(builder, day.temperatureMax); + WK2.DayWeatherConditions.addTemperatureMaxTime(builder, day.temperatureMaxTime); + WK2.DayWeatherConditions.addTemperatureMin(builder, day.temperatureMin); + WK2.DayWeatherConditions.addTemperatureMinTime(builder, day.temperatureMinTime); + WK2.DayWeatherConditions.addVisibilityMax(builder, day.visibilityMax); + WK2.DayWeatherConditions.addVisibilityMin(builder, day.visibilityMin); + WK2.DayWeatherConditions.addWindGustSpeedMax(builder, day.windGustSpeedMax); + WK2.DayWeatherConditions.addWindSpeedAvg(builder, day.windSpeedAvg); + WK2.DayWeatherConditions.addWindSpeedMax(builder, day.windSpeedMax); + if (day.daytimeForecast) WK2.DayWeatherConditions.addDaytimeForecast(builder, Offsets.daytimeForecastOffset); + if (day.overnightForecast) WK2.DayWeatherConditions.addOvernightForecast(builder, Offsets.overnightForecastOffset); + if (day.restOfDayForecast) WK2.DayWeatherConditions.addRestOfDayForecast(builder, Offsets.restOfDayForecastOffset); + return WK2.DayWeatherConditions.endDayWeatherConditions(builder); + }); + const daysOffset = WK2.DailyForecastData.createDaysVector(builder, daysOffsets); + return WK2.DailyForecastData.createDailyForecastData(builder, metadataOffset, daysOffset); +} + +function encodeForecastHourly(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const hoursOffsets = data.hours.map(hour => + WK2.HourWeatherConditions.createHourWeatherConditions( + builder, + hour.forecastStart, + hour.cloudCover, + hour.cloudCoverLowAltPct, + hour.cloudCoverMidAltPct, + hour.cloudCoverHighAltPct, + WK2.WeatherCondition[hour.conditionCode], + hour.daylight, + hour.humidity, + hour.perceivedPrecipitationIntensity, + hour.precipitationAmount, + hour.precipitationIntensity, + hour.precipitationChance, + WK2.PrecipitationType[hour.precipitationType], + hour.pressure, + WK2.PressureTrend[hour.pressureTrend], + hour.snowfallAmount, + hour.snowfallIntensity, + hour.temperature, + hour.temperatureApparent, + hour.unknown20, + hour.temperatureDewPoint, + hour.uvIndex, + hour.visibility, + hour.windDirection, + hour.windGust, + hour.windSpeed, + ), + ); + const hoursOffset = WK2.HourlyForecastData.createHoursVector(builder, hoursOffsets); + return WK2.HourlyForecastData.createHourlyForecastData(builder, metadataOffset, hoursOffset); +} + +function encodeForecastNextHour(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + // 防御第三方数据:未知枚举传给 FlatBuffer 会静默编码为 CLEAR。 + // Defend against third-party data: unknown enums would otherwise be silently encoded as CLEAR. + const conditionOffsets = data.condition + .filter(condition => [condition.beginCondition, condition.endCondition].every(value => typeof WK2.ConditionType[value] === "number")) + .map(condition => { + const parametersOffsets = condition.parameters.map(parameter => WK2.Parameter.createParameter(builder, WK2.ParameterType[parameter.type], parameter.date)); + const parametersOffset = WK2.Condition.createParametersVector(builder, parametersOffsets); + return WK2.Condition.createCondition(builder, condition.startTime, condition.endTime, WK2.ForecastToken[condition.forecastToken], WK2.ConditionType[condition.beginCondition], WK2.ConditionType[condition.endCondition], parametersOffset); + }); + const conditionOffset = WK2.NextHourForecastData.createConditionVector(builder, conditionOffsets); + const summaryOffsets = data.summary.map(summary => WK2.ForecastPeriodSummary.createForecastPeriodSummary(builder, summary.startTime, summary.endTime, WK2.PrecipitationType[summary.condition], summary.precipitationChance, summary.precipitationIntensity)); + const summaryOffset = WK2.NextHourForecastData.createSummaryVector(builder, summaryOffsets); + const minutesOffsets = data.minutes.map(minute => WK2.ForecastMinute.createForecastMinute(builder, minute.startTime, minute.precipitationChance, minute.precipitationIntensity, minute.perceivedPrecipitationIntensity)); + const minutesOffset = WK2.NextHourForecastData.createMinutesVector(builder, minutesOffsets); + return WK2.NextHourForecastData.createNextHourForecastData(builder, metadataOffset, conditionOffset, summaryOffset, data.forecastStart, data.forecastEnd, minutesOffset); +} + +function encodeNews(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const placementsOffsets = data.placements.map(placement => { + const articlesOffsets = placement.articles.map(article => { + const alertIdsOffsets = article.alertIds.map(alertId => createUuidOffset(builder, alertId)).filter(Boolean); + const alertIdsOffset = WK2.Articles.createAlertIdsVector(builder, alertIdsOffsets); + const headlineOverrideOffset = builder.createString(article.headlineOverride); + const idOffset = builder.createString(article.id); + const localeOffset = builder.createString(article.locale); + const phenomenaOffset = WK2.Articles.createPhenomenaVector( + builder, + article.phenomena.map(phenomena => builder.createString(phenomena)), + ); + const supportedStorefrontsOffset = WK2.Articles.createSupportedStorefrontsVector( + builder, + article.supportedStorefronts.map(supportedStorefront => builder.createString(supportedStorefront)), + ); + return WK2.Articles.createArticles(builder, idOffset, supportedStorefrontsOffset, alertIdsOffset, phenomenaOffset, headlineOverrideOffset, localeOffset); + }); + const articlesOffset = WK2.Placement.createArticlesVector(builder, articlesOffsets); + return WK2.Placement.createPlacement(builder, placement.priority, articlesOffset, WK2.PlacementType[placement.placement]); + }); + const placementsOffset = WK2.News.createPlacementsVector(builder, placementsOffsets); + return WK2.News.createNews(builder, metadataOffset, placementsOffset); +} + +function encodeWeatherAlerts(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const alertsOffsets = data.alerts.map(alert => { + const responsesOffsets = alert.responses.map(response => WK2.ResponseType[response]); + const responsesOffset = WK2.WeatherAlertSummary.createResponsesVector(builder, responsesOffsets); + const idOffset = createUuidOffset(builder, alert.id); + const areaIdOffset = builder.createString(alert.areaId); + const areaNameOffset = builder.createString(alert.areaName); + const attributionUrlOffset = builder.createString(alert.attributionUrl); + const countryCodeOffset = builder.createString(alert.countryCode); + const descriptionOffset = builder.createString(alert.description); + const tokenOffset = builder.createString(alert.token); + const detailsUrlOffset = builder.createString(alert.detailsUrl); + const phenomenonOffset = builder.createString(alert.phenomenon); + const sourceOffset = builder.createString(alert.source); + const eventSourceOffset = builder.createString(alert.eventSource); + return WK2.WeatherAlertSummary.createWeatherAlertSummary( + builder, + idOffset, + areaIdOffset, + areaNameOffset, + attributionUrlOffset, + countryCodeOffset, + descriptionOffset, + tokenOffset, + alert.effectiveTime, + alert.expireTime, + alert.issuedTime, + alert.eventOnsetTime, + alert.eventEndTime, + detailsUrlOffset, + phenomenonOffset, + WK2.Severity[alert.severity], + WK2.SignificanceType[alert.significance], + sourceOffset, + eventSourceOffset, + WK2.Urgency[alert.urgency], + WK2.Certainty[alert.certainty], + WK2.ImportanceType[alert.importance], + responsesOffset, + alert.unknown23, + alert.unknown24, + alert.unknown25, + alert.unknown26, + ); + }); + const alertsOffset = WK2.WeatherAlertCollectionData.createAlertsVector(builder, alertsOffsets); + const detailsUrlOffset = builder.createString(data.detailsUrl); + return WK2.WeatherAlertCollectionData.createWeatherAlertCollectionData(builder, metadataOffset, detailsUrlOffset, alertsOffset); +} + +function encodeWeatherChanges(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const changesOffsets = data.changes.map(change => WK2.Change.createChange(builder, change.forecastStart, change.forecastEnd, WK2.Direction[change.maxTemperatureChange], WK2.Direction[change.minTemperatureChange], WK2.Direction[change.dayPrecipitationChange], WK2.Direction[change.nightPrecipitationChange])); + const changesOffset = WK2.WeatherChanges.createChangesVector(builder, changesOffsets); + return WK2.WeatherChanges.createWeatherChanges(builder, metadataOffset, data.forecastStart, data.forecastEnd, changesOffset); +} + +function encodeHistoricalComparisons(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const comparisonsOffsets = data.comparisons.map(comparison => WK2.Comparison.createComparison(builder, WK2.ComparisonType[comparison.condition], comparison.currentValue, comparison.baselineValue, WK2.Deviation[comparison.deviation], comparison.baselineType, comparison.baselineStartDate)); + const comparisonsOffset = WK2.HistoricalComparison.createComparisonsVector(builder, comparisonsOffsets); + return WK2.HistoricalComparison.createHistoricalComparison(builder, metadataOffset, comparisonsOffset); +} + +function encodeLocationInfo(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + return WK2.LocationInfo.createLocationInfo(builder, metadataOffset, builder.createString(data.preciseName), builder.createString(data.countryCode), builder.createString(data.timeZone), builder.createString(data.primaryName)); +} + +function decodeAirQuality(data) { + const AirQualityData = data; + data = { + metadata: decodeMetadata(AirQualityData.metadata()), + categoryIndex: AirQualityData.categoryIndex(), + index: AirQualityData.index(), + isSignificant: AirQualityData.isSignificant(), + pollutants: [], + previousDayComparison: WK2.ComparisonTrend[AirQualityData.previousDayComparison()], + primaryPollutant: WK2.PollutantType[AirQualityData.primaryPollutant()], + scale: AirQualityData.scale(), + }; + for (let i = 0; i < AirQualityData.pollutantsLength(); i++) + data.pollutants.push({ + amount: AirQualityData.pollutants(i).amount(), + pollutantType: WK2.PollutantType[AirQualityData.pollutants(i).pollutantType()], + units: WK2.UnitType[AirQualityData.pollutants(i).units()], + }); + return data; +} + +function decodeCurrentWeather(data) { + const CurrentWeatherData = data; + data = { + metadata: decodeMetadata(CurrentWeatherData.metadata()), + asOf: CurrentWeatherData.asOf(), + cloudCover: CurrentWeatherData.cloudCover(), + cloudCoverHighAltPct: CurrentWeatherData.cloudCoverHighAltPct(), + cloudCoverLowAltPct: CurrentWeatherData.cloudCoverLowAltPct(), + cloudCoverMidAltPct: CurrentWeatherData.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[CurrentWeatherData.conditionCode()], + daylight: CurrentWeatherData.daylight(), + humidity: CurrentWeatherData.humidity(), + perceivedPrecipitationIntensity: CurrentWeatherData.perceivedPrecipitationIntensity(), + precipitationAmount1h: CurrentWeatherData.precipitationAmount1h(), + precipitationAmount24h: CurrentWeatherData.precipitationAmount24h(), + precipitationAmount6h: CurrentWeatherData.precipitationAmount6h(), + precipitationAmountNext1h: CurrentWeatherData.precipitationAmountNext1h(), + precipitationAmountNext1hByType: [], + precipitationAmountNext24h: CurrentWeatherData.precipitationAmountNext24h(), + precipitationAmountNext24hByType: [], + precipitationAmountNext6h: CurrentWeatherData.precipitationAmountNext6h(), + precipitationAmountNext6hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious24hByType: [], + precipitationAmountPrevious6hByType: [], + precipitationIntensity: CurrentWeatherData.precipitationIntensity(), + pressure: CurrentWeatherData.pressure(), + pressureTrend: WK2.PressureTrend[CurrentWeatherData.pressureTrend()], + snowfallAmount1h: CurrentWeatherData.snowfallAmount1h(), + snowfallAmount24h: CurrentWeatherData.snowfallAmount24h(), + snowfallAmount6h: CurrentWeatherData.snowfallAmount6h(), + snowfallAmountNext1h: CurrentWeatherData.snowfallAmountNext1h(), + snowfallAmountNext24h: CurrentWeatherData.snowfallAmountNext24h(), + snowfallAmountNext6h: CurrentWeatherData.snowfallAmountNext6h(), + temperature: CurrentWeatherData.temperature(), + temperatureApparent: CurrentWeatherData.temperatureApparent(), + unknown34: CurrentWeatherData.unknown34(), + temperatureDewPoint: CurrentWeatherData.temperatureDewPoint(), + uvIndex: CurrentWeatherData.uvIndex(), + visibility: CurrentWeatherData.visibility(), + windDirection: CurrentWeatherData.windDirection(), + windGust: CurrentWeatherData.windGust(), + windSpeed: CurrentWeatherData.windSpeed(), + }; + for (let i = 0; i < CurrentWeatherData.precipitationAmountNext1hByTypeLength(); i++) + data.precipitationAmountNext1hByType.push({ + expected: CurrentWeatherData.precipitationAmountNext1hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext1hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountNext24hByTypeLength(); i++) + data.precipitationAmountNext24hByType.push({ + expected: CurrentWeatherData.precipitationAmountNext24hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext24hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountNext6hByTypeLength(); i++) + data.precipitationAmountNext6hByType.push({ + expected: CurrentWeatherData.precipitationAmountNext6hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext6hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious1hByTypeLength(); i++) + data.precipitationAmountPrevious1hByType.push({ + expected: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious1hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious24hByTypeLength(); i++) + data.precipitationAmountPrevious24hByType.push({ + expected: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious24hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious6hByTypeLength(); i++) + data.precipitationAmountPrevious6hByType.push({ + expected: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious6hByType(i).precipitationType()], + }); + return data; +} + +function decodeForecastDaily(data) { + const DailyForecastData = data; + data = { + metadata: decodeMetadata(DailyForecastData.metadata()), + days: [], + }; + for (let i = 0; i < DailyForecastData.daysLength(); i++) { + const day = { + conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).conditionCode()], + forecastEnd: DailyForecastData.days(i).forecastEnd(), + forecastStart: DailyForecastData.days(i).forecastStart(), + humidityMax: DailyForecastData.days(i).humidityMax(), + humidityMin: DailyForecastData.days(i).humidityMin(), + maxUvIndex: DailyForecastData.days(i).maxUvIndex(), + moonPhase: WK2.MoonPhase[DailyForecastData.days(i).moonPhase()], + moonrise: DailyForecastData.days(i).moonrise(), + moonset: DailyForecastData.days(i).moonset(), + precipitationAmount: DailyForecastData.days(i).precipitationAmount(), + precipitationAmountByType: [], + precipitationChance: DailyForecastData.days(i).precipitationChance(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).precipitationType()], + snowfallAmount: DailyForecastData.days(i).snowfallAmount(), + solarMidnight: DailyForecastData.days(i).solarMidnight(), + solarNoon: DailyForecastData.days(i).solarNoon(), + sunrise: DailyForecastData.days(i).sunrise(), + sunriseCivil: DailyForecastData.days(i).sunriseCivil(), + sunriseNautical: DailyForecastData.days(i).sunriseNautical(), + sunriseAstronomical: DailyForecastData.days(i).sunriseAstronomical(), + sunset: DailyForecastData.days(i).sunset(), + sunsetCivil: DailyForecastData.days(i).sunsetCivil(), + sunsetNautical: DailyForecastData.days(i).sunsetNautical(), + sunsetAstronomical: DailyForecastData.days(i).sunsetAstronomical(), + temperatureMax: DailyForecastData.days(i).temperatureMax(), + temperatureMaxTime: DailyForecastData.days(i).temperatureMaxTime(), + temperatureMin: DailyForecastData.days(i).temperatureMin(), + temperatureMinTime: DailyForecastData.days(i).temperatureMinTime(), + visibilityMax: DailyForecastData.days(i).visibilityMax(), + visibilityMin: DailyForecastData.days(i).visibilityMin(), + windGustSpeedMax: DailyForecastData.days(i).windGustSpeedMax(), + windSpeedAvg: DailyForecastData.days(i).windSpeedAvg(), + windSpeedMax: DailyForecastData.days(i).windSpeedMax(), + }; + for (let j = 0; j < DailyForecastData.days(i).precipitationAmountByTypeLength(); j++) + day.precipitationAmountByType.push({ + expected: DailyForecastData.days(i).precipitationAmountByType(j).expected(), + expectedSnow: DailyForecastData.days(i).precipitationAmountByType(j).expectedSnow(), + maximumSnow: DailyForecastData.days(i).precipitationAmountByType(j).maximumSnow(), + minimumSnow: DailyForecastData.days(i).precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).precipitationAmountByType(j).precipitationType()], + }); + if (DailyForecastData.days(i).daytimeForecast()) { + day.daytimeForecast = { + cloudCover: DailyForecastData.days(i).daytimeForecast().cloudCover(), + cloudCoverHighAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverHighAltPct(), + cloudCoverLowAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverLowAltPct(), + cloudCoverMidAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).daytimeForecast().conditionCode()], + forecastEnd: DailyForecastData.days(i).daytimeForecast().forecastEnd(), + forecastStart: DailyForecastData.days(i).daytimeForecast().forecastStart(), + humidity: DailyForecastData.days(i).daytimeForecast().humidity(), + humidityMax: DailyForecastData.days(i).daytimeForecast().humidityMax(), + humidityMin: DailyForecastData.days(i).daytimeForecast().humidityMin(), + precipitationAmount: DailyForecastData.days(i).daytimeForecast().precipitationAmount(), + precipitationAmountByType: [], + precipitationChance: DailyForecastData.days(i).daytimeForecast().precipitationChance(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).daytimeForecast().precipitationType()], + snowfallAmount: DailyForecastData.days(i).daytimeForecast().snowfallAmount(), + temperatureMax: DailyForecastData.days(i).daytimeForecast().temperatureMax(), + temperatureMin: DailyForecastData.days(i).daytimeForecast().temperatureMin(), + visibilityMax: DailyForecastData.days(i).daytimeForecast().visibilityMax(), + visibilityMin: DailyForecastData.days(i).daytimeForecast().visibilityMin(), + windDirection: DailyForecastData.days(i).daytimeForecast().windDirection(), + windGustSpeedMax: DailyForecastData.days(i).daytimeForecast().windGustSpeedMax(), + windSpeed: DailyForecastData.days(i).daytimeForecast().windSpeed(), + windSpeedMax: DailyForecastData.days(i).daytimeForecast().windSpeedMax(), + precipitationIntensityMax: DailyForecastData.days(i).daytimeForecast().precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: DailyForecastData.days(i).daytimeForecast().perceivedPrecipitationIntensityMax(), + uvIndexMin: DailyForecastData.days(i).daytimeForecast().uvIndexMin(), + uvIndexMax: DailyForecastData.days(i).daytimeForecast().uvIndexMax(), + temperatureApparentMin: DailyForecastData.days(i).daytimeForecast().temperatureApparentMin(), + temperatureApparentMax: DailyForecastData.days(i).daytimeForecast().temperatureApparentMax(), + daylight: DailyForecastData.days(i).daytimeForecast().daylight(), + }; + for (let j = 0; j < DailyForecastData.days(i).daytimeForecast().precipitationAmountByTypeLength(); j++) + day.daytimeForecast.precipitationAmountByType.push({ + expected: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).expected(), + expectedSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).expectedSnow(), + maximumSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).maximumSnow(), + minimumSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).precipitationType()], + }); + } + if (DailyForecastData.days(i).overnightForecast()) { + day.overnightForecast = { + cloudCover: DailyForecastData.days(i).overnightForecast().cloudCover(), + cloudCoverHighAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverHighAltPct(), + cloudCoverLowAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverLowAltPct(), + cloudCoverMidAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).overnightForecast().conditionCode()], + forecastEnd: DailyForecastData.days(i).overnightForecast().forecastEnd(), + forecastStart: DailyForecastData.days(i).overnightForecast().forecastStart(), + humidity: DailyForecastData.days(i).overnightForecast().humidity(), + humidityMax: DailyForecastData.days(i).overnightForecast().humidityMax(), + humidityMin: DailyForecastData.days(i).overnightForecast().humidityMin(), + precipitationAmount: DailyForecastData.days(i).overnightForecast().precipitationAmount(), + precipitationAmountByType: [], + precipitationChance: DailyForecastData.days(i).overnightForecast().precipitationChance(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).overnightForecast().precipitationType()], + snowfallAmount: DailyForecastData.days(i).overnightForecast().snowfallAmount(), + temperatureMax: DailyForecastData.days(i).overnightForecast().temperatureMax(), + temperatureMin: DailyForecastData.days(i).overnightForecast().temperatureMin(), + visibilityMax: DailyForecastData.days(i).overnightForecast().visibilityMax(), + visibilityMin: DailyForecastData.days(i).overnightForecast().visibilityMin(), + windDirection: DailyForecastData.days(i).overnightForecast().windDirection(), + windGustSpeedMax: DailyForecastData.days(i).overnightForecast().windGustSpeedMax(), + windSpeed: DailyForecastData.days(i).overnightForecast().windSpeed(), + windSpeedMax: DailyForecastData.days(i).overnightForecast().windSpeedMax(), + precipitationIntensityMax: DailyForecastData.days(i).overnightForecast().precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: DailyForecastData.days(i).overnightForecast().perceivedPrecipitationIntensityMax(), + uvIndexMin: DailyForecastData.days(i).overnightForecast().uvIndexMin(), + uvIndexMax: DailyForecastData.days(i).overnightForecast().uvIndexMax(), + temperatureApparentMin: DailyForecastData.days(i).overnightForecast().temperatureApparentMin(), + temperatureApparentMax: DailyForecastData.days(i).overnightForecast().temperatureApparentMax(), + daylight: DailyForecastData.days(i).overnightForecast().daylight(), + }; + for (let j = 0; j < DailyForecastData.days(i).overnightForecast().precipitationAmountByTypeLength(); j++) + day.overnightForecast.precipitationAmountByType.push({ + expected: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).expected(), + expectedSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).expectedSnow(), + maximumSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).maximumSnow(), + minimumSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).precipitationType()], + }); + } + if (DailyForecastData.days(i).restOfDayForecast()) { + day.restOfDayForecast = { + cloudCover: DailyForecastData.days(i).restOfDayForecast().cloudCover(), + cloudCoverHighAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverHighAltPct(), + cloudCoverLowAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverLowAltPct(), + cloudCoverMidAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).restOfDayForecast().conditionCode()], + forecastEnd: DailyForecastData.days(i).restOfDayForecast().forecastEnd(), + forecastStart: DailyForecastData.days(i).restOfDayForecast().forecastStart(), + humidity: DailyForecastData.days(i).restOfDayForecast().humidity(), + humidityMax: DailyForecastData.days(i).restOfDayForecast().humidityMax(), + humidityMin: DailyForecastData.days(i).restOfDayForecast().humidityMin(), + precipitationAmount: DailyForecastData.days(i).restOfDayForecast().precipitationAmount(), + precipitationAmountByType: [], + precipitationChance: DailyForecastData.days(i).restOfDayForecast().precipitationChance(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).restOfDayForecast().precipitationType()], + snowfallAmount: DailyForecastData.days(i).restOfDayForecast().snowfallAmount(), + temperatureMax: DailyForecastData.days(i).restOfDayForecast().temperatureMax(), + temperatureMin: DailyForecastData.days(i).restOfDayForecast().temperatureMin(), + visibilityMax: DailyForecastData.days(i).restOfDayForecast().visibilityMax(), + visibilityMin: DailyForecastData.days(i).restOfDayForecast().visibilityMin(), + windDirection: DailyForecastData.days(i).restOfDayForecast().windDirection(), + windGustSpeedMax: DailyForecastData.days(i).restOfDayForecast().windGustSpeedMax(), + windSpeed: DailyForecastData.days(i).restOfDayForecast().windSpeed(), + windSpeedMax: DailyForecastData.days(i).restOfDayForecast().windSpeedMax(), + precipitationIntensityMax: DailyForecastData.days(i).restOfDayForecast().precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: DailyForecastData.days(i).restOfDayForecast().perceivedPrecipitationIntensityMax(), + uvIndexMin: DailyForecastData.days(i).restOfDayForecast().uvIndexMin(), + uvIndexMax: DailyForecastData.days(i).restOfDayForecast().uvIndexMax(), + temperatureApparentMin: DailyForecastData.days(i).restOfDayForecast().temperatureApparentMin(), + temperatureApparentMax: DailyForecastData.days(i).restOfDayForecast().temperatureApparentMax(), + daylight: DailyForecastData.days(i).restOfDayForecast().daylight(), + }; + for (let j = 0; j < DailyForecastData.days(i).restOfDayForecast().precipitationAmountByTypeLength(); j++) + day.restOfDayForecast.precipitationAmountByType.push({ + expected: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).expected(), + expectedSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).expectedSnow(), + maximumSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).maximumSnow(), + minimumSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).precipitationType()], + }); + } + data.days.push(day); + } + return data; +} + +function decodeForecastHourly(data) { + const HourlyForecastData = data; + data = { + metadata: decodeMetadata(HourlyForecastData.metadata()), + hours: [], + }; + for (let i = 0; i < HourlyForecastData.hoursLength(); i++) + data.hours.push({ + cloudCover: HourlyForecastData.hours(i).cloudCover(), + cloudCoverHighAltPct: HourlyForecastData.hours(i).cloudCoverHighAltPct(), + cloudCoverLowAltPct: HourlyForecastData.hours(i).cloudCoverLowAltPct(), + cloudCoverMidAltPct: HourlyForecastData.hours(i).cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[HourlyForecastData.hours(i).conditionCode()], + daylight: HourlyForecastData.hours(i).daylight(), + forecastStart: HourlyForecastData.hours(i).forecastStart(), + humidity: HourlyForecastData.hours(i).humidity(), + perceivedPrecipitationIntensity: HourlyForecastData.hours(i).perceivedPrecipitationIntensity(), + precipitationAmount: HourlyForecastData.hours(i).precipitationAmount(), + precipitationChance: HourlyForecastData.hours(i).precipitationChance(), + precipitationIntensity: HourlyForecastData.hours(i).precipitationIntensity(), + precipitationType: WK2.PrecipitationType[HourlyForecastData.hours(i).precipitationType()], + pressure: HourlyForecastData.hours(i).pressure(), + pressureTrend: WK2.PressureTrend[HourlyForecastData.hours(i).pressureTrend()], + snowfallAmount: HourlyForecastData.hours(i).snowfallAmount(), + snowfallIntensity: HourlyForecastData.hours(i).snowfallIntensity(), + temperature: HourlyForecastData.hours(i).temperature(), + temperatureApparent: HourlyForecastData.hours(i).temperatureApparent(), + unknown20: HourlyForecastData.hours(i).unknown20(), + temperatureDewPoint: HourlyForecastData.hours(i).temperatureDewPoint(), + uvIndex: HourlyForecastData.hours(i).uvIndex(), + visibility: HourlyForecastData.hours(i).visibility(), + windDirection: HourlyForecastData.hours(i).windDirection(), + windGust: HourlyForecastData.hours(i).windGust(), + windSpeed: HourlyForecastData.hours(i).windSpeed(), + }); + return data; +} + +function decodeForecastNextHour(data) { + const NextHourForecastData = data; + data = { + metadata: decodeMetadata(NextHourForecastData.metadata()), + condition: [], + forecastEnd: NextHourForecastData.forecastEnd(), + forecastStart: NextHourForecastData.forecastStart(), + minutes: [], + summary: [], + }; + for (let i = 0; i < NextHourForecastData.conditionLength(); i++) { + const condition = { + beginCondition: WK2.ConditionType[NextHourForecastData.condition(i).beginCondition()], + endCondition: WK2.ConditionType[NextHourForecastData.condition(i).endCondition()], + endTime: NextHourForecastData.condition(i).endTime(), + forecastToken: WK2.ForecastToken[NextHourForecastData.condition(i).forecastToken()], + parameters: [], + startTime: NextHourForecastData.condition(i).startTime(), + }; + for (let j = 0; j < NextHourForecastData.condition(i).parametersLength(); j++) + condition.parameters.push({ + date: NextHourForecastData.condition(i).parameters(j).date(), + type: WK2.ParameterType[NextHourForecastData.condition(i).parameters(j).type()], + }); + data.condition.push(condition); + } + for (let i = 0; i < NextHourForecastData.minutesLength(); i++) + data.minutes.push({ + perceivedPrecipitationIntensity: NextHourForecastData.minutes(i).perceivedPrecipitationIntensity(), + precipitationChance: NextHourForecastData.minutes(i).precipitationChance(), + precipitationIntensity: NextHourForecastData.minutes(i).precipitationIntensity(), + startTime: NextHourForecastData.minutes(i).startTime(), + }); + for (let i = 0; i < NextHourForecastData.summaryLength(); i++) + data.summary.push({ + condition: WK2.PrecipitationType[NextHourForecastData.summary(i).condition()], + endTime: NextHourForecastData.summary(i).endTime(), + precipitationChance: NextHourForecastData.summary(i).precipitationChance(), + precipitationIntensity: NextHourForecastData.summary(i).precipitationIntensity(), + startTime: NextHourForecastData.summary(i).startTime(), + }); + return data; +} + +function decodeNews(data) { + const NewsData = data; + data = { + metadata: decodeMetadata(NewsData.metadata()), + placements: [], + }; + for (let i = 0; i < NewsData.placementsLength(); i++) { + const placement = { + articles: [], + placement: WK2.PlacementType[NewsData.placements(i).placement()], + priority: NewsData.placements(i).priority(), + }; + for (let j = 0; j < NewsData.placements(i).articlesLength(); j++) { + const article = { + alertIds: [], + headlineOverride: NewsData.placements(i).articles(j).headlineOverride(), + id: NewsData.placements(i).articles(j).id(), + locale: NewsData.placements(i).articles(j).locale(), + phenomena: [], + supportedStorefronts: [], + }; + for (let k = 0; k < NewsData.placements(i).articles(j).alertIdsLength(); k++) article.alertIds.push(decodeUuid(NewsData.placements(i).articles(j).alertIds(k))); + for (let k = 0; k < NewsData.placements(i).articles(j).phenomenaLength(); k++) article.phenomena.push(NewsData.placements(i).articles(j).phenomena(k)); + for (let k = 0; k < NewsData.placements(i).articles(j).supportedStorefrontsLength(); k++) article.supportedStorefronts.push(NewsData.placements(i).articles(j).supportedStorefronts(k)); + placement.articles.push(article); + } + data.placements.push(placement); + } + return data; +} + +function decodeWeatherAlerts(data) { + const WeatherAlertCollectionData = data; + data = { + metadata: decodeMetadata(WeatherAlertCollectionData.metadata()), + alerts: [], + detailsUrl: WeatherAlertCollectionData.detailsUrl(), + }; + for (let i = 0; i < WeatherAlertCollectionData.alertsLength(); i++) { + const alert = { + areaId: WeatherAlertCollectionData.alerts(i).areaId(), + areaName: WeatherAlertCollectionData.alerts(i).areaName(), + attributionUrl: WeatherAlertCollectionData.alerts(i).attributionUrl(), + certainty: WK2.Certainty[WeatherAlertCollectionData.alerts(i).certainty()], + countryCode: WeatherAlertCollectionData.alerts(i).countryCode(), + description: WeatherAlertCollectionData.alerts(i).description(), + detailsUrl: WeatherAlertCollectionData.alerts(i).detailsUrl(), + effectiveTime: WeatherAlertCollectionData.alerts(i).effectiveTime(), + eventEndTime: WeatherAlertCollectionData.alerts(i).eventEndTime(), + eventOnsetTime: WeatherAlertCollectionData.alerts(i).eventOnsetTime(), + eventSource: WeatherAlertCollectionData.alerts(i).eventSource(), + expireTime: WeatherAlertCollectionData.alerts(i).expireTime(), + id: decodeUuid(WeatherAlertCollectionData.alerts(i).id()), + importance: WK2.ImportanceType[WeatherAlertCollectionData.alerts(i).importance()], + issuedTime: WeatherAlertCollectionData.alerts(i).issuedTime(), + phenomenon: WeatherAlertCollectionData.alerts(i).phenomenon(), + responses: [], + severity: WK2.Severity[WeatherAlertCollectionData.alerts(i).severity()], + significance: WK2.SignificanceType[WeatherAlertCollectionData.alerts(i).significance()], + source: WeatherAlertCollectionData.alerts(i).source(), + token: WeatherAlertCollectionData.alerts(i).token(), + urgency: WK2.Urgency[WeatherAlertCollectionData.alerts(i).urgency()], + unknown23: WeatherAlertCollectionData.alerts(i).unknown23(), + unknown24: WeatherAlertCollectionData.alerts(i).unknown24(), + unknown25: WeatherAlertCollectionData.alerts(i).unknown25(), + unknown26: WeatherAlertCollectionData.alerts(i).unknown26(), + }; + //for (let j = 0; j < WeatherAlertCollectionData.alerts(i).idLength(); j++) alert.id.push(WeatherAlertCollectionData.alerts(i).id(j)); + //for (let j = 0; j < WeatherAlertCollectionData.alerts(i).idLength(); j++) alert.id.push({ "lowBytes": WeatherAlertCollectionData.alerts(i).id(j).lowBytes(), "highBytes": WeatherAlertCollectionData.alerts(i).id(j).highBytes() }); + for (let j = 0; j < WeatherAlertCollectionData.alerts(i).responsesLength(); j++) alert.responses.push(WK2.ResponseType[WeatherAlertCollectionData.alerts(i).responses(j)]); + data.alerts.push(alert); + } + return data; +} + +function decodeWeatherChanges(data) { + const WeatherChangesData = data; + data = { + metadata: decodeMetadata(WeatherChangesData.metadata()), + changes: [], + forecastEnd: WeatherChangesData.forecastEnd(), + forecastStart: WeatherChangesData.forecastStart(), + }; + for (let i = 0; i < WeatherChangesData.changesLength(); i++) { + const change = { + dayPrecipitationChange: WK2.Direction[WeatherChangesData.changes(i).dayPrecipitationChange()], + forecastEnd: WeatherChangesData.changes(i).forecastEnd(), + forecastStart: WeatherChangesData.changes(i).forecastStart(), + maxTemperatureChange: WK2.Direction[WeatherChangesData.changes(i).maxTemperatureChange()], + minTemperatureChange: WK2.Direction[WeatherChangesData.changes(i).minTemperatureChange()], + nightPrecipitationChange: WK2.Direction[WeatherChangesData.changes(i).nightPrecipitationChange()], + }; + data.changes.push(change); + } + return data; +} + +function decodeHistoricalComparisons(data) { + const HistoricalComparisonsData = data; + data = { + metadata: decodeMetadata(HistoricalComparisonsData.metadata()), + comparisons: [], + }; + for (let i = 0; i < HistoricalComparisonsData.comparisonsLength(); i++) { + const comparison = { + baselineStartDate: HistoricalComparisonsData.comparisons(i).baselineStartDate(), + baselineType: HistoricalComparisonsData.comparisons(i).baselineType(), + baselineValue: HistoricalComparisonsData.comparisons(i).baselineValue(), + condition: WK2.ComparisonType[HistoricalComparisonsData.comparisons(i).condition()], + currentValue: HistoricalComparisonsData.comparisons(i).currentValue(), + deviation: WK2.Deviation[HistoricalComparisonsData.comparisons(i).deviation()], + }; + data.comparisons.push(comparison); + } + return data; +} + +function decodeLocationInfo(data) { + const LocationInfoData = data; + data = { + metadata: decodeMetadata(LocationInfoData.metadata()), + countryCode: LocationInfoData.countryCode(), + preciseName: LocationInfoData.preciseName(), + primaryName: LocationInfoData.primaryName(), + timeZone: LocationInfoData.timeZone(), + }; + return data; +} + +function encodeMetadata(builder, metadata) { + if (!metadata) return 0; + return WK2.Metadata.createMetadata( + builder, + builder.createString(metadata.attributionUrl), + metadata.expireTime, + builder.createString(metadata.language), + metadata.latitude, + metadata.longitude, + builder.createString(metadata.providerLogo), + builder.createString(metadata.providerName), + metadata.readTime, + metadata.reportedTime, + metadata.temporarilyUnavailable, + WK2.SourceType[metadata.sourceType], + metadata.unknown11, + metadata.unknown12, + metadata.unknown13, + metadata.unknown14, + metadata.unknown15, + ); +} + +function decodeMetadata(data) { + if (!data) return undefined; + data = { + attributionUrl: data.attributionUrl(), + expireTime: data.expireTime(), + language: data.language(), + latitude: data.latitude(), + longitude: data.longitude(), + providerLogo: data.providerLogo(), + providerName: data.providerName(), + readTime: data.readTime(), + reportedTime: data.reportedTime(), + temporarilyUnavailable: data.temporarilyUnavailable(), + sourceType: WK2.SourceType[data.sourceType()], + }; + return data; +} + +function toUuidBytes(uuid) { + if (uuid === null || uuid === undefined) return null; + if (uuid instanceof Uint8Array || Array.isArray(uuid)) return uuid; + if (typeof uuid === "string") { + const bytesObject = uuidStringToBytesObject(uuid); + return bytesObject ? bytesObject.bytes : null; + } + if (typeof uuid === "object") { + if (uuid.bytes instanceof Uint8Array || Array.isArray(uuid.bytes)) return uuid.bytes; + if (typeof uuid.bytesArray === "function") return uuid.bytesArray(); + } + return null; +} + +function uuidStringToBytesObject(uuidString) { + if (typeof uuidString !== "string") return null; + const hex = uuidString.replace(/-/g, "").toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(hex)) return null; + const bytes = []; + for (let index = 0; index < hex.length; index += 2) bytes.push(Number.parseInt(hex.slice(index, index + 2), 16)); + return { bytes }; +} + +function uuidBytesObjectToString(uuid) { + const bytes = toUuidBytes(uuid); + if (!bytes || bytes.length !== 16) return null; + const hex = Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function createUuidOffset(builder, uuid) { + const bytes = toUuidBytes(uuid); + if (!bytes || bytes.length === 0) return 0; + const idBytesOffset = WK2.UUID.createBytesVector(builder, bytes); + return WK2.UUID.createUUID(builder, idBytesOffset); +} + +function decodeUuid(uuid) { + return uuidBytesObjectToString(uuid); +} diff --git a/src/wk3/class/WeatherKit3.mjs b/src/wk3/class/WeatherKit3.mjs new file mode 100644 index 00000000..a83fba12 --- /dev/null +++ b/src/wk3/class/WeatherKit3.mjs @@ -0,0 +1,214 @@ +import { ByteBuffer } from "flatbuffers"; +import { AirQuality, Metadata, Pollutant } from "../proto/apple/wk2.js"; + +/** WK3.Weather, lib-rc-3.0.0-b66: observed root positions, not WK2 order. + * 4: metadata + series[1] + periods[120], each period spans 7200 seconds. + * 5: six-slot nextHour; the sole available sample has 83 timestamp-only minutes. + * 14: article placements (titles/language/IDs), not minute precipitation. + * No whole-root or nextHour encoder is provided here. + */ +export const WK3_ROOT_SLOTS = { airQuality: 0, forecastPeriodic: 4, forecastNextHour: 5, news: 14 }; + +// Only the AQ/metadata/pollutant physical layouts were verified against WK2. +// Reuse those generated accessors/builders WITHOUT its symbolic enum mappings. +// This does not imply that current/daily/hourly or the WK2 root is compatible. +export class WK3AirQualityTable extends AirQuality {} + +/** Raw wire enum integer. In particular "UNKNOWN13" must not become WK2's 13. */ +function rawEnum(value = 0) { + if (!Number.isInteger(value) || value < 0 || value > 255) throw new TypeError("WK3 enum must be a raw integer from 0 to 255; symbolic WK2 names are unsupported"); + return value; +} +function unsigned(value = 0, max = 4294967295) { + if (!Number.isInteger(value) || value < 0 || value > max) throw new RangeError("WK3 unsigned integer out of range"); + return value; +} +function finite(value = 0) { + if (typeof value !== "number" || !Number.isFinite(Math.fround(value))) throw new TypeError("WK3 finite float32 required"); + return value; +} +function decodeMetadata(table) { + if (!table) return null; + return { + attributionUrl: table.attributionUrl(), expireTime: table.expireTime(), language: table.language(), + latitude: table.latitude(), longitude: table.longitude(), providerLogo: table.providerLogo(), + providerName: table.providerName(), readTime: table.readTime(), reportedTime: table.reportedTime(), + temporarilyUnavailable: table.temporarilyUnavailable(), sourceType: table.sourceType() & 255, + }; +} +function encodeMetadata(builder, data) { + if (!data) return 0; + return Metadata.createMetadata( + builder, builder.createString(data.attributionUrl), unsigned(data.expireTime), builder.createString(data.language), + finite(data.latitude), finite(data.longitude), builder.createString(data.providerLogo), builder.createString(data.providerName), + unsigned(data.readTime), unsigned(data.reportedTime), data.temporarilyUnavailable ?? false, rawEnum(data.sourceType), + ); +} +/** Decode the eight-slot AQ table, preserving enums as numbers. No pollutant + * names or unit meanings are inferred. Use on the verified b66 AQ layout only. */ +export function decodeAirQuality(table) { + return { + metadata: decodeMetadata(table.metadata()), + categoryIndex: table.categoryIndex() & 255, + index: table.index() & 65535, + isSignificant: table.isSignificant(), + pollutants: Array.from({ length: table.pollutantsLength() }, (_, i) => { + const p = table.pollutants(i); + return { pollutantType: p.pollutantType() & 255, amount: p.amount(), units: p.units() & 255 }; + }), + previousDayComparison: table.previousDayComparison() & 255, + primaryPollutant: table.primaryPollutant() & 255, + scale: table.scale(), + }; +} +/** Build an AQ child only, returning its builder offset (do not finish a + * WK2 Weather root). Pass through decoded primaryPollutant/pollutants when + * editing index/categoryIndex/scale/comparison. No WK3 pollutant mapping has + * been verified, so provider/WK2 symbolic enum strings are rejected. + */ +export function encodeAirQuality(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const pollutants = data.pollutants == null ? 0 : AirQuality.createPollutantsVector(builder, + data.pollutants.map(p => Pollutant.createPollutant(builder, rawEnum(p.pollutantType), finite(p.amount), rawEnum(p.units)))); + const scale = builder.createString(data.scale); + return AirQuality.createAirQuality(builder, metadataOffset, rawEnum(data.categoryIndex), unsigned(data.index, 65535), + data.isSignificant ?? false, pollutants, rawEnum(data.previousDayComparison), rawEnum(data.primaryPollutant), scale); +} +// Verified by paired Apple v3 JSON / WK3 b66 responses, not WK2 enum arithmetic. +// Only observed names are mapped; unsupported provider names fail closed. +export const WK3_AQ_POLLUTANTS = { + NOT_AVAILABLE: 1, C6H6: 2, NH3: 3, NMHC: 4, NO: 5, NO2: 6, NOX: 7, + OZONE: 8, PM2_5: 9, SO2: 10, PM10: 11, CO: 12, +}; +export const WK3_AQ_UNITS = { PARTS_PER_BILLION: 1, MICROGRAMS_PER_CUBIC_METER: 2 }; +export const WK3_AQ_SOURCES = { APPLE_INTERNAL: 1, MODELED: 2, STATION: 3 }; +export const WK3_AQ_COMPARISONS = { UNKNOWN: 0, WORSE: 2, SAME: 3, BETTER: 4 }; + +const WK3_AQ_SCALES = new Set([ + "UBA", "EU.EAQI", "HJ6332012", "HJ6332025_DRAFT", "EPA_NowCast", + "HK.AQHI", "CA.AQHI", "CN.AQHI", +]); +function mapAQComparison(value = "UNKNOWN") { + if (!Object.hasOwn(WK3_AQ_COMPARISONS, value)) throw new TypeError(`Unknown WK3 AQ comparison: ${value}`); + return WK3_AQ_COMPARISONS[value]; +} +function aqCategory(scale, index) { + if (["EPA_NowCast", "HJ6332012", "HJ6332025_DRAFT"].includes(scale)) return index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; + return null; +} + + +/** Convert supported algorithm/provider AQ payloads into WK3 wire enums. + * Required metadata: providerName and sourceType. Missing optional metadata + * fields (coordinates and timestamps included) retain codec defaults of zero. + */ +export function providerToWK3AirQuality(data) { + if (!data || typeof data !== "object") throw new TypeError("Missing provider AQ data"); + if (!Number.isInteger(data.index) || data.index < 0 || data.index > 32767) throw new RangeError("WK3 AQ index must be 0..32767"); + if (!Number.isInteger(data.categoryIndex) || data.categoryIndex < 1 || data.categoryIndex > 127) throw new RangeError("WK3 AQ category must be 1..127"); + const scaleBase = typeof data.scale === "string" ? data.scale.replace(/\.\d+(?:\.\d+)*$/, "") : ""; + if (!WK3_AQ_SCALES.has(scaleBase)) throw new TypeError(`Unsupported WK3 AQ scale: ${data.scale}`); + const expectedCategory = aqCategory(scaleBase, data.index); + if (expectedCategory !== null && data.categoryIndex !== expectedCategory) throw new TypeError(`Inconsistent ${scaleBase} categoryIndex`); + if (expectedCategory !== null && data.isSignificant !== (data.categoryIndex >= 3)) throw new TypeError(`Inconsistent ${scaleBase} significance`); + if (!data.metadata || data.metadata.temporarilyUnavailable || typeof data.metadata.providerName !== "string" || !data.metadata.providerName || !Object.hasOwn(WK3_AQ_SOURCES, data.metadata.sourceType)) throw new TypeError("Missing or invalid AQ provider metadata"); + if (!Array.isArray(data.pollutants) || data.pollutants.length === 0) throw new TypeError("Missing provider pollutants"); + const seen = new Set(); + const pollutants = data.pollutants.map(p => { + if (!p || typeof p !== "object" || !Object.hasOwn(WK3_AQ_POLLUTANTS, p.pollutantType) || !Object.hasOwn(WK3_AQ_UNITS, p.units)) throw new TypeError("Unknown WK3 AQ pollutant/unit"); + const pollutantType = WK3_AQ_POLLUTANTS[p.pollutantType]; + if (pollutantType === WK3_AQ_POLLUTANTS.NOT_AVAILABLE || seen.has(pollutantType)) throw new TypeError("Invalid or duplicate pollutant"); + seen.add(pollutantType); + const amount = finite(p.amount); + if (amount < 0) throw new RangeError("Negative pollutant concentration"); + return { ...p, pollutantType, amount, units: WK3_AQ_UNITS[p.units] }; + }); + if (!Object.hasOwn(WK3_AQ_POLLUTANTS, data.primaryPollutant)) throw new TypeError("Unknown WK3 AQ primary pollutant"); + const primaryPollutant = WK3_AQ_POLLUTANTS[data.primaryPollutant]; + if (primaryPollutant !== WK3_AQ_POLLUTANTS.NOT_AVAILABLE && !seen.has(primaryPollutant)) throw new TypeError("Primary pollutant missing from provider data"); + const metadata = { + ...data.metadata, attributionUrl: data.metadata.attributionUrl ?? null, expireTime: data.metadata.expireTime ?? 0, + language: data.metadata.language ?? null, latitude: finite(data.metadata.latitude ?? 0), longitude: finite(data.metadata.longitude ?? 0), + providerLogo: data.metadata.providerLogo ?? null, readTime: data.metadata.readTime ?? 0, reportedTime: data.metadata.reportedTime ?? 0, + temporarilyUnavailable: false, sourceType: WK3_AQ_SOURCES[data.metadata.sourceType], + }; + const normalizedScale = data.scale === "EPA_NowCast.2414" ? "EPA_NowCast.2604" : data.scale; + return { ...data, metadata, index: data.index, categoryIndex: data.categoryIndex, isSignificant: data.isSignificant === true, + pollutants, primaryPollutant, previousDayComparison: mapAQComparison(data.previousDayComparison), scale: normalizedScale }; +} + +function enumName(dictionary, value, kind) { + const name = Object.keys(dictionary).find(key => dictionary[key] === value); + if (name === undefined) throw new TypeError(`Unknown WK3 AQ ${kind}: ${value}`); + return name; +} +function assertKnownVtableFields(table, count, label) { + if (!table) throw new TypeError(`Missing WK3 AQ ${label}`); + const bb = table.bb, position = table.bb_pos; + const vtable = position - bb.readInt32(position); + if (bb.readInt16(vtable) > 4 + count * 2) throw new TypeError(`Unknown WK3 AQ ${label} fields`); +} +export function wk3AirQualityToProvider(data) { + if (data && typeof data.index === "function") { + assertKnownVtableFields(data, 8, "table"); + const metadataTable = data.metadata(); + assertKnownVtableFields(metadataTable, 11, "metadata"); + const pollutants = Array.from({ length: data.pollutantsLength() }, (_, i) => { + const p = data.pollutants(i); assertKnownVtableFields(p, 3, "pollutant"); + return { pollutantType: p.pollutantType(), amount: p.amount(), units: p.units() }; + }); + return wk3AirQualityToProvider({ metadata: decodeMetadata(metadataTable), categoryIndex: data.categoryIndex(), index: data.index(), + isSignificant: data.isSignificant(), pollutants, previousDayComparison: data.previousDayComparison(), + primaryPollutant: data.primaryPollutant(), scale: data.scale() }); + } + if (!data || !data.metadata || !Array.isArray(data.pollutants)) throw new TypeError("Missing raw WK3 AQ object"); + const metadata = { ...data.metadata, sourceType: enumName(WK3_AQ_SOURCES, data.metadata.sourceType, "source") }; + const pollutants = data.pollutants.map(p => ({ + ...p, pollutantType: enumName(WK3_AQ_POLLUTANTS, p.pollutantType, "pollutant"), + units: enumName(WK3_AQ_UNITS, p.units, "unit"), + })); + return { ...data, metadata, pollutants, + previousDayComparison: enumName(WK3_AQ_COMPARISONS, data.previousDayComparison, "comparison"), + primaryPollutant: enumName(WK3_AQ_POLLUTANTS, data.primaryPollutant, "primary pollutant") }; +} + +/** US-specific compatibility wrapper retains its original 0..500 bands. */ +export function providerToWK3USAirQuality(data) { + if (!data || !["EPA_NowCast.2414", "EPA_NowCast.2604"].includes(data.scale)) throw new TypeError("Unsupported EPA_NowCast scale version"); + if (!Number.isInteger(data.index)) throw new TypeError("Missing integer AQI"); + const index = unsigned(data.index, 500); + const category = index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; + if (data.categoryIndex !== category || data.isSignificant !== (category >= 3)) throw new TypeError("Inconsistent EPA AQI category/significance"); + const result = providerToWK3AirQuality(data); + return { ...result, index, categoryIndex: category, isSignificant: category >= 3, previousDayComparison: 0, scale: "EPA_NowCast.2604" }; +} + +/** Unknown AQ/metadata/pollutant fields make complete-child replacement unsafe. */ +export function assertWK3AirQualityReplaceable(bytes) { + const bb = bytes instanceof ByteBuffer ? bytes : new ByteBuffer(bytes); + const start = bb.position(), root = start + bb.readUint32(start); + const offset = bb.__offset(root, 4 + 2 * WK3_ROOT_SLOTS.airQuality); + if (!offset) return true; + const aq = new WK3AirQualityTable().__init(bb.__indirect(root + offset), bb); + assertKnownVtableFields(aq, 8, "table"); + const metadata = aq.metadata(); + if (metadata) assertKnownVtableFields(metadata, 11, "metadata"); + for (let i = 0; i < aq.pollutantsLength(); i++) assertKnownVtableFields(aq.pollutants(i), 3, "pollutant"); + return true; +} + +export const airQualityCodec = { tableClass: WK3AirQualityTable, decode: decodeAirQuality, encode: encodeAirQuality }; +export const WK3_CODECS = { airQuality: airQualityCodec }; + +/** Convenience reader for a complete WK3 root. Missing AQ returns null. + * Valid FlatBuffers are expected, like the generated table accessors. + * The processor can instead initialize WK3AirQualityTable directly from a + * preserved child arena using __init(position, ByteBuffer). + */ +export function decodeWK3DataSet(bytes, name = "airQuality") { + if (name !== "airQuality") throw new RangeError(`Unsupported WK3 data set ${name}`); + const bb = bytes instanceof ByteBuffer ? bytes : new ByteBuffer(bytes); + const start = bb.position(), root = start + bb.readUint32(start); + const offset = bb.__offset(root, 4 + 2 * WK3_ROOT_SLOTS.airQuality); + return offset ? decodeAirQuality(new WK3AirQualityTable().__init(bb.__indirect(root + offset), bb)) : null; +} diff --git a/src/wk3/entry/index.mjs b/src/wk3/entry/index.mjs new file mode 100644 index 00000000..c10a3ad3 --- /dev/null +++ b/src/wk3/entry/index.mjs @@ -0,0 +1,47 @@ +import database from "../function/database.mjs"; +import buildSettings from "../function/buildSettings.mjs"; +import { get, set, merge } from "../utils/object.mjs"; +import { moduleOptionLabels, normalizeModuleOption } from "../option-values.mjs"; + +export function configuration(ctx) { + // SETTINGS is the same nested Settings object accepted by buildSettings. + const overrides = ctx.env?.SETTINGS ? JSON.parse(ctx.env.SETTINGS) : {}; + const dotted = {}; + for (const [key, value] of Object.entries(ctx.env ?? {})) { + if (key.includes(".") || key === "DataSets" || key === "LogLevel") set(dotted, key, value); + } + merge(overrides, dotted); + for (const key of Object.keys(moduleOptionLabels)) { + const value = get(overrides, key); + if (value !== undefined) set(overrides, key, normalizeModuleOption(key, value)); + } + // The existing merge deliberately preserves defaults for an empty array. + // Its string "[]" convention clears them during buildSettings normalization. + function emptyArrays(value) { + if (Array.isArray(value)) return value.length ? value : "[]"; + if (value && typeof value === "object") for (const key of Object.keys(value)) value[key] = emptyArrays(value[key]); + return value; + } + const result = buildSettings(database, emptyArrays(overrides)); + // Module parameters are strings. Normalize only list-valued settings here; + // never split tokens/hosts or change the shared cloud settings parser. + for (const path of ["DataSets", "Weather.Replace", "AirQuality.Current.Index.Replace", "AirQuality.Current.Pollutants.Units.Replace"]) { + const value = get(dotted, path) ?? get(overrides, path); + if (value === undefined) continue; + let list = value; + if (typeof list === "string") { + const text = list.trim(); + list = text.startsWith("[") ? JSON.parse(text) : text.split(",").map(v => v.trim()).filter(Boolean); + } + if (!Array.isArray(list) || list.some(v => typeof v !== "string" || !v.trim())) throw new TypeError(`Invalid list setting: ${path}`); + set(result.Settings, path, list.map(v => v.trim())); + } + for (const path of ["API.ColorfulClouds.Token", "API.QWeather.Token", "API.QWeather.Host"]) { + const value = get(dotted, path) ?? get(overrides, path); + if (value !== undefined) set(result.Settings, path, String(value) || (path.endsWith(".Host") ? "devapi.qweather.com" : null)); + } + if (result.Settings.WeatherAlerts?.Provider === "WeatherKit") { + result.Settings.DataSets = result.Settings.DataSets.filter(name => name !== "weatherAlerts"); + } + return result; +} diff --git a/src/wk3/entry/request.mjs b/src/wk3/entry/request.mjs new file mode 100644 index 00000000..a7663e87 --- /dev/null +++ b/src/wk3/entry/request.mjs @@ -0,0 +1,55 @@ +import { configuration } from "./index.mjs"; +import { webFetch } from "wk3:runtime"; +import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import AirQualityScale from "../class/AirQualityScale.mjs"; +import { headerObject, responseHeaders } from "../runtime.mjs"; +import { isNativeAlertDetailsID, resolveNativeAlertDetails } from "../class/NativeAlertDetails.mjs"; + +export async function handleRequest(ctx) { + const url = new URL(ctx.request.url); + if (url.hostname !== "weatherkit.apple.com") return; + if (/^\/api\/v[12]\/airQualityScale\//.test(url.pathname)) { + const parts = url.pathname.split("/").filter(Boolean); + const scaleName = decodeURIComponent(parts[4] ?? ""); + // Upstream dev requests EAQI's native, versionless Apple scale. + if (/^EU\.EAQI(?:\.\d+)?$/.test(scaleName)) { + url.pathname = `/api/${parts[1]}/airQualityScale/${parts[3]}/EU.EAQI`; + const headers = ctx.request.headers; + if (typeof headers?.delete === "function") headers.delete("if-none-match"); + else for (const key of Object.keys(headers ?? {})) if (key.toLowerCase() === "if-none-match") delete headers[key]; + return { url: url.toString(), headers }; + } + // Other standards retain their existing Apple-first behavior. + try { + const upstream = await webFetch(url.toString(), { headers: headerObject(ctx.request.headers) }); + const parts = url.pathname.split("/").filter(Boolean); + if (upstream.status === 200) { + let body = await upstream.text(); + return ctx.respond({ status: 200, headers: responseHeaders(upstream.headers), body }); + } + const fallback = AirQualityScale.buildScale(parts[3] ?? "en", parts[4] ?? ""); + if (fallback) return ctx.respond(fallback); + return ctx.respond({ status: upstream.status, headers: responseHeaders(upstream.headers), body: await upstream.text() }); + } catch { + // A failed script-side request lets the ordinary Apple request proceed. + return; + } + } + if (!/^\/api\/v[12]\/weatherAlerts$/.test(url.pathname)) return; + const { Settings } = configuration(ctx); + if (!Settings.DataSets.includes("weatherAlerts")) return; + const ids = url.searchParams.get("ids") ?? ""; + const language = url.searchParams.get("lang") ?? "zh-CN"; + const native = isNativeAlertDetailsID(ids); + if (!native && !/^[a-zA-Z0-9][a-zA-Z0-9_-]*-\d{9}$/.test(ids)) return; + try { + const alerts = native + ? await resolveNativeAlertDetails(ids, language, webFetch) + : await WeatherAlerts.DetailsForQWeather(ids, language, webFetch); + return ctx.respond({ status: 200, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, body: JSON.stringify(alerts) }); + } catch { + // Reserved locators must never be forwarded to Apple; region IDs retain + // the cloud route's pass-through behavior on public provider failure. + if (native) return ctx.respond({ status: 200, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, body: "[]" }); + } +} diff --git a/src/wk3/entry/response.mjs b/src/wk3/entry/response.mjs new file mode 100644 index 00000000..bc5e7185 --- /dev/null +++ b/src/wk3/entry/response.mjs @@ -0,0 +1,49 @@ +import { configuration } from "./index.mjs"; +import { headerObject, responseHeaders } from "../runtime.mjs"; +import { Response as processResponse } from "../process/Response.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import shouldReplaceWeatherKit from "../function/shouldReplaceWeatherKit.mjs"; +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; + +async function processingContext(ctx, url, Settings, Configs) { + const parsed = parseWeatherKitURL(url); + const parameters = { ...parsed, dataSets: parsed.dataSets.filter(name => Settings.DataSets.includes(name)) }; + const enviroments = { + colorfulClouds: new ColorfulClouds(parameters, Settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings.API?.QWeather?.Token, Settings.API?.QWeather?.Host), + parameters, country: parameters.country, + }; + const preFetched = {}; + if (shouldReplaceWeatherKit(parameters, Settings)) { + const attempt = promise => Promise.resolve(promise).catch(() => undefined); + if (parameters.dataSets.includes("forecastNextHour") && Settings.NextHour.Provider !== "WeatherKit") { + preFetched.forecastNextHour = attempt(Settings.NextHour.Provider === "QWeather" ? enviroments.qWeather.Minutely() : enviroments.colorfulClouds.Minutely()); + } + // AQ acquisition is deferred until the decoded response proves it is needed. + } + return { Settings, Configs, parameters, enviroments, preFetched }; +} + +export async function handleResponse(ctx) { + const url = new URL(ctx.request.url); + if (url.hostname !== "weatherkit.apple.com") return; + const availability = /^\/api\/v[12]\/availability\//.test(url.pathname); + if (!availability && !/^\/api\/(?:v[23]\/weather\/|v[12]\/airQualityScale\/)/.test(url.pathname)) return; + const headers = headerObject(ctx.response.headers); + const binary = /flatbuffer|protobuf|octet-stream/i.test(headers["content-type"] ?? ""); + const originalBody = binary ? new Uint8Array(await ctx.response.arrayBuffer()) : await ctx.response.text(); + const originalStatus = ctx.response.status; + try { + const { Settings, Configs } = configuration(ctx); + const context = availability ? { Settings, Configs } : await processingContext(ctx, url, Settings, Configs); + const request = { url: url.toString(), method: ctx.request.method, headers: headerObject(ctx.request.headers) }; + // Binary is kept in body, not bodyBytes: Response writes its patch to body. + const response = { status: originalStatus, headers, body: originalBody }; + const processed = await processResponse(request, response, context); + return { status: processed.status ?? originalStatus, headers: responseHeaders(ctx.response.headers, processed.headers), body: processed.body ?? originalBody }; + } catch { + // The input stream was consumed; explicitly restore its original bytes. + return { status: originalStatus, headers: responseHeaders(ctx.response.headers, headers), body: originalBody }; + } +} diff --git a/src/wk3/function/buildSettings.mjs b/src/wk3/function/buildSettings.mjs new file mode 100644 index 00000000..94d107fb --- /dev/null +++ b/src/wk3/function/buildSettings.mjs @@ -0,0 +1,120 @@ +import { Console, get, set, merge } from "../utils/index.mjs"; + +/** + * 将字符串值转换为数字(如果是纯数字字符串) + * @param {string} string + * @returns {string|number} + */ +function string2number(string) { + if (/^\d+$/.test(string)) return Number.parseInt(string, 10); + return string; +} + +/** + * 将逗号分隔的字符串转为数组 + * @param {string|number|boolean|any} value + * @returns {any[]} + */ +function value2array(value) { + switch (typeof value) { + case "string": + return value.split(","); + case "number": + case "boolean": + return [value]; + default: + return value || []; + } +} + +/** + * 深度遍历对象,对每个叶子节点执行回调函数 + * @param {object} o + * @param {function} c + * @returns {object} + */ +function traverseObject(o, c) { + for (const t in o) { + const n = o[t]; + o[t] = "object" === typeof n && null !== n ? traverseObject(n, c) : c(t, n); + } + return o; +} + +/** + * 将 Settings 中特定路径的字符串值规范化为数组 + * @param {object} Settings + * @param {string} path - 点分隔路径,如 "Weather.Replace" + */ +function string2array(Settings, path) { + const setting = get(Settings, path); + if (!Array.isArray(setting)) set(Settings, path, setting === "[]" ? [] : setting ? [setting] : []); +} + +/** + * 构建请求级配置(纯函数,无全局副作用) + * + * 替代原来的 setENV → getStorage 调用链。 + * 每次请求独立构建,不读写任何模块级或全局状态。 + * + * @param {object} database - 默认数据库配置(database.mjs) + * @param {object} [queryArguments={}] - 请求级参数(URL query 或 base64 配置解码后的对象) + * @returns {{ Settings: object, Configs: object }} + */ +export default function buildSettings(database, queryArguments = {}) { + // 1. 从 database 深拷贝合并默认 Settings(Default → WeatherKit) + const Settings = merge( + {}, + database?.Default?.Settings, + database?.WeatherKit?.Settings, + ); + + // 2. 合并请求级参数(请求参数优先级最高) + if (queryArguments && typeof queryArguments === "object") { + merge(Settings, queryArguments); + if (Array.isArray(queryArguments.AirQuality?.Current?.Index?.Replace) + && queryArguments.AirQuality.Current.Index.Replace.length === 0) { + Settings.AirQuality.Current.Index.Replace = []; + } + } + + // 3. 规范化需要为数组的字段 + string2array(Settings, "Weather.Replace"); + string2array(Settings, "AirQuality.Current.Index.Replace"); + string2array(Settings, "AirQuality.Current.Pollutants.Units.Replace"); + + // 4. 类型转换:字符串 "true"/"false"/"[]" → 原生类型;逗号分隔 → 数组;纯数字字符串 → number + traverseObject(Settings, (_key, value) => { + switch (typeof value) { + case "string": + switch (value) { + case "true": + case "false": + case "[]": + return JSON.parse(value); + default: + if (value.includes(",")) return value2array(value).map(item => string2number(item)); + return string2number(value); + } + default: + return value; + } + }); + + // 5. 构建 Configs(深拷贝合并,并将 Locale/i18n 转为 Map) + const Configs = merge( + {}, + database?.Default?.Configs, + database?.WeatherKit?.Configs, + ); + if (Configs.Locale) Configs.Locale = new Map(Configs.Locale); + if (Configs.i18n) { + for (const type in Configs.i18n) { + Configs.i18n[type] = new Map(Configs.i18n[type]); + } + } + + Console.info("[buildSettings] Settings:", JSON.stringify(Settings, null, 2)); + + return { Settings, Configs }; +} diff --git a/src/wk3/function/database.mjs b/src/wk3/function/database.mjs new file mode 100644 index 00000000..3d5c950a --- /dev/null +++ b/src/wk3/function/database.mjs @@ -0,0 +1,207 @@ +export default { + WeatherKit: { + Settings: { + DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "locationInfo", "news", "historicalComparisons", "weatherAlerts", "weatherChanges"], + Weather: { Replace: ["CN"], ReplaceDaily: false, ReplaceHourly: false, Provider: "ColorfulClouds" }, + NextHour: { Provider: "ColorfulClouds" }, + AirQuality: { + Current: { Pollutants: { Provider: "ColorfulClouds", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: ["HJ6332012"], Provider: "ColorfulCloudsCN", ForceCNPrimaryPollutants: true } }, + Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { PollutantsProvider: "ColorfulCloudsCN", IndexProvider: "ColorfulCloudsCN" } }, + Calculate: { Algorithm: "WAQI_InstantCast_US", AllowOverRange: true }, + }, + API: { QWeather: { Token: null, Host: "devapi.qweather.com" }, ColorfulClouds: { Token: null } }, + EdgeCache: false, + }, + Configs: { + Availability: { + v1: ["currentWeather", "dailyForecast", "hourlyForecast", "minuteForecast", "weatherAlerts"], + v2: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastPeriodic", "historicalComparisons", "weatherChanges", "forecastNextHour", "weatherAlerts", "weatherAlertNotifications", "news"], + }, + }, + }, + Default: { + Settings: { LogLevel: "INFO" }, + Configs: { + Storefront: { + AE: "143481", + AF: "143610", + AG: "143540", + AI: "143538", + AL: "143575", + AM: "143524", + AO: "143564", + AR: "143505", + AT: "143445", + AU: "143460", + AZ: "143568", + BA: "143612", + BB: "143541", + BD: "143490", + BE: "143446", + BF: "143578", + BG: "143526", + BH: "143559", + BJ: "143576", + BM: "143542", + BN: "143560", + BO: "143556", + BR: "143503", + BS: "143539", + BT: "143577", + BW: "143525", + BY: "143565", + BZ: "143555", + CA: "143455", + CD: "143613", + CG: "143582", + CH: "143459", + CI: "143527", + CL: "143483", + CM: "143574", + CN: "143465", + CO: "143501", + CR: "143495", + CV: "143580", + CY: "143557", + CZ: "143489", + DE: "143443", + DK: "143458", + DM: "143545", + DO: "143508", + DZ: "143563", + EC: "143509", + EE: "143518", + EG: "143516", + ES: "143454", + FI: "143447", + FJ: "143583", + FM: "143591", + FR: "143442", + GA: "143614", + GB: "143444", + GD: "143546", + GF: "143615", + GH: "143573", + GM: "143584", + GR: "143448", + GT: "143504", + GW: "143585", + GY: "143553", + HK: "143463", + HN: "143510", + HR: "143494", + HU: "143482", + ID: "143476", + IE: "143449", + IL: "143491", + IN: "143467", + IQ: "143617", + IS: "143558", + IT: "143450", + JM: "143511", + JO: "143528", + JP: "143462", + KE: "143529", + KG: "143586", + KH: "143579", + KN: "143548", + KP: "143466", + KR: "143466", + KW: "143493", + KY: "143544", + KZ: "143517", + TC: "143552", + TD: "143581", + TJ: "143603", + TH: "143475", + TM: "143604", + TN: "143536", + TO: "143608", + TR: "143480", + TT: "143551", + TW: "143470", + TZ: "143572", + LA: "143587", + LB: "143497", + LC: "143549", + LI: "143522", + LK: "143486", + LR: "143588", + LT: "143520", + LU: "143451", + LV: "143519", + LY: "143567", + MA: "143620", + MD: "143523", + ME: "143619", + MG: "143531", + MK: "143530", + ML: "143532", + MM: "143570", + MN: "143592", + MO: "143515", + MR: "143590", + MS: "143547", + MT: "143521", + MU: "143533", + MV: "143488", + MW: "143589", + MX: "143468", + MY: "143473", + MZ: "143593", + NA: "143594", + NE: "143534", + NG: "143561", + NI: "143512", + NL: "143452", + NO: "143457", + NP: "143484", + NR: "143606", + NZ: "143461", + OM: "143562", + PA: "143485", + PE: "143507", + PG: "143597", + PH: "143474", + PK: "143477", + PL: "143478", + PT: "143453", + PW: "143595", + PY: "143513", + QA: "143498", + RO: "143487", + RS: "143500", + RU: "143469", + RW: "143621", + SA: "143479", + SB: "143601", + SC: "143599", + SE: "143456", + SG: "143464", + SI: "143499", + SK: "143496", + SL: "143600", + SN: "143535", + SR: "143554", + ST: "143598", + SV: "143506", + SZ: "143602", + UA: "143492", + UG: "143537", + US: "143441", + UY: "143514", + UZ: "143566", + VC: "143550", + VE: "143502", + VG: "143543", + VN: "143471", + VU: "143609", + XK: "143624", + YE: "143571", + ZA: "143472", + ZM: "143622", + ZW: "143605", + }, + }, + }, +}; diff --git a/src/wk3/function/parseWeatherKitURL.mjs b/src/wk3/function/parseWeatherKitURL.mjs new file mode 100644 index 00000000..9cb2f568 --- /dev/null +++ b/src/wk3/function/parseWeatherKitURL.mjs @@ -0,0 +1,25 @@ +import { Console } from "../utils/index.mjs"; + +export default function parseWeatherKitURL(url = new URL($request.url)) { + Console.debug("☑️ parseWeatherKitURL"); + // NSRingo/WeatherKit v3.3.2: preserve script subtags and normalize the region. + const WeatherKitRegExp = /^\/api\/(?<version>v1|v2|v3)\/(availability|weather)\/(?<locale>[A-Z0-9]+(?:-[A-Z0-9]+)*)\/(?<latitude>-?\d+\.?\d*)\/(?<longitude>-?\d+\.?\d*)$/i; + const Parameters = url?.pathname.match(WeatherKitRegExp)?.groups; + const localeParts = Parameters?.locale?.split("-") || []; + let localeCountry; + if (localeParts.length > 1 && /^[A-Z]{2}$/i.test(localeParts.at(-1))) localeCountry = localeParts.pop().toUpperCase(); + const result = { + version: Parameters?.version, + language: localeParts.join("-") || undefined, + latitude: Parameters?.latitude, + longitude: Parameters?.longitude, + country: url?.searchParams?.get("country")?.toUpperCase() || localeCountry, + dataSets: url?.searchParams?.get("dataSets")?.split(",") || [], + }; + //log(JSON.stringify(result, null, 2)); + //const LanguageParameters = result.language.match(LanguageRegExp)?.groups; + //result.language = LanguageParameters.language; + //result.country = result.country || LanguageParameters.country + Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, `🟧latitude: ${result.latitude} 🟧longitude: ${result.longitude}`); + return result; +} diff --git a/src/wk3/function/shouldReplaceWeatherKit.mjs b/src/wk3/function/shouldReplaceWeatherKit.mjs new file mode 100644 index 00000000..7444db67 --- /dev/null +++ b/src/wk3/function/shouldReplaceWeatherKit.mjs @@ -0,0 +1,53 @@ +const HONG_KONG_BOUNDS = { + minLatitude: 22.10, + maxLatitude: 22.45, + minLongitude: 113.80, + maxLongitude: 114.45, +}; + +const MACAU_BOUNDS = { + minLatitude: 22.05, + maxLatitude: 22.25, + minLongitude: 113.45, + maxLongitude: 113.65, +}; + +const TAIWAN_BOUNDS = { + minLatitude: 21.75, + maxLatitude: 25.50, + minLongitude: 119.20, + maxLongitude: 122.10, +}; + +function inBounds(latitude, longitude, bounds) { + return latitude >= bounds.minLatitude + && latitude <= bounds.maxLatitude + && longitude >= bounds.minLongitude + && longitude <= bounds.maxLongitude; +} + +export function isChinaMainland(parameters = {}) { + if (parameters.country !== "CN") return false; + + const latitude = Number(parameters.latitude); + const longitude = Number(parameters.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return false; + + if (latitude < 18 || latitude > 54 || longitude < 73 || longitude > 135) return false; + if (inBounds(latitude, longitude, HONG_KONG_BOUNDS)) return false; + if (inBounds(latitude, longitude, MACAU_BOUNDS)) return false; + if (inBounds(latitude, longitude, TAIWAN_BOUNDS)) return false; + + return true; +} + +export function isHongKong(parameters = {}) { + if (!["HK", "CN"].includes(parameters.country)) return false; + const latitude = Number(parameters.latitude), longitude = Number(parameters.longitude); + return Number.isFinite(latitude) && Number.isFinite(longitude) && inBounds(latitude, longitude, HONG_KONG_BOUNDS); +} + +export default function shouldReplaceWeatherKit(parameters = {}, Settings = {}) { + if (!Settings?.Weather?.Replace?.includes(parameters.country)) return false; + return isChinaMainland(parameters); +} diff --git a/src/wk3/option-values.mjs b/src/wk3/option-values.mjs new file mode 100644 index 00000000..4578e4c6 --- /dev/null +++ b/src/wk3/option-values.mjs @@ -0,0 +1,27 @@ +// Module string controls display their stored values. Reuse readable labels +// across clients; existing canonical values remain accepted by scripts. +const indexSources = { + Calculate: "iRingo内置算法", + ColorfulCloudsUS: "彩云天气(美标)", + ColorfulCloudsCN: "彩云天气(国标)", + QWeather: "和风天气", +}; +export const moduleOptionLabels = { + "AirQuality.Current.Index.Provider": indexSources, + "AirQuality.Comparison.Yesterday.IndexProvider": indexSources, + "AirQuality.Calculate.Algorithm": { + None: "不转换", UBA: "德国LQI", EU_EAQI: "欧盟EAQI", + WAQI_InstantCast_US: "美标InstantCast", WAQI_InstantCast_CN: "国标InstantCast(2012)", + WAQI_InstantCast_CN_25_DRAFT: "国标InstantCast(2025草案)", + CA_AQHI: "加拿大AQHI", HK_AQHI: "香港AQHI", AQHI_Multi_CN: "AQHI-Multi(中国)", + AQHI_Multi_CN_HK: "AQHI-Multi(中国+香港)", CN_DEATH_AQHI: "中国健康风险AQHI", + CN_DEATH_HK_AQHI: "中国+香港健康风险AQHI", + }, +}; +export function displayModuleOption(key, value) { + return moduleOptionLabels[key]?.[value] ?? value; +} +export function normalizeModuleOption(key, value) { + const labels = moduleOptionLabels[key]; + return labels ? Object.keys(labels).find(option => labels[option] === value) ?? value : value; +} diff --git a/src/wk3/parameters.mjs b/src/wk3/parameters.mjs new file mode 100644 index 00000000..df6a3068 --- /dev/null +++ b/src/wk3/parameters.mjs @@ -0,0 +1,33 @@ +// Parameter keys follow the fork's settings and upstream argument templates. +// Defaults reproduce the existing local module; WK3 AQ controls use shared conversion. +// `choices` documents list inputs; only scalar `options` render as single selects. +export const localParameters = [ + { key: "DataSets", type: "array", name: "[数据集]", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", choices: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], description: "选择要处理的数据;留空或 [] 全部保留原数据。" }, + { key: "Weather.Replace", type: "string", name: "[天气] 替换范围", value: "CN", description: "普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。" }, + { key: "Weather.Provider", type: "string", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不替换)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。" }, + { key: "Weather.ReplaceDaily", type: "boolean", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "是否替换逐日预报;仅 WK2 生效。" }, + { key: "Weather.ReplaceHourly", type: "boolean", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "是否替换逐小时预报;仅 WK2 生效。" }, + { key: "WeatherAlerts.Provider", type: "string", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["WeatherKit", "QWeatherWeb"], optionLabels: {"WeatherKit": "WeatherKit(不修改)", "QWeatherWeb": "和风天气网页"}, description: "和风网页补全原生预警;选择 WeatherKit 保留原数据。" }, + { key: "NextHour.Provider", type: "string", name: "[未来一小时降水强度] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不添加)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "选择未来一小时雨雪分钟数据来源。" }, + { key: "AirQuality.Current.Index.Provider", type: "string", name: "[今日空气指数] 数据源", value: "Calculate", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "选择指数来源:iRingo内置算法、彩云或和风。" }, + { key: "AirQuality.Current.Index.Replace", type: "array", name: "[今日空气指数] 替换目标", value: "HJ6332012", choices: ["HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", "ES.MITECO", "SG.NEA", "NAQI", "DAQI", "HK.AQHI", "CN.AQHI"], description: "填写要替换的原始标准,逗号分隔;留空或 [] 不替换。" }, + { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", type: "boolean", name: "[今日空气指数] 强制主要污染物", value: "true", boolean: true, description: "国标指数不超过 50 时,仍显示主要污染物。" }, + { key: "AirQuality.Current.Pollutants.Provider", type: "string", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], optionLabels: {"ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "已有 Apple 污染物优先保留;缺失时使用此来源。" }, + { key: "AirQuality.Current.Pollutants.Units.Replace", type: "array", name: "[今日污染物 - 单位转换] 替换目标", value: "[]", choices: ["EPA_NowCast", "EU.EAQI", "HJ6332012", "UBA", "CA.AQHI", "HK.AQHI", "CN.AQHI"], description: "需要转换单位的空气质量标准;留空或 [] 不转换。" }, + { key: "AirQuality.Current.Pollutants.Units.Mode", type: "string", name: "[今日污染物 - 单位转换] 模式", value: "Scale", options: ["None", "Scale", "ugm3", "EU_ppb", "US_ppb", "Force_ugm3", "Force_EU_ppb", "Force_US_ppb"], optionLabels: {"None": "不转换污染物单位", "Scale": "与空气质量标准的要求相同", "ugm3": "除非标准要求,都转为µg/m³", "EU_ppb": "除非标准要求,都转为欧盟ppb", "US_ppb": "除非标准要求,都转为美标ppb", "Force_ugm3": "µg/m³", "Force_EU_ppb": "欧盟ppb", "Force_US_ppb": "美标ppb"}, description: "Scale 跟随标准,Force 强制转换,None 保留原单位。" }, + { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", type: "boolean", name: "[空气质量 - 对比昨日] 变化时替换", value: "false", boolean: true, description: "今日指数变化时,是否重新计算昨日对比。" }, + { key: "AirQuality.Comparison.Yesterday.IndexProvider", type: "string", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "选择昨日指数来源;仅同源、同标准的数据可比较。" }, + { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", type: "string", name: "[昨日污染物] 数据源", value: "QWeather", options: ["QWeather"], optionLabels: {"QWeather": "和风天气"}, description: "内置算法计算昨日指数所需的污染物来源。" }, + { key: "AirQuality.Calculate.Algorithm", type: "string", name: "[iRingo内置算法] 算法", value: "EU_EAQI", options: ["None", "UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI"], optionLabels: {"None": "不转换", "UBA": "德国LQI(FB001846)", "EU_EAQI": "欧盟EAQI(ETC HE Report 2024/17)", "WAQI_InstantCast_US": "美标InstantCast(EPA-454/B-24-002)", "WAQI_InstantCast_CN": "国标InstantCast(HJ 633—2012)", "WAQI_InstantCast_CN_25_DRAFT": "国标InstantCast(HJ 633 2025年草案)", "CA_AQHI": "加拿大AQHI(10.17269/s41997-019-00237-w)", "HK_AQHI": "香港AQHI", "AQHI_Multi_CN": "AQHI-Multi(中国)", "AQHI_Multi_CN_HK": "AQHI-Multi(中国+香港)", "CN_DEATH_AQHI": "中国(致死风险)AQHI", "CN_DEATH_HK_AQHI": "中国(致死风险)+香港AQHI"}, description: "使用污染物本地计算指数;None 不转换。" }, + { key: "AirQuality.Calculate.AllowOverRange", type: "boolean", name: "[iRingo内置算法] 允许指数超标", value: "true", boolean: true, description: "允许内置美标、国标指数超过 500。" }, + { key: "API.ColorfulClouds.Token", type: "string", name: "[API] 彩云天气令牌", value: "", description: "自有彩云令牌;留空使用内置公开令牌。" }, + { key: "API.QWeather.Host", type: "string", name: "[API] 和风天气主机", value: "devapi.qweather.com", description: "和风 API 主机名;使用和风来源时填写。" }, + { key: "API.QWeather.Token", type: "string", name: "[API] 和风天气令牌", value: "", description: "和风 API 令牌;网页预警不需要。" }, + { key: "LogLevel", type: "string", name: "[调试] 日志等级", value: "INFO", options: ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "ALL"], optionLabels: {"OFF": "关闭", "ERROR": "❌ 错误", "WARN": "⚠️ 警告", "INFO": "ℹ️ 信息", "DEBUG": "🅱️ 调试", "ALL": "全部"}, description: "默认 INFO;DEBUG 显示细节,OFF 关闭日志。" }, + { key: "HTTP_TIMEOUT_MS", type: "number", name: "[网络] 单次请求超时(毫秒)", value: "8000", description: "单次网络请求超时,默认 8000 毫秒。" }, +]; + +// Select controls already show their choices; help text stays concise. +export function parameterDescription(parameter) { + return parameter.description; +} diff --git a/src/wk3/process/Response.mjs b/src/wk3/process/Response.mjs new file mode 100644 index 00000000..3df73450 --- /dev/null +++ b/src/wk3/process/Response.mjs @@ -0,0 +1,1314 @@ +import * as flatbuffers from "flatbuffers"; +import AirQuality from "../class/AirQuality.mjs"; +import AirQualityScale from "../class/AirQualityScale.mjs"; +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; +import Weather from "../class/Weather.mjs"; +import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../class/WeatherKit2.mjs"; +import { decodeWK3DataSet, encodeAirQuality as encodeWK3AirQuality, providerToWK3AirQuality, wk3AirQualityToProvider, assertWK3AirQualityReplaceable } from "../class/WeatherKit3.mjs"; +import { injectWK3Comparison } from "../class/WK3Comparison.mjs"; +import { patchWK3QWeatherAlerts } from "../class/WK3Alerts.mjs"; +import { decodeWK3NextHour, encodeNextHour as encodeWK3NextHour, providerToWK3NextHour } from "../class/WK3NextHour.mjs"; +import * as WK2 from "../proto/apple/wk2.js"; +import database from "../function/database.mjs"; +import mergeWeatherKitAvailability from "../../function/mergeWeatherKitAvailability.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import buildSettings from "../function/buildSettings.mjs"; +import shouldReplaceWeatherKit from "../function/shouldReplaceWeatherKit.mjs"; +import { Console } from "../utils/index.mjs"; +/***************** Processing *****************/ +export async function Response($request, $response, context = {}) { + // 解构预取数据(从 Hono.js 并发预取传入) + const { preFetched = {}, enviroments: preEnviroments, parameters: preParameters, Settings: preSettings, Configs: preConfigs } = context; + // 解构URL + const url = new URL($request.url); + Console.debug("url:", url.toJSON()); + // 获取连接参数 + const PATHs = url.pathname.split("/").filter(Boolean); + Console.debug("PATHs:", PATHs); + // 解析格式 + const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]; + Console.debug("FORMAT:", FORMAT); + // 打印 Apple 原始响应日志 + Console.debug("[Apple Response]", url.pathname, `status: ${$response.status}`, `content-type: ${FORMAT}`); + if (url.pathname.startsWith("/api/v1/airQualityScale/") || url.pathname.startsWith("/api/v2/airQualityScale/")) { + try { + const preview = typeof $response.body === "string" ? $response.body : $response.bodyBytes ? new TextDecoder().decode(new Uint8Array($response.bodyBytes)) : "(binary)"; + Console.log(`[Apple Response Body] ${url.pathname}`, preview.length > 2000 ? `${preview.slice(0, 2000)}...` : preview); + } catch (e) { + Console.log(`[Apple Response Body] ${url.pathname}`, "(无法解析)", e.message); + } + } + /** + * 设置 + * @type {{Settings: import('../types').Settings}} + */ + const { Settings, Configs } = preSettings ? { Settings: preSettings, Configs: preConfigs } : buildSettings(database); + const requestHeaders = Object.fromEntries(Object.entries($request.headers || {}).map(([key, value]) => [key.toLowerCase(), Array.isArray(value) ? value.join(",") : `${value}`])); + const userAgent = requestHeaders["user-agent"] || ""; + const schemaVersion = requestHeaders["x-schema-version"] || ""; + const isIOS27WeatherFlatBuffer = /WeatherKit_Weather_iOS_Version 27/i.test(userAgent) || /^lib-rc-2\./i.test(schemaVersion); + + // 创建空数据 + let body = {}; + // airQualityScale 请求:Apple 200 直接透传,404 则本地构建 + if (url.pathname.startsWith("/api/v1/airQualityScale/") || url.pathname.startsWith("/api/v2/airQualityScale/")) { + if ($response.status === 200) { + Console.log(`[proxy] airQualityScale Apple 返回 200,透传: ${url.pathname}`); + return $response; + } + // Apple 返回 404 或其他错误,本地构建 + const pathParts = url.pathname.split("/").filter(Boolean); + const language = pathParts[3] ?? "en"; + const scaleName = pathParts[4] ?? ""; + const localScale = AirQualityScale.buildScale(language, scaleName); + if (localScale) { + Console.log(`[proxy] airQualityScale Apple 返回 ${$response.status},本地构建: ${scaleName}`); + $response.status = 200; + $response.headers = { ...$response.headers, ...localScale.headers }; + $response.body = localScale.body; + return $response; + } + } + // Preserve Apple's capability list and errors; only extend a recognized array. + if (url.hostname === "weatherkit.apple.com" && /^\/api\/v[12]\/availability\//.test(url.pathname)) { + if ($response.status !== 200 || !["application/json", "text/json"].includes(FORMAT)) return $response; + try { + const original = JSON.parse($response.body); + if (!Array.isArray(original) || !original.every(item => typeof item === "string")) return $response; + const merged = mergeWeatherKitAvailability(original, Configs?.Availability?.v2); + if (JSON.stringify(merged) !== JSON.stringify(original)) $response.body = JSON.stringify(merged); + } catch { /* Unknown payloads remain unchanged. */ } + return $response; + } + // 格式判断 + switch (FORMAT) { + case undefined: // 视为无body + break; + case "application/x-www-form-urlencoded": + case "text/plain": + default: + break; + case "application/x-mpegURL": + case "application/x-mpegurl": + case "application/vnd.apple.mpegurl": + case "audio/mpegurl": + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + break; + case "text/vtt": + case "application/vtt": + break; + case "text/json": + case "application/json": + body = JSON.parse($response.body); + Console.debug(`[Apple Response] ${url.pathname}`, body); + $response.body = JSON.stringify(body); + break; + case "application/vnd.apple.flatbuffer": + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + case "application/grpc": + case "application/grpc+proto": + case "application/octet-stream": { + let rawBody = $response.bodyBytes ? new Uint8Array($response.bodyBytes) : ($response.body ?? new Uint8Array()); + switch (FORMAT) { + case "application/vnd.apple.flatbuffer": { + if (url.hostname !== "weatherkit.apple.com" || !/^\/api\/v[23]\/weather\//.test(url.pathname) || $response.status !== 200) break; + const parsedParameters = preParameters || parseWeatherKitURL(url); + const parameters = { ...parsedParameters, dataSets: parsedParameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)) }; + const shouldReplace = shouldReplaceWeatherKit(parameters, Settings); + if (!shouldReplace && !url.pathname.startsWith("/api/v3/weather/")) { + Console.log(`[proxy] ${parameters.country}/${parameters.latitude},${parameters.longitude} 无需替换,直接跳过 FlatBuffer 编解码。`); + break; + } + if (url.pathname.startsWith("/api/v3/weather/")) { + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings?.API?.QWeather?.Token, Settings?.API?.QWeather?.Host), + parameters, + country: parameters.country, + }; + if (parameters.dataSets.includes("airQuality")) rawBody = await PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched); + if (shouldReplace && parameters.dataSets.includes("forecastNextHour")) rawBody = await PatchWK3NextHour(rawBody, Settings, enviroments, preFetched); + if (shouldReplace && parameters.dataSets.includes("weatherAlerts")) { + const report = {}; + const patched = await patchWK3QWeatherAlerts(rawBody, { language: parameters.language, timezone: url.searchParams.get("timezone") || undefined, rewriteDetails: true, report }); + Console.info("WK3 alerts summary", `摘要补全:${report.summariesUpdated}/${report.total} 条;事件匹配:${report.matched}/${report.total} 条。`); + Console.info("WK3 alerts details", report.detailsConverted ? "详情转换:已切换苹果原生详情,保留原事件 ID。" : `详情转换:未转换,保留原链接;reason=${report.reason}`, report.detailsConverted ? "" : report.unmatched); + if (report.titleMismatches.length) Console.info("WK3 alerts titles", report.titleMismatches); + rawBody = patched; + } + break; + } + if (isIOS27WeatherFlatBuffer && url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v2/weather/")) { + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings?.API?.QWeather?.Token, Settings?.API?.QWeather?.Host), + parameters, + country: parameters.country, + }; + rawBody = await PatchIOS27WeatherFlatBuffer(rawBody, Settings, enviroments, preFetched, parameters); + break; + } + + // 解析FlatBuffer + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v2/weather/")) { + const dataSets = parameters.dataSets.filter(dataSet => ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour"].includes(dataSet)); + body = WeatherKit2.decode(ByteBuffer, dataSets); + // 优先使用 Hono.js 预构建的环境实例,避免重复创建 + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings?.API?.QWeather?.Token, Settings?.API?.QWeather?.Host), + parameters, + country: parameters.country, + }; + + const allSections = ["currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "airQuality"]; + + await Promise.all( + dataSets.map(async dataSet => { + switch (dataSet) { + case "airQuality": { + body.airQuality = await InjectAirQuality(body.airQuality, Settings, enviroments, preFetched); + break; + } + case "currentWeather": { + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments, preFetched.currentWeather); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments, preFetched.forecastDaily); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments, preFetched.forecastHourly); + break; + } + case "forecastNextHour": { + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments, preFetched.forecastNextHour); + break; + } + default: + break; + } + }), + ); + + // 去掉所有 providerLogo + allSections.forEach(s => { + if (body?.[s]?.metadata?.providerLogo) { + body[s].metadata.providerLogo = undefined; + } + }); + rawBody = WeatherKit2.encode(ByteBuffer, body); + break; + } + break; + } + break; + } + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + break; + case "application/grpc": + case "application/grpc+proto": + break; + case "application/octet-stream": + break; + } + // 写入二进制数据 + $response.body = rawBody; + break; + } + } + return $response; +} + +// Only explicitly supported providers may supply an index; None is not an algorithm. +function canInjectIndex(Settings) { + const aq = Settings?.AirQuality; + const provider = aq?.Current?.Index?.Provider; + if (["QWeather", "ColorfulCloudsUS", "ColorfulCloudsCN"].includes(provider)) return true; + return provider === "Calculate" && aq?.Calculate?.Algorithm !== "None" + && Object.hasOwn(AirQuality.Config.Scales, aq?.Calculate?.Algorithm ?? "") + && ["WeatherKit", "QWeather", "ColorfulClouds"].includes(aq?.Current?.Pollutants?.Provider); +} + +function validIndex(candidate) { + return !!candidate?.metadata && !candidate.metadata.temporarilyUnavailable + && Number.isInteger(candidate.index) && candidate.index >= 0 && candidate.index <= 32767 + && Number.isInteger(candidate.categoryIndex) && candidate.categoryIndex >= 1 && candidate.categoryIndex <= 127 + && typeof candidate.scale === "string" && candidate.scale.length > 0; +} + +// Complete WK3 AQ products use the explicit upstream enum mappings. Existing +// unknown AQ fields remain protected; all other weather arenas stay untouched. +async function wk3AQCandidate(original, Settings, enviroments, preFetched) { + const provider = Settings?.AirQuality?.Current?.Index?.Provider; + const pollutantProvider = Settings?.AirQuality?.Current?.Pollutants?.Provider; + let pollutants; + if (provider === "Calculate" && !preFetched.index) { + // Upstream keeps Apple's available pollutants; fetch the selected + // source only when the original pollutant product is missing. + pollutants = original?.pollutants?.length ? wk3AirQualityToProvider(original) + : preFetched.pollutants ? await preFetched.pollutants + : pollutantProvider === "WeatherKit" ? undefined + : await InjectPollutants(Settings, enviroments); + if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return; + } + let candidate = preFetched.index ? await preFetched.index : await InjectIndex(pollutants, Settings, enviroments); + if (!validIndex(candidate)) return; + candidate = { ...candidate, metadata: { ...pollutants?.metadata, ...candidate.metadata } }; + if (provider !== "Calculate") { + const sameSource = pollutantProvider === "ColorfulClouds" && /^ColorfulClouds(?:US|CN)$/.test(provider) + || pollutantProvider === "QWeather" && provider === "QWeather"; + pollutants = original?.pollutants?.length ? wk3AirQualityToProvider(original) + : preFetched.pollutants ? await preFetched.pollutants + : sameSource ? candidate + : pollutantProvider === "WeatherKit" ? candidate + : await InjectPollutants(Settings, enviroments); + if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return; + } + // Calculators already normalize the concentrations used by their index. + // Unit selection operates on that result, rather than reverting to raw units. + const unitInput = provider === "Calculate" && Settings.AirQuality.Current.Pollutants.Units?.Mode !== "None" ? candidate : (pollutants ?? candidate); + if (provider === "Calculate") candidate.metadata.providerName = AirQuality.appendScaleToProviderName(candidate, Settings); + candidate.pollutants = AirQuality.ConvertPollutants(candidate, unitInput, true, candidate, Settings); + return candidate; +} + +async function wk3AQComparison(current, original, Settings, enviroments, preFetched) { + if (original && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange !== true + && [2, 3, 4].includes(original.previousDayComparison)) { + return { ...current, previousDayComparison: original.previousDayComparison }; + } + const aq = Settings.AirQuality; + const algorithm = aq.Calculate?.Algorithm; + const calculated = aq.Current?.Index?.Provider === "Calculate"; + const currentSource = original?.pollutants?.length ? "WeatherKit" : aq.Current?.Pollutants?.Provider; + const yesterdaySource = aq.Comparison?.Yesterday?.PollutantsProvider === "ColorfulCloudsCN" + ? "ColorfulClouds" : aq.Comparison?.Yesterday?.PollutantsProvider; + const currentProvider = calculated ? `Calculate:${currentSource}:${algorithm}` : aq.Current?.Index?.Provider; + const yesterdayProvider = aq.Comparison?.Yesterday?.IndexProvider === "Calculate" + ? `Calculate:${yesterdaySource}:${algorithm}` : aq.Comparison?.Yesterday?.IndexProvider; + try { + if (calculated && preFetched.index && !preFetched.pollutants) return { ...current, previousDayComparison: 0 }; + let yesterday = preFetched.yesterdayAirQuality, locationInfo; + if (currentProvider !== yesterdayProvider) { + // Upstream compares the selected source's own current/yesterday + // pair when its standard differs from the displayed index. + if (["ColorfulCloudsUS", "ColorfulCloudsCN"].includes(yesterdayProvider)) { + const useUsa = yesterdayProvider === "ColorfulCloudsUS"; + const today = await enviroments.colorfulClouds.CurrentAirQuality(useUsa, aq.Current.Index.ForceCNPrimaryPollutants); + const comparison = await injectWK3Comparison(today, { + currentProvider: yesterdayProvider, yesterdayProvider, sources: enviroments, + yesterday: preFetched.yesterdayAirQuality, + }); + const previousDayComparison = comparison.previousDayComparison; + const source = `彩云天气(${useUsa ? "美标" : "国标"})`; + Console.info("WK3 AQ comparison", `对比来源:${source}今日与昨日;结果=${previousDayComparison}。`); + return { ...current, previousDayComparison, metadata: { ...current.metadata, + providerName: previousDayComparison ? `${current.metadata.providerName}\n对比昨日:${source}` : current.metadata.providerName } }; + } + return { ...current, previousDayComparison: 0 }; + } + if (yesterday === undefined && (yesterdayProvider === "QWeather" || calculated && yesterdaySource === "QWeather")) { + const grid = preFetched.locationsGrid ?? await QWeather.GetLocationsGrid(undefined, () => {}); + locationInfo = QWeather.GetLocationInfo(grid, enviroments.qWeather.latitude, enviroments.qWeather.longitude); + } + if (yesterday === undefined && calculated) { + // The ColorfulClouds historical endpoint contains categories, not + // historical concentrations; it cannot stand in for calculated AQ. + if (yesterdaySource !== "QWeather") return { ...current, previousDayComparison: 0 }; + const source = await enviroments.qWeather.YesterdayAirQuality(locationInfo); + if (!source?.metadata || source.metadata.temporarilyUnavailable || !source.pollutants?.length) return { ...current, previousDayComparison: 0 }; + const result = AirQuality.Pollutants2AQI(source, Settings, { algorithm }); + yesterday = { ...result, metadata: { ...source.metadata, ...result?.metadata } }; + } + return await injectWK3Comparison(current, { currentProvider, yesterdayProvider, sources: enviroments, yesterday, locationInfo }); + } catch { + return { ...current, previousDayComparison: 0 }; + } +} + +async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = {}) { + try { + const original = decodeWK3DataSet(rawBody); + if (!original) return await PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched, true); + // Upstream EAQI is versionless. Update only the display resource string + // for an existing EAQI product; retain its values and all unknown fields. + if (/^EU\.EAQI\.\d+$/.test(original.scale)) { + const root = readUint32LE(rawBody, 0); + const field = getTableFieldOffset(rawBody, root, 0); + const table = root + field + readUint32LE(rawBody, root + field); + rawBody = appendFlatBufferObjectToTableField(rawBody, table, 7, createStringBuffer("EU.EAQI")); + original.scale = "EU.EAQI"; + } + const scaleName = AirQuality.GetNameFromScale(original.scale); + const replace = Settings?.AirQuality?.Current?.Index?.Replace || []; + const replaceIndex = replace.includes(scaleName) && canInjectIndex(Settings); + const replaceUnits = Settings?.AirQuality?.Current?.Pollutants?.Units?.Replace?.includes(scaleName); + if (!replaceIndex && !replaceUnits) return rawBody; + assertWK3AirQualityReplaceable(rawBody); + let replacement; + if (replaceIndex) { + const candidate = await wk3AQCandidate(original, Settings, enviroments, preFetched); + if (!candidate) return rawBody; + replacement = providerToWK3AirQuality(candidate); + const changedIndex = ["index", "categoryIndex", "scale"].some(key => replacement[key] !== original[key]); + if (!changedIndex && !replaceUnits && Settings.AirQuality.Current.Index.Provider !== "Calculate") return rawBody; + replacement = changedIndex ? await wk3AQComparison(replacement, original, Settings, enviroments, preFetched) + : { ...replacement, previousDayComparison: original.previousDayComparison }; + } else { + const provider = wk3AirQualityToProvider(original); + const pollutants = AirQuality.ConvertPollutants(provider, provider, false, provider, Settings); + replacement = providerToWK3AirQuality({ ...provider, pollutants }); + replacement.previousDayComparison = original.previousDayComparison; + } + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3AirQuality(builder, replacement)); + const patched = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); + Console.info("WK3 AQ patch", `${original.scale} -> ${replacement.scale}; AQI: ${original.index} -> ${replacement.index}; 算法=${Settings.AirQuality.Current.Index.Provider === "Calculate" ? Settings.AirQuality.Calculate.Algorithm : "数据源直取"}; 污染物=${replacement.pollutants.length}; 昨日对比=${replacement.previousDayComparison}。`); + return patched; + } catch (error) { + Console.warn("WK3 AQ patch", "指数或单位转换失败,保留 Apple 原始二进制响应。", error?.message ?? error); + return rawBody; + } +} + +async function PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched = {}, mainland = false) { + if (!Settings?.AirQuality?.Current?.Index?.Replace?.length || !canInjectIndex(Settings)) return rawBody; + const label = mainland ? "WK3 missing AQ patch" : "WK3 HK AQ patch"; + const region = { TW: "台湾地区", MO: "澳门" }[enviroments.country] || (mainland ? "大陆" : "香港"); + try { + const candidate = await wk3AQCandidate(null, Settings, enviroments, preFetched); + if (!candidate) return rawBody; + const replacement = await wk3AQComparison(providerToWK3AirQuality(candidate), null, Settings, enviroments, preFetched); + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3AirQuality(builder, replacement)); + const patched = rebuildWeatherRootWithAddedTable(rawBody, 0, builder.asUint8Array()); + Console.info(label, `补入${region} AQI ${replacement.index} (${replacement.scale}); 算法=${Settings.AirQuality.Current.Index.Provider === "Calculate" ? Settings.AirQuality.Calculate.Algorithm : "数据源直取"}; 昨日对比=${replacement.previousDayComparison},其他天气原样保留。`); + return patched; + } catch (error) { + Console.warn(label, "数据源或转换不可用,保持原响应。", error?.message ?? error); + return rawBody; + } +} + +async function PatchWK3NextHour(rawBody, Settings, enviroments, preFetched = {}) { + if (Settings?.NextHour?.Provider === "WeatherKit") return rawBody; + try { + // Inspect before replacing: unknown future fields retain their arena. + decodeWK3NextHour(rawBody); + const data = await InjectForecastNextHour(undefined, Settings, enviroments, preFetched.forecastNextHour); + const report = {}; + const nextHour = providerToWK3NextHour(data, { intensityUnit: "mm/h", chanceUnit: "percent", report }); + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3NextHour(builder, nextHour)); + const root = readUint32LE(rawBody, 0); + const patched = getTableFieldOffset(rawBody, root, 5) + ? appendFlatBufferTableToWeatherRoot(rawBody, 5, builder.asUint8Array()) + : rebuildWeatherRootWithAddedTable(rawBody, 5, builder.asUint8Array()); + Console.info("WK3 nextHour patch", ...formatNextHourSummary({ ...data, condition: nextHour.condition }), `precipitationTypes: ${[...new Set(data.summary.flatMap(item => Array.isArray(item.condition) ? item.condition : [item.condition]))].join(",")}`, `omittedSnowDescriptions: ${report.omittedSnowConditions}`); + return patched; + } catch (error) { + Console.warn("WK3 nextHour patch", "分钟降水替换失败,保留原响应。", error?.message ?? error); + return rawBody; + } +} + +async function PatchIOS27WeatherFlatBuffer(rawBody, Settings, enviroments, preFetched = {}, parameters = {}) { + let patchedBody = rawBody; + if (parameters.dataSets?.includes("airQuality")) { + patchedBody = await PatchIOS27AirQualityFlatBuffer(patchedBody, Settings, enviroments, preFetched); + } + if (parameters.dataSets?.includes("forecastNextHour")) { + patchedBody = await PatchIOS27ForecastNextHourFlatBuffer(patchedBody, Settings, enviroments, preFetched); + } + return patchedBody; +} + +async function PatchIOS27AirQualityFlatBuffer(rawBody, Settings, enviroments, preFetched = {}) { + try { + if (!getTableFieldOffset(rawBody, readUint32LE(rawBody, 0), 0)) { + Console.info("FlatBuffer AQ patch", "iOS 27 原始响应没有 airQuality 字段,保持 Apple 原始二进制响应。"); + return rawBody; + } + + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + const WeatherData = WK2.Weather.getRootAsWeather(ByteBuffer); + const originalAirQualityData = WeatherData?.airQuality(); + if (!originalAirQualityData) { + Console.info("FlatBuffer AQ patch", "iOS 27 原始响应 airQuality 为空,保持 Apple 原始二进制响应。"); + return rawBody; + } + + const originalAirQuality = decodeAirQuality(originalAirQualityData); + const patchedAirQuality = await InjectAirQuality(originalAirQuality, Settings, enviroments, preFetched); + const comparisonOffset = getTableFieldOffset(rawBody, originalAirQualityData.bb_pos, 5); + let patchedBody; + let patchMode = "known-fields"; + if (!comparisonOffset && patchedAirQuality?.previousDayComparison && patchedAirQuality.previousDayComparison !== AirQuality.Config.CompareCategoryIndexes.UNKNOWN) { + const builder = new flatbuffers.Builder(); + const patchedAirQualityOffset = encodeAirQuality(builder, patchedAirQuality); + builder.finish(patchedAirQualityOffset); + patchedBody = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); + patchMode = "replace-airQuality-table"; + } else { + patchedBody = patchIOS27AirQualityKnownFields(rawBody, originalAirQualityData.bb_pos, patchedAirQuality); + } + Console.info( + "FlatBuffer AQ patch", + patchMode === "replace-airQuality-table" + ? "检测到 iOS 27 WK2.Weather,原始 airQuality 缺少 previousDayComparison 字段,替换 airQuality 子表以写入昨日对比。" + : "检测到 iOS 27 WK2.Weather,仅修改 airQuality 已知字段并保留 Apple 原始未知字段。", + `index: ${patchedAirQuality?.index}`, + `primaryPollutant: ${patchedAirQuality?.primaryPollutant}`, + `scale: ${patchedAirQuality?.scale}`, + `previousDayComparison: ${patchedAirQuality?.previousDayComparison}`, + ); + return patchedBody; + } catch (error) { + Console.warn("FlatBuffer AQ patch", "替换 iOS 27 airQuality 失败,已回退为 Apple 原始二进制响应。", error?.message ?? error); + return rawBody; + } +} + +function patchIOS27AirQualityKnownFields(rawBody, airQualityTablePosition, airQuality) { + const patchedBody = new Uint8Array(rawBody); + + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 1, airQuality?.categoryIndex); + writeTableInt16IfPresent(patchedBody, airQualityTablePosition, 2, airQuality?.index); + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 3, airQuality?.isSignificant ? 1 : 0); + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 5, WK2.ComparisonTrend[airQuality?.previousDayComparison]); + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 6, WK2.PollutantType[airQuality?.primaryPollutant]); + + let bodyWithAppendedData = patchedBody; + bodyWithAppendedData = appendFlatBufferObjectToTableField(bodyWithAppendedData, airQualityTablePosition, 7, createStringBuffer(airQuality?.scale)); + return bodyWithAppendedData; +} + +function createMetadataBuffer(metadata = {}) { + const builder = new flatbuffers.Builder(); + const metadataOffset = WK2.Metadata.createMetadata( + builder, + builder.createString(metadata?.attributionUrl), + metadata?.expireTime, + builder.createString(metadata?.language), + metadata?.latitude, + metadata?.longitude, + builder.createString(metadata?.providerLogo), + builder.createString(metadata?.providerName), + metadata?.readTime, + metadata?.reportedTime, + metadata?.temporarilyUnavailable, + WK2.SourceType[metadata?.sourceType], + ); + builder.finish(metadataOffset); + return builder.asUint8Array(); +} + +function createPollutantsVectorBuffer(pollutants = []) { + const builder = new flatbuffers.Builder(); + const pollutantOffsets = pollutants.map(pollutant => + WK2.Pollutant.createPollutant( + builder, + WK2.PollutantType[pollutant?.pollutantType], + pollutant?.amount, + WK2.UnitType[pollutant?.units], + ), + ); + const pollutantsOffset = WK2.AirQuality.createPollutantsVector(builder, pollutantOffsets); + builder.finish(pollutantsOffset); + return builder.asUint8Array(); +} + +function createStringBuffer(value) { + const builder = new flatbuffers.Builder(); + const stringOffset = builder.createString(value ?? ""); + builder.finish(stringOffset); + return builder.asUint8Array(); +} + +async function PatchIOS27ForecastNextHourFlatBuffer(rawBody, Settings, enviroments, preFetched = {}) { + try { + if (!getTableFieldOffset(rawBody, readUint32LE(rawBody, 0), 4)) { + const patchedNextHour = await InjectForecastNextHour(undefined, Settings, enviroments, preFetched.forecastNextHour); + if (patchedNextHour?.metadata) { + const builder = new flatbuffers.Builder(); + const patchedNextHourOffset = encodeForecastNextHour(builder, patchedNextHour); + builder.finish(patchedNextHourOffset); + const patchedBody = rebuildWeatherRootWithAddedTable(rawBody, 4, builder.asUint8Array()); + Console.info( + "FlatBuffer nextHour patch", + "iOS 27 原始响应没有 forecastNextHour 字段,已重建 Weather 根表并补入 forecastNextHour,原始子表保持复用。", + ...formatNextHourSummary(patchedNextHour), + ); + return patchedBody; + } + Console.info( + "FlatBuffer nextHour patch", + "iOS 27 原始响应没有 forecastNextHour 字段,且没有可用的下一小时降水数据,保持 Apple 原始二进制响应。", + ...formatNextHourSummary(patchedNextHour), + ); + return rawBody; + } + + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + const WeatherData = WK2.Weather.getRootAsWeather(ByteBuffer); + const originalNextHourData = WeatherData?.forecastNextHour(); + if (!originalNextHourData) { + Console.info("FlatBuffer nextHour patch", "iOS 27 原始响应 forecastNextHour 为空,保持 Apple 原始二进制响应。"); + return rawBody; + } + + const originalNextHour = decodeForecastNextHour(originalNextHourData); + const patchedNextHour = await InjectForecastNextHour(originalNextHour, Settings, enviroments, preFetched.forecastNextHour); + if (!patchedNextHour?.metadata) { + Console.info("FlatBuffer nextHour patch", "没有可用的下一小时降水数据,保持 Apple 原始 forecastNextHour。", ...formatNextHourSummary(patchedNextHour)); + return rawBody; + } + + const builder = new flatbuffers.Builder(); + const patchedNextHourOffset = encodeForecastNextHour(builder, patchedNextHour); + builder.finish(patchedNextHourOffset); + const patchedBody = appendFlatBufferTableToWeatherRoot(rawBody, 4, builder.asUint8Array()); + Console.info( + "FlatBuffer nextHour patch", + "检测到 iOS 27 WK2.Weather,仅替换 forecastNextHour 字段并保留其余 Apple 原始二进制字段。", + ...formatNextHourSummary(patchedNextHour), + ); + return patchedBody; + } catch (error) { + Console.warn("FlatBuffer nextHour patch", "替换 iOS 27 forecastNextHour 失败,已回退为 Apple 原始二进制响应。", error?.message ?? error); + return rawBody; + } +} + +function formatNextHourSummary(forecastNextHour) { + const minutes = forecastNextHour?.minutes ?? []; + const intensities = minutes.map(minute => minute?.precipitationIntensity ?? 0); + return [ + `minutes: ${minutes.length}`, + `summary: ${forecastNextHour?.summary?.length ?? 0}`, + `condition: ${forecastNextHour?.condition?.length ?? 0}`, + `rainyMinutes: ${intensities.filter(intensity => intensity > 0).length}`, + `maxIntensity: ${intensities.length ? Math.max(...intensities) : 0}`, + `firstToken: ${forecastNextHour?.condition?.[0]?.forecastToken ?? "UNKNOWN"}`, + ]; +} + +function appendFlatBufferTableToWeatherRoot(rawBody, weatherFieldIndex, tableBuffer) { + const rootTable = readUint32LE(rawBody, 0); + const fieldOffset = getTableFieldOffset(rawBody, rootTable, weatherFieldIndex); + if (!fieldOffset) return rawBody; + + const tableRoot = readUint32LE(tableBuffer, 0); + const appendAt = Math.ceil(rawBody.length / 4) * 4; + const patchedBody = new Uint8Array(appendAt + tableBuffer.length); + patchedBody.set(rawBody, 0); + patchedBody.set(tableBuffer, appendAt); + + const fieldAddress = rootTable + fieldOffset; + const newTableAddress = appendAt + tableRoot; + writeUint32LE(patchedBody, fieldAddress, newTableAddress - fieldAddress); + return patchedBody; +} + +function rebuildWeatherRootWithAddedTable(rawBody, weatherFieldIndex, tableBuffer) { + const originalRoot = readUint32LE(rawBody, 0); + const originalVtable = originalRoot - readInt32LE(rawBody, originalRoot); + const originalVtableLength = readUint16LE(rawBody, originalVtable); + const originalFieldCount = Math.max(0, (originalVtableLength - 4) / 2); + const fieldCount = Math.max(10, originalFieldCount, weatherFieldIndex + 1); + const newVtableLength = 4 + fieldCount * 2; + const newObjectLength = 4 + fieldCount * 4; + const newVtablePosition = 4; + const newRootPosition = Math.ceil((newVtablePosition + newVtableLength) / 4) * 4; + // Keep the preserved arena aligned for unknown int64/double fields, even + // when the root vtable contains an odd number of slots (WK3 has 15). + const shiftedRawPosition = Math.ceil((newRootPosition + newObjectLength) / 8) * 8; + const tableBufferPosition = Math.ceil((shiftedRawPosition + rawBody.length) / 8) * 8; + const tableRoot = readUint32LE(tableBuffer, 0); + const patchedBody = new Uint8Array(tableBufferPosition + tableBuffer.length); + + patchedBody.set(rawBody, shiftedRawPosition); + patchedBody.set(tableBuffer, tableBufferPosition); + + writeUint32LE(patchedBody, 0, newRootPosition); + writeUint16LE(patchedBody, newVtablePosition, newVtableLength); + writeUint16LE(patchedBody, newVtablePosition + 2, newObjectLength); + writeUint32LE(patchedBody, newRootPosition, newRootPosition - newVtablePosition); + + for (let fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++) { + const fieldSlot = newVtablePosition + 4 + fieldIndex * 2; + const newFieldOffset = 4 + fieldIndex * 4; + let fieldValue; + + if (fieldIndex === weatherFieldIndex) { + const newFieldAddress = newRootPosition + newFieldOffset; + const newTableAddress = tableBufferPosition + tableRoot; + fieldValue = newTableAddress - newFieldAddress; + } else { + const originalFieldOffset = getTableFieldOffset(rawBody, originalRoot, fieldIndex); + if (!originalFieldOffset) continue; + + const originalFieldAddress = originalRoot + originalFieldOffset; + const originalValue = readUint32LE(rawBody, originalFieldAddress); + const originalTarget = originalFieldAddress + originalValue; + if (originalValue && originalTarget > originalFieldAddress && originalTarget < rawBody.length) { + const newFieldAddress = newRootPosition + newFieldOffset; + fieldValue = shiftedRawPosition + originalTarget - newFieldAddress; + } else { + fieldValue = originalValue; + } + } + + writeUint16LE(patchedBody, fieldSlot, newFieldOffset); + writeUint32LE(patchedBody, newRootPosition + newFieldOffset, fieldValue); + } + + return patchedBody; +} + +function readUint32LE(bytes, offset) { + return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0; +} + +function readInt32LE(bytes, offset) { + const value = readUint32LE(bytes, offset); + return value > 0x7fffffff ? value - 0x100000000 : value; +} + +function readUint16LE(bytes, offset) { + return bytes[offset] | (bytes[offset + 1] << 8); +} + +function writeUint32LE(bytes, offset, value) { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; + bytes[offset + 3] = (value >>> 24) & 0xff; +} + +function writeUint16LE(bytes, offset, value) { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; +} + +function writeTableInt8IfPresent(bytes, tablePosition, fieldIndex, value) { + if (value === undefined || value === null) return; + const fieldOffset = getTableFieldOffset(bytes, tablePosition, fieldIndex); + if (!fieldOffset) return; + bytes[tablePosition + fieldOffset] = value & 0xff; +} + +function writeTableInt16IfPresent(bytes, tablePosition, fieldIndex, value) { + if (value === undefined || value === null) return; + const fieldOffset = getTableFieldOffset(bytes, tablePosition, fieldIndex); + if (!fieldOffset) return; + writeUint16LE(bytes, tablePosition + fieldOffset, value); +} + +function appendFlatBufferObjectToTableField(rawBody, tablePosition, fieldIndex, objectBuffer) { + if (!objectBuffer?.length) return rawBody; + const fieldOffset = getTableFieldOffset(rawBody, tablePosition, fieldIndex); + if (!fieldOffset) return rawBody; + + const objectRoot = readUint32LE(objectBuffer, 0); + const patchedBody = new Uint8Array(rawBody.length + objectBuffer.length); + patchedBody.set(rawBody, 0); + patchedBody.set(objectBuffer, rawBody.length); + + const fieldAddress = tablePosition + fieldOffset; + const newObjectAddress = rawBody.length + objectRoot; + writeUint32LE(patchedBody, fieldAddress, newObjectAddress - fieldAddress); + return patchedBody; +} + +function getTableFieldOffset(bytes, tablePosition, fieldIndex) { + if (!Number.isFinite(tablePosition) || tablePosition <= 0 || tablePosition + 4 > bytes.length) return 0; + const vtablePosition = tablePosition - readInt32LE(bytes, tablePosition); + if (vtablePosition < 0 || vtablePosition + 4 > bytes.length) return 0; + const vtableLength = readUint16LE(bytes, vtablePosition); + const fieldSlot = 4 + fieldIndex * 2; + if (fieldSlot + 2 > vtableLength || vtablePosition + fieldSlot + 2 > bytes.length) return 0; + return readUint16LE(bytes, vtablePosition + fieldSlot); +} + +/** + * 注入当前天气数据 + * @param {any} currentWeather - 当前天气数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @param {Promise<any>} [preFetchedData] - 预取的数据 + * @returns {Promise<any>} 注入后的当前天气数据 + */ +async function InjectCurrentWeather(currentWeather, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectCurrentWeather"); + if (!shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.warn("InjectCurrentWeather", `Unreplaced location: ${enviroments.country}`); + Console.debug("✅ InjectCurrentWeather"); + return currentWeather; + } + let newCurrentWeather; + if (preFetchedData) { + newCurrentWeather = await preFetchedData; + Console.info("InjectCurrentWeather", "使用预取数据"); + } else { + switch (Settings?.Weather?.Provider) { + case "WeatherKit": + default: + break; + case "QWeather": { + newCurrentWeather = await enviroments.qWeather.WeatherNow(); + break; + } + case "ColorfulClouds": { + newCurrentWeather = await enviroments.colorfulClouds.CurrentWeather(); + break; + } + } + } + if (newCurrentWeather?.metadata) { + newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; + currentWeather = { ...currentWeather, ...newCurrentWeather }; + } + Console.debug("✅ InjectCurrentWeather"); + return currentWeather; +} + +/** + * 注入每日天气预报数据 + * @param {any} forecastDaily - 每日预报数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @param {Promise<any>} [preFetchedData] - 预取的数据 + * @returns {Promise<any>} 注入后的每日预报数据 + */ +async function InjectForecastDaily(forecastDaily, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectForecastDaily"); + const replaceDaily = Settings?.Weather?.ReplaceDaily ?? true; + if (!replaceDaily || !shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectForecastDaily", `Unreplaced or skipped location: ${enviroments.country}`); + Console.debug("✅ InjectForecastDaily"); + return forecastDaily; + } + let newForecastDaily; + if (preFetchedData) { + newForecastDaily = await preFetchedData; + Console.info("InjectForecastDaily", "使用预取数据"); + } else { + switch (Settings?.Weather?.Provider) { + case "WeatherKit": + default: + break; + case "QWeather": { + newForecastDaily = await enviroments.qWeather.Daily(); + break; + } + case "ColorfulClouds": { + const dailysteps = forecastDaily.days?.length || 11; + const begin = forecastDaily.days?.[0]?.forecastStart || undefined; + newForecastDaily = await enviroments.colorfulClouds.Daily(dailysteps, begin); + break; + } + } + } + if (newForecastDaily?.metadata) { + forecastDaily.metadata = { ...forecastDaily?.metadata, ...newForecastDaily.metadata }; + Weather.mergeForecast(forecastDaily?.days, newForecastDaily?.days); + } + Console.debug("✅ InjectForecastDaily"); + return forecastDaily; +} + +/** + * 注入小时天气预报数据 + * @param {any} forecastHourly - 小时预报数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @returns {Promise<any>} 注入后的小时预报数据 + */ +async function InjectForecastHourly(forecastHourly, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectForecastHourly"); + const replaceHourly = Settings?.Weather?.ReplaceHourly ?? true; + if (!replaceHourly || !shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectForecastHourly", `Unreplaced or skipped location: ${enviroments.country}`); + Console.debug("✅ InjectForecastHourly"); + return forecastHourly; + } + let newForecastHourly; + if (preFetchedData) { + newForecastHourly = await preFetchedData; + Console.info("InjectForecastHourly", "使用预取数据"); + } else { + switch (Settings?.Weather?.Provider) { + case "WeatherKit": + default: + break; + case "QWeather": { + newForecastHourly = await enviroments.qWeather.Hourly(); + break; + } + case "ColorfulClouds": { + Console.debug("✅ InjectForecastHourly ColorfulClouds"); + const hourlysteps = forecastHourly.hours?.length || 273; + const begin = forecastHourly.hours?.[0]?.forecastStart || undefined; + newForecastHourly = await enviroments.colorfulClouds.ForecastHourly(hourlysteps, begin); + break; + } + } + } + if (newForecastHourly?.metadata) { + forecastHourly.metadata = { ...forecastHourly?.metadata, ...newForecastHourly.metadata }; + forecastHourly.hours = Weather.mergeForecast(forecastHourly?.hours, newForecastHourly?.hours); + } + Console.debug("✅ InjectForecastHourly"); + return forecastHourly; +} + +/** + * 注入下一小时天气预报数据 + * @param {any} forecastNextHour - 下一小时预报数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @param {Promise<any>} [preFetchedData] - 预取的数据 + * @returns {Promise<any>} 注入后的下一小时预报数据 + */ +async function InjectForecastNextHour(forecastNextHour, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectForecastNextHour"); + if (!shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectForecastNextHour", `Unreplaced location: ${enviroments.country}`); + Console.debug("✅ InjectForecastNextHour"); + return forecastNextHour; + } + + // if (forecastNextHour) { + // Console.debug("✅ InjectForecastNextHour"); + // return forecastNextHour; + // } + + let newForecastNextHour; + if (preFetchedData) { + newForecastNextHour = await preFetchedData; + Console.info("InjectForecastNextHour", "使用预取数据"); + } else { + switch (Settings?.NextHour?.Provider) { + case "WeatherKit": + break; + case "QWeather": { + newForecastNextHour = await enviroments.qWeather.Minutely(); + break; + } + case "ColorfulClouds": + default: { + newForecastNextHour = await enviroments.colorfulClouds.Minutely(); + break; + } + } + } + if (newForecastNextHour?.metadata) { + const isClear = newForecastNextHour?.condition?.[0]?.forecastToken === "CLEAR" || newForecastNextHour?.condition?.length === 0; + if (isClear) { + Console.info("InjectForecastNextHour", "未来一小时无降水,保留下一小时预报数据"); + newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; + forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; + } else { + newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; + forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; + } + } + Console.debug("✅ InjectForecastNextHour"); + return forecastNextHour; +} + +/** + * 注入并合并空气质量数据(污染物、指数、昨日对比) + * @param {any} airQuality - WeatherKit 原始空气质量对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 各数据源实例与定位信息 + * @param {Object} [preFetched={}] - 预取的数据(含 pollutants、index、locationsGrid 等) + * @returns {Promise<any>} 合并后的空气质量对象 + */ +async function InjectAirQuality(airQuality, Settings, enviroments, preFetched = {}) { + if (!shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectAirQuality", `Unreplaced location: ${enviroments.country}`); + return AirQuality.FixPollutantsUnits(airQuality); + } + + // Step1. 修复污染物单位 + airQuality = AirQuality.FixPollutantsUnits(airQuality); + + // Step2. 仅按显式污染物来源配置注入污染物数据 + const needReplacePollutants = ["QWeather", "ColorfulClouds"].includes(Settings?.AirQuality?.Current?.Pollutants?.Provider); + const shouldInjectPollutants = needReplacePollutants; + const injectedPollutants = shouldInjectPollutants ? (preFetched.pollutants ? await preFetched.pollutants : await InjectPollutants(Settings, enviroments)) : airQuality; + if (shouldInjectPollutants && preFetched.pollutants) Console.info("InjectAirQuality", "污染物使用预取数据"); + const needPollutants = shouldInjectPollutants && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); + + // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider; + const mayInjectIndex = canInjectIndex(Settings) + && Settings?.AirQuality?.Current?.Index?.Replace?.includes(AirQuality.GetNameFromScale(airQuality?.scale)); + let candidate; + if (mayInjectIndex) { + try { + candidate = preFetched.index ? await preFetched.index : await InjectIndex(injectedPollutants, Settings, enviroments); + } catch { + // A failed candidate must not invalidate Apple's current AQ or comparison. + } + } + const needInjectIndex = mayInjectIndex && validIndex(candidate) + && ["index", "scale", "categoryIndex"].some(key => candidate[key] !== airQuality?.[key]); + const injectedIndex = needInjectIndex ? candidate : airQuality; + + // Known comparisons are recalculated only after a successful, actual change. + const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const forceInjectComparison = Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange === true && needInjectIndex; + const needInjectComparison = forceInjectComparison || weatherKitComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const currentIndexProvider = needInjectIndex ? indexProvider : "WeatherKit"; + let injectedComparison = { previousDayComparison: weatherKitComparison }; + if (needInjectComparison) { + try { + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, enviroments, preFetched) + ?? { previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN }; + } catch (error) { + Console.warn("InjectAirQuality", "昨日对比获取失败,使用 UNKNOWN 继续返回当前空气质量。", error?.message ?? error); + injectedComparison = { previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN }; + } + } + + // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 + const weatherKitMetadata = airQuality?.metadata; + const pollutantMetadata = injectedPollutants?.metadata; + const indexMetadata = injectedIndex?.metadata; + const comparisonMetadata = injectedComparison?.metadata; + + const pName = needPollutants && pollutantMetadata && !pollutantMetadata.temporarilyUnavailable ? pollutantMetadata.providerName : null; + const iName = needInjectIndex && indexMetadata && !indexMetadata.temporarilyUnavailable ? indexMetadata.providerName : null; + const cName = needInjectComparison && comparisonMetadata && !comparisonMetadata.temporarilyUnavailable ? comparisonMetadata.providerName : null; + + const providers = []; + if (pName && pName === iName && (!cName || pName === cName)) { + // 如果各环节提供商都一致(或者没开启部分注入),则合并展示,去除多余的“污染物”、“指数”前缀 + providers.push(AirQuality.appendScaleToProviderName(injectedIndex, Settings)); + } else { + if ((!needInjectIndex || !needPollutants || !needInjectComparison) && weatherKitMetadata?.providerName && !weatherKitMetadata.temporarilyUnavailable) { + providers.push(weatherKitMetadata.providerName); + } + if (pName) providers.push(`污染物:${pName}`); + if (iName) providers.push(`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`); + if (cName) providers.push(`对比昨日:${cName}`); + } + + const activeMetadata = injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable && injectedIndex.metadata.attributionUrl ? injectedIndex.metadata : pollutantMetadata && !pollutantMetadata.temporarilyUnavailable && pollutantMetadata.attributionUrl ? pollutantMetadata : null; + const providerName = providers.filter(Boolean).join("\n") || weatherKitMetadata?.providerName || indexMetadata?.providerName || pollutantMetadata?.providerName || "WeatherKit-Proxy"; + + // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison + airQuality = { + ...airQuality, + ...(needInjectIndex ? injectedIndex : {}), + metadata: { + ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), + providerName, + ...(activeMetadata ? { attributionUrl: activeMetadata.attributionUrl } : {}), + }, + pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + Console.info( + "InjectAirQuality summary", + `index: ${airQuality?.index}`, + `categoryIndex: ${airQuality?.categoryIndex}`, + `primaryPollutant: ${airQuality?.primaryPollutant}`, + `scale: ${airQuality?.scale}`, + `previousDayComparison: ${airQuality?.previousDayComparison}`, + `pollutants: ${airQuality?.pollutants?.length ?? 0}`, + `temporarilyUnavailable: ${airQuality?.metadata?.temporarilyUnavailable === true}`, + ); + Console.debug("airQuality:", airQuality); + return airQuality; +} + +async function InjectPollutants(Settings, enviroments) { + Console.debug("☑️ InjectPollutants"); + + switch (Settings?.AirQuality?.Current?.Pollutants?.Provider) { + case "QWeather": { + const currentAirQuality = await enviroments.qWeather.CurrentAirQuality(); + Console.debug("✅ InjectPollutants"); + return currentAirQuality; + } + case "ColorfulClouds": { + const currentAirQuality = await enviroments.colorfulClouds.CurrentAirQuality(); + Console.debug("✅ InjectPollutants"); + return currentAirQuality; + } + default: return undefined; + } +} + +/** + * 注入空气质量数据 + * @param {any} airQuality - 空气质量数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @returns {Promise<any>} 注入后的空气质量数据 + */ +async function InjectIndex(airQuality, Settings, enviroments) { + Console.debug("☑️ InjectIndex"); + + switch (Settings?.AirQuality?.Current?.Index?.Provider) { + case "QWeather": { + const currentAirQuality = await enviroments.qWeather.CurrentAirQuality(Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants); + Console.debug("✅ InjectIndex"); + return currentAirQuality; + } + case "ColorfulCloudsUS": + case "ColorfulCloudsCN": { + const currentAirQuality = await enviroments.colorfulClouds.CurrentAirQuality(Settings.AirQuality.Current.Index.Provider === "ColorfulCloudsUS", Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants); + Console.debug("✅ InjectIndex"); + return currentAirQuality; + } + case "Calculate": { + const currentAirQuality = AirQuality.Pollutants2AQI(airQuality, Settings); + Console.debug("✅ InjectIndex"); + return currentAirQuality; + } + default: return undefined; + } +} + +async function InjectComparison(airQuality, currentIndexProvider, Settings, enviroments, preFetched = {}) { + Console.debug("☑️ InjectComparison"); + + const { UNKNOWN } = AirQuality.Config.CompareCategoryIndexes; + + /** + * HJ 633—2012 + * [环境空气质量指数(AQI)技术规定(试行)_中华人民共和国生态环境部]{@link https://www.mee.gov.cn/ywgz/fgbz/bz/bzwb/jcffbz/201203/t20120302_224166.shtml} + */ + const isHJ6332012 = (currentIndexProvider, currentScale, Settings) => { + Console.debug("☑️ isHJ6332012", `currentIndexProvider: ${currentIndexProvider}`); + const currentScaleName = AirQuality.GetNameFromScale(currentScale); + if (currentScaleName === AirQuality.Config.Scales.HJ6332012.weatherKitScale.name) { + Console.debug("✅ isHJ6332012", true); + return true; + } + + switch (currentIndexProvider) { + case "Calculate": { + Console.debug(`Settings?.AirQuality?.Calculate?.Algorithm: ${Settings?.AirQuality?.Calculate?.Algorithm}`); + const result = Settings?.AirQuality?.Calculate?.Algorithm === "WAQI_InstantCast_CN"; + Console.debug("✅ isHJ6332012", result); + return result; + } + case "QWeather": + case "ColorfulCloudsCN": { + Console.debug("✅ isHJ6332012", true); + return true; + } + case "WeatherKit": { + const result = AirQuality.GetNameFromScale(currentScale) === AirQuality.Config.Scales.HJ6332012.weatherKitScale.name; + Console.debug("✅ isHJ6332012", result); + return result; + } + default: { + Console.debug("✅ isHJ6332012", false); + return false; + } + } + }; + /** + * EPA 454/B-18-007 + * [Technical Assistance Document for the Reporting of Daily Air Quality – the Air Quality Index (AQI)]{@link https://www.airnow.gov/sites/default/files/2020-05/aqi-technical-assistance-document-sept2018.pdf} + */ + const isEPA454_B18007 = (currentIndexProvider, currentScale) => { + Console.debug("☑️ isEPA454_B18007", `currentIndexProvider: ${currentIndexProvider}`); + const currentScaleName = AirQuality.GetNameFromScale(currentScale); + if (currentScaleName === AirQuality.Config.Scales.EPA_NowCast.weatherKitScale.name) { + Console.debug("✅ isEPA454_B18007", true); + return true; + } + + switch (currentIndexProvider) { + case "WAQI": + case "ColorfulCloudsUS": { + Console.debug("✅ isEPA454_B18007", true); + return true; + } + default: { + Console.debug("✅ isEPA454_B18007", false); + return false; + } + } + }; + + const colorfulCloudsComparison = async (useUsa, currentCategoryIndex) => { + Console.debug("☑️ colorfulCloudsComparison", `currentCategoryIndex: ${currentCategoryIndex}`); + const yesterdayAirQuality = await enviroments.colorfulClouds.YesterdayAirQuality(useUsa); + + const getMetadata = (temporarilyUnavailable = false) => ({ + ...yesterdayAirQuality.metadata, + providerName: yesterdayAirQuality.metadata.providerName, + temporarilyUnavailable, + }); + + if (!yesterdayAirQuality.metadata.temporarilyUnavailable) { + if (currentCategoryIndex) { + const comparisonAirQuality = { + ...yesterdayAirQuality, + metadata: getMetadata(false), + previousDayComparison: AirQuality.CompareCategoryIndexes(currentCategoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ colorfulCloudsComparison"); + return comparisonAirQuality; + } else { + const colorfulCloudsCurrent = await enviroments.colorfulClouds.CurrentAirQuality(useUsa); + if (!colorfulCloudsCurrent.metadata.temporarilyUnavailable) { + Console.debug(`colorfulCloudsCurrent?.index: ${colorfulCloudsCurrent?.index}`); + const comparisonAirQuality = { + ...yesterdayAirQuality, + metadata: getMetadata(false), + previousDayComparison: AirQuality.CompareCategoryIndexes(colorfulCloudsCurrent.categoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ colorfulCloudsComparison"); + return comparisonAirQuality; + } + } + } + + Console.error("colorfulCloudsComparison", `无法从彩云天气获取${yesterdayAirQuality.metadata.temporarilyUnavailable ? "昨日" : "今日"}的空气质量数据`); + return { + ...yesterdayAirQuality, + metadata: getMetadata(true), + previousDayComparison: UNKNOWN, + }; + }; + const qweatherComparison = async (currentCategoryIndex, pollutantsToAirQuality) => { + Console.debug("☑️ qweatherComparison", `currentCategoryIndex: ${currentCategoryIndex}`); + const locationsGrid = preFetched?.locationsGrid + ?? await QWeather.GetLocationsGrid(undefined, () => {}); + const { latitude, longitude } = enviroments.qWeather; + const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); + + const yesterdayQWeather = await enviroments.qWeather.YesterdayAirQuality(locationInfo); + + const getMetadata = (indexProvider, temporarilyUnavailable = false) => ({ + ...yesterdayQWeather.metadata, + providerName: indexProvider, + temporarilyUnavailable, + }); + + if (!yesterdayQWeather.metadata.temporarilyUnavailable) { + const airQualityFromPollutants = pollutantsToAirQuality ? pollutantsToAirQuality(yesterdayQWeather) : undefined; + const yesterdayAirQuality = airQualityFromPollutants + ? { + ...airQualityFromPollutants, + metadata: { + ...airQualityFromPollutants.metadata, + providerName: airQualityFromPollutants.metadata.providerName, + }, + } + : { + ...yesterdayQWeather, + metadata: { + ...yesterdayQWeather.metadata, + providerName: yesterdayQWeather.metadata.providerName, + }, + }; + + if (currentCategoryIndex) { + const comparisonAirQuality = { + ...yesterdayQWeather, + metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), + previousDayComparison: AirQuality.CompareCategoryIndexes(currentCategoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ qweatherComparison"); + return comparisonAirQuality; + } else { + const qweatherCurrent = await enviroments.qWeather.CurrentAirQuality(); + if (!qweatherCurrent.metadata.temporarilyUnavailable) { + Console.debug(`qweatherCurrent?.index: ${qweatherCurrent?.index}`); + + const comparisonAirQuality = { + ...yesterdayQWeather, + metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), + previousDayComparison: AirQuality.CompareCategoryIndexes(qweatherCurrent.categoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ qweatherComparison"); + return comparisonAirQuality; + } + } + } + + Console.error("qweatherComparison", `无法从和风天气获取${yesterdayQWeather.metadata.temporarilyUnavailable ? "昨日" : "今日"}空气质量数据`); + return { + ...yesterdayQWeather, + metadata: getMetadata(yesterdayQWeather.metadata.providerName, true), + previousDayComparison: UNKNOWN, + }; + }; + + switch (Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider) { + case "Calculate": { + const algorithm = AirQuality.chooseAlogrithm(airQuality, Settings); + const PollutantsProvider = Settings?.AirQuality?.Comparison?.Yesterday?.PollutantsProvider; + Console.debug(`Settings?.AirQuality?.Comparison?.Yesterday?.PollutantsProvider: ${PollutantsProvider}`); + + if (algorithm && algorithm !== "None") { + switch (PollutantsProvider) { + case "ColorfulCloudsCN": { + if (algorithm === "WAQI_InstantCast_US") { + const comparisonAirQuality = await colorfulCloudsComparison(true, airQuality?.categoryIndex); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + const comparisonAirQuality = await colorfulCloudsComparison(false, isHJ6332012(currentIndexProvider, airQuality?.scale, Settings) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + case "QWeather": { + const pollutantsToAirQuality = airQuality => AirQuality.Pollutants2AQI(airQuality, Settings, { algorithm }); + const comparisonAirQuality = await qweatherComparison(airQuality?.categoryIndex, pollutantsToAirQuality); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + } + } + + Console.error("InjectComparison", "不支持今日空气质量的标准"); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, previousDayComparison: UNKNOWN }; + } + case "QWeather": { + const comparisonAirQuality = await qweatherComparison(isHJ6332012(currentIndexProvider, airQuality?.scale, Settings) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + case "ColorfulCloudsCN": { + // Use injected AQI or ColorfulClouds AQI depends on data source + const comparisonAirQuality = colorfulCloudsComparison(false, isHJ6332012(currentIndexProvider, airQuality?.scale, Settings) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + case "ColorfulCloudsUS": { + const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider, airQuality?.scale) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + } +} diff --git a/src/wk3/proto/apple/wk2.js b/src/wk3/proto/apple/wk2.js new file mode 100644 index 00000000..35ddc181 --- /dev/null +++ b/src/wk3/proto/apple/wk2.js @@ -0,0 +1,3971 @@ +// src/proto/apple/wk2.js +// Auto-generated from response.bundle.js (v3.1.0) +// This module contains the Flatbuffer schema definitions for Apple WeatherKit2 +// +// NOTE: This file was reconstructed from a minified bundle. +// Variable names are shortened but logic is preserved. + +// ============================================================ +// Enum definitions +// ============================================================ + +export const StringEncodingType = { + UTF8_BYTES: 1, + UTF16_STRING: 2, +}; + +export const ComparisonTrend = { + UNKNOWN: 0, + MUCH_WORSE: 1, + WORSE: 2, + SAME: 3, + BETTER: 4, + MUCH_BETTER: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "MUCH_WORSE", + 2: "WORSE", + 3: "SAME", + 4: "BETTER", + 5: "MUCH_BETTER", +}; + +export const SourceType = { + APPLE_INTERNAL: 0, + MODELED: 1, + STATION: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + + // Reverse mapping (number → string) + 0: "APPLE_INTERNAL", + 1: "MODELED", + 2: "STATION", + 3: "UNKNOWN3", + 4: "UNKNOWN4", +}; + +export const PollutantType = { + NOT_AVAILABLE: 0, + C6H6: 1, + NH3: 2, + NMHC: 3, + NO: 4, + NO2: 5, + NOX: 6, + OZONE: 7, + PM2_5: 8, + SO2: 9, + PM10: 10, + CO: 11, + UNKNOWN12: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "NOT_AVAILABLE", + 1: "C6H6", + 2: "NH3", + 3: "NMHC", + 4: "NO", + 5: "NO2", + 6: "NOX", + 7: "OZONE", + 8: "PM2_5", + 9: "SO2", + 10: "PM10", + 11: "CO", + 12: "UNKNOWN12", + 13: "UNKNOWN13", +}; + +export const UnitType = { + PARTS_PER_BILLION: 0, + MICROGRAMS_PER_CUBIC_METER: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + + // Reverse mapping (number → string) + 0: "PARTS_PER_BILLION", + 1: "MICROGRAMS_PER_CUBIC_METER", + 2: "UNKNOWN2", + 3: "UNKNOWN3", +}; + +export const ComparisonType = { + UNKNOWN0: 0, + TEMPERATURE_MAX: 1, + PRECIPITATION: 2, + UNKNOWN3: 3, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "TEMPERATURE_MAX", + 2: "PRECIPITATION", + 3: "UNKNOWN3", +}; + +export const Deviation = { + UNKNOWN: 0, + OBSERVED: 1, + LIKELY: 2, + POSSIBLE: 3, + UNLIKELY: 4, + UNKNOWN5: 5, +}; + +export const Direction = { + STEADY: 0, + INC: 1, + DEC: 2, + UNKNOWN3: 3, + + // Reverse mapping (number → string) + 0: "STEADY", + 1: "INC", + 2: "DEC", + 3: "UNKNOWN3", +}; + +export const ComparisonType2 = { + UNKNOWN0: 0, + TEMPERATURE_MAX: 1, + PRECIPITATION: 2, + TEMPERATURE_MIN: 3, + SNOWFALL: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "TEMPERATURE_MAX", + 2: "PRECIPITATION", + 3: "TEMPERATURE_MIN", + 4: "SNOWFALL", + 5: "UNKNOWN5", +}; + +export const Deviation2 = { + MUCHHIGHER: 0, + HIGHER: 1, + NORMAL: 2, + LOWER: 3, + MUCHLOWER: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "MUCHHIGHER", + 1: "HIGHER", + 2: "NORMAL", + 3: "LOWER", + 4: "MUCHLOWER", + 5: "UNKNOWN5", +}; + +export const ConditionType = { + CLEAR: 0, + UNKNOWN1: 1, + UNKNOWN2: 2, + SLEET: 3, + POSSIBLE_SLEET: 4, + HEAVY_RAIN: 5, + RAIN: 6, + DRIZZLE: 7, + POSSIBLE_DRIZZLE: 8, + HEAVY_SNOW: 9, + SNOW: 10, + UNKNOWN11: 11, + UNKNOWN12: 12, + UNKNOWN13: 13, + UNKNOWN14: 14, + UNKNOWN15: 15, + UNKNOWN16: 16, + UNKNOWN17: 17, + UNKNOWN18: 18, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "UNKNOWN1", + 2: "UNKNOWN2", + 3: "SLEET", + 4: "POSSIBLE_SLEET", + 5: "HEAVY_RAIN", + 6: "RAIN", + 7: "DRIZZLE", + 8: "POSSIBLE_DRIZZLE", + 9: "HEAVY_SNOW", + 10: "SNOW", + 11: "UNKNOWN11", + 12: "UNKNOWN12", + 13: "UNKNOWN13", + 14: "UNKNOWN14", + 15: "UNKNOWN15", + 16: "UNKNOWN16", + 17: "UNKNOWN17", + 18: "UNKNOWN18", +}; + +export const ForecastToken = { + CLEAR: 0, + START: 1, + STOP: 2, + START_STOP: 3, + STOP_START: 4, + CONSTANT: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "START", + 2: "STOP", + 3: "START_STOP", + 4: "STOP_START", + 5: "CONSTANT", + 6: "UNKNOWN6", + 7: "UNKNOWN7", +}; + +export const ParameterType = { + FIRST_AT: 0, + SECOND_AT: 1, + UNKNOWN2: 2, + + // Reverse mapping (number → string) + 0: "FIRST_AT", + 1: "SECOND_AT", + 2: "UNKNOWN2", +}; + +export const PrecipitationType = { + CLEAR: 0, + RAIN: 1, + SNOW: 2, + SLEET: 3, + HAIL: 4, + MIXED: 5, + UNKNOWN6: 6, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "RAIN", + 2: "SNOW", + 3: "SLEET", + 4: "HAIL", + 5: "MIXED", + 6: "UNKNOWN6", +}; + +export const PressureTrend = { + RISING: 0, + FALLING: 1, + STEADY: 2, + UNDEFINED: 3, + + // Reverse mapping (number → string) + 0: "RISING", + 1: "FALLING", + 2: "STEADY", + 3: "UNDEFINED", +}; + +export const WeatherCondition = { + CLEAR: 0, + BLIZZARD: 1, + PRECIPITATION: 2, + SLEET: 3, + BREEZY: 4, + CLOUDY: 5, + DRIZZLE: 6, + FLURRIES: 7, + FOGGY: 8, + BLOWING_DUST: 9, + BLOWING_SNOW: 10, + FREEZING_RAIN: 11, + FRIGID: 12, + HAZE: 13, + HEAVY_RAIN: 14, + HEAVY_SNOW: 15, + HAIL: 16, + HOT: 17, + ISOLATED_THUNDERSTORMS: 18, + MOSTLY_CLEAR: 19, + MOSTLY_CLOUDY: 20, + PARTLY_CLOUDY: 21, + RAIN: 22, + HURRICANE: 23, + SCATTERED_THUNDERSTORMS: 24, + UNKNOWN25: 25, + SNOW: 26, + SMOKY: 27, + UNKNOWN28: 28, + FREEZING_DRIZZLE: 29, + THUNDERSTORMS: 30, + STRONG_STORMS: 31, + WINDY: 32, + WINTRY_MIX: 33, + SUN_FLURRIES: 34, + SUN_SHOWERS: 35, + UNKNOWN36: 36, + TROPICAL_STORM: 37, + UNKNOWN38: 38, + UNKNOWN39: 39, + UNKNOWN40: 40, + UNKNOWN41: 41, + UNKNOWN42: 42, + UNKNOWN43: 43, + UNKNOWN44: 44, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "BLIZZARD", + 2: "PRECIPITATION", + 3: "SLEET", + 4: "BREEZY", + 5: "CLOUDY", + 6: "DRIZZLE", + 7: "FLURRIES", + 8: "FOGGY", + 9: "BLOWING_DUST", + 10: "BLOWING_SNOW", + 11: "FREEZING_RAIN", + 12: "FRIGID", + 13: "HAZE", + 14: "HEAVY_RAIN", + 15: "HEAVY_SNOW", + 16: "HAIL", + 17: "HOT", + 18: "ISOLATED_THUNDERSTORMS", + 19: "MOSTLY_CLEAR", + 20: "MOSTLY_CLOUDY", + 21: "PARTLY_CLOUDY", + 22: "RAIN", + 23: "HURRICANE", + 24: "SCATTERED_THUNDERSTORMS", + 25: "UNKNOWN25", + 26: "SNOW", + 27: "SMOKY", + 28: "UNKNOWN28", + 29: "FREEZING_DRIZZLE", + 30: "THUNDERSTORMS", + 31: "STRONG_STORMS", + 32: "WINDY", + 33: "WINTRY_MIX", + 34: "SUN_FLURRIES", + 35: "SUN_SHOWERS", + 36: "UNKNOWN36", + 37: "TROPICAL_STORM", + 38: "UNKNOWN38", + 39: "UNKNOWN39", + 40: "UNKNOWN40", + 41: "UNKNOWN41", + 42: "UNKNOWN42", + 43: "UNKNOWN43", + 44: "UNKNOWN44", +}; + +export const MoonPhase = { + NEW: 0, + WAXING_CRESCENT: 1, + FIRST_QUARTER: 2, + WAXING_GIBBOUS: 3, + FULL: 4, + WANING_GIBBOUS: 5, + THIRD_QUARTER: 6, + WANING_CRESCENT: 7, + UNKNOWN8: 8, + + // Reverse mapping (number → string) + 0: "NEW", + 1: "WAXING_CRESCENT", + 2: "FIRST_QUARTER", + 3: "WAXING_GIBBOUS", + 4: "FULL", + 5: "WANING_GIBBOUS", + 6: "THIRD_QUARTER", + 7: "WANING_CRESCENT", + 8: "UNKNOWN8", +}; + +export const PlacementType = { + UNKNOWN0: 0, + AIR_QUALITY_DETAILS: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + TEMPERATURE_FEELS_LIKE_DETAILS: 9, + UNKNOWN10: 10, + TRENDS: 11, + UV_DETAILS: 12, + UNKNOWN13: 13, + WIND_DETAILS: 14, + UNKNOWN15: 15, + UNKNOWN16: 16, + UNKNOWN17: 17, + UNKNOWN18: 18, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "AIR_QUALITY_DETAILS", + 2: "UNKNOWN2", + 3: "UNKNOWN3", + 4: "UNKNOWN4", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "TEMPERATURE_FEELS_LIKE_DETAILS", + 10: "UNKNOWN10", + 11: "TRENDS", + 12: "UV_DETAILS", + 13: "UNKNOWN13", + 14: "WIND_DETAILS", + 15: "UNKNOWN15", + 16: "UNKNOWN16", + 17: "UNKNOWN17", + 18: "UNKNOWN18", +}; + +export const ResponseType = { + UNKNOWN0: 0, + SHELTER: 1, + EVACUATE: 2, + PREPARE: 3, + EXECUTE: 4, + AVOID: 5, + MONITOR: 6, + ASSESS: 7, + ALLCLEAR: 8, + NONE: 9, + UNKNOWN10: 10, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "SHELTER", + 2: "EVACUATE", + 3: "PREPARE", + 4: "EXECUTE", + 5: "AVOID", + 6: "MONITOR", + 7: "ASSESS", + 8: "ALLCLEAR", + 9: "NONE", + 10: "UNKNOWN10", +}; + +export const Severity = { + UNKNOWN: 0, + EXTREME: 1, + SEVERE: 2, + MODERATE: 3, + MINOR: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + UNKNOWN9: 9, + STATEMENT: 10, + WARNING: 11, + ADVISORY: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "EXTREME", + 2: "SEVERE", + 3: "MODERATE", + 4: "MINOR", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "UNKNOWN9", + 10: "STATEMENT", + 11: "WARNING", + 12: "ADVISORY", + 13: "UNKNOWN13", +}; + +export const SignificanceType = { + UNKNOWN: 0, + UNKNOWN1: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + UNKNOWN9: 9, + STATEMENT: 10, + WARNING: 11, + ADVISORY: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "UNKNOWN1", + 2: "UNKNOWN2", + 3: "UNKNOWN3", + 4: "UNKNOWN4", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "UNKNOWN9", + 10: "STATEMENT", + 11: "WARNING", + 12: "ADVISORY", + 13: "UNKNOWN13", +}; + +export const Urgency = { + UNKNOWN: 0, + IMMEDIATE: 1, + EXPECTED: 2, + FUTURE: 3, + PAST: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "IMMEDIATE", + 2: "EXPECTED", + 3: "FUTURE", + 4: "PAST", + 5: "UNKNOWN5", +}; + +export const Certainty = { + UNKNOWN: 0, + OBSERVED: 1, + LIKELY: 2, + POSSIBLE: 3, + UNLIKELY: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "OBSERVED", + 2: "LIKELY", + 3: "POSSIBLE", + 4: "UNLIKELY", + 5: "UNKNOWN5", +}; + +export const ImportanceType = { + UNKNOWN: 0, + UNKNOWN1: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + UNKNOWN9: 9, + STATEMENT: 10, + WARNING: 11, + ADVISORY: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "UNKNOWN1", + 2: "UNKNOWN2", + 3: "UNKNOWN3", + 4: "UNKNOWN4", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "UNKNOWN9", + 10: "STATEMENT", + 11: "WARNING", + 12: "ADVISORY", + 13: "UNKNOWN13", +}; + +// ============================================================ +// Flatbuffer classes +// ============================================================ + +// ByteBuffer typed-array helpers (from response.bundle.js) +const int32 = new Int32Array(2); +const float32 = new Float32Array(int32.buffer); +const float64 = new Float64Array(int32.buffer); +const isLittleEndian = new Uint16Array(new Uint8Array([1, 0]).buffer)[0] === 1; + +// ByteBuffer +export class ByteBuffer { + constructor(bytes) { + (this.bytes_ = bytes), (this.position_ = 0), (this.text_decoder_ = new TextDecoder()); + } + static allocate(size) { + return new ByteBuffer(new Uint8Array(size)); + } + clear() { + this.position_ = 0; + } + bytes() { + return this.bytes_; + } + position() { + return this.position_; + } + setPosition(pos) { + this.position_ = pos; + } + capacity() { + return this.bytes_.length; + } + readInt8(offset) { + return (this.readUint8(offset) << 24) >> 24; + } + readUint8(offset) { + return this.bytes_[offset]; + } + readInt16(offset) { + return (this.readUint16(offset) << 16) >> 16; + } + readUint16(offset) { + return this.bytes_[offset] | (this.bytes_[offset + 1] << 8); + } + readInt32(offset) { + return this.bytes_[offset] | (this.bytes_[offset + 1] << 8) | (this.bytes_[offset + 2] << 16) | (this.bytes_[offset + 3] << 24); + } + readUint32(offset) { + return this.readInt32(offset) >>> 0; + } + readInt64(offset) { + return BigInt.asIntN(64, BigInt(this.readUint32(offset)) + (BigInt(this.readUint32(offset + 4)) << BigInt(32))); + } + readUint64(offset) { + return BigInt.asUintN(64, BigInt(this.readUint32(offset)) + (BigInt(this.readUint32(offset + 4)) << BigInt(32))); + } + readFloat32(offset) { + return (int32["0"] = this.readInt32(offset)), float32["0"]; + } + readFloat64(offset) { + return (int32[+!isLittleEndian] = this.readInt32(offset)), (int32[+!!isLittleEndian] = this.readInt32(offset + 4)), float64["0"]; + } + writeInt8(offset, value) { + this.bytes_[offset] = value; + } + writeUint8(offset, value) { + this.bytes_[offset] = value; + } + writeInt16(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8); + } + writeUint16(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8); + } + writeInt32(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8), (this.bytes_[offset + 2] = value >> 16), (this.bytes_[offset + 3] = value >> 24); + } + writeUint32(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8), (this.bytes_[offset + 2] = value >> 16), (this.bytes_[offset + 3] = value >> 24); + } + writeInt64(offset, value) { + this.writeInt32(offset, Number(BigInt.asIntN(32, value))), this.writeInt32(offset + 4, Number(BigInt.asIntN(32, value >> BigInt(32)))); + } + writeUint64(offset, value) { + this.writeUint32(offset, Number(BigInt.asUintN(32, value))), this.writeUint32(offset + 4, Number(BigInt.asUintN(32, value >> BigInt(32)))); + } + writeFloat32(offset, value) { + (float32["0"] = value), this.writeInt32(offset, int32["0"]); + } + writeFloat64(offset, value) { + (float64["0"] = value), this.writeInt32(offset, int32[+!isLittleEndian]), this.writeInt32(offset + 4, int32[+!!isLittleEndian]); + } + getBufferIdentifier() { + if (this.bytes_.length < this.position_ + 4 + 4) throw Error("FlatBuffers: ByteBuffer is too short to contain an identifier."); + let t = ""; + for (let e = 0; e < 4; e++) t += String.fromCharCode(this.readInt8(this.position_ + 4 + e)); + return t; + } + __offset(table, field) { + const i = table - this.readInt32(table); + return field < this.readInt16(i) ? this.readInt16(i + field) : 0; + } + __union(obj, offset) { + return (obj.bb_pos = offset + this.readInt32(offset)), (obj.bb = this), obj; + } + __string(offset, encoding) { + offset += this.readInt32(offset); + const a = this.readInt32(offset); + offset += 4; + const s = this.bytes_.subarray(offset, offset + a); + return encoding === StringEncodingType.UTF8_BYTES ? s : this.text_decoder_.decode(s); + } + __union_with_string(unionOrStr, offset) { + return "string" === typeof unionOrStr ? this.__string(offset) : this.__union(unionOrStr, offset); + } + __indirect(offset) { + return offset + this.readInt32(offset); + } + __vector(offset) { + return offset + this.readInt32(offset) + 4; + } + __vector_len(offset) { + return this.readInt32(offset + this.readInt32(offset)); + } + __has_identifier(id) { + if (4 !== id.length) throw Error("FlatBuffers: file identifier must be length 4"); + for (let e = 0; e < 4; e++) if (id.charCodeAt(e) !== this.readInt8(this.position() + 4 + e)) return !1; + return !0; + } + createScalarList(listFunc, length) { + const i = []; + for (let a = 0; a < length; ++a) { + const value = listFunc(a); + null !== value && i.push(value); + } + return i; + } + createObjList(listFunc, length) { + const i = []; + for (let a = 0; a < length; ++a) { + const item = listFunc(a); + null !== item && i.push(item.unpack()); + } + return i; + } +} + +// Builder +export class Builder { + constructor(initialSize) { + let e; + (this.minalign = 1), + (this.vtable = null), + (this.vtable_in_use = 0), + (this.isNested = !1), + (this.object_start = 0), + (this.vtables = []), + (this.vector_num_elems = 0), + (this.force_defaults = !1), + (this.string_maps = null), + (this.text_encoder = new TextEncoder()), + (e = initialSize || 1024), + (this.bb = ByteBuffer.allocate(e)), + (this.space = e); + } + clear() { + this.bb.clear(), (this.space = this.bb.capacity()), (this.minalign = 1), (this.vtable = null), (this.vtable_in_use = 0), (this.isNested = !1), (this.object_start = 0), (this.vtables = []), (this.vector_num_elems = 0), (this.force_defaults = !1), (this.string_maps = null); + } + forceDefaults(enabled) { + this.force_defaults = enabled; + } + dataBuffer() { + return this.bb; + } + asUint8Array() { + return this.bb.bytes().subarray(this.bb.position(), this.bb.position() + this.offset()); + } + prep(align, len) { + align > this.minalign && (this.minalign = align); + const i = (~(this.bb.capacity() - this.space + len) + 1) & (align - 1); + for (; this.space < i + align + len; ) { + const align = this.bb.capacity(); + (this.bb = Builder.growByteBuffer(this.bb)), (this.space += this.bb.capacity() - align); + } + this.pad(i); + } + pad(value) { + for (let e = 0; e < value; e++) this.bb.writeInt8(--this.space, 0); + } + writeInt8(value) { + this.bb.writeInt8((this.space -= 1), value); + } + writeInt16(value) { + this.bb.writeInt16((this.space -= 2), value); + } + writeInt32(value) { + this.bb.writeInt32((this.space -= 4), value); + } + writeInt64(value) { + this.bb.writeInt64((this.space -= 8), value); + } + writeFloat32(value) { + this.bb.writeFloat32((this.space -= 4), value); + } + writeFloat64(value) { + this.bb.writeFloat64((this.space -= 8), value); + } + addInt8(value) { + this.prep(1, 0), this.writeInt8(value); + } + addInt16(value) { + this.prep(2, 0), this.writeInt16(value); + } + addInt32(value) { + this.prep(4, 0), this.writeInt32(value); + } + addInt64(value) { + this.prep(8, 0), this.writeInt64(value); + } + addFloat32(value) { + this.prep(4, 0), this.writeFloat32(value); + } + addFloat64(value) { + this.prep(8, 0), this.writeFloat64(value); + } + addFieldInt8(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt8(value), this.slot(id)); + } + addFieldInt16(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt16(value), this.slot(id)); + } + addFieldInt32(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt32(value), this.slot(id)); + } + addFieldInt64(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt64(value), this.slot(id)); + } + addFieldFloat32(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addFloat32(value), this.slot(id)); + } + addFieldFloat64(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addFloat64(value), this.slot(id)); + } + addFieldOffset(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addOffset(value), this.slot(id)); + } + addFieldStruct(id, value, defaultValue) { + value !== defaultValue && (this.nested(value), this.slot(id)); + } + nested(value) { + if (value !== this.offset()) throw TypeError("FlatBuffers: struct must be serialized inline."); + } + notNested() { + if (this.isNested) throw TypeError("FlatBuffers: object serialization must not be nested."); + } + slot(value) { + null !== this.vtable && (this.vtable[value] = this.offset()); + } + offset() { + return this.bb.capacity() - this.space; + } + static growByteBuffer(bb) { + const e = bb.capacity(); + if (0xc0000000 & e) throw Error("FlatBuffers: cannot grow buffer beyond 2 gigabytes."); + const i = e << 1, + a = ByteBuffer.allocate(i); + return a.setPosition(i - e), a.bytes().set(bb.bytes(), i - e), a; + } + addOffset(value) { + this.prep(4, 0), this.writeInt32(this.offset() - value + 4); + } + startObject(fieldCount) { + this.notNested(), null == this.vtable && (this.vtable = []), (this.vtable_in_use = fieldCount); + for (let e = 0; e < fieldCount; e++) this.vtable[e] = 0; + (this.isNested = !0), (this.object_start = this.offset()); + } + endObject() { + if (null == this.vtable || !this.isNested) throw Error("FlatBuffers: endObject called without startObject"); + this.addInt32(0); + let t = this.offset(), + e = this.vtable_in_use - 1; + for (; e >= 0 && 0 === this.vtable[e]; e--); + const i = e + 1; + for (; e >= 0; e--) this.addInt16(0 !== this.vtable[e] ? t - this.vtable[e] : 0); + this.addInt16(t - this.object_start); + const a = (i + 2) * 2; + this.addInt16(a); + let s = 0, + n = this.space; + t: for (e = 0; e < this.vtables.length; e++) { + const t = this.bb.capacity() - this.vtables[e]; + if (a === this.bb.readInt16(t)) { + for (let e = 2; e < a; e += 2) if (this.bb.readInt16(n + e) !== this.bb.readInt16(t + e)) continue t; + s = this.vtables[e]; + break; + } + } + return s ? ((this.space = this.bb.capacity() - t), this.bb.writeInt32(this.space, s - t)) : (this.vtables.push(this.offset()), this.bb.writeInt32(this.bb.capacity() - t, this.offset() - t)), (this.isNested = !1), t; + } + finish(root, fileId, sizePrefix) { + const a = 4 * !!sizePrefix; + if (fileId) { + if ((this.prep(this.minalign, 8 + a), 4 !== fileId.length)) throw TypeError("FlatBuffers: file identifier must be length 4"); + for (let root = 3; root >= 0; root--) this.writeInt8(fileId.charCodeAt(root)); + } + this.prep(this.minalign, 4 + a), this.addOffset(root), a && this.addInt32(this.bb.capacity() - this.space), this.bb.setPosition(this.space); + } + finishSizePrefixed(root, fileId) { + this.finish(root, fileId, !0); + } + requiredField(table, field) { + const i = this.bb.capacity() - table, + a = i - this.bb.readInt32(i); + if (!(field < this.bb.readInt16(a) && 0 !== this.bb.readInt16(a + field))) throw TypeError(`FlatBuffers: field ${field} must be set`); + } + startVector(elemSize, numElems, alignment) { + this.notNested(), (this.vector_num_elems = numElems), this.prep(4, elemSize * numElems), this.prep(alignment, elemSize * numElems); + } + endVector() { + return this.writeInt32(this.vector_num_elems), this.offset(); + } + createSharedString(str) { + if (!str) return 0; + if ((this.string_maps || (this.string_maps = new Map()), this.string_maps.has(str))) return this.string_maps.get(str); + const e = this.createString(str); + return this.string_maps.set(str, e), e; + } + createString(str) { + let e; + return null == str ? 0 : ((e = str instanceof Uint8Array ? str : this.text_encoder.encode(str)), this.addInt8(0), this.startVector(1, e.length, 1), this.bb.setPosition((this.space -= e.length)), this.bb.bytes().set(e, this.space), this.endVector()); + } + createByteVector(bytes) { + return null == bytes ? 0 : (this.startVector(1, bytes.length, 1), this.bb.setPosition((this.space -= bytes.length)), this.bb.bytes().set(bytes, this.space), this.endVector()); + } + createObjectOffset(obj) { + return null === obj ? 0 : "string" === typeof obj ? this.createString(obj) : obj.pack(this); + } + createObjectOffsetList(list) { + const e = []; + for (let i = 0; i < list.length; ++i) { + const a = list[i]; + if (null !== a) e.push(this.createObjectOffset(a)); + else throw TypeError("FlatBuffers: Argument for createObjectOffsetList cannot contain null."); + } + return e; + } + createStructOffsetList(list, func) { + return func(this, list.length), this.createObjectOffsetList(list.slice().reverse()), this.endVector(); + } +} + +// Metadata +export class Metadata { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsMetadata(bb, obj) { + return (obj || new Metadata()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsMetadata(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Metadata()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + attributionUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + expireTime() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + language(encoding) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + latitude() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + longitude() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + providerLogo(encoding) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + providerName(encoding) { + const e = this.bb.__offset(this.bb_pos, 16); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + readTime() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + reportedTime() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + temporarilyUnavailable() { + const t = this.bb.__offset(this.bb_pos, 22); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + sourceType() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readInt8(this.bb_pos + t) : SourceType.APPLE_INTERNAL; + } + static startMetadata(builder) { + builder.startObject(11); + } + static addAttributionUrl(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addExpireTime(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addLanguage(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addLatitude(builder, value) { + builder.addFieldFloat32(3, value, 0); + } + static addLongitude(builder, value) { + builder.addFieldFloat32(4, value, 0); + } + static addProviderLogo(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static addProviderName(builder, value) { + builder.addFieldOffset(6, value, 0); + } + static addReadTime(builder, value) { + builder.addFieldInt32(7, value, 0); + } + static addReportedTime(builder, value) { + builder.addFieldInt32(8, value, 0); + } + static addTemporarilyUnavailable(builder, value) { + builder.addFieldInt8(9, +value, 0); + } + static addSourceType(builder, value) { + builder.addFieldInt8(10, value, SourceType.APPLE_INTERNAL); + } + static endMetadata(builder) { + return builder.endObject(); + } + static createMetadata(builder, e, i, a, s, n, r, o, d, c, l, u) { + return ( + Metadata.startMetadata(builder), + Metadata.addAttributionUrl(builder, e), + Metadata.addExpireTime(builder, i), + Metadata.addLanguage(builder, a), + Metadata.addLatitude(builder, s), + Metadata.addLongitude(builder, n), + Metadata.addProviderLogo(builder, r), + Metadata.addProviderName(builder, o), + Metadata.addReadTime(builder, d), + Metadata.addReportedTime(builder, c), + Metadata.addTemporarilyUnavailable(builder, l), + Metadata.addSourceType(builder, u), + Metadata.endMetadata(builder) + ); + } +} + +// Pollutant +export class Pollutant { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsPollutant(bb, obj) { + return (obj || new Pollutant()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsPollutant(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Pollutant()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + pollutantType() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : PollutantType.NOT_AVAILABLE; + } + amount() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + units() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : UnitType.PARTS_PER_BILLION; + } + static startPollutant(builder) { + builder.startObject(3); + } + static addPollutantType(builder, value) { + builder.addFieldInt8(0, value, PollutantType.NOT_AVAILABLE); + } + static addAmount(builder, value) { + builder.addFieldFloat32(1, value, 0); + } + static addUnits(builder, value) { + builder.addFieldInt8(2, value, UnitType.PARTS_PER_BILLION); + } + static endPollutant(builder) { + return builder.endObject(); + } + static createPollutant(builder, e, i, a) { + return Pollutant.startPollutant(builder), Pollutant.addPollutantType(builder, e), Pollutant.addAmount(builder, i), Pollutant.addUnits(builder, a), Pollutant.endPollutant(builder); + } +} + +// AirQuality +export class AirQuality { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsAirQuality(bb, obj) { + return (obj || new AirQuality()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsAirQuality(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new AirQuality()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + categoryIndex() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + index() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + isSignificant() { + const t = this.bb.__offset(this.bb_pos, 10); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + pollutants(index, optional) { + const i = this.bb.__offset(this.bb_pos, 12); + return i ? (optional || new Pollutant()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + pollutantsLength() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + previousDayComparison() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : ComparisonTrend.UNKNOWN; + } + primaryPollutant() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : PollutantType.NOT_AVAILABLE; + } + scale(encoding) { + const e = this.bb.__offset(this.bb_pos, 18); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + static startAirQuality(builder) { + builder.startObject(8); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addCategoryIndex(builder, value) { + builder.addFieldInt8(1, value, 0); + } + static addIndex(builder, value) { + builder.addFieldInt16(2, value, 0); + } + static addIsSignificant(builder, value) { + builder.addFieldInt8(3, +value, 0); + } + static addPollutants(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static createPollutantsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPollutantsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPreviousDayComparison(builder, value) { + builder.addFieldInt8(5, value, ComparisonTrend.UNKNOWN); + } + static addPrimaryPollutant(builder, value) { + builder.addFieldInt8(6, value, PollutantType.NOT_AVAILABLE); + } + static addScale(builder, value) { + builder.addFieldOffset(7, value, 0); + } + static endAirQuality(builder) { + return builder.endObject(); + } + static createAirQuality(builder, e, i, a, s, n, r, o, d) { + return ( + AirQuality.startAirQuality(builder), + AirQuality.addMetadata(builder, e), + AirQuality.addCategoryIndex(builder, i), + AirQuality.addIndex(builder, a), + AirQuality.addIsSignificant(builder, s), + AirQuality.addPollutants(builder, n), + AirQuality.addPreviousDayComparison(builder, r), + AirQuality.addPrimaryPollutant(builder, o), + AirQuality.addScale(builder, d), + AirQuality.endAirQuality(builder) + ); + } +} + +// UUID +export class UUID { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsUUID(bb, obj) { + return (obj || new UUID()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsUUID(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new UUID()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + bytes(encoding) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? this.bb.readUint8(this.bb.__vector(this.bb_pos + e) + encoding) : 0; + } + bytesLength() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + bytesArray() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? new Uint8Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + t), this.bb.__vector_len(this.bb_pos + t)) : null; + } + static startUUID(builder) { + builder.startObject(1); + } + static addBytes(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static createBytesVector(builder, data) { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) builder.addInt8(data[i]); + return builder.endVector(); + } + static startBytesVector(builder, numElems) { + builder.startVector(1, numElems, 1); + } + static endUUID(builder) { + return builder.endObject(); + } + static createUUID(builder, value) { + return UUID.startUUID(builder), UUID.addBytes(builder, value), UUID.endUUID(builder); + } +} + +// Articles +export class Articles { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsArticles(bb, obj) { + return (obj || new Articles()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsArticles(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Articles()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + id(encoding) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + supportedStorefronts(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? this.bb.__string(this.bb.__vector(this.bb_pos + i) + 4 * index, optional) : null; + } + supportedStorefrontsLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + alertIds(index, optional) { + const i = this.bb.__offset(this.bb_pos, 8); + return i ? (optional || new UUID()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + alertIdsLength() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + phenomena(index, optional) { + const i = this.bb.__offset(this.bb_pos, 10); + return i ? this.bb.__string(this.bb.__vector(this.bb_pos + i) + 4 * index, optional) : null; + } + phenomenaLength() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + headlineOverride(encoding) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + locale(encoding) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + static startArticles(builder) { + builder.startObject(6); + } + static addId(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addSupportedStorefronts(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createSupportedStorefrontsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startSupportedStorefrontsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addAlertIds(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static createAlertIdsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startAlertIdsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPhenomena(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static createPhenomenaVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPhenomenaVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addHeadlineOverride(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static addLocale(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static endArticles(builder) { + return builder.endObject(); + } + static createArticles(builder, e, i, a, s, n, r) { + return Articles.startArticles(builder), Articles.addId(builder, e), Articles.addSupportedStorefronts(builder, i), Articles.addAlertIds(builder, a), Articles.addPhenomena(builder, s), Articles.addHeadlineOverride(builder, n), Articles.addLocale(builder, r), Articles.endArticles(builder); + } +} + +// Change +export class Change { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsChange(bb, obj) { + return (obj || new Change()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsChange(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Change()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + maxTemperatureChange() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + minTemperatureChange() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + dayPrecipitationChange() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + nightPrecipitationChange() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + static startChange(builder) { + builder.startObject(6); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addMaxTemperatureChange(builder, value) { + builder.addFieldInt8(2, value, Direction.STEADY); + } + static addMinTemperatureChange(builder, value) { + builder.addFieldInt8(3, value, Direction.STEADY); + } + static addDayPrecipitationChange(builder, value) { + builder.addFieldInt8(4, value, Direction.STEADY); + } + static addNightPrecipitationChange(builder, value) { + builder.addFieldInt8(5, value, Direction.STEADY); + } + static endChange(builder) { + return builder.endObject(); + } + static createChange(builder, e, i, a, s, n, r) { + return ( + Change.startChange(builder), Change.addForecastStart(builder, e), Change.addForecastEnd(builder, i), Change.addMaxTemperatureChange(builder, a), Change.addMinTemperatureChange(builder, s), Change.addDayPrecipitationChange(builder, n), Change.addNightPrecipitationChange(builder, r), Change.endChange(builder) + ); + } +} + +// Comparison +export class Comparison { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsComparison(bb, obj) { + return (obj || new Comparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsComparison(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Comparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + condition() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : ComparisonType2.UNKNOWN0; + } + currentValue() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + baselineValue() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + deviation() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : Deviation2.MUCHHIGHER; + } + baselineType() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + baselineStartDate() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + static startComparison(builder) { + builder.startObject(6); + } + static addCondition(builder, value) { + builder.addFieldInt8(0, value, ComparisonType2.UNKNOWN0); + } + static addCurrentValue(builder, value) { + builder.addFieldFloat32(1, value, 0); + } + static addBaselineValue(builder, value) { + builder.addFieldFloat32(2, value, 0); + } + static addDeviation(builder, value) { + builder.addFieldInt8(3, value, Deviation2.MUCHHIGHER); + } + static addBaselineType(builder, value) { + builder.addFieldInt32(4, value, 0); + } + static addBaselineStartDate(builder, value) { + builder.addFieldInt32(5, value, 0); + } + static endComparison(builder) { + return builder.endObject(); + } + static createComparison(builder, e, i, a, s, n, r) { + return ( + Comparison.startComparison(builder), + Comparison.addCondition(builder, e), + Comparison.addCurrentValue(builder, i), + Comparison.addBaselineValue(builder, a), + Comparison.addDeviation(builder, s), + Comparison.addBaselineType(builder, n), + Comparison.addBaselineStartDate(builder, r), + Comparison.endComparison(builder) + ); + } +} + +// Parameter +export class Parameter { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsParameter(bb, obj) { + return (obj || new Parameter()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsParameter(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Parameter()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + type() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : ParameterType.FIRST_AT; + } + date() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + static startParameter(builder) { + builder.startObject(2); + } + static addType(builder, value) { + builder.addFieldInt8(0, value, ParameterType.FIRST_AT); + } + static addDate(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static endParameter(builder) { + return builder.endObject(); + } + static createParameter(builder, e, i) { + return Parameter.startParameter(builder), Parameter.addType(builder, e), Parameter.addDate(builder, i), Parameter.endParameter(builder); + } +} + +// Condition +export class Condition { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsCondition(bb, obj) { + return (obj || new Condition()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsCondition(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Condition()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + startTime() { + const builder = this.bb.__offset(this.bb_pos, 4); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + endTime() { + const builder = this.bb.__offset(this.bb_pos, 6); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + forecastToken() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : ForecastToken.CLEAR; + } + beginCondition() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : ConditionType.CLEAR; + } + endCondition() { + const builder = this.bb.__offset(this.bb_pos, 12); + return builder ? this.bb.readInt8(this.bb_pos + builder) : ConditionType.CLEAR; + } + parameters(index, optional) { + const i = this.bb.__offset(this.bb_pos, 14); + return i ? (optional || new Parameter()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + parametersLength() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startCondition(builder) { + builder.startObject(6); + } + static addStartTime(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addEndTime(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addForecastToken(builder, value) { + builder.addFieldInt8(2, value, ForecastToken.CLEAR); + } + static addBeginCondition(builder, value) { + builder.addFieldInt8(3, value, ConditionType.CLEAR); + } + static addEndCondition(builder, value) { + builder.addFieldInt8(4, value, ConditionType.CLEAR); + } + static addParameters(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static createParametersVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startParametersVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endCondition(builder) { + return builder.endObject(); + } + static createCondition(builder, e, i, a, s, n, r) { + return Condition.startCondition(builder), Condition.addStartTime(builder, e), Condition.addEndTime(builder, i), Condition.addForecastToken(builder, a), Condition.addBeginCondition(builder, s), Condition.addEndCondition(builder, n), Condition.addParameters(builder, r), Condition.endCondition(builder); + } +} + +// PrecipitationAmountByType +export class PrecipitationAmountByType { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsPrecipitationAmountByType(bb, obj) { + return (obj || new PrecipitationAmountByType()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsPrecipitationAmountByType(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new PrecipitationAmountByType()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + expected() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + minimumSnow() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + maximumSnow() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + expectedSnow() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startPrecipitationAmountByType(builder) { + builder.startObject(5); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(0, value, PrecipitationType.CLEAR); + } + static addExpected(builder, value) { + builder.addFieldFloat32(1, value, 0); + } + static addMinimumSnow(builder, value) { + builder.addFieldFloat32(2, value, 0); + } + static addMaximumSnow(builder, value) { + builder.addFieldFloat32(3, value, 0); + } + static addExpectedSnow(builder, value) { + builder.addFieldFloat32(4, value, 0); + } + static endPrecipitationAmountByType(builder) { + return builder.endObject(); + } + static createPrecipitationAmountByType(builder, e, i, a, s, n) { + return ( + PrecipitationAmountByType.startPrecipitationAmountByType(builder), + PrecipitationAmountByType.addPrecipitationType(builder, e), + PrecipitationAmountByType.addExpected(builder, i), + PrecipitationAmountByType.addMinimumSnow(builder, a), + PrecipitationAmountByType.addMaximumSnow(builder, s), + PrecipitationAmountByType.addExpectedSnow(builder, n), + PrecipitationAmountByType.endPrecipitationAmountByType(builder) + ); + } +} + +// CurrentWeatherData +export class CurrentWeatherData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsCurrentWeatherData(bb, obj) { + return (obj || new CurrentWeatherData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsCurrentWeatherData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new CurrentWeatherData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + asOf() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + cloudCover() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverLowAltPct() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverMidAltPct() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverHighAltPct() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + daylight() { + const t = this.bb.__offset(this.bb_pos, 18); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + humidity() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount1h() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount6h() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount24h() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext1h() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext6h() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext24h() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext1hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 36); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountNext1hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountNext6hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 38); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountNext6hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountNext24hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 40); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountNext24hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountPrevious1hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 42); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountPrevious1hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountPrevious6hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 44); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountPrevious6hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountPrevious24hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 46); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountPrevious24hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + pressure() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + pressureTrend() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readInt8(this.bb_pos + t) : PressureTrend.RISING; + } + snowfallAmount1h() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmount6h() { + const t = this.bb.__offset(this.bb_pos, 56); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmount24h() { + const t = this.bb.__offset(this.bb_pos, 58); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmountNext1h() { + const t = this.bb.__offset(this.bb_pos, 60); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmountNext6h() { + const t = this.bb.__offset(this.bb_pos, 62); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmountNext24h() { + const t = this.bb.__offset(this.bb_pos, 64); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperature() { + const t = this.bb.__offset(this.bb_pos, 66); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureApparent() { + const t = this.bb.__offset(this.bb_pos, 68); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + unknown34() { + const t = this.bb.__offset(this.bb_pos, 70); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureDewPoint() { + const t = this.bb.__offset(this.bb_pos, 72); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + uvIndex() { + const t = this.bb.__offset(this.bb_pos, 74); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + visibility() { + const t = this.bb.__offset(this.bb_pos, 76); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windDirection() { + const t = this.bb.__offset(this.bb_pos, 78); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + windGust() { + const t = this.bb.__offset(this.bb_pos, 80); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeed() { + const t = this.bb.__offset(this.bb_pos, 82); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startCurrentWeatherData(builder) { + builder.startObject(40); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addAsOf(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addCloudCover(builder, value) { + builder.addFieldInt8(2, value, 0); + } + static addCloudCoverLowAltPct(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addCloudCoverMidAltPct(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addCloudCoverHighAltPct(builder, value) { + builder.addFieldInt8(5, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(6, value, WeatherCondition.CLEAR); + } + static addDaylight(builder, value) { + builder.addFieldInt8(7, +value, 0); + } + static addHumidity(builder, value) { + builder.addFieldInt8(8, value, 0); + } + static addPerceivedPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(9, value, 0); + } + static addPrecipitationAmount1h(builder, value) { + builder.addFieldFloat32(10, value, 0); + } + static addPrecipitationAmount6h(builder, value) { + builder.addFieldFloat32(11, value, 0); + } + static addPrecipitationAmount24h(builder, value) { + builder.addFieldFloat32(12, value, 0); + } + static addPrecipitationAmountNext1h(builder, value) { + builder.addFieldFloat32(13, value, 0); + } + static addPrecipitationAmountNext6h(builder, value) { + builder.addFieldFloat32(14, value, 0); + } + static addPrecipitationAmountNext24h(builder, value) { + builder.addFieldFloat32(15, value, 0); + } + static addPrecipitationAmountNext1hByType(builder, value) { + builder.addFieldOffset(16, value, 0); + } + static createPrecipitationAmountNext1hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountNext1hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountNext6hByType(builder, value) { + builder.addFieldOffset(17, value, 0); + } + static createPrecipitationAmountNext6hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountNext6hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountNext24hByType(builder, value) { + builder.addFieldOffset(18, value, 0); + } + static createPrecipitationAmountNext24hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountNext24hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountPrevious1hByType(builder, value) { + builder.addFieldOffset(19, value, 0); + } + static createPrecipitationAmountPrevious1hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountPrevious1hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountPrevious6hByType(builder, value) { + builder.addFieldOffset(20, value, 0); + } + static createPrecipitationAmountPrevious6hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountPrevious6hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountPrevious24hByType(builder, value) { + builder.addFieldOffset(21, value, 0); + } + static createPrecipitationAmountPrevious24hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountPrevious24hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(22, value, 0); + } + static addPressure(builder, value) { + builder.addFieldFloat32(23, value, 0); + } + static addPressureTrend(builder, value) { + builder.addFieldInt8(24, value, PressureTrend.RISING); + } + static addSnowfallAmount1h(builder, value) { + builder.addFieldFloat32(25, value, 0); + } + static addSnowfallAmount6h(builder, value) { + builder.addFieldFloat32(26, value, 0); + } + static addSnowfallAmount24h(builder, value) { + builder.addFieldFloat32(27, value, 0); + } + static addSnowfallAmountNext1h(builder, value) { + builder.addFieldFloat32(28, value, 0); + } + static addSnowfallAmountNext6h(builder, value) { + builder.addFieldFloat32(29, value, 0); + } + static addSnowfallAmountNext24h(builder, value) { + builder.addFieldFloat32(30, value, 0); + } + static addTemperature(builder, value) { + builder.addFieldFloat32(31, value, 0); + } + static addTemperatureApparent(builder, value) { + builder.addFieldFloat32(32, value, 0); + } + static addUnknown34(builder, value) { + builder.addFieldFloat32(33, value, 0); + } + static addTemperatureDewPoint(builder, value) { + builder.addFieldFloat32(34, value, 0); + } + static addUvIndex(builder, value) { + builder.addFieldInt8(35, value, 0); + } + static addVisibility(builder, value) { + builder.addFieldFloat32(36, value, 0); + } + static addWindDirection(builder, value) { + builder.addFieldInt16(37, value, 0); + } + static addWindGust(builder, value) { + builder.addFieldFloat32(38, value, 0); + } + static addWindSpeed(builder, value) { + builder.addFieldFloat32(39, value, 0); + } + static endCurrentWeatherData(builder) { + return builder.endObject(); + } + static createCurrentWeatherData(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v, S, O, F, w, P, E, M, R, U, W, L, D, B, $) { + return ( + CurrentWeatherData.startCurrentWeatherData(builder), + CurrentWeatherData.addMetadata(builder, e), + CurrentWeatherData.addAsOf(builder, i), + CurrentWeatherData.addCloudCover(builder, a), + CurrentWeatherData.addCloudCoverLowAltPct(builder, s), + CurrentWeatherData.addCloudCoverMidAltPct(builder, n), + CurrentWeatherData.addCloudCoverHighAltPct(builder, r), + CurrentWeatherData.addConditionCode(builder, o), + CurrentWeatherData.addDaylight(builder, d), + CurrentWeatherData.addHumidity(builder, c), + CurrentWeatherData.addPerceivedPrecipitationIntensity(builder, l), + CurrentWeatherData.addPrecipitationAmount1h(builder, u), + CurrentWeatherData.addPrecipitationAmount6h(builder, p), + CurrentWeatherData.addPrecipitationAmount24h(builder, b), + CurrentWeatherData.addPrecipitationAmountNext1h(builder, h), + CurrentWeatherData.addPrecipitationAmountNext6h(builder, m), + CurrentWeatherData.addPrecipitationAmountNext24h(builder, _), + CurrentWeatherData.addPrecipitationAmountNext1hByType(builder, y), + CurrentWeatherData.addPrecipitationAmountNext6hByType(builder, f), + CurrentWeatherData.addPrecipitationAmountNext24hByType(builder, g), + CurrentWeatherData.addPrecipitationAmountPrevious1hByType(builder, N), + CurrentWeatherData.addPrecipitationAmountPrevious6hByType(builder, C), + CurrentWeatherData.addPrecipitationAmountPrevious24hByType(builder, x), + CurrentWeatherData.addPrecipitationIntensity(builder, A), + CurrentWeatherData.addPressure(builder, I), + CurrentWeatherData.addPressureTrend(builder, T), + CurrentWeatherData.addSnowfallAmount1h(builder, v), + CurrentWeatherData.addSnowfallAmount6h(builder, S), + CurrentWeatherData.addSnowfallAmount24h(builder, O), + CurrentWeatherData.addSnowfallAmountNext1h(builder, F), + CurrentWeatherData.addSnowfallAmountNext6h(builder, w), + CurrentWeatherData.addSnowfallAmountNext24h(builder, P), + CurrentWeatherData.addTemperature(builder, E), + CurrentWeatherData.addTemperatureApparent(builder, M), + CurrentWeatherData.addUnknown34(builder, R), + CurrentWeatherData.addTemperatureDewPoint(builder, U), + CurrentWeatherData.addUvIndex(builder, W), + CurrentWeatherData.addVisibility(builder, L), + CurrentWeatherData.addWindDirection(builder, D), + CurrentWeatherData.addWindGust(builder, B), + CurrentWeatherData.addWindSpeed(builder, $), + CurrentWeatherData.endCurrentWeatherData(builder) + ); + } +} + +// DayPartForecast +export class DayPartForecast { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsDayPartForecast(bb, obj) { + return (obj || new DayPartForecast()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsDayPartForecast(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new DayPartForecast()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + cloudCover() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverLowAltPct() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverMidAltPct() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverHighAltPct() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + humidity() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + humidityMax() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + humidityMin() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationAmount() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 26); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + snowfallAmount() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMax() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMin() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMax() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMin() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windDirection() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + windGustSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeed() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationIntensityMax() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensityMax() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + uvIndexMin() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + uvIndexMax() { + const t = this.bb.__offset(this.bb_pos, 56); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + temperatureApparentMin() { + const t = this.bb.__offset(this.bb_pos, 58); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureApparentMax() { + const t = this.bb.__offset(this.bb_pos, 60); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + daylight() { + const t = this.bb.__offset(this.bb_pos, 62); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + static startDayPartForecast(builder) { + builder.startObject(30); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addCloudCover(builder, value) { + builder.addFieldInt8(2, value, 0); + } + static addCloudCoverLowAltPct(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addCloudCoverMidAltPct(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addCloudCoverHighAltPct(builder, value) { + builder.addFieldInt8(5, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(6, value, WeatherCondition.CLEAR); + } + static addHumidity(builder, value) { + builder.addFieldInt8(7, value, 0); + } + static addHumidityMax(builder, value) { + builder.addFieldInt8(8, value, 0); + } + static addHumidityMin(builder, value) { + builder.addFieldInt8(9, value, 0); + } + static addPrecipitationAmount(builder, value) { + builder.addFieldFloat32(10, value, 0); + } + static addPrecipitationAmountByType(builder, value) { + builder.addFieldOffset(11, value, 0); + } + static createPrecipitationAmountByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(12, value, 0); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(13, value, PrecipitationType.CLEAR); + } + static addSnowfallAmount(builder, value) { + builder.addFieldFloat32(14, value, 0); + } + static addTemperatureMax(builder, value) { + builder.addFieldFloat32(15, value, 0); + } + static addTemperatureMin(builder, value) { + builder.addFieldFloat32(16, value, 0); + } + static addVisibilityMax(builder, value) { + builder.addFieldFloat32(17, value, 0); + } + static addVisibilityMin(builder, value) { + builder.addFieldFloat32(18, value, 0); + } + static addWindDirection(builder, value) { + builder.addFieldInt16(19, value, 0); + } + static addWindGustSpeedMax(builder, value) { + builder.addFieldFloat32(20, value, 0); + } + static addWindSpeed(builder, value) { + builder.addFieldFloat32(21, value, 0); + } + static addWindSpeedMax(builder, value) { + builder.addFieldFloat32(22, value, 0); + } + static addPrecipitationIntensityMax(builder, value) { + builder.addFieldFloat32(23, value, 0); + } + static addPerceivedPrecipitationIntensityMax(builder, value) { + builder.addFieldFloat32(24, value, 0); + } + static addUvIndexMin(builder, value) { + builder.addFieldInt8(25, value, 0); + } + static addUvIndexMax(builder, value) { + builder.addFieldInt8(26, value, 0); + } + static addTemperatureApparentMin(builder, value) { + builder.addFieldFloat32(27, value, 0); + } + static addTemperatureApparentMax(builder, value) { + builder.addFieldFloat32(28, value, 0); + } + static addDaylight(builder, value) { + builder.addFieldInt8(29, +value, 0); + } + static endDayPartForecast(builder) { + return builder.endObject(); + } + static createDayPartForecast(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v, S, O, F, w) { + return ( + DayPartForecast.startDayPartForecast(builder), + DayPartForecast.addForecastStart(builder, e), + DayPartForecast.addForecastEnd(builder, i), + DayPartForecast.addCloudCover(builder, a), + DayPartForecast.addCloudCoverLowAltPct(builder, s), + DayPartForecast.addCloudCoverMidAltPct(builder, n), + DayPartForecast.addCloudCoverHighAltPct(builder, r), + DayPartForecast.addConditionCode(builder, o), + DayPartForecast.addHumidity(builder, d), + DayPartForecast.addHumidityMax(builder, c), + DayPartForecast.addHumidityMin(builder, l), + DayPartForecast.addPrecipitationAmount(builder, u), + DayPartForecast.addPrecipitationAmountByType(builder, p), + DayPartForecast.addPrecipitationChance(builder, b), + DayPartForecast.addPrecipitationType(builder, h), + DayPartForecast.addSnowfallAmount(builder, m), + DayPartForecast.addTemperatureMax(builder, _), + DayPartForecast.addTemperatureMin(builder, y), + DayPartForecast.addVisibilityMax(builder, f), + DayPartForecast.addVisibilityMin(builder, g), + DayPartForecast.addWindDirection(builder, N), + DayPartForecast.addWindGustSpeedMax(builder, C), + DayPartForecast.addWindSpeed(builder, x), + DayPartForecast.addWindSpeedMax(builder, A), + DayPartForecast.addPrecipitationIntensityMax(builder, I), + DayPartForecast.addPerceivedPrecipitationIntensityMax(builder, T), + DayPartForecast.addUvIndexMin(builder, v), + DayPartForecast.addUvIndexMax(builder, S), + DayPartForecast.addTemperatureApparentMin(builder, O), + DayPartForecast.addTemperatureApparentMax(builder, F), + DayPartForecast.addDaylight(builder, w), + DayPartForecast.endDayPartForecast(builder) + ); + } +} + +// DayWeatherConditions +export class DayWeatherConditions { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsDayWeatherConditions(bb, obj) { + return (obj || new DayWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsDayWeatherConditions(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new DayWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + humidityMax() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + humidityMin() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + maxUvIndex() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + moonPhase() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : MoonPhase.NEW; + } + moonrise() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + moonset() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + precipitationAmount() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 24); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + snowfallAmount() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + solarMidnight() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + solarNoon() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunrise() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunriseCivil() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunriseNautical() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunriseAstronomical() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunset() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunsetCivil() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunsetNautical() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunsetAstronomical() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + temperatureMax() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMaxTime() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + temperatureMin() { + const t = this.bb.__offset(this.bb_pos, 56); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMinTime() { + const t = this.bb.__offset(this.bb_pos, 58); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + windGustSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 60); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeedAvg() { + const t = this.bb.__offset(this.bb_pos, 62); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 64); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMax() { + const t = this.bb.__offset(this.bb_pos, 66); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMin() { + const t = this.bb.__offset(this.bb_pos, 68); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + overnightForecast(optional) { + const e = this.bb.__offset(this.bb_pos, 70); + return e ? (optional || new DayPartForecast()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + daytimeForecast(optional) { + const e = this.bb.__offset(this.bb_pos, 72); + return e ? (optional || new DayPartForecast()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + restOfDayForecast(optional) { + const e = this.bb.__offset(this.bb_pos, 74); + return e ? (optional || new DayPartForecast()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + static startDayWeatherConditions(builder) { + builder.startObject(36); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(2, value, WeatherCondition.CLEAR); + } + static addHumidityMax(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addHumidityMin(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addMaxUvIndex(builder, value) { + builder.addFieldInt8(5, value, 0); + } + static addMoonPhase(builder, value) { + builder.addFieldInt8(6, value, MoonPhase.NEW); + } + static addMoonrise(builder, value) { + builder.addFieldInt32(7, value, 0); + } + static addMoonset(builder, value) { + builder.addFieldInt32(8, value, 0); + } + static addPrecipitationAmount(builder, value) { + builder.addFieldFloat32(9, value, 0); + } + static addPrecipitationAmountByType(builder, value) { + builder.addFieldOffset(10, value, 0); + } + static createPrecipitationAmountByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(11, value, 0); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(12, value, PrecipitationType.CLEAR); + } + static addSnowfallAmount(builder, value) { + builder.addFieldFloat32(13, value, 0); + } + static addSolarMidnight(builder, value) { + builder.addFieldInt32(14, value, 0); + } + static addSolarNoon(builder, value) { + builder.addFieldInt32(15, value, 0); + } + static addSunrise(builder, value) { + builder.addFieldInt32(16, value, 0); + } + static addSunriseCivil(builder, value) { + builder.addFieldInt32(17, value, 0); + } + static addSunriseNautical(builder, value) { + builder.addFieldInt32(18, value, 0); + } + static addSunriseAstronomical(builder, value) { + builder.addFieldInt32(19, value, 0); + } + static addSunset(builder, value) { + builder.addFieldInt32(20, value, 0); + } + static addSunsetCivil(builder, value) { + builder.addFieldInt32(21, value, 0); + } + static addSunsetNautical(builder, value) { + builder.addFieldInt32(22, value, 0); + } + static addSunsetAstronomical(builder, value) { + builder.addFieldInt32(23, value, 0); + } + static addTemperatureMax(builder, value) { + builder.addFieldFloat32(24, value, 0); + } + static addTemperatureMaxTime(builder, value) { + builder.addFieldInt32(25, value, 0); + } + static addTemperatureMin(builder, value) { + builder.addFieldFloat32(26, value, 0); + } + static addTemperatureMinTime(builder, value) { + builder.addFieldInt32(27, value, 0); + } + static addWindGustSpeedMax(builder, value) { + builder.addFieldFloat32(28, value, 0); + } + static addWindSpeedAvg(builder, value) { + builder.addFieldFloat32(29, value, 0); + } + static addWindSpeedMax(builder, value) { + builder.addFieldFloat32(30, value, 0); + } + static addVisibilityMax(builder, value) { + builder.addFieldFloat32(31, value, 0); + } + static addVisibilityMin(builder, value) { + builder.addFieldFloat32(32, value, 0); + } + static addOvernightForecast(builder, value) { + builder.addFieldOffset(33, value, 0); + } + static addDaytimeForecast(builder, value) { + builder.addFieldOffset(34, value, 0); + } + static addRestOfDayForecast(builder, value) { + builder.addFieldOffset(35, value, 0); + } + static endDayWeatherConditions(builder) { + return builder.endObject(); + } +} + +// DailyForecastData +export class DailyForecastData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsDailyForecastData(bb, obj) { + return (obj || new DailyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsDailyForecastData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new DailyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + days(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new DayWeatherConditions()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + daysLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startDailyForecastData(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addDays(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createDaysVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startDaysVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endDailyForecastData(builder) { + return builder.endObject(); + } + static createDailyForecastData(builder, e, i) { + return DailyForecastData.startDailyForecastData(builder), DailyForecastData.addMetadata(builder, e), DailyForecastData.addDays(builder, i), DailyForecastData.endDailyForecastData(builder); + } +} + +// ForecastMinute +export class ForecastMinute { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsForecastMinute(bb, obj) { + return (obj || new ForecastMinute()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsForecastMinute(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new ForecastMinute()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + startTime() { + const builder = this.bb.__offset(this.bb_pos, 4); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startForecastMinute(builder) { + builder.startObject(4); + } + static addStartTime(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(1, value, 0); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(2, value, 0); + } + static addPerceivedPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(3, value, 0); + } + static endForecastMinute(builder) { + return builder.endObject(); + } + static createForecastMinute(builder, e, i, a, s) { + return ForecastMinute.startForecastMinute(builder), ForecastMinute.addStartTime(builder, e), ForecastMinute.addPrecipitationChance(builder, i), ForecastMinute.addPrecipitationIntensity(builder, a), ForecastMinute.addPerceivedPrecipitationIntensity(builder, s), ForecastMinute.endForecastMinute(builder); + } +} + +// ForecastPeriodSummary +export class ForecastPeriodSummary { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsForecastPeriodSummary(bb, obj) { + return (obj || new ForecastPeriodSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsForecastPeriodSummary(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new ForecastPeriodSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + startTime() { + const builder = this.bb.__offset(this.bb_pos, 4); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + endTime() { + const builder = this.bb.__offset(this.bb_pos, 6); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + condition() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startForecastPeriodSummary(builder) { + builder.startObject(5); + } + static addStartTime(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addEndTime(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addCondition(builder, value) { + builder.addFieldInt8(2, value, PrecipitationType.CLEAR); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(4, value, 0); + } + static endForecastPeriodSummary(builder) { + return builder.endObject(); + } + static createForecastPeriodSummary(builder, e, i, a, s, n) { + return ( + ForecastPeriodSummary.startForecastPeriodSummary(builder), + ForecastPeriodSummary.addStartTime(builder, e), + ForecastPeriodSummary.addEndTime(builder, i), + ForecastPeriodSummary.addCondition(builder, a), + ForecastPeriodSummary.addPrecipitationChance(builder, s), + ForecastPeriodSummary.addPrecipitationIntensity(builder, n), + ForecastPeriodSummary.endForecastPeriodSummary(builder) + ); + } +} + +// HistoricalComparison +export class HistoricalComparison { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsHistoricalComparison(bb, obj) { + return (obj || new HistoricalComparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsHistoricalComparison(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new HistoricalComparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + comparisons(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Comparison()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + comparisonsLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startHistoricalComparison(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addComparisons(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createComparisonsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startComparisonsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endHistoricalComparison(builder) { + return builder.endObject(); + } + static createHistoricalComparison(builder, e, i) { + return HistoricalComparison.startHistoricalComparison(builder), HistoricalComparison.addMetadata(builder, e), HistoricalComparison.addComparisons(builder, i), HistoricalComparison.endHistoricalComparison(builder); + } +} + +// HourWeatherConditions +export class HourWeatherConditions { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsHourWeatherConditions(bb, obj) { + return (obj || new HourWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsHourWeatherConditions(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new HourWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + cloudCover() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverLowAltPct() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverMidAltPct() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverHighAltPct() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + daylight() { + const t = this.bb.__offset(this.bb_pos, 16); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + humidity() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + pressure() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + pressureTrend() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readInt8(this.bb_pos + t) : PressureTrend.RISING; + } + snowfallAmount() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallIntensity() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperature() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureApparent() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + unknown20() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureDewPoint() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + uvIndex() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + visibility() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windDirection() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + windGust() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeed() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startHourWeatherConditions(builder) { + builder.startObject(26); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addCloudCover(builder, value) { + builder.addFieldInt8(1, value, 0); + } + static addCloudCoverLowAltPct(builder, value) { + builder.addFieldInt8(2, value, 0); + } + static addCloudCoverMidAltPct(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addCloudCoverHighAltPct(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(5, value, WeatherCondition.CLEAR); + } + static addDaylight(builder, value) { + builder.addFieldInt8(6, +value, 0); + } + static addHumidity(builder, value) { + builder.addFieldInt8(7, value, 0); + } + static addPerceivedPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(8, value, 0); + } + static addPrecipitationAmount(builder, value) { + builder.addFieldFloat32(9, value, 0); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(10, value, 0); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(11, value, 0); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(12, value, PrecipitationType.CLEAR); + } + static addPressure(builder, value) { + builder.addFieldFloat32(13, value, 0); + } + static addPressureTrend(builder, value) { + builder.addFieldInt8(14, value, PressureTrend.RISING); + } + static addSnowfallAmount(builder, value) { + builder.addFieldFloat32(15, value, 0); + } + static addSnowfallIntensity(builder, value) { + builder.addFieldFloat32(16, value, 0); + } + static addTemperature(builder, value) { + builder.addFieldFloat32(17, value, 0); + } + static addTemperatureApparent(builder, value) { + builder.addFieldFloat32(18, value, 0); + } + static addUnknown20(builder, value) { + builder.addFieldFloat32(19, value, 0); + } + static addTemperatureDewPoint(builder, value) { + builder.addFieldFloat32(20, value, 0); + } + static addUvIndex(builder, value) { + builder.addFieldInt8(21, value, 0); + } + static addVisibility(builder, value) { + builder.addFieldFloat32(22, value, 0); + } + static addWindDirection(builder, value) { + builder.addFieldInt16(23, value, 0); + } + static addWindGust(builder, value) { + builder.addFieldFloat32(24, value, 0); + } + static addWindSpeed(builder, value) { + builder.addFieldFloat32(25, value, 0); + } + static endHourWeatherConditions(builder) { + return builder.endObject(); + } + static createHourWeatherConditions(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v) { + return ( + HourWeatherConditions.startHourWeatherConditions(builder), + HourWeatherConditions.addForecastStart(builder, e), + HourWeatherConditions.addCloudCover(builder, i), + HourWeatherConditions.addCloudCoverLowAltPct(builder, a), + HourWeatherConditions.addCloudCoverMidAltPct(builder, s), + HourWeatherConditions.addCloudCoverHighAltPct(builder, n), + HourWeatherConditions.addConditionCode(builder, r), + HourWeatherConditions.addDaylight(builder, o), + HourWeatherConditions.addHumidity(builder, d), + HourWeatherConditions.addPerceivedPrecipitationIntensity(builder, c), + HourWeatherConditions.addPrecipitationAmount(builder, l), + HourWeatherConditions.addPrecipitationIntensity(builder, u), + HourWeatherConditions.addPrecipitationChance(builder, p), + HourWeatherConditions.addPrecipitationType(builder, b), + HourWeatherConditions.addPressure(builder, h), + HourWeatherConditions.addPressureTrend(builder, m), + HourWeatherConditions.addSnowfallAmount(builder, _), + HourWeatherConditions.addSnowfallIntensity(builder, y), + HourWeatherConditions.addTemperature(builder, f), + HourWeatherConditions.addTemperatureApparent(builder, g), + HourWeatherConditions.addUnknown20(builder, N), + HourWeatherConditions.addTemperatureDewPoint(builder, C), + HourWeatherConditions.addUvIndex(builder, x), + HourWeatherConditions.addVisibility(builder, A), + HourWeatherConditions.addWindDirection(builder, I), + HourWeatherConditions.addWindGust(builder, T), + HourWeatherConditions.addWindSpeed(builder, v), + HourWeatherConditions.endHourWeatherConditions(builder) + ); + } +} + +// HourlyForecastData +export class HourlyForecastData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsHourlyForecastData(bb, obj) { + return (obj || new HourlyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsHourlyForecastData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new HourlyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + hours(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new HourWeatherConditions()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + hoursLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startHourlyForecastData(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addHours(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createHoursVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startHoursVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endHourlyForecastData(builder) { + return builder.endObject(); + } + static createHourlyForecastData(builder, e, i) { + return HourlyForecastData.startHourlyForecastData(builder), HourlyForecastData.addMetadata(builder, e), HourlyForecastData.addHours(builder, i), HourlyForecastData.endHourlyForecastData(builder); + } +} + +// LocationInfo +export class LocationInfo { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsLocationInfo(bb, obj) { + return (obj || new LocationInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsLocationInfo(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new LocationInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + preciseName(encoding) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + countryCode(encoding) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + timeZone(encoding) { + const e = this.bb.__offset(this.bb_pos, 10); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + primaryName(encoding) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + static startLocationInfo(builder) { + builder.startObject(5); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addPreciseName(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addCountryCode(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addTimeZone(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static addPrimaryName(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static endLocationInfo(builder) { + return builder.endObject(); + } + static createLocationInfo(builder, e, i, a, s, n) { + return LocationInfo.startLocationInfo(builder), LocationInfo.addMetadata(builder, e), LocationInfo.addPreciseName(builder, i), LocationInfo.addCountryCode(builder, a), LocationInfo.addTimeZone(builder, s), LocationInfo.addPrimaryName(builder, n), LocationInfo.endLocationInfo(builder); + } +} + +// Placement +export class Placement { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsPlacement(bb, obj) { + return (obj || new Placement()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsPlacement(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Placement()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + priority() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + articles(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Articles()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + articlesLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + placement() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : PlacementType.UNKNOWN0; + } + static startPlacement(builder) { + builder.startObject(3); + } + static addPriority(builder, value) { + builder.addFieldInt8(0, value, 0); + } + static addArticles(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createArticlesVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startArticlesVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPlacement(builder, value) { + builder.addFieldInt8(2, value, PlacementType.UNKNOWN0); + } + static endPlacement(builder) { + return builder.endObject(); + } + static createPlacement(builder, e, i, a) { + return Placement.startPlacement(builder), Placement.addPriority(builder, e), Placement.addArticles(builder, i), Placement.addPlacement(builder, a), Placement.endPlacement(builder); + } +} + +// News +export class News { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsNews(bb, obj) { + return (obj || new News()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsNews(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new News()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + placements(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Placement()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + placementsLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startNews(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addPlacements(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createPlacementsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPlacementsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endNews(builder) { + return builder.endObject(); + } + static createNews(builder, e, i) { + return News.startNews(builder), News.addMetadata(builder, e), News.addPlacements(builder, i), News.endNews(builder); + } +} + +// NextHourForecastData +export class NextHourForecastData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsNextHourForecastData(bb, obj) { + return (obj || new NextHourForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsNextHourForecastData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new NextHourForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + condition(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Condition()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + conditionLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + summary(index, optional) { + const i = this.bb.__offset(this.bb_pos, 8); + return i ? (optional || new ForecastPeriodSummary()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + summaryLength() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + minutes(index, optional) { + const i = this.bb.__offset(this.bb_pos, 14); + return i ? (optional || new ForecastMinute()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + minutesLength() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startNextHourForecastData(builder) { + builder.startObject(6); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addCondition(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createConditionVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startConditionVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addSummary(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static createSummaryVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startSummaryVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(3, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(4, value, 0); + } + static addMinutes(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static createMinutesVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startMinutesVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endNextHourForecastData(builder) { + return builder.endObject(); + } + static createNextHourForecastData(builder, e, i, a, s, n, r) { + return ( + NextHourForecastData.startNextHourForecastData(builder), + NextHourForecastData.addMetadata(builder, e), + NextHourForecastData.addCondition(builder, i), + NextHourForecastData.addSummary(builder, a), + NextHourForecastData.addForecastStart(builder, s), + NextHourForecastData.addForecastEnd(builder, n), + NextHourForecastData.addMinutes(builder, r), + NextHourForecastData.endNextHourForecastData(builder) + ); + } +} + +// WeatherAlertSummary +export class WeatherAlertSummary { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeatherAlertSummary(bb, obj) { + return (obj || new WeatherAlertSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeatherAlertSummary(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new WeatherAlertSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + id(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new UUID()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + areaId(encoding) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + areaName(encoding) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + attributionUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 10); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + countryCode(encoding) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + description(encoding) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + token(encoding) { + const e = this.bb.__offset(this.bb_pos, 16); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + effectiveTime() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + expireTime() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + issuedTime() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + eventOnsetTime() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + eventEndTime() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + detailsUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 28); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + phenomenon(encoding) { + const e = this.bb.__offset(this.bb_pos, 30); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + severity() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readInt8(this.bb_pos + t) : Severity.UNKNOWN; + } + significance() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readInt8(this.bb_pos + t) : SignificanceType.UNKNOWN; + } + source(encoding) { + const e = this.bb.__offset(this.bb_pos, 36); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + eventSource(encoding) { + const e = this.bb.__offset(this.bb_pos, 38); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + urgency() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readInt8(this.bb_pos + t) : Urgency.UNKNOWN; + } + certainty() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readInt8(this.bb_pos + t) : Certainty.UNKNOWN; + } + importance() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readInt8(this.bb_pos + t) : ImportanceType.NORMAL; + } + responses(encoding) { + const e = this.bb.__offset(this.bb_pos, 46); + return e ? this.bb.readInt8(this.bb.__vector(this.bb_pos + e) + encoding) : 0; + } + responsesLength() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + responsesArray() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? new Int8Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + t), this.bb.__vector_len(this.bb_pos + t)) : null; + } + unknown23() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + unknown24() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + unknown25() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + unknown26() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + static startWeatherAlertSummary(builder) { + builder.startObject(26); + } + static addId(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addAreaId(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addAreaName(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addAttributionUrl(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static addCountryCode(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static addDescription(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static addToken(builder, value) { + builder.addFieldOffset(6, value, 0); + } + static addEffectiveTime(builder, value) { + builder.addFieldInt32(7, value, 0); + } + static addExpireTime(builder, value) { + builder.addFieldInt32(8, value, 0); + } + static addIssuedTime(builder, value) { + builder.addFieldInt32(9, value, 0); + } + static addEventOnsetTime(builder, value) { + builder.addFieldInt32(10, value, 0); + } + static addEventEndTime(builder, value) { + builder.addFieldInt32(11, value, 0); + } + static addDetailsUrl(builder, value) { + builder.addFieldOffset(12, value, 0); + } + static addPhenomenon(builder, value) { + builder.addFieldOffset(13, value, 0); + } + static addSeverity(builder, value) { + builder.addFieldInt8(14, value, Severity.UNKNOWN); + } + static addSignificance(builder, value) { + builder.addFieldInt8(15, value, SignificanceType.UNKNOWN); + } + static addSource(builder, value) { + builder.addFieldOffset(16, value, 0); + } + static addEventSource(builder, value) { + builder.addFieldOffset(17, value, 0); + } + static addUrgency(builder, value) { + builder.addFieldInt8(18, value, Urgency.UNKNOWN); + } + static addCertainty(builder, value) { + builder.addFieldInt8(19, value, Certainty.UNKNOWN); + } + static addImportance(builder, value) { + builder.addFieldInt8(20, value, ImportanceType.NORMAL); + } + static addResponses(builder, value) { + builder.addFieldOffset(21, value, 0); + } + static createResponsesVector(builder, data) { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) builder.addInt8(data[i]); + return builder.endVector(); + } + static startResponsesVector(builder, numElems) { + builder.startVector(1, numElems, 1); + } + static addUnknown23(builder, value) { + builder.addFieldInt8(22, value, 0); + } + static addUnknown24(builder, value) { + builder.addFieldInt8(23, value, 0); + } + static addUnknown25(builder, value) { + builder.addFieldInt8(24, value, 0); + } + static addUnknown26(builder, value) { + builder.addFieldInt8(25, value, 0); + } + static endWeatherAlertSummary(builder) { + return builder.endObject(); + } + static createWeatherAlertSummary(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v) { + return ( + WeatherAlertSummary.startWeatherAlertSummary(builder), + WeatherAlertSummary.addId(builder, e), + WeatherAlertSummary.addAreaId(builder, i), + WeatherAlertSummary.addAreaName(builder, a), + WeatherAlertSummary.addAttributionUrl(builder, s), + WeatherAlertSummary.addCountryCode(builder, n), + WeatherAlertSummary.addDescription(builder, r), + WeatherAlertSummary.addToken(builder, o), + WeatherAlertSummary.addEffectiveTime(builder, d), + WeatherAlertSummary.addExpireTime(builder, c), + WeatherAlertSummary.addIssuedTime(builder, l), + WeatherAlertSummary.addEventOnsetTime(builder, u), + WeatherAlertSummary.addEventEndTime(builder, p), + WeatherAlertSummary.addDetailsUrl(builder, b), + WeatherAlertSummary.addPhenomenon(builder, h), + WeatherAlertSummary.addSeverity(builder, m), + WeatherAlertSummary.addSignificance(builder, _), + WeatherAlertSummary.addSource(builder, y), + WeatherAlertSummary.addEventSource(builder, f), + WeatherAlertSummary.addUrgency(builder, g), + WeatherAlertSummary.addCertainty(builder, N), + WeatherAlertSummary.addImportance(builder, C), + WeatherAlertSummary.addResponses(builder, x), + WeatherAlertSummary.addUnknown23(builder, A), + WeatherAlertSummary.addUnknown24(builder, I), + WeatherAlertSummary.addUnknown25(builder, T), + WeatherAlertSummary.addUnknown26(builder, v), + WeatherAlertSummary.endWeatherAlertSummary(builder) + ); + } +} + +// WeatherAlertCollectionData +export class WeatherAlertCollectionData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeatherAlertCollectionData(bb, obj) { + return (obj || new WeatherAlertCollectionData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeatherAlertCollectionData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new WeatherAlertCollectionData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + detailsUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + alerts(index, optional) { + const i = this.bb.__offset(this.bb_pos, 8); + return i ? (optional || new WeatherAlertSummary()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + alertsLength() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startWeatherAlertCollectionData(builder) { + builder.startObject(3); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addDetailsUrl(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addAlerts(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static createAlertsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startAlertsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endWeatherAlertCollectionData(builder) { + return builder.endObject(); + } + static createWeatherAlertCollectionData(builder, e, i, a) { + return WeatherAlertCollectionData.startWeatherAlertCollectionData(builder), WeatherAlertCollectionData.addMetadata(builder, e), WeatherAlertCollectionData.addDetailsUrl(builder, i), WeatherAlertCollectionData.addAlerts(builder, a), WeatherAlertCollectionData.endWeatherAlertCollectionData(builder); + } +} + +// WeatherChanges +export class WeatherChanges { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeatherChanges(bb, obj) { + return (obj || new WeatherChanges()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeatherChanges(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new WeatherChanges()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + changes(index, optional) { + const i = this.bb.__offset(this.bb_pos, 10); + return i ? (optional || new Change()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + changesLength() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startWeatherChanges(builder) { + builder.startObject(4); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(2, value, 0); + } + static addChanges(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static createChangesVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startChangesVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endWeatherChanges(builder) { + return builder.endObject(); + } + static createWeatherChanges(builder, e, i, a, s) { + return WeatherChanges.startWeatherChanges(builder), WeatherChanges.addMetadata(builder, e), WeatherChanges.addForecastStart(builder, i), WeatherChanges.addForecastEnd(builder, a), WeatherChanges.addChanges(builder, s), WeatherChanges.endWeatherChanges(builder); + } +} + +// Weather +export class Weather { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeather(bb, obj) { + return (obj || new Weather()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeather(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Weather()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + airQuality(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new AirQuality()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + currentWeather(optional) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? (optional || new CurrentWeatherData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastDaily(optional) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? (optional || new DailyForecastData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastHourly(optional) { + const e = this.bb.__offset(this.bb_pos, 10); + return e ? (optional || new HourlyForecastData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastNextHour(optional) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? (optional || new NextHourForecastData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + news(optional) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? (optional || new News()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + weatherAlerts(optional) { + const e = this.bb.__offset(this.bb_pos, 16); + return e ? (optional || new WeatherAlertCollectionData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + weatherChanges(optional) { + const e = this.bb.__offset(this.bb_pos, 18); + return e ? (optional || new WeatherChanges()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + historicalComparisons(optional) { + const e = this.bb.__offset(this.bb_pos, 20); + return e ? (optional || new HistoricalComparison()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + locationInfo(optional) { + const e = this.bb.__offset(this.bb_pos, 22); + return e ? (optional || new LocationInfo()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + static startWeather(builder) { + builder.startObject(10); + } + static addAirQuality(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addCurrentWeather(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addForecastDaily(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addForecastHourly(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static addForecastNextHour(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static addNews(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static addWeatherAlerts(builder, value) { + builder.addFieldOffset(6, value, 0); + } + static addWeatherChanges(builder, value) { + builder.addFieldOffset(7, value, 0); + } + static addHistoricalComparisons(builder, value) { + builder.addFieldOffset(8, value, 0); + } + static addLocationInfo(builder, value) { + builder.addFieldOffset(9, value, 0); + } + static endWeather(builder) { + return builder.endObject(); + } + static finishWeatherBuffer(builder, table) { + builder.finish(table); + } + static finishSizePrefixedWeatherBuffer(builder, table) { + builder.finish(table, void 0, !0); + } +} + +// ============================================================ +// Module exports +// ============================================================ + +export default { + StringEncodingType, + ComparisonTrend, + SourceType, + PollutantType, + UnitType, + ComparisonType, + Deviation, + Direction, + ComparisonType2, + Deviation2, + ConditionType, + ForecastToken, + ParameterType, + PrecipitationType, + PressureTrend, + WeatherCondition, + MoonPhase, + PlacementType, + ResponseType, + Severity, + SignificanceType, + Urgency, + ByteBuffer, + Builder, + Metadata, + Pollutant, + AirQuality, + UUID, + Articles, + Change, + Comparison, + Parameter, + Condition, + PrecipitationAmountByType, + CurrentWeatherData, + DayPartForecast, + DayWeatherConditions, + DailyForecastData, + ForecastMinute, + ForecastPeriodSummary, + HistoricalComparison, + HourWeatherConditions, + HourlyForecastData, + LocationInfo, + Placement, + News, + NextHourForecastData, + WeatherAlertSummary, + WeatherAlertCollectionData, + WeatherChanges, + Weather, +}; diff --git a/src/wk3/routes.mjs b/src/wk3/routes.mjs new file mode 100644 index 00000000..e22ea34d --- /dev/null +++ b/src/wk3/routes.mjs @@ -0,0 +1,8 @@ +// Shared route definitions; each module generator renders its native syntax. +export const groups = [ + { name: 'availability.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/`, stage: 'response', body: true }, + { name: 'airQualityScale.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/`, stage: 'request' }, + { name: 'weatherAlerts.html.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)`, stage: 'request', echo: true }, + { name: 'weatherAlerts.api.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)`, stage: 'request', echo: true }, + { name: 'weather.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/`, stage: 'response', body: true, binary: true }, +]; diff --git a/src/wk3/runtime.mjs b/src/wk3/runtime.mjs new file mode 100644 index 00000000..27e7028a --- /dev/null +++ b/src/wk3/runtime.mjs @@ -0,0 +1,69 @@ +// Shared HTTP/header adapter for normalized platform contexts. +// Each bundled invocation owns its runtime; client APIs are supplied by the platform entry. +export function headerObject(headers = {}) { + const result = {}; + const add = (name, value) => { + if (value !== undefined && value !== null) result[String(name).toLowerCase()] = value; + }; + if (typeof headers.forEach === "function") headers.forEach((value, name) => add(name, value)); + else if (typeof headers.entries === "function") for (const [name, value] of headers.entries()) add(name, value); + else for (const [name, value] of Object.entries(headers)) if (typeof value !== "function") add(name, value); + // Egern documents get/property access, but does not promise iteration. + for (const name of ["content-type", "content-encoding", "etag", "user-agent", "x-schema-version"]) { + add(name, typeof headers.get === "function" ? headers.get(name) : headers[name]); + } + return result; +} + +export function responseHeaders(original = {}, updates = {}) { + const removable = new Set(["content-length", "transfer-encoding", "content-encoding", "etag", "content-md5"]); + if (typeof original.set === "function" && typeof original.delete === "function") { + for (const [name, value] of Object.entries(updates)) { + if (!removable.has(name.toLowerCase()) && value !== undefined) original.set(name, value); + } + for (const name of removable) original.delete(name); + return original; + } + const headers = { ...headerObject(original), ...headerObject(updates) }; + for (const name of removable) delete headers[name]; + return headers; +} + +export function createRuntime(ctx) { + const configuredTimeout = Number(ctx.env?.HTTP_TIMEOUT_MS); + const defaultTimeout = configuredTimeout > 0 ? configuredTimeout : 8000; + async function send(resource, options = {}, legacy = false) { + const request = typeof resource === "string" || resource instanceof URL + ? { url: String(resource), ...options } : { ...options, ...resource }; + const method = (request.method || (request.body !== undefined || request.bodyBytes !== undefined ? "POST" : "GET")).toLowerCase(); + const headers = headerObject(request.headers); + for (const name of ["host", ":authority", "content-length"]) delete headers[name]; + let timeout = Number(request.timeout) || defaultTimeout; + if (legacy && request.timeout && timeout <= 500) timeout *= 1000; + const nativeOptions = { headers, timeout, credentials: request.credentials ?? "omit" }; + for (const name of ["policy", "policyDescriptor", "redirect", "insecureTls"]) { + if (request[name] !== undefined) nativeOptions[name] = request[name]; + } + if (method !== "get" && method !== "head") { + const body = request.bodyBytes ?? request.body; + if (body !== undefined && body !== null) nativeOptions.body = body instanceof ArrayBuffer ? new Uint8Array(body) : body; + } + if (typeof ctx.http?.[method] !== "function") throw new TypeError(`Unsupported HTTP method: ${method}`); + return ctx.http[method](request.url, nativeOptions); + } + return { + webFetch: (resource, options) => send(resource, options), + async fetch(resource, options) { + const response = await send(resource, options, true); + const headers = headerObject(response.headers); + const status = response.status; + const binary = /flatbuffer|protobuf|octet-stream/i.test(headers["content-type"] ?? ""); + return { + ok: status >= 200 && status < 300, status, statusCode: status, + statusText: response.statusText ?? "", headers, + body: binary ? "" : await response.text(), + bodyBytes: binary ? await response.arrayBuffer() : undefined, + }; + }, + }; +} diff --git a/src/wk3/utils/Console.mjs b/src/wk3/utils/Console.mjs new file mode 100644 index 00000000..8895251a --- /dev/null +++ b/src/wk3/utils/Console.mjs @@ -0,0 +1,60 @@ +const LEVELS = { OFF: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4, ALL: 5 }; +let level = LEVELS.INFO; + +export function setLogEnv(env = {}) { + let settings = {}; + try { settings = typeof env.SETTINGS === "string" ? JSON.parse(env.SETTINGS) : env.SETTINGS; } catch {} + const name = String(env.LogLevel ?? settings?.LogLevel ?? "INFO").toUpperCase(); + level = Object.hasOwn(LEVELS, name) ? LEVELS[name] : LEVELS.INFO; +} + +const sensitive = key => /token|api[_-]?key|authorization|password/i.test(key) || /^key$/i.test(key); +const redactText = text => text + .replace(/("[^"]*(?:token|api[_-]?key|authorization|password)[^"]*"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\]\s]+)/gi, '$1"[REDACTED]"') + .replace(/([?&](?:token|key|api[_-]?key|authorization|password)=)[^&#\s"']*/gi, "$1[REDACTED]"); + +function redact(value, seen = new WeakSet()) { + if (typeof value === "string") { + if (/^\s*[\[{]/.test(value)) { + try { return JSON.stringify(redact(JSON.parse(value), seen), null, 2); } catch {} + } + return redactText(value); + } + if (!value || typeof value !== "object") return value; + if (value instanceof Error || Object.prototype.toString.call(value) === "[object Error]") return redactText(value.stack || value.message); + if (seen.has(value)) return "[Circular]"; + seen.add(value); + const result = Array.isArray(value) ? value.map(item => redact(item, seen)) + : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive(key) ? "[REDACTED]" : redact(item, seen)])); + seen.delete(value); + return result; +} + +function format(messages) { + // Settings includes private provider configuration; keep it out of process logs. + if (messages[0] === "[buildSettings] Settings:") return [messages[0], "[REDACTED]"]; + return messages.map(message => { + const value = redact(message); + return value && typeof value === "object" ? JSON.stringify(value, null, 2) : value; + }); +} + +export const Console = Object.fromEntries( + [["debug", 4], ["info", 3], ["warn", 2], ["error", 1], ["log", 1], ["exception", 1]].map(([method, threshold]) => [method, (...messages) => { + if (level < threshold) return; + const output = console.log ?? console[method === "exception" ? "error" : method]; + if (typeof output === "function") output.call(console, format(messages).join(" ")); + }]), +); + +export async function logExecution(handler, ctx, stage) { + Console.info(`☑️ ${stage}`); + try { + return await handler(ctx); + } catch (error) { + Console.error(`${stage}:`, error); + throw error; + } finally { + Console.info(`✅ ${stage}`); + } +} diff --git a/src/wk3/utils/context.mjs b/src/wk3/utils/context.mjs new file mode 100644 index 00000000..8cbfe0c9 --- /dev/null +++ b/src/wk3/utils/context.mjs @@ -0,0 +1,3 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export const requestContext = new AsyncLocalStorage(); diff --git a/src/wk3/utils/fetch.mjs b/src/wk3/utils/fetch.mjs new file mode 100644 index 00000000..64a509a3 --- /dev/null +++ b/src/wk3/utils/fetch.mjs @@ -0,0 +1,147 @@ +import { requestContext } from "./context.mjs"; + +export async function fetch(resource, options = {}) { + if (typeof resource === "string") { + resource = { url: resource, ...options }; + } else { + resource = { ...options, ...resource }; + } + if (!resource.method) { + resource.method = "GET"; + if (resource.body || resource.bodyBytes) { + resource.method = "POST"; + } + } + const { url, headers, method, body, bodyBytes, timeout } = resource; + const fetchOptions = { + method, + headers: headers ? { ...headers } : {}, + }; + const methodUpper = (method || "GET").toUpperCase(); + if (methodUpper !== "GET" && methodUpper !== "HEAD") { + if (bodyBytes) { + fetchOptions.body = bodyBytes; + } else if (body) { + fetchOptions.body = body; + } + } + // 移除不必要的 header + delete fetchOptions.headers?.Host; + delete fetchOptions.headers?.[":authority"]; + delete fetchOptions.headers?.["Content-Length"]; + delete fetchOptions.headers?.["content-length"]; + + let timeoutId; + if (timeout) { + const controller = new AbortController(); + fetchOptions.signal = controller.signal; + const ms = timeout > 500 ? timeout : timeout * 1000; + timeoutId = setTimeout(() => controller.abort(), ms); + } + + const isCF = typeof globalThis.caches !== "undefined" && typeof globalThis.caches.default !== "undefined"; + const isGet = methodUpper === "GET" || methodUpper === "HEAD"; + const isThirdPartyWeather = isGet && (url.includes("api.caiyunapp.com") || url.includes("qweather.com") || url.includes("qweather")); + + let cacheKey; + let cache; + + const store = requestContext.getStore(); + const settings = store && typeof store === "object" && "Settings" in store ? store.Settings : undefined; + const isEdgeCacheEnabled = settings?.EdgeCache === true; + + if (isCF && isThirdPartyWeather && isEdgeCacheEnabled) { + try { + cache = globalThis.caches.default; + cacheKey = new Request(url, { method: "GET" }); + const cachedResponse = await cache.match(cacheKey); + if (cachedResponse) { + const responseHeaders = {}; + cachedResponse.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const cachedContentType = cachedResponse.headers.get("content-type") || ""; + const isBinary = cachedContentType.includes("flatbuffer") || cachedContentType.includes("protobuf") || cachedContentType.includes("octet-stream"); + + let cachedBody = ""; + let cachedBodyBytes = null; + if (isBinary) { + cachedBodyBytes = await cachedResponse.arrayBuffer(); + } else { + cachedBody = await cachedResponse.text(); + } + + if (timeoutId) clearTimeout(timeoutId); + return { + ok: cachedResponse.ok, + status: cachedResponse.status, + statusCode: cachedResponse.status, + statusText: cachedResponse.statusText, + body: cachedBody, + bodyBytes: cachedBodyBytes, + headers: { ...responseHeaders, "x-ir-cache": "HIT" }, + }; + } + } catch (cacheErr) { + console.error("Cache match error:", cacheErr); + } + } + + try { + const response = await globalThis.fetch(url, fetchOptions); + const responseHeaders = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const contentType = response.headers.get("content-type") || ""; + const isBinary = contentType.includes("flatbuffer") || contentType.includes("protobuf") || contentType.includes("octet-stream"); + + let responseBody = ""; + let responseBodyBytes = null; + + if (isBinary) { + responseBodyBytes = await response.arrayBuffer(); + } else { + responseBody = await response.text(); + } + + const result = { + ok: response.ok, + status: response.status, + statusCode: response.status, + statusText: response.statusText, + body: responseBody, + bodyBytes: responseBodyBytes, + headers: { ...responseHeaders, "x-ir-cache": "MISS" }, + }; + + if (cache && cacheKey && response.ok) { + try { + const cacheBody = isBinary ? responseBodyBytes : responseBody; + const cfResponse = new globalThis.Response(cacheBody, { + status: response.status, + statusText: response.statusText, + headers: { + ...responseHeaders, + "Cache-Control": "public, max-age=180", + }, + }); + const cachePromise = cache.put(cacheKey, cfResponse).catch(putErr => console.error("Cache put error:", putErr)); + const ctx = store?.executionCtx; + if (ctx && typeof ctx.waitUntil === "function") { + ctx.waitUntil(cachePromise); + } + } catch (putErr) { + console.error("Cache put error:", putErr); + } + } + + return result; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} diff --git a/src/wk3/utils/index.mjs b/src/wk3/utils/index.mjs new file mode 100644 index 00000000..f6b25259 --- /dev/null +++ b/src/wk3/utils/index.mjs @@ -0,0 +1,4 @@ +export * from "./Console.mjs"; +export * from "./context.mjs"; +export * from "./fetch.mjs"; +export * from "./object.mjs"; diff --git a/src/wk3/utils/object.mjs b/src/wk3/utils/object.mjs new file mode 100644 index 00000000..7d20f943 --- /dev/null +++ b/src/wk3/utils/object.mjs @@ -0,0 +1,50 @@ +// 按点路径(如 "a.b.c" 或 ["a","b","c"])拆分为数组 +function toPath(value) { + if (Array.isArray(value)) return value; + return value.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean); +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} + +// 深度合并多个对象到 target(数组做浅拷贝,避免共享引用) +export function merge(target, ...sources) { + if (target === null || target === undefined) return target; + for (const source of sources) { + if (source === null || source === undefined) continue; + for (const key of Object.keys(source)) { + const src = source[key]; + const dst = target[key]; + if (isPlainObject(src)) { + target[key] = merge(isPlainObject(dst) ? dst : {}, src); + } else if (Array.isArray(src)) { + target[key] = src.length > 0 ? [...src] : (dst !== undefined ? dst : []); + } else if (src !== undefined) { + target[key] = src; + } + } + } + return target; +} + +// 按点路径读取对象属性 +export function get(object, path, defaultValue) { + const keys = toPath(path); + const result = keys.reduce((acc, key) => Object(acc)[key], object); + return result === undefined ? defaultValue : result; +} + +// 按点路径写入对象属性 +export function set(object, path, value) { + const keys = toPath(path); + keys.slice(0, -1).reduce((acc, key, i) => { + if (Object(acc[key]) !== acc[key]) { + acc[key] = /^\d+$/.test(keys[i + 1]) ? [] : {}; + } + return acc[key]; + }, object)[keys[keys.length - 1]] = value; + return object; +} diff --git a/template/egern.wk3.handlebars b/template/egern.wk3.handlebars new file mode 100644 index 00000000..25703db8 --- /dev/null +++ b/template/egern.wk3.handlebars @@ -0,0 +1,98 @@ +name: 'WeatherKit-WK3' +description: '空气质量、原生预警与雨雪分钟增强。' +author: WeatherKit-WK3 contributors +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +rules: +- and: + match: + - domain_suffix: + match: weatherkit.apple.com + - protocol: + match: QUIC + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - ip_cidr: + match: 139.178.128.0/18 + no_resolve: true + - ip_cidr: + match: 144.178.0.0/19 + no_resolve: true + - ip_cidr: + match: 144.178.36.0/22 + no_resolve: true + - ip_cidr: + match: 144.178.48.0/20 + no_resolve: true + - ip_cidr: + match: 17.0.0.0/8 + no_resolve: true + - ip_cidr: + match: 192.35.50.0/24 + no_resolve: true + - ip_cidr: + match: 198.183.17.0/24 + no_resolve: true + - ip_cidr: + match: 205.180.175.0/24 + no_resolve: true + - ip_cidr6: + match: 2403:300::/32 + no_resolve: true + - ip_cidr6: + match: 2620:149::/32 + no_resolve: true + - ip_cidr6: + match: 2a01:b740::/32 + no_resolve: true + - ip_cidr: + match: 63.92.224.0/19 + no_resolve: true + - ip_cidr: + match: 65.199.22.0/23 + no_resolve: true + - protocol: + match: UDP + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - asn: + match: '714' + no_resolve: true + - asn: + match: '6185' + no_resolve: true + - protocol: + match: QUIC + policy: REJECT-DROP +- domain_suffix: + match: weatherkit.apple.com + policy: DIRECT +- domain: + match: weather-analytics-events.apple.com + policy: REJECT-DROP +- domain_suffix: + match: tthr.apple.com + policy: REJECT-DROP +- domain: + match: tether.edge.apple + policy: REJECT-DROP +compat_arguments: __WK3_COMPAT_ARGUMENTS__ +compat_arguments_desc: __WK3_ARGUMENT_DESCRIPTION__ +env_schema: __WK3_ENV_SCHEMA__ +scriptings: +__WK3_SCRIPTINGS__ + +mitm: + hostnames: + includes: + - weatherkit.apple.com diff --git a/template/wk3.loon.handlebars b/template/wk3.loon.handlebars new file mode 100644 index 00000000..a815a421 --- /dev/null +++ b/template/wk3.loon.handlebars @@ -0,0 +1,20 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = __WK3_VERSION__ + +[Argument] +__WK3_ARGUMENTS__ + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP +DOMAIN,tether.edge.apple,REJECT-DROP +AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP + +[Script] +__WK3_SCRIPTS__ + +[MITM] +hostname = weatherkit.apple.com diff --git a/template/wk3.quantumultx.handlebars b/template/wk3.quantumultx.handlebars new file mode 100644 index 00000000..5bd517c5 --- /dev/null +++ b/template/wk3.quantumultx.handlebars @@ -0,0 +1,17 @@ +#!name = WeatherKit-Local-WK3 +#!desc = WK3 请求与文本处理;二进制天气透传。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = __WK3_VERSION__ +# QuantumultX 本地脚本不传模块参数;使用与其他 WK3 模块相同的默认值,二进制响应仍透传。 + +#[filter_local] +host, weather-analytics-events.apple.com, reject +host-suffix, tthr.apple.com, reject +host, tether.edge.apple, reject + +#[rewrite_local] +__WK3_SCRIPTS__ + +#[mitm] +hostname = weatherkit.apple.com diff --git a/template/wk3.stash.handlebars b/template/wk3.stash.handlebars new file mode 100644 index 00000000..66010ee0 --- /dev/null +++ b/template/wk3.stash.handlebars @@ -0,0 +1,19 @@ +name: WeatherKit-Local-WK3 +desc: 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +version: "__WK3_VERSION__" +rules: + - DOMAIN,weather-analytics-events.apple.com,REJECT-DROP + - DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP + - DOMAIN,tether.edge.apple,REJECT-DROP + - AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP +# Stash 无插件参数选择面板;编辑首个 argument 的 JSON,YAML 锚点会将设置共享到全部五组脚本。 +__WK3_ARGUMENT_COMMENTS__ +http: + mitm: + - weatherkit.apple.com + script: +__WK3_SCRIPTS__ +script-providers: +__WK3_PROVIDERS__ diff --git a/template/wk3.surge.handlebars b/template/wk3.surge.handlebars new file mode 100644 index 00000000..c81433e4 --- /dev/null +++ b/template/wk3.surge.handlebars @@ -0,0 +1,19 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = __WK3_VERSION__ +#!arguments = __WK3_ARGUMENTS__ +#!arguments-desc = __WK3_ARGUMENT_DESC__ + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP,extended-matching +DOMAIN,tether.edge.apple,REJECT-DROP,extended-matching +AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP + +[Script] +__WK3_SCRIPTS__ + +[MITM] +hostname = %APPEND% weatherkit.apple.com diff --git a/tests/legacy-aq-settings.test.mjs b/tests/legacy-aq-settings.test.mjs new file mode 100644 index 00000000..521f7fea --- /dev/null +++ b/tests/legacy-aq-settings.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import SimplePrecisionMath from "../src/class/SimplePrecisionMath.mjs"; + +// Execute the actual legacy functions and AQ algorithms without the unavailable +// private @nsnanocat/util package. Only logging and provider I/O are substituted. +const Console = Object.fromEntries(["info", "debug", "error"].map(name => [name, () => {}])); +const aqSource = await readFile(new URL("../src/class/AirQuality.mjs", import.meta.url), "utf8"); +const AirQuality = new Function("Console", "SimplePrecisionMath", aqSource.replace(/^import .*;\n/gm, "").replace("export default class AirQuality", "return class AirQuality"))(Console, SimplePrecisionMath); +const logoSource = await readFile(new URL("../src/function/providerNameToLogo.mjs", import.meta.url), "utf8"); +const providerNameToLogo = new Function("Console", logoSource.replace(/^import .*;\n/gm, "").replace("export default function", "return function"))(Console); + +async function loadInjection(path) { + const source = await readFile(new URL(path, import.meta.url), "utf8"); + return new Function("AirQuality", "Console", "providerNameToLogo", `${source.slice(source.indexOf("async function InjectAirQuality("))}\nreturn InjectAirQuality;`)(AirQuality, Console, providerNameToLogo); +} +const [Response, ResponseDev] = await Promise.all([loadInjection("../src/process/Response.mjs"), loadInjection("../src/process/Response.dev.mjs")]); + +const pollutants = [{ pollutantType: "PM2_5", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }]; +const apple = { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + index: 42, + categoryIndex: 1, + scale: "HJ6332012.2414", + isSignificant: true, + primaryPollutant: "PM2_5", + pollutants, + previousDayComparison: "BETTER", +}; +const supplement = { + ...apple, + metadata: { providerName: "QWeather", temporarilyUnavailable: false }, + index: 180, + categoryIndex: 4, + scale: "HJ6332012", +}; + +async function run(t, handler, { source = apple, replace = ["HJ6332012"], provider = "Calculate", algorithm = "EU_EAQI", replaceComparison = false, yesterday = "ColorfulCloudsUS", candidate = supplement } = {}) { + const calls = { current: 0, yesterday: 0 }; + const enviroments = { + qWeather: { CurrentAirQuality: async () => { calls.current++; return structuredClone(candidate); } }, + colorfulClouds: { + YesterdayAirQuality: async () => { calls.yesterday++; return structuredClone({ ...supplement, categoryIndex: 2 }); }, + CurrentAirQuality: async () => { calls.current++; return structuredClone({ ...supplement, categoryIndex: 1 }); }, + }, + }; + const settings = { + DataSets: ["airQuality"], LogLevel: "OFF", Storage: "Argument", + AirQuality: { + Current: { Pollutants: { Provider: "QWeather", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: replace, Provider: provider } }, + Calculate: { Algorithm: algorithm }, + Comparison: { ReplaceWhenCurrentChange: replaceComparison, Yesterday: { IndexProvider: yesterday, PollutantsProvider: "QWeather" } }, + }, + }; + return { output: await handler(structuredClone(source), settings, {}, enviroments), calls }; +} + +function assertAppleIndex(output) { + for (const key of ["index", "categoryIndex", "scale", "isSignificant", "primaryPollutant"]) assert.equal(output[key], apple[key], key); + assert.doesNotMatch(output.metadata.providerName, /指数:/); +} + +for (const [name, handler] of [["release", Response], ["dev", ResponseDev]]) { + test(`${name}: empty targets preserve Apple index while supplementing pollutants`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, pollutants: [] }, replace: [] }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, pollutants); + assert.match(output.metadata.providerName, /污染物:QWeather/); + assert.equal(output.previousDayComparison, "BETTER"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: nonmatching target cannot replace a valid Apple index after supplementation`, async t => { + const { output } = await run(t, handler, { source: { ...apple, pollutants: [] }, replace: ["UBA"] }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, pollutants); + }); + + test(`${name}: Calculate None preserves Apple fields and independent supplementation`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, pollutants: [] }, algorithm: "None", replaceComparison: true }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, pollutants); + assert.equal(output.previousDayComparison, "BETTER"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: nonempty targets allow recovery of a missing Apple index`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, metadata: { ...apple.metadata, temporarilyUnavailable: true }, index: 0, categoryIndex: 0, scale: "", pollutants: [] }, replace: ["UBA"] }); + assert.equal(output.scale, "EU.EAQI"); + assert.ok(Number.isFinite(output.index)); + assert.equal(output.metadata.temporarilyUnavailable, false); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: empty targets also disable recovery of a missing Apple index`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, index: 0, categoryIndex: 0, scale: "", pollutants: [] }, replace: [] }); + assert.equal(output.index, 0); + assert.equal(output.scale, ""); + assert.deepEqual(output.pollutants, pollutants); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: category-only change with switch true recalculates comparison`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, index: apple.index, scale: apple.scale }, replaceComparison: true }); + assert.equal(output.index, apple.index); + assert.equal(output.categoryIndex, supplement.categoryIndex); + assert.equal(calls.yesterday, 1); + }); + + test(`${name}: None disables calculation without suppressing external index providers`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", algorithm: "None" }); + assert.equal(output.index, supplement.index); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: replacement keeps Apple comparison when switch is false`, async t => { + const { output, calls } = await run(t, handler); + assert.equal(output.scale, "EU.EAQI"); + assert.equal(output.previousDayComparison, "BETTER"); + assert.equal(calls.yesterday, 0); + }); + + test(`${name}: identical injected index does not force comparison`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, index: apple.index, categoryIndex: apple.categoryIndex, scale: apple.scale }, replaceComparison: true }); + assert.equal(output.previousDayComparison, "BETTER"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: failed injected index does not force comparison or claim its source`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, metadata: { providerName: "QWeather", temporarilyUnavailable: true } }, replaceComparison: true }); + assertAppleIndex(output); + assert.equal(output.previousDayComparison, "BETTER"); + assert.equal(calls.yesterday, 0); + }); + + test(`${name}: invalid numeric injected index retains Apple data`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, index: NaN }, replaceComparison: true }); + assertAppleIndex(output); + assert.equal(output.previousDayComparison, "BETTER"); + assert.equal(calls.yesterday, 0); + }); + + test(`${name}: a changed index with switch true recalculates comparison`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", replaceComparison: true }); + assert.equal(output.index, supplement.index); + assert.equal(calls.yesterday, 1); + assert.match(output.metadata.providerName, /对比昨日:/); + }); + + test(`${name}: unknown comparison is supplemented even without index replacement`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, previousDayComparison: "UNKNOWN" }, replace: [] }); + assert.equal(calls.yesterday, 1); + assert.equal(output.previousDayComparison, "BETTER"); + assert.match(output.metadata.providerName, /对比昨日:/); + }); + + for (const yesterday of ["WeatherKit", "UnknownProvider"]) { + test(`${name}: unknown comparison with ${yesterday} does not use ColorfulCloudsUS`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, previousDayComparison: "UNKNOWN" }, replace: [], yesterday }); + assertAppleIndex(output); + assert.equal(output.previousDayComparison, "UNKNOWN"); + assert.equal(output.metadata.providerName, "Apple"); + assert.deepEqual(calls, { current: 0, yesterday: 0 }); + }); + } + + test(`${name}: unavailable supplement cannot erase existing index or add source attribution`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, pollutants: [] }, replace: [], candidate: { metadata: { providerName: "QWeather", temporarilyUnavailable: true }, pollutants: [] } }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, []); + assert.equal(output.metadata.providerName, "Apple"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); +} diff --git a/tests/wk3-alert-normalization.test.mjs b/tests/wk3-alert-normalization.test.mjs new file mode 100644 index 00000000..6e4ab3e9 --- /dev/null +++ b/tests/wk3-alert-normalization.test.mjs @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import { Builder } from "flatbuffers"; +import { buildEgern } from "../scripts/build-egern.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { decodeWK3Alerts, patchWK3QWeatherAlerts } from "../src/wk3/class/WK3Alerts.mjs"; +import { sameAlertTitle, resolveNativeAlertDetails, buildNativeAlertDetailsURL } from "../src/wk3/class/NativeAlertDetails.mjs"; + +const issued = Date.parse("2030-01-01T00:00:00Z") / 1000; +const pageURL = "https://www.qweather.com/severe-weather/synthetic-101010100.html?from=AppleWeatherService"; +const blue = { title: "大风", severity: 4, color: "蓝" }; +const yellow = { title: "寒潮", severity: 3, color: "黄" }; +const orange = { title: "暴雪", severity: 2, color: "橙" }; // Upstream @nsringo/weatherkit 2.0.5 Severity. +function fixture(events) { + const b = new Builder(), details = b.createString(pageURL); + const offsets = events.map((event, index) => { + const description = b.createString(event.title), source = b.createString("国家预警信息发布中心"); + b.startVector(1, 16, 1); + for (let i = 15; i >= 0; i--) b.addInt8(index * 16 + i); + const bytes = b.endVector(); + b.startObject(1); b.addFieldOffset(0, bytes, 0); const id = b.endObject(); + b.startObject(event.unknown ? 23 : 22); + b.addFieldOffset(0, id, 0); b.addFieldOffset(5, description, 0); b.addFieldOffset(12, details, 0); + b.addFieldOffset(16, source, 0); b.addFieldInt32(9, event.time ?? issued, 0); b.addFieldInt8(14, event.severity, 0); + if (event.unknown) b.addFieldInt32(22, 42, 0); + return b.endObject(); + }); + b.startVector(4, offsets.length, 4); + for (const offset of [...offsets].reverse()) b.addOffset(offset); + const alerts = b.endVector(); + b.startObject(3); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); const collection = b.endObject(); + b.startObject(10); b.addFieldOffset(9, collection, 0); b.finish(b.endObject()); return b.asUint8Array(); +} +function html(events) { + return events.map(event => `<div class="c-city-warning-events warning--${{ 蓝: "blue", 黄: "yellow", 橙: "orange", 红: "red" }[event.color]}"><h3>测试区气象台发布${event.title}${event.color}色预警</h3><p>发布日期:${new Date((event.time ?? issued) * 1000).toISOString()}</p><p class="warning-events__txt">测试正文。</p></div>`).join(""); +} +const fetchPage = events => async () => new Response(html(events), { headers: { "Content-Type": "text/html" } }); +const cases = [ + { name: "signal suffix", events: [{ ...blue, title: " 大风预警信号。 " }], page: [blue], updated: 1, native: true }, + { name: "reordered yellow and blue", events: [blue, yellow], page: [yellow, blue], updated: 2, native: true }, + { name: "time differs by one second", events: [blue, yellow], page: [blue, { ...yellow, time: issued + 1 }], updated: 1, native: false, reason: "issued-time-mismatch" }, + { name: "missing second event", events: [blue, yellow], page: [blue], updated: 1, native: false, reason: "title-mismatch" }, + { name: "orange and blue", events: [blue, orange], page: [orange, blue], updated: 2, native: true }, + { name: "red highway and yellow road ice", events: [{ title: "高速公路灾害性天气预警", severity: 1, color: "红" }, { title: "道路结冰", severity: 3, color: "黄" }], page: [{ title: "道路结冰", severity: 3, color: "黄" }, { title: "高速公路灾害性天气预警", severity: 1, color: "红" }], updated: 2, native: true }, + { name: "generic red highway and named road ice", events: [{ title: "恶劣天气", severity: 1 }, { title: "道路结冰", severity: 3 }], page: [{ title: "道路结冰", severity: 3, color: "黄" }, { title: "高速公路灾害性天气预警", severity: 1, color: "红" }], updated: 2, native: true }, + { name: "generic severity mismatch", events: [{ title: "恶劣天气", severity: 1 }], page: [blue], updated: 0, native: false, reason: "severity-mismatch" }, + { name: "generic requires exact time", events: [{ title: "恶劣天气", severity: 1 }], page: [{ title: "暴雨", severity: 1, color: "红", time: issued + 1 }], updated: 0, native: false, reason: "issued-time-mismatch" }, + { name: "generic requires nonzero Apple time", events: [{ title: "恶劣天气", severity: 1, time: 0 }], page: [{ title: "暴雨", severity: 1, color: "红" }], updated: 0, native: false, reason: "missing-issued-time" }, + { name: "generic does not choose by order", events: [{ title: "恶劣天气", severity: 1 }], page: [{ title: "暴雨", severity: 1, color: "红" }, { title: "高速公路灾害性天气预警", severity: 1, color: "红" }], updated: 0, native: false, reason: "ambiguous-match" }, + { name: "two generic alerts cannot share candidate", events: [{ title: "恶劣天气", severity: 1 }, { title: "恶劣天气", severity: 1 }], page: [{ title: "暴雨", severity: 1, color: "红" }], updated: 1, native: false, reason: "ambiguous-match" }, + { name: "unknown severity", events: [{ ...blue, severity: 231 }], page: [blue], updated: 0, native: false, reason: "unverified-severity" }, + { name: "unrelated substring", events: [blue], page: [{ ...blue, title: "雷暴大风" }], updated: 0, native: false, reason: "title-mismatch" }, + { name: "duplicate candidate", events: [blue], page: [blue, blue], updated: 0, native: false, reason: "ambiguous-match" }, + { name: "shared candidate", events: [blue, { ...blue, title: "大风蓝色预警信号" }], page: [blue], updated: 1, native: false, reason: "ambiguous-match" }, + { name: "unverified summary field", events: [{ ...blue, unknown: true }], page: [blue], updated: 0, native: false, stage: "summary-patch-failed" }, +]; + +test("one title normalizer accepts suffix, whitespace and punctuation but rejects unrelated events and colors", () => { + for (const title of ["大风", "大风预警", "大风预警信号", " 大 风 蓝色预警信号。 "]) assert.equal(sameAlertTitle(title, "大风蓝色预警"), true); + for (const title of ["雷暴大风", "风", "大风黄色预警", "", null]) assert.equal(sameAlertTitle(title, "大风蓝色预警"), false); + assert.equal(sameAlertTitle("Wind", "wind"), true); + assert.equal(sameAlertTitle("高速公路灾害性天气预警", "高速公路灾害性天气预警红色预警信号"), true); + assert.equal(sameAlertTitle("高速公路灾害性天气", "高速公路灾害性天气预警红色预警"), true); + assert.equal(sameAlertTitle("高速公路灾害性天气预警黄色预警", "高速公路灾害性天气预警红色预警"), false); +}); + +test("summary report describes only committed changes and retains every matching safeguard", async () => { + for (const scenario of cases) { + const raw = fixture(scenario.events), report = {}; + const out = await patchWK3QWeatherAlerts(raw, { fetchImpl: fetchPage(scenario.page), rewriteDetails: true, report }); + assert.equal(report.summariesUpdated, scenario.updated, scenario.name); + assert.equal(report.detailsConverted, scenario.native, scenario.name); + assert.equal(new URL(decodeWK3Alerts(out).detailsUrl).hostname, scenario.native ? "weatherkit.apple.com" : "www.qweather.com", scenario.name); + if (scenario.reason) assert.equal(report.unmatched[scenario.reason], 1, scenario.name); + if (scenario.reason === "title-mismatch") { + assert.deepEqual(Object.keys(report.titleMismatches[0]), ["appleTitle", "pageTitles", "parsedTitles"]); + assert.ok(report.titleMismatches[0].appleTitle); + assert.ok(report.titleMismatches[0].pageTitles[0].startsWith("测试区气象台发布")); + assert.equal(report.titleMismatches[0].parsedTitles.length, scenario.page.length); + } else assert.deepEqual(report.titleMismatches, []); + if (scenario.stage) { assert.equal(report.reason, scenario.stage); assert.strictEqual(out, raw); } + } + const report = {}, raw = fixture([blue]); + assert.strictEqual(await patchWK3QWeatherAlerts(raw, { report, fetchImpl: async () => { throw new Error("offline"); }, rewriteDetails: true }), raw); + assert.equal(report.reason, "page-fetch-failed"); + assert.equal(report.summariesUpdated, 0); assert.equal(report.detailsConverted, false); +}); + +async function executePlatform(code, platform, raw, page, stage = "response", url = "https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts") { + const logs = [], requests = []; + const logger = Object.fromEntries(["log", "info", "debug", "warn", "error"].map(key => [key, (...args) => logs.push(args.join(" "))])); + const fetch = async (requestURL, options) => { + assert.equal(String(requestURL), pageURL); + assert.deepEqual(Object.keys(options.headers).map(key => key.toLowerCase()), ["accept"]); + requests.push(requestURL); + return new Response(html(page), { headers: { "Content-Type": "text/html" } }); + }; + const headers = { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }; + let result; + if (platform === "egern") { + const transformed = code.replace("export default async function(ctx)", "globalThis.run = async function(ctx)"); + const context = { URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Headers, btoa, atob, console: logger }; + vm.createContext(context); vm.runInContext(transformed, context, { timeout: 30000 }); + result = await context.run({ env: { DataSets: "weatherAlerts", LogLevel: "INFO" }, request: { url, method: "GET", headers: new Headers() }, response: { status: 200, headers: new Headers(headers), arrayBuffer: async () => raw.slice().buffer }, http: { get: fetch }, respond: value => value }); + } else { + let finish; + const complete = new Promise(resolve => { finish = resolve; }); + const globals = { URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, console: logger, + $argument: { DataSets: ["weatherAlerts"], LogLevel: "INFO" }, + $request: { url, method: "GET", headers: {} }, + $response: stage === "response" ? { status: 200, headers, body: raw } : undefined, + $done: finish }; + if (platform === "quantumultx") globals.$task = { fetch: async options => { const r = await fetch(options.url, options); return { statusCode: r.status, headers: Object.fromEntries(r.headers), body: await r.text() }; } }; + else globals.$httpClient = { get(options, callback) { fetch(options.url, options).then(async r => callback(null, { status: r.status, headers: Object.fromEntries(r.headers) }, await r.text())); } }; + vm.runInNewContext(code, globals, { timeout: 30000 }); + let timer; + try { result = await Promise.race([complete, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("platform completion timeout")), 5000); })]); } + finally { clearTimeout(timer); } + if (stage === "request" && platform !== "quantumultx") result = result?.response; + } + return { result, logs, requests }; +} + +for (const platform of ["egern", "surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: built scripts normalize titles and log summary and native conversion independently`, async () => { + const outdir = process.env.WK3_TEST_OUTDIR ? `${process.env.WK3_TEST_OUTDIR}/${platform}` : undefined; + const bundles = process.env.WK3_TEST_BUNDLE_DIR + ? Object.fromEntries(await Promise.all(["request", "response"].map(async stage => [stage, { code: await readFile(`${process.env.WK3_TEST_BUNDLE_DIR}/${platform}/wk3-${platform}-${stage}.bundle.js`, "utf8") }]))) + : platform === "egern" ? await buildEgern({ outdir, filePrefix: "wk3-egern-" }) : await buildPlatform(platform, { outdir }); + if (platform !== "egern") { + const url = new URL(buildNativeAlertDetailsURL({ identifier: "synthetic-101010100", alerts: [{ id: { bytes: Array.from({ length: 16 }, (_, i) => i) }, description: " 大风预警信号。 ", issuedTime: issued, severity: 4 }] })); + const requestURL = `https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=${url.searchParams.get("ids")}`; + const detail = await executePlatform(bundles.request.code, platform, fixture([blue]), [blue], "request", requestURL); + const parsed = JSON.parse(detail.result.body); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].id, "00010203-0405-0607-0809-0a0b0c0d0e0f"); + assert.equal(parsed[0].description, "大风蓝色预警"); + } + for (const scenario of cases) { + const raw = fixture(scenario.events); + const { result, logs, requests } = await executePlatform(bundles.response.code, platform, raw, scenario.page); + if (platform === "quantumultx") { + assert.equal(result, undefined); assert.equal(requests.length, 0); + assert.equal(logs.some(line => line.includes("WK3 alerts summary") || line.includes("WK3 alerts details")), false); + continue; + } + const bytes = typeof result.body === "string" ? Uint8Array.from(result.body, c => c.charCodeAt(0)) : result.body; + const after = decodeWK3Alerts(bytes); + assert.equal(new URL(after.detailsUrl).hostname, scenario.native ? "weatherkit.apple.com" : "www.qweather.com", scenario.name); + const summaryLogs = logs.filter(line => line.includes("WK3 alerts summary")); + const detailLogs = logs.filter(line => line.includes("WK3 alerts details")); + assert.equal(summaryLogs.length, 1); assert.equal(detailLogs.length, 1); + assert.ok(summaryLogs[0].includes(`摘要补全:${scenario.updated}/${scenario.events.length}`), scenario.name); + assert.ok(detailLogs[0].includes(scenario.native ? "已切换苹果原生详情" : "未转换,保留原链接"), scenario.name); + if (scenario.reason) assert.ok(detailLogs[0].includes(scenario.reason), scenario.name); + const titleLogs = logs.filter(line => line.includes("WK3 alerts titles")); + assert.equal(titleLogs.length, scenario.reason === "title-mismatch" ? 1 : 0, scenario.name); + if (titleLogs.length) { + assert.ok(titleLogs[0].includes('"appleTitle"')); + assert.ok(titleLogs[0].includes('"pageTitles"')); + assert.ok(titleLogs[0].includes('"parsedTitles"')); + assert.ok(!titleLogs[0].includes(pageURL)); + assert.ok(!titleLogs[0].includes("issuedTime")); + } + if (scenario.native) { + const native = new URL(after.detailsUrl); + const details = await resolveNativeAlertDetails(native.searchParams.get("ids"), "zh-CN", fetchPage(scenario.page)); + assert.equal(details.length, scenario.events.length); + if (scenario.name === "generic red highway and named road ice") { + assert.equal(after.alerts[0].description, "高速公路灾害性天气预警红色预警"); + const payload = JSON.parse(Buffer.from(native.searchParams.get("ids").slice(4), "base64url").toString("utf8")); + assert.equal(payload.alerts[0].description, after.alerts[0].description); + const requestURL = `https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=${native.searchParams.get("ids")}`; + const resolved = await executePlatform(bundles.request.code, platform, raw, scenario.page, "request", requestURL); + assert.deepEqual(JSON.parse(resolved.result.body), details); + } + assert.deepEqual(details.map(item => item.id), scenario.events.map((_, i) => { + const h = Array.from({ length: 16 }, (_, j) => (i * 16 + j).toString(16).padStart(2, "0")).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; + })); + } + } + }); +} diff --git a/tests/wk3-availability.test.mjs b/tests/wk3-availability.test.mjs new file mode 100644 index 00000000..69d9c690 --- /dev/null +++ b/tests/wk3-availability.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {handleResponse} from '../src/wk3/entry/response.mjs'; +import {Response as processResponse} from '../src/wk3/process/Response.mjs'; +import buildSettings from '../src/wk3/function/buildSettings.mjs'; +import database from '../src/wk3/function/database.mjs'; + +for (const version of ['v1','v2']) { + test(`${version} availability preserves Apple capabilities and request language`,async()=>{ + const url=`https://weatherkit.apple.com/api/${version}/availability/zh-Hans-SG/41.797/123.429?country=CN`; + const capabilities=['currentWeather','uvIndex','appleFutureLocalizedProduct']; + const request={url,method:'GET',headers:{'Accept-Language':'zh-Hans-SG'}}; + const reply=await handleResponse({env:{},request,response:new Response(JSON.stringify(capabilities),{headers:{'content-type':'application/json','content-language':'zh-Hans-SG'}})}); + const out=JSON.parse(reply.body); + for(const capability of capabilities)assert.ok(out.includes(capability),capability); + assert.equal(out.filter(x=>x==='currentWeather').length,1); + assert.ok(out.includes('airQuality')); + assert.equal(reply.headers.get('content-language'),'zh-Hans-SG'); + assert.equal(request.url,url); + assert.equal(request.headers['Accept-Language'],'zh-Hans-SG'); + const {Settings,Configs}=buildSettings(database); + const processed=await processResponse(request,{status:200,headers:{'content-type':'application/json'},body:JSON.stringify(capabilities)},{Settings,Configs}); + for(const capability of capabilities)assert.ok(JSON.parse(processed.body).includes(capability),`direct core ${capability}`); + }); +} +for(const value of [{status:503,body:'{"error":"暂时不可用"}',type:'application/json'},{status:200,body:'{"language":"zh-Hans","capabilities":["uvIndex"]}',type:'application/json'},{status:200,body:'not JSON',type:'application/json'},{status:200,body:'<html>原始响应</html>',type:'text/html'}]){ + test(`availability preserves unrecognized response ${value.status}/${value.type}/${value.body}`,async()=>{ + const reply=await handleResponse({env:{},request:{url:'https://weatherkit.apple.com/api/v2/availability/zh-Hans/31/121',headers:{}},response:new Response(value.body,{status:value.status,headers:{'content-type':value.type,'content-language':'zh-Hans'}})}); + assert.equal(reply.status,value.status); + assert.equal(reply.body,value.body); + assert.equal(reply.headers.get('content-type'),value.type); + assert.equal(reply.headers.get('content-language'),'zh-Hans'); + }); +} diff --git a/tests/wk3-logging.test.mjs b/tests/wk3-logging.test.mjs new file mode 100644 index 00000000..8b53fdf4 --- /dev/null +++ b/tests/wk3-logging.test.mjs @@ -0,0 +1,145 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import { buildEgern } from "../scripts/build-egern.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; + +const source = await readFile(new URL("../src/wk3/utils/Console.mjs", import.meta.url), "utf8"); +function capture(logOnly = false) { + const logs = []; + const console = Object.fromEntries((logOnly ? ["log"] : ["debug", "info", "warn", "error", "log"]).map(method => [method, (...args) => logs.push([method, ...args])])); + return { logs, console }; +} +function logger(logOnly = false) { + const output = capture(logOnly); + const context = vm.createContext({ console: output.console }); + vm.runInContext(`${source.replace(/export /g, "")}\nglobalThis.logger={Console,setLogEnv};`, context); + return { ...output, ...context.logger }; +} +const secret = "private-token-unique"; +const payload = { Token: secret, nested: [{ apiKey: secret, Authorization: `Bearer ${secret}`, password: secret }], url: `https://example.invalid/?token=${secret}&key=${secret}&keep=yes`, summary: "provider summary intact" }; + +test("shared levels, defaults, SETTINGS precedence and console.log fallback", () => { + const { Console, setLogEnv, logs } = logger(true); + const methods = ["debug", "info", "warn", "error", "log", "exception"]; + for (const [LogLevel, count] of [["OFF", 0], ["ERROR", 3], ["WARN", 4], ["INFO", 5], ["DEBUG", 6], ["ALL", 6], ["invalid", 5]]) { + logs.length = 0; + setLogEnv({ LogLevel }); + for (const method of methods) Console[method](method); + assert.equal(logs.length, count, LogLevel); + assert.ok(logs.every(([method]) => method === "log")); + } + logs.length = 0; + setLogEnv({ SETTINGS: '{"LogLevel":"OFF"}' }); Console.info("hidden"); + setLogEnv({ LogLevel: "INFO", SETTINGS: '{"LogLevel":"OFF"}' }); Console.info("visible"); + setLogEnv({ SETTINGS: "invalid json" }); Console.info("default"); + setLogEnv(); Console.info("default again"); + assert.equal(logs.length, 3); +}); + +test("formatter redacts objects, JSON strings, prefixed JSON, nested JSON and query keys without mutating inputs", () => { + const { Console, logs } = logger(); + const before = JSON.stringify(payload); + Console.info(payload, before, `body: ${before}`, { body: before }); + Console.info("[buildSettings] Settings:", JSON.stringify({ privateHost: "private-host-unique", ...payload })); + const output = JSON.stringify(logs); + assert.ok(!output.includes(secret), output); + assert.ok(!output.includes("private-host-unique"), output); + assert.match(output, /provider summary intact/); + assert.match(output, /keep=yes/); + assert.equal(JSON.stringify(payload), before); +}); + +const bundles = new Map(); +async function files(platform) { + if (!bundles.has(platform)) bundles.set(platform, platform === "egern" ? buildEgern({ minify: false }) : buildPlatform(platform, { minify: false })); + return bundles.get(platform); +} +async function execute(platform, code, stage, { env = {}, body = "{}", logOnly = false, invalid = false } = {}) { + const output = capture(logOnly); + const url = invalid ? "invalid URL" : "https://weatherkit.apple.com/api/v2/airQualityScale/en/HJ6332012?token=private-token-unique&key=private-token-unique"; + const globals = { URL, Headers, Response, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, console: output.console }; + if (platform === "egern") { + const context = vm.createContext(globals); + const handler = vm.runInContext(code.replace("export default async function(ctx)", "(async function(ctx)") + ")", context); + const ctx = { env, request: { url, headers: {} }, response: new Response(body, { headers: { "content-type": "application/json" } }), http: { async get() { return new Response(body, { headers: { "content-type": "application/json" } }); } }, respond: value => value }; + let result, error; + try { result = await handler(ctx); } catch (caught) { error = caught; } + return { ...output, result, error, handler, ctx }; + } + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + Object.assign(globals, { + $environment: { LogLevel: "ALL" }, // Parsed script arguments must override host env. + $argument: { LogLevel: "INFO", ...env }, + $request: { url, headers: {} }, + $response: { status: 200, headers: { "content-type": "application/json" }, body }, + $httpClient: platform === "quantumultx" ? undefined : { get(options, callback) { callback(null, { status: 200, headers: { "content-type": "application/json" } }, body); } }, + $task: { async fetch() { return { statusCode: 200, headers: { "content-type": "application/json" }, body }; } }, + $done(value) { finish(value); }, + }); + vm.runInNewContext(code, globals, { timeout: 30000 }); + const result = await Promise.race([completed, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("bundle did not finish")), 5000); timer.unref(); })]); + return { ...output, result }; +} + +for (const platform of ["egern", "surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: generated request/response INFO progress, OFF silence, redaction and error fallback`, async () => { + const bundle = await files(platform); + for (const stage of ["request", "response"]) { + const code = bundle[stage].code; + const visible = await execute(platform, code, stage, { body: JSON.stringify(payload), env: { SETTINGS: JSON.stringify({ API: { QWeather: { Token: secret, Host: "private-host-unique" } } }) } }); + const text = JSON.stringify(visible.logs); + assert.equal(visible.logs[0][1], `☑️ ${stage}`); + assert.equal(visible.logs.at(-1)[1], `✅ ${stage}`); + assert.ok(!text.includes(secret), text); + assert.ok(!text.includes("private-host-unique"), text); + assert.ok(visible.logs.every(([method, message, ...rest]) => method === "log" && typeof message === "string" && rest.length === 0), `${platform} must write one string per console.log call`); + if (stage === "response") { + assert.match(text, /provider summary intact/); + assert.match(text, /parseWeatherKitURL/); + assert.match(text, /airQualityScale Apple/); + } + const off = await execute(platform, code, stage, { env: { LogLevel: "OFF" }, invalid: true, logOnly: true }); + assert.deepEqual(off.logs, []); + const failed = await execute(platform, code, stage, { env: { LogLevel: "ERROR" }, invalid: true, logOnly: true }); + assert.equal(failed.logs.length, 1); + assert.equal(failed.logs[0][0], "log"); + assert.match(JSON.stringify(failed.logs), /Invalid URL/); + } + }); +} + +test("Egern INFO reaches console.log even when console.info exists", async () => { + const bundle = await files("egern"); + for (const stage of ["request", "response"]) { + const visible = await execute("egern", bundle[stage].code, stage); + const logMessages = visible.logs.filter(([method]) => method === "log").map(([, message]) => message); + assert.equal(logMessages[0], `☑️ ${stage}`); + assert.equal(logMessages.at(-1), `✅ ${stage}`); + } +}); + +test("Loon INFO reaches console.log even when console.info exists", async () => { + const bundle = await files("loon"); + for (const stage of ["request", "response"]) { + const visible = await execute("loon", bundle[stage].code, stage); + const logMessages = visible.logs.filter(([method]) => method === "log").map(([, message]) => message); + assert.equal(logMessages[0], `☑️ ${stage}`); + assert.equal(logMessages.at(-1), `✅ ${stage}`); + assert.ok(logMessages.every(message => typeof message === "string")); + } +}); + +test("Egern overlapping invocations keep their own ctx log level", async () => { + const bundle = await files("egern"); + const first = await execute("egern", bundle.request.code, "request"); + first.logs.length = 0; + let release; + const waiting = first.handler({ ...first.ctx, http: { get: () => new Promise(resolve => { release = resolve; }) } }); + await first.handler({ ...first.ctx, env: { LogLevel: "OFF" } }); + release(new Response("{}")); + await waiting; + assert.deepEqual(first.logs.map(row => row[1]), ["☑️ request", "✅ request"]); +}); diff --git a/tests/wk3-loon-binary.test.mjs b/tests/wk3-loon-binary.test.mjs new file mode 100644 index 00000000..582a31e9 --- /dev/null +++ b/tests/wk3-loon-binary.test.mjs @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { Builder } from "flatbuffers"; +import { runPlatform } from "../src/platform/wk3-adapter.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { encodeAirQuality, decodeWK3DataSet } from "../src/wk3/class/WeatherKit3.mjs"; +import { localParameters } from "../src/wk3/parameters.mjs"; + +const raw = new Uint8Array([0, 1, 127, 128, 195, 255]); +for (const stage of ["request", "response"]) { + test(`Loon ${stage}: native bytes survive $done without text encoding`, async () => { + for (const body of [raw, raw.buffer, new Uint8Array([99, ...raw, 99]).subarray(1, -1)]) { + let returned; + await runPlatform(async () => ({ status: 200, headers: {}, body }), stage, { + $request: { url: "https://example.invalid", headers: {} }, + $response: { status: 200, headers: {}, body: raw }, + $done(value) { returned = value; }, + }, "loon"); + const result = stage === "request" ? returned.response : returned; + assert.ok(result.body instanceof Uint8Array, "binary Loon output must be native Uint8Array, never a string"); + assert.deepEqual(Array.from(result.body), Array.from(raw)); + } + }); +} + +test("Loon text responses remain strings", async () => { + let returned; + await runPlatform(async () => ({ status: 200, headers: {}, body: "天气正常" }), "response", { + $request: { url: "https://example.invalid", headers: {} }, + $done(value) { returned = value; }, + }, "loon"); + assert.equal(returned.body, "天气正常"); +}); + +let built; +const bundle = () => built ??= buildPlatform("loon"); +async function execute(code, body, overrides = {}) { + const logs = [], requests = []; + const values = localParameters.map(p => overrides[p.key] ?? p.value); + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + vm.runInNewContext(code, { + URL, Headers, Response, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, + console: { log: line => logs.push(line) }, + $loon: "3.5.4", $argument: JSON.stringify(values), + $request: { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality", method: "GET", headers: {} }, + $response: { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body }, + $httpClient: { get(options, callback) { + requests.push(options.url); + assert.ok(options.url.includes("/realtime"), options.url); + callback(null, { status: 200, headers: { "content-type": "application/json" } }, JSON.stringify({ status: "ok", location: [31, 121], result: { realtime: { status: "ok", air_quality: { description: { usa: "moderate" }, aqi: { usa: 87, chn: 60 }, obs_time: 1893456000, pm25: 12, pm10: 20, co: 0.4, no2: 10, so2: 5, o3: 40 } } } })); + } }, + $done: value => finish(value), + }, { timeout: 30000 }); + const result = await Promise.race([completed, new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("Loon script did not finish")), 5000); + timer.unref(); + })]); + return { result, logs, requests }; +} + +test("actual minified Loon bundle preserves binary pass-through with high bytes", async () => { + const { response } = await bundle(); + const { result, logs, requests } = await execute(response.code, raw, { DataSets: "[]" }); + assert.ok(result.body instanceof Uint8Array, "native Loon host receives bytes, not UTF-8 text"); + assert.deepEqual(Array.from(result.body), Array.from(raw)); + assert.equal(requests.length, 0); + assert.equal(logs[0], "☑️ response"); + assert.equal(logs.at(-1), "✅ response"); +}); + +test("actual minified Loon bundle returns decodable WK3 US AQ after source request", async () => { + const b = new Builder(); + const aq = encodeAirQuality(b, { metadata: { sourceType: 3, providerName: "Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); + b.startObject(15); b.addFieldOffset(0, aq, 0); b.finish(b.endObject()); + const { response } = await bundle(); + const { result, logs, requests } = await execute(response.code, b.asUint8Array(), { "AirQuality.Current.Index.Provider": "ColorfulCloudsUS", "AirQuality.Comparison.Yesterday.IndexProvider": "WeatherKit" }); + assert.ok(result.body instanceof Uint8Array, "returned WK3 must stay binary for Loon"); + const decoded = decodeWK3DataSet(result.body); + assert.equal(decoded.index, 87); + assert.equal(decoded.scale, "EPA_NowCast.2604"); + assert.equal(requests.length, 1); + assert.ok(logs.some(line => /WK3 AQ patch/.test(line))); +}); diff --git a/tests/wk3-module-parameters.test.mjs b/tests/wk3-module-parameters.test.mjs new file mode 100644 index 00000000..52289320 --- /dev/null +++ b/tests/wk3-module-parameters.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { displayModuleOption, normalizeModuleOption } from '../src/wk3/option-values.mjs'; +import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; +import { renderLocalModule } from '../scripts/build-egern-module.mjs'; +import { renderWK3Module } from '../scripts/build-wk3-modules.mjs'; +import { configuration } from '../src/wk3/entry/index.mjs'; +import { runPlatform } from '../src/platform/wk3-adapter.mjs'; + +const parameters = new Map(localParameters.map(p => [p.key, p])); +const jsonLine = (text, prefix) => JSON.parse(text.split('\n').find(line => line.startsWith(prefix)).slice(prefix.length)); +const get = (object, path) => path.split('.').reduce((value, key) => value?.[key], object); +const version = '3.3.4'; + +test('upstream names and types, preserved local defaults and supported options', () => { + const upstream = fs.readFileSync(new URL('../arguments-builder.full.config.ts', import.meta.url), 'utf8'); + assert.equal(parameters.size, 23); + for (const parameter of localParameters) { + const block = upstream.slice(upstream.indexOf(`key: "${parameter.key}"`)); + if (!upstream.includes(`key: "${parameter.key}"`)) continue; + assert.equal(parameter.name, block.match(/name: "([^"]+)"/)[1], parameter.key); + assert.equal(parameter.type, block.match(/type: "([^"]+)"/)[1], parameter.key); + } + assert.equal(parameters.get('AirQuality.Current.Index.Provider').value, 'Calculate'); + assert.equal(parameters.get('AirQuality.Comparison.Yesterday.IndexProvider').value, 'ColorfulCloudsUS'); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').value, 'EU_EAQI'); + assert.equal(parameters.get('LogLevel').value, 'INFO'); + assert.deepEqual(parameters.get('WeatherAlerts.Provider').options, ['WeatherKit', 'QWeatherWeb']); + assert.deepEqual(parameters.get('AirQuality.Comparison.Yesterday.PollutantsProvider').options, ['QWeather']); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').options.length, 12); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').options[0], 'None'); + assert.ok(!parameters.has('Storage') && !parameters.has('API.WAQI.Token')); +}); + +test('Egern actual env schema and compatibility help show all supported choices', () => { + const text = renderLocalModule({ version }); + const schema = jsonLine(text, 'env_schema: '); + const help = jsonLine(text, 'compat_arguments_desc: '); + assert.equal(Object.keys(schema).length, 23); + for (const parameter of localParameters) { + assert.equal(schema[parameter.key].name, parameter.name); + assert.equal(schema[parameter.key].default_value, displayModuleOption(parameter.key, parameter.value)); + assert.equal(schema[parameter.key].description, parameterDescription(parameter)); + assert.ok(help.includes(parameter.name)); + if (parameter.options) assert.deepEqual(schema[parameter.key].options, parameter.options.map(value => displayModuleOption(parameter.key, value))); + if (parameter.type === 'array') assert.equal(schema[parameter.key].options, undefined, 'lists must remain editable, not single selects'); + } +}); + +for (const platform of ['surge', 'loon', 'stash']) test(`${platform}: actual module displays names, choices and limitations`, () => { + const text = renderWK3Module(platform, version); + for (const parameter of localParameters) { + assert.ok(text.includes(parameter.name), parameter.key); + if (platform === 'loon') for (const option of parameter.options ?? []) assert.ok(text.includes(displayModuleOption(parameter.key, option)), `${parameter.key}: ${option}`); + } + for (const parameter of localParameters) assert.ok(parameter.description.length <= 80, parameter.key); + assert.doesNotMatch(text, /确保包含国内原标准|十二种算法|始终仅做同源/); +}); + +function moduleArguments(platform, changes = {}) { + const text = platform === 'egern' ? renderLocalModule({ version }) : renderWK3Module(platform, version); + if (platform === 'egern') return { ...jsonLine(text, 'compat_arguments: '), ...changes }; + if (platform === 'stash') { + const original = JSON.parse(text.match(/argument: &wk3-arguments '([^']+)'/)[1]); + return JSON.stringify({ ...original, ...changes }); + } + if (platform === 'surge') { + const header = text.match(/^#!arguments = (.*)$/m)[1]; + const original = Object.fromEntries([...header.matchAll(/(?:^|,)([^:,]+):("(?:[^"\\]|\\.)*")/g)].map(([, key, value]) => [key, JSON.parse(value)])); + const values = { ...original, ...changes }; + return text.match(/, argument=(.*)$/m)[1].replace(/\{\{\{([^}]+)\}\}\}/g, (_, key) => values[key]); + } + const values = text.split('[Argument]\n')[1].split('\n[Rule]')[0].trim().split('\n').map(line => { + const [, key, kind, first] = line.match(/^([^ ]+) = (input|select|switch),("(?:[^"\\]|\\.)*"|[^,]+)/); + return changes[key] ?? (kind === 'switch' ? first === 'true' : JSON.parse(first)); + }); + return JSON.stringify(values); +} + +async function consume(platform, changes) { + const argument = moduleArguments(platform, changes); + if (platform === 'egern') return { settings: configuration({ env: argument }).Settings, env: argument }; + let result, configurationError; + await runPlatform(ctx => { + try { result = { settings: configuration(ctx).Settings, env: ctx.env }; } + catch (error) { configurationError = error; } + }, 'request', { $argument: argument }, platform); + if (configurationError) throw configurationError; + assert.ok(result, 'generated module arguments must parse into Settings'); + return result; +} + +for (const platform of ['egern', 'surge', 'loon', 'stash']) { + test(`${platform}: generated module edits retain types and all scalar choices reach Settings`, async () => { + for (const parameter of localParameters) { + const edits = parameter.boolean ? ['true', 'false'] : parameter.options?.flatMap(value => [value, displayModuleOption(parameter.key, value)]); + if (!edits) continue; + for (const edit of edits) { + const { settings } = await consume(platform, { [parameter.key]: edit }); + assert.equal(get(settings, parameter.key), parameter.boolean ? edit === 'true' : normalizeModuleOption(parameter.key, edit), `${parameter.key}=${edit}`); + } + } + const { settings, env } = await consume(platform, { + DataSets: 'airQuality,forecastNextHour', 'AirQuality.Current.Index.Replace': 'EPA_NowCast', + 'AirQuality.Current.Pollutants.Units.Replace': 'EPA_NowCast,UBA', + 'API.ColorfulClouds.Token': '001234', 'API.QWeather.Token': 'synthetic,token', + 'API.QWeather.Host': 'example.invalid', HTTP_TIMEOUT_MS: '1234', + }); + assert.deepEqual(settings.DataSets, ['airQuality', 'forecastNextHour']); + assert.deepEqual(settings.AirQuality.Current.Index.Replace, ['EPA_NowCast']); + assert.deepEqual(settings.AirQuality.Current.Pollutants.Units.Replace, ['EPA_NowCast', 'UBA']); + assert.equal(settings.API.ColorfulClouds.Token, '001234'); + assert.equal(settings.API.QWeather.Token, 'synthetic,token'); + assert.equal(settings.API.QWeather.Host, 'example.invalid'); + assert.equal(env.HTTP_TIMEOUT_MS, '1234'); + }); + test(`${platform}: generated module [] disables datasets and index replacement`, async () => { + const { settings } = await consume(platform, { DataSets: '[]', 'AirQuality.Current.Index.Replace': '[]', 'Weather.Replace': '[]' }); + assert.deepEqual(settings.DataSets, []); + assert.deepEqual(settings.AirQuality.Current.Index.Replace, []); + assert.deepEqual(settings.Weather.Replace, []); + }); +} + +for (const platform of ['egern', 'surge', 'loon', 'stash']) test(`${platform}: generated defaults and native EAQI request match Egern`, async () => { + const { settings } = await consume(platform, {}); + assert.equal(settings.AirQuality.Current.Index.Provider, 'Calculate'); + assert.equal(settings.AirQuality.Calculate.Algorithm, 'EU_EAQI'); + assert.equal(settings.AirQuality.Comparison.Yesterday.IndexProvider, 'ColorfulCloudsUS'); + assert.equal(settings.AirQuality.Comparison.ReplaceWhenCurrentChange, false); + if (platform !== 'egern') { + const argument = moduleArguments(platform); + let returned; + await runPlatform(async ctx => { + assert.equal(configuration(ctx).Settings.AirQuality.Calculate.Algorithm, 'EU_EAQI'); + return { url: 'https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EU.EAQI', headers: { authorization: 'synthetic' } }; + }, 'request', { $argument: argument, $request: { url: 'https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EU.EAQI.2414' }, $done(value) { returned = value; } }, platform); + assert.equal(returned.url, 'https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EU.EAQI'); + assert.equal(returned.response, undefined); + } +}); +test('Quantumult X without module argument uses the same defaults and retains explicit overrides', async () => { + let settings; + await runPlatform(ctx => { settings = configuration(ctx).Settings; }, 'request', { $request: { url: 'https://example.invalid/' }, $done() {} }, 'Quantumult X'); + assert.equal(settings.AirQuality.Current.Index.Provider, 'Calculate'); + assert.equal(settings.AirQuality.Calculate.Algorithm, 'EU_EAQI'); + assert.equal(settings.AirQuality.Comparison.ReplaceWhenCurrentChange, false); + await runPlatform(ctx => { settings = configuration(ctx).Settings; }, 'request', { $argument: { 'AirQuality.Calculate.Algorithm': '德国LQI' }, $request: { url: 'https://example.invalid/' }, $done() {} }, 'Quantumult X'); + assert.equal(settings.AirQuality.Calculate.Algorithm, 'UBA'); +}); diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs new file mode 100644 index 00000000..dffff569 --- /dev/null +++ b/tests/wk3-modules.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { displayModuleOption } from '../src/wk3/option-values.mjs'; +import { localParameters } from '../src/wk3/parameters.mjs'; +import { buildWK3Modules, groups, platforms, release, renderWK3Module } from '../scripts/build-wk3-modules.mjs'; +import { localScriptGroups } from '../scripts/build-egern-module.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const base = release('3.3.2'); +const rows = (platform, text) => { + if (platform === 'surge') return text.split('\n').filter(line => /^WeatherKit\.api\./.test(line)); + if (platform === 'loon') return text.split('\n').filter(line => /^http-(?:request|response) /.test(line)); + if (platform === 'stash') return text.split('\n').filter(line => /^ - match: /.test(line)); + return text.split('\n').filter(line => /^\^https/.test(line)); +}; + +const examples = [ + ['https://weatherkit.apple.com/api/v1/availability/en_US', 0], + ['https://weatherkit.apple.com/api/v2/availability/en_US', 0], + ['https://weatherkit.apple.com/api/v1/airQualityScale/en_US', 1], + ['https://weatherkit.apple.com/api/v2/airQualityScale/en_US', 1], + ['https://weatherkit.apple.com/api/v1/weatherAlerts?ids=CN-123456789', 2], + ['https://weatherkit.apple.com/api/v2/weatherAlerts?language=zh&ids=CN-123456789', 2], + ['https://weatherkit.apple.com/api/v1/weatherAlerts?ids=31.2,121.5', 3], + ['https://weatherkit.apple.com/api/v2/weatherAlerts?ids=wk3_123', 3], + ['https://weatherkit.apple.com/api/v2/weatherAlerts?ids=wk3_foo-123456789', 3], + ['https://weatherkit.apple.com/api/v2/weather/en_US/31/121', 4], + ['https://weatherkit.apple.com/api/v3/weather/en_US/31/121', 4], +]; + +test('five disjoint WK3 routing groups cover the supported API versions', () => { + assert.equal(groups.length, 5); + for (const [url, expected] of examples) { + assert.deepEqual(groups.flatMap(({ pattern }, i) => new RegExp(pattern).test(url) ? [i] : []), [expected], url); + } + for (const url of ['https://weatherkit.apple.com/api/v1/weather/en_US/', 'https://weatherkit.apple.com/api/v3/availability/', 'https://example.com/api/v3/weather/', 'https://weatherkit.apple.com/api/v1/weatherAlerts?ids=garbage']) { + assert.equal(groups.some(({ pattern }) => new RegExp(pattern).test(url)), false, url); + } +}); + +test('Egern shares all five route groups without overlapping native alert IDs', () => { + assert.equal(localScriptGroups.length, groups.length); + for (const [url, expected] of examples) { + assert.deepEqual(localScriptGroups.flatMap(({ match }, i) => new RegExp(match).test(url) ? [i] : []), [expected], url); + } + for (const [i, group] of groups.entries()) { + assert.equal(localScriptGroups[i].file, group.stage); + assert.equal(localScriptGroups[i].body, Boolean(group.body)); + assert.equal(localScriptGroups[i].binary, Boolean(group.binary)); + } +}); + +for (const platform of Object.keys(platforms)) test(`${platform}: structure, five groups, versioned scripts and MITM`, () => { + const text = renderWK3Module(platform, '3.3.2'); + const rules = rows(platform, text); + assert.equal(rules.length, 5); + assert.match(text, /weatherkit\.apple\.com/); + assert.match(text, /weather-analytics-events\.apple\.com/); + assert.match(text, /tthr\.apple\.com/); + assert.match(text, /tether\.edge\.apple/); + assert.ok(text.includes('WK3') || text.includes('WeatherKit')); + assert.doesNotMatch(text, /__WK3_|releases\/latest|shadowrocket|NSRingo\/WeatherKit\/releases|gist\.githubusercontent/); + for (const [index, group] of groups.entries()) { + assert.ok(rules[index].includes(group.pattern), `${platform} ${group.name} regex`); + const url = `${base}/wk3-${platform}-${group.stage}.bundle.js`; + if (platform === 'stash') { + const provider = text.split(` WeatherKit.api.${group.name}:\n`)[1]?.split(' interval: 86400')[0]; + assert.ok(provider?.includes(`url: ${url}`), `${platform} ${group.name} provider URL`); + } else assert.ok(rules[index].includes(url), `${platform} ${group.name} URL`); + } + assert.equal(text.split(`wk3-${platform}-request.bundle.js`).length - 1, 3); + assert.equal(text.split(`wk3-${platform}-response.bundle.js`).length - 1, 2); + assert.equal((text.match(/v3\.3\.2\//g) ?? []).length, 5); + if (platform === 'surge') { + assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"欧盟EAQI"/m); + assert.match(rules[4], /requires-body=1, binary-body-mode=1/); + assert.match(rules[0], /requires-body=1/); + assert.ok(rules.slice(1, 4).every(line => !line.includes('binary-body-mode=1'))); + assert.match(rules[0], /argument=DataSets="\{\{\{DataSets\}\}\}"&/); + assert.match(text, /hostname = %APPEND% weatherkit\.apple\.com/); + } + if (platform === 'loon') { + const args = text.split('[Argument]\n')[1].split('\n[Rule]')[0].trim().split('\n'); + assert.equal(args.length, 23); + for (const { key } of localParameters) assert.ok(args.some(line => line.startsWith(`${key} = `)), key); + for (const key of ['Weather.ReplaceDaily', 'Weather.ReplaceHourly']) assert.match(args.find(line => line.startsWith(`${key} =`)), /switch,false/); + assert.match(args.find(line => line.startsWith('Weather.Provider =')), /select,"ColorfulClouds","WeatherKit","QWeather"/); + const algorithms = args.find(line => line.startsWith('AirQuality.Calculate.Algorithm =')); + const officialOrder = ['None', 'UBA', 'EU_EAQI', 'WAQI_InstantCast_US', 'WAQI_InstantCast_CN', 'WAQI_InstantCast_CN_25_DRAFT', 'CA_AQHI', 'HK_AQHI', 'AQHI_Multi_CN', 'AQHI_Multi_CN_HK', 'CN_DEATH_AQHI', 'CN_DEATH_HK_AQHI']; + assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"欧盟EAQI",/); + assert.deepEqual(algorithms.split(',tag=')[0].match(/"[^"]+"/g).map(value => JSON.parse(value)), + ['EU_EAQI', ...officialOrder.filter(value => value !== 'EU_EAQI')].map(value => displayModuleOption('AirQuality.Calculate.Algorithm', value))); + assert.match(rules[4], /requires-body=1, binary-body-mode=1/); + assert.match(rules[0], /requires-body=1/); + assert.match(rules[0], /argument=\[\{DataSets\},/); + } + if (platform === 'stash') { + const scripts = text.split(' script:\n')[1].split('script-providers:')[0]; + const providers = text.split('script-providers:\n')[1]; + assert.equal((scripts.match(/^ name:/gm) ?? []).length, 5); + assert.equal((scripts.match(/^ type: request$/gm) ?? []).length, 3); + assert.equal((scripts.match(/^ type: response$/gm) ?? []).length, 2); + assert.equal((scripts.match(/^ require-body: true$/gm) ?? []).length, 2); + assert.equal((scripts.match(/^ binary-mode: true$/gm) ?? []).length, 1); + const sharedArgument = scripts.match(/^ argument: &wk3-arguments '([^']+)'$/m); + assert.ok(sharedArgument, 'one editable parameter object shared by all routes'); + assert.equal((scripts.match(/^ argument: \*wk3-arguments$/gm) ?? []).length, 4); + const argumentsJSON = [JSON.parse(sharedArgument[1])]; + for (const values of argumentsJSON) { + assert.equal(Object.keys(values).length, 23); + assert.equal(values['AirQuality.Calculate.Algorithm'], '欧盟EAQI'); + assert.equal(values['AirQuality.Current.Index.Provider'], 'iRingo内置算法'); + } + for (const group of groups) assert.match(providers, new RegExp(`^ WeatherKit\\.api\\.${group.name.replaceAll('.', '\\.')}:[\\s\\S]*? interval: 86400`, 'm')); + } + if (platform === 'quantumultx') { + assert.match(text, /#\[filter_local\]/); + assert.match(text, /#\[rewrite_local\]/); + assert.match(text, /#\[mitm\]/); + assert.equal((rules.join('\n').match(/script-response-body/g) ?? []).length, 2); + assert.equal((rules.join('\n').match(/script-request-header/g) ?? []).length, 1); + assert.equal((rules.join('\n').match(/script-echo-response/g) ?? []).length, 2); + assert.doesNotMatch(text, /binary-body-mode=1|binary-mode: true/); + } +}); + +test('explicit CLI release and output location; no implicit latest or shadowrocket', () => { + assert.throws(() => renderWK3Module('shadowrocket', '3.3.2'), /Unsupported platform/); + assert.throws(() => renderWK3Module('surge', 'latest'), /explicit release version/); + assert.throws(() => buildWK3Modules({ version: '3.3.2' }), /--outdir/); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wk3-modules-')); + try { + const cli = spawnSync(process.execPath, [path.join(root, 'scripts/build-wk3-modules.mjs'), '--version', '3.3.2', '--outdir', dir], { encoding: 'utf8' }); + assert.equal(cli.status, 0, cli.stderr); + assert.deepEqual(fs.readdirSync(dir).sort(), Object.values(platforms).map(ext => `iRingo.WeatherKit.WK3.${ext}`).sort()); + for (const [platform, ext] of Object.entries(platforms)) { + assert.equal(fs.readFileSync(path.join(dir, `iRingo.WeatherKit.WK3.${ext}`), 'utf8'), renderWK3Module(platform, '3.3.2')); + } + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/tests/wk3-platform-adapter.test.mjs b/tests/wk3-platform-adapter.test.mjs new file mode 100644 index 00000000..0495a7a2 --- /dev/null +++ b/tests/wk3-platform-adapter.test.mjs @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { Builder } from "flatbuffers"; +import { encodeAirQuality, decodeWK3DataSet } from "../src/wk3/class/WeatherKit3.mjs"; +import { runPlatform } from "../src/platform/wk3-adapter.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { renderWK3Module } from "../scripts/build-wk3-modules.mjs"; + +const platforms = ["surge", "loon", "stash", "quantumultx"]; +const bytes = [0, 1, 127, 128, 255]; +const binary = String.fromCharCode(...bytes); + +for (const platform of platforms) { + test(`${platform}: $request/$response/$done, binary and Header contract`, async () => { + let doneCalls = 0, returned; + const globals = { + $platform: platform === "quantumultx" ? "Quantumult X" : platform, + $request: { url: "https://weatherkit.apple.com/api/v2/weather/x", method: "GET", headers: { "X-Request": "kept" } }, + $response: { status: 206, headers: { "content-type": "application/octet-stream", "content-length": "5", "content-encoding": "gzip", "x-original": "yes" }, body: binary }, + $done(value) { doneCalls++; returned = value; }, console: { error(error) { throw error; } }, + }; + let observed; + const binaryUnsupported = platform === "quantumultx"; + await runPlatform(async ctx => { + observed = ctx; + assert.equal(ctx.request.url, globals.$request.url); + assert.equal(ctx.request.headers["X-Request"], "kept"); + assert.equal(ctx.response.status, 206); + assert.deepEqual(Array.from(new Uint8Array(await ctx.response.arrayBuffer())), bytes); + return { status: 201, headers: { "Content-Type": binaryUnsupported ? "application/json" : "application/octet-stream", "Content-Length": "5", "Content-Encoding": "gzip", "X-Patched": "yes" }, body: binaryUnsupported ? JSON.stringify({ ok: true }) : new Uint8Array(bytes) }; + }, "response", globals); + assert.equal(doneCalls, 1); + if (binaryUnsupported) { + assert.equal(observed, undefined, "Quantumult X binary handler must not run"); + assert.equal(returned, undefined, "Quantumult X raw binary response is passed through"); + } else { + assert.ok(observed); + const result = returned; + assert.equal(result.status, 201); + assert.equal(result.headers["X-Patched"], "yes"); + assert.ok(!Object.keys(result.headers).some(k => ["content-length", "content-encoding", "transfer-encoding"].includes(k.toLowerCase()))); + if (platform === "surge" || platform === "loon") { + assert.ok(result.body instanceof Uint8Array); + assert.deepEqual(Array.from(result.body), bytes); + } else assert.deepEqual(Array.from(result.body, c => c.charCodeAt(0)), bytes); + } + + doneCalls = 0; + await runPlatform(async ctx => { assert.equal(ctx.request.url, globals.$request.url); return undefined; }, "request", globals); + assert.equal(doneCalls, 1); + assert.equal(returned, undefined); + }); +} + +test("binary bodyBytes takes precedence over an empty text body", async () => { + let returned; + const globals = { + $request: { url: "https://weatherkit.apple.com/api/v3/weather/x", headers: {} }, + $response: { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: "", bodyBytes: new Uint8Array(bytes) }, + $done(value) { returned = value; }, + }; + await runPlatform(async ctx => { + assert.deepEqual(Array.from(new Uint8Array(await ctx.response.arrayBuffer())), bytes); + return { status: 200, headers: {}, body: new Uint8Array(bytes) }; + }, "response", globals, "surge"); + assert.ok(returned.body instanceof Uint8Array); + assert.deepEqual(Array.from(returned.body), bytes); +}); + +test("all verified platforms produce executable request and response bundles", async () => { + for (const platform of platforms) { + const bundles = await buildPlatform(platform, { minify: false }); + for (const stage of ["request", "response"]) { + const calls = []; + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, console, + $platform: platform === "quantumultx" ? "Quantumult X" : platform, + $environment: {}, $request: { url: "https://elsewhere.invalid/pass-through", method: "GET", headers: {} }, + $response: { status: 503, headers: { "content-type": "text/plain" }, body: "unchanged" }, + $done(value) { calls.push(value); }, + }; + vm.runInNewContext(bundles[stage].code, globals, { timeout: 30000 }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(calls.length, 1, `${platform} ${stage}`); + if (stage === "request") assert.equal(calls[0], undefined); + } + } +}); + +test("Surge bundle patches WK3 AQ using platform HTTP callbacks", async () => { + const builder = new Builder(); + const aq = encodeAirQuality(builder, { metadata: { sourceType: 3, providerName: "Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); + builder.startObject(15); builder.addFieldOffset(0, aq, 0); builder.finish(builder.endObject()); + const original = builder.asUint8Array(); + const bundles = await buildPlatform("surge", { minify: false }); + const calls = [], requests = [], payload = original; + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, console, + $argument: 'AirQuality.Current.Index.Provider="ColorfulCloudsUS"&AirQuality.Current.Index.Replace="HJ6332012"&AirQuality.Comparison.Yesterday.IndexProvider="WeatherKit"&DataSets="airQuality"', + $request: { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality", headers: {} }, + $response: { status: 200, headers: { "content-type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: payload }, + $httpClient: { get(options, done) { + requests.push(options.url); + if (!options.url.includes("/realtime")) throw new Error(`Unexpected source: ${options.url}`); + done(null, { status: 200, headers: { "content-type": "application/json" } }, JSON.stringify({ status: "ok", location: [31, 121], result: { realtime: { status: "ok", air_quality: { description: { usa: "moderate" }, aqi: { usa: 87, chn: 60 }, obs_time: 1893456000, pm25: 12, pm10: 20, co: 0.4, no2: 10, so2: 5, o3: 40 } } } })); + } }, + $done(value) { calls.push(value); }, + }; + vm.runInNewContext(bundles.response.code, globals, { timeout: 30000 }); + await new Promise(resolve => setTimeout(resolve, 300)); + assert.equal(calls.length, 1); + assert.ok(requests.some(url => url.includes("/realtime")), `No provider request: ${JSON.stringify(requests)}`); + const result = calls[0]; + assert.ok(result.body instanceof Uint8Array); + assert.equal(decodeWK3DataSet(result.body).index, 87); +}); + +test("unknown platform is refused instead of advertised as supported", async () => { + await assert.rejects(buildPlatform("shadowrocket", { minify: false }), /Unverified platform/); +}); + +test("platform arguments preserve nested list settings and booleans", async () => { + let captured; + const globals = { + $environment: { "Weather.Provider": "ColorfulClouds" }, + $argument: 'DataSets:["airQuality","forecastNextHour"],Weather.Replace:["CN","HK"],Weather.Provider:QWeather,AirQuality.Calculate.Algorithm:WAQI_InstantCast_CN_25_DRAFT,AirQuality.Calculate.AllowOverRange:false', + $request: { url: "https://weatherkit.apple.com/api/v2/weather/x", headers: {} }, + $done() {}, + }; + let env; + await runPlatform(async ctx => { env = ctx.env; }, "request", globals, "surge"); + assert.deepEqual(env.DataSets, ["airQuality", "forecastNextHour"]); + assert.deepEqual(env["Weather.Replace"], ["CN", "HK"]); + assert.equal(env["Weather.Provider"], "QWeather"); + assert.equal(env["AirQuality.Calculate.Algorithm"], "WAQI_InstantCast_CN_25_DRAFT"); + assert.equal(env["AirQuality.Calculate.AllowOverRange"], false); + + const surge = renderWK3Module("surge", "3.3.2"); + const argumentTemplate = surge.split("\n").find(line => line.startsWith("WeatherKit.api.weather.response =")).split("argument=")[1]; + const actualArgument = argumentTemplate.replaceAll(/\{\{\{([^}]+)\}\}\}/g, (_, key) => key === "Weather.Provider" ? "QWeather" : key === "AirQuality.Calculate.Algorithm" ? "HK_AQHI" : ""); + await runPlatform(async ctx => { env = ctx.env; }, "request", { ...globals, $argument: actualArgument }, "surge"); + assert.equal(env["Weather.Provider"], "QWeather"); + assert.equal(env["AirQuality.Calculate.Algorithm"], "HK_AQHI"); + + let loonEnv; + const loon = { $argument: '["airQuality,forecastNextHour","CN,HK","QWeather","false","false","QWeatherWeb","ColorfulClouds","ColorfulCloudsUS","HJ6332012","true","ColorfulClouds","[]","Scale","true","ColorfulCloudsUS","ColorfulCloudsCN","WAQI_InstantCast_CN_25_DRAFT","false"]', $request: globals.$request, $done() {} }; + await runPlatform(async ctx => { loonEnv = ctx.env; }, "request", loon, "loon"); + assert.equal(loonEnv.DataSets, "airQuality,forecastNextHour"); + assert.equal(loonEnv["Weather.Provider"], "QWeather"); + assert.equal(loonEnv["AirQuality.Calculate.Algorithm"], "WAQI_InstantCast_CN_25_DRAFT"); + assert.equal(loonEnv["AirQuality.Calculate.AllowOverRange"], false); +}); diff --git a/tests/wk3-platform-alert-compat.test.mjs b/tests/wk3-platform-alert-compat.test.mjs new file mode 100644 index 00000000..fb4b4c35 --- /dev/null +++ b/tests/wk3-platform-alert-compat.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { Builder } from "flatbuffers"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { readAlertHeader, encodeAlertBase64, decodeAlertBase64 } from "../src/platform/wk3-alert-compat.mjs"; +import { buildNativeAlertDetailsURL } from "../src/wk3/class/NativeAlertDetails.mjs"; +import { decodeWK3Alerts } from "../src/wk3/class/WK3Alerts.mjs"; + +const identifier = "synthetic-101010100"; +const issuedTime = Date.parse("2030-01-01T00:00:00Z") / 1000; +const uuid = "00010203-0405-0607-0809-0a0b0c0d0e0f"; +const original = { id: { bytes: Array.from({ length: 16 }, (_, i) => i) }, description: "大风", issuedTime, severity: 4 }; +const html = '<div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00:00+08:00</p><p class="warning-events__txt">注意防范大风。</p></div>'; +const nativeURL = () => new URL(buildNativeAlertDetailsURL({ identifier, alerts: [original] })); +const detailsURL = ids => `https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=${ids}`; + +function fixture() { + const b = new Builder(); + const details = b.createString(`https://www.qweather.com/severe-weather/${identifier}.html?from=AppleWeatherService`); + const description = b.createString(original.description); + b.startVector(1, 16, 1); + for (let i = 15; i >= 0; i--) b.addInt8(i); + const bytes = b.endVector(); + b.startObject(1); b.addFieldOffset(0, bytes, 0); const id = b.endObject(); + b.startObject(22); + b.addFieldOffset(0, id, 0); b.addFieldOffset(5, description, 0); b.addFieldOffset(12, details, 0); + b.addFieldInt32(9, issuedTime, 0); b.addFieldInt8(14, 4, 0); + const alert = b.endObject(); + b.startVector(4, 1, 4); b.addOffset(alert); const alerts = b.endVector(); + b.startObject(3); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); const collection = b.endObject(); + b.startObject(10); b.addFieldOffset(9, collection, 0); b.finish(b.endObject()); + return b.asUint8Array(); +} + +async function execute(code, platform, url, { stage = "request", header = "Content-Type", contentType = "text/html" } = {}) { + const requests = []; + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, + console: { log() {}, info() {}, debug() {}, warn() {}, error() {} }, + $argument: { DataSets: ["weatherAlerts"] }, + $request: { url, method: "GET", headers: {} }, + $response: stage === "response" ? { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: fixture() } : undefined, + $done: finish, + }; + const fetch = options => { + requests.push(options); + assert.equal(options.url, `https://www.qweather.com/severe-weather/${identifier}.html?from=AppleWeatherService`); + assert.deepEqual(Object.keys(options.headers), ["accept"]); + assert.equal(options.headers.accept, "text/html"); + return { status: 200, headers: { [header]: contentType }, body: html }; + }; + if (platform === "quantumultx") globals.$task = { fetch: async options => ({ ...fetch(options), statusCode: 200 }) }; + else globals.$httpClient = { get(options, callback) { const response = fetch(options); callback(null, response, response.body); } }; + assert.equal("btoa" in globals, false); + assert.equal("atob" in globals, false); + vm.runInNewContext(code, globals, { timeout: 30000 }); + const timer = setTimeout(() => finish({ timeout: true }), 5000); + try { + const result = await completed; + assert.ok(!result?.timeout, "platform completed"); + return { result: platform !== "quantumultx" && stage === "request" ? result?.response : result, requests }; + } finally { clearTimeout(timer); } +} + +test("platform alert helpers cover case-insensitive headers and all base64 byte/padding boundaries", () => { + for (const key of ["content-type", "Content-Type", "CONTENT-TYPE", "cOnTeNt-TyPe"]) assert.equal(readAlertHeader({ [key]: "text/html" }, "content-type"), "text/html"); + assert.equal(readAlertHeader(new Headers({ "Content-Type": "text/html" }), "content-type"), "text/html"); + assert.equal(readAlertHeader(undefined, "content-type"), undefined); + for (const length of [0, 1, 2, 3, 4, 255, 256, 257, 11998, 11999, 12000]) { + const bytes = Uint8Array.from({ length }, (_, i) => i & 255); + const encoded = encodeAlertBase64(bytes); + assert.equal(encoded, Buffer.from(bytes).toString("base64")); + assert.equal(decodeAlertBase64(encoded), Buffer.from(bytes).toString("latin1")); + } + for (const value of ["!", "a", "aa=", "====", "a==="]) assert.throws(() => decodeAlertBase64(value)); +}); + +for (const platform of ["surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: minified alerts accept plain Content-Type without host base64 and retain native UUID roundtrip`, async () => { + const bundles = await buildPlatform(platform, { outdir: process.env.WK3_TEST_OUTDIR ? `${process.env.WK3_TEST_OUTDIR}/${platform}` : undefined }); + for (const stage of ["request", "response"]) assert.doesNotMatch(bundles[stage].code, /\b(?:btoa|atob)\b/); + let url = nativeURL(); + const response = await execute(bundles.response.code, platform, "https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts", { stage: "response" }); + if (platform === "quantumultx") { + assert.equal(response.result, undefined, "existing Quantumult X binary pass-through is retained"); + assert.equal(response.requests.length, 0); + } else { + assert.equal(response.requests.length, 1); + const bytes = typeof response.result.body === "string" ? Uint8Array.from(response.result.body, c => c.charCodeAt(0)) : response.result.body; + const patched = decodeWK3Alerts(bytes); + assert.equal(patched.alerts[0].description, "大风蓝色预警"); + assert.deepEqual(patched.alerts[0].id, original.id); + url = new URL(patched.detailsUrl); + assert.equal(url.origin, "https://weatherkit.apple.com"); + assert.equal(patched.alerts[0].detailsUrl, url.toString()); + assert.equal(url.searchParams.get("ids"), new URL(buildNativeAlertDetailsURL({ identifier, alerts: [{ ...original, description: "大风蓝色预警" }] })).searchParams.get("ids")); + } + const ids = url.searchParams.get("ids"); + for (const header of ["Content-Type", "content-type", "cOnTeNt-TyPe"]) { + const { result, requests } = await execute(bundles.request.code, platform, detailsURL(ids), { header }); + assert.equal(requests.length, 1); + const details = JSON.parse(result.body); + assert.equal(details.length, 1); + assert.equal(details[0].id, uuid); + assert.equal(details[0].detailsUrl, `#${uuid}`); + assert.equal(details[0].description, "大风蓝色预警"); + assert.equal(details[0].severity, "minor"); + assert.equal(details[0].issuedTime, "2030-01-01T00:00:00.000Z"); + assert.match(details[0].messages[0].text, /注意防范/); + } + const region = await execute(bundles.request.code, platform, detailsURL(identifier)); + assert.equal(JSON.parse(region.result.body).length, 1); + const wrongType = await execute(bundles.request.code, platform, detailsURL(ids), { contentType: "application/json" }); + assert.deepEqual(JSON.parse(wrongType.result.body), []); + const payload = JSON.parse(Buffer.from(ids.slice(4), "base64url")); + const invalidShape = "wk3_" + Buffer.from(JSON.stringify({ ...payload, extra: true })).toString("base64url"); + // Valid JSON with noncanonical unused bits must still fail before HTTP. + const noncanonical = "wk3_" + Buffer.from("{}").toString("base64url").slice(0, -1) + "1"; + for (const malformed of ["wk3_!", "wk3_a", "wk3__w", ids + "=", invalidShape, noncanonical]) { + const rejected = await execute(bundles.request.code, platform, detailsURL(malformed)); + assert.deepEqual(JSON.parse(rejected.result.body), []); + assert.equal(rejected.requests.length, 0); + } + }); +} diff --git a/tests/wk3-platform-settings.test.mjs b/tests/wk3-platform-settings.test.mjs new file mode 100644 index 00000000..fa5ce066 --- /dev/null +++ b/tests/wk3-platform-settings.test.mjs @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { runPlatform } from "../src/platform/wk3-adapter.mjs"; +import { localParameters } from "../src/wk3/parameters.mjs"; + +const bundles = new Map(); +async function bundle(platform) { + if (!bundles.has(platform)) bundles.set(platform, buildPlatform(platform, { minify: false })); + return bundles.get(platform); +} +async function execute(code, values) { + const logs = [], requests = []; + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, + console: Object.fromEntries(["debug", "info", "warn", "error", "log"].map(key => [key, (...args) => logs.push([key, ...args])])), + $environment: { HTTP_TIMEOUT_MS: "9999", LogLevel: "ALL" }, + $request: { url: "https://weatherkit.apple.com/api/v2/airQualityScale/en/HJ6332012", headers: {} }, + $response: { status: 200, headers: { "content-type": "application/json" }, body: "{}" }, + $httpClient: { get(options, callback) { + requests.push(options); + callback(null, { status: 200, headers: { "content-type": "application/json" } }, "{}"); + } }, + $task: { async fetch(options) { + requests.push(options); + return { statusCode: 200, headers: { "content-type": "application/json" }, body: "{}" }; + } }, + $done(value) { finish(value); }, + ...values, + }; + vm.runInNewContext(code, globals, { timeout: 30000 }); + const result = await completed; + return { result, logs, requests, globals }; +} + +for (const platform of ["surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: generated bundle consumes argument timeout and OFF`, async () => { + const files = await bundle(platform); + const values = localParameters.map(parameter => parameter.key === "HTTP_TIMEOUT_MS" ? "1234" : parameter.key === "LogLevel" ? "OFF" : parameter.value); + const argument = platform === "loon" ? JSON.stringify(values) + : platform === "stash" ? { HTTP_TIMEOUT_MS: "1234", LogLevel: "OFF" } + : platform === "quantumultx" ? '{"HTTP_TIMEOUT_MS":"1234","LogLevel":"OFF"}' + : 'HTTP_TIMEOUT_MS="1234"&LogLevel="OFF"'; + const options = { $argument: argument, ...(platform === "quantumultx" ? { $httpClient: undefined } : {}) }; + const request = await execute(files.request.code, options); + assert.equal(request.requests.length, 1); + const response = await execute(files.response.code, { + ...options, + $request: { url: "https://weatherkit.apple.com/api/v2/availability/en", headers: {} }, + }); + assert.equal(response.logs.length, 0, JSON.stringify(response.logs)); + const visible = await execute(files.response.code, { + ...options, $argument: { LogLevel: "INFO" }, + $request: { url: "https://weatherkit.apple.com/api/v2/availability/en", headers: {} }, + }); + assert.ok(visible.logs.length > 0, "INFO control must exercise the logger"); + assert.equal(request.requests[0].timeout, 1234); + assert.equal(request.globals.fetch, undefined, "native fetch must not be patched"); + assert.equal(request.globals.$environment.HTTP_TIMEOUT_MS, "9999"); + }); +} + +for (const platform of ["surge", "loon", "stash"]) { + test(`${platform}: generated binary response keeps bodyBytes over empty body`, async () => { + const files = await bundle(platform); + const bytes = new Uint8Array([0, 1, 127, 128, 255]); + const result = await execute(files.response.code, { + $argument: { DataSets: [], LogLevel: "OFF" }, + $request: { url: "https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality", headers: {} }, + $response: { status: 200, headers: { "content-type": "application/vnd.apple.flatbuffer" }, body: "", bodyBytes: bytes }, + }); + assert.deepEqual(Array.from(result.result.body, c => typeof c === "number" ? c : c.charCodeAt(0)), Array.from(bytes)); + assert.equal(result.logs.length, 0); + assert.equal(result.requests.length, 0); + }); +} + +for (const platform of ["surge", "loon", "stash"]) { + for (const stage of ["request", "response"]) { + test(`${platform}: ${stage} $done has documented shape and preserves bytes`, async () => { + let returned; + const bytes = new Uint8Array([0, 1, 127, 128, 255]); + await runPlatform(async ctx => { + if (stage === "response") assert.deepEqual(new Uint8Array(await ctx.response.arrayBuffer()), bytes); + return { status: 201, headers: { "Content-Type": "application/octet-stream" }, body: bytes }; + }, stage, { + $request: { url: "https://example.invalid", headers: {} }, + $response: { status: 200, headers: { "Content-Type": "application/octet-stream" }, body: "", bodyBytes: bytes }, + $done(value) { returned = value; }, + }, platform); + const result = stage === "request" ? returned.response : returned; + assert.equal(result.status, 201); + assert.deepEqual(Array.from(result.body, c => typeof c === "number" ? c : c.charCodeAt(0)), Array.from(bytes)); + }); + } +} + +test("Quantumult X binary response remains untouched", async () => { + let called = false, returned = "pending"; + await runPlatform(async () => { called = true; }, "response", { + $response: { headers: { "content-type": "application/octet-stream" }, bodyBytes: new Uint8Array([128, 255]) }, + $done(value) { returned = value; }, + }, "Quantumult X"); + assert.equal(called, false); + assert.equal(returned, undefined); +}); diff --git a/tests/wk3-release-build.test.mjs b/tests/wk3-release-build.test.mjs new file mode 100644 index 00000000..c00f0233 --- /dev/null +++ b/tests/wk3-release-build.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { buildWK3Release } from "../scripts/build-wk3-release.mjs"; +import { renderLocalModule } from "../scripts/build-egern-module.mjs"; + +const platforms = ["egern", "surge", "loon", "stash", "quantumultx"]; +const extensions = ["yaml", "sgmodule", "lpx", "stoverride", "snippet"]; + +test("tagged WK3 build publishes all platform scripts and matching modules", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "wk3-release-")); + try { + const result = await buildWK3Release({ outdir: dir, version: "3.3.2" }); + assert.deepEqual(result, { scripts: 10, modules: 5 }); + const files = await readdir(dir); + for (const platform of platforms) for (const stage of ["request", "response"]) { + const file = `wk3-${platform}-${stage}.bundle.js`; + assert.ok(files.includes(file), file); + assert.ok((await stat(path.join(dir, file))).size > 1000, file); + } + for (const [index, extension] of extensions.entries()) { + const file = `iRingo.WeatherKit.WK3.${extension}`; + const text = await readFile(path.join(dir, file), "utf8"); + assert.ok(text.length > 500, file); + const references = [...text.matchAll(/https:\/\/github\.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/(wk3-[a-z]+-(?:request|response)\.bundle\.js)/g)].map(match => match[1]); + assert.deepEqual(references.sort(), [ + ...Array(3).fill(`wk3-${platforms[index]}-request.bundle.js`), + ...Array(2).fill(`wk3-${platforms[index]}-response.bundle.js`), + ].sort(), `${file} must reference exactly its own five versioned scripts`); + for (const asset of references) assert.ok(files.includes(asset), `${file} references missing ${asset}`); + } + const egern = await readFile(path.join(dir, "iRingo.WeatherKit.WK3.yaml"), "utf8"); + assert.equal(egern, renderLocalModule({ version: "3.3.2" })); + assert.equal((egern.match(/script_url: https:\/\/github\.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/wk3-egern-(?:request|response)\.bundle\.js/g) ?? []).length, 5); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test("release build refuses an implicit output path or unversioned tag", async () => { + await assert.rejects(buildWK3Release({ version: "3.3.2" }), /--outdir/); + await assert.rejects(buildWK3Release({ outdir: path.join(os.tmpdir(), "unused"), version: "latest" }), /explicit release version/); +}); diff --git a/tests/wk3-surge-binary.test.mjs b/tests/wk3-surge-binary.test.mjs new file mode 100644 index 00000000..7780a524 --- /dev/null +++ b/tests/wk3-surge-binary.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runPlatform } from '../src/platform/wk3-adapter.mjs'; + +const bytes = new Uint8Array([0,1,127,128,195,255]); +for (const stage of ['request','response']) test(`Surge ${stage}: binary body is a native Uint8Array with exact view bounds`, async () => { + for (const body of [bytes, bytes.buffer, new Uint8Array([99,...bytes,99]).subarray(1,-1)]) { + let output; + await runPlatform(async () => ({ status:200, headers:{'Content-Type':'application/vnd.apple.flatbuffer'}, body }), stage, { + $request:{url:'https://weatherkit.apple.com/api/v3/weather/zh/31/121'}, + $response:{status:200,headers:{},body:bytes}, + $done:value=>{ output=stage==='request'?value.response:value; }, + }, 'surge'); + assert.ok(output.body instanceof Uint8Array, 'Surge binary-body-mode must receive native bytes, not UTF-8 text'); + assert.deepEqual([...output.body],[...bytes]); + } +}); +test('Surge text responses remain strings', async () => { + let result; + await runPlatform(async()=>({status:200,headers:{},body:'天气测试'}),'response',{$response:{},$done:value=>{result=value;}},'surge'); + assert.equal(result.body,'天气测试'); +}); diff --git a/tests/wk3/aq-algorithm-scales.test.mjs b/tests/wk3/aq-algorithm-scales.test.mjs new file mode 100644 index 00000000..e9d5bb85 --- /dev/null +++ b/tests/wk3/aq-algorithm-scales.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import AirQuality from "../../src/wk3/class/AirQuality.mjs"; +import AirQualityScale from "../../src/wk3/class/AirQualityScale.mjs"; +import { Response as processResponse } from "../../src/wk3/process/Response.mjs"; + +const algorithms = [ + "UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", + "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", +]; +function calculate(algorithm, amount = 25) { + return AirQuality.Pollutants2AQI({ + metadata: { providerName: "Synthetic", temporarilyUnavailable: false }, + pollutants: ["PM2_5", "PM10", "OZONE", "NO2", "SO2"].map(pollutantType => ({ + pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER", + })), + }, {}, { algorithm }); +} +function local(scaleName, language = "en-US") { + const result = AirQualityScale.buildScale(language, scaleName); + assert.ok(result, `missing local fallback for ${scaleName}`); + assert.equal(result.status, 200); + return JSON.parse(result.body); +} +for (const algorithm of algorithms) { + test(`${algorithm}: emitted scale has matching identity, category count and display ranges`, () => { + const definition = AirQuality.Config.Scales[algorithm]; + const current = calculate(algorithm); + const scale = local(current.scale); + assert.equal(scale.name, definition.weatherKitScale.name); + assert.equal(scale.version, Number(definition.weatherKitScale.version ?? 1)); + assert.equal(scale.aqi.categories.length, definition.categories.ranges.length); + assert.equal(scale.aqi.numerical, algorithm !== "EU_EAQI"); + assert.equal(scale.aqi.ascending, true); + for (const amount of [0, 1, 25, 100, 300]) { + const aq = calculate(algorithm, amount); + assert.equal(aq.metadata.temporarilyUnavailable, false); + const category = scale.aqi.categories.find(item => item.categoryNumber === aq.categoryIndex); + assert.ok(category, `${algorithm}: missing category ${aq.categoryIndex}`); + assert.ok(aq.index >= category.range[0] && aq.index <= category.range[1], + `${algorithm}: index ${aq.index}, category ${aq.categoryIndex}, range ${category.range}`); + assert.ok(aq.index >= scale.aqi.range[0] && aq.index <= scale.aqi.range[1], + `${algorithm}: index ${aq.index} outside scale range ${scale.aqi.range}`); + } + }); +} + +test("AQHI names retain their dotted identity with or without a numeric version suffix", () => { + for (const name of ["HK.AQHI", "CN.AQHI", "CA.AQHI"]) { + assert.equal(local(name).name, name); + assert.equal(local(name).version, 1); + for (const version of [2414, 2604]) { + assert.equal(local(`${name}.${version}`).name, name); + assert.equal(local(`${name}.${version}`).version, version); + } + } +}); + +test("Canada AQHI uses Canadian health risk bands, 1..10 plus 10+, without HK labels", () => { + for (const language of ["en-US", "zh-Hans-CN", "zh-Hant-HK", "zh-Hant-TW"]) { + const scale = local("CA.AQHI", language); + assert.deepEqual(scale.aqi.range, [1, 11]); + assert.equal(scale.name, "CA.AQHI"); + assert.match(scale.longDisplayName, /Canada|加拿大/); + assert.doesNotMatch(scale.longDisplayName, /Hong Kong|香港/); + assert.equal(scale.aqi.categories.length, 11); + for (const [i, category] of scale.aqi.categories.entries()) { + assert.equal(category.categoryNumber, i + 1); + assert.deepEqual(category.range, [i + 1, i + 1]); + assert.ok(category.categoryName); + assert.ok(category.recommendation); + assert.match(category.color, /^#[0-9A-F]{6}$/i); + } + } + const categories = local("CA.AQHI").aqi.categories; + assert.deepEqual(categories.map(item => item.categoryName), [ + "Low Health Risk", "Low Health Risk", "Low Health Risk", + "Moderate Health Risk", "Moderate Health Risk", "Moderate Health Risk", + "High Health Risk", "High Health Risk", "High Health Risk", "High Health Risk", "Very High Health Risk", + ]); + assert.notDeepEqual(categories, local("HK.AQHI").aqi.categories); +}); + +for (const algorithm of algorithms) { + test(`${algorithm}: Apple 404 falls back through the real response route`, async () => { + const current = calculate(algorithm); + const original = { status: 404, headers: { "content-type": "application/json" }, body: '{"error":"not found"}' }; + const result = await processResponse({ + url: `https://weatherkit.apple.com/api/v2/airQualityScale/en-US/${current.scale}`, + headers: {}, + }, original, { Settings: {} }); + assert.equal(result.status, 200); + const scale = JSON.parse(result.body); + assert.equal(scale.name, AirQuality.Config.Scales[algorithm].weatherKitScale.name); + assert.ok(scale.aqi.categories.some(item => item.categoryNumber === current.categoryIndex)); + }); +} + +test("Apple 200 remains unchanged and unknown local standards stay unsupported", async () => { + const response = { status: 200, headers: { "content-type": "application/json" }, body: '{"name":"Apple original"}' }; + const result = await processResponse({ + url: "https://weatherkit.apple.com/api/v2/airQualityScale/en-US/CA.AQHI", headers: {}, + }, response, { Settings: {} }); + assert.equal(result, response); + assert.equal(result.body, '{"name":"Apple original"}'); + for (const name of ["Other.AQHI", "CABogus.AQHI", "HK.Other", "EU.Other", "CA.AQHI.extra"]) { + assert.equal(AirQualityScale.buildScale("en-US", name), null, name); + } +}); + +test("all algorithm display indices map to exactly their numbered category", () => { + const expected = { + "UBA.2414": [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]], + "EU.EAQI.2414": [[0, 9], [10, 19], [20, 29], [30, 39], [40, 49], [50, 60]], + "HJ6332012.2604": [[0, 50], [51, 100], [101, 150], [151, 200], [201, 300], [301, 500]], + "EPA_NowCast.2414": [[0, 50], [51, 100], [101, 150], [151, 200], [201, 300], [301, 500]], + "HK.AQHI": Array.from({ length: 11 }, (_, i) => [i + 1, i + 1]), + "CA.AQHI": Array.from({ length: 11 }, (_, i) => [i + 1, i + 1]), + "CN.AQHI": Array.from({ length: 11 }, (_, i) => [i + 1, i + 1]), + }; + for (const [name, ranges] of Object.entries(expected)) { + const scale = local(name); + assert.deepEqual(scale.aqi.categories.map(item => item.range), ranges, name); + for (let value = scale.aqi.range[0]; value <= scale.aqi.range[1]; value++) { + assert.equal(scale.aqi.categories.filter(item => value >= item.range[0] && value <= item.range[1]).length, 1, + `${name}: index ${value} must map to one category`); + } + } +}); + +test("existing HK/CN AQHI bands, colors and ranges remain intact", () => { + const expected = { + "HK.AQHI": { + colors: ["#04DE71", "#04DE71", "#58E156", "#FFE620", "#FFBE10", "#FF9500", "#FA114F", "#D11343", "#A91537", "#80172B", "#80172B"], + names: ["低", "低", "低", "中", "中", "中", "高", "甚高", "甚高", "甚高", "严重"], + }, + "CN.AQHI": { + colors: ["#2094FA", "#04DE71", "#CCFF66", "#FFE620", "#FF9500", "#FA114F", "#DC1346", "#BD143D", "#9F1634", "#80172B", "#80172B"], + names: ["极低", "低", "低", "中", "中", "高", "高", "高", "高", "高", "极高"], + }, + }; + for (const [name, { colors, names }] of Object.entries(expected)) { + const scale = local(name, "zh-Hans-CN"); + assert.deepEqual(scale.aqi.range, [1, 11]); + assert.deepEqual(scale.aqi.categories.map(item => item.color), colors, name); + assert.deepEqual(scale.aqi.categories.map(item => item.categoryName), names, name); + } + assert.deepEqual(local("UBA.2414").aqi.categories.map(item => item.color), ["#00C853", "#64DD17", "#FFEB3B", "#FF9800", "#F44336"]); + assert.deepEqual(local("EPA_NowCast.2414").aqi.categories.map(item => item.color), ["#00E400", "#FFFF00", "#FF7E00", "#FF0000", "#99004C", "#7E0023"]); +}); diff --git a/tests/wk3/aq-algorithms.test.mjs b/tests/wk3/aq-algorithms.test.mjs new file mode 100644 index 00000000..d31ccc9f --- /dev/null +++ b/tests/wk3/aq-algorithms.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { default: AirQuality } = await import("../../src/wk3/class/AirQuality.mjs"); + +const pollutants = [ + { pollutantType: "PM2_5", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "PM10", amount: 50, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "OZONE", amount: 100, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "NO2", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "SO2", amount: 40, units: "MICROGRAMS_PER_CUBIC_METER" }, +]; +const input = { metadata: { providerName: "test" }, pollutants }; +const algorithms = [ + "None", "UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", + "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", + "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", +]; + +test("WK3 exposes all twelve upstream AQ algorithms without silent UBA fallback", () => { + for (const algorithm of algorithms) { + if (algorithm === "None") { + assert.equal(AirQuality.Pollutants2AQI(input, {}, { algorithm }), input); + continue; + } + assert.ok(AirQuality.Config.Scales[algorithm], `missing scale: ${algorithm}`); + const result = AirQuality.Pollutants2AQI(input, {}, { algorithm }); + assert.equal(result.metadata?.temporarilyUnavailable, false, algorithm); + assert.ok(Number.isFinite(result.index), `${algorithm}: index`); + assert.ok(Number.isFinite(result.categoryIndex), `${algorithm}: categoryIndex`); + assert.equal(result.scale, AirQuality.ToWeatherKitScale(AirQuality.Config.Scales[algorithm].weatherKitScale), `${algorithm}: scale`); + } +}); + +test("AQHI algorithms use their distinct upstream risk/index calculation", () => { + const expected = { + HK_AQHI: ["HK.AQHI", 5], + CA_AQHI: ["CA.AQHI", 5], + CN_DEATH_AQHI: ["CN.AQHI", 4], + CN_DEATH_HK_AQHI: ["CN.AQHI", 5], + AQHI_Multi_CN: ["CA.AQHI", 4], + AQHI_Multi_CN_HK: ["CA.AQHI", 4], + }; + for (const [algorithm, [scale, categoryIndex]] of Object.entries(expected)) { + const result = AirQuality.Pollutants2AQI(input, {}, { algorithm }); + assert.equal(result.scale, scale, algorithm); + assert.equal(result.categoryIndex, categoryIndex, algorithm); + assert.equal(result.index, categoryIndex, `${algorithm}: AQHI index mirrors category`); + } +}); + +test("unknown algorithm fails closed rather than pretending to be UBA", () => { + const actual = AirQuality.Pollutants2AQI(input, {}, { algorithm: "not-a-supported-algorithm" }); + assert.equal(actual.metadata?.temporarilyUnavailable, true); + assert.deepEqual(actual.pollutants, pollutants); + assert.notEqual(actual.scale, AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.UBA.weatherKitScale)); +}); diff --git a/tests/wk3/aq-all-algorithms.test.mjs b/tests/wk3/aq-all-algorithms.test.mjs new file mode 100644 index 00000000..b7e8fcea --- /dev/null +++ b/tests/wk3/aq-all-algorithms.test.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import { setLogEnv } from '../../src/wk3/utils/Console.mjs'; + +setLogEnv({ LogLevel: 'OFF' }); +const ug = 'MICROGRAMS_PER_CUBIC_METER', ppb = 'PARTS_PER_BILLION'; +const sample = () => ({ metadata: { providerName: 'Synthetic' }, pollutants: [ + ['PM2_5', 25], ['PM10', 50], ['OZONE', 100], ['NO2', 25], ['SO2', 40], ['CO', 600], +].map(([pollutantType, amount]) => ({ pollutantType, amount, units: ug })) }); +// Measured against upstream b4a75db and the actual response.dev.bundle.js. +// CA retains the existing corrected ppb conversion and rounded AQHI formula. +const cases = [ + ['UBA', 'UBA.2414', 3, 3, 'PM10', [2.636428571429, 2.837692307692, 2.376478873239, 1.729473684211, 2.30724137931, -1]], + ['EU_EAQI', 'EU.EAQI', 22, 3, 'PM2_5', [22.382352941176, 20.486486486486, 19, 19, 19, -1]], + ['WAQI_InstantCast_US', 'EPA_NowCast.2414', 81, 2, 'PM2_5', [80.623574144487, 46.296296296296, 40.72, 12.264150943396, 21.428571428571, 5.681818181818]], + ['WAQI_InstantCast_CN', 'HJ6332012.2604', 50, 1, 'NOT_AVAILABLE', [35.714285714286, 50, 31.25, 12.5, 13.333333333333, 6]], + ['WAQI_InstantCast_CN_25_DRAFT', 'HJ6332012.2604', 50, 1, 'NOT_AVAILABLE', [41.666666666667, 50, 31.25, 12.5, 13.333333333333, 6]], + ['CA_AQHI', 'CA.AQHI', 5, 5, 'OZONE', [1.224941701489, 0, 2.774951470588, 1.164683764963, 0, -1]], + ['HK_AQHI', 'HK.AQHI', 5, 5, 'OZONE', [0.546630351405, 1.420875321381, 5.009468889136, 1.121886218007, 0.558849771744, -1]], + ['AQHI_Multi_CN', 'CA.AQHI', 4, 4, 'OZONE', [3.0162, 0, 27.1273, 9.769525, 8.68896, -1]], + ['AQHI_Multi_CN_HK', 'CA.AQHI', 4, 4, 'OZONE', [3.0162, 7.80615, 27.1273, 9.769525, 8.68896, -1]], + ['CN_DEATH_AQHI', 'CN.AQHI', 4, 4, 'OZONE', [0.551515276734, 0, 2.429031789062, 2.275503416445, 2.388068369577, -1]], + ['CN_DEATH_HK_AQHI', 'CN.AQHI', 5, 5, 'OZONE', [0.546630351405, 1.420875321381, 2.429031789062, 2.275503416445, 2.388068369577, -1]], +]; +const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-8, `${message}: ${actual} != ${expected}`); +function settings(algorithm, mode = 'Scale', replace = []) { + return buildSettings(database, { AirQuality: { Calculate: { Algorithm: algorithm, AllowOverRange: true }, Current: { Index: { Provider: 'Calculate', ForceCNPrimaryPollutants: false }, Pollutants: { Units: { Mode: mode, Replace: replace } } } } }).Settings; +} +for (const [algorithm, scale, index, categoryIndex, primaryPollutant, indexes] of cases) { + const name = AirQuality.GetNameFromScale(scale); + test(`${algorithm}: configured calculation, six pollutant values and schema`, () => { + const input = sample(); + const saved = structuredClone(input); + Object.freeze(input.metadata); + input.pollutants.forEach(Object.freeze); + Object.freeze(input.pollutants); + Object.freeze(input); + const result = AirQuality.Pollutants2AQI(input, settings(algorithm)); + assert.equal(result.scale, scale); + assert.equal(result.index, index); + assert.equal(result.categoryIndex, categoryIndex); + assert.equal(result.primaryPollutant, primaryPollutant); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(typeof result.isSignificant, 'boolean'); + assert.equal(result.pollutants.length, 6); + assert.deepEqual(Object.keys(result).sort(), ['index', 'categoryIndex', 'primaryPollutant', 'scale', 'isSignificant', 'metadata', 'pollutants'].sort()); + for (let i = 0; i < 6; i++) { + const p = result.pollutants[i], original = saved.pollutants[i]; + assert.equal(p.pollutantType, original.pollutantType); + const factor = AirQuality.Config.Scales[algorithm].pollutants[p.pollutantType]?.stpConversionFactor; + const amount = p.units === ug ? p.amount : AirQuality.ConvertUnit(p.amount, p.units, ug, factor); + close(amount, original.amount, `${algorithm}/${p.pollutantType} concentration`); + close(p.index, indexes[i], `${algorithm}/${p.pollutantType} index`); + } + assert.deepEqual(input, saved); + }); + test(`${algorithm}: six actual zeros are usable and preserved`, () => { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 0; }); + const result = AirQuality.Pollutants2AQI(input, settings(algorithm)); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(result.categoryIndex, 1); + assert.equal(result.index, algorithm === 'UBA' || algorithm.includes('AQHI') ? 1 : 0); + assert.equal(result.pollutants.length, 6); + assert.ok(result.pollutants.every(p => p.amount === 0)); + }); + test(`${algorithm}: AllowOverRange follows upstream per-algorithm policy`, () => { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 100000; }); + const capped = AirQuality.Pollutants2AQI(input, settings(algorithm), { allowOverRange: false }); + const extended = AirQuality.Pollutants2AQI(input, settings(algorithm), { allowOverRange: true }); + assert.equal(capped.metadata.temporarilyUnavailable, false); + assert.equal(extended.metadata.temporarilyUnavailable, false); + const max = algorithm === 'UBA' ? 5 : algorithm === 'EU_EAQI' ? 60 : algorithm.includes('AQHI') ? 11 : 500; + assert.equal(capped.index, max); + assert.equal(extended.index, algorithm === 'WAQI_InstantCast_US' ? 199051 : algorithm.startsWith('WAQI_InstantCast_CN') ? 99900 : max); + assert.equal(capped.categoryIndex, extended.categoryIndex); + }); + for (const mode of ['None', 'Scale', 'ugm3', 'EU_ppb', 'US_ppb', 'Force_ugm3', 'Force_EU_ppb', 'Force_US_ppb']) { + test(`${algorithm}: unit conversion ${mode} preserves six physical pollutants`, () => { + const input = sample(); + const calculated = AirQuality.Pollutants2AQI(input, settings(algorithm)); + const configured = settings(algorithm, mode, [name]); + const result = AirQuality.ConvertPollutants(input, undefined, true, calculated, configured); + assert.equal(result.length, 6); + for (let i = 0; i < 6; i++) { + const p = result[i], original = input.pollutants[i]; + assert.equal(p.pollutantType, original.pollutantType); + // Display conversion follows the public scale, which is shared by Multi variants. + const displayAlgorithm = name === 'CA.AQHI' ? 'CA_AQHI' : name === 'CN.AQHI' ? 'CN_DEATH_AQHI' : algorithm; + const scaleAlgorithm = mode === 'Scale' ? algorithm : displayAlgorithm; + const standard = AirQuality.Config.Scales[scaleAlgorithm].pollutants[p.pollutantType]; + let expectedUnit = mode.includes('ppb') && !['PM2_5', 'PM10'].includes(p.pollutantType) ? ppb : ug; + if (!mode.startsWith('Force_') && standard) expectedUnit = standard.units; + if (mode === 'None') expectedUnit = ug; + assert.equal(p.units, expectedUnit, p.pollutantType); + if (mode === 'None') assert.deepEqual(p, original); + if (p.units === ug) close(p.amount, original.amount, p.pollutantType); + else { + const fallbackFactors = mode.includes('EU_ppb') ? AirQuality.Config.STP_ConversionFactors.EU : AirQuality.Config.STP_ConversionFactors.US; + const factor = !mode.startsWith('Force_') && standard?.units === ppb ? standard.stpConversionFactor : fallbackFactors[p.pollutantType]; + close(p.amount * factor, original.amount, p.pollutantType); + } + } + }); + } +} + +test('None algorithm is identity; explicit algorithm options override configuration', () => { + const input = sample(); + assert.equal(AirQuality.Pollutants2AQI(input, settings('None')), input); + assert.equal(AirQuality.Pollutants2AQI(input, settings('UBA'), { algorithm: 'None' }), input); + assert.equal(AirQuality.Pollutants2AQI(input, settings('UBA'), { algorithm: 'WAQI_InstantCast_US' }).index, 81); +}); + +test('None units retain the selected pollutant provider even when calculated index is injected', () => { + const input = sample(); + const injected = { metadata: { providerName: 'Selected' }, pollutants: input.pollutants.map(p => ({ ...p, amount: p.amount * 2 })) }; + const calculated = AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_US')); + const configured = settings('WAQI_InstantCast_US', 'None', ['EPA_NowCast']); + assert.equal(AirQuality.ConvertPollutants(input, injected, true, calculated, configured), injected.pollutants); + injected.metadata.temporarilyUnavailable = true; + assert.equal(AirQuality.ConvertPollutants(input, injected, true, calculated, configured), input.pollutants); +}); + +test('explicit conversion targets gate every unit mode', () => { + const input = sample(); + const calculated = AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_US')); + for (const mode of ['None', 'Scale', 'ugm3', 'EU_ppb', 'US_ppb', 'Force_ugm3', 'Force_EU_ppb', 'Force_US_ppb']) { + for (const replace of [[], ['UBA']]) assert.equal(AirQuality.ConvertPollutants(input, undefined, true, calculated, settings('WAQI_InstantCast_US', mode, replace)), input.pollutants); + } +}); + +test('CN.AQHI public scale supports force units even without an injected calculated index', () => { + const input = { ...sample(), scale: 'CN.AQHI' }; + const result = AirQuality.ConvertPollutants(input, undefined, false, undefined, settings('CN_DEATH_AQHI', 'Force_US_ppb', ['CN.AQHI'])); + assert.equal(result.length, 6); + assert.equal(result.find(p => p.pollutantType === 'OZONE').units, ppb); + assert.equal(input.pollutants.find(p => p.pollutantType === 'OZONE').units, ug); +}); + +test('WeatherKit EU standard maps to runnable EU_EAQI algorithm, not EU.EAQI scale text', () => { + for (const [scale, expected] of [['EU.EAQI', 'EU_EAQI'], ['EU.EAQI.2414', 'EU_EAQI'], ['UBA.2414', 'UBA'], ['EPA_NowCast.2414', 'WAQI_InstantCast_US'], ['HJ6332012.2604', 'WAQI_InstantCast_CN']]) { + const configured = { AirQuality: { Comparison: { Yesterday: { IndexProvider: 'WeatherKit' } } } }; + const algorithm = AirQuality.chooseAlogrithm({ scale }, configured); + assert.equal(algorithm, expected); + assert.equal(AirQuality.Pollutants2AQI(sample(), configured, { algorithm }).metadata.temporarilyUnavailable, false); + } +}); + +test('AQHI provider descriptions match upstream and preserve multiline attribution', () => { + const suffixes = { + CA_AQHI: ' (InstantCast with CA AQHI)', HK_AQHI: ' (InstantCast with HK AQHI)', + CN_DEATH_AQHI: ' (China CDC Death Risk AQHI)', CN_DEATH_HK_AQHI: ' (China CDC Death Risk + HK AQHI)', + AQHI_Multi_CN: ' (AQHI-Multi for CN)', AQHI_Multi_CN_HK: ' (AQHI-Multi for CN+HK)', + }; + for (const [algorithm, suffix] of Object.entries(suffixes)) { + assert.equal(AirQuality.appendScaleToProviderName({ metadata: { providerName: 'iRingo\nProvider attribution' } }, settings(algorithm)), `iRingo${suffix}\nProvider attribution`); + } + assert.equal(AirQuality.Author, 'Virgil Clyne & Wordless Echo'); +}); + +test('CN 2012 and 2025 draft execute different PM2.5 thresholds', () => { + const input = { metadata: { providerName: 'Synthetic' }, pollutants: [{ pollutantType: 'PM2_5', amount: 40, units: ug }] }; + assert.equal(AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_CN')).index, 56); + assert.equal(AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_CN_25_DRAFT')).index, 66); +}); + +for (const [algorithm] of cases) { + test(`${algorithm}: empty, invalid and unsupported pollutant inputs fail closed`, () => { + for (const pollutants of [[], sample().pollutants.map(p => ({ ...p, amount: -1 })), sample().pollutants.map(p => ({ ...p, amount: NaN })), sample().pollutants.map(p => ({ ...p, units: 'UNKNOWN' })), [{ pollutantType: 'NOX', amount: 10, units: ug }]]) { + const result = AirQuality.Pollutants2AQI({ metadata: { providerName: 'Synthetic' }, pollutants }, settings(algorithm)); + assert.equal(result.metadata.temporarilyUnavailable, true); + } + }); +} + +test('menu contains exactly the eleven supported algorithms plus None, all runnable through settings', async () => { + const { localParameters } = await import('../../src/wk3/parameters.mjs'); + const parameter = localParameters.find(p => p.key === 'AirQuality.Calculate.Algorithm'); + assert.deepEqual([...parameter.options].sort(), ['None', ...cases.map(([algorithm]) => algorithm)].sort()); + for (const algorithm of parameter.options) { + const input = sample(); + const result = AirQuality.Pollutants2AQI(input, settings(algorithm)); + if (algorithm === 'None') assert.equal(result, input); + else { + assert.ok(Object.hasOwn(AirQuality.Config.Scales, algorithm)); + assert.equal(result.metadata.temporarilyUnavailable, false); + } + } +}); + +test('AllowOverRange false survives buildSettings string normalization and actually caps InstantCast', () => { + for (const algorithm of ['WAQI_InstantCast_US', 'WAQI_InstantCast_CN', 'WAQI_InstantCast_CN_25_DRAFT']) { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 100000; }); + const configured = buildSettings(database, { AirQuality: { Calculate: { Algorithm: algorithm, AllowOverRange: 'false' } } }).Settings; + assert.equal(configured.AirQuality.Calculate.AllowOverRange, false); + assert.equal(AirQuality.Pollutants2AQI(input, configured).index, 500); + configured.AirQuality.Calculate.AllowOverRange = true; + assert.ok(AirQuality.Pollutants2AQI(input, configured).index > 500); + } +}); + +for (const [algorithm, scale] of cases) { + test(`${algorithm}: zero remains zero in all eight unit modes`, () => { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 0; }); + const calculated = AirQuality.Pollutants2AQI(input, settings(algorithm)); + for (const mode of ['None', 'Scale', 'ugm3', 'EU_ppb', 'US_ppb', 'Force_ugm3', 'Force_EU_ppb', 'Force_US_ppb']) { + const result = AirQuality.ConvertPollutants(input, undefined, true, calculated, settings(algorithm, mode, [AirQuality.GetNameFromScale(scale)])); + assert.equal(result.length, 6); + assert.ok(result.every(p => p.amount === 0), mode); + } + }); +} diff --git a/tests/wk3/aq-correctness.test.mjs b/tests/wk3/aq-correctness.test.mjs new file mode 100644 index 00000000..c8ce9fba --- /dev/null +++ b/tests/wk3/aq-correctness.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import AirQualityWK3 from "../../src/wk3/class/AirQuality.mjs"; + +// Exercise the legacy implementation without installing its runtime logger dependency. +// Only imports are redirected; all algorithm code and configuration remain unchanged. +const legacyURL = new URL("../../src/class/AirQuality.mjs", import.meta.url); +const legacySource = (await readFile(legacyURL, "utf8")) + .replace('"@nsnanocat/util"', JSON.stringify(new URL("../../src/wk3/utils/index.mjs", import.meta.url).href)) + .replace('"./SimplePrecisionMath.mjs"', JSON.stringify(new URL("../../src/class/SimplePrecisionMath.mjs", import.meta.url).href)); +const { default: AirQualityLegacy } = await import(`data:text/javascript;base64,${Buffer.from(legacySource).toString("base64")}`); +const ug = "MICROGRAMS_PER_CUBIC_METER", mg = "MILLIGRAMS_PER_CUBIC_METER"; +const ppb = "PARTS_PER_BILLION", ppm = "PARTS_PER_MILLION"; +const p = (pollutantType, amount, units = ug) => ({ pollutantType, amount, units }); +const sample = () => [p("PM2_5", 25), p("PM10", 50), p("OZONE", 100), p("NO2", 25), p("SO2", 40), p("CO", 700)]; +const input = pollutants => ({ metadata: { providerName: "test" }, pollutants }); +const freezeInput = pollutants => { + const value = input(pollutants.map(Object.freeze)); + Object.freeze(value.pollutants); + Object.freeze(value.metadata); + return Object.freeze(value); +}; +for (const [entry, AQ] of [["wk3", AirQualityWK3], ["legacy", AirQualityLegacy]]) { + const calc = (pollutants, algorithm = "WAQI_InstantCast_US", options = {}) => AQ.Pollutants2AQI(input(pollutants), {}, { algorithm, ...options }); + test(`${entry}: US concentration truncation affects index only, including decimal boundaries`, () => { + // Fixed boundary expectations from the configured InstantCast breakpoints. + for (const [type, amount, units, expected] of [ + ["PM2_5", 9.09, ug, 50], ["PM2_5", 9.1, ug, 51], + ["PM2_5", 35.49, ug, 100], ["PM2_5", 35.5, ug, 101], + ["PM10", 54.99, ug, 50], ["PM10", 55, ug, 51], + ["OZONE", 62.59, ppb, 50], ["OZONE", 62.6, ppb, 51], + ["SO2", 35.99, ppb, 50], ["SO2", 36, ppb, 51], + ["NO2", 53.99, ppb, 50], ["NO2", 54, ppb, 51], + ["CO", 4499, ppb, 50], + ]) { + const result = calc([p(type, amount, units)]); + assert.equal(result.index, expected, `${type} ${amount}`); + assert.equal(result.pollutants[0].amount, amount, "physical concentration must not be truncated"); + } + }); + test(`${entry}: identical mixing-ratio units do not depend on STP factors`, () => { + assert.equal(AQ.ConvertUnit(53, ppb, ppb, 1.88, -1), 53); + assert.equal(AQ.ConvertUnit(53, ppb, ppb, 1.88, 1.91), 53); + assert.equal(AQ.ConvertUnit(0.053, ppm, ppb, 1.88, 1.91), 53); + assert.equal(AQ.ConvertUnit(53, ppb, ppm, -1, 1.91), 0.053); + for (const amount of [NaN, Infinity, -1, null, undefined, ""]) assert.equal(AQ.ConvertUnit(amount, ug, ug), -1); + }); + test(`${entry}: PM10 over-range extrapolation is continuous at 604/605`, () => { + assert.equal(calc([p("PM10", 604)], undefined, { allowOverRange: true }).index, 500); + assert.equal(calc([p("PM10", 605)], undefined, { allowOverRange: true }).index, 501); + assert.equal(calc([p("PM10", 605)], undefined, { allowOverRange: false }).index, 500); + }); + test(`${entry}: CN25 zero concentration is valid category 1`, () => { + const result = calc(sample().map(x => ({ ...x, amount: 0 })), "WAQI_InstantCast_CN_25_DRAFT"); + assert.equal(result.index, 0); + assert.equal(result.categoryIndex, 1); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(result.primaryPollutant, "NOT_AVAILABLE"); + }); + test(`${entry}: Canadian AQHI ppb/mass equivalents use all required model inputs`, () => { + const gases = [p("NO2", 20, ppb), p("OZONE", 40, ppb), p("PM2_5", 10)]; + const mass = [p("NO2", 37.60945396612444), p("OZONE", 78.4758596679524), p("PM2_5", 10)]; + // (1000/10.4) * (expm1(.000871*20)+expm1(.000537*40)+expm1(.000487*10)) = 4.2468121822. + for (const pollutants of [gases, mass]) { + const result = calc(pollutants, "CA_AQHI"); + assert.equal(result.index, 4); + assert.equal(result.categoryIndex, 4); + assert.equal(result.metadata.temporarilyUnavailable, false); + } + }); + test(`${entry}: Canadian AQHI rounds rather than ceiling, minimum 1 and maximum 10+`, () => { + // Only NO2 is nonzero; these fixed concentrations yield 2.49, 2.51, and 11.2 before rounding. + for (const [amount, expected] of [[0, 1], [29.352901364443454, 2], [29.58565572211123, 3], [126.49928704508866, 11]]) { + const result = calc([p("NO2", amount, ppb), p("OZONE", 0, ppb), p("PM2_5", 0)], "CA_AQHI"); + assert.equal(result.index, expected, String(amount)); + } + }); + test(`${entry}: AQHI missing required gas or particle inputs is unavailable`, () => { + for (const algorithm of ["CA_AQHI", "HK_AQHI", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK"]) { + for (const absent of [["NO2"], ["OZONE"], ["PM10", "PM2_5"]]) { + const result = calc(sample().filter(x => !absent.includes(x.pollutantType)), algorithm); + assert.equal(result.metadata.temporarilyUnavailable, true, `${algorithm} absent ${absent}`); + } + // A PM10 alternative is valid only when the model defines its coefficient. + const pm10Only = calc(sample().filter(x => x.pollutantType !== "PM2_5"), algorithm); + assert.equal(pm10Only.metadata.temporarilyUnavailable, !["HK_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN_HK"].includes(algorithm), algorithm); + const invalid = sample().map(x => x.pollutantType === "NO2" ? { ...x, amount: NaN } : x); + assert.equal(calc(invalid, algorithm).metadata.temporarilyUnavailable, true, `${algorithm} invalid gas`); + } + }); + test(`${entry}: QWeather CO conversion is unit-driven, immutable and idempotent`, () => { + for (const providerName of ["QWeather", "和风天气"]) { + const original = Object.freeze({ metadata: Object.freeze({ providerName }), pollutants: Object.freeze([Object.freeze(p("CO", 0.7, mg)), Object.freeze(p("PM10", 20))]) }); + const fixed = AQ.FixPollutantsUnits(original); + assert.deepEqual(fixed.pollutants[0], p("CO", 700)); + assert.deepEqual(AQ.FixPollutantsUnits(fixed), fixed); + assert.deepEqual(original.pollutants[0], p("CO", 0.7, mg)); + const normalized = { metadata: { providerName }, pollutants: [p("CO", 700)] }; + assert.deepEqual(AQ.FixPollutantsUnits(normalized), normalized); + } + }); + test(`${entry}: invalid category comparisons are UNKNOWN`, () => { + for (const invalid of [-1, 0, null, undefined, NaN, Infinity, "", "bad", true, 1.5]) { + assert.equal(AQ.CompareCategoryIndexes(invalid, 2), "UNKNOWN", String(invalid)); + assert.equal(AQ.CompareCategoryIndexes(2, invalid), "UNKNOWN", String(invalid)); + } + assert.equal(AQ.CompareCategoryIndexes("2", 2), "SAME"); + assert.equal(AQ.CompareCategoryIndexes(1, 2), "BETTER"); + assert.equal(AQ.CompareCategoryIndexes(3, 2), "WORSE"); + }); + test(`${entry}: empty Units.Replace retains physical source units even with calculated index`, () => { + const original = { ...input(sample()), scale: "EPA_NowCast" }; + const injectedIndex = { ...input([p("CO", 600, ppb)]), index: 10, scale: "EPA_NowCast", pollutants: [{ ...p("CO", 600, ppb), index: 10 }] }; + const settings = { AirQuality: { Current: { Index: { Provider: "Calculate" }, Pollutants: { Units: { Mode: "Scale", Replace: [] } } } } }; + assert.deepEqual(AQ.ConvertPollutants(original, undefined, true, injectedIndex, settings), original.pollutants); + settings.AirQuality.Current.Pollutants.Units.Replace = ["EPA_NowCast"]; + assert.deepEqual(AQ.ConvertPollutants(original, undefined, true, injectedIndex, settings), injectedIndex.pollutants); + settings.AirQuality.Current.Index.Provider = "WeatherKit"; + assert.doesNotThrow(() => AQ.ConvertPollutants(original, undefined, false, undefined, settings)); + }); + test(`${entry}: explicit CA.AQHI replacement handles versionless scale; unknown CN scale is safe`, () => { + const settings = { AirQuality: { Current: { Pollutants: { Units: { Mode: "Scale", Replace: ["CA.AQHI"] } } } } }; + const original = { ...input([p("NO2", 18.80472698306222), p("OZONE", 19.6189649169881), p("PM2_5", 10)]), scale: "CA.AQHI" }; + const result = AQ.ConvertPollutants(original, undefined, false, undefined, settings); + assert.deepEqual(result.map(x => x.units), [ppb, ppb, ug]); + assert.ok(Math.abs(result[0].amount - 10) < 1e-10); + assert.equal(original.pollutants[0].units, ug); + settings.AirQuality.Current.Pollutants.Units.Replace = ["CN.AQHI"]; + assert.doesNotThrow(() => AQ.ConvertPollutants({ ...original, scale: "CN.AQHI" }, undefined, false, undefined, settings)); + }); + test(`${entry}: unknown algorithms fail closed and None remains identity`, () => { + const original = freezeInput(sample()); + for (const algorithm of ["unsupported", "toString", "constructor", "__proto__"]) { + const result = AQ.Pollutants2AQI(original, {}, { algorithm }); + assert.equal(result.metadata.temporarilyUnavailable, true, algorithm); + assert.notEqual(result.scale, "UBA"); + assert.deepEqual(result.pollutants, original.pollutants); + } + assert.equal(AQ.Pollutants2AQI(original, {}, { algorithm: "None" }), original); + assert.equal(AQ.Pollutants2AQI(original, {}).scale, AQ.ToWeatherKitScale(AQ.Config.Scales.UBA.weatherKitScale)); + assert.equal(AQ.Pollutants2AQI(input([]), {}, { algorithm: "UBA" }).metadata.temporarilyUnavailable, true); + }); + test(`${entry}: no supported pollutant index is unavailable, not a negative usable AQI`, () => { + const raw = freezeInput([p("NOX", 12)]); + const result = AQ.Pollutants2AQI(raw, {}, { algorithm: "WAQI_InstantCast_US" }); + assert.equal(result.metadata.temporarilyUnavailable, true); + assert.deepEqual(result.pollutants.map(x => [x.pollutantType, x.amount, x.units]), [["NOX", 12, ug]]); + }); + test(`${entry}: calculations preserve six physical pollutants and never mutate inputs`, () => { + for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK"]) { + const original = freezeInput(sample()); + const result = AQ.Pollutants2AQI(original, {}, { algorithm }); + assert.equal(result.pollutants.length, 6, algorithm); + assert.deepEqual(result.pollutants.map(x => x.pollutantType), original.pollutants.map(x => x.pollutantType)); + for (const pollutant of result.pollutants) { + const source = original.pollutants.find(x => x.pollutantType === pollutant.pollutantType); + const targetFactor = AQ.Config.Scales[algorithm].pollutants[pollutant.pollutantType]?.stpConversionFactor; + const expected = pollutant.units === source.units ? source.amount : AQ.ConvertUnit(pollutant.amount, pollutant.units, source.units, targetFactor); + assert.ok(Math.abs(expected - source.amount) < 1e-8, `${algorithm}: ${pollutant.pollutantType} physical concentration`); + assert.equal(Object.hasOwn(source, "index"), false); + } + } + }); +} diff --git a/tests/wk3/aq-settings-semantics.test.mjs b/tests/wk3/aq-settings-semantics.test.mjs new file mode 100644 index 00000000..0b49bafc --- /dev/null +++ b/tests/wk3/aq-settings-semantics.test.mjs @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import WeatherKit2 from '../../src/wk3/class/WeatherKit2.mjs'; +import QWeather from '../../src/wk3/class/QWeather.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import { handleResponse } from '../../src/wk3/entry/response.mjs'; +import { decodeWK3DataSet, encodeAirQuality } from '../../src/wk3/class/WeatherKit3.mjs'; +import ColorfulClouds from '../../src/wk3/class/ColorfulClouds.mjs'; + +const apple = { metadata: { providerName: 'Apple', sourceType: 3 }, scale: 'HJ6332012.2604', index: 60, categoryIndex: 2, previousDayComparison: 3 }; +const provider = { metadata: { providerName: 'Synthetic', sourceType: 'MODELED' }, scale: 'EPA_NowCast.2414', index: 75, categoryIndex: 2, isSignificant: false, primaryPollutant: 'PM2_5', pollutants: [{ pollutantType: 'PM2_5', amount: 12, units: 'MICROGRAMS_PER_CUBIC_METER' }] }; +const url = 'https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality'; +function bytes(source = apple) { + const b = new Builder(); + const a = encodeAirQuality(b, source); + b.startObject(1); + b.addFieldOffset(0, a, 0); + b.finish(b.endObject()); + return b.asUint8Array(); +} +function settings(overrides = {}) { + return buildSettings(database, { AirQuality: overrides }).Settings; +} +async function patch(Settings, preFetched = {}, source = apple, sources = {}) { + const input = bytes(source); + const result = await processResponse({ url, headers: {} }, { status: 200, headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' }, body: input }, { Settings, Configs: {}, enviroments: { parameters: { country: 'CN', latitude: 31, longitude: 121 }, country: 'CN', ...sources }, preFetched }); + return { input, output: result.body, aq: decodeWK3DataSet(result.body) }; +} + +test('explicit index targets and yesterday choice survive configuration normalization', () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: '[]' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }); + assert.deepEqual(s.AirQuality.Current.Index.Replace, []); + assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider, 'ColorfulCloudsCN'); + const c = settings({ Current: { Index: { Provider: 'Calculate' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }); + assert.equal(c.AirQuality.Comparison.Yesterday.IndexProvider, 'ColorfulCloudsCN'); +}); + +test('WK3 explicit empty targets leave Apple AQ and comparison byte-exact', async () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: '[]' } } }); + const result = await patch(s, { index: Promise.resolve(provider) }); + assert.deepEqual(result.output, result.input); + assert.equal(result.aq.previousDayComparison, 3); +}); + +test('WK3 Calculate plus no pollutant source does not overwrite Apple AQ', async () => { + const s = settings({ Current: { Index: { Provider: 'Calculate', Replace: ['HJ6332012'] }, Pollutants: { Provider: 'None' } } }); + const result = await patch(s); + assert.deepEqual(result.output, result.input); +}); + +test('WK3 replacement keeps Apple comparison when current-change comparison is disabled', async () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { ReplaceWhenCurrentChange: false } }); + const result = await patch(s, { index: Promise.resolve(provider) }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 3); +}); + +test('WK3 replacement respects selected yesterday provider and same-source guard', async () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }); + const result = await patch(s, { index: Promise.resolve(provider), yesterdayAirQuality: Promise.resolve({ ...provider, index: 125, categoryIndex: 3 }) }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 0); +}); + +test('entry does not prefetch for empty targets or a disabled index provider', async () => { + const original = ColorfulClouds.prototype.CurrentAirQuality; + const oldYesterday = ColorfulClouds.prototype.prefetchYesterdayHourly; + let calls = 0; + ColorfulClouds.prototype.CurrentAirQuality = async () => { calls++; return provider; }; + ColorfulClouds.prototype.prefetchYesterdayHourly = async () => { calls++; }; + try { + for (const [country, overrides] of [ + ['CN', { Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: [] } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], + ['CN', { Current: { Index: { Provider: 'None', Replace: ['HJ6332012'] } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], + ]) { + const requestUrl = url.replace('country=CN', `country=${country}`); + const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: overrides }) }, request: { url: requestUrl, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' } }) }); + assert.equal(output.status, 200); + assert.equal(calls, 0, `unexpected provider call for ${country}/${overrides.Current.Index.Provider}`); + } + } finally { + ColorfulClouds.prototype.CurrentAirQuality = original; + ColorfulClouds.prototype.prefetchYesterdayHourly = oldYesterday; + } +}); + +const wk2Apple = { ...apple, metadata: { providerName: 'Apple', sourceType: 'MODELED' }, previousDayComparison: 'SAME', primaryPollutant: 'PM2_5', pollutants: provider.pollutants }; +async function patchWK2(overrides, { source = wk2Apple, candidate = provider, ios27 = false } = {}) { + const calls = { current: 0, yesterday: 0 }; + const sources = { colorfulClouds: { + CurrentAirQuality: async () => { calls.current++; return candidate; }, + YesterdayAirQuality: async () => { calls.yesterday++; return { ...provider, index: 125, categoryIndex: 3 }; }, + } }; + const input = WeatherKit2.encode(undefined, { airQuality: source }); + const result = await processResponse({ url: url.replace('/v3/', '/v2/'), headers: ios27 ? { 'user-agent': 'WeatherKit_Weather_iOS_Version 27' } : {} }, { status: 200, headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK2.Weather' }, body: input }, { Settings: settings({ Current: { Pollutants: { Provider: 'WeatherKit' } }, ...overrides }), Configs: {}, enviroments: { parameters: { country: 'CN', latitude: 31, longitude: 121 }, country: 'CN', ...sources } }); + return { calls, aq: WeatherKit2.decode(new ByteBuffer(result.body), ['airQuality']).airQuality }; +} + +test('explicit nonempty index targets are not expanded with HJ', () => { + assert.deepEqual(settings({ Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: ['UBA'] } } }).AirQuality.Current.Index.Replace, ['UBA']); +}); + +for (const indexProvider of ['WeatherKit', 'UnknownProvider', 'Calculate']) { + test(`real WK3 ${indexProvider}/algorithm None never uses the index candidate or queries AQ`, async () => { + let calls = 0; + const result = await patch(settings({ Current: { Index: { Provider: indexProvider }, Pollutants: { Provider: 'ColorfulClouds' } }, Calculate: { Algorithm: 'None' } }), {}, apple, { colorfulClouds: { CurrentAirQuality: async () => { calls++; return provider; }, YesterdayAirQuality: async () => { calls++; return provider; } } }); + assert.deepEqual(result.output, result.input); + assert.equal(calls, 0); + }); + test(`real WK2 ${indexProvider}/algorithm None keeps Apple index and known comparison`, async () => { + const result = await patchWK2({ Current: { Index: { Provider: indexProvider }, Pollutants: { Provider: 'WeatherKit' } }, Calculate: { Algorithm: 'None' } }); + assert.equal(result.aq.index, wk2Apple.index); + assert.equal(result.aq.scale, wk2Apple.scale); + assert.equal(result.aq.previousDayComparison, 'SAME'); + assert.deepEqual(result.calls, { current: 0, yesterday: 0 }); + assert.equal(result.aq.metadata.providerName, 'Apple'); + }); +} + +test('WK2 unchanged index/scale/category does not query yesterday or claim an index replacement', async () => { + const result = await patchWK2({ Current: { Index: { Provider: 'ColorfulCloudsCN' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }, { candidate: { ...provider, index: 60, categoryIndex: 2, scale: wk2Apple.scale } }); + assert.equal(result.aq.previousDayComparison, 'SAME'); + assert.equal(result.calls.yesterday, 0); + assert.equal(result.aq.metadata.providerName, 'Apple'); +}); + +for (const candidate of [null, { ...provider, metadata: { providerName: 'Failed', temporarilyUnavailable: true } }, { ...provider, index: NaN }, { ...provider, categoryIndex: 0 }, { ...provider, scale: '' }]) { + test(`WK2 invalid candidate ${candidate?.index}/${candidate?.categoryIndex}/${candidate?.scale} preserves Apple and skips yesterday`, async () => { + const result = await patchWK2({ Current: { Index: { Provider: 'ColorfulCloudsUS' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }, { candidate }); + assert.equal(result.aq.index, 60); + assert.equal(result.aq.previousDayComparison, 'SAME'); + assert.equal(result.aq.metadata.providerName, 'Apple'); + assert.equal(result.calls.yesterday, 0); + }); +} + +test('WK2 real changed index recomputes known comparison only when flag is true', async () => { + for (const flag of [false, true]) { + const result = await patchWK2({ Current: { Index: { Provider: 'ColorfulCloudsUS' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: flag, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, flag ? 'BETTER' : 'SAME'); + assert.equal(result.calls.yesterday, flag ? 1 : 0); + } +}); + +test('WK2 UNKNOWN comparison can be filled from explicit source without replacing index', async () => { + const result = await patchWK2({ Current: { Index: { Provider: 'WeatherKit' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: false, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }, { source: { ...wk2Apple, previousDayComparison: 'UNKNOWN' } }); + assert.equal(result.aq.index, 60); + assert.equal(result.aq.previousDayComparison, 'BETTER'); + assert.deepEqual(result.calls, { current: 1, yesterday: 1 }); +}); + +test('WK2 WeatherKit and unknown yesterday sources never fall back to ColorfulClouds', async () => { + for (const yesterday of ['WeatherKit', 'MissingProvider']) { + const result = await patchWK2({ Current: { Index: { Provider: 'WeatherKit' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { Yesterday: { IndexProvider: yesterday } } }, { source: { ...wk2Apple, previousDayComparison: 'UNKNOWN' } }); + assert.equal(result.aq.previousDayComparison, 'UNKNOWN'); + assert.deepEqual(result.calls, { current: 0, yesterday: 0 }); + } +}); + +test('WK3 flag false can fill UNKNOWN on a real replacement but preserves valid comparison', async () => { + let yesterdayCalls = 0; + const sources = { colorfulClouds: { YesterdayAirQuality: async () => { yesterdayCalls++; return { ...provider, index: 125, categoryIndex: 3 }; } } }; + const result = await patch(settings({ Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { ReplaceWhenCurrentChange: false, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }), { index: Promise.resolve(provider) }, { ...apple, previousDayComparison: 0 }, sources); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 4); + assert.equal(yesterdayCalls, 1); +}); + +test('handleResponse never prefetches yesterday for unchanged, failed or disabled index', async () => { + const restore = [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality, ColorfulClouds.prototype.prefetchYesterdayHourly, QWeather.GetLocationsGrid]; + let current = 0, yesterday = 0, grid = 0; + let candidate = provider; + ColorfulClouds.prototype.CurrentAirQuality = async () => { current++; return candidate; }; + ColorfulClouds.prototype.YesterdayAirQuality = async () => { yesterday++; return provider; }; + ColorfulClouds.prototype.prefetchYesterdayHourly = async () => { yesterday++; }; + QWeather.GetLocationsGrid = async () => { grid++; return {}; }; + try { + for (const indexProvider of ['WeatherKit', 'UnknownProvider', 'Calculate', 'ColorfulCloudsUS']) { + current = 0; yesterday = 0; grid = 0; + candidate = { ...provider, metadata: { providerName: 'Unavailable', temporarilyUnavailable: true } }; + const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: { Current: { Index: { Provider: indexProvider }, Pollutants: { Provider: 'ColorfulClouds' } }, Calculate: { Algorithm: 'None' }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } } }) }, request: { url, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer' } }) }); + assert.equal(decodeWK3DataSet(output.body).index, 60); + assert.deepEqual(output.body, bytes(), `${indexProvider} must preserve original bytes`); + assert.equal(current, indexProvider === 'ColorfulCloudsUS' ? 1 : 0, indexProvider); + assert.equal(yesterday, 0, indexProvider); + assert.equal(grid, 0, indexProvider); + } + } finally { + [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality, ColorfulClouds.prototype.prefetchYesterdayHourly, QWeather.GetLocationsGrid] = restore; + } +}); + +test('handleResponse positive control replaces AQ and obtains yesterday only after success', async () => { + const restore = [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality]; + let current = 0, yesterday = 0; + ColorfulClouds.prototype.CurrentAirQuality = async () => { current++; return provider; }; + ColorfulClouds.prototype.YesterdayAirQuality = async () => { yesterday++; return { ...provider, index: 125, categoryIndex: 3 }; }; + try { + const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: { Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } } }) }, request: { url, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer' } }) }); + assert.equal(decodeWK3DataSet(output.body).index, 75); + assert.equal(decodeWK3DataSet(output.body).previousDayComparison, 4); + assert.equal(current, 1); + assert.equal(yesterday, 1); + } finally { + [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality] = restore; + } +}); + +test('WK3 same-scale unchanged AQ has byte-exact output and no yesterday query', async () => { + let yesterday = 0; + const result = await patch(settings({ Current: { Index: { Provider: 'ColorfulCloudsCN' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }), { index: Promise.resolve({ ...provider, scale: apple.scale, index: apple.index, categoryIndex: apple.categoryIndex }) }, apple, { colorfulClouds: { YesterdayAirQuality: async () => { yesterday++; return provider; } } }); + assert.deepEqual(result.output, result.input); + assert.equal(yesterday, 0); +}); + +test('WK3 bare Calculate does not identify a comparable source/algorithm pair', async () => { + let calls = 0; + const result = await patch(settings({ Current: { Index: { Provider: 'Calculate' }, Pollutants: { Provider: 'ColorfulClouds' } }, Comparison: { Yesterday: { IndexProvider: 'Calculate' } } }), { index: Promise.resolve(provider), yesterdayAirQuality: Promise.resolve({ ...provider, index: 125, categoryIndex: 3 }) }, apple, { colorfulClouds: { YesterdayAirQuality: async () => { calls++; return provider; } } }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 0); + assert.equal(calls, 0); +}); diff --git a/tests/wk3/eaqi-presentation-comparison.test.mjs b/tests/wk3/eaqi-presentation-comparison.test.mjs new file mode 100644 index 00000000..98012d2c --- /dev/null +++ b/tests/wk3/eaqi-presentation-comparison.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Builder} from 'flatbuffers'; +import Scale from '../../src/wk3/class/AirQualityScale.mjs'; +import {encodeAirQuality,decodeWK3DataSet} from '../../src/wk3/class/WeatherKit3.mjs'; +import {Response as processResponse} from '../../src/wk3/process/Response.mjs'; +import {configuration} from '../../src/wk3/entry/index.mjs'; +import {localParameters} from '../../src/wk3/parameters.mjs'; +const pollutants=[[9,25],[11,50],[8,100],[6,25],[10,40],[12,800]].map(([pollutantType,amount])=>({pollutantType,amount,units:2})); +function root(comparison=0){const b=new Builder();const aq=encodeAirQuality(b,{metadata:{providerName:'Apple',sourceType:2},scale:'HJ6332012.2604',index:70,categoryIndex:2,isSignificant:false,primaryPollutant:9,previousDayComparison:comparison,pollutants});b.startObject(1);b.addFieldOffset(0,aq,0);b.finish(b.endObject());return b.asUint8Array();} +const source=(index,categoryIndex)=>({metadata:{providerName:'Synthetic Caiyun',sourceType:'MODELED'},scale:'EPA_NowCast.2414',index,categoryIndex}); +async function run({comparison=0,force=false,today=source(81,2),yesterday=source(125,3),failure=false}={}){const env=Object.fromEntries(localParameters.map(p=>[p.key,p.value]));const {Settings}=configuration({env:{...env,'AirQuality.Current.Index.Provider':'Calculate','AirQuality.Calculate.Algorithm':'EU_EAQI','AirQuality.Comparison.Yesterday.IndexProvider':'ColorfulCloudsUS','AirQuality.Comparison.ReplaceWhenCurrentChange':String(force)}});const calls=[]; + const input=root(comparison);const result=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer'},body:input},{Settings,Configs:{},enviroments:{colorfulClouds:{CurrentAirQuality:async usa=>{calls.push('today');assert.equal(usa,true);if(failure)throw new Error('offline');return today;},YesterdayAirQuality:async usa=>{calls.push('yesterday');assert.equal(usa,true);return yesterday;}}}});return {aq:decodeWK3DataSet(result.body),calls};} +test('EAQI local scale uses EU naming, categorical display and continuous interpolation',()=>{ + const a=JSON.parse(Scale.buildScale('zh-Hans','EU.EAQI.2414').body);assert.equal(a.longDisplayName,'欧盟 (EAQI)');assert.equal(a.displayName,'欧盟空气质量指数');assert.equal(a.aqi.numerical,false);assert.deepEqual(a.aqi.range,[0,60]);assert.equal(a.aqi.categories.length,6);assert.equal(a.aqi.gradient.stops.length,121);assert.equal(a.aqi.gradient.stops[0].location,0);assert.equal(a.aqi.gradient.stops.at(-1).location,60); + for(let i=1;i<a.aqi.gradient.stops.length;i++){const previous=a.aqi.gradient.stops[i-1],next=a.aqi.gradient.stops[i];assert.equal(next.location-previous.location,0.5);for(const offset of [1,3,5])assert.ok(Math.abs(parseInt(next.color.slice(offset,offset+2),16)-parseInt(previous.color.slice(offset,offset+2),16))<=11);} + assert.equal(JSON.parse(Scale.buildScale('zh-Hant','EU.EAQI.2414').body).longDisplayName,'歐盟 (EAQI)'); + assert.equal(JSON.parse(Scale.buildScale('zh-Hans','EPA_NowCast.2604').body).aqi.numerical,true); +}); +test('EAQI default yesterday choice compares Caiyun own current/past pair, not EAQI vs US',async()=>{ + for(const [index,categoryIndex,expected]of [[125,3,4],[81,2,3],[20,1,2]]){const r=await run({yesterday:source(index,categoryIndex)});assert.equal(r.aq.scale,'EU.EAQI');assert.equal(r.aq.index,22);assert.equal(r.aq.previousDayComparison,expected);assert.deepEqual(r.calls,['today','yesterday']);assert.ok(r.aq.metadata.providerName.includes('对比昨日:彩云天气(美标)'));} +}); +test('valid original comparison and disabled refresh retain their value without requests',async()=>{const r=await run({comparison:3});assert.equal(r.aq.previousDayComparison,3);assert.deepEqual(r.calls,[]);}); +test('forced refresh uses selected source; source failure leaves EAQI valid and comparison unknown',async()=>{const refreshed=await run({comparison:3,force:true});assert.equal(refreshed.aq.previousDayComparison,4);assert.deepEqual(refreshed.calls,['today','yesterday']);const fail=await run({failure:true});assert.equal(fail.aq.index,22);assert.equal(fail.aq.scale,'EU.EAQI');assert.equal(fail.aq.previousDayComparison,0);}); diff --git a/tests/wk3/eaqi-upstream-resource.test.mjs b/tests/wk3/eaqi-upstream-resource.test.mjs new file mode 100644 index 00000000..fcf9f661 --- /dev/null +++ b/tests/wk3/eaqi-upstream-resource.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {Builder,ByteBuffer} from 'flatbuffers'; +import {decodeWK3DataSet,encodeAirQuality} from '../../src/wk3/class/WeatherKit3.mjs'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import {Response as processResponse} from '../../src/wk3/process/Response.mjs'; +import {buildEgern} from '../../scripts/build-egern.mjs'; +import {configuration} from '../../src/wk3/entry/index.mjs'; +import {localParameters} from '../../src/wk3/parameters.mjs'; +const dir=process.env.WK3_TEST_BUNDLE_DIR; +const built=dir ? null : await buildEgern({minify:false}); +const load=async stage=>{const code=dir ? await readFile(`${dir}/egern/wk3-egern-${stage}.bundle.js`,'utf8') : built[stage].code;return (await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'))).default;}; +const request=await load('request'),response=await load('response'); +const env={...Object.fromEntries(localParameters.map(p=>[p.key,p.value])),LogLevel:'OFF'}; +const pollutants=[[9,25],[11,50],[8,100],[6,25],[10,40],[12,800]].map(([pollutantType,amount])=>({pollutantType,amount,units:2})); +function fixture(scale){const b=new Builder();const a=encodeAirQuality(b,{metadata:{providerName:'Apple',sourceType:2},scale,index:18,categoryIndex:2,isSignificant:false,primaryPollutant:9,previousDayComparison:3,pollutants});const mark=b.createString('keep all weather and alerts');b.startObject(1);b.addFieldOffset(0,mark,0);const other=b.endObject();b.startObject(18);b.addFieldOffset(0,a,0);for(const slot of [1,5,9,17])b.addFieldOffset(slot,other,0);b.finish(b.endObject());return b.asUint8Array();} +const url='https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality'; +const context=raw=>({env,request:{url,headers:{}},response:new Response(raw,{headers:{'content-type':'application/vnd.apple.flatbuffer'}}),http:{get(){assert.fail('No script-side fetch for existing AQ');}}}); +test('actual request bundle follows upstream version stripping, without fetching or synthesizing EAQI',async()=>{ + for(const version of ['v1','v2'])for(const language of ['zh-Hans','zh-Hant','en'])for(const name of ['EU.EAQI.2414','EU.EAQI.2604','EU.EAQI']){ + const original=`https://weatherkit.apple.com/api/${version}/airQualityScale/${language}/${name}?keep=yes`;const headers=new Headers({'authorization':'synthetic-test-only','if-none-match':'synthetic-etag'}); + const result=await request({env,request:{url:original,headers},http:{get(){assert.fail('must use native Apple request');}},respond(){assert.fail('must not replace native Apple scale with local color stops');}}); + assert.equal(result.url,`https://weatherkit.apple.com/api/${version}/airQualityScale/${language}/EU.EAQI?keep=yes`);assert.equal(result.headers,headers); + assert.equal(headers.get('authorization'),'synthetic-test-only');assert.equal(headers.get('if-none-match'),null); + } +}); +test('upstream algorithm outputs EU.EAQI without altering calculated index',()=>{const symbolic=pollutants.map((p,i)=>({...p,pollutantType:['PM2_5','PM10','OZONE','NO2','SO2','CO'][i],units:'MICROGRAMS_PER_CUBIC_METER'}));const result=AirQuality.Pollutants2AQI({metadata:{providerName:'Apple'},pollutants:symbolic},{},{algorithm:'EU_EAQI'});assert.equal(result.scale,'EU.EAQI');assert.equal(result.index,22);assert.equal(result.categoryIndex,3);}); +test('actual weather bundle changes existing EAQI resource identity, not AQ values or other original bytes',async()=>{ + for(const scale of ['EU.EAQI.2414','EU.EAQI.2604']){const raw=fixture(scale),snapshot=raw.slice();const before=decodeWK3DataSet(raw);const out=(await response(context(raw))).body;const after=decodeWK3DataSet(out);assert.deepEqual({...after,scale:before.scale},before);assert.equal(after.scale,'EU.EAQI');assert.deepEqual(raw,snapshot); + const bb=new ByteBuffer(raw),root=bb.readUint32(0),aq=bb.__indirect(root+bb.__offset(root,4)),field=aq+bb.__offset(aq,18); + for(let i=0;i<raw.length;i++)if(i<field||i>=field+4)assert.equal(out[i],raw[i]); + } + const raw=fixture('EU.EAQI');assert.deepEqual((await response(context(raw))).body,raw); +}); +test('non-EAQI native data and existing settings are left alone',async()=>{const raw=fixture('EPA_NowCast.2604');const out=(await response(context(raw))).body;assert.deepEqual(out,raw);assert.equal(configuration({env}).Settings.AirQuality.Calculate.Algorithm,'EU_EAQI');}); diff --git a/tests/wk3/egern-local.test.mjs b/tests/wk3/egern-local.test.mjs new file mode 100644 index 00000000..cd4930de --- /dev/null +++ b/tests/wk3/egern-local.test.mjs @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { buildEgern } from "../../scripts/build-egern.mjs"; +import { createRuntime } from "../../src/wk3/runtime.mjs"; +import { encodeAirQuality, decodeWK3DataSet } from "../../src/wk3/class/WeatherKit3.mjs"; +import { decodeWK3NextHour } from "../../src/wk3/class/WK3NextHour.mjs"; +import { decodeWK3Alerts } from "../../src/wk3/class/WK3Alerts.mjs"; +import vm from "node:vm"; +import WeatherKit2 from "../../src/wk3/class/WeatherKit2.mjs"; + +const bundles = await buildEgern(); +const load = async code => (await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`)).default; +const request = await load(bundles.request.code), response = await load(bundles.response.code); +const issued = 1893456000; +const html = '<div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00:00+08:00</p><p class="warning-events__txt">注意防范大风。</p></div>'; +const json = data => new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }); +const webResponse = bytes => new Response(bytes, { headers: { "content-type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather", "content-length": String(bytes.length), "content-encoding": "gzip", "x-keep": "original", etag: "old" } }); +const settings = extra => ({ SETTINGS: JSON.stringify({ DataSets: ["airQuality"], AirQuality: { Current: { Index: { Provider: "ColorfulCloudsUS", Replace: ["HJ6332012"] } }, Comparison: { Yesterday: { IndexProvider: "WeatherKit" } } }, ...extra }) }); +function context(bytes, datasets = "airQuality", extra = {}) { + return { env: settings(), request: { method: "GET", url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=${datasets}`, headers: new Headers({ authorization: "synthetic-apple", "x-schema-version": "lib-rc-3.0-b66" }) }, response: webResponse(bytes), http: { async get() { throw new Error("unexpected network"); } }, ...extra }; +} +function source() { + const b = new Builder(), aq = encodeAirQuality(b, { metadata: { sourceType: 3, providerName: "Synthetic Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); + const text = b.createString("preserve opaque future" ); b.startObject(1); b.addFieldOffset(0, text, 0); const opaque = b.endObject(); + b.startObject(15); b.addFieldOffset(0, aq, 0); b.addFieldOffset(14, opaque, 0); b.finish(b.endObject()); return b.asUint8Array(); +} +function provider(index = 75, yesterday = 125) { + return async function(url, options) { + assert.equal(options.timeout, 8000); assert.equal(options.credentials, "omit"); assert.equal(options.headers.authorization, undefined); + if (url.includes("/realtime")) return json({ status: "ok", location: [31, 121], result: { realtime: { status: "ok", air_quality: { description: { usa: "moderate" }, aqi: { usa: index, chn: 60 }, obs_time: issued, pm25: 12, pm10: 20, co: 0.4, no2: 10, so2: 5, o3: 40 } } } }); + if (url.includes("/hourly")) return json({ status: "ok", location: [31, 121], result: { server_time: issued, hourly: { status: "ok", air_quality: { aqi: [{ value: { usa: yesterday, chn: 90 } }] } } } }); + if (url.includes("/minutely")) return json({ status: "ok", server_time: 2000000040, location: [31, 121], result: { minutely: { status: "ok", description: "小雨", probability: [0.7, 0.7, 0.7, 0.7], precipitation_2h: Array(60).fill(1.5) } } }); + throw new Error(`Unexpected synthetic path: ${url}`); + }; +} + +test("bundles have one native ESM export and no cloud runtime, runtime imports or global mutation", () => { + for (const { code, inputs } of Object.values(bundles)) { + assert.match(code, /export default async function\(ctx\)/); + assert.doesNotMatch(code, /node:async_hooks|AsyncLocalStorage|caches\.default|waitUntil|globalThis\.fetch|\beval\(|new Function|\$done\(/); + assert.doesNotMatch(code, /\bimport\s*(?:\(|["'{*])/); + assert.ok(!inputs.some(path => /node_modules\/hono\/|src\/Hono\.js/.test(path))); + } +}); + +test("HTTP adapter respects native methods, body bytes, milliseconds and response shape", async () => { + const calls = []; + const runtime = createRuntime({ env: { HTTP_TIMEOUT_MS: "2500" }, http: { async post(url, options) { calls.push({ url, options }); return json({ ok: true }); }, async get() { return new Response(new Uint8Array([0, 255, 127]), { status: 200, headers: { "content-type": "application/octet-stream" } }); } } }); + const bytes = new Uint8Array([9, 0, 255, 8]).subarray(1, 3); + const result = await runtime.fetch({ url: "https://synthetic.invalid", bodyBytes: bytes, timeout: 4, headers: { Host: "wrong", "Content-Length": "99" } }); + assert.deepEqual(JSON.parse(result.body), { ok: true }); assert.equal(result.ok, true); assert.equal(result.statusCode, 200); + assert.equal(calls[0].options.timeout, 4000); assert.deepEqual(calls[0].options.body, bytes); assert.deepEqual(calls[0].options.headers, {}); + const binary = await runtime.fetch("https://synthetic.invalid/binary"); assert.deepEqual(new Uint8Array(binary.bodyBytes), new Uint8Array([0, 255, 127])); + assert.equal(binary.body, ""); +}); + +test("availability follows upstream response stage; UUID details and Apple native page pass through", async () => { + const ctx = { env: {}, request: { url: "https://weatherkit.apple.com/api/v2/availability/zh-Hans/31/121" }, response: new Response('["currentWeather"]', { headers: { 'content-type':'application/json' } }), http: {} }; + assert.equal(await request(ctx), undefined); + const reply = await response(ctx); assert.equal(reply.status, 200); assert.ok(JSON.parse(reply.body).includes("airQuality")); assert.ok(JSON.parse(reply.body).includes("forecastNextHour")); + for (const url of ["https://weatherkit.apple.com/alertDetails/index.html?ids=wk3_x", "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=00010203-0405-0607-0809-0a0b0c0d0e0f"]) assert.equal(await request({ ...ctx, request: { url } }), undefined); +}); + +test("binary response reuses real WK3 provider conversion and clears stale transport headers", async () => { + const bytes = source(), before = bytes.slice(); + const out = await response(context(bytes, "airQuality", { http: { get: provider(87) } })); + assert.ok(out.body instanceof Uint8Array); assert.equal(decodeWK3DataSet(out.body).index, 87); assert.equal(decodeWK3DataSet(out.body).scale, "EPA_NowCast.2604"); + assert.equal(out.headers.get("x-keep"), "original"); assert.equal(out.headers.get("content-length"), null); assert.equal(out.headers.get("content-encoding"), null); assert.equal(out.headers.get("etag"), null); + assert.deepEqual(bytes, before); +}); + +test("AQ, actual provider minutes and yesterday comparison patch together, preserving future arena", async () => { + const bytes = source(); + const env = settings({ DataSets: ["airQuality", "forecastNextHour"], NextHour: { Provider: "ColorfulClouds" }, AirQuality: { Current: { Index: { Provider: "ColorfulCloudsUS", Replace: ["HJ6332012"] } }, Comparison: { Yesterday: { IndexProvider: "ColorfulCloudsUS" } } } }); + const out = await response(context(bytes, "airQuality,forecastNextHour", { env, http: { get: provider() } })); + const aq = decodeWK3DataSet(out.body), rain = decodeWK3NextHour(out.body); + assert.equal(aq.index, 75); assert.equal(aq.previousDayComparison, 4); assert.equal(rain.minutes.length, 60); assert.equal(rain.minutes[0].precipitationIntensity, 1.5); + const bb = new ByteBuffer(out.body), root = bb.readUint32(0); assert.ok(bb.__offset(root, 32)); +}); + +test("same imported module keeps concurrent ctx HTTP and Settings isolated", async () => { + let enter, release; const entered = new Promise(resolve => { enter = resolve; }); const blocked = new Promise(resolve => { release = resolve; }); + const bytes = source(), calls = []; + const first = response(context(bytes, "airQuality", { http: { async get(url, options) { calls.push("first"); enter(); await blocked; return provider(75)(url, options); } } })); + await entered; + const disabled = await response(context(bytes, "airQuality", { env: { SETTINGS: '{"DataSets":[]}' } })); + const secondEnv = settings({ API: { ColorfulClouds: { Token: "synthetic-second" } } }); + const second = await response(context(bytes, "airQuality", { env: secondEnv, http: { async get(url, options) { assert.ok(url.includes("/synthetic-second/")); calls.push("second"); return provider(125)(url, options); } } })); + release(); const firstResult = await first; + assert.deepEqual(disabled.body, bytes); + assert.equal(decodeWK3DataSet(firstResult.body).index, 75); assert.equal(decodeWK3DataSet(second.body).index, 125); assert.deepEqual(calls, ["first", "second"]); + assert.equal(globalThis.fetch.name, "fetch"); +}); + +test("consumed binary input is restored on invalid config or provider failure; other paths untouched", async () => { + const bytes = source(); + for (const extra of [{ env: { SETTINGS: "{" } }, { http: { async get() { throw new Error("offline"); } } }]) { + const out = await response(context(bytes, "airQuality", extra)); assert.deepEqual(out.body, bytes); assert.equal(out.status, 200); + } + let read = false; + const unrelated = { request: { url: "https://weatherkit.apple.com/alertDetails/index.html" }, response: { arrayBuffer() { read = true; } } }; + assert.equal(await response(unrelated), undefined); assert.equal(read, false); +}); + +function alertSource() { + const b = new Builder(), details = b.createString("https://www.qweather.com/severe-weather/test-101010100.html?from=AppleWeatherService"), title = b.createString("大风"), source = b.createString("国家预警信息发布中心"); + b.startVector(1, 16, 1); for (let i = 15; i >= 0; i--) b.addInt8(i); const idbytes = b.endVector(); b.startObject(1); b.addFieldOffset(0, idbytes, 0); const id = b.endObject(); + b.startObject(22); b.addFieldOffset(0, id, 0); b.addFieldOffset(5, title, 0); b.addFieldOffset(16, source, 0); b.addFieldOffset(12, details, 0); b.addFieldInt8(14, 4, 0); b.addFieldInt32(9, issued, 0); const alert = b.endObject(); + b.startVector(4, 1, 4); b.addOffset(alert); const alerts = b.endVector(); b.startObject(4); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); const collection = b.endObject(); b.startObject(18); b.addFieldOffset(9, collection, 0); b.finish(b.endObject()); return b.asUint8Array(); +} + +test("unchanged Response default fetch binds to ctx; native Apple details complete with original UUID", async () => { + const calls = []; + const http = { async get(url, options) { calls.push({ url, options }); return new Response(html, { headers: { "content-type": "text/html" } }); } }; + const ctx = context(alertSource(), "weatherAlerts", { env: settings({ DataSets: ["weatherAlerts"] }), http }); + const out = await response(ctx), alerts = decodeWK3Alerts(out.body); + assert.equal(alerts.alerts[0].description, "大风蓝色预警"); + const details = new URL(alerts.detailsUrl); assert.equal(details.hostname, "weatherkit.apple.com"); assert.match(details.searchParams.get("ids"), /^wk3_/); + const reply = await request({ env: {}, request: { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?" + details.searchParams, headers: ctx.request.headers }, http, respond: value => value }); + const data = JSON.parse(reply.body); assert.equal(data.length, 1); assert.equal(data[0].id, "00010203-0405-0607-0809-0a0b0c0d0e0f"); assert.equal(data[0].detailsUrl, "#" + data[0].id); assert.match(data[0].messages[0].text, /注意防范/); + assert.equal(calls.length, 2); for (const call of calls) { assert.equal(call.options.headers.authorization, undefined); assert.equal(call.options.redirect, "error"); assert.equal(call.options.credentials, "omit"); } +}); + +test("malformed reserved native locator replies empty without upstream; disabled details pass through", async () => { + const ctx = { env: {}, request: { url: "https://weatherkit.apple.com/api/v2/weatherAlerts?ids=wk3_!" }, http: { async get() { throw new Error("must not fetch"); } }, respond: value => value }; + assert.equal((await request(ctx)).body, "[]"); + assert.equal(await request({ ...ctx, env: { SETTINGS: '{"DataSets":[]}' } }), undefined); +}); + +test("bundle runs in restricted host without Node, Fetch globals, ALS or Cloudflare globals", async () => { + const host = vm.createContext({ URL, TextEncoder, TextDecoder, atob, btoa, console: { log() {}, info() {}, debug() {}, warn() {}, error() {} } }); + const compiled = vm.runInContext(`(${bundles.response.code.replace("export default ", "")})`, host); + const ctx = context(source(), "airQuality", { http: { get: provider(75) } }); + const out = await compiled(ctx); + assert.equal(decodeWK3DataSet(out.body).index, 75); + for (const name of ["process", "Buffer", "fetch", "Request", "Response", "Headers", "caches", "AbortController", "setTimeout"]) assert.equal(vm.runInContext(`typeof ${name}`, host), "undefined"); +}); + +test("minimal official Headers methods preserve unknown headers and malformed body bytes", async () => { + const native = new Headers({ "content-type": "application/vnd.apple.flatbuffer", "x-unlisted": "keep", "content-encoding": "gzip", "content-length": "4" }); + const documentedHeaders = { get: name => native.get(name), set: (name, value) => native.set(name, value), delete: name => native.delete(name) }; + const bytes = new Uint8Array([255, 255, 255, 255]); + const ctx = context(bytes, "airQuality", { env: { SETTINGS: '{"DataSets":[]}' }, response: { status: 200, headers: documentedHeaders, async arrayBuffer() { return bytes.buffer; } } }); + const out = await response(ctx); assert.deepEqual(out.body, bytes); assert.equal(out.headers.get("x-unlisted"), "keep"); assert.equal(out.headers.get("content-length"), null); +}); + +test("airQualityScale fallback returns local JSON without third-party weather requests", async () => { + const ctx = context(new Uint8Array(), "", { request: { url: "https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EPA_NowCast.2604", headers: {} }, response: new Response("{}", { status: 404, headers: { "content-type": "application/json" } }) }); + const out = await response(ctx); assert.equal(out.status, 200); assert.equal(typeof out.body, "string"); assert.ok(JSON.parse(out.body)); +}); + +test("v2 selective codec and early iOS27 binary route run through native response adapter", async () => { + const bytes = WeatherKit2.encode(undefined, { airQuality: { metadata: { sourceType: "MODELED", providerName: "Synthetic" }, scale: "HJ6332012.2604", index: 52, categoryIndex: 2, pollutants: [{ pollutantType: "PM2_5", amount: 10, units: "MICROGRAMS_PER_CUBIC_METER" }], primaryPollutant: "PM2_5", previousDayComparison: "SAME" } }); + for (const ios27 of [false, true]) { + const ctx = context(bytes, "airQuality", { env: settings({ AirQuality: { Current: { Index: { Provider: "WeatherKit", Replace: [] }, Pollutants: { Provider: "WeatherKit" } }, Comparison: { ReplaceWhenCurrentChange: false, Yesterday: { IndexProvider: "WeatherKit" } } } }) }); + ctx.request.url = ctx.request.url.replace("/v3/", "/v2/"); if (ios27) ctx.request.headers.set("user-agent", "WeatherKit_Weather_iOS_Version 27.2"); + const out = await response(ctx); assert.equal(WeatherKit2.decode(new ByteBuffer(out.body), ["airQuality"]).airQuality.index, 52); + } +}); + +test('upstream scale request category preserves Apple-first result and local fallback',async()=>{ + const u='https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EPA_NowCast.2604'; + for(const status of [200,404]){ + let calls=0;const result=await request({env:{},request:{url:u,headers:{Authorization:'synthetic-apple'}},http:{async get(url,options){calls++;assert.equal(url,u);assert.equal(options.headers.authorization,'synthetic-apple');return new Response('{"name":"Apple original"}',{status,headers:{'content-type':'application/json','x-original':'keep'}})}},respond:x=>x}); + assert.equal(calls,1);assert.equal(result.status,200);const j=JSON.parse(result.body);if(status===200){assert.equal(j.name,'Apple original');assert.equal(result.headers.get('x-original'),'keep');}else assert.equal(j.name,'EPA_NowCast'); + } + let responded=false;assert.equal(await request({env:{},request:{url:u,headers:{}},http:{async get(){throw Error('offline')}},respond(){responded=true}}),undefined);assert.equal(responded,false); +}); diff --git a/tests/wk3/egern-parameters.test.mjs b/tests/wk3/egern-parameters.test.mjs new file mode 100644 index 00000000..cd9d6824 --- /dev/null +++ b/tests/wk3/egern-parameters.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm'; +import {displayModuleOption} from '../../src/wk3/option-values.mjs'; +import {configuration}from '../../src/wk3/entry/index.mjs';import {localParameters}from '../../src/wk3/parameters.mjs';import {renderLocalModule,localScriptGroups}from '../../scripts/build-egern-module.mjs';import {buildEgern}from '../../scripts/build-egern.mjs'; +const defaults=()=>Object.fromEntries(localParameters.map(p=>[p.key,p.value])); +const value=(o,path)=>path.split('.').reduce((a,k)=>a?.[k],o); +test('all original restored keys reach Settings with current upstream EU/minute/yesterday defaults',()=>{ + const {Settings:s}=configuration({env:defaults()});assert.equal(s.AirQuality.Current.Index.Provider,'Calculate');assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider,'ColorfulCloudsUS');assert.equal(s.NextHour.Provider,'ColorfulClouds');assert.equal(s.WeatherAlerts.Provider,'QWeatherWeb');assert.deepEqual(s.Weather.Replace,['CN']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,[]);assert.equal(s.Weather.ReplaceDaily,false);assert.equal(s.Weather.ReplaceHourly,false);assert.equal(s.AirQuality.Calculate.Algorithm,'EU_EAQI');assert.equal(s.API.QWeather.Host,'devapi.qweather.com');assert.equal(s.API.QWeather.Token,null); + for(const p of localParameters){if(p.key==='HTTP_TIMEOUT_MS')continue;assert.notEqual(value(s,p.key),undefined,p.key);} +}); +test('dotted edits override JSON defaults and list values do not turn into nested arrays',()=>{ + const s=configuration({env:{SETTINGS:JSON.stringify({AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS'}}}}),DataSets:'airQuality, forecastNextHour','Weather.Replace':'CN,HK','AirQuality.Current.Index.Provider':'ColorfulCloudsCN','AirQuality.Current.Index.Replace':'["HJ6332012","EPA_NowCast"]','AirQuality.Current.Pollutants.Units.Replace':'EPA_NowCast,UBA','Weather.ReplaceDaily':'true'}}).Settings; + assert.deepEqual(s.DataSets,['airQuality','forecastNextHour']);assert.deepEqual(s.Weather.Replace,['CN','HK']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012','EPA_NowCast']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,['EPA_NowCast','UBA']);assert.equal(s.AirQuality.Current.Index.Provider,'ColorfulCloudsCN');assert.equal(s.Weather.ReplaceDaily,true); +}); +test('explicit empty lists clear selections while malformed lists fail before processing',()=>{ + const s=configuration({env:{...defaults(),DataSets:'[]','Weather.Replace':'[]','AirQuality.Current.Index.Replace':'[]'}}).Settings;assert.deepEqual(s.DataSets,[]);assert.deepEqual(s.Weather.Replace,[]);assert.deepEqual(s.AirQuality.Current.Index.Replace,[]); + assert.throws(()=>configuration({env:{DataSets:'[1]'}}),/Invalid list/);assert.throws(()=>configuration({env:{DataSets:'['}})); +}); +test('WeatherKit alert source disables both request and response alert handling dataset',()=>{ + const s=configuration({env:{...defaults(),'WeatherAlerts.Provider':'WeatherKit'}}).Settings;assert.ok(!s.DataSets.includes('weatherAlerts'));assert.ok(s.DataSets.includes('forecastNextHour')); +}); +test('API numeric strings and comma values survive without coercion or accidental disclosure',()=>{ + const s=configuration({env:{...defaults(),'API.ColorfulClouds.Token':'001234','API.QWeather.Token':'example,synthetic','API.QWeather.Host':''}}).Settings;assert.equal(s.API.ColorfulClouds.Token,'001234');assert.equal(s.API.QWeather.Token,'example,synthetic');assert.equal(s.API.QWeather.Host,'devapi.qweather.com'); +}); +test('template declares every placeholder and binds identical parameters to both scripts',()=>{ + const m=renderLocalModule();const line=prefix=>JSON.parse(m.split('\n').find(s=>s.startsWith(prefix)).slice(prefix.length));const args=line('compat_arguments: '),schema=line('env_schema: ');assert.equal(Object.keys(args).length,localParameters.length);assert.deepEqual(Object.keys(args),Object.keys(schema));const refs=[...m.matchAll(/\{\{\{([^}]+)\}\}\}/g)].map(x=>x[1]);for(const p of localParameters){assert.equal(refs.filter(x=>x===p.key).length,localScriptGroups.length);assert.equal(args[p.key],displayModuleOption(p.key,p.value));assert.equal(schema[p.key].default_value,displayModuleOption(p.key,p.value));} + assert.ok(!m.includes('url_rewrites:')&&!m.includes('vercel.app'));assert.match(m,/binary_body: true/);assert.match(m,/hostnames:\n includes:/); +}); +test('tagged Egern module uses the same versioned release attachments as other platforms',()=>{ + const text=renderLocalModule({version:'3.3.2'}); + assert.equal((text.match(/script_url: https:\/\/github.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/wk3-egern-(?:request|response)\.bundle\.js/g)||[]).length,localScriptGroups.length); + assert.doesNotMatch(text,/gist\.githubusercontent\.com/); + assert.match(renderLocalModule(),/gist\.githubusercontent\.com/); + assert.throws(()=>renderLocalModule({version:'latest'}),/explicit release version/); +}); +test('actual built script obeys DataSets=[] and LogLevel OFF, preserving binary bytes with no network',async()=>{ + const {response}=await buildEgern();let logs=0,network=0;const host=vm.createContext({URL,TextEncoder,TextDecoder,atob,btoa,console:Object.fromEntries(['log','info','warn','error','debug'].map(k=>[k,()=>logs++]))});const handler=vm.runInContext('('+response.code.replace('export default ','')+')',host);const bytes=new Uint8Array([255,255,255,255]);const out=await handler({env:{...defaults(),DataSets:'[]',LogLevel:'OFF'},request:{url:'https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality',headers:{}},response:new Response(bytes,{headers:{'content-type':'application/vnd.apple.flatbuffer'}}),http:{get(){network++;throw Error('unexpected network')}}});assert.deepEqual(Array.from(out.body),Array.from(bytes));assert.equal(network,0);assert.equal(logs,0); +}); diff --git a/tests/wk3/native-alert-details.test.mjs b/tests/wk3/native-alert-details.test.mjs new file mode 100644 index 00000000..729306de --- /dev/null +++ b/tests/wk3/native-alert-details.test.mjs @@ -0,0 +1,184 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import WeatherAlerts from "../../src/wk3/class/WeatherAlerts.mjs"; +import { buildNativeAlertDetailsURL, resolveNativeAlertDetails, isNativeAlertDetailsID } from "../../src/wk3/class/NativeAlertDetails.mjs"; + +// Synthetic public HTML and UUID bytes only; no Apple sessions or coordinates. +const identifier = "synthetic-101010100"; +const issuedTime = Date.parse("2030-01-01T00:00:00Z") / 1000; +const uuid = "00010203-0405-0607-0809-0a0b0c0d0e0f"; +function alert(description = "大风", seed = 0, extra = {}) { + return { id: { bytes: Array.from({ length: 16 }, (_, i) => i + seed) }, description, issuedTime, severity: 4, ...extra }; +} +function block(title = "大风蓝色预警", time = "2030-01-01T08:00:00+08:00", color = "blue") { + return `<div class="c-city-warning-events warning--${color}"><h3>测试区气象台发布${title}</h3><p>发布日期:${time}</p><p class="warning-events__txt">注意防范${title}。</p><div class="warning-defense__txt"><p>1. 停止高空作业。</p></div></div>`; +} +const htmlFetch = html => async () => new Response(html, { headers: { "content-type": "text/html" } }); +function idsFor(alerts = [alert()], options = {}) { + return new URL(buildNativeAlertDetailsURL({ identifier, alerts, ...options })).searchParams.get("ids"); +} +function encodePayload(payload) { + return "wk3_" + Buffer.from(JSON.stringify(payload)).toString("base64url"); +} +function readPayload(ids) { + return JSON.parse(Buffer.from(ids.slice(4), "base64url").toString("utf8")); +} +const validPayload = () => readPayload(idsFor()); + +test("native Apple page URL carries a minimal UTF-8 event mapping and optional timezone", () => { + const original = alert("大风蓝色预警 🌬", 0, { latitude: 1, apiKey: "excluded", source: "excluded" }); + const before = structuredClone(original); + const url = new URL(buildNativeAlertDetailsURL({ identifier, language: "zh-Hans", alerts: [original], timezone: "Asia/Shanghai" })); + assert.equal(url.origin, "https://weatherkit.apple.com"); + assert.equal(url.pathname, "/alertDetails/index.html"); + assert.equal(url.searchParams.get("party"), "qweather"); + assert.equal(url.searchParams.get("lang"), "zh-Hans"); + assert.equal(url.searchParams.get("timezone"), "Asia/Shanghai"); + const ids = url.searchParams.get("ids"); + assert.match(ids, /^wk3_[A-Za-z0-9_-]+$/); + assert.deepEqual(readPayload(ids), { region: identifier, alerts: [{ id: uuid, description: original.description, issuedTime, severity: 4 }] }); + assert.deepEqual(original, before); + assert.equal(new URL(buildNativeAlertDetailsURL({ identifier, alerts: [alert()] })).searchParams.has("timezone"), false); + assert.equal(isNativeAlertDetailsID("wk3_!"), true); + assert.equal(isNativeAlertDetailsID(identifier), false); + assert.equal(isNativeAlertDetailsID(null), false); +}); + +test("single event keeps every REST field, Apple UUID and fragment with matching message language", async () => { + const fetchImpl = htmlFetch(block()); + const source = await WeatherAlerts.DetailsForQWeather(identifier, "zh-Hans", fetchImpl); + const result = await resolveNativeAlertDetails(idsFor(), "zh-Hans", fetchImpl); + assert.notEqual(source[0].id, uuid); + assert.deepEqual(result, [{ ...source[0], id: uuid, detailsUrl: `#${uuid}` }]); + assert.equal(result[0].description, "大风蓝色预警"); + assert.equal(result[0].severity, "minor"); + assert.equal(result[0].effectiveTime, "2030-01-01T00:00:00.000Z"); + assert.equal(result[0].source, "测试区气象台"); + assert.ok(result[0].messages.length); + assert.ok(result[0].messages.every(message => message.language === "zh-Hans")); + assert.equal(result[0].name, "WeatherAlert"); + assert.equal(result[0].responses[0], "monitor"); +}); + +test("multiple events match by title and issuance across source reorder, excluding unrequested events", async () => { + const originals = [alert(), alert("暴雨蓝色预警", 16), alert("大风", 32, { issuedTime: issuedTime + 3600 })]; + const fetchImpl = htmlFetch(block("大风蓝色预警", "2030-01-01T09:00:00+08:00") + block("雷电蓝色预警") + block("暴雨蓝色预警") + block()); + const result = await resolveNativeAlertDetails(idsFor(originals), "zh-CN", fetchImpl); + assert.deepEqual(result.map(item => item.id), readPayload(idsFor(originals)).alerts.map(item => item.id)); + assert.deepEqual(result.map(item => item.description), ["大风蓝色预警", "暴雨蓝色预警", "大风蓝色预警"]); + assert.deepEqual(result.map(item => Date.parse(item.issuedTime) / 1000), [issuedTime, issuedTime, issuedTime + 3600]); + assert.ok(result.every(item => item.detailsUrl === `#${item.id}`)); +}); + +test("missing, expired, different issuance, unrelated substring and severity never fall back by index", async () => { + for (const html of ["", block("暴雨蓝色预警"), block("雷暴大风蓝色预警"), block("大风蓝色预警", "2030-01-01T08:01:00+08:00"), block("大风黄色预警", undefined, "yellow")]) { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", htmlFetch(html)), /Missing or ambiguous/); + } + await assert.rejects(resolveNativeAlertDetails(idsFor([alert("大风黄色预警")]), "zh-CN", htmlFetch(block())), /Missing or ambiguous/); + // A full title's exact event name is accepted; a prefix is not a match. + await assert.rejects(resolveNativeAlertDetails(idsFor([alert("风")]), "zh-CN", htmlFetch(block())), /Missing or ambiguous/); +}); + +test("ambiguity is atomic: duplicate source or two originals sharing a candidate fails", async () => { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", htmlFetch(block() + block())), /ambiguous/); + await assert.rejects(resolveNativeAlertDetails(idsFor([alert(), alert("大风蓝色预警", 16)]), "zh-CN", htmlFetch(block())), /Ambiguous shared/); + await assert.rejects(resolveNativeAlertDetails(idsFor([alert(), alert("暴雨", 16)]), "zh-CN", htmlFetch(block())), /Missing or ambiguous/); +}); + +test("source failures propagate and only fixed public QWeather requests receive fetch", async () => { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", async () => { throw new Error("offline"); }), /offline/); + for (const response of [new Response("no", { status: 503 }), new Response("{}", { headers: { "content-type": "application/json" } })]) { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", async () => response)); + } + await resolveNativeAlertDetails(idsFor(), "en-US", async (url, options) => { + assert.equal(url, `https://www.qweather.com/en/severe-weather/${identifier}.html?from=AppleWeatherService`); + assert.deepEqual(options, { redirect: "error", headers: { Accept: "text/html" } }); + return htmlFetch(block())(); + }); +}); + +test("malformed payloads, SSRF regions and extra fields are rejected before any fetch", async () => { + let calls = 0; + const neverFetch = async () => { calls++; throw new Error("must not fetch"); }; + const malformed = [undefined, "", identifier, "wk3_", "wk3_!", "wk3_a", "wk3_e30=", "wk3__w", "wk3_" + "a".repeat(16384)]; + for (const mutate of [ + p => { p.region = "https://evil.invalid/a-101010100"; }, + p => { p.region = "../synthetic-101010100"; }, + p => { p.region = "synthetic-123"; }, + p => { p.region = "a".repeat(129) + "-101010100"; }, + p => { p.alerts = []; }, + p => { p.alerts = Array.from({ length: 33 }, () => p.alerts[0]); }, + p => { p.alerts.push({ ...p.alerts[0] }); }, + p => { p.alerts[0].id = "not-a-uuid"; }, + p => { p.alerts[0].id = uuid.toUpperCase(); }, + p => { p.alerts[0].description = " "; }, + p => { p.alerts[0].description = "x".repeat(513); }, + p => { p.alerts[0].issuedTime = 0; }, + p => { p.alerts[0].issuedTime = "2030-01-01T00:00:00Z"; }, + p => { p.alerts[0].issuedTime = 1.5; }, + p => { p.alerts[0].issuedTime = 0x100000000; }, + p => { p.alerts[0].severity = "minor"; }, + p => { p.alerts[0].severity = 256; }, + p => { delete p.alerts[0].description; }, + p => { p.latitude = 1; }, + p => { p.alerts[0].authorization = "excluded"; }, + ]) { + const payload = validPayload(); mutate(payload); malformed.push(encodePayload(payload)); + } + malformed.push(encodePayload(null), encodePayload([])); + malformed.push("wk3_" + Buffer.from("not JSON").toString("base64url")); + // Noncanonical unused base64 bits decode to valid JSON, but are rejected. + const emptyObject = Buffer.from("{}").toString("base64url"); + malformed.push("wk3_" + emptyObject.slice(0, -1) + "1"); + for (const ids of malformed) await assert.rejects(resolveNativeAlertDetails(ids, "zh-CN", neverFetch)); + await assert.rejects(resolveNativeAlertDetails(idsFor(), "en?bad", neverFetch), /language/); + assert.equal(calls, 0); +}); + +test("builder validates UUID bytes, region, count, field size, locale and timezone", () => { + const build = options => buildNativeAlertDetailsURL({ identifier, alerts: [alert()], ...options }); + for (const bytes of [[], Array(16).fill(-1), Array(16).fill(256), Array(16).fill(1.5), Array(16).fill("1"), Array(16)]) { + assert.throws(() => build({ alerts: [alert("大风", 0, { id: { bytes } })] }), /UUID/); + } + assert.doesNotThrow(() => build({ alerts: [alert("大风", 0, { id: { bytes: new Uint8Array(16) } })] })); + for (const options of [ + { identifier: "https://evil.invalid" }, { alerts: [] }, { alerts: null }, + { alerts: [alert(), alert()] }, { alerts: [alert("x".repeat(513))] }, + { alerts: [alert("大风", 0, { issuedTime: 0 })] }, + { language: "zh-CN&ids=other" }, { timezone: "Asia/Shanghai?x=1" }, { timezone: 1 }, + ]) assert.throws(() => build(options)); + assert.throws(() => build({ alerts: Array.from({ length: 32 }, (_, i) => alert("风".repeat(500), i)) }), /too long/); +}); + +test("unverified WK3 severity integers are never guessed or added to the wire mapping", async () => { + for (const severity of [0, 5, 231]) { + await assert.rejects(resolveNativeAlertDetails(idsFor([alert("大风", 0, { severity })]), "zh-CN", async () => { assert.fail("must not fetch"); }), /Unverified WK3 severity/); + } +}); + +test('paired MODERATE=3 supports reordered yellow/yellow and yellow/blue event groups', async()=>{ + for(const originals of [[alert('寒潮黄色预警',0,{severity:3}),alert('大风蓝色预警',16)], [alert('大风',0,{severity:3}),alert('寒潮',16,{severity:3})]]){ + const page=originals.slice().reverse().map(a=>block(a.description.includes('寒潮')?'寒潮黄色预警':a.severity===3?'大风黄色预警':'大风蓝色预警',undefined,a.severity===3?'yellow':'blue')).join(''); + const result=await resolveNativeAlertDetails(idsFor(originals),'zh-CN',htmlFetch(page)); + assert.deepEqual(result.map(a=>a.severity),originals.map(a=>a.severity===3?'moderate':'minor')); + assert.deepEqual(result.map(a=>a.id),readPayload(idsFor(originals)).alerts.map(a=>a.id)); + assert.ok(result.every(a=>a.messages.length&&a.detailsUrl==='#'+a.id)); + } +}); + +test('upstream severity definitions support red/orange across event types and mixed groups', async () => { + for (const name of ['高速公路灾害性天气预警', '道路结冰', '暴雪', '暴雨', '森林火险', '大风']) { + for (const [severity, color, css, token] of [[1, '红', 'red', 'extreme'], [2, '橙', 'orange', 'severe']]) { + const original = alert(name, 0, { severity }); + const result = await resolveNativeAlertDetails(idsFor([original]), 'zh-CN', htmlFetch(block(`${name}${color}色预警信号`, undefined, css))); + assert.equal(result[0].id, uuid); + assert.equal(result[0].severity, token); + assert.ok(result[0].messages.length); + await assert.rejects(resolveNativeAlertDetails(idsFor([original]), 'zh-CN', htmlFetch(block(`${name}${color}色预警信号`, '2030-01-01T08:01:00+08:00', css))), /Missing or ambiguous/); + } + } + const originals = [alert('高速公路灾害性天气预警', 0, { severity: 1 }), alert('道路结冰', 16, { severity: 3 })]; + const result = await resolveNativeAlertDetails(idsFor(originals), 'zh-CN', htmlFetch(block('道路结冰黄色预警', undefined, 'yellow') + block('高速公路灾害性天气预警红色预警信号', undefined, 'red'))); + assert.deepEqual(result.map(a => a.severity), ['extreme', 'moderate']); + assert.deepEqual(result.map(a => a.id), readPayload(idsFor(originals)).alerts.map(a => a.id)); +}); diff --git a/tests/wk3/wk2-selective.test.mjs b/tests/wk3/wk2-selective.test.mjs new file mode 100644 index 00000000..5503ef12 --- /dev/null +++ b/tests/wk3/wk2-selective.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Builder, ByteBuffer } from "flatbuffers"; +import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../../src/wk3/class/WeatherKit2.mjs"; +import * as WK2 from "../../src/wk3/proto/apple/wk2.js"; + +// All fixtures are synthetic FlatBuffers, with no HAR or user response data. +const airQuality = { + categoryIndex: 2, index: 42, isSignificant: true, pollutants: [], + previousDayComparison: "SAME", primaryPollutant: "PM2_5", scale: "SYNTHETIC", +}; +const nextHour = { condition: [], minutes: [], summary: [], forecastStart: 100, forecastEnd: 200 }; + +function slotTable(buffer, id) { + const root = buffer.position() + buffer.readUint32(buffer.position()); + const field = buffer.__offset(root, 4 + id * 2); + assert.notEqual(field, 0, `root slot ${id} must exist`); + return buffer.__indirect(root + field); +} + +// Unknown slot #12 is a real table with a string and nested table containing a +// 64-bit scalar. Keeping its arena intact must preserve every relative pointer. +function fixture() { + const builder = new Builder(); + const label = builder.createString("future product\u0000\u5929\u6c14"); + builder.startObject(1); + builder.addFieldInt64(0, 0x123456789abcdefn, 0n); + const nested = builder.endObject(); + builder.startObject(2); + builder.addFieldOffset(0, label, 0); + builder.addFieldOffset(1, nested, 0); + const future = builder.endObject(); + const aq = encodeAirQuality(builder, airQuality); + const current = WK2.CurrentWeatherData.createCurrentWeatherData(builder, 0, 123); + builder.startObject(13); + builder.addFieldOffset(0, aq, 0); + builder.addFieldOffset(1, current, 0); + builder.addFieldOffset(12, future, 0); + builder.finish(builder.endObject()); + return builder.asUint8Array(); +} + +function readFuture(buffer) { + const table = slotTable(buffer, 12); + const labelField = buffer.__offset(table, 4); + const nestedField = buffer.__offset(table, 6); + const nested = buffer.__indirect(table + nestedField); + const valueField = buffer.__offset(nested, 4); + assert.equal((nested + valueField) % 8, 0, "64-bit scalar remains aligned"); + return { label: buffer.__string(table + labelField), value: buffer.readInt64(nested + valueField) }; +} + +function tableBytes(buffer, id) { + const table = slotTable(buffer, id); + const vtable = table - buffer.readInt32(table); + return buffer.bytes().slice(table, table + buffer.readUint16(vtable + 2)); +} + +test("only selected products are decoded, even with an unreadable other codec", () => { + const buffer = new ByteBuffer(fixture()); + const original = WK2.CurrentWeatherData.prototype.asOf; + let calls = 0; + WK2.CurrentWeatherData.prototype.asOf = function () { calls++; throw new Error("unselected codec called"); }; + try { + const decoded = WeatherKit2.decode(buffer, ["airQuality"]); + assert.deepEqual(Object.keys(decoded), ["airQuality"]); + assert.equal(decoded.airQuality.index, 42); + assert.deepEqual(WeatherKit2.decode(buffer), {}); + assert.equal(calls, 0); + // Positive control: selecting the same codec actually invokes it. + assert.deepEqual(WeatherKit2.decode(buffer, ["currentWeather"]), {}); + assert.equal(calls, 1); + } finally { + WK2.CurrentWeatherData.prototype.asOf = original; + } +}); + +test("patch preserves a real unknown root slot, nested offsets and untouched known table bytes", () => { + const bytes = fixture(); + const before = new ByteBuffer(bytes); + const snapshot = bytes.slice(); + const updated = WeatherKit2.encode(before, { airQuality: { ...airQuality, index: 87 } }); + const after = new ByteBuffer(updated); + assert.deepEqual(readFuture(after), readFuture(before)); + assert.deepEqual(readFuture(after), { label: "future product\u0000\u5929\u6c14", value: 0x123456789abcdefn }); + assert.deepEqual(tableBytes(after, 1), tableBytes(before, 1)); + assert.equal(WK2.Weather.getRootAsWeather(after).currentWeather().asOf(), 123); + assert.equal(WeatherKit2.decode(after, ["airQuality"]).airQuality.index, 87); + assert.deepEqual(bytes, snapshot, "source buffer is not mutated"); + // A second patch must keep the same unknown data alive as arenas accumulate. + const twice = new ByteBuffer(WeatherKit2.encode(after, { airQuality: { ...airQuality, index: 90 } })); + assert.deepEqual(readFuture(twice), readFuture(before)); + assert.equal(WeatherKit2.decode(twice, ["airQuality"]).airQuality.index, 90); +}); + +test("empty or rejected patches preserve the original bytes exactly", () => { + const source = fixture(); + assert.deepEqual(WeatherKit2.encode(new ByteBuffer(source)), source); + assert.deepEqual(WeatherKit2.encode(new ByteBuffer(source), { futureProduct: {} }), source); + assert.deepEqual(WeatherKit2.encode(new ByteBuffer(source), { airQuality: null }), source); +}); + +test("source ByteBuffer position is respected when preserving an unknown slot", () => { + const source = fixture(); + const prefixed = new Uint8Array(source.length + 8); + prefixed.set(source, 8); + const buffer = new ByteBuffer(prefixed); + buffer.setPosition(8); + const output = WeatherKit2.encode(buffer, { airQuality: { ...airQuality, index: 21 } }); + assert.equal(buffer.position(), 8); + assert.deepEqual(readFuture(new ByteBuffer(output)), readFuture(new ByteBuffer(source))); + assert.equal(WeatherKit2.decode(new ByteBuffer(output), ["airQuality"]).airQuality.index, 21); + assert.deepEqual(WeatherKit2.encode(buffer, {}), source); +}); + +test("new roots and adding previously absent products use the patch API", () => { + const source = WeatherKit2.encode(undefined, { airQuality }); + const output = WeatherKit2.encode(new ByteBuffer(source), { forecastNextHour: nextHour }); + const decoded = WeatherKit2.decode(new ByteBuffer(output), ["forecastNextHour", "airQuality"]); + assert.deepEqual(Object.keys(decoded), ["airQuality", "forecastNextHour"], "physical slot order"); + assert.equal(decoded.airQuality.index, 42); + assert.deepEqual(decoded.forecastNextHour, { metadata: undefined, ...nextHour }); +}); + +test("standalone AQ and next-hour exports accept generated tables and produce builder offsets", () => { + for (const [encode, decode, Model, data] of [ + [encodeAirQuality, decodeAirQuality, WK2.AirQuality, airQuality], + [encodeForecastNextHour, decodeForecastNextHour, WK2.NextHourForecastData, nextHour], + ]) { + const builder = new Builder(); + const offset = encode(builder, data); + assert.ok(Number.isSafeInteger(offset) && offset > 0); + builder.finish(offset); + const buffer = new ByteBuffer(builder.asUint8Array()); + const table = new Model().__init(buffer.readUint32(0), buffer); + assert.deepEqual(decode(table), { metadata: undefined, ...data }); + } +}); + +test("all ten upstream product codecs round-trip through the bundled generated models", () => { + const patch = { + airQuality, + currentWeather: { + asOf: 123, + precipitationAmountNext1hByType: [], precipitationAmountNext6hByType: [], + precipitationAmountNext24hByType: [], precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious6hByType: [], precipitationAmountPrevious24hByType: [], + }, + forecastDaily: { days: [] }, + forecastHourly: { hours: [] }, + forecastNextHour: nextHour, + news: { placements: [] }, + weatherAlerts: { alerts: [], detailsUrl: "https://example.invalid/alerts" }, + weatherChanges: { changes: [], forecastStart: 100, forecastEnd: 200 }, + historicalComparisons: { comparisons: [] }, + locationInfo: { preciseName: "Test", countryCode: "SG", timeZone: "Asia/Singapore", primaryName: "Test" }, + }; + const decoded = WeatherKit2.decode(new ByteBuffer(WeatherKit2.encode(undefined, patch)), Object.keys(patch)); + assert.deepEqual(Object.keys(decoded), Object.keys(patch)); + assert.equal(decoded.currentWeather.asOf, 123); + assert.equal(decoded.weatherAlerts.detailsUrl, patch.weatherAlerts.detailsUrl); + assert.equal(decoded.locationInfo.timeZone, "Asia/Singapore"); + const roundTrip = WeatherKit2.decode(new ByteBuffer(WeatherKit2.encode(undefined, decoded)), Object.keys(patch)); + assert.deepEqual(roundTrip, decoded); +}); + +test("invalid selectors and patch containers reject the old API shape", () => { + assert.throws(() => WeatherKit2.decode(new ByteBuffer(fixture()), "airQuality"), TypeError); + assert.throws(() => WeatherKit2.encode(undefined, []), TypeError); + assert.throws(() => WeatherKit2.encode(new Builder(), { airQuality }), TypeError); +}); diff --git a/tests/wk3/wk3-alerts.test.mjs b/tests/wk3/wk3-alerts.test.mjs new file mode 100644 index 00000000..a6ed3621 --- /dev/null +++ b/tests/wk3/wk3-alerts.test.mjs @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { decodeWK3Alerts, patchWK3Alerts, enrichWK3Alerts, patchWK3QWeatherAlerts, WK3_ALERT_ENUMS, WK3AlertsTable, wk3AlertsCodec } from "../../src/wk3/class/WK3Alerts.mjs"; +import WeatherAlerts from "../../src/wk3/class/WeatherAlerts.mjs"; + +// All fixtures synthetic. No HAR, Apple response bytes, credentials or real location. +const html = `<h1 class="c-submenu__location">测试区</h1><div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00+08:00</p><p class="warning-events__txt">注意防范大风。</p><div class="warning-defense__txt"><p>1. 停止高空作业。</p></div></div>`; +const issued = Date.parse("2030-01-01T00:00:00Z") / 1000; +function fixture({ unknown = false, missing = false, empty = false, rawSeverity = 4 } = {}) { + const b = new Builder(); + const aq = b.createString("AQ-UNKNOWN-PRESERVE"), future = b.createString("FUTURE-WEATHER-PRESERVE"); + const details = b.createString("https://www.qweather.com/severe-weather/test-101010100.html?from=AppleWeatherService"); + const description = b.createString("大风"), source = b.createString("国家预警信息发布中心"); + b.startVector(1, 16, 1); for (let i = 15; i >= 0; i--) b.addInt8(i); const idbytes = b.endVector(); + b.startObject(1); b.addFieldOffset(0, idbytes, 0); const id = b.endObject(); + b.startVector(1, 1, 1); b.addInt8(7); const responses = b.endVector(); + b.startObject(unknown ? 23 : 22); b.addFieldOffset(0, id, 0); b.addFieldOffset(5, description, 0); b.addFieldOffset(16, source, 0); + b.addFieldOffset(12, details, 0); b.addFieldInt8(14, rawSeverity, 0); b.addFieldOffset(21, responses, 0); b.addFieldInt32(9, issued, 0); + if (unknown) b.addFieldInt32(22, 123456, 0); const alert = b.endObject(); + b.startVector(4, empty ? 0 : 1, 4); if (!empty) b.addOffset(alert); const alerts = b.endVector(); + b.startObject(4); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); b.addFieldInt32(3, 987654, 0); const collection = b.endObject(); + b.startObject(18); b.addFieldOffset(0, aq, 0); b.addFieldOffset(17, future, 0); if (!missing) b.addFieldOffset(9, collection, 0); + b.finish(b.endObject()); return b.asUint8Array(); +} +function alertVectorPointer(bytes) { + const bb = new ByteBuffer(bytes), root = bb.readUint32(0), col = bb.__indirect(root + bb.__offset(root, 22)); + return bb.__vector(col + bb.__offset(col, 8)); +} +const fetchPage = async () => new Response(html, { headers: { "Content-Type": "text/html" } }); +test("observed WK3 enums are explicit, not WK2 arithmetic", () => { + assert.equal(WK3_ALERT_ENUMS.severity.MINOR, 4); assert.equal(WK3_ALERT_ENUMS.significance.STATEMENT, 10); + assert.equal(WK3_ALERT_ENUMS.responses.MONITOR, 7); + assert.equal(WK3_ALERT_ENUMS.severity.SEVERE, 2); assert.equal(WK3_ALERT_ENUMS.severity.EXTREME, 1); + const d = decodeWK3Alerts(fixture({ rawSeverity: 231 })); assert.equal(d.alerts[0].severity, 231); + assert.deepEqual(d.alerts[0].responses, [7]); assert.deepEqual(d.alerts[0].id.bytes, Array.from({ length: 16 }, (_, i) => i)); +}); +test("patch only changes vector pointer and appends child; entire weather survives", () => { + const bytes = fixture(), snapshot = bytes.slice(), d = decodeWK3Alerts(bytes), pointer = alertVectorPointer(bytes); + const out = patchWK3Alerts(bytes, [{ ...d.alerts[0], description: "大风蓝色预警", eventEndTime: issued + 3600 }]); + assert.deepEqual(bytes, snapshot); assert.ok(out.length > bytes.length); + for (let i = 0; i < bytes.length; i++) if (i < pointer || i >= pointer + 4) assert.equal(out[i], bytes[i]); + assert.equal(decodeWK3Alerts(out).alerts[0].description, "大风蓝色预警"); + assert.equal(decodeWK3Alerts(out).alerts[0].eventEndTime, issued + 3600); +}); +test("no-op/missing/empty collections preserve original view", () => { + const bytes = fixture(); assert.strictEqual(patchWK3Alerts(bytes, decodeWK3Alerts(bytes).alerts), bytes); + const empty = fixture({ empty: true }); assert.strictEqual(patchWK3Alerts(empty, []), empty); + assert.equal(decodeWK3Alerts(fixture({ missing: true })), null); +}); +test("unknown summary fields and unsupported enum strings fail atomically", () => { + const bytes = fixture({ unknown: true }), d = decodeWK3Alerts(bytes); + assert.throws(() => patchWK3Alerts(bytes, [{ ...d.alerts[0], description: "changed" }]), /Unverified/); + const b = fixture(), a = decodeWK3Alerts(b).alerts[0]; + assert.throws(() => patchWK3Alerts(b, [{ ...a, severity: "MINOR" }]), /wire integer/); + assert.throws(() => patchWK3Alerts(b, [{ ...a, areaName: "unverified" }]), /Unverified/); + assert.throws(() => patchWK3Alerts(b, []), /add\/remove/); + assert.throws(() => patchWK3Alerts(b, [{ ...a, id: null }]), /identity/); +}); +test("unobserved provider enum names are not injected", () => { + const d = decodeWK3Alerts(fixture()); + const [a] = enrichWK3Alerts(d, [{ severity: "severe", urgency: "immediate", eventEndTime: "2030-01-02T00:00:00Z" }]); + assert.equal(a.severity, 4); assert.equal(a.urgency, 0); assert.equal(a.eventEndTime, issued + 86400); +}); +test("ByteBuffer positions, views and malformed offsets", () => { + const bytes = fixture(), padded = new Uint8Array(bytes.length + 8); padded.set(bytes, 8); + const bb = new ByteBuffer(padded); bb.setPosition(8); + assert.deepEqual(decodeWK3Alerts(bb).alerts, decodeWK3Alerts(padded.subarray(8)).alerts); + assert.throws(() => decodeWK3Alerts(new Uint8Array([255, 255, 255, 255])), /offset/); +}); +test("codec embeds existing opaque collection arena without WK2 tables", () => { + const bytes = fixture(), bb = new ByteBuffer(bytes), root = bb.readUint32(0), col = bb.__indirect(root + bb.__offset(root, 22)); + const d = wk3AlertsCodec.decode(new WK3AlertsTable().__init(col, bb)); d.alerts[0].description = "modified"; + const b = new Builder(); const offset = wk3AlertsCodec.encode(b, d); b.finish(offset); + const out = b.asUint8Array(), childbb = new ByteBuffer(out); + const reread = wk3AlertsCodec.decode(new WK3AlertsTable().__init(childbb.readUint32(0), childbb)); + assert.equal(reread.alerts[0].description, "modified"); + assert.throws(() => wk3AlertsCodec.encode(new Builder(), { alerts: [] }), /source arena/); +}); +test("QWeather pure parser produces REST details with lowercase tokens", async () => { + const alerts = await WeatherAlerts.DetailsForQWeather("test-101010100", "zh-CN", fetchPage); + assert.equal(alerts[0].description, "大风蓝色预警"); assert.equal(alerts[0].source, "测试区气象台"); + assert.equal(alerts[0].severity, "minor"); assert.equal(alerts[0].effectiveTime, "2030-01-01T00:00:00.000Z"); + assert.match(alerts[0].messages.map(m => m.text).join("\n"), /注意防范/); + assert.equal(alerts[0].name, "WeatherAlert"); assert.equal(alerts[0].detailsUrl, `#${alerts[0].id}`); + assert.equal(WeatherAlerts.ParseWeatherAlertPageURL("https://evil.invalid/severe-weather/test-1.html?from=AppleWeatherService"), undefined); + assert.throws(() => WeatherAlerts.BuildWeatherAlertPageURL("../evil"), /identifier/); +}); +test("domestic enrichment matches event, severity, issuance; details rewrite opt-in", async () => { + const bytes = fixture(), out = await patchWK3QWeatherAlerts(bytes, { fetchImpl: fetchPage }); + const d = decodeWK3Alerts(out); assert.equal(d.alerts[0].description, "大风蓝色预警"); + assert.equal(d.alerts[0].source, "测试区气象台"); assert.equal(d.detailsUrl, decodeWK3Alerts(bytes).detailsUrl); + const rewrite = decodeWK3Alerts(await patchWK3QWeatherAlerts(bytes, { fetchImpl: fetchPage, rewriteDetails: true })); + assert.equal(new URL(rewrite.detailsUrl).hostname, 'weatherkit.apple.com'); + assert.match(new URL(rewrite.detailsUrl).searchParams.get('ids'), /^wk3_/); + assert.equal(rewrite.alerts[0].detailsUrl, rewrite.detailsUrl); + assert.strictEqual(await patchWK3QWeatherAlerts(bytes, { fetchImpl: async () => { throw new Error("offline"); } }), bytes); + assert.strictEqual(await patchWK3QWeatherAlerts(bytes, { fetchImpl: async () => new Response(html + html, { headers: { "Content-Type": "text/html" } }) }), bytes); +}); +test("HTML fetch sends no Apple authorization and fails on status/content type", async () => { + await WeatherAlerts.FetchWeatherAlertPage("test-101010100", "zh-CN", async (url, options) => { + assert.ok(url.startsWith("https://www.qweather.com/")); assert.deepEqual(options.headers, { Accept: "text/html" }); + return fetchPage(); + }); + await assert.rejects(() => WeatherAlerts.FetchWeatherAlertPage("test-101010100", "zh-CN", async () => new Response("no", { status: 500 })), /unavailable/); + await assert.rejects(() => WeatherAlerts.FetchWeatherAlertPage("test-101010100", "zh-CN", async () => new Response("{}", { headers: { "content-type": "application/json" } })), /HTML/); +}); + +test('production Response routes to Apple native details while preserving summary UUID', async()=>{ + const {Response:processResponse}=await import('../../src/wk3/process/Response.mjs'); + const {default:database}=await import('../../src/wk3/function/database.mjs'); + const {default:buildSettings}=await import('../../src/wk3/function/buildSettings.mjs'); + const saved=globalThis.fetch;globalThis.fetch=fetchPage; + try{const bytes=fixture(),before=decodeWK3Alerts(bytes);const out=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database).Settings,Configs:{}});const after=decodeWK3Alerts(out.body);assert.equal(after.alerts[0].description,'大风蓝色预警');assert.equal(new URL(after.detailsUrl).origin,'https://weatherkit.apple.com');assert.equal(new URL(after.detailsUrl).pathname,'/alertDetails/index.html');assert.equal(after.alerts[0].detailsUrl,after.detailsUrl);assert.deepEqual(after.alerts[0].id,before.alerts[0].id);const u=new URL(after.detailsUrl);const {resolveNativeAlertDetails}=await import('../../src/wk3/class/NativeAlertDetails.mjs');const details=await resolveNativeAlertDetails(u.searchParams.get('ids'),u.searchParams.get('lang'),fetchPage);assert.equal(details.length,1);assert.equal(details[0].id,'00010203-0405-0607-0809-0a0b0c0d0e0f');assert.equal(details[0].detailsUrl,'#'+details[0].id);assert.match(details[0].messages[0].text,/注意防范/);}finally{globalThis.fetch=saved;} +}); diff --git a/tests/wk3/wk3-aq-all-conversions.test.mjs b/tests/wk3/wk3-aq-all-conversions.test.mjs new file mode 100644 index 00000000..40951c38 --- /dev/null +++ b/tests/wk3/wk3-aq-all-conversions.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import { encodeAirQuality, decodeWK3DataSet } from '../../src/wk3/class/WeatherKit3.mjs'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import QWeather from '../../src/wk3/class/QWeather.mjs'; +import { merge } from '../../src/wk3/utils/object.mjs'; +const algorithms=['UBA','EU_EAQI','WAQI_InstantCast_US','WAQI_InstantCast_CN','WAQI_InstantCast_CN_25_DRAFT','CA_AQHI','HK_AQHI','AQHI_Multi_CN','AQHI_Multi_CN_HK','CN_DEATH_AQHI','CN_DEATH_HK_AQHI']; +const pollutants=[['PM2_5',25],['PM10',50],['OZONE',100],['NO2',25],['SO2',40],['CO',800]].map(([pollutantType,amount])=>({pollutantType,amount,units:'MICROGRAMS_PER_CUBIC_METER'})); +const source=()=>({metadata:{providerName:'Synthetic',sourceType:'MODELED'},index:70,categoryIndex:2,isSignificant:false,scale:'HJ6332012.2604',primaryPollutant:'PM2_5',pollutants:structuredClone(pollutants)}); +const apple=()=>({metadata:{providerName:'Apple',sourceType:2},index:70,categoryIndex:2,isSignificant:false,scale:'HJ6332012.2604',primaryPollutant:9,pollutants:pollutants.map((p,i)=>({...p,pollutantType:[9,11,8,6,10,12][i],units:2})),previousDayComparison:3}); +function root(aq) {const b=new Builder(); const offset=aq?encodeAirQuality(b,aq):0;const marker=b.createString('other-weather-preserved');b.startObject(1);b.addFieldOffset(0,marker,0);const child=b.endObject();b.startObject(18);if(offset)b.addFieldOffset(0,offset,0);for(const slot of [1,4,9,17])b.addFieldOffset(slot,child,0);b.finish(b.endObject());return b.asUint8Array();} +function target(bytes,slot){const bb=new ByteBuffer(bytes),r=bb.readUint32(0);return bb.__indirect(r+bb.__offset(r,4+2*slot));} +async function patch({algorithm='UBA',missing=false,provider='Calculate',pollutantProvider='ColorfulClouds',overrides={},input,fail=false,preFetched={},sources={}}={}){ + const settings=buildSettings(database,merge({}, {AirQuality:{Current:{Index:{Provider:provider,Replace:['HJ6332012']},Pollutants:{Provider:pollutantProvider,Units:{Replace:[],Mode:'Scale'}}},Calculate:{Algorithm:algorithm,AllowOverRange:true},Comparison:{Yesterday:{IndexProvider:'WeatherKit'}}}},overrides)).Settings; + const bytes=input??root(missing?undefined:apple()),calls=[]; + const get=async label=>{calls.push(label);if(fail)throw new Error('offline');return source();}; + const response=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer'},body:bytes},{Settings:settings,Configs:{},enviroments:{parameters:{country:'CN',latitude:31,longitude:121},colorfulClouds:{CurrentAirQuality:()=>get('ColorfulClouds')},qWeather:{CurrentAirQuality:()=>get('QWeather')},...sources},preFetched}); + return {bytes,out:response.body,aq:decodeWK3DataSet(response.body),calls,settings}; +} +for(const algorithm of algorithms)for(const missing of [false,true])test(`${algorithm}: ${missing?'missing AQ injection':'cross-standard replacement'} retains full AQ and other products`,async()=>{ + const r=await patch({algorithm,missing});const expected=AirQuality.Pollutants2AQI(source(),r.settings); + assert.ok(r.aq,algorithm);assert.equal(r.aq.index,expected.index);assert.equal(r.aq.categoryIndex,expected.categoryIndex); + assert.equal(AirQuality.GetNameFromScale(r.aq.scale),AirQuality.GetNameFromScale(expected.scale));assert.equal(r.calls.length,missing?1:0); + assert.ok(r.aq.metadata.providerName.includes('iRingo'));assert.equal(r.aq.pollutants.length,expected.pollutants.length); + const shift=target(r.out,1)-target(r.bytes,1);for(const slot of [4,9,17])assert.equal(target(r.out,slot)-target(r.bytes,slot),shift); + if(missing)assert.deepEqual(r.out.slice(shift,shift+r.bytes.length),r.bytes); +}); +test('all algorithms can calculate from original Apple symbolic-mapped concentrations without provider calls',async()=>{ + for(const algorithm of algorithms){const r=await patch({algorithm,pollutantProvider:'WeatherKit'});assert.equal(r.calls.length,0);const e=AirQuality.Pollutants2AQI(source(),r.settings);assert.equal(r.aq.index,e.index,algorithm);assert.equal(AirQuality.GetNameFromScale(r.aq.scale),AirQuality.GetNameFromScale(e.scale),algorithm);} +}); +test('all direct CN/US providers can fill missing AQ and cross standards',async()=>{ + for(const provider of ['ColorfulCloudsCN','QWeather'])for(const missing of [false,true]){const r=await patch({provider,missing,pollutantProvider:provider==='QWeather'?'QWeather':'ColorfulClouds'});assert.equal(r.aq.index,70);assert.equal(r.aq.scale,'HJ6332012.2604');assert.equal(r.calls.length,1);} +}); +test('explicit disabled targets, None and source failures preserve the complete response',async()=>{ + for(const missing of [false,true]){ + for(const overrides of [{AirQuality:{Current:{Index:{Replace:'[]'}}}},{AirQuality:{Calculate:{Algorithm:'None'}}}]){const r=await patch({missing,overrides});assert.deepEqual(r.out,r.bytes);assert.equal(r.calls.length,0);} + const r=await patch({missing,fail:true}); + if(missing)assert.deepEqual(r.out,r.bytes);else {assert.equal(r.calls.length,0);assert.equal(r.aq.scale,'UBA.2414');} + } +}); +test('explicit pollution unit modes transform existing AQ independently of index calculation',async()=>{ + for(const mode of ['Scale','ugm3','EU_ppb','US_ppb','Force_ugm3','Force_EU_ppb','Force_US_ppb']){ + const r=await patch({provider:'WeatherKit',overrides:{AirQuality:{Current:{Pollutants:{Units:{Replace:['HJ6332012'],Mode:mode}}}}}}); + assert.equal(r.aq.index,70);assert.equal(r.aq.previousDayComparison,3);assert.equal(r.calls.length,0); + if(mode.startsWith('Force_')&&mode.endsWith('ppb')){assert.equal(r.aq.pollutants.find(p=>p.pollutantType===8).units,1);assert.ok(r.aq.pollutants.find(p=>p.pollutantType===8).amount<100);} + } +}); + +test('all menu origin standards can be replaced by each supported algorithm',async()=>{ + const standards=['HJ6332012.2604','EPA_NowCast.2604','UBA.2414','EU.EAQI.2414','CA.AQHI','HK.AQHI','CN.AQHI','IE.AQIH.2414','AT.AQI.2414','BE.BelAQI.2414','FR.ATMO.2414','KR.CAI.2414','CZ.AQI.2414','NL.LKI.2414','ICARS.2414','CH.KBI.2414','ES.MITECO.2414','SG.NEA.2414','NAQI.2414','DAQI.2414']; + for(const standard of standards)for(const algorithm of algorithms){ + const original={...apple(),scale:standard};const r=await patch({algorithm,input:root(original),overrides:{AirQuality:{Current:{Index:{Replace:[AirQuality.GetNameFromScale(standard)]}}}}}); + const e=AirQuality.Pollutants2AQI(source(),r.settings);assert.equal(r.aq.index,e.index,`${standard}->${algorithm}`);assert.equal(AirQuality.GetNameFromScale(r.aq.scale),AirQuality.GetNameFromScale(e.scale)); + } +}); +test('calculated yesterday requires real same-source concentrations and uses the selected algorithm',async()=>{ + for(const algorithm of algorithms){ + const today=source(),yesterday=source();yesterday.pollutants.forEach(p=>p.amount*=2); + const r=await patch({algorithm,pollutantProvider:'QWeather',preFetched:{locationsGrid:[]},overrides:{AirQuality:{Comparison:{Yesterday:{IndexProvider:'Calculate',PollutantsProvider:'QWeather'}}}}}); + // No usable historical endpoint in this harness: failure is isolated from current AQ. + assert.equal(r.aq.previousDayComparison,0);assert.equal(r.aq.index,AirQuality.Pollutants2AQI(today,r.settings).index); + } +}); +test('zero AQI and within-wire overrange results are not blocked by the US-only 500 cap',async()=>{ + for(const algorithm of algorithms){ + const zero=source();zero.pollutants.forEach(p=>p.amount=0); + const r=await patch({algorithm,missing:true,preFetched:{pollutants:Promise.resolve(zero)}});assert.ok(r.aq);assert.equal(r.aq.index,AirQuality.Pollutants2AQI(zero,r.settings).index); + } + const high=source();high.pollutants.forEach(p=>p.amount=1000); + const r=await patch({algorithm:'WAQI_InstantCast_US',missing:true,preFetched:{pollutants:Promise.resolve(high)}}); + assert.ok(r.aq.index>500);assert.ok(r.aq.index<=32767); +}); + +test('same-source QWeather yesterday concentrations are recalculated for every algorithm',async()=>{ + const saved=QWeather.GetLocationInfo;QWeather.GetLocationInfo=()=>({locationID:'synthetic'}); + try{for(const algorithm of algorithms){ + const previous=source();previous.pollutants.forEach(p=>p.amount*=3);let yesterdayCalls=0; + const r=await patch({algorithm,missing:true,pollutantProvider:'QWeather',preFetched:{locationsGrid:[]},overrides:{AirQuality:{Comparison:{Yesterday:{IndexProvider:'Calculate',PollutantsProvider:'QWeather'}}}},sources:{qWeather:{latitude:31,longitude:121,CurrentAirQuality:async()=>source(),YesterdayAirQuality:async()=>{yesterdayCalls++;return previous;}}}}); + const current=AirQuality.Pollutants2AQI(source(),r.settings),past=AirQuality.Pollutants2AQI(previous,r.settings); + assert.equal(r.aq.previousDayComparison,current.categoryIndex<past.categoryIndex?4:current.categoryIndex>past.categoryIndex?2:3,algorithm);assert.equal(yesterdayCalls,1); + }}finally{QWeather.GetLocationInfo=saved;} +}); +test('all calculated standards respect every explicit output unit mode with a complete pollutant list',async()=>{ + for(const algorithm of algorithms)for(const mode of ['None','Scale','ugm3','EU_ppb','US_ppb','Force_ugm3','Force_EU_ppb','Force_US_ppb']){ + const result=AirQuality.Pollutants2AQI(source(),{AirQuality:{Calculate:{Algorithm:algorithm}}}); + const name=AirQuality.GetNameFromScale(result.scale); + const r=await patch({algorithm,overrides:{AirQuality:{Current:{Pollutants:{Units:{Replace:[name],Mode:mode}}}}}}); + assert.equal(r.aq.index,result.index,`${algorithm}/${mode}`);assert.equal(r.aq.pollutants.length,6); + if(mode==='Force_ugm3'||mode==='None')assert.ok(r.aq.pollutants.every(p=>p.units===2),`${algorithm}/${mode}`); + if(mode==='Force_US_ppb'||mode==='Force_EU_ppb')assert.equal(r.aq.pollutants.find(p=>p.pollutantType===8).units,1,`${algorithm}/${mode}`); + } +}); diff --git a/tests/wk3/wk3-aq-conversion.test.mjs b/tests/wk3/wk3-aq-conversion.test.mjs new file mode 100644 index 00000000..7e0dbb14 --- /dev/null +++ b/tests/wk3/wk3-aq-conversion.test.mjs @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { AirQuality, Metadata } from "../../src/wk3/proto/apple/wk2.js"; +import { + WK3_AQ_COMPARISONS, WK3_AQ_POLLUTANTS, WK3_AQ_SOURCES, WK3_AQ_UNITS, + WK3AirQualityTable, assertWK3AirQualityReplaceable, decodeAirQuality, encodeAirQuality, + providerToWK3AirQuality, providerToWK3USAirQuality, wk3AirQualityToProvider, +} from "../../src/wk3/class/WeatherKit3.mjs"; + +const source = (scale = "HJ6332012", index = 56, categoryIndex = 2) => ({ + metadata: { providerName: "Synthetic", sourceType: "MODELED" }, scale, index, categoryIndex, + isSignificant: false, primaryPollutant: "PM2_5", previousDayComparison: "SAME", + pollutants: [{ pollutantType: "PM2_5", amount: 0, units: "MICROGRAMS_PER_CUBIC_METER" }], +}); +function roundTrip(value) { + const b = new Builder(), child = encodeAirQuality(b, value); + b.finish(child); + const bytes = b.asUint8Array(), bb = new ByteBuffer(bytes), root = bb.readUint32(0); + return decodeAirQuality(new WK3AirQualityTable().__init(root, bb)); +} + +test("every upstream-configured scale family converts and round-trips enums", () => { + const scales = ["UBA", "EU.EAQI", "HJ6332012", "HJ6332025_DRAFT", "EPA_NowCast", "HK.AQHI", "CA.AQHI", "CN.AQHI"]; + for (const scale of scales) { + const result = providerToWK3AirQuality(source(scale)); + assert.equal(result.scale, scale); + assert.equal(wk3AirQualityToProvider(new WK3AirQualityTable().__init(...(() => { + const b = new Builder(), child = encodeAirQuality(b, result); b.finish(child); + const bb = new ByteBuffer(b.asUint8Array()); return [bb.readUint32(0), bb]; + })())).scale, scale); + } + for (const enumSet of [WK3_AQ_POLLUTANTS, WK3_AQ_UNITS, WK3_AQ_SOURCES, WK3_AQ_COMPARISONS]) { + for (const [name, wire] of Object.entries(enumSet)) assert.equal(enumSet[name], wire); + } +}); + +test("plain decoded numeric AQ reverses to symbolic provider enums", () => { + const raw = roundTrip(providerToWK3AirQuality(source())); + const symbolic = wk3AirQualityToProvider(raw); + assert.equal(symbolic.pollutants[0].pollutantType, "PM2_5"); + assert.equal(symbolic.pollutants[0].units, "MICROGRAMS_PER_CUBIC_METER"); + assert.equal(symbolic.metadata.sourceType, "MODELED"); + assert.equal(wk3AirQualityToProvider({ ...raw, primaryPollutant: 1, pollutants: [] }).primaryPollutant, "NOT_AVAILABLE"); +}); + +test("generic conversion permits NOT_AVAILABLE primary without inventing pollutants", () => { + const result = providerToWK3AirQuality({ ...source(), primaryPollutant: "NOT_AVAILABLE" }); + assert.equal(result.primaryPollutant, 1); +}); + +test("provider metadata fills optional wire defaults and preserves modeled/station", () => { + const result = providerToWK3AirQuality(source()); + assert.equal(result.metadata.latitude, 0); + assert.equal(result.metadata.longitude, 0); + assert.equal(result.metadata.readTime, 0); + assert.equal(result.metadata.reportedTime, 0); + assert.equal(result.metadata.sourceType, 2); + assert.equal(providerToWK3AirQuality({ ...source(), metadata: { providerName: "Station", sourceType: "STATION" } }).metadata.sourceType, 3); +}); + +test("rejects unrecognized scales/enums, missing metadata, negative and invalid scalars", () => { + for (const invalid of [ + { ...source(), scale: "IE.AQIH" }, + { ...source(), metadata: null }, + { ...source(), metadata: { providerName: "x" } }, + { ...source(), pollutants: [{ pollutantType: "UNKNOWN14", amount: 1, units: "PARTS_PER_BILLION" }] }, + { ...source(), pollutants: [{ pollutantType: "PM2_5", amount: -0.1, units: "PARTS_PER_BILLION" }] }, + { ...source(), index: 32768 }, + { ...source(), categoryIndex: 128 }, + ]) assert.throws(() => providerToWK3AirQuality(invalid)); +}); + +test("generic accepts EPA AQI above 500; strict US wrapper keeps its limit", () => { + const generic = providerToWK3AirQuality({ ...source("EPA_NowCast.2414", 501, 6), isSignificant: true }); + assert.equal(generic.scale, "EPA_NowCast.2604"); + assert.equal(generic.index, 501); + assert.throws(() => providerToWK3AirQuality(source("EPA_NowCast", 501, 5))); + assert.throws(() => providerToWK3USAirQuality({ ...source("EPA_NowCast.2414", 501, 6), isSignificant: true })); + assert.equal(providerToWK3USAirQuality({ ...source("EPA_NowCast.2414", 400, 6), isSignificant: true }).scale, "EPA_NowCast.2604"); +}); + +test("unknown AQ enum wire values fail closed on reverse mapping", () => { + const b = new Builder(); + const child = encodeAirQuality(b, { metadata: { providerName: "x", sourceType: 1 }, scale: "UBA", index: 0, categoryIndex: 1, primaryPollutant: 13, pollutants: [] }); + b.finish(child); + const bb = new ByteBuffer(b.asUint8Array()); + const table = new WK3AirQualityTable().__init(bb.readUint32(0), bb); + assert.throws(() => wk3AirQualityToProvider(table)); +}); + +test("replaceability permits missing AQ and checks nested unknown fields", () => { + const b = new Builder(); + b.startObject(1); b.addFieldInt32(0, 1, 0); const marker = b.endObject(); + b.startObject(15); b.addFieldOffset(1, marker, 0); b.finish(b.endObject()); + assert.equal(assertWK3AirQualityReplaceable(b.asUint8Array()), true); + const valid = roundTrip(providerToWK3AirQuality(source())); + assert.equal(valid.scale, "HJ6332012"); + const nested = new Builder(); + const metadata = Metadata.createMetadata(nested, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 1); + nested.startObject(9); nested.addFieldOffset(0, metadata, 0); nested.addFieldInt32(8, 1, 0); const aq = nested.endObject(); + nested.startObject(15); nested.addFieldOffset(0, aq, 0); nested.finish(nested.endObject()); + assert.throws(() => assertWK3AirQualityReplaceable(nested.asUint8Array()), /Unknown WK3 AQ table fields/); + const missingMeta = new Builder(); + const child = AirQuality.createAirQuality(missingMeta, 0, 1, 0, false, 0, 0, 0, 0); + missingMeta.startObject(15); missingMeta.addFieldOffset(0, child, 0); missingMeta.finish(missingMeta.endObject()); + assert.equal(assertWK3AirQualityReplaceable(missingMeta.asUint8Array()), true); +}); diff --git a/tests/wk3/wk3-codec.test.mjs b/tests/wk3/wk3-codec.test.mjs new file mode 100644 index 00000000..49e02291 --- /dev/null +++ b/tests/wk3/wk3-codec.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { WK3_CODECS, WK3_ROOT_SLOTS, decodeWK3DataSet, encodeAirQuality, decodeAirQuality, WK3AirQualityTable } from "../../src/wk3/class/WeatherKit3.mjs"; + +// Synthetic only. No HAR bytes, provider URLs, coordinates or authorization. +function finish(builder, offset) { builder.finish(offset); return builder.asUint8Array(); } +function root(data) { + const b = new Builder(), offset = data ? encodeAirQuality(b, data) : 0; + b.startObject(15); if (offset) b.addFieldOffset(0, offset, 0); + return finish(b, b.endObject()); +} +function child(bytes) { const bb = new ByteBuffer(bytes); return new WK3AirQualityTable().__init(bb.readUint32(0), bb); } +const aq = { + metadata: { providerName: "Synthetic", sourceType: 3, expireTime: 2000000000, temporarilyUnavailable: false }, + categoryIndex: 2, index: 42, isSignificant: true, + pollutants: [{ pollutantType: 12, amount: 0.5, units: 2 }, { pollutantType: 231, amount: 16.25, units: 202 }], + previousDayComparison: 3, primaryPollutant: 11, scale: "SYNTHETIC", +}; + +test("AQ keeps raw enums, float32 amounts and metadata", () => { + const actual = decodeWK3DataSet(root(aq)); + assert.deepEqual(actual.pollutants, aq.pollutants); + assert.equal(actual.primaryPollutant, 11); + assert.equal(actual.metadata.sourceType, 3); + assert.equal(actual.metadata.expireTime, 2000000000); + assert.equal(actual.index, 42); + assert.equal(actual.isSignificant, true); + const b = new Builder(), bytes = finish(b, encodeAirQuality(b, actual)); + assert.deepEqual(decodeAirQuality(child(bytes)), actual); +}); +test("index/category/scale edits preserve source primary and pollutant integers", () => { + const actual = decodeWK3DataSet(root(aq)); + const patch = { ...actual, index: 75, categoryIndex: 3, scale: "ANOTHER_SYNTHETIC", previousDayComparison: 4 }; + const b = new Builder(), bytes = finish(b, encodeAirQuality(b, patch)); + assert.deepEqual(decodeAirQuality(child(bytes)), patch); + assert.equal(patch.primaryPollutant, actual.primaryPollutant); + assert.deepEqual(patch.pollutants, actual.pollutants); +}); +test("root positions explicitly separate periodic, nextHour and articles", () => { + assert.deepEqual(WK3_ROOT_SLOTS, { airQuality: 0, forecastPeriodic: 4, forecastNextHour: 5, news: 14 }); + assert.deepEqual(Object.keys(WK3_CODECS), ["airQuality"]); + assert.throws(() => decodeWK3DataSet(root(), "forecastNextHour"), /Unsupported/); +}); +test("missing AQ and omitted fields use generated wire defaults", () => { + assert.equal(decodeWK3DataSet(root()), null); + const actual = decodeWK3DataSet(root({})); + assert.equal(actual.metadata, null); + assert.equal(actual.scale, null); + assert.equal(actual.index, 0); + assert.equal(actual.isSignificant, false); + assert.deepEqual(actual.pollutants, []); +}); +test("WK2 enum strings including UNKNOWN13 are rejected", () => { + for (const name of ["CO", "UNKNOWN13", "PM2_5"]) assert.throws(() => encodeAirQuality(new Builder(), { primaryPollutant: name }), /raw integer/); + assert.throws(() => encodeAirQuality(new Builder(), { pollutants: [{ pollutantType: "NO2" }] }), /raw integer/); + assert.throws(() => encodeAirQuality(new Builder(), { metadata: { sourceType: "STATION" } }), /raw integer/); + assert.throws(() => encodeAirQuality(new Builder(), { pollutants: [{ pollutantType: 6, units: "UNKNOWN2" }] }), /raw integer/); + for (const value of [-1, 256, 1.5, NaN, Infinity]) assert.throws(() => encodeAirQuality(new Builder(), { categoryIndex: value })); + assert.throws(() => encodeAirQuality(new Builder(), { index: 65536 })); + assert.throws(() => encodeAirQuality(new Builder(), { pollutants: [{ amount: 1e100 }] })); +}); +test("raw 13 is preserved as a number without claiming a pollutant name", () => { + const actual = decodeWK3DataSet(root({ primaryPollutant: 13, pollutants: [{ pollutantType: 13, units: 2, amount: 10 }] })); + assert.equal(actual.primaryPollutant, 13); + assert.deepEqual(actual.pollutants, [{ pollutantType: 13, units: 2, amount: 10 }]); +}); +test("ByteBuffer position and Uint8Array view offsets are honored", () => { + const bytes = root(aq), padded = new Uint8Array(bytes.length + 8); padded.set(bytes, 8); + const bb = new ByteBuffer(padded); bb.setPosition(8); + assert.deepEqual(decodeWK3DataSet(bb).pollutants, aq.pollutants); + assert.deepEqual(decodeWK3DataSet(padded.subarray(8)).pollutants, aq.pollutants); +}); diff --git a/tests/wk3/wk3-comparison.test.mjs b/tests/wk3/wk3-comparison.test.mjs new file mode 100644 index 00000000..b0ca5f50 --- /dev/null +++ b/tests/wk3/wk3-comparison.test.mjs @@ -0,0 +1,310 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { WK3AirQualityTable, encodeAirQuality, decodeAirQuality } from "../../src/wk3/class/WeatherKit3.mjs"; +import { WK3_COMPARISON, computeWK3Comparison, injectWK3Comparison } from "../../src/wk3/class/WK3Comparison.mjs"; +import AirQuality from "../../src/wk3/class/AirQuality.mjs"; + +const aq = (categoryIndex = 2, scale = "EPA_NowCast.2604") => ({ + metadata: { providerName: "Synthetic", temporarilyUnavailable: false }, + index: [0, 25, 75, 125, 175, 250, 350][categoryIndex], categoryIndex, scale, + previousDayComparison: 2, pollutants: [], primaryPollutant: 1, +}); +const us = { currentProvider: "ColorfulCloudsUS", yesterdayProvider: "ColorfulCloudsUS" }; + +test("independently observed WK3 enums survive actual AQ FlatBuffer codec", () => { + // Sanitized paired Apple v3 JSON/FlatBuffer observations, 2026-11-04. + // BETTER observed in two US requests and one Canadian request; WORSE in US. + const observed = [["UNKNOWN", 0], ["SAME", 3], ["BETTER", 4], ["WORSE", 2]]; + for (const [json, wire] of observed) { + assert.equal(WK3_COMPARISON[json], wire); + const b = new Builder(); + b.finish(encodeAirQuality(b, { ...aq(), previousDayComparison: wire })); + const bb = new ByteBuffer(b.asUint8Array()); + const table = new WK3AirQualityTable().__init(bb.readUint32(0), bb); + assert.equal(decodeAirQuality(table).previousDayComparison, wire); + } +}); + +test("category trend uses verified BETTER=4 WORSE=2 SAME=3, not AQI arithmetic", () => { + assert.equal(computeWK3Comparison(aq(1), aq(2), us), 4); + assert.equal(computeWK3Comparison(aq(3), aq(2), us), 2); + assert.equal(computeWK3Comparison({ ...aq(2), index: 51 }, { ...aq(2), index: 99 }, us), 3); + // The two observed scale versions share category boundaries. + assert.equal(computeWK3Comparison(aq(2), aq(2, "EPA_NowCast.2414"), us), 3); + const cn = { currentProvider: "ColorfulCloudsCN", yesterdayProvider: "ColorfulCloudsCN" }; + assert.equal(computeWK3Comparison(aq(2, "HJ6332012.2604"), aq(3, "HJ6332012.2414"), cn), 4); +}); + +test("missing, unavailable, invalid or inconsistent categories fail closed", () => { + const bad = [null, {}, { ...aq(), metadata: undefined }, { ...aq(), metadata: { temporarilyUnavailable: true } }, + ...[undefined, null, -1, 0, 7, 2.5, "2", NaN, Infinity].map(categoryIndex => ({ ...aq(), categoryIndex })), + ...["EPA_NowCast.9999", "EPA_NowCast", "EU.2604", "HJ6332012.9999", undefined].map(scale => ({ ...aq(), scale })), + ...[-1, 501, NaN, Infinity, "75", null, 200].map(index => ({ ...aq(), index }))]; + for (const value of bad) { + assert.equal(computeWK3Comparison(aq(), value, us), 0); + assert.equal(computeWK3Comparison(value, aq(), us), 0); + } + assert.equal(computeWK3Comparison({ ...aq(), index: undefined }, aq(), us), 3); +}); + +test("same category never permits cross-standard or cross-provider comparison", () => { + const a = aq(), cn = aq(2, "HJ6332012.2604"); + assert.equal(computeWK3Comparison(a, cn, us), 0); + assert.equal(computeWK3Comparison(cn, cn, us), 0); // US provider cannot claim CN. + assert.equal(computeWK3Comparison(a, a, { currentProvider: "QWeather", yesterdayProvider: "QWeather" }), 0); + assert.equal(computeWK3Comparison(a, a, { ...us, yesterdayProvider: "WeatherKit" }), 0); + assert.equal(computeWK3Comparison(a, a), 0); + assert.equal(computeWK3Comparison(a, a, { currentProvider: "", yesterdayProvider: "" }), 0); + assert.equal(computeWK3Comparison(a, a, { currentProvider: "Calculate", yesterdayProvider: "Calculate" }), 0); + assert.equal(computeWK3Comparison(a, a, { currentProvider: "Calculate:QWeather:US", yesterdayProvider: "Calculate:ColorfulClouds:US" }), 0); +}); + +test("ColorfulClouds US fetch uses YesterdayAirQuality(true) and never fetches another current AQ", async () => { + const original = aq(1), snapshot = structuredClone(original), calls = []; + const sources = { colorfulClouds: { + YesterdayAirQuality: async useUsa => { calls.push(useUsa); return aq(2, "EPA_NowCast.2414"); }, + CurrentAirQuality: async () => { throw new Error("Must not substitute another current index"); }, + } }; + const result = await injectWK3Comparison(original, { ...us, sources }); + assert.deepEqual(calls, [true]); + assert.equal(result.previousDayComparison, 4); + assert.deepEqual(original, snapshot); + assert.notEqual(result, original); + assert.equal(result.metadata, original.metadata); + assert.equal(result.pollutants, original.pollutants); + assert.equal(result.index, original.index); + assert.equal(result.scale, original.scale); +}); + +test("CN selection uses YesterdayAirQuality(false), without relabeling its standard", async () => { + const options = { currentProvider: "ColorfulCloudsCN", sources: { colorfulClouds: { + YesterdayAirQuality: async useUsa => { assert.equal(useUsa, false); return aq(1, "HJ6332012.2414"); }, + } } }; + assert.equal((await injectWK3Comparison(aq(3, "HJ6332012.2604"), options)).previousDayComparison, 2); + assert.equal((await injectWK3Comparison(aq(3), options)).previousDayComparison, 0); +}); + +test("mismatched configured provider never triggers fallback to another provider", async () => { + let calls = 0; + const sources = { colorfulClouds: { YesterdayAirQuality: async () => { calls++; return aq(); } } }; + const result = await injectWK3Comparison(aq(), { currentProvider: "WeatherKit", yesterdayProvider: "ColorfulCloudsUS", sources }); + assert.equal(result.previousDayComparison, 0); + assert.equal(calls, 0); + assert.equal((await injectWK3Comparison(aq(), { currentProvider: "WeatherKit", sources })).previousDayComparison, 0); +}); + +test("already fetched data and promises bypass network and preserve caller source identity", async () => { + let calls = 0; + const sources = { colorfulClouds: { YesterdayAirQuality: () => { calls++; throw new Error("No network needed"); } } }; + for (const yesterday of [aq(3), Promise.resolve(aq(3))]) { + assert.equal((await injectWK3Comparison(aq(2), { ...us, yesterday, sources })).previousDayComparison, 4); + } + const provider = "Calculate:QWeather:WAQI_InstantCast_US"; + assert.equal((await injectWK3Comparison(aq(3), { currentProvider: provider, yesterday: aq(1) })).previousDayComparison, 2); + assert.equal((await injectWK3Comparison(aq(2), { ...us, yesterday: null, sources })).previousDayComparison, 0); + assert.equal(calls, 0); +}); + +test("QWeather receives caller-resolved locationInfo only for its own CN standard", async () => { + const locationInfo = { id: "synthetic" }; let calls = 0; + const options = { currentProvider: "QWeather", locationInfo, sources: { qWeather: { + YesterdayAirQuality: async location => { calls++; assert.equal(location, locationInfo); return aq(2, "HJ6332012.2414"); }, + } } }; + assert.equal((await injectWK3Comparison(aq(2, "HJ6332012.2604"), options)).previousDayComparison, 3); + assert.equal((await injectWK3Comparison(aq(), options)).previousDayComparison, 0); + assert.equal((await injectWK3Comparison(aq(2, "HJ6332012.2604"), { ...options, locationInfo: undefined })).previousDayComparison, 0); + assert.equal(calls, 1); +}); + +test("provider errors, rejected prefetch and unsupported data clear stale comparison, keeping AQ usable", async () => { + const original = aq(); + const scenarios = [ + { sources: {} }, + { sources: { colorfulClouds: { YesterdayAirQuality: async () => { throw new Error("private provider failure"); } } } }, + { sources: { colorfulClouds: { YesterdayAirQuality: () => { throw new Error("sync failure"); } } } }, + { yesterday: Promise.reject(new Error("prefetch failed")) }, + { yesterday: { ...aq(), metadata: { temporarilyUnavailable: true } } }, + { yesterday: aq(2, "HJ6332012.2414") }, + { yesterday: { ...aq(), categoryIndex: -1 } }, + ]; + for (const scenario of scenarios) { + const result = await injectWK3Comparison(original, { ...us, ...scenario }); + assert.deepEqual(result, { ...original, previousDayComparison: 0 }); + } + assert.equal(original.previousDayComparison, 2); +}); + +test("rejected prefetch is consumed even if mismatch skips comparison", async () => { + const result = await injectWK3Comparison(aq(), { ...us, yesterdayProvider: "QWeather", yesterday: Promise.reject(new Error("already dispatched")) }); + assert.equal(result.previousDayComparison, 0); + await new Promise(resolve => setImmediate(resolve)); +}); + +const menuStandards = [ + "HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", + "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", + "ES.MITECO", "SG.NEA", "NAQI", "DAQI", "HK.AQHI", "CN.AQHI", +]; +const weatherKit = { currentProvider: "WeatherKit", yesterdayProvider: "WeatherKit" }; + +test("all menu standards compare same-source categories without imposing CN/US AQI ranges", async () => { + for (const name of menuStandards) { + for (const version of ["2414", "2604"]) { + const scale = `${name}.${version}`; + // Category-only history is a supported provider contract. The local + // helper does not invent concentration or foreign index boundaries. + const current = { ...aq(2, scale), index: undefined }; + const previous = { ...aq(3, scale), index: undefined }; + assert.equal(computeWK3Comparison(current, previous, weatherKit), 4, scale); + assert.equal(computeWK3Comparison(previous, current, weatherKit), 2, scale); + assert.equal(computeWK3Comparison(current, current, weatherKit), 3, scale); + const result = await injectWK3Comparison(current, { ...weatherKit, yesterday: previous }); + assert.equal(result.previousDayComparison, 4, scale); + } + assert.equal(computeWK3Comparison({ ...aq(2, `${name}.9999`), index: undefined }, + { ...aq(2, `${name}.9999`), index: undefined }, weatherKit), 0, name); + } + for (const name of ["CA.AQHI", "HK.AQHI", "CN.AQHI"]) { + const current = { ...aq(2, name), index: 2 }, yesterday = { ...aq(3, name), index: 3 }; + assert.equal(computeWK3Comparison(current, yesterday, weatherKit), 4, name); + assert.equal(computeWK3Comparison(current, { ...yesterday, scale: `${name}.2414` }, weatherKit), 0, name); + } + for (const name of menuStandards.filter(name => !["HJ6332012", "EPA_NowCast"].includes(name))) { + assert.equal(computeWK3Comparison({ ...aq(2, `${name}.2414`), index: undefined }, + { ...aq(2, `${name}.2604`), index: undefined }, weatherKit), 0, name); + } +}); + +test("non-US/CN indices use source categories, including AQHI 11 and representable WK3 values", () => { + const local = (categoryIndex, scale, index) => ({ ...aq(), categoryIndex, scale, index }); + assert.equal(computeWK3Comparison(local(11, "CA.AQHI", 11), local(10, "CA.AQHI", 10), weatherKit), 2); + assert.equal(computeWK3Comparison(local(7, "DAQI.2604", 7), local(9, "DAQI.2604", 9), weatherKit), 4); + assert.equal(computeWK3Comparison(local(6, "KR.CAI.2604", 900), local(6, "KR.CAI.2604", 1000), weatherKit), 3); + for (const [scale, categoryIndex] of [["UBA.2414", 6], ["EU.EAQI.2414", 7], ["CA.AQHI", 12], ["DAQI.2414", 128]]) { + const value = local(categoryIndex, scale, undefined); + assert.equal(computeWK3Comparison(value, value, weatherKit), 0, scale); + } + assert.equal(computeWK3Comparison(local(2, "EU.EAQI", 18), local(2, "EU.EAQI", 18), weatherKit), 3); + for (const scale of ["UBA", "SomeFutureAQI.2604", "EPA_NowCast.2604.extra", "CA.AQHI.9999"]) { + const value = local(2, scale, 2); + assert.equal(computeWK3Comparison(value, value, weatherKit), 0, scale); + } + for (const index of [NaN, Infinity, -1, 32768, "7", null, 1.5]) { + const value = local(2, "DAQI.2414", index); + assert.equal(computeWK3Comparison(value, value, weatherKit), 0, String(index)); + } +}); + +const algorithms = [ + "UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", + "CA_AQHI", "HK_AQHI", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", +]; +function calculatedAQ(algorithm, amount) { + return AirQuality.Pollutants2AQI({ + metadata: { providerName: "Synthetic QWeather", temporarilyUnavailable: false }, + pollutants: ["PM2_5", "PM10", "OZONE", "NO2", "SO2"].map(pollutantType => ({ + pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER", + })), + }, {}, { algorithm }); +} + +test("all eleven actual built-in algorithms compare precomputed same-source pairs in every direction", async () => { + for (const algorithm of algorithms) { + const current = calculatedAQ(algorithm, 1), yesterday = calculatedAQ(algorithm, 300); + assert.equal(current.metadata.temporarilyUnavailable, false, algorithm); + assert.equal(yesterday.metadata.temporarilyUnavailable, false, algorithm); + assert.ok(current.categoryIndex < yesterday.categoryIndex, algorithm); + const provider = `Calculate:QWeather:${algorithm}`; + const options = { currentProvider: provider, yesterdayProvider: provider }; + assert.equal(computeWK3Comparison(current, yesterday, options), 4, algorithm); + assert.equal(computeWK3Comparison(yesterday, current, options), 2, algorithm); + assert.equal(computeWK3Comparison(current, current, options), 3, algorithm); + const injected = await injectWK3Comparison(current, { currentProvider: provider, yesterday: Promise.resolve(yesterday) }); + assert.equal(injected.previousDayComparison, 4, algorithm); + assert.equal(injected.pollutants, current.pollutants, algorithm); + assert.equal(injected.metadata, current.metadata, algorithm); + } +}); + +test("calculated pairs require a complete identical source+algorithm identity consistent with scale", () => { + const current = calculatedAQ("AQHI_Multi_CN", 1), yesterday = calculatedAQ("AQHI_Multi_CN", 300); + assert.equal(current.scale, "CA.AQHI"); + for (const provider of ["Calculate", "Calculate:", "Calculate::AQHI_Multi_CN", "Calculate:QWeather", + "Calculate:QWeather:None", "Calculate:QWeather:unknown", "Calculate:QWeather:HK_AQHI", + "Calculate:QWeather:UBA", "Calculate:QWeather:AQHI_Multi_CN:extra", "CalculateBogus"]) { + assert.equal(computeWK3Comparison(current, yesterday, { currentProvider: provider, yesterdayProvider: provider }), 0, provider); + } + const provider = "Calculate:QWeather:AQHI_Multi_CN"; + for (const yesterdayProvider of ["Calculate:ColorfulClouds:AQHI_Multi_CN", "Calculate:QWeather:CA_AQHI", + "Calculate:QWeather:AQHI_Multi_CN_HK", "QWeather", "Calculate"]) { + assert.equal(computeWK3Comparison(current, yesterday, { currentProvider: provider, yesterdayProvider }), 0, yesterdayProvider); + } + const sameScaleAlgorithms = [ + ["WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT"], ["CN_DEATH_AQHI", "CN_DEATH_HK_AQHI"], + ["CA_AQHI", "AQHI_Multi_CN"], ["AQHI_Multi_CN", "AQHI_Multi_CN_HK"], + ]; + for (const [todayAlgorithm, yesterdayAlgorithm] of sameScaleAlgorithms) { + assert.equal(computeWK3Comparison(calculatedAQ(todayAlgorithm, 1), calculatedAQ(yesterdayAlgorithm, 300), { + currentProvider: `Calculate:QWeather:${todayAlgorithm}`, yesterdayProvider: `Calculate:QWeather:${yesterdayAlgorithm}`, + }), 0, `${todayAlgorithm}/${yesterdayAlgorithm}`); + } +}); + +test("Calculate history is never fabricated from ColorfulClouds categories or another source", async () => { + let calls = 0; + const sources = { + colorfulClouds: { YesterdayAirQuality: () => { calls++; throw new Error("category-only source"); } }, + qWeather: { YesterdayAirQuality: () => { calls++; throw new Error("caller must compute history first"); } }, + }; + for (const source of ["QWeather", "ColorfulClouds", "WeatherKit"]) { + const current = calculatedAQ("UBA", 1); + const result = await injectWK3Comparison(current, { currentProvider: `Calculate:${source}:UBA`, sources, locationInfo: { id: "synthetic" } }); + assert.equal(result.previousDayComparison, 0, source); + assert.equal(result.index, current.index, source); + } + assert.equal(calls, 0); + const current = calculatedAQ("UBA", 1), categoryOnly = { ...aq(), pollutants: [], index: undefined }; + assert.equal((await injectWK3Comparison(current, { + currentProvider: "Calculate:ColorfulClouds:UBA", yesterday: categoryOnly, + })).previousDayComparison, 0); +}); + +test("all algorithm scale collisions still reject cross-standard history and inconsistent calculated indices", () => { + for (const algorithm of algorithms) { + const current = calculatedAQ(algorithm, 1), previous = calculatedAQ(algorithm, 300); + const provider = `Calculate:QWeather:${algorithm}`, options = { currentProvider: provider, yesterdayProvider: provider }; + const different = { ...previous, scale: current.scale === "CA.AQHI" ? "HK.AQHI" : "CA.AQHI" }; + assert.equal(computeWK3Comparison(current, different, options), 0, algorithm); + assert.equal(computeWK3Comparison(current, { ...previous, index: 0 }, options), 0, algorithm); + } + const current = { ...aq(6), index: 700 }, previous = { ...aq(6), index: 900 }; + const provider = "Calculate:QWeather:WAQI_InstantCast_US"; + assert.equal(computeWK3Comparison(current, previous, { currentProvider: provider, yesterdayProvider: provider }), 3); + assert.equal(computeWK3Comparison(current, previous, us), 0); +}); + +test("every pair of distinct menu standards refuses same-source category comparison", () => { + for (const todayName of menuStandards) { + for (const yesterdayName of menuStandards) { + if (todayName === yesterdayName) continue; + const today = { ...aq(2, `${todayName}.2604`), index: undefined }; + const yesterday = { ...aq(2, `${yesterdayName}.2604`), index: undefined }; + assert.equal(computeWK3Comparison(today, yesterday, weatherKit), 0, `${todayName}/${yesterdayName}`); + } + } +}); + +test("calculated over-range injection preserves index and consumes failed skipped prefetch", async () => { + const current = { ...aq(6), index: 700 }, provider = "Calculate:QWeather:WAQI_InstantCast_US"; + const result = await injectWK3Comparison(current, { currentProvider: provider, yesterday: { ...current, index: 900 } }); + assert.equal(result.previousDayComparison, 3); + assert.equal(result.index, 700); + assert.equal(current.previousDayComparison, 2); + const mismatch = await injectWK3Comparison(current, { + currentProvider: provider, yesterdayProvider: "Calculate:ColorfulClouds:WAQI_InstantCast_US", + yesterday: Promise.reject(new Error("already dispatched calculated history")), + }); + assert.equal(mismatch.previousDayComparison, 0); + await new Promise(resolve => setImmediate(resolve)); +}); diff --git a/tests/wk3/wk3-features-integration.test.mjs b/tests/wk3/wk3-features-integration.test.mjs new file mode 100644 index 00000000..eff69b1c --- /dev/null +++ b/tests/wk3/wk3-features-integration.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder,ByteBuffer } from 'flatbuffers'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import { encodeAirQuality,decodeWK3DataSet } from '../../src/wk3/class/WeatherKit3.mjs'; +import { encodeNextHour,providerToWK3NextHour,decodeWK3NextHour } from '../../src/wk3/class/WK3NextHour.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +const start=2000000040; +function rain(){return {metadata:{sourceType:'MODELED',providerName:'Synthetic',latitude:0,longitude:0},forecastStart:start,forecastEnd:start+180,minutes:[0,1,2].map(i=>({startTime:start+i*60,precipitationChance:70,precipitationIntensity:1.5,perceivedPrecipitationIntensity:1.2})),summary:[{startTime:start,endTime:0,condition:'RAIN',precipitationChance:70,precipitationIntensity:1.5}],condition:[{startTime:start,endTime:0,forecastToken:'CONSTANT',beginCondition:'RAIN',endCondition:'RAIN',parameters:[]}]};} +const aq={metadata:{sourceType:3,providerName:'Synthetic Apple'},scale:'HJ6332012.2604',index:60,categoryIndex:2,isSignificant:true,primaryPollutant:11,pollutants:[],previousDayComparison:2}; +const us={metadata:{sourceType:'MODELED',providerName:'Synthetic US'},scale:'EPA_NowCast.2414',index:75,categoryIndex:2,isSignificant:false,primaryPollutant:'PM2_5',pollutants:[{pollutantType:'PM2_5',amount:12,units:'MICROGRAMS_PER_CUBIC_METER'}]}; +function source({next=false,futureNext=false}={}){const b=new Builder();let n=0;if(next)n=encodeNextHour(b,providerToWK3NextHour(rain(),{intensityUnit:'mm/h',chanceUnit:'percent'}));if(futureNext){b.startObject(7);b.addFieldInt32(6,10,0);n=b.endObject();}const a=encodeAirQuality(b,aq),marker=b.createString('preserved periodic/news/future');b.startObject(1);b.addFieldOffset(0,marker,0);const o=b.endObject();b.startObject(15);b.addFieldOffset(0,a,0);if(n)b.addFieldOffset(5,n,0);for(const i of [4,14])b.addFieldOffset(i,o,0);b.finish(b.endObject());return b.asUint8Array();} +function child(bytes,slot){const bb=new ByteBuffer(bytes),r=bb.readUint32(0),o=bb.__offset(r,4+2*slot);return o?bb.__indirect(r+o):null;} +async function patch(bytes,{datasets='airQuality,forecastNextHour',prefetched={},settings={},country='CN'}={}){const Settings=buildSettings(database,{AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS',Replace:['HJ6332012']}},Comparison:{Yesterday:{IndexProvider:'ColorfulCloudsUS'}}},...settings}).Settings;return (await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/en/31/121?country=${country}&dataSets=${datasets}`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings,Configs:{},enviroments:{parameters:{country,latitude:31,longitude:121},country},preFetched:{index:Promise.resolve(us),forecastNextHour:Promise.resolve(rain()),...prefetched}})).body;} +test('missing nextHour root 5 is added while complete original arena remains byte-exact and aligned',async()=>{const bytes=source(),copy=bytes.slice(),out=await patch(bytes,{datasets:'forecastNextHour'});assert.equal(decodeWK3NextHour(out).minutes[2].precipitationIntensity,1.5);const shift=child(out,4)-child(bytes,4);assert.equal(shift%8,0);assert.deepEqual(out.slice(shift,shift+bytes.length),bytes);assert.deepEqual(decodeWK3DataSet(out),decodeWK3DataSet(bytes));assert.equal(child(out,14)-child(bytes,14),shift);assert.deepEqual(bytes,copy);}); +test('existing nextHour replacement preserves root4,root14 and current AQ',async()=>{const bytes=source({next:true}),out=await patch(bytes,{datasets:'forecastNextHour'});assert.equal(child(out,4),child(bytes,4));assert.equal(child(out,14),child(bytes,14));assert.deepEqual(decodeWK3DataSet(out),decodeWK3DataSet(bytes));const root=new ByteBuffer(bytes).readUint32(0),f=root+new ByteBuffer(bytes).__offset(root,14);for(let i=0;i<bytes.length;i++)if(i<f||i>=f+4)assert.equal(out[i],bytes[i]);}); +test('AQ yesterday improvements, equality and deterioration reach actual WK3 child alongside rain',async()=>{for(const [index,cat,expected] of [[125,3,4],[80,2,3],[25,1,2]]){const bytes=source(),out=await patch(bytes,{prefetched:{yesterdayAirQuality:Promise.resolve({...us,index,categoryIndex:cat})}});const a=decodeWK3DataSet(out);assert.equal(a.scale,'EPA_NowCast.2604');assert.equal(a.previousDayComparison,expected);assert.equal(decodeWK3NextHour(out).minutes.length,3);}}); +test('yesterday failure never blocks current US AQ or successful minutes',async()=>{const out=await patch(source(),{prefetched:{yesterdayAirQuality:Promise.resolve(undefined)}});assert.equal(decodeWK3DataSet(out).previousDayComparison,0);assert.equal(decodeWK3DataSet(out).index,75);assert.equal(decodeWK3NextHour(out).minutes.length,3);}); +test('minute failure, unknown future schema, WeatherKit selection and overseas preserve original minute arena',async()=>{for(const bytes of [source(),source({futureNext:true})]){assert.deepEqual(await patch(bytes,{datasets:'forecastNextHour',prefetched:{forecastNextHour:Promise.resolve(undefined)}}),bytes);assert.deepEqual(await patch(bytes,{datasets:'forecastNextHour',settings:{NextHour:{Provider:'WeatherKit'}}}),bytes);assert.deepEqual(await patch(bytes,{datasets:'forecastNextHour',country:'US'}),bytes);}assert.deepEqual(await patch(source({futureNext:true}),{datasets:'forecastNextHour'}),source({futureNext:true}));}); diff --git a/tests/wk3/wk3-hk-aq.test.mjs b/tests/wk3/wk3-hk-aq.test.mjs new file mode 100644 index 00000000..14c6145b --- /dev/null +++ b/tests/wk3/wk3-hk-aq.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import { decodeWK3DataSet, encodeAirQuality } from '../../src/wk3/class/WeatherKit3.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import { isHongKong } from '../../src/wk3/function/shouldReplaceWeatherKit.mjs'; +const provider={metadata:{providerName:'Synthetic HK',sourceType:'MODELED'},scale:'EPA_NowCast.2414',index:72,categoryIndex:2,isSignificant:false,primaryPollutant:'PM2_5',pollutants:[{pollutantType:'PM2_5',amount:15,units:'MICROGRAMS_PER_CUBIC_METER'}]}; +function root(aq=false){const b=new Builder(),a=aq?encodeAirQuality(b,{scale:'HK.2604',index:3,categoryIndex:2,metadata:{providerName:'Apple',sourceType:3},primaryPollutant:1,pollutants:[]}):0;const text=b.createString('keep weather/alerts/news/unknown');b.startObject(1);b.addFieldOffset(0,text,0);const o=b.endObject();b.startObject(18);if(a)b.addFieldOffset(0,a,0);for(const slot of[1,2,3,9,17])b.addFieldOffset(slot,o,0);b.finish(b.endObject());return b.asUint8Array();} +async function run(bytes,{country='HK',lat=22.278,lon=114.171,dataSets='airQuality,currentWeather,weatherAlerts',result=provider,settings={}}={}){return (await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/zh-Hans-SG/${lat}/${lon}?country=${country}&dataSets=${dataSets}`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database,{AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS',Replace:['HJ6332012']}},Comparison:{Yesterday:{IndexProvider:'ColorfulCloudsUS'}}},...settings}).Settings,Configs:{},enviroments:{},preFetched:{index:Promise.resolve(result),yesterdayAirQuality:Promise.resolve({...provider,index:125,categoryIndex:3})}})).body;} +test('HK without AQ gets verified US child while original entire arena stays byte exact and 8-byte aligned',async()=>{const bytes=root(),out=await run(bytes),a=decodeWK3DataSet(out);assert.equal(a.index,72);assert.equal(a.scale,'EPA_NowCast.2604');assert.equal(a.previousDayComparison,4);assert.equal(a.metadata.providerName,'Synthetic HK');const b=new ByteBuffer(bytes),n=new ByteBuffer(out),r=b.readUint32(0),nr=n.readUint32(0);let shift;for(const slot of[1,2,3,9,17]){const t=b.__indirect(r+b.__offset(r,4+2*slot)),nt=n.__indirect(nr+n.__offset(nr,4+2*slot));shift??=nt-t;assert.equal(nt-t,shift);}assert.equal(shift%8,0);assert.deepEqual(out.slice(shift,shift+bytes.length),bytes);}); +test('geographic HK recognition stays independent of the upstream AQ processing scope',async()=>{assert.ok(isHongKong({country:'CN',latitude:22.278,longitude:114.171}));assert.equal(decodeWK3DataSet(await run(root(),{country:'CN'})).index,72);for(const args of[{country:'US'},{country:'SG'},{country:'HK',latitude:31,longitude:121},{country:'MO',latitude:22.19,longitude:113.54}])assert.equal(isHongKong(args),false);}); +test('existing Apple AQ, request exclusion, disabled dataset and non-US provider remain unchanged',async()=>{const existing=root(true);assert.deepEqual(await run(existing),existing);for(const options of[{dataSets:'currentWeather,weatherAlerts'},{settings:{DataSets:'[]'}},{settings:{AirQuality:{Current:{Index:{Provider:'WeatherKit'}}}}}]){const bytes=root();assert.deepEqual(await run(bytes,options),bytes);}}); +test('unavailable, incomplete and invalid HK provider output never creates synthetic usable AQ',async()=>{for(const result of[undefined,{...provider,metadata:{temporarilyUnavailable:true}},{...provider,pollutants:[]},{...provider,index:NaN}]){const bytes=root();const opts={result:result===undefined?null:result};assert.deepEqual(await run(bytes,opts),bytes);}}); diff --git a/tests/wk3/wk3-mainland-aq.test.mjs b/tests/wk3/wk3-mainland-aq.test.mjs new file mode 100644 index 00000000..2d0d04e5 --- /dev/null +++ b/tests/wk3/wk3-mainland-aq.test.mjs @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { Response as processResponse } from "../../src/wk3/process/Response.mjs"; +import { decodeWK3DataSet } from "../../src/wk3/class/WeatherKit3.mjs"; +import buildSettings from "../../src/wk3/function/buildSettings.mjs"; +import database from "../../src/wk3/function/database.mjs"; + +const provider = { metadata: { providerName: "Synthetic mainland", sourceType: "MODELED" }, scale: "EPA_NowCast.2414", index: 87, categoryIndex: 2, isSignificant: false, primaryPollutant: "PM2_5", pollutants: [{ pollutantType: "PM2_5", amount: 12, units: "MICROGRAMS_PER_CUBIC_METER" }] }; +function root() { + const b = new Builder(), text = b.createString("preserve weather, alerts and future slots"); + b.startObject(1); b.addFieldOffset(0, text, 0); const child = b.endObject(); + b.startObject(18); for (const slot of [1, 2, 3, 9, 17]) b.addFieldOffset(slot, child, 0); + b.finish(b.endObject()); return b.asUint8Array(); +} +async function run(bytes, { country = "CN", lat = 52.972, lon = 122.539, dataSets = "airQuality", env = {}, failure = false, result = provider } = {}) { + const parameters = {}; + for (const [key, value] of Object.entries({ "AirQuality.Current.Index.Provider": "ColorfulCloudsUS", "AirQuality.Comparison.Yesterday.IndexProvider": "WeatherKit", ...env })) { + const keys = key.split("."), leaf = keys.pop(); let object = parameters; + for (const part of keys) object = object[part] ??= {}; + object[leaf] = value; + } + const { Settings } = buildSettings(database, parameters); + let calls = 0; + const response = await processResponse({ url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans-SG/${lat}/${lon}?country=${country}&dataSets=${dataSets}`, headers: {} }, { status: 200, headers: { "content-type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: bytes }, { Settings, Configs: {}, enviroments: { colorfulClouds: { async CurrentAirQuality(us) { calls++; assert.equal(us, true); if (failure) throw new Error("unavailable"); return result; } } } }); + return { body: response.body, calls }; +} +test("mainland missing AQ receives verified US child and keeps original arena aligned byte-for-byte", async () => { + for (const [lat, lon] of [[52.972, 122.539], [27.705, 108.844]]) { + const bytes = root(), { body, calls } = await run(bytes, { lat, lon }), aq = decodeWK3DataSet(body); + assert.equal(calls, 1); assert.equal(aq.index, 87); assert.equal(aq.scale, "EPA_NowCast.2604"); + const a = new ByteBuffer(bytes), b = new ByteBuffer(body), ar = a.readUint32(0), br = b.readUint32(0); + let shift; + for (const slot of [1, 2, 3, 9, 17]) { + const oldTarget = a.__indirect(ar + a.__offset(ar, 4 + 2 * slot)); + const newTarget = b.__indirect(br + b.__offset(br, 4 + 2 * slot)); + shift ??= newTarget - oldTarget; assert.equal(newTarget - oldTarget, shift); + } + assert.equal(shift % 8, 0); assert.deepEqual(body.slice(shift, shift + bytes.length), bytes); + const again = await run(body, { lat, lon }); assert.equal(again.calls, 0); assert.deepEqual(again.body, body); + } +}); +test("missing AQ respects request, dataset, region, replacement and source settings without querying", async () => { + for (const options of [ + { dataSets: "currentWeather" }, { env: { DataSets: "[]" } }, + + { env: { "AirQuality.Current.Index.Provider": "WeatherKit" } }, + { env: { "AirQuality.Current.Index.Provider": "Calculate", "AirQuality.Calculate.Algorithm": "None" } }, + { env: { "AirQuality.Current.Index.Replace": "[]" } }, + ]) { + const bytes = root(), output = await run(bytes, options); assert.equal(output.calls, 0); assert.deepEqual(output.body, bytes); + } +}); +test("missing AQ preserves response when provider fails or returns unavailable/incomplete data", async () => { + for (const options of [{ failure: true }, { result: null }, { result: { ...provider, metadata: { temporarilyUnavailable: true } } }, { result: { ...provider, pollutants: [] } }, { result: { ...provider, index: NaN } }]) { + const bytes = root(), output = await run(bytes, options); assert.equal(output.calls, 1); assert.deepEqual(output.body, bytes); + } +}); diff --git a/tests/wk3/wk3-minutely-provider.test.mjs b/tests/wk3/wk3-minutely-provider.test.mjs new file mode 100644 index 00000000..ffbd33cd --- /dev/null +++ b/tests/wk3/wk3-minutely-provider.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder } from 'flatbuffers'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { buildEgern } from '../../scripts/build-egern.mjs'; +import { decodeWK3NextHour } from '../../src/wk3/class/WK3NextHour.mjs'; + +const responseCode = process.env.WK3_TEST_BUNDLE_DIR + ? await readFile(path.join(process.env.WK3_TEST_BUNDLE_DIR, 'egern', 'wk3-egern-response.bundle.js'), 'utf8') + : (await buildEgern({ minify: false })).response.code; +const response = (await import(`data:text/javascript;base64,${Buffer.from(responseCode).toString('base64')}`)).default; +const start = Date.parse('2030-01-01T08:00:00+08:00') / 1000; +function source() { + const b = new Builder(); + b.startObject(15); b.finish(b.endObject()); + return b.asUint8Array(); +} +function qweather(updateTime = '2030-01-01T08:00:00+08:00') { + return { code: '200', updateTime, fxLink: 'https://synthetic.invalid', summary: '小雨', + minutely: Array.from({ length: 12 }, (_, i) => ({ fxTime: new Date((start + i * 300) * 1000).toISOString(), precip: '0.1' })) }; +} +async function patch(body, { provider = 'QWeather', original = source(), handler = response } = {}) { + const calls = []; + const out = await handler({ + env: { SETTINGS: JSON.stringify({ DataSets: ['forecastNextHour'], NextHour: { Provider: provider }, API: { QWeather: { Token: 'synthetic', Host: 'synthetic.invalid' } } }) }, + request: { method: 'GET', url: 'https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=forecastNextHour', headers: new Headers() }, + response: new Response(original, { headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' } }), + http: { async get(url) { calls.push(url); return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); } }, + }); + return { out: out.body, calls, original }; +} +test('QWeather rain reaches WK3 through Egern URL string coordinates', async () => { + const { out, calls } = await patch(qweather()); + const next = decodeWK3NextHour(out); + assert.ok(next, 'available minutely rain must not disappear during metadata encoding'); + assert.equal(next.metadata.latitude, 31); + assert.equal(next.metadata.longitude, 121); + assert.equal(next.minutes.length, 60); + assert.ok(Math.abs(next.minutes[0].precipitationIntensity - 1.2) < 1e-6); + assert.equal(calls.length, 1, 'prefetched minutes must not be fetched twice'); +}); +test('QWeather minutely uses first fxTime when updateTime precedes the forecast grid', async () => { + const { out } = await patch(qweather('2030-01-01T07:58:00+08:00')); + const next = decodeWK3NextHour(out); + assert.ok(next, 'updateTime is publication time, not the minute grid origin'); + assert.equal(next.forecastStart, start); + assert.equal(next.forecastEnd, start + 3600); + assert.equal(next.minutes[0].startTime, start); +}); +test('missing QWeather minutes and a discontinuous grid preserve original bytes', async () => { + const gap = qweather(); gap.minutely[1].fxTime = new Date((start + 360) * 1000).toISOString(); + for (const body of [{ code: '204' }, { ...qweather(), minutely: [] }, gap]) { + const { out, original } = await patch(body); + assert.deepEqual(out, original); + } +}); +test('QWeather light snow retains minutes and SNOW summary without guessed text enums', async () => { + const snow = qweather(); snow.summary = '小雪'; + const { out } = await patch(snow); + const next = decodeWK3NextHour(out); + assert.equal(next.minutes.length, 60); + assert.ok(next.minutes[0].precipitationIntensity > 0); + assert.deepEqual(next.summary[0].condition, [3]); + assert.deepEqual(next.condition, []); +}); +function colorful(intensity) { + return { status: 'ok', server_time: start, location: [31, 121], result: { minutely: { + status: 'ok', description: intensity ? '小雨' : '未来两小时无降水', probability: [0.7, 0.7, 0.7, 0.7], precipitation_2h: Array(60).fill(intensity), + } } }; +} +test('ColorfulClouds actual rain and clear minutely both reach WK3 without deriving rain from current weather', async () => { + for (const intensity of [0, 1.5]) { + const { out, calls } = await patch(colorful(intensity), { provider: 'ColorfulClouds' }); + const next = decodeWK3NextHour(out); + assert.ok(next); + assert.equal(next.minutes[0].precipitationIntensity, intensity); + assert.equal(next.condition[0].forecastToken, intensity ? 6 : 1); + assert.equal(calls.length, 1); + } +}); +test('upstream heavy and possible-rain enums replace an existing nonzero minute arena', async () => { + const { out: original } = await patch(colorful(1.5), { provider: 'ColorfulClouds' }); + for (const [intensity, condition] of [[0.1, 9], [15, 6]]) { + const { out } = await patch(colorful(intensity), { provider: 'ColorfulClouds', original }); + const next = decodeWK3NextHour(out); + assert.notDeepEqual(out, original); + assert.ok(Math.abs(next.minutes[0].precipitationIntensity - intensity) < 1e-6); + assert.equal(next.condition[0].beginCondition, condition); + } +}); +// Explicit NSRingo dev b4a75db / actual 2026-11-07 bundle enums, not new device evidence. +function precipitationBody(provider, values, description) { + if (provider === 'QWeather') { + const body = qweather(); body.summary = description; + body.minutely.forEach((m, i) => { m.precip = String(values[i * 5] / 12); }); + return body; + } + const body = colorful(0); body.result.minutely.description = description; + body.result.minutely.precipitation_2h = values; + return body; +} +test('Egern light-snow fallback preserves original bytes for invalid normalized conditions', async () => { + // Faults enter AFTER real provider normalization, because HTTP bodies do not + // expose internal forecast tokens/parameters. The generated handler remains + // unchanged except for this test-only injection at its codec call boundary. + const code = process.env.WK3_TEST_BUNDLE_DIR ? (await buildEgern({ minify: false })).response.code : responseCode; + const marker = 'const nextHour = providerToWK3NextHour(data,'; + assert.equal(code.split(marker).length, 2, 'one real conversion boundary'); + const { out: original } = await patch(colorful(1.5), { provider: 'ColorfulClouds' }); + for (const fault of [ + 'data.condition[0].forecastToken = "UNKNOWN_TOKEN";', + 'data.condition[0].startTime++;', + 'data.condition[0].parameters = [{type:"THIRD_AT",date:data.forecastStart}];', + 'data.condition[0].endCondition = "UNKNOWN_WEATHER";' + ]) { + const instrumented = code.replace(marker, `${fault}\n${marker}`); + const handler = (await import(`data:text/javascript;base64,${Buffer.from(instrumented).toString('base64')}`)).default; + for (const provider of ['QWeather', 'ColorfulClouds']) { + const body = precipitationBody(provider, Array(60).fill(1.5), '下雪'); + const { out } = await patch(body, { provider, original, handler }); + assert.deepEqual(out, original, `${provider}: ${fault}`); + } + } +}); +for (const provider of ['QWeather', 'ColorfulClouds']) { + test(`${provider} SNOW and HEAVY_SNOW retain minute intensities through Egern`, async () => { + for (const [intensity, numericCondition] of [[5, 11], [15, 10]]) { + // First minute/block is 1.5; later SNOW/HEAVY_SNOW determines the run condition. + const values = Array.from({ length: 60 }, (_, i) => i < 5 ? 1.5 : intensity); + const { out, calls } = await patch(precipitationBody(provider, values, '下雪'), { provider }); + const next = decodeWK3NextHour(out); + assert.ok(next); + assert.equal(next.minutes.length, 60); + assert.ok(Math.abs(next.minutes[0].precipitationIntensity - 1.5) < 1e-6); + assert.ok(Math.abs(next.minutes[20].precipitationIntensity - intensity) < 1e-6); + assert.deepEqual(next.summary[0].condition, [3]); + assert.equal(next.condition[0].beginCondition, numericCondition); + assert.equal(next.condition[0].forecastToken, 6); + assert.equal(calls.length, 1); + } + }); + test(`${provider} unnamed snow descriptions omit text but retain light and mixed snow minutes`, async () => { + const { out: original } = await patch(colorful(1.5), { provider: 'ColorfulClouds' }); + for (const values of [Array(60).fill(0.1), Array(60).fill(1.5), Array.from({ length: 60 }, (_, i) => i < 20 ? 0.1 : i < 40 ? 1.5 : 0)]) { + const { out } = await patch(precipitationBody(provider, values, '下雪'), { provider, original }); + const next = decodeWK3NextHour(out); + assert.notDeepEqual(out, original); + assert.equal(next.minutes.length, 60); + assert.ok(next.minutes.some(m => m.precipitationIntensity > 0)); + assert.ok(next.summary.some(s => s.condition[0] === 3)); + assert.deepEqual(next.condition, []); + for (const i of [0, 20, 40]) assert.ok(Math.abs(next.minutes[i].precipitationIntensity - values[i]) < 2e-6); + } + }); + test(`${provider} rain and snow start, stop and intermittent runs reach Egern`, async () => { + for (const [description, intensity, kind] of [['下雨', 1.5, 2], ['下雪', 5, 3]]) { + for (const [pattern, requiredToken] of [['start', 2], ['stop', 3], ['intermittent', 5]]) { + const values = Array.from({ length: 60 }, (_, i) => { + const wet = pattern === 'start' ? i >= 10 : pattern === 'stop' ? i < 20 : i < 10 || i >= 20; + return wet ? intensity : 0; + }); + const { out } = await patch(precipitationBody(provider, values, description), { provider }); + const next = decodeWK3NextHour(out); + assert.ok(next, `${description} ${pattern}`); + assert.equal(next.minutes.length, 60); + assert.ok(next.condition.some(c => c.forecastToken === requiredToken), `${description} ${pattern} token`); + assert.ok(next.summary.some(s => s.condition[0] === kind)); + assert.equal(next.condition.at(-1).endTime, 0); + } + } + }); +} diff --git a/tests/wk3/wk3-minutely-source.test.mjs b/tests/wk3/wk3-minutely-source.test.mjs new file mode 100644 index 00000000..ace1c62a --- /dev/null +++ b/tests/wk3/wk3-minutely-source.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import ForecastNextHour from '../../src/wk3/class/ForecastNextHour.mjs'; +import { providerToWK3NextHour } from '../../src/wk3/class/WK3NextHour.mjs'; +const start = 2000000040; +const units = { intensityUnit: 'mm/h', chanceUnit: 'percent' }; +function source(intensities) { + const minutes = ForecastNextHour.Minute(intensities.map((precipitationIntensity, i) => ({ startTime: start + i * 60, precipitationIntensity, precipitationChance: 70 })), '小雨'); + const summary = ForecastNextHour.Summary(minutes); + const condition = ForecastNextHour.Condition(summary); + return { metadata: { providerName: 'Synthetic', sourceType: 'MODELED' }, forecastStart: start, forecastEnd: start + minutes.length * 60, minutes, summary, condition }; +} +test('actual source ordinary rain, start and stop convert using only verified WK3 enums', () => { + for (const values of [Array(85).fill(1.5), Array(10).fill(0).concat(Array(75).fill(1.5)), Array(10).fill(1.5).concat(Array(75).fill(0))]) { + const data = source(values), next = providerToWK3NextHour(data, units); + assert.equal(next.minutes.length, 85); + assert.ok(next.minutes.some(m => m.precipitationIntensity > 0)); + assert.equal(next.summary.at(-1).endTime, 0); + } +}); +test('source heavy rain, possible rain and intermittent STOP_START use explicit upstream WK3 definitions', () => { + const heavy = providerToWK3NextHour(source(Array(85).fill(15)), units); + assert.equal(heavy.condition[0].beginCondition, 6); + const possible = providerToWK3NextHour(source(Array(85).fill(0.1)), units); + assert.equal(possible.condition[0].beginCondition, 9); + const intermittent = providerToWK3NextHour(source(Array.from({ length: 85 }, (_, i) => i < 10 || i >= 20 ? 1.5 : 0)), units); + assert.ok(intermittent.condition.some(c => c.forecastToken === 5)); +}); +test('four-segment condition limit does not mutate the longer source summary timeline', () => { + const minutes = ForecastNextHour.Minute(Array.from({ length: 85 }, (_, i) => ({ startTime: start + i * 60, precipitationIntensity: i < 60 && Math.floor(i / 10) % 2 === 0 ? 1.5 : 0, precipitationChance: 70 })), '小雨'); + const summary = ForecastNextHour.Summary(minutes), before = structuredClone(summary); + const condition = ForecastNextHour.Condition(summary); + assert.equal(summary.length, 6); + assert.equal(condition.length, 4); + assert.equal(condition.at(-1).endTime, 0); + assert.deepEqual(summary, before, 'condition truncation must not introduce endTime=0 inside the summary'); + assert.equal(summary[3].endTime, summary[4].startTime); +}); diff --git a/tests/wk3/wk3-next-hour.test.mjs b/tests/wk3/wk3-next-hour.test.mjs new file mode 100644 index 00000000..872daab9 --- /dev/null +++ b/tests/wk3/wk3-next-hour.test.mjs @@ -0,0 +1,169 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import { WK3NextHourTable, WK3_NEXT_HOUR_ENUMS, decodeNextHour, decodeWK3NextHour, encodeNextHour, providerToWK3NextHour, nextHourCodec } from '../../src/wk3/class/WK3NextHour.mjs'; + +const units = { intensityUnit: 'mm/h', chanceUnit: 'percent' }; +// Fixed public London coordinate; synthetic values, not a captured user request. +function provider() { + const start = 2000000040; + return { metadata: { providerName: 'Synthetic London', latitude: 51.5072, longitude: -0.1276, sourceType: 'MODELED', reportedTime: start, temporarilyUnavailable: false }, forecastStart: start, forecastEnd: start + 180, + minutes: [0, 1, 2].map((i) => ({ startTime: start + i * 60, precipitationChance: [0, 50, 80][i], precipitationIntensity: [0, 0.25, 1.5][i], perceivedPrecipitationIntensity: [0, 0.5, 1.2][i] })), + summary: [{ startTime: start, endTime: start + 60, condition: 'CLEAR', precipitationChance: 0, precipitationIntensity: 0 }, { startTime: start + 60, endTime: 0, condition: 'RAIN', precipitationChance: 80, precipitationIntensity: 1.5 }], + condition: [{ startTime: start, endTime: start + 60, forecastToken: 'START', beginCondition: 'DRIZZLE', endCondition: 'DRIZZLE', parameters: [{ type: 'FIRST_AT', date: start + 60 }] }, { startTime: start + 60, endTime: 0, forecastToken: 'CONSTANT', beginCondition: 'RAIN', endCondition: 'RAIN', parameters: [] }] }; +} +function root(data) { + const b = new Builder(), child = encodeNextHour(b, data); + b.startObject(15); b.addFieldOffset(5, child, 0); b.finish(b.endObject()); + return b.asUint8Array(); +} +function numericClose(a, b, path = '') { + if (a && typeof a === 'object') { for (const key of Object.keys(a)) numericClose(a[key], b[key], `${path}.${key}`); } + else if (typeof a === 'number') assert.ok(Math.abs(a - b) <= Math.max(1e-5, Math.abs(a) * 1e-6), `${path}: ${a} != ${b}`); + else assert.equal(a, b, path); +} + +test('provider conversion maps paired rain enums independently from WK2', () => { + const data = provider(), out = providerToWK3NextHour(data, units); + assert.deepEqual(out.summary.map(s => s.condition), [[1], [2]]); + assert.deepEqual(out.condition.map(c => c.forecastToken), [2, 6]); + assert.deepEqual(out.condition.map(c => c.beginCondition), [8, 7]); + assert.deepEqual(out.condition[0].parameters, [{ type: 1, date: data.forecastStart + 60 }]); + assert.equal(out.metadata.sourceType, 2); + assert.equal(out.minutes[2].precipitationIntensity, 1.5); + assert.equal(data.summary[0].condition, 'CLEAR'); + assert.deepEqual(WK3_NEXT_HOUR_ENUMS.forecastToken, { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, STOP_START: 5, CONSTANT: 6 }); +}); +test('explicit upstream WK3 snow, precipitation and condition definitions round-trip', () => { + // NSRingo dev b4a75db / @nsringo/weatherkit 2.0.5 / actual dev bundle 2026-11-07. + const conditions = { SLEET: 4, POSSIBLE_SLEET: 5, HEAVY_RAIN: 6, RAIN: 7, DRIZZLE: 8, POSSIBLE_DRIZZLE: 9, HEAVY_SNOW: 10, SNOW: 11 }; + const precipitation = { CLEAR: 1, RAIN: 2, SNOW: 3, SLEET: 4, HAIL: 5, MIXED: 7 }; + for (const [name, value] of Object.entries(conditions)) { + const p = provider(); p.condition.forEach(c => { c.beginCondition = c.endCondition = name; }); + assert.equal(decodeWK3NextHour(root(providerToWK3NextHour(p, units))).condition[0].beginCondition, value); + } + for (const [name, value] of Object.entries(precipitation)) { + const p = provider(); p.summary[1].condition = name; + assert.deepEqual(decodeWK3NextHour(root(providerToWK3NextHour(p, units))).summary[1].condition, [value]); + } + assert.equal(WK3_NEXT_HOUR_ENUMS.condition.FLURRIES, undefined); + assert.equal(WK3_NEXT_HOUR_ENUMS.condition.POSSIBLE_FLURRIES, undefined); +}); +function lightSnowProvider() { + const p = provider(); + p.summary[1].condition = 'SNOW'; + p.condition[0].beginCondition = p.condition[0].endCondition = 'FLURRIES'; + p.condition[1].beginCondition = p.condition[1].endCondition = 'POSSIBLE_FLURRIES'; + return p; +} +test('light snow omits the complete descriptive vector only after validating all source fields', () => { + const p = lightSnowProvider(), before = structuredClone(p), report = {}; + const converted = providerToWK3NextHour(p, { ...units, report }); + const out = decodeWK3NextHour(root(converted)); + assert.deepEqual(out.condition, []); + assert.deepEqual(out.summary[1].condition, [3]); + assert.equal(out.minutes[2].precipitationIntensity, 1.5); + assert.equal(report.omittedSnowConditions, 2); + assert.deepEqual(p, before); + const knownReport = {}; + providerToWK3NextHour(provider(), { ...units, report: knownReport }); + assert.equal(knownReport.omittedSnowConditions, 0); +}); +test('light snow never masks broken timelines, tokens, parameters or other unknown conditions', () => { + for (const change of [ + d => d.condition[1].startTime++, + d => d.condition[0].endTime++, + d => d.condition[1].endTime = d.forecastEnd, + d => d.minutes[1].startTime++, + d => d.condition[0].forecastToken = 'UNKNOWN_TOKEN', + d => d.condition[1].forecastToken = 'UNKNOWN_TOKEN', + d => d.condition[0].parameters[0].type = 'THIRD_AT', + d => d.condition[0].parameters[0].date = NaN, + d => d.condition[1].parameters = undefined, + d => d.condition[0].endCondition = 'UNKNOWN_WEATHER', + d => d.condition[1].beginCondition = 'HAIL', + d => d.summary[1].condition = 'UNKNOWN_WEATHER', + d => d.summary[1].condition = 'RAIN', + d => d.metadata.sourceType = 'UNKNOWN' + ]) { + const p = lightSnowProvider(), report = {}; change(p); + assert.throws(() => providerToWK3NextHour(p, { ...units, report })); + assert.equal(report.omittedSnowConditions, undefined); + } +}); +test('root 5 codec round-trips nonzero chance/intensity/perceived and summary enum vectors', () => { + const data = providerToWK3NextHour(provider(), units), bytes = root(data), copy = bytes.slice(); + const out = decodeWK3NextHour(bytes); + numericClose(data, out); assert.deepEqual(bytes, copy); + assert.equal(out.minutes[2].precipitationChance, 80); + assert.equal(out.summary[1].precipitationIntensity, 1.5); + assert.deepEqual(out.summary[1].condition, [2]); + assert.equal(nextHourCodec.tableClass, WK3NextHourTable); + assert.equal(nextHourCodec.encode, encodeNextHour); +}); +test('raw codec round-trips unknown enum integers without guessing meanings', () => { + const data = providerToWK3NextHour(provider(), units); + data.summary[1].condition = [231]; + data.condition[0].forecastToken = 212; data.condition[0].beginCondition = 254; + data.condition[0].parameters[0].type = 223; + const out = decodeWK3NextHour(root(data)); + assert.deepEqual(out.summary[1].condition, [231]); + assert.equal(out.condition[0].forecastToken, 212); + assert.equal(out.condition[0].beginCondition, 254); + assert.equal(out.condition[0].parameters[0].type, 223); +}); +test('singleton summary encoding has zero padding compatible with u8/u16 readers', () => { + const bytes = root(providerToWK3NextHour(provider(), units)), bb = new ByteBuffer(bytes); + const r = bb.readUint32(0), t = bb.__indirect(r + bb.__offset(r, 14)); + const vector = bb.__vector(t + bb.__offset(t, 8)); + for (let i = 0; i < 2; i++) { + const summary = bb.__indirect(vector + 4 * i), f = summary + bb.__offset(summary, 8); + const p = bb.__vector(f); + assert.equal(bb.__vector_len(f), 1); + assert.equal(bb.readUint16(p), i + 1); + assert.equal(bb.readUint8(p + 1), 0); + } +}); +test('unverified multi-condition summary vectors fail closed in both directions', () => { + const data = providerToWK3NextHour(provider(), units); + data.summary[0].condition = [1, 2]; + assert.throws(() => root(data), /Unverified/); + const p = provider(); p.summary[0].condition = ['CLEAR', 'RAIN']; + assert.throws(() => providerToWK3NextHour(p, units), /Unverified/); + const bytes = root(providerToWK3NextHour(provider(), units)), bb = new ByteBuffer(bytes); + const r = bb.readUint32(0), t = bb.__indirect(r + bb.__offset(r, 14)); + const vector = bb.__vector(t + bb.__offset(t, 8)), summary = bb.__indirect(vector); + const conditions = bb.__vector(summary + bb.__offset(summary, 8)); + bb.writeUint32(conditions - 4, 2); + assert.throws(() => decodeWK3NextHour(bytes), /Unverified/); +}); +test('child codec plugs into isolated arena reader and missing root 5 returns null', () => { + const b = new Builder(), offset = nextHourCodec.encode(b, providerToWK3NextHour(provider(), units)); b.finish(offset); + const bb = new ByteBuffer(b.asUint8Array()); + const out = nextHourCodec.decode(new nextHourCodec.tableClass().__init(bb.readUint32(0), bb)); + assert.equal(out.minutes.length, 3); + const empty = new Builder(); empty.startObject(1); empty.finish(empty.endObject()); + assert.equal(decodeWK3NextHour(empty.asUint8Array()), null); +}); +test('raw codec rejects WK2 summary scalar, invalid floats/probabilities and symbolic enum arithmetic', () => { + for (const change of [d => d.summary[0].condition = 1, d => d.minutes[0].precipitationIntensity = -1, d => d.minutes[0].precipitationIntensity = NaN, d => d.minutes[0].perceivedPrecipitationIntensity = Infinity, d => d.minutes[0].precipitationChance = 101, d => d.condition[0].forecastToken = 'START', d => d.summary[0].condition = [256]]) { + const data = providerToWK3NextHour(provider(), units); change(data); + assert.throws(() => encodeNextHour(new Builder(), data)); + } +}); +test('provider conversion fails closed for unknown enums, absent units, missing or inconsistent data', () => { + assert.throws(() => providerToWK3NextHour(provider())); + assert.throws(() => providerToWK3NextHour(provider(), { ...units, chanceUnit: 'fraction' })); + assert.throws(() => providerToWK3NextHour(provider(), { ...units, intensityUnit: 'mm/5min' })); + for (const change of [d => d.minutes[0].precipitationChance = 0.5, d => d.minutes[0].precipitationIntensity = undefined, d => d.minutes[0].perceivedPrecipitationIntensity = undefined, d => d.minutes[1].startTime++, d => d.forecastEnd++, d => d.condition[0].forecastToken = 'START_STOP_START', d => d.condition[0].beginCondition = 'FLURRIES', d => d.condition[0].endCondition = 'POSSIBLE_FLURRIES', d => d.condition[0].parameters[0].type = 'THIRD_AT', d => d.condition[0].parameters[0].type = 'NO_RAIN', d => d.condition[0].parameters[0].type = 'BOTH', d => d.summary[1].condition = 'FLURRIES', d => d.summary[0].condition = [], d => d.summary[0].endTime++, d => d.metadata.sourceType = 'UNKNOWN', d => d.metadata.temporarilyUnavailable = true, d => d.minutes = [], d => d.summary = []]) { + const data = provider(); change(data); + assert.throws(() => providerToWK3NextHour(data, units)); + } +}); +test('unsupported future child layout and truncation reject instead of emitting guessed data', () => { + const b = new Builder(); b.startObject(7); b.addFieldInt32(6, 42, 0); b.finish(b.endObject()); + const bb = new ByteBuffer(b.asUint8Array()); + assert.throws(() => decodeNextHour(new WK3NextHourTable().__init(bb.readUint32(0), bb)), /Unsupported/); + assert.throws(() => decodeWK3NextHour(new Uint8Array(3)), /Truncated/); + assert.throws(() => decodeWK3NextHour(root(providerToWK3NextHour(provider(), units)).subarray(0, 24))); +}); diff --git a/tests/wk3/wk3-upstream-aq-routing.test.mjs b/tests/wk3/wk3-upstream-aq-routing.test.mjs new file mode 100644 index 00000000..743fbc9a --- /dev/null +++ b/tests/wk3/wk3-upstream-aq-routing.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Builder,ByteBuffer} from 'flatbuffers'; +import {Response as processResponse} from '../../src/wk3/process/Response.mjs'; +import {encodeAirQuality,decodeWK3DataSet} from '../../src/wk3/class/WeatherKit3.mjs'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +const regions=[['TW',25.033,121.565],['CN',25.033,121.565],['MO',22.1987,113.5439],['CN',22.1987,113.5439]]; +const pollutants=[['PM2_5',25,9],['PM10',50,11],['OZONE',100,8],['NO2',25,6],['SO2',40,10],['CO',800,12]]; +const aq={metadata:{providerName:'Apple',sourceType:2},scale:'HJ6332012.2604',index:70,categoryIndex:2,isSignificant:false,primaryPollutant:9,previousDayComparison:3,pollutants:pollutants.map(([,amount,pollutantType])=>({amount,pollutantType,units:2}))}; +const source={metadata:{providerName:'Synthetic source',sourceType:'MODELED'},scale:'HJ6332012.2604',index:20,categoryIndex:1,isSignificant:false,primaryPollutant:'PM2_5',pollutants:pollutants.map(([pollutantType,amount])=>({pollutantType,amount:amount*2,units:'MICROGRAMS_PER_CUBIC_METER'}))}; +function root(existing=true){const b=new Builder(),a=existing?encodeAirQuality(b,aq):0,text=b.createString('weather/minutes/alerts-preserved');b.startObject(1);b.addFieldOffset(0,text,0);const child=b.endObject();b.startObject(18);if(a)b.addFieldOffset(0,a,0);for(const i of [1,5,9,17])b.addFieldOffset(i,child,0);b.finish(b.endObject());return b.asUint8Array();} +function marker(bytes,i){const b=new ByteBuffer(bytes),r=b.readUint32(0),t=b.__indirect(r+b.__offset(r,4+i*2));return b.__string(t+b.__offset(t,4));} +async function run(region,{existing=true,algorithm='UBA',targets=['HJ6332012'],datasets=['airQuality','forecastNextHour','weatherAlerts'],sourceFailure=false}={}){ + const [country,lat,lon]=region,bytes=root(existing),Settings=buildSettings(database,{Weather:{Replace:'[]'},DataSets:datasets,AirQuality:{Current:{Index:{Provider:'Calculate',Replace:targets},Pollutants:{Provider:'QWeather'}},Calculate:{Algorithm:algorithm},Comparison:{Yesterday:{IndexProvider:'WeatherKit'}}}}).Settings; + let calls=0;const output=await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/zh-Hant/${lat}/${lon}?country=${country}&dataSets=airQuality,forecastNextHour,weatherAlerts`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer'},body:bytes},{Settings,Configs:{},enviroments:{qWeather:{CurrentAirQuality:async()=>{calls++;if(sourceFailure)throw new Error('offline');return source;}},colorfulClouds:{Minutely:()=>assert.fail('must not fetch minutes')}}}); + for(const i of [1,5,9,17])assert.equal(marker(output.body,i),marker(bytes,i));return {bytes,out:output.body,aq:decodeWK3DataSet(output.body),calls,Settings}; +} +test('upstream AQ path processes Taipei/Macao without weather region overrides and keeps existing pollutants',async()=>{ + for(const region of regions){const r=await run(region);assert.equal(r.calls,0);assert.equal(r.aq.scale,'UBA.2414');const expected=AirQuality.Pollutants2AQI({metadata:{providerName:'Apple'},pollutants:pollutants.map(([pollutantType,amount])=>({pollutantType,amount,units:'MICROGRAMS_PER_CUBIC_METER'}))},r.Settings);assert.equal(r.aq.index,expected.index);} +}); +test('missing regional AQ uses configured provider, no country remapping',async()=>{ + for(const region of regions){const r=await run(region,{existing:false});assert.equal(r.calls,1);assert.equal(r.aq.scale,'UBA.2414');assert.equal(r.aq.index,AirQuality.Pollutants2AQI(source,r.Settings).index);} +}); +test('all eleven algorithms run on original Taipei/Macao Apple pollutants without provider requests',async()=>{ + const algorithms=['UBA','EU_EAQI','WAQI_InstantCast_US','WAQI_InstantCast_CN','WAQI_InstantCast_CN_25_DRAFT','CA_AQHI','HK_AQHI','AQHI_Multi_CN','AQHI_Multi_CN_HK','CN_DEATH_AQHI','CN_DEATH_HK_AQHI']; + for(const region of [regions[0],regions[2]])for(const algorithm of algorithms){const r=await run(region,{algorithm});assert.equal(r.calls,0);assert.notDeepEqual(r.out,r.bytes);} +}); +test('AQ disabled, algorithm None, unmatched index targets and provider failure preserve original bytes',async()=>{ + for(const region of [regions[0],regions[2]]){ + for(const options of [{algorithm:'None'},{targets:['EPA_NowCast']},{targets:[]},{datasets:['currentWeather']},{existing:false,sourceFailure:true}]){const r=await run(region,options);assert.deepEqual(r.out,r.bytes);} + } +});