mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
Add WeatherKit data source package
Implements @aris/data-source-weatherkit for fetching weather data from Apple WeatherKit REST API. - WeatherKitDataSource class implementing DataSource interface - Feed items: current, hourly, daily, and alerts - Priority adjustment based on weather conditions and alert severity - Unit conversion (metric/imperial) - Response validation with arktype - Test fixtures from real API responses Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
22
bun.lock
22
bun.lock
@@ -14,11 +14,27 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aris-core": {
|
"packages/aris-core": {
|
||||||
"name": "aris-core",
|
"name": "@aris/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/aris-data-source-weatherkit": {
|
||||||
|
"name": "@aris/data-source-weatherkit",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"arktype": "^2.1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||||
|
|
||||||
|
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||||
|
|
||||||
|
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
||||||
|
|
||||||
|
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
|
||||||
|
|
||||||
"@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="],
|
"@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="],
|
||||||
|
|
||||||
"@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="],
|
"@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="],
|
||||||
@@ -55,7 +71,9 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||||
|
|
||||||
"aris-core": ["aris-core@workspace:packages/aris-core"],
|
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
|
||||||
|
|
||||||
|
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||||
|
|
||||||
|
|||||||
4
packages/aris-data-source-weatherkit/.env.example
Normal file
4
packages/aris-data-source-weatherkit/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
WEATHERKIT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||||
|
WEATHERKIT_KEY_ID=ABC123DEFG
|
||||||
|
WEATHERKIT_TEAM_ID=TEAM123456
|
||||||
|
WEATHERKIT_SERVICE_ID=com.example.weatherkit.test
|
||||||
51
packages/aris-data-source-weatherkit/README.md
Normal file
51
packages/aris-data-source-weatherkit/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# @aris/data-source-weatherkit
|
||||||
|
|
||||||
|
Weather data source using Apple WeatherKit REST API.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { WeatherKitDataSource, Units } from "@aris/data-source-weatherkit"
|
||||||
|
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: {
|
||||||
|
privateKey: "-----BEGIN PRIVATE KEY-----\n...",
|
||||||
|
keyId: "ABC123",
|
||||||
|
teamId: "DEF456",
|
||||||
|
serviceId: "com.example.weatherkit",
|
||||||
|
},
|
||||||
|
hourlyLimit: 12, // optional, default: 12
|
||||||
|
dailyLimit: 7, // optional, default: 7
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = await dataSource.query(context, {
|
||||||
|
units: Units.metric, // or Units.imperial
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feed Items
|
||||||
|
|
||||||
|
The data source returns four types of feed items:
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
| ----------------- | -------------------------- |
|
||||||
|
| `weather-current` | Current weather conditions |
|
||||||
|
| `weather-hourly` | Hourly forecast |
|
||||||
|
| `weather-daily` | Daily forecast |
|
||||||
|
| `weather-alert` | Weather alerts |
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
Base priorities are adjusted based on weather conditions:
|
||||||
|
|
||||||
|
- Severe conditions (tornado, hurricane, blizzard, etc.): +0.3
|
||||||
|
- Moderate conditions (thunderstorm, heavy rain, etc.): +0.15
|
||||||
|
- Alert severity: extreme=1.0, severe=0.9, moderate=0.75, minor=0.7
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
WeatherKit requires Apple Developer credentials. Generate a private key in the Apple Developer portal under Certificates, Identifiers & Profiles > Keys.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
API responses are validated using [arktype](https://arktype.io) schemas.
|
||||||
6457
packages/aris-data-source-weatherkit/fixtures/san-francisco.json
Normal file
6457
packages/aris-data-source-weatherkit/fixtures/san-francisco.json
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/aris-data-source-weatherkit/package.json
Normal file
14
packages/aris-data-source-weatherkit/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@aris/data-source-weatherkit",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"arktype": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { fetchWeather } from "../src/weatherkit"
|
||||||
|
|
||||||
|
function loadEnv(): Record<string, string> {
|
||||||
|
const content = require("fs").readFileSync(".env", "utf-8")
|
||||||
|
const env: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
|
||||||
|
const eqIndex = trimmed.indexOf("=")
|
||||||
|
if (eqIndex === -1) continue
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, eqIndex)
|
||||||
|
let value = trimmed.slice(eqIndex + 1)
|
||||||
|
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
env[key] = value.replace(/\\n/g, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadEnv()
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
privateKey: env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
|
keyId: env.WEATHERKIT_KEY_ID!,
|
||||||
|
teamId: env.WEATHERKIT_TEAM_ID!,
|
||||||
|
serviceId: env.WEATHERKIT_SERVICE_ID!,
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = {
|
||||||
|
sanFrancisco: { lat: 37.7749, lng: -122.4194 },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Fetching weather data for San Francisco...")
|
||||||
|
|
||||||
|
const response = await fetchWeather(
|
||||||
|
{ credentials },
|
||||||
|
{ lat: locations.sanFrancisco.lat, lng: locations.sanFrancisco.lng },
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixture = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
location: locations.sanFrancisco,
|
||||||
|
response,
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = JSON.stringify(fixture)
|
||||||
|
await Bun.write("fixtures/san-francisco.json", output)
|
||||||
|
|
||||||
|
console.log("Fixture written to fixtures/san-francisco.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
103
packages/aris-data-source-weatherkit/src/data-source.test.ts
Normal file
103
packages/aris-data-source-weatherkit/src/data-source.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Context } from "@aris/core"
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import fixture from "../fixtures/san-francisco.json"
|
||||||
|
import { WeatherKitDataSource, Units } from "./data-source"
|
||||||
|
import { WeatherFeedItemType } from "./feed-items"
|
||||||
|
|
||||||
|
const mockCredentials = {
|
||||||
|
privateKey: "mock",
|
||||||
|
keyId: "mock",
|
||||||
|
teamId: "mock",
|
||||||
|
serviceId: "mock",
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
||||||
|
time: new Date("2026-01-17T00:00:00Z"),
|
||||||
|
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("WeatherKitDataSource", () => {
|
||||||
|
test("returns empty array when location is missing", async () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
|
||||||
|
const items = await dataSource.query(createMockContext())
|
||||||
|
|
||||||
|
expect(items).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("type is weather-current", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
|
||||||
|
|
||||||
|
expect(dataSource.type).toBe(WeatherFeedItemType.current)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("WeatherKitDataSource with fixture", () => {
|
||||||
|
const response = fixture.response
|
||||||
|
|
||||||
|
test("parses current weather from fixture", () => {
|
||||||
|
const current = response.currentWeather
|
||||||
|
|
||||||
|
expect(current.conditionCode).toBe("Clear")
|
||||||
|
expect(typeof current.temperature).toBe("number")
|
||||||
|
expect(typeof current.humidity).toBe("number")
|
||||||
|
expect(current.pressureTrend).toMatch(/^(rising|falling|steady)$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses hourly forecast from fixture", () => {
|
||||||
|
const hours = response.forecastHourly.hours
|
||||||
|
|
||||||
|
expect(hours.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const firstHour = hours[0]!
|
||||||
|
expect(firstHour.forecastStart).toBeDefined()
|
||||||
|
expect(typeof firstHour.temperature).toBe("number")
|
||||||
|
expect(typeof firstHour.precipitationChance).toBe("number")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses daily forecast from fixture", () => {
|
||||||
|
const days = response.forecastDaily.days
|
||||||
|
|
||||||
|
expect(days.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const firstDay = days[0]!
|
||||||
|
expect(firstDay.forecastStart).toBeDefined()
|
||||||
|
expect(typeof firstDay.temperatureMax).toBe("number")
|
||||||
|
expect(typeof firstDay.temperatureMin).toBe("number")
|
||||||
|
expect(firstDay.sunrise).toBeDefined()
|
||||||
|
expect(firstDay.sunset).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hourly limit is respected", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
hourlyLimit: 6,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dataSource["hourlyLimit"]).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("daily limit is respected", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({
|
||||||
|
credentials: mockCredentials,
|
||||||
|
dailyLimit: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dataSource["dailyLimit"]).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default limits are applied", () => {
|
||||||
|
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
|
||||||
|
|
||||||
|
expect(dataSource["hourlyLimit"]).toBe(12)
|
||||||
|
expect(dataSource["dailyLimit"]).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("unit conversion", () => {
|
||||||
|
test("Units enum has metric and imperial", () => {
|
||||||
|
expect(Units.metric).toBe("metric")
|
||||||
|
expect(Units.imperial).toBe("imperial")
|
||||||
|
})
|
||||||
|
})
|
||||||
304
packages/aris-data-source-weatherkit/src/data-source.ts
Normal file
304
packages/aris-data-source-weatherkit/src/data-source.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import type { Context, DataSource } from "@aris/core"
|
||||||
|
|
||||||
|
import {
|
||||||
|
WeatherFeedItemType,
|
||||||
|
type CurrentWeatherFeedItem,
|
||||||
|
type DailyWeatherFeedItem,
|
||||||
|
type HourlyWeatherFeedItem,
|
||||||
|
type WeatherAlertFeedItem,
|
||||||
|
type WeatherFeedItem,
|
||||||
|
} from "./feed-items"
|
||||||
|
import {
|
||||||
|
ConditionCode,
|
||||||
|
Severity,
|
||||||
|
fetchWeather,
|
||||||
|
type CurrentWeather,
|
||||||
|
type DailyForecast,
|
||||||
|
type HourlyForecast,
|
||||||
|
type WeatherAlert,
|
||||||
|
type WeatherKitCredentials,
|
||||||
|
} from "./weatherkit"
|
||||||
|
|
||||||
|
export const Units = {
|
||||||
|
metric: "metric",
|
||||||
|
imperial: "imperial",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Units = (typeof Units)[keyof typeof Units]
|
||||||
|
|
||||||
|
export interface WeatherKitDataSourceOptions {
|
||||||
|
credentials: WeatherKitCredentials
|
||||||
|
hourlyLimit?: number
|
||||||
|
dailyLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitQueryConfig {
|
||||||
|
units?: Units
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||||
|
|
||||||
|
readonly type = WeatherFeedItemType.current
|
||||||
|
private readonly credentials: WeatherKitCredentials
|
||||||
|
private readonly hourlyLimit: number
|
||||||
|
private readonly dailyLimit: number
|
||||||
|
|
||||||
|
constructor(options: WeatherKitDataSourceOptions) {
|
||||||
|
this.credentials = options.credentials
|
||||||
|
this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT
|
||||||
|
this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||||
|
if (!context.location) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = config.units ?? Units.metric
|
||||||
|
const timestamp = context.time
|
||||||
|
|
||||||
|
const response = await fetchWeather(
|
||||||
|
{ credentials: this.credentials },
|
||||||
|
{
|
||||||
|
lat: context.location.lat,
|
||||||
|
lng: context.location.lng,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const items: WeatherFeedItem[] = []
|
||||||
|
|
||||||
|
if (response.currentWeather) {
|
||||||
|
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, units))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.forecastHourly?.hours) {
|
||||||
|
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
||||||
|
for (let i = 0; i < hours.length; i++) {
|
||||||
|
const hour = hours[i]
|
||||||
|
if (hour) {
|
||||||
|
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, units))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.forecastDaily?.days) {
|
||||||
|
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||||||
|
for (let i = 0; i < days.length; i++) {
|
||||||
|
const day = days[i]
|
||||||
|
if (day) {
|
||||||
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, units))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.weatherAlerts?.alerts) {
|
||||||
|
for (const alert of response.weatherAlerts.alerts) {
|
||||||
|
items.push(createWeatherAlertFeedItem(alert, timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_PRIORITY = {
|
||||||
|
current: 0.5,
|
||||||
|
hourly: 0.3,
|
||||||
|
daily: 0.2,
|
||||||
|
alert: 0.7,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SEVERE_CONDITIONS = new Set<ConditionCode>([
|
||||||
|
ConditionCode.SevereThunderstorm,
|
||||||
|
ConditionCode.Hurricane,
|
||||||
|
ConditionCode.Tornado,
|
||||||
|
ConditionCode.TropicalStorm,
|
||||||
|
ConditionCode.Blizzard,
|
||||||
|
ConditionCode.FreezingRain,
|
||||||
|
ConditionCode.Hail,
|
||||||
|
ConditionCode.Frigid,
|
||||||
|
ConditionCode.Hot,
|
||||||
|
])
|
||||||
|
|
||||||
|
const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||||||
|
ConditionCode.Thunderstorm,
|
||||||
|
ConditionCode.IsolatedThunderstorms,
|
||||||
|
ConditionCode.ScatteredThunderstorms,
|
||||||
|
ConditionCode.HeavyRain,
|
||||||
|
ConditionCode.HeavySnow,
|
||||||
|
ConditionCode.FreezingDrizzle,
|
||||||
|
ConditionCode.BlowingSnow,
|
||||||
|
])
|
||||||
|
|
||||||
|
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||||||
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return Math.min(1, basePriority + 0.3)
|
||||||
|
}
|
||||||
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||||||
|
return Math.min(1, basePriority + 0.15)
|
||||||
|
}
|
||||||
|
return basePriority
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||||||
|
switch (severity) {
|
||||||
|
case Severity.Extreme:
|
||||||
|
return 1
|
||||||
|
case Severity.Severe:
|
||||||
|
return 0.9
|
||||||
|
case Severity.Moderate:
|
||||||
|
return 0.75
|
||||||
|
case Severity.Minor:
|
||||||
|
return BASE_PRIORITY.alert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTemperature(celsius: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return (celsius * 9) / 5 + 32
|
||||||
|
}
|
||||||
|
return celsius
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertSpeed(kmh: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return kmh * 0.621371
|
||||||
|
}
|
||||||
|
return kmh
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDistance(km: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return km * 0.621371
|
||||||
|
}
|
||||||
|
return km
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPrecipitation(mm: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return mm * 0.0393701
|
||||||
|
}
|
||||||
|
return mm
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPressure(mb: number, units: Units): number {
|
||||||
|
if (units === Units.imperial) {
|
||||||
|
return mb * 0.02953
|
||||||
|
}
|
||||||
|
return mb
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCurrentWeatherFeedItem(
|
||||||
|
current: CurrentWeather,
|
||||||
|
timestamp: Date,
|
||||||
|
units: Units,
|
||||||
|
): CurrentWeatherFeedItem {
|
||||||
|
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
|
type: WeatherFeedItemType.current,
|
||||||
|
priority,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
conditionCode: current.conditionCode,
|
||||||
|
daylight: current.daylight,
|
||||||
|
humidity: current.humidity,
|
||||||
|
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
|
||||||
|
pressure: convertPressure(current.pressure, units),
|
||||||
|
pressureTrend: current.pressureTrend,
|
||||||
|
temperature: convertTemperature(current.temperature, units),
|
||||||
|
temperatureApparent: convertTemperature(current.temperatureApparent, units),
|
||||||
|
uvIndex: current.uvIndex,
|
||||||
|
visibility: convertDistance(current.visibility, units),
|
||||||
|
windDirection: current.windDirection,
|
||||||
|
windGust: convertSpeed(current.windGust, units),
|
||||||
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHourlyWeatherFeedItem(
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
index: number,
|
||||||
|
timestamp: Date,
|
||||||
|
units: Units,
|
||||||
|
): HourlyWeatherFeedItem {
|
||||||
|
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
|
type: WeatherFeedItemType.hourly,
|
||||||
|
priority,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
forecastTime: new Date(hourly.forecastStart),
|
||||||
|
conditionCode: hourly.conditionCode,
|
||||||
|
daylight: hourly.daylight,
|
||||||
|
humidity: hourly.humidity,
|
||||||
|
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
|
||||||
|
precipitationChance: hourly.precipitationChance,
|
||||||
|
precipitationType: hourly.precipitationType,
|
||||||
|
temperature: convertTemperature(hourly.temperature, units),
|
||||||
|
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
|
||||||
|
uvIndex: hourly.uvIndex,
|
||||||
|
windDirection: hourly.windDirection,
|
||||||
|
windGust: convertSpeed(hourly.windGust, units),
|
||||||
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDailyWeatherFeedItem(
|
||||||
|
daily: DailyForecast,
|
||||||
|
index: number,
|
||||||
|
timestamp: Date,
|
||||||
|
units: Units,
|
||||||
|
): DailyWeatherFeedItem {
|
||||||
|
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
|
type: WeatherFeedItemType.daily,
|
||||||
|
priority,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
forecastDate: new Date(daily.forecastStart),
|
||||||
|
conditionCode: daily.conditionCode,
|
||||||
|
maxUvIndex: daily.maxUvIndex,
|
||||||
|
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
|
||||||
|
precipitationChance: daily.precipitationChance,
|
||||||
|
precipitationType: daily.precipitationType,
|
||||||
|
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
|
||||||
|
sunrise: new Date(daily.sunrise),
|
||||||
|
sunset: new Date(daily.sunset),
|
||||||
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||||||
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
||||||
|
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `weather-alert-${alert.id}`,
|
||||||
|
type: WeatherFeedItemType.alert,
|
||||||
|
priority,
|
||||||
|
timestamp,
|
||||||
|
data: {
|
||||||
|
alertId: alert.id,
|
||||||
|
areaName: alert.areaName,
|
||||||
|
certainty: alert.certainty,
|
||||||
|
description: alert.description,
|
||||||
|
detailsUrl: alert.detailsUrl,
|
||||||
|
effectiveTime: new Date(alert.effectiveTime),
|
||||||
|
expireTime: new Date(alert.expireTime),
|
||||||
|
severity: alert.severity,
|
||||||
|
source: alert.source,
|
||||||
|
urgency: alert.urgency,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
97
packages/aris-data-source-weatherkit/src/feed-items.ts
Normal file
97
packages/aris-data-source-weatherkit/src/feed-items.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
|
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
|
||||||
|
|
||||||
|
export const WeatherFeedItemType = {
|
||||||
|
current: "weather-current",
|
||||||
|
hourly: "weather-hourly",
|
||||||
|
daily: "weather-daily",
|
||||||
|
alert: "weather-alert",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
|
||||||
|
|
||||||
|
export type CurrentWeatherData = {
|
||||||
|
conditionCode: ConditionCode
|
||||||
|
daylight: boolean
|
||||||
|
humidity: number
|
||||||
|
precipitationIntensity: number
|
||||||
|
pressure: number
|
||||||
|
pressureTrend: "rising" | "falling" | "steady"
|
||||||
|
temperature: number
|
||||||
|
temperatureApparent: number
|
||||||
|
uvIndex: number
|
||||||
|
visibility: number
|
||||||
|
windDirection: number
|
||||||
|
windGust: number
|
||||||
|
windSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentWeatherFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.current,
|
||||||
|
CurrentWeatherData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type HourlyWeatherData = {
|
||||||
|
forecastTime: Date
|
||||||
|
conditionCode: ConditionCode
|
||||||
|
daylight: boolean
|
||||||
|
humidity: number
|
||||||
|
precipitationAmount: number
|
||||||
|
precipitationChance: number
|
||||||
|
precipitationType: PrecipitationType
|
||||||
|
temperature: number
|
||||||
|
temperatureApparent: number
|
||||||
|
uvIndex: number
|
||||||
|
windDirection: number
|
||||||
|
windGust: number
|
||||||
|
windSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyWeatherFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.hourly,
|
||||||
|
HourlyWeatherData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type DailyWeatherData = {
|
||||||
|
forecastDate: Date
|
||||||
|
conditionCode: ConditionCode
|
||||||
|
maxUvIndex: number
|
||||||
|
precipitationAmount: number
|
||||||
|
precipitationChance: number
|
||||||
|
precipitationType: PrecipitationType
|
||||||
|
snowfallAmount: number
|
||||||
|
sunrise: Date
|
||||||
|
sunset: Date
|
||||||
|
temperatureMax: number
|
||||||
|
temperatureMin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyWeatherFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.daily,
|
||||||
|
DailyWeatherData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type WeatherAlertData = {
|
||||||
|
alertId: string
|
||||||
|
areaName: string
|
||||||
|
certainty: Certainty
|
||||||
|
description: string
|
||||||
|
detailsUrl: string
|
||||||
|
effectiveTime: Date
|
||||||
|
expireTime: Date
|
||||||
|
severity: Severity
|
||||||
|
source: string
|
||||||
|
urgency: Urgency
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherAlertFeedItem extends FeedItem<
|
||||||
|
typeof WeatherFeedItemType.alert,
|
||||||
|
WeatherAlertData
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export type WeatherFeedItem =
|
||||||
|
| CurrentWeatherFeedItem
|
||||||
|
| HourlyWeatherFeedItem
|
||||||
|
| DailyWeatherFeedItem
|
||||||
|
| WeatherAlertFeedItem
|
||||||
35
packages/aris-data-source-weatherkit/src/index.ts
Normal file
35
packages/aris-data-source-weatherkit/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export {
|
||||||
|
WeatherKitDataSource,
|
||||||
|
Units,
|
||||||
|
type Units as UnitsType,
|
||||||
|
type WeatherKitDataSourceOptions,
|
||||||
|
type WeatherKitQueryConfig,
|
||||||
|
} from "./data-source"
|
||||||
|
|
||||||
|
export {
|
||||||
|
WeatherFeedItemType,
|
||||||
|
type WeatherFeedItemType as WeatherFeedItemTypeType,
|
||||||
|
type CurrentWeatherData,
|
||||||
|
type CurrentWeatherFeedItem,
|
||||||
|
type DailyWeatherData,
|
||||||
|
type DailyWeatherFeedItem,
|
||||||
|
type HourlyWeatherData,
|
||||||
|
type HourlyWeatherFeedItem,
|
||||||
|
type WeatherAlertData,
|
||||||
|
type WeatherAlertFeedItem,
|
||||||
|
type WeatherFeedItem,
|
||||||
|
} from "./feed-items"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Severity,
|
||||||
|
Urgency,
|
||||||
|
Certainty,
|
||||||
|
PrecipitationType,
|
||||||
|
ConditionCode,
|
||||||
|
type Severity as SeverityType,
|
||||||
|
type Urgency as UrgencyType,
|
||||||
|
type Certainty as CertaintyType,
|
||||||
|
type PrecipitationType as PrecipitationTypeType,
|
||||||
|
type ConditionCode as ConditionCodeType,
|
||||||
|
type WeatherKitCredentials,
|
||||||
|
} from "./weatherkit"
|
||||||
359
packages/aris-data-source-weatherkit/src/weatherkit.ts
Normal file
359
packages/aris-data-source-weatherkit/src/weatherkit.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
// WeatherKit REST API client and response types
|
||||||
|
// https://developer.apple.com/documentation/weatherkitrestapi
|
||||||
|
|
||||||
|
import { type } from "arktype"
|
||||||
|
|
||||||
|
export async function fetchWeather(
|
||||||
|
options: WeatherKitClientOptions,
|
||||||
|
query: WeatherKitQueryOptions,
|
||||||
|
): Promise<WeatherKitResponse> {
|
||||||
|
const token = await generateJwt(options.credentials)
|
||||||
|
|
||||||
|
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(",")
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
|
||||||
|
)
|
||||||
|
url.searchParams.set("dataSets", dataSets)
|
||||||
|
if (query.timezone) {
|
||||||
|
url.searchParams.set("timezone", query.timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const result = weatherKitResponseSchema(json)
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
throw new Error(`WeatherKit API response validation failed: ${result.summary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitCredentials {
|
||||||
|
privateKey: string
|
||||||
|
keyId: string
|
||||||
|
teamId: string
|
||||||
|
serviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitClientOptions {
|
||||||
|
credentials: WeatherKitCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherKitQueryOptions {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
language?: string
|
||||||
|
timezone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Severity = {
|
||||||
|
Minor: "minor",
|
||||||
|
Moderate: "moderate",
|
||||||
|
Severe: "severe",
|
||||||
|
Extreme: "extreme",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Severity = (typeof Severity)[keyof typeof Severity]
|
||||||
|
|
||||||
|
export const Urgency = {
|
||||||
|
Immediate: "immediate",
|
||||||
|
Expected: "expected",
|
||||||
|
Future: "future",
|
||||||
|
Past: "past",
|
||||||
|
Unknown: "unknown",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Urgency = (typeof Urgency)[keyof typeof Urgency]
|
||||||
|
|
||||||
|
export const Certainty = {
|
||||||
|
Observed: "observed",
|
||||||
|
Likely: "likely",
|
||||||
|
Possible: "possible",
|
||||||
|
Unlikely: "unlikely",
|
||||||
|
Unknown: "unknown",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Certainty = (typeof Certainty)[keyof typeof Certainty]
|
||||||
|
|
||||||
|
export const PrecipitationType = {
|
||||||
|
Clear: "clear",
|
||||||
|
Precipitation: "precipitation",
|
||||||
|
Rain: "rain",
|
||||||
|
Snow: "snow",
|
||||||
|
Sleet: "sleet",
|
||||||
|
Hail: "hail",
|
||||||
|
Mixed: "mixed",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PrecipitationType = (typeof PrecipitationType)[keyof typeof PrecipitationType]
|
||||||
|
|
||||||
|
export const ConditionCode = {
|
||||||
|
Clear: "Clear",
|
||||||
|
Cloudy: "Cloudy",
|
||||||
|
Dust: "Dust",
|
||||||
|
Fog: "Fog",
|
||||||
|
Haze: "Haze",
|
||||||
|
MostlyClear: "MostlyClear",
|
||||||
|
MostlyCloudy: "MostlyCloudy",
|
||||||
|
PartlyCloudy: "PartlyCloudy",
|
||||||
|
ScatteredThunderstorms: "ScatteredThunderstorms",
|
||||||
|
Smoke: "Smoke",
|
||||||
|
Breezy: "Breezy",
|
||||||
|
Windy: "Windy",
|
||||||
|
Drizzle: "Drizzle",
|
||||||
|
HeavyRain: "HeavyRain",
|
||||||
|
Rain: "Rain",
|
||||||
|
Showers: "Showers",
|
||||||
|
Flurries: "Flurries",
|
||||||
|
HeavySnow: "HeavySnow",
|
||||||
|
MixedRainAndSleet: "MixedRainAndSleet",
|
||||||
|
MixedRainAndSnow: "MixedRainAndSnow",
|
||||||
|
MixedRainfall: "MixedRainfall",
|
||||||
|
MixedSnowAndSleet: "MixedSnowAndSleet",
|
||||||
|
ScatteredShowers: "ScatteredShowers",
|
||||||
|
ScatteredSnowShowers: "ScatteredSnowShowers",
|
||||||
|
Sleet: "Sleet",
|
||||||
|
Snow: "Snow",
|
||||||
|
SnowShowers: "SnowShowers",
|
||||||
|
Blizzard: "Blizzard",
|
||||||
|
BlowingSnow: "BlowingSnow",
|
||||||
|
FreezingDrizzle: "FreezingDrizzle",
|
||||||
|
FreezingRain: "FreezingRain",
|
||||||
|
Frigid: "Frigid",
|
||||||
|
Hail: "Hail",
|
||||||
|
Hot: "Hot",
|
||||||
|
Hurricane: "Hurricane",
|
||||||
|
IsolatedThunderstorms: "IsolatedThunderstorms",
|
||||||
|
SevereThunderstorm: "SevereThunderstorm",
|
||||||
|
Thunderstorm: "Thunderstorm",
|
||||||
|
Tornado: "Tornado",
|
||||||
|
TropicalStorm: "TropicalStorm",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ConditionCode = (typeof ConditionCode)[keyof typeof ConditionCode]
|
||||||
|
|
||||||
|
const WEATHERKIT_API_BASE = "https://weatherkit.apple.com/api/v1"
|
||||||
|
|
||||||
|
const severitySchema = type.enumerated(
|
||||||
|
Severity.Minor,
|
||||||
|
Severity.Moderate,
|
||||||
|
Severity.Severe,
|
||||||
|
Severity.Extreme,
|
||||||
|
)
|
||||||
|
|
||||||
|
const urgencySchema = type.enumerated(
|
||||||
|
Urgency.Immediate,
|
||||||
|
Urgency.Expected,
|
||||||
|
Urgency.Future,
|
||||||
|
Urgency.Past,
|
||||||
|
Urgency.Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
const certaintySchema = type.enumerated(
|
||||||
|
Certainty.Observed,
|
||||||
|
Certainty.Likely,
|
||||||
|
Certainty.Possible,
|
||||||
|
Certainty.Unlikely,
|
||||||
|
Certainty.Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
const precipitationTypeSchema = type.enumerated(
|
||||||
|
PrecipitationType.Clear,
|
||||||
|
PrecipitationType.Precipitation,
|
||||||
|
PrecipitationType.Rain,
|
||||||
|
PrecipitationType.Snow,
|
||||||
|
PrecipitationType.Sleet,
|
||||||
|
PrecipitationType.Hail,
|
||||||
|
PrecipitationType.Mixed,
|
||||||
|
)
|
||||||
|
|
||||||
|
const conditionCodeSchema = type.enumerated(...Object.values(ConditionCode))
|
||||||
|
|
||||||
|
const pressureTrendSchema = type.enumerated("rising", "falling", "steady")
|
||||||
|
|
||||||
|
const currentWeatherSchema = type({
|
||||||
|
asOf: "string",
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
daylight: "boolean",
|
||||||
|
humidity: "number",
|
||||||
|
precipitationIntensity: "number",
|
||||||
|
pressure: "number",
|
||||||
|
pressureTrend: pressureTrendSchema,
|
||||||
|
temperature: "number",
|
||||||
|
temperatureApparent: "number",
|
||||||
|
temperatureDewPoint: "number",
|
||||||
|
uvIndex: "number",
|
||||||
|
visibility: "number",
|
||||||
|
windDirection: "number",
|
||||||
|
windGust: "number",
|
||||||
|
windSpeed: "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CurrentWeather = typeof currentWeatherSchema.infer
|
||||||
|
|
||||||
|
const hourlyForecastSchema = type({
|
||||||
|
forecastStart: "string",
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
daylight: "boolean",
|
||||||
|
humidity: "number",
|
||||||
|
precipitationAmount: "number",
|
||||||
|
precipitationChance: "number",
|
||||||
|
precipitationType: precipitationTypeSchema,
|
||||||
|
pressure: "number",
|
||||||
|
snowfallIntensity: "number",
|
||||||
|
temperature: "number",
|
||||||
|
temperatureApparent: "number",
|
||||||
|
temperatureDewPoint: "number",
|
||||||
|
uvIndex: "number",
|
||||||
|
visibility: "number",
|
||||||
|
windDirection: "number",
|
||||||
|
windGust: "number",
|
||||||
|
windSpeed: "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type HourlyForecast = typeof hourlyForecastSchema.infer
|
||||||
|
|
||||||
|
const dayWeatherConditionsSchema = type({
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
humidity: "number",
|
||||||
|
precipitationAmount: "number",
|
||||||
|
precipitationChance: "number",
|
||||||
|
precipitationType: precipitationTypeSchema,
|
||||||
|
snowfallAmount: "number",
|
||||||
|
temperatureMax: "number",
|
||||||
|
temperatureMin: "number",
|
||||||
|
windDirection: "number",
|
||||||
|
"windGust?": "number",
|
||||||
|
windSpeed: "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DayWeatherConditions = typeof dayWeatherConditionsSchema.infer
|
||||||
|
|
||||||
|
const dailyForecastSchema = type({
|
||||||
|
forecastStart: "string",
|
||||||
|
forecastEnd: "string",
|
||||||
|
conditionCode: conditionCodeSchema,
|
||||||
|
maxUvIndex: "number",
|
||||||
|
moonPhase: "string",
|
||||||
|
"moonrise?": "string",
|
||||||
|
"moonset?": "string",
|
||||||
|
precipitationAmount: "number",
|
||||||
|
precipitationChance: "number",
|
||||||
|
precipitationType: precipitationTypeSchema,
|
||||||
|
snowfallAmount: "number",
|
||||||
|
sunrise: "string",
|
||||||
|
sunriseCivil: "string",
|
||||||
|
sunriseNautical: "string",
|
||||||
|
sunriseAstronomical: "string",
|
||||||
|
sunset: "string",
|
||||||
|
sunsetCivil: "string",
|
||||||
|
sunsetNautical: "string",
|
||||||
|
sunsetAstronomical: "string",
|
||||||
|
temperatureMax: "number",
|
||||||
|
temperatureMin: "number",
|
||||||
|
"daytimeForecast?": dayWeatherConditionsSchema,
|
||||||
|
"overnightForecast?": dayWeatherConditionsSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DailyForecast = typeof dailyForecastSchema.infer
|
||||||
|
|
||||||
|
const weatherAlertSchema = type({
|
||||||
|
id: "string",
|
||||||
|
areaId: "string",
|
||||||
|
areaName: "string",
|
||||||
|
certainty: certaintySchema,
|
||||||
|
countryCode: "string",
|
||||||
|
description: "string",
|
||||||
|
detailsUrl: "string",
|
||||||
|
effectiveTime: "string",
|
||||||
|
expireTime: "string",
|
||||||
|
issuedTime: "string",
|
||||||
|
responses: "string[]",
|
||||||
|
severity: severitySchema,
|
||||||
|
source: "string",
|
||||||
|
urgency: urgencySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type WeatherAlert = typeof weatherAlertSchema.infer
|
||||||
|
|
||||||
|
const weatherKitResponseSchema = type({
|
||||||
|
"currentWeather?": currentWeatherSchema,
|
||||||
|
"forecastHourly?": type({
|
||||||
|
hours: hourlyForecastSchema.array(),
|
||||||
|
}),
|
||||||
|
"forecastDaily?": type({
|
||||||
|
days: dailyForecastSchema.array(),
|
||||||
|
}),
|
||||||
|
"weatherAlerts?": type({
|
||||||
|
alerts: weatherAlertSchema.array(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type WeatherKitResponse = typeof weatherKitResponseSchema.infer
|
||||||
|
|
||||||
|
async function generateJwt(credentials: WeatherKitCredentials): Promise<string> {
|
||||||
|
const header = {
|
||||||
|
alg: "ES256",
|
||||||
|
kid: credentials.keyId,
|
||||||
|
id: `${credentials.teamId}.${credentials.serviceId}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const payload = {
|
||||||
|
iss: credentials.teamId,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600,
|
||||||
|
sub: credentials.serviceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const headerB64 = btoa(JSON.stringify(header))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
const payloadB64 = btoa(JSON.stringify(payload))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`
|
||||||
|
|
||||||
|
const pemContents = credentials.privateKey
|
||||||
|
.replace(/-----BEGIN PRIVATE KEY-----/, "")
|
||||||
|
.replace(/-----END PRIVATE KEY-----/, "")
|
||||||
|
.replace(/\s/g, "")
|
||||||
|
|
||||||
|
const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0))
|
||||||
|
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
binaryKey,
|
||||||
|
{ name: "ECDSA", namedCurve: "P-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
)
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
{ name: "ECDSA", hash: "SHA-256" },
|
||||||
|
cryptoKey,
|
||||||
|
encoder.encode(signingInput),
|
||||||
|
)
|
||||||
|
|
||||||
|
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
|
||||||
|
return `${signingInput}.${signatureB64}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user