From 32e273393f21f71163edaf0480716129bf8e3a1e Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Fri, 3 May 2024 16:58:30 +0930 Subject: [PATCH 1/6] Weather API definition. --- docs/src/SUMMARY.md | 3 + .../plugins/resource_provider_plugins.md | 2 +- .../plugins/weather_provider_plugins.md | 176 +++++ docs/src/develop/rest-api/weather_api.md | 61 ++ docs/src/features/weather/weather.md | 54 ++ packages/server-api/src/index.ts | 2 + packages/server-api/src/weatherapi.ts | 115 +++ src/api/index.ts | 13 +- src/api/swagger.ts | 4 +- src/api/weather/index.ts | 476 ++++++++++++ src/api/weather/openApi.json | 725 ++++++++++++++++++ src/api/weather/openApi.ts | 8 + 12 files changed, 1636 insertions(+), 3 deletions(-) create mode 100644 docs/src/develop/plugins/weather_provider_plugins.md create mode 100644 docs/src/develop/rest-api/weather_api.md create mode 100644 docs/src/features/weather/weather.md create mode 100644 packages/server-api/src/weatherapi.ts create mode 100644 src/api/weather/index.ts create mode 100644 src/api/weather/openApi.json create mode 100644 src/api/weather/openApi.ts diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 7f0059c92..0942b480c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -15,6 +15,7 @@ # Feature How Tos * [Anchor Alarm](./features/anchoralarm/anchoralarm.md) * [NMEA0183 Server](./features/navdataserver/navdataserver.md) + * [Working with Weather Data](./features/weather/weather.md) # Support * [Help & Support](./support/help.md) * [Sponsor](./support/sponsor.md) @@ -29,6 +30,7 @@ * [Resource Providers](./develop/plugins/resource_provider_plugins.md) * [Course Providers](./develop/rest-api/course_calculations.md) * [Autopilot Providers](./develop/plugins/autopilot_provider_plugins.md) + * [Weather Providers](./develop/plugins/weather_provider_plugins.md) * [Publishing to the AppStore](./develop/plugins/publishing.md) * [REST APIs](./develop/rest-api/open_api.md) * [Course API](./develop/rest-api/course_api.md) @@ -37,5 +39,6 @@ * [Notifications API](./develop/rest-api/notifications_api.md) * [Autopilot API](./develop/rest-api/autopilot_api.md) * [Anchor API](./develop/rest-api/anchor_api.md) + * [Weather API](./develop/rest-api/weather_api.md) * [Contribute](./develop/contributing.md) diff --git a/docs/src/develop/plugins/resource_provider_plugins.md b/docs/src/develop/plugins/resource_provider_plugins.md index 9e899f85a..df88c7c78 100644 --- a/docs/src/develop/plugins/resource_provider_plugins.md +++ b/docs/src/develop/plugins/resource_provider_plugins.md @@ -81,7 +81,7 @@ _**Note: The Resource Provider is responsible for implementing the methods and r _Note: It is the responsibility of the resource provider plugin to filter the resources returned as per the supplied query parameters._ -- `query:` Object contining `key | value` pairs repesenting the parameters by which to filter the returned entries. _e.g. {region: 'fishing_zone'}_ +- `query:` Object containing `key | value` pairs repesenting the parameters by which to filter the returned entries. _e.g. {region: 'fishing_zone'}_ returns: `Promise<{[id: string]: any}>` diff --git a/docs/src/develop/plugins/weather_provider_plugins.md b/docs/src/develop/plugins/weather_provider_plugins.md new file mode 100644 index 000000000..a257803ea --- /dev/null +++ b/docs/src/develop/plugins/weather_provider_plugins.md @@ -0,0 +1,176 @@ +# Weather Provider Plugins + +#### (Under Development) + +_Note: This API is currently under development and the information provided here is likely to change._ + + +The Signal K server [Weather API](../rest-api/autopilot_api.md) will provide a common set of operations for retrieving meteorological data and (like the Resources API) will rely on a "provider plugin" to facilitate communication with the autopilot device. + +--- + +## Provider plugins: + +A weather provider plugin is a Signal K server plugin that implements the **Weather Provider Interface** which: +- Tells server that the plugin is a weather data source +- Registers the methods used to action requests passed from the server to retrieve data from the weather provider. + +_Note: multiple weather providers can be registered._ + +The `WeatherProvider` interface is defined as follows in _`@signalk/server-api`_: + +```typescript +interface WeatherProvider { + name: string + methods: WeatherProviderMethods +} +``` +where: + +- `name`: The weather ssource name. _(e.g. `'OpenWeather'`, `'Open-Meteo'`)_ + +- `methods`: An object implementing the `WeatherProviderMethods` interface defining the functions to which requests are passed by the SignalK server. _Note: The plugin __MUST__ implement each method, even if that operation is NOT supported by the plugin!_ + +The `WeatherProviderMethods` interface is defined as follows in _`@signalk/server-api`_: + +```typescript +interface WeatherProviderMethods { + getData: (position: Position) => Promise + getObservations: ( + position: Position + ) => Promise + getForecasts: (position: Position) => Promise + getWarnings: (position: Position) => Promise +} +``` + +_**Note: The Weather Provider is responsible for implementing the methods and returning data in the required format!**_ + + + +### Provider Methods: + +**`getData(position)`**: This method is called when a request to retrieve weather data for the provided position is made. + + +- `position:` Object containing the location of interest. _e.g. {latitude: 16.34765, longitude: 12.5432}_ + +returns: `Promise` + +_Example: Return weather information for location {latitude: 16.34765, longitude: 12.5432}:_ +``` +GET /signalk/v2/api/weather?lat=6.34765&lon=12.5432 +``` +_WeatherProvider method invocation:_ +```javascript +getData({latitude: 16.34765, longitude: 12.5432}); +``` + +_Returns:_ +```JSON +{ + "id": "df85kfo", + "position": {"latitude": 16.34765, "longitude": 12.5432}, + "observations": [...], + "forecasts": [...], + "warnings": [...] +} +``` + +--- + +**`getObservations(position)`**: This method is called when a request to retrieve observation data for the provided position is made. + + +- `position:` Object containing the location of interest. _e.g. {latitude: 16.34765, longitude: 12.5432}_ + + +returns: `Promise` + + +_Example: Return observations for location {latitude: 16.34765, longitude: 12.5432}:_ +``` +GET /signalk/v2/api/weather/observations?lat=6.34765&lon=12.5432 +``` + +_WeatherProvider method invocation:_ +```javascript +getObservations({latitude: 16.34765, longitude: 12.5432}); +``` + +_Returns:_ +```JSON +[ + { + "date": "2024-05-03T06:00:00.259Z", + "type": "observation", + "outside": { ... } + }, + { + "date": "2024-05-03T05:00:00.259Z", + "type": "observation", + "outside": { ... } + } +] +``` + +--- + +**`getForecasts(position)`**: This method is called when a request to retrieve observation data for the provided position is made. + + +- `position:` Object containing the location of interest. _e.g. {latitude: 16.34765, longitude: 12.5432}_ + +returns: `Promise` + + +_Example: Return forecasts for location {latitude: 16.34765, longitude: 12.5432}:_ +``` +GET /signalk/v2/api/weather/forecasts?lat=6.34765&lon=12.5432 +``` + +_WeatherProvider method invocation:_ +```javascript +getForecasts({latitude: 16.34765, longitude: 12.5432}); +``` + +_Returns:_ +```JSON +[ + { + "date": "2024-05-03T06:00:00.259Z", + "type": "point", + "outside": { ... } + }, + { + "date": "2024-05-03T05:00:00.259Z", + "type": "point", + "outside": { ... } + } +] +``` + +--- + +**`getWarnings(position)`**: This method is called when a request to retrieve warning data for the provided position is made. + +- `position:` Object containing the location of interest. _e.g. {latitude: 16.34765, longitude: 12.5432}_ + +returns: `Promise` + + +_Example: Return warnings for location {latitude: 16.34765, longitude: 12.5432}:_ +``` +GET /signalk/v2/api/weather/warnings?lat=6.34765&lon=12.5432 +``` +_WeatherProvider method invocation:_ +```javascript +getWarnings({latitude: 16.34765, longitude: 12.5432}); +``` + +_Returns:_ +```JSON +[ + +] +``` diff --git a/docs/src/develop/rest-api/weather_api.md b/docs/src/develop/rest-api/weather_api.md new file mode 100644 index 000000000..4c94c1f94 --- /dev/null +++ b/docs/src/develop/rest-api/weather_api.md @@ -0,0 +1,61 @@ +# Weather API + +#### (Under Development) + +_Note: This API is currently under development and the information provided here is likely to change._ + +The Signal K server Weather API will provide a common set of operations for interacting with weather sources and (like the Resources API) will rely on a "provider plugin" to facilitate communication with the weather source. + +The Weather API will handle requests to `/signalk/v2/api/weather` paths and pass them to an Weather Provider plugin which will return data fetched from the weather service. + +The following operations are an example of the operations identified for implementation via HTTP `GET` requests: + +```javascript +// fetch weather data for the provided location +GET "/signalk/v2/api/weather?lat=5.432&lon=7.334" +``` + +```javascript +// Returns an array of observations for the provided location +GET "/signalk/v2/api/weather/observations?lat=5.432&lon=7.334" +``` + +```javascript +// Returns an array of forecasts for the provided location +GET "/signalk/v2/api/weather/forecasts?lat=5.432&lon=7.334" +``` + +```javascript +// Returnsjavascript an array of warnings for the provided location +GET "/signalk/v2/api/weather/warnings?lat=5.432&lon=7.334" +``` + +The Weather API supports the registration of multiple weather provider plugins. The first plugin registered is set as the default source for all API requests. + +A list of the registered providers can be retrieved using a HTTP `GET` request to `/signalk/v2/api/weather/providers`. + +```javascript +GET "/signalk/v2/api/weather/providers" +``` + +_Example response:_ +```JSON +{ + "providerId1": { + "name":"my-provider-1", + "isDefault":true + }, + "providerId2": { + "name":"my-provider-2", + "isDefault":false + } +} +``` + +The provider can be changed to another of those listed by using a HTTP `POST` request and supplying the provider identifier in the body of the request: + +```javascript +POST "/signalk/v2/api/weather/providers" { + "id": "providerId2" +} +``` diff --git a/docs/src/features/weather/weather.md b/docs/src/features/weather/weather.md new file mode 100644 index 000000000..be46cbfc3 --- /dev/null +++ b/docs/src/features/weather/weather.md @@ -0,0 +1,54 @@ +# Working with Weather Data + +## Introduction + +This document outlines the way in which weather data is managed in Signal K and how to reliably access and use weather data from various sources. + +The Signal K specification defines an [`environment`](https://github.com/SignalK/specification/blob/master/schemas/groups/environment.json) schema which contains attributes pertaining to weather and the environment, grouped under headings such as `outside`, `inside`, `water`, `wind`, etc. + +The `environment` schema is then able to be applied to Signal K contexts such as `vessel`, `aton`, `meteo`, etc. In order for Signal K client apps to reliably consume weather data it is important to understand these contexts and their use. + + +## On Vessel sensors + +The values from environment sensors installed on a vesssel which provide measurements in relation to that vessel are foound under the `vessels.self` context. + +_Example:_ + +- `vessels.self.environment.outside` - Measurements taken outside the vessel hull +- `vessels.self.environment.inside` - Measurements taken inside the vessel hull +- `vessels.self.environment.water` - Measurements taken from the water the vessel is in. + + +## AIS Weather stations + +Weather observation data sourced from AIS weather stations via `VDM` sentences are found under the `meteo` context, with each station having a unique identifier. + +_Example:_ + +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.outside` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.inside` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.water` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.tide` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.current` + + +## Weather Services + +Data sourced from weather services is generally comprised of a collection of observations, forecasts and warnings for a specific location updated at regular intervals (e.g. hourly). + +Observations may be a combination of current and historical measurements and forecasts a combination of daily and "point in time" values. + +The nature of this data makes it more suited to a REST API rather than a websocket stream and as such the [Signal K Weather API](../../develop/rest-api/weather_api.md) is where this information is made available. + +Signal K Server plugins are the vehicle for fetching and transforming the data from the various data services and make it available via the Weather API. + +For example, a `provider plugin` sourcing data from the Open-Meteo service can provide current and historical observation data as well as daily and hourly forecast information. + + +_Example:_ + +- `GET "/signalk/v2/api/weather?lat=5.432&lon=7.334` +- `GET "/signalk/v2/api/weather/forecasts?lat=5.432&lon=7.334` +- `GET "/signalk/v2/api/weather/observations?lat=5.432&lon=7.334` + diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index fc7a6c532..48fd76368 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -30,6 +30,7 @@ import { ResourceProviderRegistry } from './resourcesapi' import { PointDestination, RouteDestination, CourseInfo } from './coursetypes' export * from './autopilotapi' +export * from './weatherapi' export type SignalKApiId = | 'resources' @@ -38,6 +39,7 @@ export type SignalKApiId = | 'autopilot' | 'anchor' | 'logbook' + | 'weather' | 'historyplayback' //https://signalk.org/specification/1.7.0/doc/streaming_api.html#history-playback | 'historysnapshot' //https://signalk.org/specification/1.7.0/doc/rest_api.html#history-snapshot-retrieval diff --git a/packages/server-api/src/weatherapi.ts b/packages/server-api/src/weatherapi.ts new file mode 100644 index 000000000..c34c50846 --- /dev/null +++ b/packages/server-api/src/weatherapi.ts @@ -0,0 +1,115 @@ +import { Position } from '.' + +export interface WeatherApi { + register: (pluginId: string, provider: WeatherProvider) => void + unRegister: (pluginId: string) => void + emitWarning: ( + pluginId: string, + position: Position, + warnings: WeatherWarning[] + ) => void +} + +export interface WeatherProviderRegistry { + registerWeatherProvider: (provider: WeatherProvider) => void +} + +export interface WeatherProviders { + [id: string]: { + name: string + isDefault: boolean + } +} + +export interface WeatherProvider { + name: string // e.g. OpenWeather, Open-Meteo, NOAA + methods: WeatherProviderMethods +} + +export interface WeatherProviderMethods { + pluginId?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getData: (position: Position) => Promise + getObservations: (position: Position) => Promise + getForecasts: (position: Position) => Promise + getWarnings: (position: Position) => Promise +} + +export interface WeatherProviderData { + id: string + position: Position + observations: WeatherData[] + forecasts: WeatherData[] + warnings?: Array +} + +export interface WeatherWarning { + startTime: string + endTime: string + details: string + source: string + type: string +} + +// Aligned with Signal K environment specification +export interface WeatherData { + description: string + date: string + type: 'daily' | 'point' | 'observation' // daily forecast, point-in-time forecast, observed values + outside?: { + minTemperature?: number + maxTemperature?: number + feelsLikeTemperature: number + precipitationVolume?: number + absoluteHumidity?: number + horizontalVisibility?: number + uvIndex?: number + cloudCover?: number + temperature?: number + dewPointTemperature?: number + pressure?: number + pressureTendency?: TendencyKind + relativeHumidity?: number + precipitationType?: PrecipitationKind + } + water?: { + temperature?: number + level?: number + levelTendency?: TendencyKind + surfaceCurrentSpeed?: number + surfaceCurrentDirection?: number + salinity?: number + waveSignificantHeight?: number + wavePeriod?: number + waveDirection?: number + swellHeight?: number + swellPeriod?: number + swellDirection?: number + } + wind?: { + speedTrue?: number + directionTrue?: number + gust?: number + gustDirection?: number + } + sun?: { + sunrise?: string + sunset?: string + } +} + +export type TendencyKind = + | 'steady' + | 'decreasing' + | 'increasing' + | 'not available' + +export type PrecipitationKind = + | 'reserved' + | 'rain' + | 'thunderstorm' + | 'freezing rain' + | 'mixed/ice' + | 'snow' + | 'reserved' + | 'not available' diff --git a/src/api/index.ts b/src/api/index.ts index a3ccff4ea..d62dcb71e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,6 +4,7 @@ import { WithSecurityStrategy } from '../security' import { CourseApi } from './course' import { FeaturesApi } from './discovery' import { ResourcesApi } from './resources' +import { WeatherApi } from './weather' import { SignalKApiId } from '@signalk/server-api' export interface ApiResponse { @@ -56,8 +57,18 @@ export const startApis = ( ;(app as any).courseApi = courseApi apiList.push('course') + const weatherApi = new WeatherApi(app) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(app as any).weatherApi = weatherApi + apiList.push('weather') + const featuresApi = new FeaturesApi(app) - Promise.all([resourcesApi.start(), courseApi.start(), featuresApi.start()]) + Promise.all([ + resourcesApi.start(), + courseApi.start(), + weatherApi.start(), + featuresApi.start() + ]) return apiList } diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 028b8719e..d02cf493c 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -7,6 +7,7 @@ import { notificationsApiRecord } from './notifications/openApi' import { resourcesApiRecord } from './resources/openApi' import { securityApiRecord } from './security/openApi' import { discoveryApiRecord } from './discovery/openApi' +import { weatherApiRecord } from './weather/openApi' import { appsApiRecord } from './apps/openApi' import { PluginId, PluginManager } from '../interfaces/plugins' import { Brand } from '@signalk/server-api' @@ -29,7 +30,8 @@ const apiDocs = [ securityApiRecord, courseApiRecord, notificationsApiRecord, - resourcesApiRecord + resourcesApiRecord, + weatherApiRecord ].reduce((acc, apiRecord: OpenApiRecord) => { acc[apiRecord.name] = apiRecord return acc diff --git a/src/api/weather/index.ts b/src/api/weather/index.ts new file mode 100644 index 000000000..a2e888eec --- /dev/null +++ b/src/api/weather/index.ts @@ -0,0 +1,476 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:weather') + +import { IRouter, NextFunction, Request, Response } from 'express' +import { WithSecurityStrategy } from '../../security' + +import { Responses } from '../' +import { SignalKMessageHub } from '../../app' + +import { + WeatherProvider, + WeatherProviders, + WeatherProviderMethods, + WeatherWarning, + //isWeatherProvider, + SKVersion, + Path, + Delta, + Position, + ALARM_STATE, + ALARM_METHOD +} from '@signalk/server-api' + +const WEATHER_API_PATH = `/signalk/v2/api/weather` + +interface WeatherApplication + extends WithSecurityStrategy, + SignalKMessageHub, + IRouter {} + +export class WeatherApi { + private weatherProviders: Map = new Map() + + private defaultProviderId?: string + + constructor(private app: WeatherApplication) {} + + async start() { + this.initApiEndpoints() + return Promise.resolve() + } + + // ***** Plugin Interface methods ***** + + // Register plugin as provider. + register(pluginId: string, provider: WeatherProvider) { + debug(`** Registering provider(s)....${pluginId} ${provider}`) + + if (!pluginId || !provider) { + throw new Error(`Error registering provider ${pluginId}!`) + } + /*if (!isWeatherProvider(provider)) { + throw new Error( + `${pluginId} is missing WeatherProvider properties/methods!` + ) + } else {*/ + if (!this.weatherProviders.has(pluginId)) { + this.weatherProviders.set(pluginId, provider) + } + if (this.weatherProviders.size === 1) { + this.defaultProviderId = pluginId + } + //} + debug(`No. of WeatherProviders registered =`, this.weatherProviders.size) + } + + // Unregister plugin as provider. + unRegister(pluginId: string) { + if (!pluginId) { + return + } + debug(`** Request to un-register plugin.....${pluginId}`) + + if (!this.weatherProviders.has(pluginId)) { + debug(`** NOT FOUND....${pluginId}... cannot un-register!`) + return + } + + debug(`** Un-registering autopilot provider....${pluginId}`) + this.weatherProviders.delete(pluginId) + if (pluginId === this.defaultProviderId) { + this.defaultProviderId = undefined + } + // update defaultProviderId if required + if (this.weatherProviders.size !== 0 && !this.defaultProviderId) { + this.defaultProviderId = this.weatherProviders.keys().next().value + } + debug( + `Remaining number of Weather Providers registered =`, + this.weatherProviders.size, + 'defaultProvider =', + this.defaultProviderId + ) + } + + // Send warning Notification + emitWarning( + pluginId: string, + position: Position, + warnings: WeatherWarning[] + ) { + this.sendNotification(pluginId, position, warnings) + } + + // ************************************* + + private updateAllowed(request: Request): boolean { + return this.app.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'weather' + ) + } + + /** @returns 1= OK, 0= invalid location, -1= location not provided */ + private checkLocation(req: Request): number { + if (req.query.lat && req.query.lon) { + return isNaN(Number(req.query.lat)) || isNaN(Number(req.query.lon)) + ? 0 + : 1 + } else { + return -1 + } + } + + private parseRequest(req: Request, res: Response, next: NextFunction) { + debug(`Autopilot path`, req.method, req.params) + try { + debug(`Weather`, req.method, req.path, req.query, req.body) + if (['PUT', 'POST'].includes(req.method)) { + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + } else { + next() + } + } else { + const l = this.checkLocation(req) + if (l === 1) { + next() + } else if (l === 0) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: 'Invalid position data!' + }) + } else { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: 'Location not supplied!' + }) + } + } + } catch (err: any) { + res.status(500).json({ + state: 'FAILED', + statusCode: 500, + message: err.message + }) + } + } + + private initApiEndpoints() { + debug(`** Initialise ${WEATHER_API_PATH} endpoints. **`) + + this.app.use( + `${WEATHER_API_PATH}`, + (req: Request, res: Response, next: NextFunction) => { + debug(`Using... ${WEATHER_API_PATH}`) + if (req.path.includes('providers')) { + next() + } else { + return this.parseRequest(req, res, next) + } + } + ) + + // return list of weather providers + this.app.get( + `${WEATHER_API_PATH}/providers`, + async (req: Request, res: Response) => { + debug(`**route = ${req.method} ${req.path}`) + try { + const r: WeatherProviders = {} + this.weatherProviders.forEach((v: WeatherProvider, k: string) => { + r[k] = { + name: v.name, + isDefault: k === this.defaultProviderId + } + }) + res.status(200).json(r) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // change weather provider + this.app.post( + `${WEATHER_API_PATH}/providers`, + async (req: Request, res: Response) => { + debug(`**route = ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + try { + if (!req.body.id) { + throw new Error('Provider id not supplied!') + } + if (this.weatherProviders.has(req.body.id)) { + this.defaultProviderId = req.body.id + res.status(200).json({ + statusCode: 200, + state: 'COMPLETED', + message: `Default provider set to ${req.body.id}.` + }) + } else { + throw new Error(`Provider ${req.body.id} not found!`) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // fetch weather data for provided lat / lon + this.app.get(`${WEATHER_API_PATH}`, async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + const r = await this.useProvider().getData({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + res.status(200).json(r) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + }) + + // return observation data at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/observations`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + const r = await this.useProvider().getObservations({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + res.status(200).json(r) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // return specific observation entry at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/observations/:id`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + if (isNaN(Number(req.params.id))) { + throw new Error('Invalid index supplied!') + } + const r = await this.useProvider().getObservations({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + if (Number(req.params.id) >= r.length) { + throw new Error('Index out of range!') + } + res.status(200).json(r[Number(req.params.id)]) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // return forecast data at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/forecasts`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + const r = await this.useProvider().getForecasts({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + res.status(200).json(r) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // return specific forecast entry at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/forecasts/:id`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + if (isNaN(Number(req.params.id))) { + throw new Error('Invalid index supplied!') + } + const r = await this.useProvider().getForecasts({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + if (Number(req.params.id) >= r.length) { + throw new Error('Index out of range!') + } + res.status(200).json(r[Number(req.params.id)]) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // return warning data at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/warnings`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + const r = await this.useProvider().getWarnings({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + res.status(200).json(r) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // return specific warning entry at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/warnings/:id`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + if (isNaN(Number(req.params.id))) { + throw new Error('Invalid index supplied!') + } + const r = await this.useProvider().getWarnings({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + if (Number(req.params.id) >= r.length) { + throw new Error('Index out of range!') + } + res.status(200).json(r[Number(req.params.id)]) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // error response + this.app.use( + `${WEATHER_API_PATH}/*`, + (err: any, req: Request, res: Response, next: NextFunction) => { + debug(`** route = error path **`) + const msg = { + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'Weather provider error!' + } + if (res.headersSent) { + console.log('EXCEPTION: headersSent') + return next(msg) + } + res.status(500).json(msg) + } + ) + } + + /** Returns provider to use as data source. + * @param req If not supplied default provider is returned. + */ + private useProvider(req?: Request): WeatherProviderMethods { + debug('** useProvider()') + if (this.weatherProviders.size === 0) { + throw new Error('No providers registered!') + } + if (!req) { + if ( + this.defaultProviderId && + this.weatherProviders.has(this.defaultProviderId) + ) { + debug(`Using default provider...${this.defaultProviderId}`) + return this.weatherProviders.get(this.defaultProviderId as string) + ?.methods as WeatherProviderMethods + } else { + throw new Error(`Default provider not found!`) + } + } else { + if (this.weatherProviders.has(req.params.id)) { + debug(`Provider found...using ${req.params.id}`) + return this.weatherProviders.get(req.params.id) + ?.methods as WeatherProviderMethods + } else { + throw new Error(`Cannot get provider (${req.params.id})!`) + } + } + } + + // send weather warning notification + private sendNotification( + sourceId: string, + pos: Position, + warnings: WeatherWarning[] + ) { + const msg: Delta = { + updates: [ + { + values: [ + { + path: `notifications.weather.warning` as Path, + value: { + state: ALARM_STATE.warn, + method: ALARM_METHOD.visual, + message: `Weather Warning`, + data: { + position: pos, + warnings: warnings + } + } + } + ] + } + ] + } + debug(`delta -> ${sourceId}:`, msg.updates[0]) + this.app.handleMessage(sourceId, msg, SKVersion.v2) + } +} diff --git a/src/api/weather/openApi.json b/src/api/weather/openApi.json new file mode 100644 index 000000000..8038b77ef --- /dev/null +++ b/src/api/weather/openApi.json @@ -0,0 +1,725 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.5.0", + "title": "Weather API", + "description": "Signal K weather API endpoints.", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "tags": [ + { + "name": "Weather", + "description": "Operations to interact with weather service data." + }, + { + "name": "Provider", + "description": "Operations to view / switch providers." + } + ], + "components": { + "schemas": { + "Position": { + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + }, + "IsoTime": { + "type": "string", + "description": "Date / Time when data values were recorded", + "pattern": "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2}(?:\\.\\d*)?)((-(\\d{2}):(\\d{2})|Z)?)$", + "example": "2022-04-22T05:02:56.484Z" + }, + "WeatherSourceInfoModel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "12345678", + "description": "Source identifier." + }, + "position": { + "$ref": "#/components/schemas/Position" + } + } + }, + "WeatherDataModel": { + "type": "object", + "required": ["date", "type"], + "properties": { + "date": { + "$ref": "#/components/schemas/IsoTime" + }, + "description": { + "type": "string", + "example": "broken clouds" + }, + "type": { + "type": "string", + "enum": ["daily", "point", "observation"] + }, + "sun": { + "type": "object", + "required": ["times"], + "properties": { + "sunrise": { + "$ref": "#/components/schemas/IsoTime" + }, + "sunset": { + "$ref": "#/components/schemas/IsoTime" + } + } + }, + "outside": { + "type": "object", + "properties": { + "uvIndex": { + "type": "number", + "example": 7.5, + "description": "UV Index (1 UVI = 25mW/sqm)" + }, + "cloudCover": { + "type": "number", + "example": 85, + "description": "Amount of cloud cover (%)" + }, + "horizontalVisibility": { + "type": "number", + "example": 5000, + "description": "Visibilty (m)" + }, + "horizontalVisibilityOverRange": { + "type": "boolean", + "example": "true", + "description": "Visibilty distance is greater than the range of the measuring equipment." + }, + "temperature": { + "type": "number", + "example": 290, + "description": "Air temperature (K)" + }, + "feelsLikeTemperature": { + "type": "number", + "example": 277, + "description": "Feels-like temperature (K)" + }, + "dewPointTemperature": { + "type": "number", + "example": 260, + "description": "Dew point temperature (K)" + }, + "pressure": { + "type": "number", + "example": 10100, + "description": "Air pressure (Pa)" + }, + "pressureTendency": { + "type": "string", + "enum": ["steady", "decreasing", "increasing"], + "example": "steady", + "description": "Air pressure tendency" + }, + "absoluteHumidity": { + "type": "number", + "example": 56, + "description": "Absolute humidity (%)" + }, + "relativeHumidity": { + "type": "number", + "example": 56, + "description": "Relative humidity (%)" + }, + "precipitationType": { + "type": "string", + "enum": [ + "rain", + "thunderstorm", + "snow", + "freezing rain", + "mixed/ice" + ], + "example": "rain", + "description": "Type of preceipitation" + }, + "precipitationVolume": { + "type": "number", + "example": 56, + "description": "Amount of precipitation (mm)" + } + } + }, + "wind": { + "type": "object", + "properties": { + "averageSpeed": { + "type": "number", + "example": 9.3, + "description": "Average wind speed (m/s)" + }, + "speedTrue": { + "type": "number", + "example": 15.3, + "description": "Wind speed (m/s)" + }, + "directionTrue": { + "type": "number", + "example": 2.145, + "description": "Wind direction relative to true north (rad)" + }, + "gust": { + "type": "number", + "example": 21.6, + "description": "Wind gust (m/s)" + }, + "gustDirectionTrue": { + "type": "number", + "example": 2.6, + "description": "Wind gust direction relative to true north (rad)" + } + } + }, + "water": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "example": 21.6, + "description": "Wind gust (m/s)" + }, + "level": { + "type": "number", + "example": 11.9, + "description": "Water level (m)" + }, + "levelTendency": { + "type": "number", + "enum": ["steady", "decreasing", "increasing"], + "example": "steady", + "description": "Water level trend" + }, + "waves": { + "type": "object", + "properties": { + "significantHeight": { + "type": "number", + "example": 2.6, + "description": "Wave height (m)" + }, + "directionTrue": { + "type": "number", + "example": 2.3876, + "description": "Wave direction relative to true north (rad)" + }, + "period": { + "type": "number", + "example": 2.3876, + "description": "Wave period (m/s)" + } + } + }, + "swell": { + "type": "object", + "properties": { + "height": { + "type": "number", + "example": 2.6, + "description": "Swell height (m)" + }, + "directionTrue": { + "type": "number", + "example": 2.3876, + "description": "Swell direction relative to true north (rad)" + }, + "period": { + "type": "number", + "example": 2.3876, + "description": "Swell period (m/s)" + } + } + }, + "seaState": { + "type": "number", + "example": 2, + "description": "Sea state (Beaufort)" + }, + "salinity": { + "type": "number", + "example": 12, + "description": "Water salinity (%)" + }, + "ice": { + "type": "boolean", + "example": true, + "description": "Ice present." + } + } + }, + "current": { + "type": "object", + "properties": { + "drift": { + "type": "number", + "example": 3.4, + "description": "Surface current speed (m/s)" + }, + "set": { + "type": "number", + "example": 1.74, + "description": "Surface current direction (rad)" + } + } + } + } + }, + "WeatherWarningModel": { + "type": "object", + "required": ["startTime", "endTime"], + "properties": { + "startTime": { + "$ref": "#/components/schemas/IsoTime" + }, + "endTime": { + "$ref": "#/components/schemas/IsoTime" + }, + "source": { + "type": "string", + "description": "Name of source." + }, + "type": { + "type": "string", + "description": "Type of warning.", + "example": "Heat Advisory" + }, + "details": { + "type": "string", + "description": "Text describing the details of the warning.", + "example": "HEAT ADVISORY REMAINS IN EFFECT FROM 1 PM THIS AFTERNOON...." + } + } + }, + "ObservationsAttribute": { + "type": "object", + "required": ["observations"], + "properties": { + "observations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + }, + "ForecastsAttribute": { + "type": "object", + "required": ["forecasts"], + "properties": { + "forecasts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + }, + "WarningsAttribute": { + "type": "object", + "required": ["warnings"], + "properties": { + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherWarningModel" + } + } + } + }, + "WeatherResponseModel": { + "allOf": [ + { + "$ref": "#/components/schemas/WeatherSourceInfoModel" + }, + { + "$ref": "#/components/schemas/WarningsAttribute" + }, + { + "$ref": "#/components/schemas/ObservationsAttribute" + }, + { + "$ref": "#/components/schemas/ForecastsAttribute" + } + ] + } + }, + "responses": { + "200OKResponse": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request success response", + "properties": { + "state": { + "type": "string", + "enum": ["COMPLETED"] + }, + "statusCode": { + "type": "number", + "enum": [200] + } + }, + "required": ["state", "statusCode"] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": ["FAILED"] + }, + "statusCode": { + "type": "number", + "enum": [404] + }, + "message": { + "type": "string" + } + }, + "required": ["state", "statusCode", "message"] + } + } + } + } + }, + "parameters": { + "IndexParam": { + "in": "path", + "required": true, + "name": "index", + "description": "Index of entry in a collection.", + "schema": { + "type": "number" + } + }, + "LatitudeParam": { + "in": "query", + "required": true, + "name": "lat", + "description": "Latitude at specified position.", + "schema": { + "type": "number", + "min": -90, + "max": 90 + } + }, + "LongitudeParam": { + "in": "query", + "required": true, + "name": "lon", + "description": "Longitude at specified position.", + "schema": { + "type": "number", + "min": -180, + "max": 180 + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], + "paths": { + "/weather": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Return / fetch weather data at the specified location (lat / lon).", + "responses": { + "default": { + "description": "Fetches from weather service and returns data for the specified location (lat / lon).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherResponseModel" + } + } + } + } + } + } + }, + "/weather/observations": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve observation data.", + "responses": { + "default": { + "description": "Returns the observation data for the specified location (lat / lon).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + } + } + } + } + }, + "/weather/observations/{index}": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + }, + { + "$ref": "#/components/parameters/IndexParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve supplied observation entry.", + "responses": { + "default": { + "description": "Return the observation entry at the specified index in the observation list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + } + } + } + }, + "/weather/forecasts": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve forecast data.", + "responses": { + "default": { + "description": "Returns the forecast data for the specified location (lat / lon).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + } + } + } + } + }, + "/weather/forecasts/{index}": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + }, + { + "$ref": "#/components/parameters/IndexParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve supplied forecast entry.", + "responses": { + "default": { + "description": "Return the forecast entry at the specified index in the forecast list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + } + } + } + }, + "/weather/warnings": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve warning data.", + "responses": { + "default": { + "description": "Returns the warning data for the specified location (lat / lon).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherWarningModel" + } + } + } + } + } + } + } + }, + "/weather/warnings/{index}": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + }, + { + "$ref": "#/components/parameters/IndexParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve supplied warning entry.", + "responses": { + "default": { + "description": "Return the warning entry at the specified index in the warning list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherWarningModel" + } + } + } + } + } + } + }, + "/weather/providers": { + "get": { + "tags": ["Provider"], + "summary": "Retrieve list of registered providers.", + "responses": { + "default": { + "description": "Return information about the registered weather providers.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["name", "isDefault"], + "properties": { + "name": { + "type": "string", + "description": "Provider name." + }, + "isDefault": { + "type": "boolean", + "description": "true if this provider is set as the default." + } + }, + "example": { + "name": "OpenWeather", + "isDefault": false + } + } + } + } + } + } + } + }, + "post": { + "tags": ["Provider"], + "summary": "Retrieve supplied warning entry.", + "body": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Provider identifier." + } + } + }, + "responses": { + "default": { + "$ref": "#/components/responses/ErrorResponse" + }, + "200": { + "$ref": "#/components/responses/200OKResponse" + } + } + } + } + } +} diff --git a/src/api/weather/openApi.ts b/src/api/weather/openApi.ts new file mode 100644 index 000000000..cc45c1cdb --- /dev/null +++ b/src/api/weather/openApi.ts @@ -0,0 +1,8 @@ +import { OpenApiDescription } from '../swagger' +import weatherApiDoc from './openApi.json' + +export const weatherApiRecord = { + name: 'weather', + path: '/signalk/v2/api', + apiDoc: weatherApiDoc as unknown as OpenApiDescription +} From 87534324db696c354dfc5c89f8fe1565eb18f416 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Sun, 5 May 2024 16:22:21 +0930 Subject: [PATCH 2/6] Expose methods to plugins. --- docs/src/develop/plugins/server_plugin_api.md | 47 +++++++++++++++++ .../plugins/weather_provider_plugins.md | 8 ++- docs/src/features/weather/weather.md | 52 ++++++++++--------- packages/server-api/src/index.ts | 10 +++- packages/server-api/src/weatherapi.ts | 6 +-- src/api/weather/index.ts | 40 +++++++++----- src/interfaces/plugins.ts | 20 ++++++- 7 files changed, 140 insertions(+), 43 deletions(-) diff --git a/docs/src/develop/plugins/server_plugin_api.md b/docs/src/develop/plugins/server_plugin_api.md index 7a12a4cc0..95501c854 100644 --- a/docs/src/develop/plugins/server_plugin_api.md +++ b/docs/src/develop/plugins/server_plugin_api.md @@ -652,6 +652,53 @@ in the specified direction and starting at the specified point. --- +### Weather API Interface + +The [`Weather API`](../rest-api/weather_api.md) provides the following functions for use by plugins. + +#### `app.emitWeatherWarning = (pluginId, position?, warnings?)` + +Weather API interface method for raising, updating and clearing weather warning notifications. + + - `pluginId`: The plugin identifier + + - `position`: A valid `Position` object containing latitude & longitude. + + - `warnings`: An array of `WeatherWarning` objects + +- returns: `void`. + +The notification is placed in the path `vessels.self.notifications.weather.warning`. + +To raise or update a warning, call the method with a valid `Position` object and array of `WeatherData` objects. + +_Example:_ +```javascript +app.emitWeatherWarning( + 'myWeatherPluginId', + {latitude: 54.345, longitude: 6.39845}, + [ + { + "startTime": "2024-05-03T05:00:00.259Z", + "endTime": "2024-05-03T08:00:00.702Z", + "details": "Strong wind warning.", + "source": "OpenWeather", + "type": "Warning" + } + ] +) +``` + +To clear the warning, call the method with an `undefined` position or warnings attribute. + +_Example: Clear weather warning_ +```javascript +app.emitWeatherWarning('myWeatherPluginId') +``` + +--- + + ### Notifications API _(proposed)_ #### `app.notify(path, value, pluginId)` diff --git a/docs/src/develop/plugins/weather_provider_plugins.md b/docs/src/develop/plugins/weather_provider_plugins.md index a257803ea..ca4ae55f3 100644 --- a/docs/src/develop/plugins/weather_provider_plugins.md +++ b/docs/src/develop/plugins/weather_provider_plugins.md @@ -171,6 +171,12 @@ getWarnings({latitude: 16.34765, longitude: 12.5432}); _Returns:_ ```JSON [ - + { + "startTime": "2024-05-03T05:00:00.259Z", + "endTime": "2024-05-03T08:00:00.702Z", + "details": "Strong wind warning.", + "source": "OpenWeather", + "type": "Warning" + } ] ``` diff --git a/docs/src/features/weather/weather.md b/docs/src/features/weather/weather.md index be46cbfc3..dfc4b6e2d 100644 --- a/docs/src/features/weather/weather.md +++ b/docs/src/features/weather/weather.md @@ -6,49 +6,53 @@ This document outlines the way in which weather data is managed in Signal K and The Signal K specification defines an [`environment`](https://github.com/SignalK/specification/blob/master/schemas/groups/environment.json) schema which contains attributes pertaining to weather and the environment, grouped under headings such as `outside`, `inside`, `water`, `wind`, etc. -The `environment` schema is then able to be applied to Signal K contexts such as `vessel`, `aton`, `meteo`, etc. In order for Signal K client apps to reliably consume weather data it is important to understand these contexts and their use. +The `environment` schema is then able to be applied to Signal K contexts such as `vessels`, `atons`, `meteo`, etc to allow Signal K client apps to reliably consume weather data. +Additionally, the `environment` schema is used by the `Weather API` to provide access to observation and forecast information sourced from weather service providers. -## On Vessel sensors +Following are the different contexts and their use. -The values from environment sensors installed on a vesssel which provide measurements in relation to that vessel are foound under the `vessels.self` context. -_Example:_ +## 1. On Vessel sensors -- `vessels.self.environment.outside` - Measurements taken outside the vessel hull -- `vessels.self.environment.inside` - Measurements taken inside the vessel hull -- `vessels.self.environment.water` - Measurements taken from the water the vessel is in. +Sensors installed on a vesssel making measurements directly outside of the vessel _(e.g. temperature, humidity, etc)_ are placed in the `vessels.self` context. +_On vessel sensor data paths:_ -## AIS Weather stations +- `vessels.self.environment.outside.*` Measurements taken outside the vessel hull +- `vessels.self.environment.inside.*` Measurements taken inside the vessel hull +- `vessels.self.environment.water.*` Measurements taken relating to the water the vessel is in. -Weather observation data sourced from AIS weather stations via `VDM` sentences are found under the `meteo` context, with each station having a unique identifier. -_Example:_ +## 2. AIS Weather Sources -- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.outside` -- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.inside` -- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.water` -- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.tide` -- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.current` +Environment data from AIS weather stations via NMEA0183 `VDM` sentences are placed in the `meteo` context, with each station identified by a unique identifier. +_Example - AIS sourced weather data paths:_ -## Weather Services +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.outside.*` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.inside.*` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.water.*` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.tide.*` +- `meteo.urn:mrn:imo:mmsi:123456789:081751.environment.current.*` -Data sourced from weather services is generally comprised of a collection of observations, forecasts and warnings for a specific location updated at regular intervals (e.g. hourly). -Observations may be a combination of current and historical measurements and forecasts a combination of daily and "point in time" values. +## 3. Weather Service Providers _(Weather API)_ -The nature of this data makes it more suited to a REST API rather than a websocket stream and as such the [Signal K Weather API](../../develop/rest-api/weather_api.md) is where this information is made available. +Weather service providers provide a collection of observations, forecasts and weather warnings for a location that can include: +- Current and historical data (observations) +- Daily and "point in time" forecasts +over varying time periods. -Signal K Server plugins are the vehicle for fetching and transforming the data from the various data services and make it available via the Weather API. +This information is updated at regular intervals (e.g. hourly) and will relate to an area (of varying size) based on the location provided. -For example, a `provider plugin` sourcing data from the Open-Meteo service can provide current and historical observation data as well as daily and hourly forecast information. +The nature of this data makes it more suited to a REST API rather than a websocket stream and as such the [Signal K Weather API](../../develop/rest-api/weather_api.md) is where this information is made available. +As each weather provider tends to have different interfaces to source information, [Signal K Server plugins](../../develop/plugins/weather_provider_plugins.md) provide the vehicle for fetching and transforming the data from the various data sources and making it available via the Weather API. -_Example:_ +The Weather API supports the use of multiple weather provider plugins with the ability to switch between them. +_Example: Fetching weather data for a location._ - `GET "/signalk/v2/api/weather?lat=5.432&lon=7.334` -- `GET "/signalk/v2/api/weather/forecasts?lat=5.432&lon=7.334` -- `GET "/signalk/v2/api/weather/observations?lat=5.432&lon=7.334` + diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 48fd76368..ae5a5c0fe 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -31,6 +31,8 @@ import { PointDestination, RouteDestination, CourseInfo } from './coursetypes' export * from './autopilotapi' export * from './weatherapi' +export { WeatherProviderRegistry } from './weatherapi' +import { WeatherProviderRegistry, WeatherWarning } from './weatherapi' export type SignalKApiId = | 'resources' @@ -65,7 +67,8 @@ export interface PropertyValuesEmitter { export interface PluginServerApp extends PropertyValuesEmitter, - ResourceProviderRegistry {} + ResourceProviderRegistry, + WeatherProviderRegistry {} /** * This is the API that a [server plugin](https://github.com/SignalK/signalk-server/blob/master/SERVERPLUGINS.md) must implement. @@ -203,6 +206,11 @@ export interface ServerAPI extends PluginServerApp { dest: (PointDestination & { arrivalCircle?: number }) | null ) => Promise activateRoute: (dest: RouteDestination | null) => Promise + emitWeatherWarning: ( + pluginId: string, + position?: Position, + warnings?: WeatherWarning[] + ) => void /** * A plugin can report that it has handled output messages. This will diff --git a/packages/server-api/src/weatherapi.ts b/packages/server-api/src/weatherapi.ts index c34c50846..8bb210f01 100644 --- a/packages/server-api/src/weatherapi.ts +++ b/packages/server-api/src/weatherapi.ts @@ -5,8 +5,8 @@ export interface WeatherApi { unRegister: (pluginId: string) => void emitWarning: ( pluginId: string, - position: Position, - warnings: WeatherWarning[] + position?: Position, + warnings?: WeatherWarning[] ) => void } @@ -53,7 +53,7 @@ export interface WeatherWarning { // Aligned with Signal K environment specification export interface WeatherData { - description: string + description?: string date: string type: 'daily' | 'point' | 'observation' // daily forecast, point-in-time forecast, observed values outside?: { diff --git a/src/api/weather/index.ts b/src/api/weather/index.ts index a2e888eec..9f5261662 100644 --- a/src/api/weather/index.ts +++ b/src/api/weather/index.ts @@ -97,8 +97,8 @@ export class WeatherApi { // Send warning Notification emitWarning( pluginId: string, - position: Position, - warnings: WeatherWarning[] + position?: Position, + warnings?: WeatherWarning[] ) { this.sendNotification(pluginId, position, warnings) } @@ -447,24 +447,38 @@ export class WeatherApi { // send weather warning notification private sendNotification( sourceId: string, - pos: Position, - warnings: WeatherWarning[] + pos?: Position, + warnings?: WeatherWarning[] ) { + let value: { [key: string]: any } + if ( + !pos || + !warnings || + (Array.isArray(warnings) && warnings.length === 0) + ) { + value = { + state: ALARM_STATE.normal, + method: [], + message: `` + } + } else { + value = { + state: ALARM_STATE.warn, + method: [ALARM_METHOD.visual], + message: `Weather Warning`, + data: { + position: pos, + warnings: warnings + } + } + } const msg: Delta = { updates: [ { values: [ { path: `notifications.weather.warning` as Path, - value: { - state: ALARM_STATE.warn, - method: ALARM_METHOD.visual, - message: `Weather Warning`, - data: { - position: pos, - warnings: warnings - } - } + value: value } ] } diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index 9498f9176..5cd01569c 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -23,7 +23,11 @@ import { ResourceProvider, ServerAPI, RouteDestination, - SignalKApiId + SignalKApiId, + WeatherProvider, + WeatherApi, + Position, + WeatherWarning } from '@signalk/server-api' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -472,6 +476,7 @@ module.exports = (theApp: any) => { onStopHandlers[plugin.id].push(() => app.resourcesApi.unRegister(plugin.id) ) + onStopHandlers[plugin.id].push(() => app.weatherApi.unRegister(plugin.id)) plugin.start(safeConfiguration, restart) debug('Started plugin ' + plugin.name) setPluginStartedMessage(plugin) @@ -551,6 +556,19 @@ module.exports = (theApp: any) => { }) appCopy.putPath = putPath + const weatherApi: WeatherApi = app.weatherApi + _.omit(appCopy, 'weatherApi') // don't expose the actual weather api manager + appCopy.registerWeatherProvider = (provider: WeatherProvider) => { + weatherApi.register(plugin.id, provider) + } + appCopy.emitWeatherWarning = ( + pluginId: string, + position?: Position, + warnings?: WeatherWarning[] + ) => { + return weatherApi.emitWarning(pluginId, position, warnings) + } + const resourcesApi: ResourcesApi = app.resourcesApi _.omit(appCopy, 'resourcesApi') // don't expose the actual resource api manager appCopy.registerResourceProvider = (provider: ResourceProvider) => { From 235c038a00c50013c6832fa2bd8a273b322aeccf Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 8 May 2024 16:01:35 +0930 Subject: [PATCH 3/6] add daily & point forecast endpoints --- packages/server-api/src/weatherapi.ts | 2 +- src/api/weather/index.ts | 51 ++++++++++++++++++++++- src/api/weather/openApi.json | 58 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/server-api/src/weatherapi.ts b/packages/server-api/src/weatherapi.ts index 8bb210f01..e8c70224f 100644 --- a/packages/server-api/src/weatherapi.ts +++ b/packages/server-api/src/weatherapi.ts @@ -59,7 +59,7 @@ export interface WeatherData { outside?: { minTemperature?: number maxTemperature?: number - feelsLikeTemperature: number + feelsLikeTemperature?: number precipitationVolume?: number absoluteHumidity?: number horizontalVisibility?: number diff --git a/src/api/weather/index.ts b/src/api/weather/index.ts index 9f5261662..c5cf1b6ca 100644 --- a/src/api/weather/index.ts +++ b/src/api/weather/index.ts @@ -13,6 +13,7 @@ import { WeatherProviders, WeatherProviderMethods, WeatherWarning, + WeatherData, //isWeatherProvider, SKVersion, Path, @@ -299,7 +300,7 @@ export class WeatherApi { } ) - // return forecast data at the provided lat / lon + // return all forecasts at the provided lat / lon this.app.get( `${WEATHER_API_PATH}/forecasts`, async (req: Request, res: Response) => { @@ -320,6 +321,54 @@ export class WeatherApi { } ) + // return daily forecast data at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/forecasts/daily`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + const r = await this.useProvider().getForecasts({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + const df = r.filter( (i: WeatherData) => { + return i.type === 'daily' + }) + res.status(200).json(df) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + + // return point forecast data at the provided lat / lon + this.app.get( + `${WEATHER_API_PATH}/forecasts/point`, + async (req: Request, res: Response) => { + debug(`** route = ${req.method} ${req.path}`) + try { + const r = await this.useProvider().getForecasts({ + latitude: Number(req.query.lat), + longitude: Number(req.query.lon) + }) + const pf = r.filter( (i: WeatherData) => { + return i.type === 'point' + }) + res.status(200).json(pf) + } catch (err: any) { + res.status(400).json({ + statusCode: 400, + state: 'FAILED', + message: err.message + }) + } + } + ) + // return specific forecast entry at the provided lat / lon this.app.get( `${WEATHER_API_PATH}/forecasts/:id`, diff --git a/src/api/weather/openApi.json b/src/api/weather/openApi.json index 8038b77ef..9def843da 100644 --- a/src/api/weather/openApi.json +++ b/src/api/weather/openApi.json @@ -576,6 +576,64 @@ } } }, + "/weather/forecasts/daily": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve daily forecast data.", + "responses": { + "default": { + "description": "Returns daily forecast data for the specified location (lat / lon).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + } + } + } + } + }, + "/weather/forecasts/point": { + "parameters": [ + { + "$ref": "#/components/parameters/LatitudeParam" + }, + { + "$ref": "#/components/parameters/LongitudeParam" + } + ], + "get": { + "tags": ["Weather"], + "summary": "Retrieve point forecast data.", + "responses": { + "default": { + "description": "Returns point forecast data for the specified location (lat / lon).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherDataModel" + } + } + } + } + } + } + } + }, "/weather/forecasts/{index}": { "parameters": [ { From c896ade660c14e53b3cf5b09f3e1448c3b7a68d1 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 8 May 2024 16:38:51 +0930 Subject: [PATCH 4/6] chore: formatting --- src/api/weather/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/weather/index.ts b/src/api/weather/index.ts index c5cf1b6ca..30ad70b34 100644 --- a/src/api/weather/index.ts +++ b/src/api/weather/index.ts @@ -331,7 +331,7 @@ export class WeatherApi { latitude: Number(req.query.lat), longitude: Number(req.query.lon) }) - const df = r.filter( (i: WeatherData) => { + const df = r.filter((i: WeatherData) => { return i.type === 'daily' }) res.status(200).json(df) @@ -355,7 +355,7 @@ export class WeatherApi { latitude: Number(req.query.lat), longitude: Number(req.query.lon) }) - const pf = r.filter( (i: WeatherData) => { + const pf = r.filter((i: WeatherData) => { return i.type === 'point' }) res.status(200).json(pf) From 67d2209601c8855d0ba45185ba8aa25348871cbe Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 15 May 2024 09:20:05 +0930 Subject: [PATCH 5/6] Add ts-auto-guard to WeatherProvider interface. --- packages/server-api/package.json | 4 +++- packages/server-api/src/index.ts | 4 ++-- packages/server-api/src/weatherapi.guard.ts | 23 +++++++++++++++++++++ packages/server-api/src/weatherapi.ts | 1 + src/api/weather/index.ts | 18 ++++++++-------- 5 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 packages/server-api/src/weatherapi.guard.ts diff --git a/packages/server-api/package.json b/packages/server-api/package.json index f54d487eb..0d6c62ece 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc --declaration", + "build": "npm run generate && tsc --declaration", + "generate": "ts-auto-guard src/weatherapi.ts 2>/dev/null", "watch": "tsc --declaration --watch", "prepublishOnly": "npm run build", "typedoc": "typedoc --out docs src", @@ -33,6 +34,7 @@ "express": "^4.10.4", "mocha": "^10.2.0", "prettier": "^2.8.4", + "ts-auto-guard": "^4.2.0", "ts-node": "^10.9.1", "typedoc": "^0.23.23", "typescript": "^4.1.5" diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index ae5a5c0fe..5b6e69097 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -25,14 +25,14 @@ export * from './deltas' export * from './coursetypes' export * from './resourcetypes' export * from './resourcesapi' -export { ResourceProviderRegistry } from './resourcesapi' import { ResourceProviderRegistry } from './resourcesapi' import { PointDestination, RouteDestination, CourseInfo } from './coursetypes' export * from './autopilotapi' + export * from './weatherapi' -export { WeatherProviderRegistry } from './weatherapi' import { WeatherProviderRegistry, WeatherWarning } from './weatherapi' +export * from './weatherapi.guard' export type SignalKApiId = | 'resources' diff --git a/packages/server-api/src/weatherapi.guard.ts b/packages/server-api/src/weatherapi.guard.ts new file mode 100644 index 000000000..18ddf1ad0 --- /dev/null +++ b/packages/server-api/src/weatherapi.guard.ts @@ -0,0 +1,23 @@ +/* + * Generated type guards for "weatherapi.ts". + * WARNING: Do not manually change this file. + */ +import { WeatherProvider } from './weatherapi' + +export function isWeatherProvider(obj: unknown): obj is WeatherProvider { + const typedObj = obj as WeatherProvider + return ( + ((typedObj !== null && typeof typedObj === 'object') || + typeof typedObj === 'function') && + typeof typedObj['name'] === 'string' && + ((typedObj['methods'] !== null && + typeof typedObj['methods'] === 'object') || + typeof typedObj['methods'] === 'function') && + (typeof typedObj['methods']['pluginId'] === 'undefined' || + typeof typedObj['methods']['pluginId'] === 'string') && + typeof typedObj['methods']['getData'] === 'function' && + typeof typedObj['methods']['getObservations'] === 'function' && + typeof typedObj['methods']['getForecasts'] === 'function' && + typeof typedObj['methods']['getWarnings'] === 'function' + ) +} diff --git a/packages/server-api/src/weatherapi.ts b/packages/server-api/src/weatherapi.ts index e8c70224f..c5c578cd8 100644 --- a/packages/server-api/src/weatherapi.ts +++ b/packages/server-api/src/weatherapi.ts @@ -21,6 +21,7 @@ export interface WeatherProviders { } } +/** @see {isWeatherProvider} ts-auto-guard:type-guard */ export interface WeatherProvider { name: string // e.g. OpenWeather, Open-Meteo, NOAA methods: WeatherProviderMethods diff --git a/src/api/weather/index.ts b/src/api/weather/index.ts index 30ad70b34..6b742ca62 100644 --- a/src/api/weather/index.ts +++ b/src/api/weather/index.ts @@ -14,7 +14,7 @@ import { WeatherProviderMethods, WeatherWarning, WeatherData, - //isWeatherProvider, + isWeatherProvider, SKVersion, Path, Delta, @@ -51,18 +51,18 @@ export class WeatherApi { if (!pluginId || !provider) { throw new Error(`Error registering provider ${pluginId}!`) } - /*if (!isWeatherProvider(provider)) { + if (!isWeatherProvider(provider)) { throw new Error( `${pluginId} is missing WeatherProvider properties/methods!` ) - } else {*/ - if (!this.weatherProviders.has(pluginId)) { - this.weatherProviders.set(pluginId, provider) - } - if (this.weatherProviders.size === 1) { - this.defaultProviderId = pluginId + } else { + if (!this.weatherProviders.has(pluginId)) { + this.weatherProviders.set(pluginId, provider) + } + if (this.weatherProviders.size === 1) { + this.defaultProviderId = pluginId + } } - //} debug(`No. of WeatherProviders registered =`, this.weatherProviders.size) } From 79931dc9e5cf0f0e633c4093de73aeba4436767a Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:33:29 +1030 Subject: [PATCH 6/6] align API conventions --- src/api/index.ts | 2 +- src/api/weather/index.ts | 4 ++-- src/api/weather/openApi.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index a33132521..8a6883369 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -79,7 +79,7 @@ export const startApis = ( resourcesApi.start(), courseApi.start(), weatherApi.start(), - featuresApi.start() + featuresApi.start(), autopilotApi.start() ]) return apiList diff --git a/src/api/weather/index.ts b/src/api/weather/index.ts index 6b742ca62..e597fffe0 100644 --- a/src/api/weather/index.ts +++ b/src/api/weather/index.ts @@ -180,7 +180,7 @@ export class WeatherApi { // return list of weather providers this.app.get( - `${WEATHER_API_PATH}/providers`, + `${WEATHER_API_PATH}/_providers`, async (req: Request, res: Response) => { debug(`**route = ${req.method} ${req.path}`) try { @@ -205,7 +205,7 @@ export class WeatherApi { // change weather provider this.app.post( - `${WEATHER_API_PATH}/providers`, + `${WEATHER_API_PATH}/_providers`, async (req: Request, res: Response) => { debug(`**route = ${req.method} ${req.path} ${JSON.stringify(req.body)}`) try { diff --git a/src/api/weather/openApi.json b/src/api/weather/openApi.json index 9def843da..62b918f9d 100644 --- a/src/api/weather/openApi.json +++ b/src/api/weather/openApi.json @@ -721,7 +721,7 @@ } } }, - "/weather/providers": { + "/weather/_providers": { "get": { "tags": ["Provider"], "summary": "Retrieve list of registered providers.",