diff --git a/docs/images/wk3-widget-tianjin.png b/docs/images/wk3-widget-tianjin.png new file mode 100644 index 00000000..0ae93d52 Binary files /dev/null and b/docs/images/wk3-widget-tianjin.png differ diff --git a/docs/wk3-binary-weather.md b/docs/wk3-binary-weather.md new file mode 100644 index 00000000..7d3d5e42 --- /dev/null +++ b/docs/wk3-binary-weather.md @@ -0,0 +1,101 @@ +# WK3 FlatBuffer 天气替换 + +本分支接入 `NSRingo/apple.WeatherKit` 的完整 WK3 schema 与 `WK3.Codec`,在 +`Response.dev.mjs` 中替换当前、逐日、逐小时天气。`WeatherKit3.mjs` 保留产品注册和 +供应商数据形态适配,字段布局与枚举读写统一由协议包负责。 + +## 三个 PR 的衔接 + +- [apple.WeatherKit #1](https://github.com/NSRingo/apple.WeatherKit/pull/1) 提供 Schema/Codec; + 协议包只负责表示层,不管理请求或供应商。 +- [WeatherKit #110](https://github.com/NSRingo/WeatherKit/pull/110) 负责 JSON 协商和 JSON 响应。 + 符合地区、供应商、数据集设置的主 App 请求继续协商 `Accept: application/json`。 +- [WeatherKit #111](https://github.com/NSRingo/WeatherKit/pull/111) 按实际响应 Content-Type + 处理 WK3 FlatBuffer;不修改 Request.dev.mjs,也不覆盖 #110 的 Accept 选择。 + +正常主 App JSON 响应在 JSON 分支返回,二进制 decoder/encoder 不执行;每个产品 +只调用一次供应商。仍收到 FlatBuffer 的请求进入本 PR 的产品替换与原始数据保留链。 +这让两种格式共享既有产品注入函数,不需要第二套客户端白名单或自动重试协议。 + +建议保留上述 JSON 优先策略,由维护者确认长期路线。下方主 App 二进制验收使用专用 +测试模块保留原生 Accept,用于证明 codec 与原生客户端兼容,不表示切换主 App 默认路由。 +macOS 小组件已通过下方二进制实测,本轮保留 FlatBuffer 路径,暂不扩展 JSON 白名单。 + +## 合并顺序 + +#110 和协议包 #1 可分别审阅。#111 保持 Draft,等待 #1 发布,再更新协议依赖及 +lockfile、同步已合并的 #110、重跑组合验证后合并。现有 2.0.5 没有 WK3.Schema/Codec, +本分支暂时使用协议包源码构建产物验证。 + +本分支的 cloudCover 修复与 #110 相同,同步到已合并 #110 的 dev 后消除这部分重复。 +样本说明使用独立文件名,避免两个 PR 同时新增 README 的冲突。 + +## 验证 + +本地使用协议包分支生成的 `dist/index.js` 替换本 worktree 的 +`node_modules/@nsringo/weatherkit/dist/index.js`,package.json 和 lockfile 未修改。 +协议包发布前可按这一方式复现;两个仓库的分支名均为 `codex/wk3-binary-codecs`。 + +```sh +# 在 apple.WeatherKit 中 +npm run WK3 +npm run tsc +npm test +npm run build + +# 在 WeatherKit 中,协议包位于相邻的 ../apple.WeatherKit +cp ../apple.WeatherKit/dist/index.js node_modules/@nsringo/weatherkit/dist/index.js +node --test tests/*.test.mjs +npm run build +npm run build:dev +``` + +- 协议包 26 项测试通过;主仓库 141 项测试通过,正式和开发构建通过。 +- 回归用例验证三类天气注入、供应商失败时单产品保留、未来字段保留,以及未选中产品 + 的完整原始 arena 逐字节保留。关闭替换或选择 WeatherKit 时响应逐字节不变。 +- 2026-11-14,macOS 27.3 (26C5035r) + Surge 6.12.0,成都、苏州主 App 实测请求和响应 + 均为 `application/vnd.apple.flatbuffer;messageType=WK3.Weather`。 +- 成都响应:当前 17.20°,今日 18.81° / 12.03°;彩云逐日 11 项、逐小时 274 项。 +- 苏州响应与页面对齐如下;当前、逐日、逐小时 metadata.providerName 均为彩云天气。 + +| 项目 | 二进制解码值 | 页面显示 | +| --- | --- | --- | +| 当前温度 | 18.16° | 18° | +| 今日最高 / 最低 | 18.16° / 16.41° | 18° / 16° | +| 17 时 | 18.00° | 18° | +| 18 时 | 17.68° | 18° | +| 19 时 | 17.49° | 17° | + +## 组合验证 + +将 #110 的 JSON 改动与本分支合并到独立 checkout,并使用协议包 #1 的构建产物。 +组合回归覆盖主 App JSON 协商、JSON 不进入 binary codec、每个产品只调用一次供应商、 +未协商 JSON 的 Widget 请求保持 FlatBuffer,以及新旧 schema 的成对样本对照。 +组合后的 161 项测试全部通过(0 跳过),正式和开发构建通过。对应提交见 PR 正文。 + +## macOS 小组件实测(2026-11-14) + +macOS 27.3(26C5035r)、Surge 6.12.0(12840)。主 App 隐藏,用户在桌面小组件中 +依次选择天津市津南区、昆明市、福州市,再恢复原来的当前位置。三次抓到的请求进程均为 +`WeatherWidget`,UA 均为 `WeatherKit_WeatherWidget_macOS_Version 27.3 (Build 26C5035r)`。 +Accept 和响应 Content-Type 均为 `application/vnd.apple.flatbuffer;messageType=WK3.Weather`。 + +| 地点 / 请求时间(UTC+8) | 当前温度:解码 → 显示 | 当日高 / 低:解码 → 显示 | 成功构建 timeline | +| --- | --- | --- | --- | +| 天津市津南区 / 18:50:29 | 7.74° → 8° | 13.42° / 5.07° → 13° / 5° | 2 次 | +| 昆明市 / 18:54:16 | 16.44° → 16° | 18.65° / 9.36° → 19° / 9° | 2 次 | +| 福州市 / 18:54:27 | 22.07° → 22° | 23.10° / 22.00° → 23° / 22° | 1 次 | + +三次改写响应的当前、逐日、逐小时 metadata.providerName 均为彩云天气,分别含 +10 天和 24 小时预报。请求完成后的原生日志成功获取天气并构建 timeline,观察窗口内 +`failedToCreateResponseModel`、`Unable to decode`、`FlatbuffersErrors` 均为零。 + +津南区同时保存了像素截图;昆明、福州用原生 Accessibility 文本核对完整显示结果。 +津南区 19–0 时温度的二进制值为 6.63、6.58、6.54、6.54、6.43、6.33°, +截图显示 7、7、7、7、6、6°。 + +![macOS 天气小组件:天津市津南区的 WK3 二进制替换结果](images/wk3-widget-tianjin.png) + +因此本轮保留“主 App JSON、小组件 FlatBuffer”的分工,暂不扩展小组件 JSON 白名单。 +Siri、地图、watchOS 等入口后续分别抓请求和验收。测试结束后恢复了原模块、当前位置 +与原桌面,并移除了只针对 WeatherKit 的临时抓包规则。 diff --git a/src/class/ColorfulClouds.mjs b/src/class/ColorfulClouds.mjs index 0a7f1c6a..b3c78ca6 100644 --- a/src/class/ColorfulClouds.mjs +++ b/src/class/ColorfulClouds.mjs @@ -519,7 +519,7 @@ export default class ColorfulClouds { daytimeForecast: { forecastStart: timeStamp + 8 * 3600, // 8 hours forecastEnd: timeStamp + 8 * 3600 + 12 * 3600, // 8 + 12 hours - cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given @@ -543,7 +543,7 @@ export default class ColorfulClouds { overnightForecast: { forecastStart: timeStamp + 20 * 3600, // 20 hours forecastEnd: timeStamp + 20 * 3600 + 12 * 3600, // 20 + 12 hours - cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given @@ -839,7 +839,7 @@ export default class ColorfulClouds { return { metadata: this.#Metadata(hourly.result.server_time, hourly.location), hours: Array.from({ length: hourly.result.hourly.skycon.length }, (_, i) => ({ - cloudCover: hourly.result.hourly.cloudrate[i].value, + cloudCover: Math.round(hourly.result.hourly.cloudrate[i].value * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given diff --git a/src/class/WeatherKit3.mjs b/src/class/WeatherKit3.mjs index ab59c65b..b84ae0ea 100644 --- a/src/class/WeatherKit3.mjs +++ b/src/class/WeatherKit3.mjs @@ -1,367 +1,51 @@ -import { FlatBufferRootProcessor, inspectTable } from "@nsnanocat/flatbuffer-root"; +import { FlatBufferRootProcessor } from "@nsnanocat/flatbuffer-root"; import { WK3 } from "@nsringo/weatherkit"; -import { createUuidOffset, decodeUuid } from "./WeatherKit2.mjs"; -/** - * 使用 Weather 根模型和产品 codec 配置通用根表处理器。 - * WK3 根槽位稀疏(0/5/9),各 codec 以显式 slot 对齐线槽位;其余产品保持原始 arena。 - * Configure the generic root-table processor with the Weather model and product codecs. - * WK3 root slots are sparse (0/5/9); each codec aligns to its wire slot explicitly, other products keep their original arenas. - */ +// Product boundaries preserve opaque/future roots. Table layout and enum handling live +// in the protocol package and are shared by every registered WK3 product. +const roots = WK3.Schema.tables.Weather; const WeatherKit3 = new FlatBufferRootProcessor({ - codecs: { - airQuality: { decode: decodeAirQuality, encode: encodeAirQuality, tableClass: WK3.AirQuality }, - forecastNextHour: { decode: decodeForecastNextHour, encode: encodeForecastNextHour, tableClass: WK3.NextHourForecastData }, - weatherAlerts: { decode: decodeWeatherAlerts, encode: encodeWeatherAlerts, tableClass: WK3.WeatherAlertCollectionData }, - }, - configurableRootNames: ["airQuality", "forecastNextHour", "weatherAlerts"], name: "WeatherKit3", rootClass: WK3.Weather, + configurableRootNames: roots.map(field => field.name), + codecs: Object.fromEntries(roots.map(({ name, type }) => [name, { + tableClass: WK3[type], + decode: table => WK3.Codec.decode(type, table, { uuidStrings: true }), + encode: (builder, data) => WK3.Codec.encode(type, builder, data), + }])), }); - export default WeatherKit3; -/** - * 编码空气质量产品表。标量超宽或非有限浓度抛 RangeError,由处理器隔离为单 slot 失败。 - * Encode the air quality product table. Out-of-range scalars or non-finite amounts throw. - * @param {import("flatbuffers").Builder} builder - FlatBuffer 构建器 / FlatBuffer builder. - * @param {object} data - 业务空气质量对象 / Air quality payload. - * @returns {number} 产品表偏移 / Table offset. - */ -function encodeAirQuality(builder, data) { - if (!Number.isInteger(data.index) || data.index < 0 || data.index > 32767 || !Number.isInteger(data.categoryIndex) || data.categoryIndex < 0 || data.categoryIndex > 127) { - throw new RangeError("WK3 AQ scalar out of range"); - } - const metadataOffset = encodeMetadata(builder, data.metadata); - const pollutantsOffset = WK3.AirQuality.createPollutantsVector( - builder, - data.pollutants.map(pollutant => { - if (!Number.isFinite(Math.fround(pollutant.amount))) throw new RangeError("WK3 pollutant amount must be finite"); - return WK3.Pollutant.createPollutant(builder, enumValue(WK3.PollutantType, pollutant.pollutantType), pollutant.amount, enumValue(WK3.UnitType, pollutant.units)); - }), - ); - const scaleOffset = builder.createString(data.scale); - return WK3.AirQuality.createAirQuality(builder, metadataOffset, data.categoryIndex, data.index, data.isSignificant, pollutantsOffset, enumValue(WK3.ComparisonTrend, data.previousDayComparison), enumValue(WK3.PollutantType, data.primaryPollutant), scaleOffset); -} - -/** - * 解码空气质量产品表;未验证字段或未知枚举值抛 RangeError。 - * Decode the air quality product table; unverified fields or unknown enums throw. - * @param {WK3.AirQuality} data - 生成的 AQ 表实例 / Generated AQ table instance. - * @returns {object} 业务空气质量对象 / Air quality payload. - */ -function decodeAirQuality(data) { - checkFields(data, 8); - const pollutants = []; - for (let i = 0; i < data.pollutantsLength(); i++) { - const pollutant = data.pollutants(i); - checkFields(pollutant, 3); - pollutants.push({ - pollutantType: enumName(WK3.PollutantType, pollutant.pollutantType()), - amount: pollutant.amount(), - units: enumName(WK3.UnitType, pollutant.units()), - }); - } - return { - metadata: decodeMetadata(data.metadata()), - categoryIndex: data.categoryIndex(), - index: data.index(), - isSignificant: data.isSignificant(), - pollutants, - previousDayComparison: enumName(WK3.ComparisonTrend, data.previousDayComparison()), - primaryPollutant: enumName(WK3.PollutantType, data.primaryPollutant()), - scale: data.scale(), - }; -} - -/** - * 编码下一小时降水产品表;仅供注入缺失产品使用,不重编码 Apple 原始数据。 - * Encode the next-hour precipitation product table; only used to inject a missing product, never to re-encode Apple's own data. - * @param {import("flatbuffers").Builder} builder - FlatBuffer 构建器 / FlatBuffer builder. - * @param {object} data - 业务下一小时降水对象 / Next-hour precipitation payload. - * @returns {number} 产品表偏移 / Table offset. - */ -function encodeForecastNextHour(builder, data) { - const metadataOffset = encodeMetadata(builder, data.metadata); - // 防御第三方数据:未知枚举传给 FlatBuffer 会静默编码为 UNKNOWN0。 - // Defend against third-party data: unknown enums would otherwise be silently encoded as UNKNOWN0. - const conditionOffsets = data.condition - .filter(condition => typeof WK3.ForecastToken[condition.forecastToken] === "number" && [condition.beginCondition, condition.endCondition].every(value => typeof WK3.ConditionType[value] === "number")) - .map(condition => { - const parametersOffset = WK3.Condition.createParametersVector( - builder, - condition.parameters.map(parameter => WK3.Parameter.createParameter(builder, enumValue(WK3.ParameterType, parameter.type), parameter.date)), - ); - return WK3.Condition.createCondition(builder, condition.startTime, condition.endTime, enumValue(WK3.ForecastToken, condition.forecastToken), enumValue(WK3.ConditionType, condition.beginCondition), enumValue(WK3.ConditionType, condition.endCondition), parametersOffset); - }); - const conditionOffset = WK3.NextHourForecastData.createConditionVector(builder, conditionOffsets); - // 摘要槽位 2 的 condition 枚举向量:业务摘要恒为单条件分段(v3 形态为单元素数组), - // 单元素 u8 向量与 u16 向量字节一致,可安全写入(PR #108 实机验证); - // 多元素数组的元素宽度未验证、未知降水类型未定义,均抛错由处理器隔离。 - // Summary slot 2 (condition enum vector): business summaries are single-condition segments - // (a single-element array in v3 shape), and a singleton u8 vector is byte-identical to u16, - // so writing is safe (device-verified in PR #108); multi-element arrays (unverified element - // width) and unknown precipitation types throw and are isolated by the processor. - const summaryOffsets = data.summary.map(summary => { - if (!Array.isArray(summary.condition) || summary.condition.length !== 1) throw new RangeError("WK3 summary condition must be a single-element array"); - const conditionVectorOffset = builder.createByteVector([enumValue(WK3.PrecipitationType, summary.condition[0])]); - WK3.ForecastPeriodSummary.startForecastPeriodSummary(builder); - WK3.ForecastPeriodSummary.addStartTime(builder, summary.startTime); - WK3.ForecastPeriodSummary.addEndTime(builder, summary.endTime); - builder.addFieldOffset(2, conditionVectorOffset, 0); - WK3.ForecastPeriodSummary.addPrecipitationChance(builder, summary.precipitationChance); - WK3.ForecastPeriodSummary.addPrecipitationIntensity(builder, summary.precipitationIntensity); - return WK3.ForecastPeriodSummary.endForecastPeriodSummary(builder); - }); - const summaryOffset = WK3.NextHourForecastData.createSummaryVector(builder, summaryOffsets); - const minutesOffset = WK3.NextHourForecastData.createMinutesVector( - builder, - data.minutes.map(minute => WK3.Common.ForecastMinute.createForecastMinute(builder, minute.startTime, minute.precipitationChance, minute.precipitationIntensity, minute.perceivedPrecipitationIntensity)), - ); - return WK3.NextHourForecastData.createNextHourForecastData(builder, metadataOffset, conditionOffset, summaryOffset, data.forecastStart, data.forecastEnd, minutesOffset); -} - -/** - * 解码下一小时降水产品表;未验证字段或未知枚举值抛 RangeError。 - * Decode the next-hour precipitation product table; unverified fields or unknown enums throw. - * @param {WK3.NextHourForecastData} data - 生成的下一小时降水表实例 / Generated next-hour table instance. - * @returns {object} 业务下一小时降水对象 / Next-hour precipitation payload. - */ -function decodeForecastNextHour(data) { - checkFields(data, 6); - const NextHourForecastData = data; - const condition = []; - for (let i = 0; i < NextHourForecastData.conditionLength(); i++) { - const conditionData = NextHourForecastData.condition(i); - checkFields(conditionData, 6); - const entry = { - beginCondition: enumName(WK3.ConditionType, conditionData.beginCondition()), - endCondition: enumName(WK3.ConditionType, conditionData.endCondition()), - endTime: conditionData.endTime(), - forecastToken: enumName(WK3.ForecastToken, conditionData.forecastToken()), - parameters: [], - startTime: conditionData.startTime(), - }; - for (let j = 0; j < conditionData.parametersLength(); j++) { - const parameter = conditionData.parameters(j); - checkFields(parameter, 2); - entry.parameters.push({ date: parameter.date(), type: enumName(WK3.ParameterType, parameter.type()) }); +/** Translate existing provider products into native WK3 value shapes before encoding. */ +export function prepareWeatherKit3Product(name, product) { + const type = roots.find(root => root.name === name)?.type; + if (!type) throw new RangeError(`Unknown WK3 product ${name}`); + function visit(table, data) { + if (table === "PrecipitationRange" && typeof data === "number") return { expected: data, minimum: data, maximum: data }; + if (table === "UUID" && typeof data === "string") return data; + if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError(`WK3 ${table} object expected`); + const result = {}; + if (table === "NextHourForecastData" && Array.isArray(data.condition)) { + data = { ...data, condition: data.condition.filter(item => + Object.hasOwn(WK3.Schema.enums.ForecastToken.values, item.forecastToken) && + [item.beginCondition, item.endCondition].every(value => Object.hasOwn(WK3.Schema.enums.ConditionType.values, value))) }; } - condition.push(entry); - } - const minutes = []; - for (let i = 0; i < NextHourForecastData.minutesLength(); i++) { - const minute = NextHourForecastData.minutes(i); - checkFields(minute, 4); - minutes.push({ - perceivedPrecipitationIntensity: minute.perceivedPrecipitationIntensity(), - precipitationChance: minute.precipitationChance(), - precipitationIntensity: minute.precipitationIntensity(), - startTime: minute.startTime(), - }); - } - const summary = []; - for (let i = 0; i < NextHourForecastData.summaryLength(); i++) { - const summaryData = NextHourForecastData.summary(i); - checkFields(summaryData, 5); - // 摘要槽位 2 的 condition 枚举向量元素宽度未验证,不解码。 - // Slot 2 of summaries (condition enum vector) is unverified and not decoded. - summary.push({ - endTime: summaryData.endTime(), - precipitationChance: summaryData.precipitationChance(), - precipitationIntensity: summaryData.precipitationIntensity(), - startTime: summaryData.startTime(), - }); - } - return { - metadata: decodeMetadata(NextHourForecastData.metadata()), - condition, - forecastEnd: NextHourForecastData.forecastEnd(), - forecastStart: NextHourForecastData.forecastStart(), - minutes, - summary, - }; -} - -/** - * 编码气象预警集合产品表。 - * Encode the weather alert collection product table. - * @param {import("flatbuffers").Builder} builder - FlatBuffer 构建器 / FlatBuffer builder. - * @param {object} data - 业务气象预警集合对象 / Weather alert collection payload. - * @returns {number} 产品表偏移 / Table offset. - */ -function encodeWeatherAlerts(builder, data) { - // 缺省字符串不写入(0 偏移),保留 Apple 原始字段缺席状态。 - // Absent strings are not written (0 offset), preserving Apple's original field absence. - const stringOffset = value => (value ? builder.createString(value) : 0); - const metadataOffset = encodeMetadata(builder, data.metadata); - const alertsOffsets = data.alerts.map(alert => { - const responsesOffset = WK3.WeatherAlertSummary.createResponsesVector( - builder, - alert.responses.map(response => enumValue(WK3.ResponseType, response)), - ); - return WK3.WeatherAlertSummary.createWeatherAlertSummary( - builder, - createUuidOffset(builder, alert.id), - stringOffset(alert.areaId), - stringOffset(alert.attributionUrl), - stringOffset(alert.countryCode), - stringOffset(alert.description), - stringOffset(alert.token), - alert.effectiveTime, - alert.expireTime, - alert.issuedTime, - alert.eventOnsetTime, - alert.eventEndTime, - stringOffset(alert.detailsUrl), - stringOffset(alert.phenomenon), - enumValue(WK3.Severity, alert.severity), - enumValue(WK3.SignificanceType, alert.significance), - stringOffset(alert.source), - stringOffset(alert.eventSource), - enumValue(WK3.Urgency, alert.urgency), - enumValue(WK3.Certainty, alert.certainty), - enumValue(WK3.ImportanceType, alert.importance), - responsesOffset, - ); - }); - const alertsOffset = WK3.WeatherAlertCollectionData.createAlertsVector(builder, alertsOffsets); - return WK3.WeatherAlertCollectionData.createWeatherAlertCollectionData(builder, metadataOffset, stringOffset(data.detailsUrl), alertsOffset); -} - -/** - * 解码气象预警集合产品表;未验证字段或未知枚举值抛 RangeError。 - * Decode the weather alert collection product table; unverified fields or unknown enums throw. - * @param {WK3.WeatherAlertCollectionData} data - 生成的预警集合表实例 / Generated alert collection table instance. - * @returns {object} 业务气象预警集合对象 / Weather alert collection payload. - */ -function decodeWeatherAlerts(data) { - checkFields(data, 3); - const WeatherAlertCollectionData = data; - const alerts = []; - for (let i = 0; i < WeatherAlertCollectionData.alertsLength(); i++) { - const alert = WeatherAlertCollectionData.alerts(i); - checkFields(alert, 22); - const summary = { - areaId: alert.areaId(), - attributionUrl: alert.attributionUrl(), - certainty: enumName(WK3.Certainty, alert.certainty()), - countryCode: alert.countryCode(), - description: alert.description(), - detailsUrl: alert.detailsUrl(), - effectiveTime: alert.effectiveTime(), - eventEndTime: alert.eventEndTime(), - eventOnsetTime: alert.eventOnsetTime(), - eventSource: alert.eventSource(), - expireTime: alert.expireTime(), - id: decodeUuid(alert.id()), - importance: enumName(WK3.ImportanceType, alert.importance()), - issuedTime: alert.issuedTime(), - phenomenon: alert.phenomenon(), - responses: [], - severity: enumName(WK3.Severity, alert.severity()), - significance: enumName(WK3.SignificanceType, alert.significance()), - source: alert.source(), - token: alert.token(), - urgency: enumName(WK3.Urgency, alert.urgency()), - }; - for (let j = 0; j < alert.responsesLength(); j++) summary.responses.push(enumName(WK3.ResponseType, alert.responses(j))); - alerts.push(summary); + for (const field of WK3.Schema.tables[table]) { + let value = data[field.name]; + if (value == null || (field.optional && value === 0)) continue; + const tableType = WK3.Schema.tables[field.type]; + if (field.vector) { + if (WK3.Schema.enums[field.type] && typeof value === "string") value = [value]; + if (!Array.isArray(value)) throw new TypeError(`WK3 ${field.name} vector expected`); + result[field.name] = tableType ? value.map(item => visit(field.type, item)) : value; + } else if (tableType) result[field.name] = visit(field.type, value); + else { + if (table === "Metadata" && ["latitude", "longitude"].includes(field.name) && typeof value === "string") value = Number(value); + if (["byte", "ubyte", "short", "ushort", "int", "uint"].includes(field.type) && typeof value === "number") value = field.type === "uint" ? Math.trunc(value) : Math.round(value); + result[field.name] = value; + } + } + return result; } - return { - metadata: decodeMetadata(WeatherAlertCollectionData.metadata()), - alerts, - detailsUrl: WeatherAlertCollectionData.detailsUrl(), - }; -} - -/** - * 编码产品元数据子表;缺失时返回 0(不写入)。 - * Encode the metadata child table; returns 0 when absent. - * @param {import("flatbuffers").Builder} builder - FlatBuffer 构建器 / FlatBuffer builder. - * @param {object} [data] - 元数据对象 / Metadata payload. - * @returns {number} 子表偏移 / Table offset. - */ -function encodeMetadata(builder, data) { - if (!data) return 0; - // 缺省字符串不写入(0 偏移),保留 Apple 原始字段缺席状态。 - // Absent strings are not written (0 offset), preserving Apple's original field absence. - const stringOffset = value => (value ? builder.createString(value) : 0); - return WK3.Metadata.createMetadata( - builder, - stringOffset(data.attributionUrl), - data.expireTime, - stringOffset(data.language), - data.latitude, - data.longitude, - stringOffset(data.providerLogo), - stringOffset(data.providerName), - data.readTime, - data.reportedTime, - data.temporarilyUnavailable, - enumValue(WK3.SourceType, data.sourceType), - ); -} - -/** - * 解码产品元数据子表;缺失时返回 undefined。 - * Decode the metadata child table; returns undefined when absent. - * @param {WK3.Metadata} [data] - 生成的元数据表实例 / Generated metadata table instance. - * @returns {object|undefined} 元数据对象 / Metadata payload. - */ -function decodeMetadata(data) { - if (!data) return undefined; - checkFields(data, 11); - return { - 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: enumName(WK3.SourceType, data.sourceType()), - }; -} - -/** - * 校验表的字段数不超过已验证范围;超出时抛 RangeError,由处理器隔离为单 slot 失败。 - * Validate that a table declares no more than the verified field count; excess throws. - * @param {object} table - 生成的表实例 / Generated table instance. - * @param {number} count - 已验证的字段数 / Verified field count. - * @returns {void} - */ -function checkFields(table, count) { - // 超出已验证字段数的表结构抛 RangeError,由处理器隔离为单 slot 失败。 - // Tables declaring more than the verified field count throw, isolated as per-slot failures. - inspectTable(table.bb, table.bb_pos, count); -} - -/** - * 将 wire 枚举值映射为名称;未知或占位值抛 RangeError。 - * Map a wire enum value to its name; unknown or placeholder values throw. - * @param {object} values - 版本枚举表 / Versioned enum table. - * @param {number} value - wire 枚举值 / Wire enum value. - * @returns {string} 枚举名称 / Enum name. - */ -function enumName(values, value) { - const name = values[value]; - if (typeof name !== "string" || /^UNKNOWN\d+$/.test(name)) throw new RangeError(`Unverified WK3 enum: ${value}`); - return name; -} - -/** - * 将枚举名称映射为 wire 枚举值;未知或占位名抛 RangeError。 - * Map an enum name to its wire value; unknown or placeholder names throw. - * @param {object} values - 版本枚举表 / Versioned enum table. - * @param {string} name - 枚举名称 / Enum name. - * @returns {number} wire 枚举值 / Wire enum value. - */ -function enumValue(values, name) { - const value = values[name]; - if (typeof value !== "number" || /^UNKNOWN\d+$/.test(name)) throw new RangeError(`Unverified WK3 enum: ${name}`); - return value; + return visit(type, product); } diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 59d33872..e298acf2 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -8,7 +8,7 @@ import WAQI from "../class/WAQI.mjs"; import Weather from "../class/Weather.mjs"; import WeatherAlerts from "../class/WeatherAlerts.mjs"; import WeatherKit2 from "../class/WeatherKit2.mjs"; -import WeatherKit3 from "../class/WeatherKit3.mjs"; +import WeatherKit3, { prepareWeatherKit3Product } from "../class/WeatherKit3.mjs"; import database from "../function/database.mjs"; import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; @@ -191,9 +191,8 @@ export async function Response($request, $response) { case url.pathname.startsWith("/api/v3/weather/"): { Console.info("🟪 WeatherKit.api.v3.weather"); const parameters = parseWeatherKitURL(url); - // WK3 仅注册了这三个产品的 codec,其余产品保持原始 arena。 - // Only these three products have registered WK3 codecs; other products keep their original arenas. - const dataSets = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet) && ["airQuality", "forecastNextHour", "weatherAlerts"].includes(dataSet)); + // Only selected injectable products are decoded; all other roots retain their original arena. + const dataSets = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet) && ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)); if (dataSets.length === 0) break; const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); const { json, failures } = WeatherKit3.decodeWithReport(ByteBuffer, rootNames); @@ -213,6 +212,16 @@ export async function Response($request, $response) { country: parameters.country, }; const patch = {}; + const replaceWeather = async (name, inject) => { + const original = body[name]; + try { + const copy = original ? JSON.parse(JSON.stringify(original)) : undefined; + const result = await inject(copy, Settings, enviroments); + if (result?.metadata && result.metadata.temporarilyUnavailable !== true && JSON.stringify(result) !== JSON.stringify(original)) patch[name] = result; + } catch (error) { + Console.warn("WeatherKit.api.v3.weather", `preserved ${name}: ${error?.name ?? "Error"}`); + } + }; await Promise.all( dataSets.map(async dataSet => { @@ -221,6 +230,18 @@ export async function Response($request, $response) { return; } switch (dataSet) { + case "currentWeather": { + await replaceWeather("currentWeather", InjectCurrentWeather); + break; + } + case "forecastDaily": { + if (body.forecastDaily) await replaceWeather("forecastDaily", InjectForecastDaily); + break; + } + case "forecastHourly": { + if (body.forecastHourly) await replaceWeather("forecastHourly", InjectForecastHourly); + break; + } case "airQuality": { if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { matchEnum.airQuality(); @@ -266,6 +287,10 @@ export async function Response($request, $response) { } }), ); + for (const name of Object.keys(patch)) { + try { patch[name] = prepareWeatherKit3Product(name, patch[name]); } + catch (error) { delete patch[name]; Console.warn("WeatherKit.api.v3.weather", `preserved ${name}: ${error?.name ?? "Error"}`); } + } if (Object.keys(patch).length > 0) rawBody = WeatherKit3.encode(ByteBuffer, patch); break; } diff --git a/tests/dailyPrecipitationTotals.test.mjs b/tests/dailyPrecipitationTotals.test.mjs index ed99a25a..1290a629 100644 --- a/tests/dailyPrecipitationTotals.test.mjs +++ b/tests/dailyPrecipitationTotals.test.mjs @@ -7,7 +7,8 @@ globalThis.$argument = { LogLevel: "OFF", Storage: "database" }; globalThis.$httpClient = { get(request, callback) { let body; - if (request.url.includes("api.caiyunapp.com")) body = colorfulCloudsDaily; + if (request.url.includes("/hourly?")) body = colorfulCloudsHourly; + else if (request.url.includes("api.caiyunapp.com")) body = colorfulCloudsDaily; else if (request.url.includes("/v7/weather/")) body = qWeatherDaily; else throw new Error(`unexpected request: ${request.url}`); callback(null, { headers: {}, status: 200 }, JSON.stringify(body)); @@ -35,6 +36,8 @@ test("ColorfulClouds keeps WeatherKit daily amount pairs while replacing support assert.equal(appleDay.precipitationChance, 80); assert.equal(appleDay.daytimeForecast.precipitationChance, 70); assert.equal(appleDay.overnightForecast.precipitationChance, 60); + assert.equal(appleDay.daytimeForecast.cloudCover, 80); + assert.equal(appleDay.overnightForecast.cloudCover, 80); }); test("QWeather keeps WeatherKit daily amount pairs instead of reusing one daily total", async () => { @@ -129,3 +132,36 @@ const qWeatherDaily = { fxLink: "https://www.qweather.com/", updateTime: "2026-07-16T08:00:00+08:00", }; + +// The provider boundary converts fractions once, before either WK2 encoding or WK3 JSON rounding. +const colorfulCloudsHourly = { + status: "ok", + location: [30, 120], + result: { + hourly: { + status: "ok", + skycon: [{ value: "CLOUDY", datetime: "2026-11-13T12:00:00Z" }], + cloudrate: [{ value: 0.5 }], + humidity: [{ value: 0.5 }], + precipitation: [{ value: 0, probability: 0 }], + pressure: [{ value: 101300 }], + temperature: [{ value: 20 }], + apparent_temperature: [{ value: 20 }], + visibility: [{ value: 10 }], + wind: [{ direction: 0, speed: 1 }], + }, + }, +}; +test("Caiyun hourly cloud-cover fractions are converted to percentages before rounding", async () => { + for (const [fraction, percent] of [ + [0, 0], + [0.005, 1], + [0.5, 50], + [0.8, 80], + [1, 100], + ]) { + colorfulCloudsHourly.result.hourly.cloudrate[0].value = fraction; + const forecast = await new ColorfulClouds({ ...parameters, version: "v3" }, "token").ForecastHourly(1); + assert.equal(forecast.hours[0].cloudCover, percent); + } +}); diff --git a/tests/fixtures/wk3-binary-integration.md b/tests/fixtures/wk3-binary-integration.md new file mode 100644 index 00000000..3e1a7a77 --- /dev/null +++ b/tests/fixtures/wk3-binary-integration.md @@ -0,0 +1,11 @@ +# WK3 integration fixture + +`hangzhou/` is a sanitized native macOS 27.3 (26C5035r) JSON/FlatBuffer pair from +2026-11-13, schema `lib-rc-3.0.0-b66`. The manifest records acquisition times, +response media types, hashes and in-place redaction offsets. Coordinates are zero; +no request credentials or device identifiers are included. The binary is not re-encoded. + +`weatherKit3WeatherReplacement.test.mjs` uses this binary to verify current/daily/hourly +replacement, provider failure isolation, future-field preservation and unchanged +native/disabled-provider responses. Other protocol samples and schema tests belong +to the companion `NSRingo/apple.WeatherKit` change. diff --git a/tests/fixtures/wk3-paired/hangzhou/apple.bin b/tests/fixtures/wk3-paired/hangzhou/apple.bin new file mode 100644 index 00000000..bcebe175 Binary files /dev/null and b/tests/fixtures/wk3-paired/hangzhou/apple.bin differ diff --git a/tests/fixtures/wk3-paired/hangzhou/apple.json b/tests/fixtures/wk3-paired/hangzhou/apple.json new file mode 100644 index 00000000..6c4dc0a8 --- /dev/null +++ b/tests/fixtures/wk3-paired/hangzhou/apple.json @@ -0,0 +1 @@ +{"news":{"metadata":{"latitude":0,"longitude":0,"attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"expireTime":1794573006,"temporarilyUnavailable":false,"sourceType":"APPLE_INTERNAL","reportedTime":1794569406},"placements":[]},"airQuality":{"metadata":{"language":"en","latitude":0,"longitude":0,"expireTime":1794573000,"sourceType":"STATION","attributionUrl":"https://www.qweather.com/air/a/00.0000,000.00?from=AppleWeatherService&lang=en","readTime":1794569400,"temporarilyUnavailable":false,"providerName":"QWeather","reportedTime":1794564000},"index":50,"pollutants":[{"amount":9.0,"units":"MICROGRAMS_PER_CUBIC_METER","pollutantType":"SO2"},{"amount":34.0,"units":"MICROGRAMS_PER_CUBIC_METER","pollutantType":"PM2_5"},{"amount":18.0,"units":"MICROGRAMS_PER_CUBIC_METER","pollutantType":"OZONE"},{"amount":0.7,"units":"MICROGRAMS_PER_CUBIC_METER","pollutantType":"CO"},{"amount":79.0,"units":"MICROGRAMS_PER_CUBIC_METER","pollutantType":"NO2"},{"amount":50.0,"units":"MICROGRAMS_PER_CUBIC_METER","pollutantType":"PM10"}],"scale":"HJ6332012.2604","primaryPollutant":"NOT_AVAILABLE","previousDayComparison":"UNKNOWN","categoryIndex":1,"isSignificant":true},"currentWeather":{"metadata":{"latitude":0,"longitude":0,"attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"expireTime":1794569706,"temporarilyUnavailable":false,"sourceType":"MODELED","reportedTime":1794569406},"daylight":false,"humidity":82,"pressure":1019.95459,"temperature":19.18985,"visibility":16033.438477,"asOf":1794569406,"precipitationIntensity":0.0,"temperatureDewPoint":16.042374,"windGust":3.437658,"temperatureApparent":20.357151,"snowfallAmountPrevious6h":{"expected":0.0,"minimum":0.0,"maximum":0.0},"snowfallAmountPrevious24h":{"expected":0.0,"minimum":0.0,"maximum":0.0},"precipitationAmountNext6hByType":[{"expected":0.850883,"minimum":0.850883,"maximum":0.850883,"precipitationType":["RAIN"]}],"snowfallAmountNext24h":{"expected":0.0,"minimum":0.0,"maximum":0.0},"precipitationAmountPrevious6hByType":[],"snowfallAmountPrevious1h":{"expected":0.0,"minimum":0.0,"maximum":0.0},"pressureTrend":"RISING","windDirection":116,"windSpeed":2.820522,"cloudCoverLowAltPct":1,"perceivedPrecipitationIntensity":0.0,"precipitationAmountPrevious1hByType":[],"cloudCover":95,"precipitationAmountNext1hByType":[],"snowfallAmountNext1h":{"expected":0.0,"minimum":0.0,"maximum":0.0},"precipitationAmountPrevious24hByType":[{"expected":0.644,"minimum":0.644,"maximum":0.644,"precipitationType":["RAIN"]}],"cloudCoverMidAltPct":93,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":83,"snowfallAmountNext6h":{"expected":0.0,"minimum":0.0,"maximum":0.0},"uvIndex":0,"precipitationAmountNext24hByType":[{"expected":9.529001,"minimum":9.529001,"maximum":9.529001,"precipitationType":["RAIN"]}]},"weatherChanges":{"metadata":{"language":"en","latitude":0,"longitude":0,"expireTime":1794573006,"sourceType":"MODELED","attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"temporarilyUnavailable":false,"reportedTime":1794567934},"changes":[{"forecastEnd":1794672000,"maxTemperatureChange":"STEADY","forecastStart":1794585600,"minTemperatureChange":"STEADY","dayPrecipitationChange":"INC","nightPrecipitationChange":"DEC"},{"forecastEnd":1794758400,"maxTemperatureChange":"STEADY","forecastStart":1794672000,"minTemperatureChange":"STEADY","dayPrecipitationChange":"DEC","nightPrecipitationChange":"STEADY"}],"forecastEnd":1794758400,"forecastStart":1794585600},"weatherAlerts":{"metadata":{"language":"en-US","latitude":0,"longitude":0,"expireTime":1794569706,"sourceType":"STATION","attributionUrl":"https://www.qweather.com","readTime":1794569406,"temporarilyUnavailable":false,"providerName":"National Early Warning Center","reportedTime":1794569406},"alerts":[]},"historicalComparisons":{"metadata":{"latitude":0,"longitude":0,"attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"expireTime":1794573006,"temporarilyUnavailable":false,"sourceType":"MODELED","reportedTime":1794567934},"comparisons":[{"condition":"TEMPERATURE_MAX","deviation":"NORMAL","baselineValue":17.244995,"baselineStartDate":0,"currentValue":20.800938,"baselineType":"MEAN"},{"condition":"PRECIPITATION","deviation":"NORMAL","baselineValue":0.0,"baselineStartDate":0,"currentValue":0.0,"baselineType":"MEAN"}]},"forecastDaily":{"metadata":{"latitude":0,"longitude":0,"attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"expireTime":1794573006,"temporarilyUnavailable":false,"sourceType":"MODELED","reportedTime":1794567934},"days":[{"moonrise":1794534809,"moonset":1794570807,"sunrise":1794522168,"sunset":1794560632,"forecastEnd":1794585600,"sunriseCivil":1794520650,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.800938,"solarMidnight":1794584614,"windSpeedAvg":3.814125,"temperatureMaxTime":1794549662,"windGustSpeedMax":9.5688,"sunriseAstronomical":1794517209,"forecastStart":1794499200,"moonPhase":"WAXING_CRESCENT","humidityMax":87,"precipitationAmountByType":[{"expected":0.622,"minimum":0.622,"maximum":0.622,"precipitationType":["RAIN"]}],"sunsetAstronomical":1794565589,"visibilityMax":19238.0,"precipitationChance":56,"temperatureMin":15.190534,"maxUvIndex":4,"temperatureMinTime":1794517604,"sunsetNautical":1794563880,"daytimeForecast":{"humidity":77,"daylight":true,"forecastEnd":1794567600,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.800938,"uvIndexMin":0,"temperatureApparentMin":15.483466,"windGustSpeedMax":9.5688,"perceivedPrecipitationIntensityMax":0.302,"uvIndexMax":4,"temperatureApparentMax":21.281811,"forecastStart":1794524400,"windDirection":32,"windSpeed":3.91425,"humidityMax":87,"precipitationAmountByType":[{"expected":0.125,"minimum":0.125,"maximum":0.125,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":8,"visibilityMax":19238.0,"cloudCover":96,"precipitationChance":31,"temperatureMin":15.25,"cloudCoverMidAltPct":82,"precipitationIntensityMax":0.067,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":89,"humidityMin":68,"visibilityMin":13129.0,"precipitationType":["RAIN"],"windSpeedMax":5.9364},"conditionCode":["DRIZZLE"],"humidityMin":68,"solarNoon":1794541398,"visibilityMin":10597.0,"overnightForecast":{"humidity":85,"daylight":false,"forecastEnd":1794610800,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.432705,"uvIndexMin":0,"temperatureApparentMin":19.020458,"windGustSpeedMax":8.5572,"perceivedPrecipitationIntensityMax":1.189,"uvIndexMax":0,"temperatureApparentMax":20.606981,"forecastStart":1794567600,"windDirection":325,"windSpeed":3.1044,"humidityMax":86,"precipitationAmountByType":[{"expected":6.367,"minimum":6.367,"maximum":6.367,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":45,"visibilityMax":16314.0,"cloudCover":99,"precipitationChance":89,"temperatureMin":18.13209,"cloudCoverMidAltPct":95,"precipitationIntensityMax":1.376,"conditionCode":["RAIN"],"cloudCoverHighAltPct":96,"humidityMin":80,"visibilityMin":4861.0,"precipitationType":["RAIN"],"windSpeedMax":4.0608},"precipitationType":["RAIN"],"sunriseNautical":1794518920,"sunsetCivil":1794562152,"windSpeedMax":6.1056,"restOfDayForecast":{"humidity":84,"daylight":false,"forecastEnd":1794585600,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.18985,"uvIndexMin":0,"temperatureApparentMin":19.723528,"windGustSpeedMax":4.9716,"perceivedPrecipitationIntensityMax":0.608,"uvIndexMax":0,"temperatureApparentMax":20.378323,"forecastStart":1794569406,"windDirection":118,"windSpeed":2.516788,"humidityMax":85,"precipitationAmountByType":[{"expected":0.4,"minimum":0.4,"maximum":0.4,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":16,"visibilityMax":16314.0,"cloudCover":97,"precipitationChance":44,"temperatureMin":18.43,"cloudCoverMidAltPct":95,"precipitationIntensityMax":0.291,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":90,"humidityMin":82,"visibilityMin":10597.0,"precipitationType":["RAIN"],"windSpeedMax":3.294}},{"moonrise":1794623929,"moonset":1794660566,"sunrise":1794608618,"sunset":1794647000,"forecastEnd":1794672000,"sunriseCivil":1794607096,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.408365,"solarMidnight":1794671023,"windSpeedAvg":4.6044,"temperatureMaxTime":1794633910,"windGustSpeedMax":16.74,"sunriseAstronomical":1794603652,"forecastStart":1794585600,"moonPhase":"WAXING_CRESCENT","humidityMax":98,"precipitationAmountByType":[{"expected":9.129001,"minimum":9.129001,"maximum":9.129001,"precipitationType":["RAIN"]}],"sunsetAstronomical":1794651965,"visibilityMax":20319.0,"precipitationChance":98,"temperatureMin":15.61,"maxUvIndex":3,"temperatureMinTime":1794672000,"sunsetNautical":1794650253,"daytimeForecast":{"humidity":82,"daylight":true,"forecastEnd":1794654000,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.408365,"uvIndexMin":0,"temperatureApparentMin":18.308884,"windGustSpeedMax":16.74,"perceivedPrecipitationIntensityMax":1.004,"uvIndexMax":3,"temperatureApparentMax":20.453522,"forecastStart":1794610800,"windDirection":1,"windSpeed":5.25075,"humidityMax":88,"precipitationAmountByType":[{"expected":3.162,"minimum":3.162,"maximum":3.162,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":73,"visibilityMax":20319.0,"cloudCover":98,"precipitationChance":93,"temperatureMin":17.6,"cloudCoverMidAltPct":82,"precipitationIntensityMax":0.908,"conditionCode":["RAIN"],"cloudCoverHighAltPct":82,"humidityMin":77,"visibilityMin":9793.0,"precipitationType":["RAIN"],"windSpeedMax":7.3512},"conditionCode":["RAIN"],"humidityMin":77,"solarNoon":1794627807,"visibilityMin":4861.0,"overnightForecast":{"humidity":96,"daylight":false,"forecastEnd":1794697200,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":17.6,"uvIndexMin":0,"temperatureApparentMin":14.197939,"windGustSpeedMax":19.152,"perceivedPrecipitationIntensityMax":0.354,"uvIndexMax":0,"temperatureApparentMax":18.308884,"forecastStart":1794654000,"windDirection":3,"windSpeed":5.7861,"humidityMax":100,"precipitationAmountByType":[{"expected":0.126,"minimum":0.126,"maximum":0.126,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":93,"visibilityMax":18939.0,"cloudCover":96,"precipitationChance":26,"temperatureMin":14.570957,"cloudCoverMidAltPct":35,"precipitationIntensityMax":0.093,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":3,"humidityMin":87,"visibilityMin":12824.0,"precipitationType":["RAIN"],"windSpeedMax":8.496},"precipitationType":["RAIN"],"sunriseNautical":1794605364,"sunsetCivil":1794648523,"windSpeedMax":7.3512},{"moonrise":1794712720,"moonset":1794750377,"sunrise":1794695068,"sunset":1794733370,"forecastEnd":1794758400,"sunriseCivil":1794693543,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.175529,"solarMidnight":1794757433,"windSpeedAvg":7.4865,"temperatureMaxTime":1794716785,"windGustSpeedMax":22.395601,"sunriseAstronomical":1794690094,"forecastStart":1794672000,"moonPhase":"WAXING_CRESCENT","humidityMax":100,"precipitationAmountByType":[{"expected":0.126,"minimum":0.126,"maximum":0.126,"precipitationType":["RAIN"]}],"sunsetAstronomical":1794738342,"visibilityMax":21906.0,"precipitationChance":43,"temperatureMin":14.570957,"maxUvIndex":4,"temperatureMinTime":1794687824,"sunsetNautical":1794736628,"daytimeForecast":{"humidity":69,"daylight":true,"forecastEnd":1794740400,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.175529,"uvIndexMin":0,"temperatureApparentMin":15.482128,"windGustSpeedMax":22.395601,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":4,"temperatureApparentMax":19.936546,"forecastStart":1794697200,"windDirection":8,"windSpeed":8.397449,"humidityMax":87,"precipitationAmountByType":[],"cloudCoverLowAltPct":59,"visibilityMax":21906.0,"cloudCover":77,"precipitationChance":0,"temperatureMin":15.64,"cloudCoverMidAltPct":13,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLOUDY"],"cloudCoverHighAltPct":50,"humidityMin":62,"visibilityMin":18937.0,"precipitationType":["CLEAR"],"windSpeedMax":9.6408},"conditionCode":["DRIZZLE"],"humidityMin":62,"solarNoon":1794714216,"visibilityMin":12824.0,"overnightForecast":{"humidity":83,"daylight":false,"forecastEnd":1794783600,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":17.07,"uvIndexMin":0,"temperatureApparentMin":13.370182,"windGustSpeedMax":15.9588,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":0,"temperatureApparentMax":16.961658,"forecastStart":1794740400,"windDirection":29,"windSpeed":5.632051,"humidityMax":90,"precipitationAmountByType":[],"cloudCoverLowAltPct":51,"visibilityMax":20055.0,"cloudCover":84,"precipitationChance":0,"temperatureMin":13.299702,"cloudCoverMidAltPct":26,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLOUDY"],"cloudCoverHighAltPct":57,"humidityMin":74,"visibilityMin":17620.0,"precipitationType":["CLEAR"],"windSpeedMax":7.3404},"precipitationType":["RAIN"],"sunriseNautical":1794691809,"sunsetCivil":1794734895,"windSpeedMax":9.6408},{"moonrise":1794801228,"moonset":1794840185,"sunrise":1794781518,"sunset":1794819740,"forecastEnd":1794844800,"sunriseCivil":1794779990,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":18.463999,"solarMidnight":1794843843,"windSpeedAvg":6.425475,"temperatureMaxTime":1794807928,"windGustSpeedMax":16.7616,"sunriseAstronomical":1794776537,"forecastStart":1794758400,"moonPhase":"WAXING_CRESCENT","humidityMax":90,"precipitationAmountByType":[],"sunsetAstronomical":1794824720,"visibilityMax":21797.0,"precipitationChance":0,"temperatureMin":13.299702,"maxUvIndex":4,"temperatureMinTime":1794772656,"sunsetNautical":1794823005,"daytimeForecast":{"humidity":70,"daylight":true,"forecastEnd":1794826800,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":18.463999,"uvIndexMin":0,"temperatureApparentMin":15.188446,"windGustSpeedMax":16.7616,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":4,"temperatureApparentMax":18.230001,"forecastStart":1794783600,"windDirection":59,"windSpeed":6.97035,"humidityMax":79,"precipitationAmountByType":[],"cloudCoverLowAltPct":44,"visibilityMax":21797.0,"cloudCover":76,"precipitationChance":0,"temperatureMin":14.93,"cloudCoverMidAltPct":21,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLOUDY"],"cloudCoverHighAltPct":53,"humidityMin":64,"visibilityMin":17620.0,"precipitationType":["CLEAR"],"windSpeedMax":8.856},"conditionCode":["MOSTLY_CLOUDY"],"humidityMin":64,"solarNoon":1794800627,"visibilityMin":17620.0,"overnightForecast":{"humidity":85,"daylight":false,"forecastEnd":1794870000,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":16.74,"uvIndexMin":0,"temperatureApparentMin":11.96024,"windGustSpeedMax":14.7816,"perceivedPrecipitationIntensityMax":0.799,"uvIndexMax":0,"temperatureApparentMax":16.213861,"forecastStart":1794826800,"windDirection":50,"windSpeed":6.6612,"humidityMax":94,"precipitationAmountByType":[{"expected":0.224,"minimum":0.224,"maximum":0.224,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":45,"visibilityMax":19767.0,"cloudCover":63,"precipitationChance":22,"temperatureMin":12.188884,"cloudCoverMidAltPct":46,"precipitationIntensityMax":0.534,"conditionCode":["MOSTLY_CLOUDY"],"cloudCoverHighAltPct":22,"humidityMin":72,"visibilityMin":13908.0,"precipitationType":["RAIN"],"windSpeedMax":7.4988},"precipitationType":["CLEAR"],"sunriseNautical":1794778254,"sunsetCivil":1794821269,"windSpeedMax":8.856},{"moonrise":1794889527,"moonset":1794929975,"sunrise":1794867969,"sunset":1794906113,"forecastEnd":1794931200,"sunriseCivil":1794866438,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":15.357734,"solarMidnight":1794930255,"windSpeedAvg":7.7052,"temperatureMaxTime":1794890602,"windGustSpeedMax":22.726801,"sunriseAstronomical":1794862980,"forecastStart":1794844800,"moonPhase":"WAXING_CRESCENT","humidityMax":100,"precipitationAmountByType":[{"expected":13.804999,"minimum":13.804999,"maximum":13.804999,"precipitationType":["RAIN"]}],"sunsetAstronomical":1794911100,"visibilityMax":18315.0,"precipitationChance":84,"temperatureMin":11.48,"maxUvIndex":4,"temperatureMinTime":1794931200,"sunsetNautical":1794909383,"daytimeForecast":{"humidity":89,"daylight":true,"forecastEnd":1794913200,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":15.357734,"uvIndexMin":0,"temperatureApparentMin":11.665749,"windGustSpeedMax":13.741199,"perceivedPrecipitationIntensityMax":1.144,"uvIndexMax":4,"temperatureApparentMax":15.194091,"forecastStart":1794870000,"windDirection":23,"windSpeed":7.07115,"humidityMax":100,"precipitationAmountByType":[{"expected":9.075,"minimum":9.075,"maximum":9.075,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":66,"visibilityMax":13908.0,"cloudCover":90,"precipitationChance":80,"temperatureMin":12.39,"cloudCoverMidAltPct":85,"precipitationIntensityMax":1.248,"conditionCode":["RAIN"],"cloudCoverHighAltPct":13,"humidityMin":82,"visibilityMin":4911.0,"precipitationType":["RAIN"],"windSpeedMax":9.8604},"conditionCode":["RAIN"],"humidityMin":82,"solarNoon":1794887038,"visibilityMin":4911.0,"overnightForecast":{"humidity":97,"daylight":false,"forecastEnd":1794956400,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":12.74,"uvIndexMin":0,"temperatureApparentMin":7.883188,"windGustSpeedMax":33.530403,"perceivedPrecipitationIntensityMax":1.144,"uvIndexMax":0,"temperatureApparentMax":11.665749,"forecastStart":1794913200,"windDirection":339,"windSpeed":12.117449,"humidityMax":100,"precipitationAmountByType":[{"expected":5.712,"minimum":5.712,"maximum":5.712,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":78,"visibilityMax":19540.0,"cloudCover":97,"precipitationChance":72,"temperatureMin":10.771482,"cloudCoverMidAltPct":90,"precipitationIntensityMax":1.248,"conditionCode":["RAIN"],"cloudCoverHighAltPct":2,"humidityMin":83,"visibilityMin":4911.0,"precipitationType":["RAIN"],"windSpeedMax":14.191199},"precipitationType":["RAIN"],"sunriseNautical":1794864698,"sunsetCivil":1794907644,"windSpeedMax":12.492},{"moonrise":1794977686,"sunrise":1794954419,"sunset":1794992487,"forecastEnd":1795017600,"sunriseCivil":1794952885,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":13.762805,"solarMidnight":1795016667,"windSpeedAvg":16.059074,"temperatureMaxTime":1794981021,"windGustSpeedMax":45.216,"sunriseAstronomical":1794949423,"forecastStart":1794931200,"moonPhase":"FIRST_QUARTER","humidityMax":100,"precipitationAmountByType":[{"expected":1.206,"minimum":1.206,"maximum":1.206,"precipitationType":["RAIN"]}],"sunsetAstronomical":1794997482,"visibilityMax":22986.0,"precipitationChance":65,"temperatureMin":7.876788,"maxUvIndex":4,"temperatureMinTime":1795007096,"sunsetNautical":1794995763,"daytimeForecast":{"humidity":58,"daylight":true,"forecastEnd":1794999600,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":13.762805,"uvIndexMin":0,"temperatureApparentMin":4.5453,"windGustSpeedMax":45.216,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":4,"temperatureApparentMax":9.23888,"forecastStart":1794956400,"windDirection":321,"windSpeed":19.52055,"humidityMax":83,"precipitationAmountByType":[],"cloudCoverLowAltPct":18,"visibilityMax":22986.0,"cloudCover":51,"precipitationChance":0,"temperatureMin":9.46,"cloudCoverMidAltPct":26,"precipitationIntensityMax":0.0,"conditionCode":["PARTLY_CLOUDY"],"cloudCoverHighAltPct":50,"humidityMin":44,"visibilityMin":19540.0,"precipitationType":["CLEAR"],"windSpeedMax":23.205599},"conditionCode":["DRIZZLE"],"humidityMin":44,"solarNoon":1794973450,"visibilityMin":7687.0,"overnightForecast":{"humidity":51,"daylight":false,"forecastEnd":1795042800,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":9.46,"uvIndexMin":0,"temperatureApparentMin":3.700094,"windGustSpeedMax":30.510002,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":0,"temperatureApparentMax":9.349154,"forecastStart":1794999600,"windDirection":327,"windSpeed":9.3105,"humidityMax":53,"precipitationAmountByType":[],"cloudCoverLowAltPct":0,"visibilityMax":22302.0,"cloudCover":26,"precipitationChance":0,"temperatureMin":7.255062,"cloudCoverMidAltPct":4,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":44,"humidityMin":47,"visibilityMin":20816.0,"precipitationType":["CLEAR"],"windSpeedMax":15.1164},"precipitationType":["RAIN"],"sunriseNautical":1794951143,"sunsetCivil":1794994021,"windSpeedMax":23.205599},{"moonrise":1795065789,"moonset":1795019775,"sunrise":1795040869,"sunset":1795078863,"forecastEnd":1795104000,"sunriseCivil":1795039333,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":12.891275,"solarMidnight":1795103081,"windSpeedAvg":4.99455,"temperatureMaxTime":1795069099,"windGustSpeedMax":22.392,"sunriseAstronomical":1795035866,"forecastStart":1795017600,"moonPhase":"WAXING_GIBBOUS","humidityMax":64,"precipitationAmountByType":[],"sunsetAstronomical":1795083865,"visibilityMax":23706.0,"precipitationChance":0,"temperatureMin":7.255062,"maxUvIndex":4,"temperatureMinTime":1795033627,"sunsetNautical":1795082144,"daytimeForecast":{"humidity":42,"daylight":true,"forecastEnd":1795086000,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":12.891275,"uvIndexMin":0,"temperatureApparentMin":9.349154,"windGustSpeedMax":12.2292,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":4,"temperatureApparentMax":15.174844,"forecastStart":1795042800,"windDirection":304,"windSpeed":4.40835,"humidityMax":55,"precipitationAmountByType":[],"cloudCoverLowAltPct":8,"visibilityMax":23706.0,"cloudCover":26,"precipitationChance":0,"temperatureMin":9.16,"cloudCoverMidAltPct":14,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":29,"humidityMin":37,"visibilityMin":20816.0,"precipitationType":["CLEAR"],"windSpeedMax":5.1228},"conditionCode":["MOSTLY_CLEAR"],"humidityMin":37,"solarNoon":1795059863,"visibilityMin":20816.0,"overnightForecast":{"humidity":64,"daylight":false,"forecastEnd":1795129200,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":9.81,"uvIndexMin":0,"temperatureApparentMin":7.323315,"windGustSpeedMax":13.0968,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":0,"temperatureApparentMax":9.450615,"forecastStart":1795086000,"windDirection":254,"windSpeed":3.9546,"humidityMax":69,"precipitationAmountByType":[],"cloudCoverLowAltPct":1,"visibilityMax":22262.0,"cloudCover":25,"precipitationChance":0,"temperatureMin":8.265721,"cloudCoverMidAltPct":7,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":35,"humidityMin":55,"visibilityMin":15087.0,"precipitationType":["CLEAR"],"windSpeedMax":5.1336},"precipitationType":["CLEAR"],"sunriseNautical":1795037588,"sunsetCivil":1795080400,"windSpeedMax":9.9396},{"moonrise":1795153911,"moonset":1795109638,"sunrise":1795127320,"sunset":1795165240,"forecastEnd":1795190400,"sunriseCivil":1795125781,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":17.98996,"solarMidnight":1795189495,"windSpeedAvg":4.918575,"temperatureMaxTime":1795153458,"windGustSpeedMax":17.6364,"sunriseAstronomical":1795122309,"forecastStart":1795104000,"moonPhase":"WAXING_GIBBOUS","humidityMax":72,"precipitationAmountByType":[],"sunsetAstronomical":1795170250,"visibilityMax":23946.0,"precipitationChance":0,"temperatureMin":7.89,"maxUvIndex":4,"temperatureMinTime":1795190400,"sunsetNautical":1795168527,"daytimeForecast":{"humidity":44,"daylight":true,"forecastEnd":1795172400,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":17.98996,"uvIndexMin":0,"temperatureApparentMin":8.861073,"windGustSpeedMax":17.6364,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":4,"temperatureApparentMax":18.810215,"forecastStart":1795129200,"windDirection":316,"windSpeed":5.967,"humidityMax":69,"precipitationAmountByType":[],"cloudCoverLowAltPct":1,"visibilityMax":23946.0,"cloudCover":25,"precipitationChance":0,"temperatureMin":8.28,"cloudCoverMidAltPct":0,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":42,"humidityMin":33,"visibilityMin":12780.0,"precipitationType":["CLEAR"],"windSpeedMax":7.2972},"conditionCode":["MOSTLY_CLEAR"],"humidityMin":33,"solarNoon":1795146277,"visibilityMin":12780.0,"overnightForecast":{"humidity":70,"daylight":false,"forecastEnd":1795215600,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":12.99,"uvIndexMin":0,"temperatureApparentMin":6.877808,"windGustSpeedMax":9.0792,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":0,"temperatureApparentMax":11.841161,"forecastStart":1795172400,"windDirection":262,"windSpeed":3.2652,"humidityMax":78,"precipitationAmountByType":[],"cloudCoverLowAltPct":0,"visibilityMax":21599.0,"cloudCover":14,"precipitationChance":0,"temperatureMin":7.439852,"cloudCoverMidAltPct":1,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":3,"humidityMin":52,"visibilityMin":16929.0,"precipitationType":["CLEAR"],"windSpeedMax":4.3056},"precipitationType":["CLEAR"],"sunriseNautical":1795124033,"sunsetCivil":1795166780,"windSpeedMax":7.2972},{"moonrise":1795242146,"moonset":1795199632,"sunrise":1795213770,"sunset":1795251619,"forecastEnd":1795276800,"sunriseCivil":1795212229,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":14.780753,"solarMidnight":1795275909,"windSpeedAvg":4.989,"temperatureMaxTime":1795243174,"windGustSpeedMax":16.621201,"sunriseAstronomical":1795208752,"forecastStart":1795190400,"moonPhase":"WAXING_GIBBOUS","humidityMax":78,"precipitationAmountByType":[],"sunsetAstronomical":1795256636,"visibilityMax":23325.0,"precipitationChance":0,"temperatureMin":7.439852,"maxUvIndex":4,"temperatureMinTime":1795197707,"sunsetNautical":1795254911,"daytimeForecast":{"humidity":59,"daylight":true,"forecastEnd":1795258800,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":14.780753,"uvIndexMin":0,"temperatureApparentMin":8.329831,"windGustSpeedMax":16.621201,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":4,"temperatureApparentMax":16.340837,"forecastStart":1795215600,"windDirection":102,"windSpeed":5.97705,"humidityMax":78,"precipitationAmountByType":[],"cloudCoverLowAltPct":0,"visibilityMax":23325.0,"cloudCover":34,"precipitationChance":0,"temperatureMin":7.769081,"cloudCoverMidAltPct":14,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":0,"humidityMin":47,"visibilityMin":16929.0,"precipitationType":["CLEAR"],"windSpeedMax":6.7788},"conditionCode":["MOSTLY_CLEAR"],"humidityMin":47,"solarNoon":1795232692,"visibilityMin":16929.0,"overnightForecast":{"humidity":74,"daylight":false,"forecastEnd":1795302000,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":14.28,"uvIndexMin":0,"temperatureApparentMin":8.172373,"windGustSpeedMax":11.3004,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":0,"temperatureApparentMax":13.514278,"forecastStart":1795258800,"windDirection":7,"windSpeed":3.56895,"humidityMax":82,"precipitationAmountByType":[],"cloudCoverLowAltPct":0,"visibilityMax":20158.0,"cloudCover":32,"precipitationChance":0,"temperatureMin":8.565985,"cloudCoverMidAltPct":23,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLEAR"],"cloudCoverHighAltPct":0,"humidityMin":57,"visibilityMin":15954.0,"precipitationType":["CLEAR"],"windSpeedMax":5.5764},"precipitationType":["CLEAR"],"sunriseNautical":1795210478,"sunsetCivil":1795253161,"windSpeedMax":6.7788},{"moonrise":1795330591,"moonset":1795289830,"sunrise":1795300221,"sunset":1795338000,"forecastEnd":1795363200,"sunriseCivil":1795298676,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.560213,"solarMidnight":1795362325,"windSpeedAvg":5.3463,"temperatureMaxTime":1795327302,"windGustSpeedMax":20.916,"sunriseAstronomical":1795295195,"forecastStart":1795276800,"moonPhase":"WAXING_GIBBOUS","humidityMax":89,"precipitationAmountByType":[{"expected":0.703,"minimum":0.703,"maximum":0.703,"precipitationType":["RAIN"]}],"sunsetAstronomical":1795343024,"visibilityMax":21599.0,"precipitationChance":11,"temperatureMin":8.565985,"maxUvIndex":3,"temperatureMinTime":1795285929,"sunsetNautical":1795341297,"daytimeForecast":{"humidity":53,"daylight":true,"forecastEnd":1795345200,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.560213,"uvIndexMin":0,"temperatureApparentMin":9.880361,"windGustSpeedMax":20.916,"perceivedPrecipitationIntensityMax":0.0,"uvIndexMax":3,"temperatureApparentMax":18.62365,"forecastStart":1795302000,"windDirection":6,"windSpeed":7.1967,"humidityMax":82,"precipitationAmountByType":[],"cloudCoverLowAltPct":0,"visibilityMax":21599.0,"cloudCover":74,"precipitationChance":0,"temperatureMin":8.84,"cloudCoverMidAltPct":25,"precipitationIntensityMax":0.0,"conditionCode":["MOSTLY_CLOUDY"],"cloudCoverHighAltPct":5,"humidityMin":42,"visibilityMin":15954.0,"precipitationType":["CLEAR"],"windSpeedMax":10.3716},"conditionCode":["MOSTLY_CLOUDY"],"humidityMin":42,"solarNoon":1795319107,"visibilityMin":11352.0,"overnightForecast":{"humidity":80,"daylight":false,"forecastEnd":1795388400,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":15.23,"uvIndexMin":0,"temperatureApparentMin":9.337468,"windGustSpeedMax":11.779201,"perceivedPrecipitationIntensityMax":0.901,"uvIndexMax":0,"temperatureApparentMax":14.363777,"forecastStart":1795345200,"windDirection":343,"windSpeed":4.0209,"humidityMax":89,"precipitationAmountByType":[{"expected":4.412,"minimum":4.412,"maximum":4.412,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":0,"visibilityMax":19948.0,"cloudCover":82,"precipitationChance":25,"temperatureMin":8.858445,"cloudCoverMidAltPct":82,"precipitationIntensityMax":0.705,"conditionCode":["MOSTLY_CLOUDY"],"cloudCoverHighAltPct":0,"humidityMin":56,"visibilityMin":8248.0,"precipitationType":["RAIN"],"windSpeedMax":6.5952},"precipitationType":["RAIN"],"sunriseNautical":1795296924,"sunsetCivil":1795339545,"windSpeedMax":10.3716}]},"forecastHourly":{"metadata":{"latitude":0,"longitude":0,"attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"expireTime":1794573006,"temporarilyUnavailable":false,"sourceType":"MODELED","reportedTime":1794567934},"hours":[{"daylight":false,"humidity":80,"pressure":1020.090027,"temperature":19.43,"visibility":15751.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":2,"perceivedPrecipitationIntensity":0.0,"precipitationIntensity":0.0,"temperatureDewPoint":15.889755,"windGust":4.0824,"temperatureApparent":20.595747,"cloudCover":96,"precipitationChance":0,"cloudCoverMidAltPct":94,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":94,"precipitationType":["CLEAR"],"precipitationAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"forecastStart":1794567600,"pressureTrend":"RISING","windDirection":137,"uvIndex":0,"windSpeed":2.844,"snowfallIntensity":0.0},{"daylight":false,"humidity":84,"pressure":1019.820007,"temperature":18.84,"visibility":16314.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":1,"perceivedPrecipitationIntensity":0.0,"precipitationIntensity":0.0,"temperatureDewPoint":16.078018,"windGust":2.7972,"temperatureApparent":20.008615,"cloudCover":94,"precipitationChance":0,"cloudCoverMidAltPct":92,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":73,"precipitationType":["CLEAR"],"precipitationAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"forecastStart":1794571200,"pressureTrend":"STEADY","windDirection":96,"uvIndex":0,"windSpeed":2.7972,"snowfallIntensity":0.0},{"daylight":false,"humidity":84,"pressure":1020.140015,"temperature":18.799999,"visibility":10747.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":8,"perceivedPrecipitationIntensity":0.608,"precipitationIntensity":0.291,"temperatureDewPoint":16.038864,"windGust":4.9716,"temperatureApparent":19.944382,"cloudCover":98,"precipitationChance":25,"cloudCoverMidAltPct":96,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":88,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.291,"minimum":0.291,"maximum":0.291},"forecastStart":1794574800,"pressureTrend":"STEADY","windDirection":141,"uvIndex":0,"windSpeed":3.294,"snowfallIntensity":0.0},{"daylight":false,"humidity":84,"pressure":1019.76001,"temperature":18.709999,"visibility":10597.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":20,"perceivedPrecipitationIntensity":0.382,"precipitationIntensity":0.109,"temperatureDewPoint":15.950729,"windGust":2.4372,"temperatureApparent":19.976105,"cloudCover":97,"precipitationChance":6,"cloudCoverMidAltPct":92,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":94,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.109,"minimum":0.109,"maximum":0.109},"forecastStart":1794578400,"pressureTrend":"STEADY","windDirection":155,"uvIndex":0,"windSpeed":2.4372,"snowfallIntensity":0.0},{"daylight":false,"humidity":84,"pressure":1019.530029,"temperature":18.620001,"visibility":14656.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":26,"perceivedPrecipitationIntensity":0.0,"precipitationIntensity":0.0,"temperatureDewPoint":15.862625,"windGust":2.1492,"temperatureApparent":20.0208,"cloudCover":100,"precipitationChance":0,"cloudCoverMidAltPct":100,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":98,"precipitationType":["CLEAR"],"precipitationAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"forecastStart":1794582000,"pressureTrend":"STEADY","windDirection":84,"uvIndex":0,"windSpeed":1.6092,"snowfallIntensity":0.0},{"daylight":false,"humidity":85,"pressure":1018.830017,"temperature":18.43,"visibility":13339.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":36,"perceivedPrecipitationIntensity":0.492,"precipitationIntensity":0.185,"temperatureDewPoint":15.861649,"windGust":3.492,"temperatureApparent":19.723528,"cloudCover":98,"precipitationChance":44,"cloudCoverMidAltPct":90,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":97,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.185,"minimum":0.185,"maximum":0.185},"forecastStart":1794585600,"pressureTrend":"FALLING","windDirection":45,"uvIndex":0,"windSpeed":2.3652,"snowfallIntensity":0.0},{"daylight":false,"humidity":85,"pressure":1018.440002,"temperature":18.42,"visibility":6740.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":38,"perceivedPrecipitationIntensity":0.796,"precipitationIntensity":0.53,"temperatureDewPoint":15.851852,"windGust":4.2948,"temperatureApparent":19.634636,"cloudCover":100,"precipitationChance":61,"cloudCoverMidAltPct":98,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.53,"minimum":0.53,"maximum":0.53},"forecastStart":1794589200,"pressureTrend":"FALLING","windDirection":311,"uvIndex":0,"windSpeed":3.06,"snowfallIntensity":0.0},{"daylight":false,"humidity":86,"pressure":1017.809998,"temperature":18.25,"visibility":5734.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":61,"perceivedPrecipitationIntensity":1.135,"precipitationIntensity":1.225,"temperatureDewPoint":15.868057,"windGust":5.4144,"temperatureApparent":19.452429,"cloudCover":100,"precipitationChance":74,"cloudCoverMidAltPct":98,"conditionCode":["RAIN"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":1.225,"minimum":1.225,"maximum":1.225},"forecastStart":1794592800,"pressureTrend":"FALLING","windDirection":317,"uvIndex":0,"windSpeed":3.1896,"snowfallIntensity":0.0},{"daylight":false,"humidity":86,"pressure":1017.450012,"temperature":18.23,"visibility":4861.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":63,"perceivedPrecipitationIntensity":1.168,"precipitationIntensity":1.315,"temperatureDewPoint":15.848434,"windGust":4.05,"temperatureApparent":19.313406,"cloudCover":100,"precipitationChance":78,"cloudCoverMidAltPct":97,"conditionCode":["RAIN"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":1.315,"minimum":1.315,"maximum":1.315},"forecastStart":1794596400,"pressureTrend":"FALLING","windDirection":296,"uvIndex":0,"windSpeed":3.8376,"snowfallIntensity":0.0},{"daylight":false,"humidity":85,"pressure":1017.02002,"temperature":18.5,"visibility":9299.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":82,"perceivedPrecipitationIntensity":1.049,"precipitationIntensity":1.009,"temperatureDewPoint":15.930283,"windGust":6.426,"temperatureApparent":19.531525,"cloudCover":100,"precipitationChance":76,"cloudCoverMidAltPct":98,"conditionCode":["RAIN"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":1.009,"minimum":1.009,"maximum":1.009},"forecastStart":1794600000,"pressureTrend":"FALLING","windDirection":312,"uvIndex":0,"windSpeed":4.0608,"snowfallIntensity":0.0},{"daylight":false,"humidity":84,"pressure":1017.219971,"temperature":18.58,"visibility":9277.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":93,"perceivedPrecipitationIntensity":1.189,"precipitationIntensity":1.376,"temperatureDewPoint":15.823471,"windGust":8.5572,"temperatureApparent":19.657303,"cloudCover":100,"precipitationChance":75,"cloudCoverMidAltPct":95,"conditionCode":["RAIN"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":1.376,"minimum":1.376,"maximum":1.376},"forecastStart":1794603600,"pressureTrend":"STEADY","windDirection":294,"uvIndex":0,"windSpeed":3.8052,"snowfallIntensity":0.0},{"daylight":false,"humidity":86,"pressure":1017.190002,"temperature":18.190001,"visibility":10314.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":75,"perceivedPrecipitationIntensity":0.641,"precipitationIntensity":0.327,"temperatureDewPoint":15.809158,"windGust":6.9408,"temperatureApparent":19.273407,"cloudCover":100,"precipitationChance":67,"cloudCoverMidAltPct":90,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.327,"minimum":0.327,"maximum":0.327},"forecastStart":1794607200,"pressureTrend":"STEADY","windDirection":345,"uvIndex":0,"windSpeed":3.8304,"snowfallIntensity":0.0},{"daylight":true,"humidity":86,"pressure":1017.469971,"temperature":18.139999,"visibility":10244.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":69,"perceivedPrecipitationIntensity":0.758,"precipitationIntensity":0.475,"temperatureDewPoint":15.760056,"windGust":6.678,"temperatureApparent":19.020458,"cloudCover":100,"precipitationChance":64,"cloudCoverMidAltPct":90,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.475,"minimum":0.475,"maximum":0.475},"forecastStart":1794610800,"pressureTrend":"STEADY","windDirection":267,"uvIndex":0,"windSpeed":3.0888,"snowfallIntensity":0.0},{"daylight":true,"humidity":86,"pressure":1017.77002,"temperature":18.25,"visibility":10016.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":61,"perceivedPrecipitationIntensity":0.929,"precipitationIntensity":0.756,"temperatureDewPoint":15.868057,"windGust":9.2916,"temperatureApparent":18.76442,"cloudCover":98,"precipitationChance":62,"cloudCoverMidAltPct":87,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":97,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.756,"minimum":0.756,"maximum":0.756},"forecastStart":1794614400,"pressureTrend":"STEADY","windDirection":321,"uvIndex":1,"windSpeed":3.42,"snowfallIntensity":0.0},{"daylight":true,"humidity":85,"pressure":1018.090027,"temperature":18.440001,"visibility":9793.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":61,"perceivedPrecipitationIntensity":1.004,"precipitationIntensity":0.908,"temperatureDewPoint":15.871475,"windGust":8.899199,"temperatureApparent":18.646,"cloudCover":100,"precipitationChance":60,"cloudCoverMidAltPct":95,"conditionCode":["RAIN"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.908,"minimum":0.908,"maximum":0.908},"forecastStart":1794618000,"pressureTrend":"RISING","windDirection":327,"uvIndex":2,"windSpeed":4.338,"snowfallIntensity":0.0},{"daylight":true,"humidity":83,"pressure":1017.890015,"temperature":18.93,"visibility":11005.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":62,"perceivedPrecipitationIntensity":0.782,"precipitationIntensity":0.509,"temperatureDewPoint":15.978439,"windGust":10.3932,"temperatureApparent":18.786564,"cloudCover":100,"precipitationChance":55,"cloudCoverMidAltPct":100,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.509,"minimum":0.509,"maximum":0.509},"forecastStart":1794621600,"pressureTrend":"STEADY","windDirection":335,"uvIndex":3,"windSpeed":5.904,"snowfallIntensity":0.0},{"daylight":true,"humidity":82,"pressure":1017.369995,"temperature":19.190001,"visibility":12224.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":78,"perceivedPrecipitationIntensity":0.467,"precipitationIntensity":0.166,"temperatureDewPoint":16.042526,"windGust":8.6904,"temperatureApparent":19.263245,"cloudCover":100,"precipitationChance":54,"cloudCoverMidAltPct":95,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.166,"minimum":0.166,"maximum":0.166},"forecastStart":1794625200,"pressureTrend":"STEADY","windDirection":335,"uvIndex":3,"windSpeed":5.0472,"snowfallIntensity":0.0},{"daylight":true,"humidity":80,"pressure":1016.469971,"temperature":19.74,"visibility":17514.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":84,"perceivedPrecipitationIntensity":0.0,"precipitationIntensity":0.0,"temperatureDewPoint":16.191422,"windGust":10.166399,"temperatureApparent":20.002459,"cloudCover":100,"precipitationChance":0,"cloudCoverMidAltPct":89,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":100,"precipitationType":["CLEAR"],"precipitationAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"forecastStart":1794628800,"pressureTrend":"FALLING","windDirection":6,"uvIndex":3,"windSpeed":4.1148,"snowfallIntensity":0.0},{"daylight":true,"humidity":77,"pressure":1015.669983,"temperature":20.35,"visibility":19293.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":87,"perceivedPrecipitationIntensity":0.0,"precipitationIntensity":0.0,"temperatureDewPoint":16.184067,"windGust":11.739599,"temperatureApparent":20.444609,"cloudCover":100,"precipitationChance":0,"cloudCoverMidAltPct":91,"conditionCode":["CLOUDY"],"cloudCoverHighAltPct":100,"precipitationType":["CLEAR"],"precipitationAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"forecastStart":1794632400,"pressureTrend":"FALLING","windDirection":9,"uvIndex":3,"windSpeed":4.9464,"snowfallIntensity":0.0},{"daylight":true,"humidity":77,"pressure":1015.049988,"temperature":20.299999,"visibility":18453.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":77,"perceivedPrecipitationIntensity":0.343,"precipitationIntensity":0.087,"temperatureDewPoint":16.135635,"windGust":13.7844,"temperatureApparent":20.213022,"cloudCover":100,"precipitationChance":46,"cloudCoverMidAltPct":78,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.087,"minimum":0.087,"maximum":0.087},"forecastStart":1794636000,"pressureTrend":"FALLING","windDirection":9,"uvIndex":2,"windSpeed":5.7816,"snowfallIntensity":0.0},{"daylight":true,"humidity":80,"pressure":1014.969971,"temperature":19.610001,"visibility":20319.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":81,"perceivedPrecipitationIntensity":0.138,"precipitationIntensity":0.014,"temperatureDewPoint":16.064926,"windGust":16.588799,"temperatureApparent":19.245304,"cloudCover":100,"precipitationChance":39,"cloudCoverMidAltPct":77,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":98,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.014,"minimum":0.014,"maximum":0.014},"forecastStart":1794639600,"pressureTrend":"FALLING","windDirection":20,"uvIndex":1,"windSpeed":7.1136,"snowfallIntensity":0.0},{"daylight":true,"humidity":83,"pressure":1014.900024,"temperature":19.0,"visibility":19439.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":71,"perceivedPrecipitationIntensity":0.379,"precipitationIntensity":0.107,"temperatureDewPoint":16.04686,"windGust":16.74,"temperatureApparent":18.782953,"cloudCover":98,"precipitationChance":37,"cloudCoverMidAltPct":74,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":43,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.107,"minimum":0.107,"maximum":0.107},"forecastStart":1794643200,"pressureTrend":"FALLING","windDirection":21,"uvIndex":0,"windSpeed":7.3512,"snowfallIntensity":0.0},{"daylight":true,"humidity":85,"pressure":1015.280029,"temperature":18.5,"visibility":16217.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":70,"perceivedPrecipitationIntensity":0.138,"precipitationIntensity":0.014,"temperatureDewPoint":15.930283,"windGust":14.3244,"temperatureApparent":19.127232,"cloudCover":95,"precipitationChance":32,"cloudCoverMidAltPct":68,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":26,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.014,"minimum":0.014,"maximum":0.014},"forecastStart":1794646800,"pressureTrend":"STEADY","windDirection":26,"uvIndex":0,"windSpeed":5.7384,"snowfallIntensity":0.0},{"daylight":false,"humidity":84,"pressure":1015.840027,"temperature":18.540001,"visibility":13897.0,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"cloudCoverLowAltPct":70,"perceivedPrecipitationIntensity":0.41,"precipitationIntensity":0.126,"temperatureDewPoint":15.784317,"windGust":13.32,"temperatureApparent":19.140728,"cloudCover":84,"precipitationChance":32,"cloudCoverMidAltPct":59,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":42,"precipitationType":["RAIN"],"precipitationAmount":{"expected":0.126,"minimum":0.126,"maximum":0.126},"forecastStart":1794650400,"pressureTrend":"RISING","windDirection":18,"uvIndex":0,"windSpeed":5.166,"snowfallIntensity":0.0}]},"forecastPeriodic":{"metadata":{"latitude":0,"longitude":0,"attributionUrl":"https://developer.apple.com/weatherkit/data-source-attribution-internal/","readTime":1794569406,"expireTime":1794573006,"temporarilyUnavailable":false,"sourceType":"MODELED","reportedTime":1794567934},"forecasts":[{"periods":[{"humidity":84,"daylight":false,"forecastEnd":1794582000,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.432705,"uvIndexMin":0,"temperatureApparentMin":19.937618,"windGustSpeedMax":4.9716,"perceivedPrecipitationIntensityMax":0.608,"uvIndexMax":0,"temperatureApparentMax":20.606981,"forecastStart":1794567600,"windDirection":128,"windSpeed":2.68875,"humidityMax":84,"precipitationAmountByType":[{"expected":0.4,"minimum":0.4,"maximum":0.4,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":11,"visibilityMax":16314.0,"cloudCover":97,"precipitationChance":30,"temperatureMin":18.620001,"cloudCoverMidAltPct":94,"precipitationIntensityMax":0.291,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":88,"humidityMin":80,"visibilityMin":10597.0,"precipitationType":["RAIN"],"windSpeedMax":3.294},{"humidity":85,"daylight":false,"forecastEnd":1794596400,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":18.620001,"uvIndexMin":0,"temperatureApparentMin":19.312206,"windGustSpeedMax":5.4144,"perceivedPrecipitationIntensityMax":1.168,"uvIndexMax":0,"temperatureApparentMax":20.0208,"forecastStart":1794582000,"windDirection":332,"windSpeed":2.83455,"humidityMax":86,"precipitationAmountByType":[{"expected":1.94,"minimum":1.94,"maximum":1.94,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":45,"visibilityMax":14656.0,"cloudCover":100,"precipitationChance":78,"temperatureMin":18.209585,"cloudCoverMidAltPct":96,"precipitationIntensityMax":1.315,"conditionCode":["RAIN"],"cloudCoverHighAltPct":99,"humidityMin":84,"visibilityMin":4861.0,"precipitationType":["RAIN"],"windSpeedMax":3.8376},{"humidity":85,"daylight":false,"forecastEnd":1794610800,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":18.597536,"uvIndexMin":0,"temperatureApparentMin":19.020458,"windGustSpeedMax":8.5572,"perceivedPrecipitationIntensityMax":1.189,"uvIndexMax":0,"temperatureApparentMax":19.666948,"forecastStart":1794596400,"windDirection":309,"windSpeed":3.7899,"humidityMax":86,"precipitationAmountByType":[{"expected":4.027,"minimum":4.027,"maximum":4.027,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":79,"visibilityMax":10314.0,"cloudCover":100,"precipitationChance":90,"temperatureMin":18.13209,"cloudCoverMidAltPct":94,"precipitationIntensityMax":1.376,"conditionCode":["RAIN"],"cloudCoverHighAltPct":100,"humidityMin":84,"visibilityMin":4861.0,"precipitationType":["RAIN"],"windSpeedMax":4.0608},{"humidity":85,"daylight":true,"forecastEnd":1794625200,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.190001,"uvIndexMin":0,"temperatureApparentMin":18.645838,"windGustSpeedMax":10.3932,"perceivedPrecipitationIntensityMax":1.004,"uvIndexMax":3,"temperatureApparentMax":19.263245,"forecastStart":1794610800,"windDirection":325,"windSpeed":4.432499,"humidityMax":86,"precipitationAmountByType":[{"expected":2.648,"minimum":2.648,"maximum":2.648,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":64,"visibilityMax":12224.0,"cloudCover":100,"precipitationChance":84,"temperatureMin":18.139999,"cloudCoverMidAltPct":94,"precipitationIntensityMax":0.908,"conditionCode":["RAIN"],"cloudCoverHighAltPct":99,"humidityMin":82,"visibilityMin":9793.0,"precipitationType":["RAIN"],"windSpeedMax":5.904},{"humidity":79,"daylight":true,"forecastEnd":1794639600,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":20.408365,"uvIndexMin":1,"temperatureApparentMin":19.245304,"windGustSpeedMax":16.588799,"perceivedPrecipitationIntensityMax":0.467,"uvIndexMax":3,"temperatureApparentMax":20.453522,"forecastStart":1794625200,"windDirection":6,"windSpeed":5.2308,"humidityMax":82,"precipitationAmountByType":[{"expected":0.253,"minimum":0.253,"maximum":0.253,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":82,"visibilityMax":20319.0,"cloudCover":100,"precipitationChance":69,"temperatureMin":19.190001,"cloudCoverMidAltPct":86,"precipitationIntensityMax":0.166,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":100,"humidityMin":77,"visibilityMin":12224.0,"precipitationType":["RAIN"],"windSpeedMax":7.1136},{"humidity":84,"daylight":true,"forecastEnd":1794654000,"snowfallAmount":{"expected":0.0,"minimum":0.0,"maximum":0.0},"temperatureMax":19.610001,"uvIndexMin":0,"temperatureApparentMin":18.308884,"windGustSpeedMax":16.74,"perceivedPrecipitationIntensityMax":0.41,"uvIndexMax":1,"temperatureApparentMax":19.245304,"forecastStart":1794639600,"windDirection":21,"windSpeed":6.08895,"humidityMax":88,"precipitationAmountByType":[{"expected":0.261,"minimum":0.261,"maximum":0.261,"precipitationType":["RAIN"]}],"cloudCoverLowAltPct":73,"visibilityMax":20319.0,"cloudCover":93,"precipitationChance":59,"temperatureMin":17.6,"cloudCoverMidAltPct":66,"precipitationIntensityMax":0.126,"conditionCode":["DRIZZLE"],"cloudCoverHighAltPct":48,"humidityMin":80,"visibilityMin":13897.0,"precipitationType":["RAIN"],"windSpeedMax":7.3512}],"periodLength":4}]}} diff --git a/tests/fixtures/wk3-paired/hangzhou/manifest.json b/tests/fixtures/wk3-paired/hangzhou/manifest.json new file mode 100644 index 00000000..6afe3367 --- /dev/null +++ b/tests/fixtures/wk3-paired/hangzhou/manifest.json @@ -0,0 +1,82 @@ +{ + "schema": "lib-rc-3.0.0-b66", + "public_test_location": "Hangzhou city centre (not device location)", + "responses": [ + { + "file": "apple.json", + "started": 1794569406.314852, + "finished": 1794569406.575212, + "status": 200, + "content_type": "application/json", + "bytes": 52693 + }, + { + "file": "apple.bin", + "started": 1794569406.314922, + "finished": 1794569406.559099, + "status": 200, + "content_type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather", + "bytes": 11332 + } + ], + "sanitization": { + "coordinate_fields": [ + { + "root_slot": 0, + "latitude_offset": 11208, + "longitude_offset": 11204 + }, + { + "root_slot": 1, + "latitude_offset": 10860, + "longitude_offset": 10856 + }, + { + "root_slot": 2, + "latitude_offset": 10376, + "longitude_offset": 10372 + }, + { + "root_slot": 3, + "latitude_offset": 5028, + "longitude_offset": 5024 + }, + { + "root_slot": 4, + "latitude_offset": 1832, + "longitude_offset": 1828 + }, + { + "root_slot": 6, + "latitude_offset": 184, + "longitude_offset": 180 + }, + { + "root_slot": 7, + "latitude_offset": 420, + "longitude_offset": 416 + }, + { + "root_slot": 9, + "latitude_offset": 572, + "longitude_offset": 568 + }, + { + "root_slot": 14, + "latitude_offset": 704, + "longitude_offset": 700 + } + ], + "coordinate_urls": 1, + "method": "Only vtable-validated Metadata latitude/longitude floats and same-length coordinate URL digits replaced. No re-encoding." + }, + "sha256": { + "apple.json": "47f2bdeaad2495dcfb2619ea5e7e379e3071c4f9f69fdb6aa417be1acc9f32a2", + "apple.bin": "59ea8fe207345aad6c8ccb04f22b49b648fce0fa2566a7aed8aa035e875ed0a4" + }, + "schema_source": { + "weatherkit_dev": "cd9d7927094aff79906fe3430f339f39970ca46e", + "protocol_tag": "v2.0.5", + "protocol_commit": "c87c1dcd851ea9293141bcfb32fde1b2cca564b9" + } +} diff --git a/tests/weatherKit3.test.mjs b/tests/weatherKit3.test.mjs index 92b7295b..295ecc8c 100644 --- a/tests/weatherKit3.test.mjs +++ b/tests/weatherKit3.test.mjs @@ -143,7 +143,7 @@ test("missing AQ can use the existing provider fill without touching other roots test("unsupported provider enums or out-of-range scalars do not replace AQ", async t => { mockProvider(t); - for (const patch of [{ primaryPollutant: "UNVERIFIED" }, { index: 40000 }, { index: NaN }]) { + for (const patch of [{ primaryPollutant: "UNVERIFIED" }, { index: 65536 }, { index: NaN }]) { t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ ...structuredClone(replacement), ...patch })); assert.deepEqual((await response()).body, fixture); } @@ -255,7 +255,7 @@ function buildWK3Alerts(builder, detailsUrl = "https://www.qweather.com/severe-w ), ); const responses = WK3.WeatherAlertSummary.createResponsesVector(builder, [7]); - const alert = WK3.WeatherAlertSummary.createWeatherAlertSummary(builder, id, builder.createString("101010100"), 0, builder.createString("CN"), builder.createString("大风蓝色预警"), 0, 2000000000, 2000003600, 2000000000, 2000000000, 2000003600, 0, 0, 4, 10, 0, 0, 2, 2, 2, responses); + const alert = WK3.WeatherAlertSummary.createWeatherAlertSummary(builder, id, builder.createString("101010100"), 0, 0, builder.createString("CN"), builder.createString("大风蓝色预警"), 0, 2000000000, 2000003600, 2000000000, 2000000000, 2000003600, 0, 0, 4, 10, 0, 0, 2, 2, 2, responses, 0, 0); const alerts = WK3.WeatherAlertCollectionData.createAlertsVector(builder, [alert]); return WK3.WeatherAlertCollectionData.createWeatherAlertCollectionData(builder, buildWK3AlertsMetadata(builder), builder.createString(detailsUrl), alerts); } @@ -398,7 +398,7 @@ test("Summary generates condition shape by target version at creation", async () assert.deepEqual(ForecastNextHour.Summary(minutes, "v3")[0].condition, ["RAIN"]); }); -test("WK3 rejects multi-element summary condition arrays on injection", async t => { +test("WK3 writes multi-element UInt8 summary condition vectors", async t => { t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => structuredClone(replacement)); t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", async () => ({ ...structuredClone(replacement), index: 25, categoryIndex: 1 })); t.mock.method(ColorfulClouds.prototype, "Minutely", async () => ({ @@ -407,7 +407,8 @@ test("WK3 rejects multi-element summary condition arrays on injection", async t })); const output = (await response(buildWK3([[0, buildWK3AirQuality]]))).body; const weather = WK3.Weather.getRootAsWeather(new ByteBuffer(output)); - // 多元素向量的元素宽度未验证:注入被隔离,其余产品不受影响 / Multi-element vectors are unverified: injection is isolated while other products proceed - assert.equal(weather.forecastNextHour(), null); + const summary = weather.forecastNextHour().summary(0); + assert.equal(summary.conditionLength(), 2); + assert.deepEqual([summary.condition(0), summary.condition(1)], [2, 3]); assert.equal(weather.airQuality().index(), 175); }); diff --git a/tests/weatherKit3WeatherReplacement.test.mjs b/tests/weatherKit3WeatherReplacement.test.mjs new file mode 100644 index 00000000..9a585971 --- /dev/null +++ b/tests/weatherKit3WeatherReplacement.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test, { beforeEach } from "node:test"; +import { ByteBuffer } from "flatbuffers"; +import { WK3 } from "@nsringo/weatherkit"; + +globalThis.$environment = { "surge-version": "test" }; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { LogLevel: "OFF", Storage: "Argument" }; +const [{ Response }, { Request }, { default: ColorfulClouds }] = await Promise.all([ + import("../src/process/Response.dev.mjs"), import("../src/process/Request.dev.mjs"), import("../src/class/ColorfulClouds.mjs"), +]); +const source = new Uint8Array(readFileSync(new URL("./fixtures/wk3-paired/hangzhou/apple.bin", import.meta.url))); +const original = WK3.Codec.decode("Weather", WK3.Weather.getRootAsWeather(new ByteBuffer(source))); +const metadata = { providerName: "Controlled provider", sourceType: "MODELED", temporarilyUnavailable: false, latitude: "30", longitude: "120" }; +const request = () => ({ method: "GET", url: "https://weatherkit.apple.com/api/v3/weather/en-US/30/120?country=CN&dataSets=currentWeather,forecastDaily,forecastHourly,weatherAlerts", headers: { Accept: "application/vnd.apple.flatbuffer;messageType=WK3.Weather", "User-Agent": "WeatherWidget" } }); +const response = (body = source) => Response(request(), { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, bodyBytes: body }); +function pointer(bytes, slot) { const b = new ByteBuffer(bytes), root = b.readUint32(0), off = b.__offset(root, 4 + 2 * slot); return off ? b.__indirect(root + off) : 0; } +beforeEach(() => { globalThis.$request = request(); globalThis.$argument = { LogLevel: "OFF", Storage: "Argument", DataSets: "currentWeather,forecastDaily,forecastHourly", Weather: { Replace: "CN", Provider: "ColorfulClouds" } }; }); +function mockWeather(t) { + t.mock.method(ColorfulClouds.prototype, "CurrentWeather", async () => ({ metadata, temperature: 27.125, conditionCode: "RAIN" })); + t.mock.method(ColorfulClouds.prototype, "Daily", async () => ({ metadata, days: [{ forecastStart: original.forecastDaily.days[0].forecastStart, temperatureMax: 31.25, conditionCode: "SNOW", daytimeForecast: { cloudCover: 80 } }] })); + t.mock.method(ColorfulClouds.prototype, "ForecastHourly", async () => ({ metadata, hours: [{ forecastStart: original.forecastHourly.hours[0].forecastStart, temperature: 24.5, conditionCode: "RAIN", precipitationAmount: 2.5 }] })); +} + +test("WK3 FlatBuffer requests stay binary and preserve every unrelated root arena", async t => { + mockWeather(t); + const req = (await Request(request())).$request; + assert.equal(req.headers.Accept, "application/vnd.apple.flatbuffer;messageType=WK3.Weather"); + const output = (await response()).body; + assert.ok(output instanceof Uint8Array); + const body = WK3.Codec.decode("Weather", WK3.Weather.getRootAsWeather(new ByteBuffer(output))); + assert.equal(body.currentWeather.temperature, 27.125); + assert.deepEqual(body.currentWeather.conditionCode, ["RAIN"]); + assert.equal(body.forecastDaily.days[0].temperatureMax, 31.25); + assert.deepEqual(body.forecastDaily.days[0].conditionCode, ["SNOW"]); + assert.equal(body.forecastDaily.days[0].daytimeForecast.cloudCover, 80); + assert.equal(body.forecastHourly.hours[0].temperature, 24.5); + assert.deepEqual(body.forecastHourly.hours[0].precipitationAmount, { expected: 2.5, minimum: 2.5, maximum: 2.5 }); + const shift = pointer(output, 9) - pointer(source, 9); + assert.deepEqual(output.subarray(shift, shift + source.length), source); + for (const slot of [0, 4, 6, 7, 9, 14]) assert.equal(pointer(output, slot) - shift, pointer(source, slot)); +}); + +test("a failed current-weather provider leaves that arena intact while hourly replacement succeeds", async t => { + mockWeather(t); + t.mock.method(ColorfulClouds.prototype, "CurrentWeather", async () => { throw new Error("offline"); }); + const output = (await response()).body, shift = pointer(output, 9) - pointer(source, 9); + assert.equal(pointer(output, 1) - shift, pointer(source, 1)); + assert.equal(WK3.Weather.getRootAsWeather(new ByteBuffer(output)).forecastHourly().hours(0).temperature(), 24.5); +}); + +test("a future current-weather field is kept opaque and never reaches the provider", async t => { + mockWeather(t); + const now = t.mock.method(ColorfulClouds.prototype, "CurrentWeather", async () => assert.fail("future product must not be rewritten")); + const bytes = source.slice(), bb = new ByteBuffer(bytes), p = pointer(bytes, 1), vtable = p - bb.readInt32(p); + new DataView(bytes.buffer).setUint16(vtable, 4 + 34 * 2, true); + const output = (await response(bytes)).body, shift = pointer(output, 9) - pointer(bytes, 9); + assert.equal(now.mock.callCount(), 0); + assert.equal(pointer(output, 1) - shift, pointer(bytes, 1)); + assert.deepEqual(output.subarray(shift, shift + bytes.length), bytes); +}); + +test("disabled or native-provider weather stays byte-identical", async t => { + mockWeather(t); + globalThis.$argument.DataSets = "[]"; + assert.deepEqual((await response()).body, source); + globalThis.$argument.DataSets = "currentWeather,forecastDaily,forecastHourly"; + globalThis.$argument.Weather.Provider = "WeatherKit"; + assert.deepEqual((await response()).body, source); +});