mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 06:41:18 +01:00
chore: remove aelis-data-source-weatherkit
Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
10
bun.lock
10
bun.lock
@@ -170,14 +170,6 @@
|
|||||||
"@nym.sh/jrx": "*",
|
"@nym.sh/jrx": "*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aelis-data-source-weatherkit": {
|
|
||||||
"name": "@aelis/data-source-weatherkit",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@aelis/core": "workspace:*",
|
|
||||||
"arktype": "^2.1.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aelis-feed-enhancers": {
|
"packages/aelis-feed-enhancers": {
|
||||||
"name": "@aelis/feed-enhancers",
|
"name": "@aelis/feed-enhancers",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -248,8 +240,6 @@
|
|||||||
|
|
||||||
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
||||||
|
|
||||||
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
|
|
||||||
|
|
||||||
"@aelis/feed-enhancers": ["@aelis/feed-enhancers@workspace:packages/aelis-feed-enhancers"],
|
"@aelis/feed-enhancers": ["@aelis/feed-enhancers@workspace:packages/aelis-feed-enhancers"],
|
||||||
|
|
||||||
"@aelis/source-caldav": ["@aelis/source-caldav@workspace:packages/aelis-source-caldav"],
|
"@aelis/source-caldav": ["@aelis/source-caldav@workspace:packages/aelis-source-caldav"],
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# @aelis/data-source-weatherkit
|
|
||||||
|
|
||||||
Weather data source using Apple WeatherKit REST API.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { WeatherKitDataSource, Units } from "@aelis/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.
|
|
||||||
|
|
||||||
## Generating Test Fixtures
|
|
||||||
|
|
||||||
To regenerate fixture data from the real API:
|
|
||||||
|
|
||||||
1. Create a `.env` file with your credentials (see `.env.example`)
|
|
||||||
2. Run `bun run scripts/generate-fixtures.ts`
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aelis/data-source-weatherkit",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test ."
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aelis/core": "workspace:*",
|
|
||||||
"arktype": "^2.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { DefaultWeatherKitClient } 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 client = new DefaultWeatherKitClient({
|
|
||||||
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 client.fetch({
|
|
||||||
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)
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import type { ContextKey } from "@aelis/core"
|
|
||||||
|
|
||||||
import { Context, contextKey } from "@aelis/core"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
accuracy: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
|
|
||||||
|
|
||||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
|
||||||
fetch: async () => response,
|
|
||||||
})
|
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
|
||||||
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
|
||||||
if (location) {
|
|
||||||
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws error if neither client nor credentials provided", () => {
|
|
||||||
expect(() => new WeatherKitDataSource({})).toThrow(
|
|
||||||
"Either client or credentials must be provided",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("WeatherKitDataSource with fixture", () => {
|
|
||||||
const response = fixture.response
|
|
||||||
|
|
||||||
test("parses current weather from fixture", () => {
|
|
||||||
const current = response.currentWeather
|
|
||||||
|
|
||||||
expect(typeof current.conditionCode).toBe("string")
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("query() with mocked client", () => {
|
|
||||||
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
|
|
||||||
|
|
||||||
test("transforms API response into feed items", async () => {
|
|
||||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
|
||||||
|
|
||||||
expect(items.length).toBeGreaterThan(0)
|
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Current)).toBe(true)
|
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Hourly)).toBe(true)
|
|
||||||
expect(items.some((i) => i.type === WeatherFeedItemType.Daily)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("applies hourly and daily limits", async () => {
|
|
||||||
const dataSource = new WeatherKitDataSource({
|
|
||||||
client: mockClient,
|
|
||||||
hourlyLimit: 3,
|
|
||||||
dailyLimit: 2,
|
|
||||||
})
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
|
||||||
|
|
||||||
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
|
|
||||||
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
|
|
||||||
|
|
||||||
expect(hourlyItems.length).toBe(3)
|
|
||||||
expect(dailyItems.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sets timestamp from context.time", async () => {
|
|
||||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
|
||||||
const queryTime = new Date("2026-01-17T12:00:00Z")
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
context.time = queryTime
|
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.timestamp).toEqual(queryTime)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("converts temperatures to imperial", async () => {
|
|
||||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const metricItems = await dataSource.query(context, {
|
|
||||||
units: Units.metric,
|
|
||||||
})
|
|
||||||
const imperialItems = await dataSource.query(context, {
|
|
||||||
units: Units.imperial,
|
|
||||||
})
|
|
||||||
|
|
||||||
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.Current)
|
|
||||||
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.Current)
|
|
||||||
|
|
||||||
expect(metricCurrent).toBeDefined()
|
|
||||||
expect(imperialCurrent).toBeDefined()
|
|
||||||
|
|
||||||
const metricTemp = (metricCurrent!.data as { temperature: number }).temperature
|
|
||||||
const imperialTemp = (imperialCurrent!.data as { temperature: number }).temperature
|
|
||||||
|
|
||||||
// Verify conversion: F = C * 9/5 + 32
|
|
||||||
const expectedImperial = (metricTemp * 9) / 5 + 32
|
|
||||||
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("assigns signals based on weather conditions", async () => {
|
|
||||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.signals).toBeDefined()
|
|
||||||
expect(item.signals!.urgency).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(item.signals!.urgency).toBeLessThanOrEqual(1)
|
|
||||||
expect(item.signals!.timeRelevance).toBeDefined()
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current)
|
|
||||||
expect(currentItem).toBeDefined()
|
|
||||||
expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("generates unique IDs for each item", async () => {
|
|
||||||
const dataSource = new WeatherKitDataSource({ client: mockClient })
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await dataSource.query(context)
|
|
||||||
const ids = items.map((i) => i.id)
|
|
||||||
const uniqueIds = new Set(ids)
|
|
||||||
|
|
||||||
expect(uniqueIds.size).toBe(ids.length)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aelis/core"
|
|
||||||
|
|
||||||
import { TimeRelevance, contextKey } from "@aelis/core"
|
|
||||||
|
|
||||||
import {
|
|
||||||
WeatherFeedItemType,
|
|
||||||
type CurrentWeatherFeedItem,
|
|
||||||
type DailyWeatherFeedItem,
|
|
||||||
type HourlyWeatherFeedItem,
|
|
||||||
type WeatherAlertFeedItem,
|
|
||||||
type WeatherFeedItem,
|
|
||||||
} from "./feed-items"
|
|
||||||
import {
|
|
||||||
ConditionCode,
|
|
||||||
DefaultWeatherKitClient,
|
|
||||||
Severity,
|
|
||||||
type CurrentWeather,
|
|
||||||
type DailyForecast,
|
|
||||||
type HourlyForecast,
|
|
||||||
type WeatherAlert,
|
|
||||||
type WeatherKitClient,
|
|
||||||
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
|
|
||||||
client?: WeatherKitClient
|
|
||||||
hourlyLimit?: number
|
|
||||||
dailyLimit?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherKitQueryConfig {
|
|
||||||
units?: Units
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
|
|
||||||
|
|
||||||
const SOURCE_ID = "aelis.weather"
|
|
||||||
|
|
||||||
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 client: WeatherKitClient
|
|
||||||
private readonly hourlyLimit: number
|
|
||||||
private readonly dailyLimit: number
|
|
||||||
|
|
||||||
constructor(options: WeatherKitDataSourceOptions) {
|
|
||||||
if (!options.client && !options.credentials) {
|
|
||||||
throw new Error("Either client or credentials must be provided")
|
|
||||||
}
|
|
||||||
this.client = options.client ?? new DefaultWeatherKitClient(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[]> {
|
|
||||||
const location = context.get(LocationKey)
|
|
||||||
if (!location) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const units = config.units ?? Units.metric
|
|
||||||
const timestamp = context.time
|
|
||||||
|
|
||||||
const response = await this.client.fetch({
|
|
||||||
lat: location.lat,
|
|
||||||
lng: 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_URGENCY = {
|
|
||||||
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 adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
|
||||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
|
||||||
return Math.min(1, baseUrgency + 0.3)
|
|
||||||
}
|
|
||||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
|
||||||
return Math.min(1, baseUrgency + 0.15)
|
|
||||||
}
|
|
||||||
return baseUrgency
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustUrgencyForAlertSeverity(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_URGENCY.alert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
|
|
||||||
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
|
||||||
return TimeRelevance.Imminent
|
|
||||||
}
|
|
||||||
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
|
||||||
return TimeRelevance.Upcoming
|
|
||||||
}
|
|
||||||
return TimeRelevance.Ambient
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
|
|
||||||
switch (severity) {
|
|
||||||
case Severity.Extreme:
|
|
||||||
case Severity.Severe:
|
|
||||||
return TimeRelevance.Imminent
|
|
||||||
case Severity.Moderate:
|
|
||||||
return TimeRelevance.Upcoming
|
|
||||||
case Severity.Minor:
|
|
||||||
return TimeRelevance.Ambient
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 signals: FeedItemSignals = {
|
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
|
||||||
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Current,
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
signals,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHourlyWeatherFeedItem(
|
|
||||||
hourly: HourlyForecast,
|
|
||||||
index: number,
|
|
||||||
timestamp: Date,
|
|
||||||
units: Units,
|
|
||||||
): HourlyWeatherFeedItem {
|
|
||||||
const signals: FeedItemSignals = {
|
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
|
||||||
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Hourly,
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
signals,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDailyWeatherFeedItem(
|
|
||||||
daily: DailyForecast,
|
|
||||||
index: number,
|
|
||||||
timestamp: Date,
|
|
||||||
units: Units,
|
|
||||||
): DailyWeatherFeedItem {
|
|
||||||
const signals: FeedItemSignals = {
|
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
|
||||||
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Daily,
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
signals,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
|
||||||
const signals: FeedItemSignals = {
|
|
||||||
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
|
||||||
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `weather-alert-${alert.id}`,
|
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Alert,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
signals,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/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
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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,
|
|
||||||
DefaultWeatherKitClient,
|
|
||||||
type Severity as SeverityType,
|
|
||||||
type Urgency as UrgencyType,
|
|
||||||
type Certainty as CertaintyType,
|
|
||||||
type PrecipitationType as PrecipitationTypeType,
|
|
||||||
type ConditionCode as ConditionCodeType,
|
|
||||||
type WeatherKitCredentials,
|
|
||||||
type WeatherKitClient,
|
|
||||||
type WeatherKitQueryOptions,
|
|
||||||
} from "./weatherkit"
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
// WeatherKit REST API client and response types
|
|
||||||
// https://developer.apple.com/documentation/weatherkitrestapi
|
|
||||||
|
|
||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
export interface WeatherKitCredentials {
|
|
||||||
privateKey: string
|
|
||||||
keyId: string
|
|
||||||
teamId: string
|
|
||||||
serviceId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherKitQueryOptions {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
language?: string
|
|
||||||
timezone?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherKitClient {
|
|
||||||
fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DefaultWeatherKitClient implements WeatherKitClient {
|
|
||||||
private readonly credentials: WeatherKitCredentials
|
|
||||||
|
|
||||||
constructor(credentials: WeatherKitCredentials) {
|
|
||||||
this.credentials = credentials
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse> {
|
|
||||||
const token = await generateJwt(this.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) {
|
|
||||||
const body = await response.text()
|
|
||||||
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}: ${body}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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