diff --git a/packages/ambientweather/api.test.ts b/packages/ambientweather/api.test.ts new file mode 100644 index 000000000..12f51cf26 --- /dev/null +++ b/packages/ambientweather/api.test.ts @@ -0,0 +1,249 @@ +import { + AmbientWeatherRateLimitError, + makeAmbientWeatherRequest, + packAmbientWeatherCredentials, + parseAmbientWeatherKey, +} from './client'; +import { getData, list } from './endpoints/devices'; +import { + AmbientWeatherDeviceDataResponseSchema, + AmbientWeatherEndpointOutputSchemas, +} from './endpoints/types'; +import { ambientweather, ambientweatherAuthConfig } from './index'; + +const mockFetch = jest.fn(); + +beforeAll(() => { + globalThis.fetch = mockFetch as typeof fetch; +}); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); +} + +const sampleDevice = { + macAddress: '00:11:22:33:44:55', + info: { + name: 'Backyard Station', + location: 'Patio', + }, + lastData: { + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + winddir: 58, + windspeedmph: 0.9, + yearlyrainin: 0, + feelsLike: 66.9, + dewPoint: 34.45, + }, +}; + +describe('ambientweather client', () => { + it('packs and parses credentials for the key builder', () => { + const credentials = { + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }; + + const packed = packAmbientWeatherCredentials(credentials); + expect(parseAmbientWeatherKey(packed)).toEqual(credentials); + expect(parseAmbientWeatherKey('not-json')).toBeNull(); + }); + + it('adds both auth query params on every request', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([sampleDevice])); + + const response = await makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + { query: { limit: 1 } }, + ); + + AmbientWeatherEndpointOutputSchemas.devicesList.parse(response); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const called = mockFetch.mock.calls[0]?.[0]; + expect(called).toBeInstanceOf(URL); + const url = called as URL; + expect(url.origin).toBe('https://rt.ambientweather.net'); + expect(url.pathname).toBe('/v1/devices'); + expect(url.searchParams.get('apiKey')).toBe('user-api-key'); + expect(url.searchParams.get('applicationKey')).toBe('developer-app-key'); + expect(url.searchParams.get('limit')).toBe('1'); + }); + + it('wraps 429 responses in AmbientWeatherRateLimitError', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { error: 'rate limited' }, + { status: 429, statusText: 'Too Many Requests' }, + ), + ); + + await expect( + makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + ), + ).rejects.toBeInstanceOf(AmbientWeatherRateLimitError); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('parses Retry-After delta-seconds and HTTP-date', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'rate limited' }), { + status: 429, + statusText: 'Too Many Requests', + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '2', + }, + }), + ); + + await expect( + makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + ), + ).rejects.toMatchObject({ + name: 'AmbientWeatherRateLimitError', + retryAfter: 2000, + }); + + const future = new Date(Date.now() + 5000).toUTCString(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'rate limited' }), { + status: 429, + statusText: 'Too Many Requests', + headers: { + 'Content-Type': 'application/json', + 'Retry-After': future, + }, + }), + ); + + const err = await makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + ).catch((error: unknown) => error); + expect(err).toBeInstanceOf(AmbientWeatherRateLimitError); + expect((err as AmbientWeatherRateLimitError).retryAfter).toBeGreaterThan(0); + }); +}); + +describe('ambientweather endpoints', () => { + it('lists devices and upserts them into the devices entity', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([sampleDevice])); + const upsertByEntityId = jest.fn().mockResolvedValue(undefined); + + const ctx = { + key: packAmbientWeatherCredentials({ + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }), + db: { devices: { upsertByEntityId } }, + } as unknown as Parameters[0]; + + const parsed = await list(ctx, {}); + + AmbientWeatherEndpointOutputSchemas.devicesList.parse(parsed); + expect(mockFetch).toHaveBeenCalledTimes(1); + const url = mockFetch.mock.calls[0]?.[0] as URL; + expect(url.pathname).toBe('/v1/devices'); + expect(url.searchParams.get('apiKey')).toBe('user-api-key'); + expect(url.searchParams.get('applicationKey')).toBe('developer-app-key'); + expect(upsertByEntityId).toHaveBeenCalledWith('00:11:22:33:44:55', { + macAddress: '00:11:22:33:44:55', + name: 'Backyard Station', + location: 'Patio', + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + winddir: 58, + windspeedmph: 0.9, + yearlyrainin: 0, + feelsLike: 66.9, + dewPoint: 34.45, + }); + }); + + it('fetches device history, omits unset limit, and upserts readings', async () => { + const reading = { + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + yearlyrainin: 0, + }; + mockFetch.mockResolvedValueOnce(jsonResponse([reading])); + const upsertByEntityId = jest.fn().mockResolvedValue(undefined); + + const ctx = { + key: packAmbientWeatherCredentials({ + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }), + db: { readings: { upsertByEntityId } }, + } as unknown as Parameters[0]; + + const parsed = await getData(ctx, { + macAddress: '00:11:22:33:44:55', + endDate: 1720000000000, + }); + + AmbientWeatherDeviceDataResponseSchema.parse(parsed); + expect(mockFetch).toHaveBeenCalledTimes(1); + const url = mockFetch.mock.calls[0]?.[0] as URL; + expect(url.pathname).toBe('/v1/devices/00%3A11%3A22%3A33%3A44%3A55'); + expect(url.searchParams.get('apiKey')).toBe('user-api-key'); + expect(url.searchParams.get('applicationKey')).toBe('developer-app-key'); + expect(url.searchParams.get('endDate')).toBe('1720000000000'); + expect(url.searchParams.has('limit')).toBe(false); + expect(upsertByEntityId).toHaveBeenCalledWith( + '00:11:22:33:44:55:1720000000000', + { + macAddress: '00:11:22:33:44:55', + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + yearlyrainin: 0, + }, + ); + }); +}); + +describe('ambientweather factory', () => { + it('registers the two read endpoints with the expected auth config', () => { + const plugin = ambientweather(); + + expect(plugin.id).toBe('ambientweather'); + expect(plugin.authConfig).toEqual(ambientweatherAuthConfig); + expect(plugin.schema?.entities).toHaveProperty('devices'); + expect(plugin.schema?.entities).toHaveProperty('readings'); + expect(plugin.endpoints).toBeDefined(); + expect(plugin.endpoints!.devices.list).toEqual(expect.any(Function)); + expect(plugin.endpoints!.devices.getData).toEqual(expect.any(Function)); + expect(Object.keys(plugin.webhooks ?? {})).toHaveLength(0); + }); +}); diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts new file mode 100644 index 000000000..ad85011cf --- /dev/null +++ b/packages/ambientweather/client.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; + +export const AmbientWeatherCredentialsSchema = z.object({ + apiKey: z.string().min(1), + applicationKey: z.string().min(1), +}); + +export type AmbientWeatherCredentials = z.infer< + typeof AmbientWeatherCredentialsSchema +>; + +export class AmbientWeatherAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + public readonly body?: unknown; + public readonly retryAfter?: number; + + constructor( + message: string, + public readonly code?: number, + options?: { + cause?: Error; + status?: number; + statusText?: string; + body?: unknown; + retryAfter?: number; + }, + ) { + super(message, options); + this.name = 'AmbientWeatherAPIError'; + this.status = options?.status ?? code; + this.statusText = options?.statusText; + this.body = options?.body; + this.retryAfter = options?.retryAfter; + } +} + +export class AmbientWeatherRateLimitError extends AmbientWeatherAPIError { + constructor( + message: string, + options?: ConstructorParameters[2], + ) { + super(message, 429, options); + this.name = 'AmbientWeatherRateLimitError'; + } +} + +const AMBIENTWEATHER_API_BASE = 'https://rt.ambientweather.net'; + +export type AmbientWeatherQueryValue = string | number | boolean | undefined; + +export type AmbientWeatherRequestQuery = Record< + string, + AmbientWeatherQueryValue +>; + +export function packAmbientWeatherCredentials( + credentials: AmbientWeatherCredentials, +): string { + return JSON.stringify(credentials); +} + +export function parseAmbientWeatherKey( + key: string, +): AmbientWeatherCredentials | null { + try { + const parsed = JSON.parse(key) as unknown; + const credentials = AmbientWeatherCredentialsSchema.safeParse(parsed); + return credentials.success ? credentials.data : null; + } catch { + return null; + } +} + +function buildAmbientWeatherUrl( + endpoint: string, + apiKey: string, + applicationKey: string, + query?: AmbientWeatherRequestQuery, +): URL { + const path = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + const url = new URL(path, `${AMBIENTWEATHER_API_BASE}/`); + const params: AmbientWeatherRequestQuery = { + ...query, + apiKey, + applicationKey, + }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url; +} + +/** Retry-After: delta-seconds or HTTP-date → non-negative delay ms. */ +function parseRetryAfterMs(header: string | null): number | undefined { + if (!header) return undefined; + + const seconds = Number(header); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const dateMs = Date.parse(header); + if (!Number.isFinite(dateMs)) return undefined; + + const delayMs = dateMs - Date.now(); + return delayMs >= 0 ? delayMs : undefined; +} + +export async function makeAmbientWeatherRequest( + endpoint: string, + apiKey: string, + applicationKey: string, + options: { + query?: AmbientWeatherRequestQuery; + } = {}, +): Promise { + const url = buildAmbientWeatherUrl( + endpoint, + apiKey, + applicationKey, + options.query, + ); + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + } catch (error) { + if (error instanceof Error) { + throw new AmbientWeatherAPIError(error.message, undefined, { + cause: error, + }); + } + throw new AmbientWeatherAPIError('Unknown Ambient Weather API error'); + } + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + + const retryAfter = parseRetryAfterMs(response.headers.get('Retry-After')); + + if (response.status === 429) { + throw new AmbientWeatherRateLimitError( + response.statusText || 'Too Many Requests', + { + status: 429, + statusText: response.statusText, + body, + retryAfter, + }, + ); + } + + throw new AmbientWeatherAPIError( + response.statusText || 'Ambient Weather API error', + response.status, + { + status: response.status, + statusText: response.statusText, + body, + retryAfter, + }, + ); + } + + return (await response.json()) as T; +} diff --git a/packages/ambientweather/endpoints/devices.ts b/packages/ambientweather/endpoints/devices.ts new file mode 100644 index 000000000..134e375f3 --- /dev/null +++ b/packages/ambientweather/endpoints/devices.ts @@ -0,0 +1,89 @@ +import { AuthMissingError } from 'corsair/core'; +import type { AmbientWeatherEndpoints } from '..'; +import { makeAmbientWeatherRequest, parseAmbientWeatherKey } from '../client'; +import { pickAmbientWeatherReadingFields } from '../schema/database'; +import type { AmbientWeatherEndpointOutputs } from './types'; +import { + AmbientWeatherDeviceDataResponseSchema, + AmbientWeatherDeviceListResponseSchema, +} from './types'; + +function requireAmbientWeatherCredentials(key: string): { + apiKey: string; + applicationKey: string; +} { + const credentials = parseAmbientWeatherKey(key); + if (!credentials) { + throw new AuthMissingError('ambientweather', 'api_key'); + } + return credentials; +} + +export const list: AmbientWeatherEndpoints['devicesList'] = async ( + ctx, + _input, +) => { + const { apiKey, applicationKey } = requireAmbientWeatherCredentials(ctx.key); + + const response = AmbientWeatherDeviceListResponseSchema.parse( + await makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['devicesList'] + >('/v1/devices', apiKey, applicationKey), + ); + + if (ctx.db.devices) { + for (const device of response) { + try { + await ctx.db.devices.upsertByEntityId(device.macAddress, { + macAddress: device.macAddress, + name: device.info.name, + location: device.info.location, + ...pickAmbientWeatherReadingFields(device.lastData), + }); + } catch (error) { + console.warn('Failed to save Ambient Weather device:', error); + } + } + } + + return response; +}; + +export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( + ctx, + input, +) => { + const { apiKey, applicationKey } = requireAmbientWeatherCredentials(ctx.key); + + const response = AmbientWeatherDeviceDataResponseSchema.parse( + await makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['devicesGetData'] + >( + `/v1/devices/${encodeURIComponent(input.macAddress)}`, + apiKey, + applicationKey, + { + query: { + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + }, + }, + ), + ); + + if (ctx.db.readings) { + for (const point of response) { + try { + const entityId = `${input.macAddress}:${point.dateutc}`; + await ctx.db.readings.upsertByEntityId(entityId, { + macAddress: input.macAddress, + ...pickAmbientWeatherReadingFields(point), + }); + } catch (error) { + console.warn('Failed to save Ambient Weather reading:', error); + } + } + } + + return response; +}; diff --git a/packages/ambientweather/endpoints/index.ts b/packages/ambientweather/endpoints/index.ts new file mode 100644 index 000000000..ab8d62442 --- /dev/null +++ b/packages/ambientweather/endpoints/index.ts @@ -0,0 +1,8 @@ +import { getData, list } from './devices'; + +export const Devices = { + list, + getData, +}; + +export * from './types'; diff --git a/packages/ambientweather/endpoints/types.ts b/packages/ambientweather/endpoints/types.ts new file mode 100644 index 000000000..c1ed85494 --- /dev/null +++ b/packages/ambientweather/endpoints/types.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; + +export const AmbientWeatherDeviceInfoSchema = z + .object({ + name: z.string(), + location: z.string().optional(), + }) + .passthrough(); + +/** + * Device data point fields from the Ambient Weather REST sample + wiki. + * `.passthrough()` keeps station-specific sensors (temp1f, pm25, …). + * https://ambientweather.docs.apiary.io/ + * https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs + */ +export const AmbientWeatherDataPointSchema = z + .object({ + dateutc: z.number().int(), + date: z.string().optional(), + tz: z.string().optional(), + winddir: z.number().optional(), + windspeedmph: z.number().optional(), + windgustmph: z.number().optional(), + maxdailygust: z.number().optional(), + windgustdir: z.number().optional(), + winddir_avg2m: z.number().optional(), + windspdmph_avg2m: z.number().optional(), + winddir_avg10m: z.number().optional(), + windspdmph_avg10m: z.number().optional(), + tempf: z.number().optional(), + humidity: z.number().optional(), + baromrelin: z.number().optional(), + baromabsin: z.number().optional(), + tempinf: z.number().optional(), + humidityin: z.number().optional(), + hourlyrainin: z.number().optional(), + dailyrainin: z.number().optional(), + weeklyrainin: z.number().optional(), + monthlyrainin: z.number().optional(), + yearlyrainin: z.number().optional(), + eventrainin: z.number().optional(), + totalrainin: z.number().optional(), + uv: z.number().optional(), + solarradiation: z.number().optional(), + feelsLike: z.number().optional(), + dewPoint: z.number().optional(), + lastRain: z.string().optional(), + }) + .passthrough(); + +export const AmbientWeatherDeviceListItemSchema = z + .object({ + macAddress: z.string(), + info: AmbientWeatherDeviceInfoSchema, + lastData: AmbientWeatherDataPointSchema, + }) + .passthrough(); + +export const AmbientWeatherDeviceListResponseSchema = z.array( + AmbientWeatherDeviceListItemSchema, +); + +export const AmbientWeatherDeviceDataResponseSchema = z.array( + AmbientWeatherDataPointSchema, +); + +export const AmbientWeatherDevicesListInputSchema = z.object({}); + +/** Docs: limit max 288; omit to use Ambient Weather's default (288). */ +export const AmbientWeatherDevicesGetDataInputSchema = z.object({ + macAddress: z.string().min(1), + limit: z.coerce.number().int().min(1).max(288).optional(), + endDate: z.coerce.number().int().optional(), +}); + +export type AmbientWeatherDeviceInfo = z.infer< + typeof AmbientWeatherDeviceInfoSchema +>; +export type AmbientWeatherDataPoint = z.infer< + typeof AmbientWeatherDataPointSchema +>; +export type AmbientWeatherDeviceListItem = z.infer< + typeof AmbientWeatherDeviceListItemSchema +>; +export type AmbientWeatherDeviceListResponse = z.infer< + typeof AmbientWeatherDeviceListResponseSchema +>; +export type AmbientWeatherDeviceDataResponse = z.infer< + typeof AmbientWeatherDeviceDataResponseSchema +>; +export type AmbientWeatherDevicesListInput = z.infer< + typeof AmbientWeatherDevicesListInputSchema +>; +export type AmbientWeatherDevicesGetDataInput = z.infer< + typeof AmbientWeatherDevicesGetDataInputSchema +>; + +export type AmbientWeatherEndpointInputs = { + devicesList: AmbientWeatherDevicesListInput; + devicesGetData: AmbientWeatherDevicesGetDataInput; +}; + +export type AmbientWeatherEndpointOutputs = { + devicesList: AmbientWeatherDeviceListResponse; + devicesGetData: AmbientWeatherDeviceDataResponse; +}; + +export const AmbientWeatherEndpointInputSchemas = { + devicesList: AmbientWeatherDevicesListInputSchema, + devicesGetData: AmbientWeatherDevicesGetDataInputSchema, +} as const; + +export const AmbientWeatherEndpointOutputSchemas = { + devicesList: AmbientWeatherDeviceListResponseSchema, + devicesGetData: AmbientWeatherDeviceDataResponseSchema, +} as const; diff --git a/packages/ambientweather/error-handlers.ts b/packages/ambientweather/error-handlers.ts new file mode 100644 index 000000000..b1d520229 --- /dev/null +++ b/packages/ambientweather/error-handlers.ts @@ -0,0 +1,57 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import type { AmbientWeatherAPIError } from './client'; + +function getStatus(error: Error): number | undefined { + return ( + (error as Partial).code ?? + (error as Partial).status + ); +} + +function getRetryAfter(error: Error): number | undefined { + return (error as Partial).retryAfter; +} + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error) => { + const status = getStatus(error); + if (status === 429) return true; + const message = error.message.toLowerCase(); + return message.includes('rate limit') || message.includes('429'); + }, + handler: async (error) => ({ + maxRetries: 0, + headersRetryAfterMs: getRetryAfter(error), + }), + }, + AUTH_ERROR: { + match: (error) => { + const status = getStatus(error); + if (status === 401 || status === 403) return true; + const message = error.message.toLowerCase(); + return ( + message.includes('unauthorized') || + message.includes('forbidden') || + message.includes('authentication') || + message.includes('apikey') || + message.includes('applicationkey') + ); + }, + handler: async () => ({ maxRetries: 0 }), + }, + SERVER_ERROR: { + match: (error) => { + const status = getStatus(error); + return status !== undefined && status >= 500; + }, + handler: async () => ({ + maxRetries: 2, + retryStrategy: 'exponential_backoff' as const, + }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/ambientweather/index.ts b/packages/ambientweather/index.ts new file mode 100644 index 000000000..4fbe16159 --- /dev/null +++ b/packages/ambientweather/index.ts @@ -0,0 +1,187 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { packAmbientWeatherCredentials } from './client'; +import { Devices } from './endpoints'; +import type { + AmbientWeatherEndpointInputs, + AmbientWeatherEndpointOutputs, +} from './endpoints/types'; +import { + AmbientWeatherEndpointInputSchemas, + AmbientWeatherEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AmbientWeatherSchema } from './schema'; + +export type AmbientWeatherPluginOptions = { + authType?: PickAuth<'api_key'>; + hooks?: InternalAmbientWeatherPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AmbientWeatherContext = CorsairPluginContext< + typeof AmbientWeatherSchema, + AmbientWeatherPluginOptions, + undefined, + typeof ambientweatherAuthConfig +>; + +export type AmbientWeatherKeyBuilderContext = KeyBuilderContext< + AmbientWeatherPluginOptions, + typeof ambientweatherAuthConfig +>; + +export type AmbientWeatherBoundEndpoints = BindEndpoints< + typeof ambientweatherEndpointsNested +>; + +type AmbientWeatherEndpoint = + CorsairEndpoint< + AmbientWeatherContext, + AmbientWeatherEndpointInputs[K], + AmbientWeatherEndpointOutputs[K] + >; + +export type AmbientWeatherEndpoints = { + devicesList: AmbientWeatherEndpoint<'devicesList'>; + devicesGetData: AmbientWeatherEndpoint<'devicesGetData'>; +}; + +const ambientweatherEndpointsNested = { + devices: { + list: Devices.list, + getData: Devices.getData, + }, +} as const; + +const ambientweatherWebhooksNested = {} as const; + +export const ambientweatherEndpointSchemas = { + 'devices.list': { + input: AmbientWeatherEndpointInputSchemas.devicesList, + output: AmbientWeatherEndpointOutputSchemas.devicesList, + }, + 'devices.getData': { + input: AmbientWeatherEndpointInputSchemas.devicesGetData, + output: AmbientWeatherEndpointOutputSchemas.devicesGetData, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof ambientweatherEndpointsNested +>; + +const ambientweatherEndpointMeta = { + 'devices.list': { + riskLevel: 'read', + description: + 'List all Ambient Weather devices for the connected account with their latest readings', + }, + 'devices.getData': { + riskLevel: 'read', + description: + 'Fetch historical weather data for a specific Ambient Weather device', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof ambientweatherEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +export const ambientweatherAuthConfig = { + api_key: { + account: ['applicationKey'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAmbientWeatherPlugin = + CorsairPlugin< + 'ambientweather', + typeof AmbientWeatherSchema, + typeof ambientweatherEndpointsNested, + typeof ambientweatherWebhooksNested, + T, + typeof defaultAuthType, + typeof ambientweatherAuthConfig + >; + +export type InternalAmbientWeatherPlugin = + BaseAmbientWeatherPlugin; + +export type ExternalAmbientWeatherPlugin< + T extends AmbientWeatherPluginOptions, +> = BaseAmbientWeatherPlugin; + +export function ambientweather( + incomingOptions: AmbientWeatherPluginOptions & + T = {} as AmbientWeatherPluginOptions & T, +): ExternalAmbientWeatherPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + + return { + id: 'ambientweather', + authConfig: ambientweatherAuthConfig, + schema: AmbientWeatherSchema, + options, + hooks: options.hooks, + endpoints: ambientweatherEndpointsNested, + webhooks: ambientweatherWebhooksNested, + endpointMeta: ambientweatherEndpointMeta, + endpointSchemas: ambientweatherEndpointSchemas, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AmbientWeatherKeyBuilderContext) => { + if (ctx.authType !== 'api_key') { + throw new AuthMissingError('ambientweather', 'api_key'); + } + + const [apiKey, applicationKey] = await Promise.all([ + ctx.keys.get_api_key(), + ctx.keys.get_applicationKey(), + ]); + + if (!apiKey || !applicationKey) { + throw new AuthMissingError('ambientweather', 'api_key'); + } + + return packAmbientWeatherCredentials({ apiKey, applicationKey }); + }, + } satisfies InternalAmbientWeatherPlugin; +} + +export { + AmbientWeatherAPIError, + AmbientWeatherCredentialsSchema, + AmbientWeatherRateLimitError, + makeAmbientWeatherRequest, + packAmbientWeatherCredentials, + parseAmbientWeatherKey, +} from './client'; +export type { + AmbientWeatherDataPoint, + AmbientWeatherDeviceInfo, + AmbientWeatherDeviceListItem, + AmbientWeatherDeviceListResponse, + AmbientWeatherDevicesGetDataInput, + AmbientWeatherDevicesListInput, + AmbientWeatherEndpointInputs, + AmbientWeatherEndpointOutputs, +} from './endpoints/types'; diff --git a/packages/ambientweather/jest.config.cjs b/packages/ambientweather/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/ambientweather/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/ambientweather/package.json b/packages/ambientweather/package.json new file mode 100644 index 000000000..df285a397 --- /dev/null +++ b/packages/ambientweather/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/ambientweather", + "version": "0.1.0", + "description": "AmbientWeather plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "ambientweather", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/ambientweather/schema.test.ts b/packages/ambientweather/schema.test.ts new file mode 100644 index 000000000..365ea4246 --- /dev/null +++ b/packages/ambientweather/schema.test.ts @@ -0,0 +1,18 @@ +import { AmbientWeatherSchema } from './schema'; + +describe('AmbientWeather schema', () => { + it('declares a semver version', () => { + expect(AmbientWeatherSchema.version).toBeDefined(); + expect(AmbientWeatherSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares devices and readings entities from Ambient Weather docs', () => { + expect(Object.keys(AmbientWeatherSchema.entities).sort()).toEqual([ + 'devices', + 'readings', + ]); + for (const entity of Object.values(AmbientWeatherSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); diff --git a/packages/ambientweather/schema/database.ts b/packages/ambientweather/schema/database.ts new file mode 100644 index 000000000..49fb712ae --- /dev/null +++ b/packages/ambientweather/schema/database.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +/** + * Core Ambient Weather reading fields from the REST API docs sample + wiki. + * https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs + */ +const AmbientWeatherReadingFieldsSchema = z.object({ + dateutc: z.number().int(), + date: z.string().optional(), + tz: z.string().optional(), + winddir: z.number().optional(), + windspeedmph: z.number().optional(), + windgustmph: z.number().optional(), + maxdailygust: z.number().optional(), + windgustdir: z.number().optional(), + winddir_avg2m: z.number().optional(), + windspdmph_avg2m: z.number().optional(), + winddir_avg10m: z.number().optional(), + windspdmph_avg10m: z.number().optional(), + tempf: z.number().optional(), + humidity: z.number().optional(), + baromrelin: z.number().optional(), + baromabsin: z.number().optional(), + tempinf: z.number().optional(), + humidityin: z.number().optional(), + hourlyrainin: z.number().optional(), + dailyrainin: z.number().optional(), + weeklyrainin: z.number().optional(), + monthlyrainin: z.number().optional(), + yearlyrainin: z.number().optional(), + eventrainin: z.number().optional(), + totalrainin: z.number().optional(), + uv: z.number().optional(), + solarradiation: z.number().optional(), + feelsLike: z.number().optional(), + dewPoint: z.number().optional(), + lastRain: z.string().optional(), +}); + +/** Latest-reading cache row from GET /v1/devices. */ +export const AmbientWeatherDevice = z.object({ + macAddress: z.string(), + name: z.string(), + location: z.string().optional(), + ...AmbientWeatherReadingFieldsSchema.shape, + dateutc: z.number().int().optional(), + checkedAt: z.coerce.date().nullable().optional(), +}); + +/** Historical reading cache row from GET /v1/devices/{macAddress}. */ +export const AmbientWeatherReading = z.object({ + macAddress: z.string(), + ...AmbientWeatherReadingFieldsSchema.shape, + checkedAt: z.coerce.date().nullable().optional(), +}); + +export type AmbientWeatherDevice = z.infer; +export type AmbientWeatherReading = z.infer; + +export function pickAmbientWeatherReadingFields( + data: unknown, +): z.infer { + return AmbientWeatherReadingFieldsSchema.parse(data); +} diff --git a/packages/ambientweather/schema/index.ts b/packages/ambientweather/schema/index.ts new file mode 100644 index 000000000..238c87ebe --- /dev/null +++ b/packages/ambientweather/schema/index.ts @@ -0,0 +1,15 @@ +import { AmbientWeatherDevice, AmbientWeatherReading } from './database'; + +export const AmbientWeatherSchema = { + version: '1.0.0', + entities: { + devices: AmbientWeatherDevice, + readings: AmbientWeatherReading, + }, +} as const; + +export { + AmbientWeatherDevice, + AmbientWeatherReading, + pickAmbientWeatherReadingFields, +} from './database'; diff --git a/packages/ambientweather/tsconfig.json b/packages/ambientweather/tsconfig.json new file mode 100644 index 000000000..9653f2a43 --- /dev/null +++ b/packages/ambientweather/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["**/dist/**", "**/node_modules/**", "**/*.test.ts"], + "references": [] +} diff --git a/packages/ambientweather/tsup.config.ts b/packages/ambientweather/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/ambientweather/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 1401222a5..4de5e8529 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -25,6 +25,7 @@ export const BaseProviders = [ 'airtable', 'algolia', 'alttextai', + 'ambientweather', 'ambee', 'amplitude', 'apilabz', @@ -133,6 +134,7 @@ export const ProviderDisplayNames = { airtable: 'Airtable', algolia: 'Algolia', alttextai: 'AltText.ai', + ambientweather: 'Ambient Weather', ambee: 'Ambee', amplitude: 'Amplitude', apilabz: 'API Labz', @@ -248,6 +250,7 @@ export type AllProviders = | 'airtable' | 'algolia' | 'alttextai' + | 'ambientweather' | 'ambee' | 'amplitude' | 'apilabz' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a403012d7..230242f03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -596,7 +596,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambee: + packages/ambientweather: devDependencies: '@types/jest': specifier: ^29.5.14