From b572e969edda1a99c24244313af4a41939cac9ce Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 24 May 2023 16:09:23 +0930 Subject: [PATCH 1/5] V2 Alarms API For raising and clearing Signal K standard alarm types with predefined (overridable) message text and methods. --- packages/server-api/src/deltas.ts | 1 + src/api/alarms/index.ts | 245 ++++++++++++++++++++++++++++++ src/api/alarms/openApi.json | 173 +++++++++++++++++++++ src/api/alarms/openApi.ts | 8 + src/api/index.ts | 4 +- src/api/swagger.ts | 8 +- 6 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 src/api/alarms/index.ts create mode 100644 src/api/alarms/openApi.json create mode 100644 src/api/alarms/openApi.ts diff --git a/packages/server-api/src/deltas.ts b/packages/server-api/src/deltas.ts index 2e60078bd..df4167ad1 100644 --- a/packages/server-api/src/deltas.ts +++ b/packages/server-api/src/deltas.ts @@ -63,6 +63,7 @@ export interface Notification { state: ALARM_STATE method: ALARM_METHOD[] message: string + data?: {[key: string]: object | number | string | null} } // MetaMessage diff --git a/src/api/alarms/index.ts b/src/api/alarms/index.ts new file mode 100644 index 000000000..2a44dae58 --- /dev/null +++ b/src/api/alarms/index.ts @@ -0,0 +1,245 @@ +/* + API for raising / clearing Standard Alarm types as defined in Signal K specification, + providing default message text for each alarm type which can be overridden. +*/ + +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:alarms') + +import { IRouter, Request, Response, NextFunction } from 'express' +import _ from 'lodash' + +import { SignalKMessageHub, WithConfig } from '../../app' +import { WithSecurityStrategy } from '../../security' + +import { + Position, + ALARM_METHOD, + ALARM_STATE, + Notification, + SKVersion +} from '@signalk/server-api' +import { Responses } from '..' + +import { buildSchemaSync } from 'api-schema-builder' +import alarmsApiDoc from './openApi.json' + +const ALARMS_API_SCHEMA = buildSchemaSync(alarmsApiDoc) + +const SIGNALK_API_PATH = `/signalk/v2/api` +const ALARMS_API_PATH = `${SIGNALK_API_PATH}/notifications` + +const STANDARD_ALARMS = [ + 'mob', + 'fire', + 'sinking', + 'flooding', + 'collision', + 'grounding', + 'listing', + 'adrift', + 'piracy', + 'abandon' +] + +interface AlarmsApplication + extends IRouter, + WithConfig, + WithSecurityStrategy, + SignalKMessageHub {} + +export class AlarmsApi { + constructor(private server: AlarmsApplication) {} + + async start() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + this.initAlarmEndpoints() + resolve() + }) + } + + private getVesselPosition() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return _.get((this.server.signalk as any).self, 'navigation.position.value') + } + + private getVesselAttitude() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return _.get((this.server.signalk as any).self, 'navigation.attitude.value') + } + + private updateAllowed(request: Request): boolean { + return this.server.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'notifications' + ) + } + + private initAlarmEndpoints() { + debug(`** Initialise ${ALARMS_API_PATH} path handlers **`) + + this.server.put( + `${ALARMS_API_PATH}/:alarmType`, + (req: Request, res: Response, next: NextFunction) => { + debug(`** PUT ${ALARMS_API_PATH}/${req.params.alarmType}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (!STANDARD_ALARMS.includes(req.params.alarmType)) { + next() + return + } + try { + const endpoint = + ALARMS_API_SCHEMA[`${ALARMS_API_PATH}/:standardAlarm`].put + if (!endpoint.body.validate(req.body)) { + res.status(400).json(endpoint.body.errors) + return + } + const r = this.updateAlarmState(req) + res.status(200).json(r) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + this.server.delete( + `${ALARMS_API_PATH}/:alarmType`, + (req: Request, res: Response, next: NextFunction) => { + debug(`** DELETE ${ALARMS_API_PATH}/${req.params.alarmType}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (!STANDARD_ALARMS.includes(req.params.alarmType)) { + next() + return + } + try { + const r = this.updateAlarmState(req, true) + res.status(200).json(r) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + } + + // set / clear alarm state + private updateAlarmState = (req: Request, clear = false) => { + const path = `notifications.${req.params.alarmType as string}` + let alarmValue: Notification | null + + if (clear) { + alarmValue = null + } else { + let msg = req.body.message + ? req.body.message + : this.getDefaultMessage(req.params.alarmType as string) + + const pos: Position = this.getVesselPosition() + msg += pos ? '' : ' (No position data available.)' + + let roll: number | null = null + if (req.params.alarmType === 'listing') { + const att = this.getVesselAttitude() + roll = att && att.roll ? att.roll : null + } + + alarmValue = { + message: msg, + method: [ALARM_METHOD.sound, ALARM_METHOD.visual], + state: ALARM_STATE.emergency + } + + if (req.body.additionalData || pos || roll) { + alarmValue.data = {} + + if (req.body.additionalData) { + Object.assign(alarmValue.data, req.body.additionalData) + } + if (pos) { + Object.assign(alarmValue.data, { position: pos }) + } + if (roll) { + Object.assign(alarmValue.data, { roll: roll }) + } + } + } + + debug(`****** Sending ${req.params.alarmType} Notification: ******`) + debug(path, JSON.stringify(alarmValue)) + this.emitAlarmNotification(path, alarmValue, SKVersion.v1) + return { state: 'COMPLETED', resultStatus: 200, statusCode: 200 } + } + + // return default message for supplied alarm type + private getDefaultMessage = (alarmType: string): string => { + switch (alarmType) { + case 'mob': + return 'Man overboard!' + break + case 'fire': + return 'Fire onboard vessel!' + break + case 'sinking': + return 'Vessel sinking!' + break + case 'flooding': + return 'Vessel talking on water!' + break + case 'collision': + return 'Vessel has collided with another!' + break + case 'grounding': + return 'Vessel has run aground!' + break + case 'listing': + return 'Vessel has exceeded maximum safe angle of list!' + break + case 'adrift': + return 'Vessel is cast adrift!' + break + case 'piracy': + return 'Vessel has encountered pirates!' + break + case 'abandon': + return 'Vessel has been abandoned!' + break + } + return alarmType + } + + // emit delta of specified version + private emitAlarmNotification(path: string, value: Notification | null, version: SKVersion ) { + this.server.handleMessage( + 'alarmsApi', + { + updates: [ + { + values: [ + { + path: path, + value: value + } + ] + } + ] + }, + version + ) + } +} diff --git a/src/api/alarms/openApi.json b/src/api/alarms/openApi.json new file mode 100644 index 000000000..071578ed5 --- /dev/null +++ b/src/api/alarms/openApi.json @@ -0,0 +1,173 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Alarms API", + "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." + }, + "servers": [ + { + "url": "/signalk/v2/api/notifications" + } + ], + "tags": [ + { + "name": "alarms", + "description": "Special Alarms" + } + ], + "components": { + "schemas": { + "AlarmData": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Override standard message text associated with this alarm." + }, + "additionalData": { + "type": "object", + "additionalProperties": true, + "description": "Additional data values associated with this alarm." + } + } + } + }, + "responses": { + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "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": { + "StandardAlarms": { + "name": "standardAlarm", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "mob", + "sinking", + "fire", + "listing", + "piracy", + "flooding", + "collision", + "grounding", + "adrift", + "abandon" + ] + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], + "paths": { + "/{standardAlarm}": { + "parameters": [ + { + "$ref": "#/components/parameters/StandardAlarms" + } + ], + "put": { + "tags": ["alarms"], + "summary": "Raise a standard alarm.", + "description": "Raise one of the standard alarms defined in the Signal K specification.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlarmData" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": ["alarms"], + "summary": "Clear a standard alarm.", + "description": "Clear the specified standard alarm.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } +} diff --git a/src/api/alarms/openApi.ts b/src/api/alarms/openApi.ts new file mode 100644 index 000000000..f84d94b4d --- /dev/null +++ b/src/api/alarms/openApi.ts @@ -0,0 +1,8 @@ +import { OpenApiDescription } from '../swagger' +import alarmsApiDoc from './openApi.json' + +export const alarmsApiRecord = { + name: 'alarms', + path: '/signalk/v2/api/notifications', + apiDoc: alarmsApiDoc as unknown as OpenApiDescription +} diff --git a/src/api/index.ts b/src/api/index.ts index 2b781c9a1..92f91e89a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,6 +3,7 @@ import { SignalKMessageHub, WithConfig } from '../app' import { WithSecurityStrategy } from '../security' import { CourseApi } from './course' import { ResourcesApi } from './resources' +import { AlarmsApi } from './alarms' export interface ApiResponse { state: 'FAILED' | 'COMPLETED' | 'PENDING' @@ -43,5 +44,6 @@ export const startApis = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(app as any).resourcesApi = resourcesApi const courseApi = new CourseApi(app, resourcesApi) - Promise.all([resourcesApi.start(), courseApi.start()]) + const alarmsApi = new AlarmsApi(app) + Promise.all([resourcesApi.start(), courseApi.start(), alarmsApi.start()]) } diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 028b8719e..2ad70fb54 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -2,6 +2,7 @@ import { IRouter, NextFunction, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' +import { alarmsApiRecord } from './alarms/openApi' import { courseApiRecord } from './course/openApi' import { notificationsApiRecord } from './notifications/openApi' import { resourcesApiRecord } from './resources/openApi' @@ -24,12 +25,13 @@ interface ApiRecords { } const apiDocs = [ - discoveryApiRecord, + alarmsApiRecord, appsApiRecord, - securityApiRecord, courseApiRecord, + discoveryApiRecord, notificationsApiRecord, - resourcesApiRecord + resourcesApiRecord, + securityApiRecord ].reduce((acc, apiRecord: OpenApiRecord) => { acc[apiRecord.name] = apiRecord return acc From 62da3ce0d6e54f9473065d42921074d47d708885 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 24 May 2023 16:48:21 +0930 Subject: [PATCH 2/5] chore: format --- packages/server-api/src/deltas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-api/src/deltas.ts b/packages/server-api/src/deltas.ts index df4167ad1..4cd40540a 100644 --- a/packages/server-api/src/deltas.ts +++ b/packages/server-api/src/deltas.ts @@ -63,7 +63,7 @@ export interface Notification { state: ALARM_STATE method: ALARM_METHOD[] message: string - data?: {[key: string]: object | number | string | null} + data?: { [key: string]: object | number | string | null } } // MetaMessage From df382030427e1ecb426bdf641366c837bf587f63 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 24 May 2023 16:52:59 +0930 Subject: [PATCH 3/5] chore: format --- src/api/alarms/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/alarms/index.ts b/src/api/alarms/index.ts index 2a44dae58..f4d44ee3f 100644 --- a/src/api/alarms/index.ts +++ b/src/api/alarms/index.ts @@ -224,7 +224,11 @@ export class AlarmsApi { } // emit delta of specified version - private emitAlarmNotification(path: string, value: Notification | null, version: SKVersion ) { + private emitAlarmNotification( + path: string, + value: Notification | null, + version: SKVersion + ) { this.server.handleMessage( 'alarmsApi', { From 8bc79fccf05f818632cc61ef8efc443822d2ef0f Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Mon, 12 Jun 2023 17:09:22 +0930 Subject: [PATCH 4/5] notifications api --- SERVERPLUGINS.md | 43 ++ WORKING_WITH_NOTIFICATIONS_API.md | 356 ++++++++++++++++ packages/server-api/src/deltas.ts | 1 + packages/server-api/src/index.ts | 2 + src/api/alarms/index.ts | 249 ------------ src/api/alarms/openApi.json | 173 -------- src/api/alarms/openApi.ts | 8 - src/api/index.ts | 14 +- src/api/notifications/index.ts | 579 +++++++++++++++++++++++++++ src/api/notifications/openApi.json | 424 ++++++++++++-------- src/api/notifications/openApi.ts | 2 +- src/api/notificationsv1/openApi.json | 331 +++++++++++++++ src/api/notificationsv1/openApi.ts | 8 + src/api/swagger.ts | 2 - src/interfaces/plugins.ts | 8 + 15 files changed, 1604 insertions(+), 596 deletions(-) create mode 100644 WORKING_WITH_NOTIFICATIONS_API.md delete mode 100644 src/api/alarms/index.ts delete mode 100644 src/api/alarms/openApi.json delete mode 100644 src/api/alarms/openApi.ts create mode 100644 src/api/notifications/index.ts create mode 100644 src/api/notificationsv1/openApi.json create mode 100644 src/api/notificationsv1/openApi.ts diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md index 3ee4ddd72..de8deea01 100644 --- a/SERVERPLUGINS.md +++ b/SERVERPLUGINS.md @@ -706,6 +706,49 @@ app.registerDeltaInputHandler((delta, next) => { }) ``` +### `app.notify(path, value, pluginId)` + +Notifications API interface method for raising, updating and clearing notifications. + + - `path`: Signal K path of the notification + + - `value`: A valid `Notification` object or `null` if clearing a notification. + + - `pluginId` The plugin identifier. + + +To raise or update a for a specified path, call the method with a valid `Notification` object as the `value`. + +- returns: `string` value containing the `id` of the new / updated notification. + +_Example:_ +```javascript +const alarmId = app.notify( + 'myalarm', + { + message: 'My cutom alarm text', + state: 'alert' + }, + 'myAlarmPlugin' +) + +// alarmId = "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" +``` + +To clear (cancel) a notification call the method with `null` as the `value`. + +- returns: `void`. + +_Example: Clear notification_ +```javascript +const alarmId = app.notify( + 'myalarm', + null, + 'myAlarmPlugin' +) +``` + + ### `app.registerResourceProvider(resourceProvider)` See [`RESOURCE_PROVIDER_PLUGINS`](./RESOURCE_PROVIDER_PLUGINS.md) for details. diff --git a/WORKING_WITH_NOTIFICATIONS_API.md b/WORKING_WITH_NOTIFICATIONS_API.md new file mode 100644 index 000000000..33627910d --- /dev/null +++ b/WORKING_WITH_NOTIFICATIONS_API.md @@ -0,0 +1,356 @@ +# Working with the Notifications API + + +## Overview + +The SignalK Notifications API provides the ability to raise and action notifications / alarms using `HTTP` requests. + +The API provides endpoints at the following path `/signalk/v2/api/notifications`. + +**See the [OpenAPI documentation](https://demo.signalk.io/admin/openapi/) in the Signal K server Admin UI (under Documentation) for details.** + +--- + +## Operation + +The Notifications API manages the raising, actioning and clearing of notifications. + +It does this by providing: +1. HTTP endpoints for interactive use +1. An Interface for use by plugins and connection handlers. + +In this way, notifications triggered by both stream data and client interactions are consistently represented in the Signal K data model and that actions (e.g. acknowledge, silence, etc) and their resulting status is preseved and available to all connected devices. + +Additionally, the Notifications API applies a unique `id` to each notification which can be used in as an alternative to the `path` and `$source` to identify a notification entry. + + +## Using the API Plugin Interface +--- + +The Notifications API exposes the `notify()` method for use by plugins for raising, updating and clearing notifications. + +**`app.notify(path, value, sourceId)`** + + - `path`: Signal K path of the notification + + - `value`: A valid `Notification` object or `null` if clearing a notification. + + - `sourceId` The source identifier associated with the notification. + + +To raise (create) a new notification or update and existing notification call the method with a valid `Notification` object as the `value`. + +- returns: `string` value containing the `id` of the new / updated notification. + +_Example: Raise notification_ +```javascript +const alarmId = app.notify( + 'myalarm', + { + message: 'My alarm text', + state: 'alert' + }, + 'myAlarmPlugin' +) + +// alarmId = "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" +``` + +To clear (cancel) a notification call the method with `null` as the `value`. + +- returns: `void`. + +_Example: Clear notification_ +```javascript +const alarmId = app.notify( + 'myalarm', + null, + 'myAlarmPlugin' +) +``` + +## Using HTTP Endpoints +--- + +### Raising a Notification + +To create (or raise) a notification you submit a HTTP `PUT` request to the specified `path` under `/signalk/v2/api/notifications`. + +_Example: Raise notification_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/myalarm' { + "message": "My alarm text.", + "state": "alert" +} +``` + +You can also provide additional data values associated with the alarm. + +_Example: Raise notification with temperature values_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/myalarm' { + "message": "My alarm text.", + "state": "alert", + "data": { + "temperature": { + "outside": 293.5, + "inside": 289.7 + } + } +} +``` + +If the action is successful, a response containing the `id` of the notification is generated. + +_Example response:_ +```JSON +{ + "state": "COMPLETED", + "statusCode": 201, + "id": "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" +} +``` + +This `id` can be used to perform actions on the notification. + + +### Updating notification content +--- + +To update the information contained in a notification, you need to replace it by submitting another `HTTP PUT` request containing a the new values. + +You can either use the notification `path` or `id` to update it. + +_Example: Update notification by path_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/myalarm' { + "message": "New alarm text.", + "state": "warning" +} +``` + +_Example: Update notification by id_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' { + "message": "New alarm text.", + "state": "warning" +} +``` + +### Clear a notification +--- + +To clear or cancel a notification submit a `HTTP DELETE` request to either the notification `path` or `id`. + +_Example: Clear notification by path_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/notifications/myalarm' +``` + +_Example: Clear notification by id_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/notifications/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +``` + +Additionally, you can clear a notification with a specific `$source` by providing the source value as a query parameter. + +_Example: Clear notification by path created by `zone-watch`_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/notifications/enteredZone?source=zone-watch' +``` + +### Acknowledge a notification +--- + +To acknowledge a notification, submit a `HTTP PUT` request to `http://hostname:3000/signalk/v2/api/notifications/ack/`. + +This adds the **`actions`** property to the notification which holds a list of actions taken on the notification. "ACK" will be added to the list of actions when a notificationis acknowledged. + +``` +{ + ... + "actions": ["ACK"], + ... +} +``` + +_Example: Acknowledge notification using a path_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/ack/myalarm' +``` + +_Example: Acknowledge notification using an id_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/ack/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +``` + +_Acknowledged notification response._ +```JSON +{ + "message": "Man Overboard!", + "method": [ + "sound", + "visual" + ], + "actions": ["ACK"], + "state": "emergency", + "id": "96171e52-38de-45d9-aa32-30633553f58d", + "data": { + "position": { + "longitude": -166.18340908333334, + "latitude": 60.03309133333333 + } + } +} +``` + +### Standard Alarms +--- + +Standard alarms, such as Man Overboard, can be raised submitting a HTTP `POST` request to the specified `alarm path`. + +These alarms will be raised by the server with pre-defined content. + + +_Example: Raise Man Overboard Alarm_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/notifications/mob' +``` + +_Notification content._ +```JSON +{ + "message": "Man Overboard!", + "method": [ + "sound", + "visual" + ], + "state": "emergency", + "id": "96171e52-38de-45d9-aa32-30633553f58d", + "data": { + "position": { + "longitude": -166.18340908333334, + "latitude": 60.03309133333333 + } + } +} +``` + +## View / List notifications +--- + +### View a specified notification +To view a specific notification submit a `HTTP GET` request to either the notification `path` or `id`. + +_Example: Retrieve notification by path_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications/myalarm' +``` + +_Example: Retrieve notification by id_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +``` + +_Response: Includes `path` attribute associated with the notification._ +```JSON +{ + "meta": {}, + "value": { + "message": "My test alarm!", + "method": ["sound", "visual"], + "state": "alert", + "id": "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6", + "data": { + "position": { + "lat": 12, + "lon": 148 + } + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:52.459Z", + "path": "notifications.myalarm" + } +``` + +_Example: Retrieve notification by path with the specified $source_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications/myalarm?source=zone-watch' +``` + +### View a list notifications +A list of notifications generated using the Notifications API and be retrieved by submitting a `HTTP GET` request to `http://hostname:3000/signalk/v2/api/notifications`. + +By default the list of notification objects will be keyed by their `path`. + +_Example: Notification list keyed by path (default)_ +```JSON +{ + "notifications.myalarm": { + "value": { + "message": "My test alarm!", + "method": ["sound", "visual"], + "state": "alert", + "id": "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6", + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:52.459Z" + }, + "notifications.mob": { + "value": { + "message": "Man Overboard!", + "method": ["sound", "visual"], + "state": "emergency", + "id": "ff105ae9-43d5-4039-abaf-afeefb03566e", + "data": { + "position": "No vessel position data." + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:54.124Z" + } +} +``` + +To view a list of notifications keyed by their identifier, add `key=id` to the request. + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications?key=id` +``` + +```JSON +{ + "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6": { + "value": { + "message": "My test alarm!", + "method": ["sound", "visual"], + "state": "alert", + "id": "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6", + "data": { + "position": { + "lat": 12, + "lon": 148 + } + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:52.459Z", + "path": "notifications.myalarm" + }, + "ff105ae9-43d5-4039-abaf-afeefb03566e": { + "value": { + "message": "Man Overboard!", + "method": ["sound", "visual"], + "state": "emergency", + "id": "ff105ae9-43d5-4039-abaf-afeefb03566e", + "data": { + "position": "No vessel position data." + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:54.124Z", + "path": "notifications.mob" + } +} +``` diff --git a/packages/server-api/src/deltas.ts b/packages/server-api/src/deltas.ts index 4cd40540a..8d38ea291 100644 --- a/packages/server-api/src/deltas.ts +++ b/packages/server-api/src/deltas.ts @@ -64,6 +64,7 @@ export interface Notification { method: ALARM_METHOD[] message: string data?: { [key: string]: object | number | string | null } + id?: string } // MetaMessage diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 7973aa02a..3b92ef476 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -22,6 +22,7 @@ export enum SKVersion { export type Brand = K & { __brand: T } export * from './deltas' +import { Notification } from './deltas' export * from './resourcetypes' export * from './resourcesapi' export { ResourceProviderRegistry } from './resourcesapi' @@ -126,6 +127,7 @@ export interface ServerAPI extends PluginServerApp { source: string // eslint-disable-next-line @typescript-eslint/no-explicit-any ) => Promise + notify: (path: string, value: Notification, source: string) => void //TSTODO convert queryRequest to ts // eslint-disable-next-line @typescript-eslint/no-explicit-any queryRequest: (requestId: string) => Promise diff --git a/src/api/alarms/index.ts b/src/api/alarms/index.ts deleted file mode 100644 index f4d44ee3f..000000000 --- a/src/api/alarms/index.ts +++ /dev/null @@ -1,249 +0,0 @@ -/* - API for raising / clearing Standard Alarm types as defined in Signal K specification, - providing default message text for each alarm type which can be overridden. -*/ - -import { createDebug } from '../../debug' -const debug = createDebug('signalk-server:api:alarms') - -import { IRouter, Request, Response, NextFunction } from 'express' -import _ from 'lodash' - -import { SignalKMessageHub, WithConfig } from '../../app' -import { WithSecurityStrategy } from '../../security' - -import { - Position, - ALARM_METHOD, - ALARM_STATE, - Notification, - SKVersion -} from '@signalk/server-api' -import { Responses } from '..' - -import { buildSchemaSync } from 'api-schema-builder' -import alarmsApiDoc from './openApi.json' - -const ALARMS_API_SCHEMA = buildSchemaSync(alarmsApiDoc) - -const SIGNALK_API_PATH = `/signalk/v2/api` -const ALARMS_API_PATH = `${SIGNALK_API_PATH}/notifications` - -const STANDARD_ALARMS = [ - 'mob', - 'fire', - 'sinking', - 'flooding', - 'collision', - 'grounding', - 'listing', - 'adrift', - 'piracy', - 'abandon' -] - -interface AlarmsApplication - extends IRouter, - WithConfig, - WithSecurityStrategy, - SignalKMessageHub {} - -export class AlarmsApi { - constructor(private server: AlarmsApplication) {} - - async start() { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve) => { - this.initAlarmEndpoints() - resolve() - }) - } - - private getVesselPosition() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return _.get((this.server.signalk as any).self, 'navigation.position.value') - } - - private getVesselAttitude() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return _.get((this.server.signalk as any).self, 'navigation.attitude.value') - } - - private updateAllowed(request: Request): boolean { - return this.server.securityStrategy.shouldAllowPut( - request, - 'vessels.self', - null, - 'notifications' - ) - } - - private initAlarmEndpoints() { - debug(`** Initialise ${ALARMS_API_PATH} path handlers **`) - - this.server.put( - `${ALARMS_API_PATH}/:alarmType`, - (req: Request, res: Response, next: NextFunction) => { - debug(`** PUT ${ALARMS_API_PATH}/${req.params.alarmType}`) - if (!this.updateAllowed(req)) { - res.status(403).json(Responses.unauthorised) - return - } - if (!STANDARD_ALARMS.includes(req.params.alarmType)) { - next() - return - } - try { - const endpoint = - ALARMS_API_SCHEMA[`${ALARMS_API_PATH}/:standardAlarm`].put - if (!endpoint.body.validate(req.body)) { - res.status(400).json(endpoint.body.errors) - return - } - const r = this.updateAlarmState(req) - res.status(200).json(r) - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - } - ) - - this.server.delete( - `${ALARMS_API_PATH}/:alarmType`, - (req: Request, res: Response, next: NextFunction) => { - debug(`** DELETE ${ALARMS_API_PATH}/${req.params.alarmType}`) - if (!this.updateAllowed(req)) { - res.status(403).json(Responses.unauthorised) - return - } - if (!STANDARD_ALARMS.includes(req.params.alarmType)) { - next() - return - } - try { - const r = this.updateAlarmState(req, true) - res.status(200).json(r) - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - } - ) - } - - // set / clear alarm state - private updateAlarmState = (req: Request, clear = false) => { - const path = `notifications.${req.params.alarmType as string}` - let alarmValue: Notification | null - - if (clear) { - alarmValue = null - } else { - let msg = req.body.message - ? req.body.message - : this.getDefaultMessage(req.params.alarmType as string) - - const pos: Position = this.getVesselPosition() - msg += pos ? '' : ' (No position data available.)' - - let roll: number | null = null - if (req.params.alarmType === 'listing') { - const att = this.getVesselAttitude() - roll = att && att.roll ? att.roll : null - } - - alarmValue = { - message: msg, - method: [ALARM_METHOD.sound, ALARM_METHOD.visual], - state: ALARM_STATE.emergency - } - - if (req.body.additionalData || pos || roll) { - alarmValue.data = {} - - if (req.body.additionalData) { - Object.assign(alarmValue.data, req.body.additionalData) - } - if (pos) { - Object.assign(alarmValue.data, { position: pos }) - } - if (roll) { - Object.assign(alarmValue.data, { roll: roll }) - } - } - } - - debug(`****** Sending ${req.params.alarmType} Notification: ******`) - debug(path, JSON.stringify(alarmValue)) - this.emitAlarmNotification(path, alarmValue, SKVersion.v1) - return { state: 'COMPLETED', resultStatus: 200, statusCode: 200 } - } - - // return default message for supplied alarm type - private getDefaultMessage = (alarmType: string): string => { - switch (alarmType) { - case 'mob': - return 'Man overboard!' - break - case 'fire': - return 'Fire onboard vessel!' - break - case 'sinking': - return 'Vessel sinking!' - break - case 'flooding': - return 'Vessel talking on water!' - break - case 'collision': - return 'Vessel has collided with another!' - break - case 'grounding': - return 'Vessel has run aground!' - break - case 'listing': - return 'Vessel has exceeded maximum safe angle of list!' - break - case 'adrift': - return 'Vessel is cast adrift!' - break - case 'piracy': - return 'Vessel has encountered pirates!' - break - case 'abandon': - return 'Vessel has been abandoned!' - break - } - return alarmType - } - - // emit delta of specified version - private emitAlarmNotification( - path: string, - value: Notification | null, - version: SKVersion - ) { - this.server.handleMessage( - 'alarmsApi', - { - updates: [ - { - values: [ - { - path: path, - value: value - } - ] - } - ] - }, - version - ) - } -} diff --git a/src/api/alarms/openApi.json b/src/api/alarms/openApi.json deleted file mode 100644 index 071578ed5..000000000 --- a/src/api/alarms/openApi.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "version": "2.0.0", - "title": "Signal K Alarms API", - "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." - }, - "servers": [ - { - "url": "/signalk/v2/api/notifications" - } - ], - "tags": [ - { - "name": "alarms", - "description": "Special Alarms" - } - ], - "components": { - "schemas": { - "AlarmData": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Override standard message text associated with this alarm." - }, - "additionalData": { - "type": "object", - "additionalProperties": true, - "description": "Additional data values associated with this alarm." - } - } - } - }, - "responses": { - "200Ok": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "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": { - "StandardAlarms": { - "name": "standardAlarm", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "mob", - "sinking", - "fire", - "listing", - "piracy", - "flooding", - "collision", - "grounding", - "adrift", - "abandon" - ] - } - } - }, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - }, - "cookieAuth": { - "type": "apiKey", - "in": "cookie", - "name": "JAUTHENTICATION" - } - } - }, - "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], - "paths": { - "/{standardAlarm}": { - "parameters": [ - { - "$ref": "#/components/parameters/StandardAlarms" - } - ], - "put": { - "tags": ["alarms"], - "summary": "Raise a standard alarm.", - "description": "Raise one of the standard alarms defined in the Signal K specification.", - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlarmData" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - }, - "delete": { - "tags": ["alarms"], - "summary": "Clear a standard alarm.", - "description": "Clear the specified standard alarm.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - } - } -} diff --git a/src/api/alarms/openApi.ts b/src/api/alarms/openApi.ts deleted file mode 100644 index f84d94b4d..000000000 --- a/src/api/alarms/openApi.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { OpenApiDescription } from '../swagger' -import alarmsApiDoc from './openApi.json' - -export const alarmsApiRecord = { - name: 'alarms', - path: '/signalk/v2/api/notifications', - apiDoc: alarmsApiDoc as unknown as OpenApiDescription -} diff --git a/src/api/index.ts b/src/api/index.ts index 92f91e89a..2f54993a9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,12 +3,12 @@ import { SignalKMessageHub, WithConfig } from '../app' import { WithSecurityStrategy } from '../security' import { CourseApi } from './course' import { ResourcesApi } from './resources' -import { AlarmsApi } from './alarms' +import { NotificationsApi } from './notifications' export interface ApiResponse { state: 'FAILED' | 'COMPLETED' | 'PENDING' statusCode: number - message: string + message?: string requestId?: string href?: string token?: string @@ -44,6 +44,12 @@ export const startApis = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(app as any).resourcesApi = resourcesApi const courseApi = new CourseApi(app, resourcesApi) - const alarmsApi = new AlarmsApi(app) - Promise.all([resourcesApi.start(), courseApi.start(), alarmsApi.start()]) + const notificationsApi = new NotificationsApi(app) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(app as any).notificationsApi = notificationsApi + Promise.all([ + resourcesApi.start(), + courseApi.start(), + notificationsApi.start() + ]) } diff --git a/src/api/notifications/index.ts b/src/api/notifications/index.ts new file mode 100644 index 000000000..468722c3b --- /dev/null +++ b/src/api/notifications/index.ts @@ -0,0 +1,579 @@ +/* + API for working with Notifications / Alarms. +*/ + +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:notifications') + +import { IRouter, Request, Response } from 'express' +import _ from 'lodash' +import { v4 as uuidv4 } from 'uuid' + +import { SignalKMessageHub, WithConfig } from '../../app' +import { WithSecurityStrategy } from '../../security' +import { ApiResponse } from '../' + +import { + ALARM_METHOD, + ALARM_STATE, + Notification, + SKVersion +} from '@signalk/server-api' + +import { buildSchemaSync } from 'api-schema-builder' +import notificationsApiDoc from './openApi.json' + +const NOTI_API_SCHEMA = buildSchemaSync(notificationsApiDoc) + +const SIGNALK_API_PATH = `/signalk/v2/api` +const NOTI_API_PATH = `${SIGNALK_API_PATH}/notifications` +const $SRC = 'notificationsApi' + +interface NotificationsApplication + extends IRouter, + WithConfig, + WithSecurityStrategy, + SignalKMessageHub {} + +export class NotificationsApi { + private idToPathMap: Map + + constructor(private server: NotificationsApplication) { + this.idToPathMap = new Map() + } + + async start() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + this.initApiEndpoints() + resolve() + }) + } + + /** public interface methods */ + notify(path: string, value: Notification | null, source: string) { + debug(`** Interface:put(${path}, value, ${source})`) + if (!path || !source) { + throw new Error('Path and source values must be specified!') + } + if (path.split('.')[0] !== 'notifications') { + throw new Error('Invalid notifications path!') + } + + try { + if (!value) { + this.clearNotificationAtPath(path, source) + } else { + return this.setNotificationAtPath(path, value, source) + } + } catch (e) { + debug((e as Error).message) + throw e + } + } + + private updateAllowed(request: Request): boolean { + return this.server.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'notifications' + ) + } + + private initApiEndpoints() { + debug(`** Initialise ${NOTI_API_PATH} path handlers **`) + + // Raise man overboard alarm + this.server.post(`${NOTI_API_PATH}/mob`, (req: Request, res: Response) => { + debug(`** POST ${NOTI_API_PATH}/mob`) + + const notiPath = `notifications.mob` + const pos = this.getSelfPath('navigation.position') + try { + const notiValue: Notification = { + message: 'Man Overboard!', + method: [ALARM_METHOD.sound, ALARM_METHOD.visual], + state: ALARM_STATE.emergency, + id: uuidv4(), + data: { + position: pos ? pos.value : 'No vessel position data.' + } + } + this.updateModel(notiPath, notiValue, $SRC) + + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: notiValue.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Acknowledge notification + this.server.put(`${NOTI_API_PATH}/ack/*`, (req: Request, res: Response) => { + debug(`** PUT ${NOTI_API_PATH}/ack/${req.params[0]}`) + debug(`** params ${JSON.stringify(req.query)}`) + const source = req.query.source as string ?? $SRC + + try { + const id = this.pathIsUuid(req.params[0]) + let noti + if (id) { + debug(`** id detected: Fetch Notification with id = ${id}`) + noti = this.getNotificationById(id) + if (!noti) { + res.status(400).json({ + state: 'FAILED', + statusCode: 404, + message: `Notification with id = ${id} NOT found!` + }) + return + } + } else { + const notiPath = `notifications.` + req.params[0].split('/').join('.') + noti = this.getNotificationByPath(notiPath, source) + if (noti) { + res.status(200).json(noti) + } else { + res.status(400).json({ + state: 'FAILED', + statusCode: 404, + message: `Notification ${notiPath} NOT found!` + }) + return + } + } + if (noti.value.actions && Array.isArray(noti.value.actions)) { + if (!noti.value.actions.includes('ACK')) { + noti.value.actions.push('ACK') + } + } else { + noti.value.actions = ['ACK'] + } + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Create / update notification + this.server.put(`${NOTI_API_PATH}/*`, (req: Request, res: Response) => { + debug(`** PUT ${NOTI_API_PATH}/${req.params[0]}`) + debug(JSON.stringify(req.body)) + + /*if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + }*/ + if (!req.params[0]) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: 'No path provided!' + }) + } + + try { + /*const endpoint = + NOTI_API_SCHEMA[`${NOTI_API_PATH}/:standardAlarm`].put + if (!endpoint.body.validate(req.body)) { + res.status(400).json(endpoint.body.errors) + return + }*/ + let id = this.pathIsUuid(req.params[0]) + let notiPath: string + if (id) { + notiPath = this.idToPathMap.get(id) as string + debug(`** id supplied: PUT(${id}) ---> mapped to path ${notiPath}`) + if (!notiPath) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Supplied id is not mapped to a notification path!` + }) + return + } + } else { + notiPath = `notifications.` + req.params[0].split('/').join('.') + debug(`** path supplied: ${notiPath}`) + } + + const notiValue: Notification = { + message: req.body.message ?? '', + method: this.getNotificationMethod(), + state: req.body.state ?? ALARM_STATE.alert + } + if (req.body.data) { + notiValue.data = req.body.data + } + + id = this.setNotificationAtPath(notiPath, notiValue, $SRC) + + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Clear notification + this.server.delete(`${NOTI_API_PATH}/*`, (req: Request, res: Response) => { + debug(`** DELETE ${NOTI_API_PATH}/${req.params[0]}`) + debug(`** params ${JSON.stringify(req.query)}`) + /* + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + */ + const source = (req.query.source as string) ?? $SRC + debug(`** source = ${source}`) + try { + const id = this.pathIsUuid(req.params[0]) + if (id) { + debug(`** id supplied: ${id}`) + this.clearNotificationWithId(id) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200 + }) + } else { + const notiPath = `notifications.` + req.params[0].split('/').join('.') + debug(`** path supplied: Clear ${notiPath} from $source= ${source}`) + this.clearNotificationAtPath(notiPath, source) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200 + }) + } + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // List notifications keyed by either path or id + this.server.get(`${NOTI_API_PATH}`, (req: Request, res: Response) => { + debug(`** GET ${NOTI_API_PATH}`) + debug(`** params ${JSON.stringify(req.query)}`) + const keyById = req.query.key === 'id' ? true : false + try { + const notiList: { [key: string]: Notification } = {} + this.idToPathMap.forEach((path, id) => { + const noti = this.getNotificationById(id, keyById) + if (noti) { + const key = keyById ? id : path + notiList[key] = noti + } + }) + res.status(200).json(notiList) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Return notification + this.server.get(`${NOTI_API_PATH}/*`, (req: Request, res: Response) => { + debug(`** GET ${NOTI_API_PATH}/*`) + debug(`** params ${JSON.stringify(req.query)}`) + const source = req.query.source as string + + try { + const id = this.pathIsUuid(req.params[0]) + if (id) { + debug(`** id detected: getNotificationById(${id})`) + const noti = this.getNotificationById(id, true) + if (noti) { + res.status(200).json(noti) + } else { + res.status(400).json({ + state: 'FAILED', + statusCode: 404, + message: `Notification with id = ${id} NOT found!` + }) + } + } else { + const notiPath = `notifications.` + req.params[0].split('/').join('.') + let noti + if(source) { + debug(`** filtering results by source: ${source}`) + noti = this.getNotificationByPath(notiPath, source) + } else { + noti = this.getSelfPath(notiPath) + } + if (noti) { + res.status(200).json(noti) + } else { + res.status(400).json({ + state: 'FAILED', + statusCode: 404, + message: `Notification ${notiPath} NOT found!` + }) + } + } + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + } + + /** Clear Notification with provided id + * @param id: UUID of notification to clear + */ + private clearNotificationWithId(id: string){ + if (!this.idToPathMap.has(id)) { + throw new Error(`Notification with id = ${id} NOT found!`) + } + const path = this.idToPathMap.get(id) + // Get $source of notification + const noti = this.getSelfPath(path as string) + const source = this.sourceOfId(noti, id) ?? $SRC + this.updateModel(path as string, null, source) + this.idToPathMap.delete(id) + } + + /** Clear Notification at `path` raised by the specified $source + * @param path: signal k path in dot notation + * @param source: $source value to use. + */ + private clearNotificationAtPath(path: string, source: string) { + debug(`** path supplied: Clear ${path} from $source= ${source}`) + // Get notification value for the supplied source + const noti = this.getSelfPath(path) + const notiValue = this.valueWithSource(noti, source) + if (!notiValue) { + throw new Error( + `No notification found at ${path} that is from ${source}!` + ) + } + // Check notification for an id, if present then delete from map + if (notiValue.id && this.idToPathMap.has(notiValue.id)) { + debug(`** id detected..removing from map: ${notiValue.id}`) + this.idToPathMap.delete(notiValue.id) + } + this.updateModel(path, null, source) + } + + /** Set Notification value and $source at supplied path. + * @param path: signal k path in dot notation + * @param value: value to assign to path + * @param source: source identifier + * @returns id assigned to notification + */ + private setNotificationAtPath( + path: string, + value: Notification, + source: string + ): string { + debug(`** Set Notification at ${path} with $source= ${source}`) + // get id from existing value or generate id + const noti = this.getSelfPath(path) + const nv = noti ? this.valueWithSource(noti, source) : null + value.id = nv && nv.id ? noti.value.id : uuidv4() + debug(`** id = ${value.id}`) + + this.updateModel(path, value, source) + + return value.id as string + } + + /** TODO *** Get the Notification method for the supplied Notification type */ + private getNotificationMethod = (type?: string) => { + if (!type) { + return [ALARM_METHOD.sound, ALARM_METHOD.visual] + } else { + // return method for supplied type from settings + return [ALARM_METHOD.sound, ALARM_METHOD.visual] + } + } + + /** Maintain id mapping and send delta. + * @param path: signal k path in dot notation + * @param value: value to assign to path + * @param source: source identifier + */ + private updateModel = ( + path: string, + value: Notification | null, + source: string + ) => { + debug(`****** Sending ${path} Notification: ******`) + debug(`value: `, JSON.stringify(value)) + debug(`source: `, source ?? 'self (default)') + + if (value && value.id) { + debug(`ADDING to idToPathMap(${value.id})`) + this.idToPathMap.set(value.id, path) + } + + this.server.handleMessage( + source, + { + updates: [ + { + values: [ + { + path: path, + value: value + } + ] + } + ] + }, + SKVersion.v1 + ) + } + + /** Checks if path is a UUID + * @param path: UUID or signal k path in / notation + * @returns UUID value (or empty string + * */ + private pathIsUuid(path: string): string { + const testId = (id: string): boolean => { + const uuid = RegExp( + '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$' + ) + return uuid.test(id) + } + const p = path.indexOf('/') !== -1 ? path.split('/') : path.split('.') + if (p.length === 1 && testId(p[0])) { + return p[0] + } else { + return '' + } + } + + /** Get Signal K object from `self` at supplied path. + * @param path: signal k path in dot notation + * @returns signal k object + */ + private getSelfPath(path: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return _.get((this.server.signalk as any).self, path) + } + + /** Get Notification object with the supplied id. + * Note: values attribute (if present) is omitted from the returned object! + * @param id: notification id value to match. + @param incPath: If true includes a path attribute containing the signal k path. + @returns signal k object or null + */ + private getNotificationById(id: string, incPath?: boolean) { + if (this.idToPathMap.has(id)) { + const path = this.idToPathMap.get(id) + debug(`getNotificationById(${id}) => ${path}`) + const n = this.getSelfPath(path as string) + if (n['$source'] !== $SRC) { + const v = this.valueWithSource(n, $SRC) + if (!v) { + return null + } + n.value = v + n['$source'] !== $SRC + } + delete n.values + + const noti = Object.assign({}, n, incPath ? { path: path } : {}) + debug(`**NOTIFICATION with id = ${id}`, JSON.stringify(noti)) + return noti + } else { + debug(`idToPathMap(${id}) => NOT FOUND`) + return null + } + } + + /** Get Notification object at specified path with the value from the supplied $source. + * Note: values attribute (if present) is omitted from the returned object! + * @param path: signal k path in dot notation. + @param source: source identifier of the value to return + @returns signal k object or null + */ + private getNotificationByPath(path: string, source: string = $SRC) { + const n = this.getSelfPath(path as string) + if (n['$source'] !== source) { + const v = this.valueWithSource(n, source) + if (!v) { + console.log(`*** Couldn't find $source = ${source}`) + return null + } + n.value = v + n['$source'] = source + } + delete n.values + const noti = Object.assign({}, n) + debug(`**NOTIFICATION at ${path} from ${source}`, JSON.stringify(noti)) + return noti + } + + // returns $source value of supplied SK object with the specified id attribute value + /** Get the $source of the notification with the supplied id (including when multiple values are present). + * @param o: signal k object + * @param id: notification id + * @returns astring containing the value of $source | undefined + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private sourceOfId(o: any, id: string) { + let src + if (o.values) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Object.entries(o.values).forEach((e: Array) => { + if (e[1].value && e[1].value.id && e[1].value.id === id) { + src = e[0] + } + }) + } else { + if (o.value && o.value.id && o.value.id === id) { + src = o['$source'] + } + } + debug(`** sourceWithId(${id}) = ${src}`) + return src + } + + /** Get the value (including when multiple values are present) with the provided $source. + * @param o: signal k object + * @param source: $source identifier of desired value. + * @returns Notification | null + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private valueWithSource(o: any, source: string) { + let v + if (o.values && o.values[source]) { + v = Object.assign({}, o.values[source].value) + } else { + if (o['$source'] === source) { + v = Object.assign({}, o.value) + } + } + debug(`** valueWithSource(${source}) = ${JSON.stringify(v)}`) + return v + } +} diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json index a28e33d58..344377992 100644 --- a/src/api/notifications/openApi.json +++ b/src/api/notifications/openApi.json @@ -7,7 +7,8 @@ "license": { "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - } + }, + "description": "API for raising and actioning Notifications / Alarms. Notifications raised using this API are assigned an id which can be used for subsequent actions." }, "externalDocs": { "url": "http://signalk.org/specification/", @@ -15,91 +16,169 @@ }, "servers": [ { - "url": "/signalk/v1/api/vessels/self/notifications" + "url": "/signalk/v2/api/notifications" } ], "tags": [ { "name": "notifications", - "description": "Root path" + "description": "General actions." + }, + { + "name": "via path", + "description": "Action notifications via specified path." }, { - "name": "special", - "description": "Special Alarms" + "name": "via id", + "description": "Action notifications via supplied id." }, { - "name": "course", - "description": "Course notifications" + "name": "alarms", + "description": "Standard, pre-defined alarms." } ], "components": { "schemas": { - "AlarmState": { + "UuidDef": { "type": "string", - "description": "Value describing the current state of the alarm.", - "example": "alert", - "enum": ["normal", "nominal", "alert", "warning", "alarm", "emergency"] + "pattern": "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", + "example": "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" }, - "AlarmMethod": { + "PathDef": { + "type": "string", + "description": "Notification path.", + "example": "notifications.mob" + }, + "MethodDef": { "type": "array", "minimum": 0, "maximum": 2, "uniqueItems": true, - "description": "Methods to use to raise the alarm.", + "description": "How the alarm is actioned.", "example": ["sound"], "items": { "type": "string", "enum": ["visual", "sound"] } }, - "Alarm": { + "SeverityDef": { + "type": "string", + "description": "Severity of the alarm.", + "example": "alert", + "enum": ["normal", "nominal", "alert", "warning", "alarm", "emergency"] + }, + "MessageDef": { + "type": "string", + "description": "Notification message to display.", + "example": "My message!" + }, + "TypeDef": { + "type": "string", + "description": "Type of notification.", + "example": "OverVoltage" + }, + "DataDef": { + "type": "object", + "additionalProperties": true, + "description": "Data values associated with this notification." + }, + "RequestModel": { "type": "object", - "required": ["state", "method", "message"], "properties": { "state": { - "$ref": "#/components/schemas/AlarmState" - }, - "method": { - "$ref": "#/components/schemas/AlarmMethod" + "$ref": "#/components/schemas/SeverityDef" }, "message": { - "type": "string" + "$ref": "#/components/schemas/MessageDef" + }, + "data": { + "$ref": "#/components/schemas/DataDef" + }, + "type": { + "$ref": "#/components/schemas/TypeDef" } } }, - "Notification": { + "ResponseModel": { + "description": "Notification information", "type": "object", - "required": ["value"], + "required": ["timestamp", "$source"], "properties": { + "timestamp": { + "type": "string" + }, + "$source": { + "type": "string" + }, "value": { - "$ref": "#/components/schemas/Alarm" + "type": "object", + "required": ["method", "state", "message"], + "properties": { + "method": { + "$ref": "#/components/schemas/MethodDef" + }, + "state": { + "$ref": "#/components/schemas/SeverityDef" + }, + "message": { + "$ref": "#/components/schemas/MessageDef" + }, + "data": { + "$ref": "#/components/schemas/DataDef" + }, + "id": { + "$ref": "#/components/schemas/UuidDef" + }, + "type": { + "$ref": "#/components/schemas/TypeDef" + } + } } } - }, - "Notifications": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/Alarm" - } } }, "responses": { - "ListResponse": { - "description": "Collection of notifications", + "200Ok": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Notifications" + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["COMPLETED"] + }, + "statusCode": { + "type": "number", + "enum": [200] + } + }, + "required": ["state", "statusCode"] } } } }, - "200Ok": { - "description": "OK", + "201ActionResponse": { + "description": "Action response - success.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Notification" + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["COMPLETED"] + }, + "statusCode": { + "type": "number", + "enum": [201] + }, + "id": { + "$ref": "#/components/schemas/UuidDef" + } + }, + "required": ["id", "statusCode", "state"] } } } @@ -128,30 +207,105 @@ } } } + }, + "NotificationResponse": { + "description": "Notification information response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseModel" + } + } + } + } + }, + "parameters": { + "Source": { + "name": "source", + "description": "The source that raised the notification at the defined path.", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" } } }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], "paths": { "/": { "get": { "tags": ["notifications"], - "summary": "Notifications endpoint.", - "description": "Root path for notifications.", - "responses": { - "200": { - "$ref": "#/components/responses/ListResponse" + "summary": "Filtered notification list keyed by id.", + "description": "Retrieve list of notifications filtered by the specified parameter.", + "parameters": [ + { + "name": "type", + "description": "Filter results by notification type.", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/TypeDef" + } + }, + { + "name": "severity", + "description": "Filter results by alarm severity.", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/SeverityDef" + } }, + { + "name": "key", + "description": "List results by provided key value.", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["path", "id"] + } + } + ], + "responses": { "default": { - "$ref": "#/components/responses/ErrorResponse" + "description": "An object containing notifications, keyed by their id.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/ResponseModel" + } + ] + } + } + } + } } } } }, "/mob": { - "get": { - "tags": ["special"], + "post": { + "tags": ["alarms"], "summary": "Man overboard alarm.", - "description": "Alarm indicating person(s) overboard.", + "description": "Raise a Man overboard alarm with system generated message including vessel position.", "responses": { "200": { "$ref": "#/components/responses/200Ok" @@ -162,56 +316,42 @@ } } }, - "/fire": { - "get": { - "tags": ["special"], - "summary": "Fire onboard alarm.", - "description": "Alarm indicating there is a fire onboard.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + "/{id}": { + "parameters": [ + { + "name": "id", + "description": "Notification identifier.", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/UuidDef" } } - } - }, - "/sinking": { + ], "get": { - "tags": ["special"], - "summary": "Sinking vessel alarm.", - "description": "Alarm indicating vessel is sinking.", + "tags": ["via id"], + "summary": "Retrieve notification information.", + "description": "Retrieve information for notification with supplied id.", "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, "default": { - "$ref": "#/components/responses/ErrorResponse" + "$ref": "#/components/responses/NotificationResponse" } } - } - }, - "/flooding": { - "get": { - "tags": ["special"], - "summary": "Floodingalarm.", - "description": "Alarm indicating that veseel is taking on water.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + }, + "put": { + "tags": ["via id"], + "summary": "Action a notification.", + "description": "Raise or action the notification with the specified id.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestModel" + } + } } - } - } - }, - "/collision": { - "get": { - "tags": ["special"], - "summary": "Collision alarm.", - "description": "Alarm indicating vessel has been involved in a collision.", + }, "responses": { "200": { "$ref": "#/components/responses/200Ok" @@ -220,13 +360,11 @@ "$ref": "#/components/responses/ErrorResponse" } } - } - }, - "/grounding": { - "get": { - "tags": ["special"], - "summary": "Grounding alarm.", - "description": "Alarm indicating vessel has run aground.", + }, + "delete": { + "tags": ["via id"], + "summary": "Clear notification.", + "description": "Clear the notification with the supplied id.", "responses": { "200": { "$ref": "#/components/responses/200Ok" @@ -237,86 +375,54 @@ } } }, - "/listing": { + "/*": { "get": { - "tags": ["special"], - "summary": "Listing alarm.", - "description": "Alarm indicating vessel is listing beyond acceptable parameters.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + "tags": ["via path"], + "summary": "Retrieve notification information.", + "description": "Retrieve information for notification with supplied id.", + "parameters": [ + { + "$ref": "#/components/parameters/Source" } - } - } - }, - "/adrift": { - "get": { - "tags": ["special"], - "summary": "Adrift alarm.", - "description": "Alarm indicating that the vessel is set adrift.", + ], "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, "default": { - "$ref": "#/components/responses/ErrorResponse" + "$ref": "#/components/responses/NotificationResponse" } } - } - }, - "/piracy": { - "get": { - "tags": ["special"], - "summary": "Piracy alarm.", - "description": "Alarm indicating pirates have been encountered / boarded.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + }, + "put": { + "tags": ["via path"], + "summary": "Action a notification.", + "description": "Raise or action a notification at the specified path.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestModel" + } + } } - } - } - }, - "/abandon": { - "get": { - "tags": ["special"], - "summary": "Abandon alarm.", - "description": "Alarm indicating vessel has been abandoned.", + }, "responses": { - "200": { - "$ref": "#/components/responses/200Ok" + "201": { + "$ref": "#/components/responses/201ActionResponse" }, "default": { "$ref": "#/components/responses/ErrorResponse" } } - } - }, - "/navigation/course/arrivalCircleEntered": { - "get": { - "tags": ["course"], - "summary": "Arrival circle entered.", - "description": "Set when arrival circle around destination point has been entered.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" + }, + "delete": { + "tags": ["via path"], + "summary": "Clear notification.", + "description": "Clear the specified notification.", + "parameters": [ + { + "$ref": "#/components/parameters/Source" } - } - } - }, - "/navigation/course/perpendicularPassed": { - "get": { - "tags": ["course"], - "summary": "Perpendicular passed.", - "description": "Set when line perpendicular to destination point has been passed by the vessel.", + ], "responses": { "200": { "$ref": "#/components/responses/200Ok" diff --git a/src/api/notifications/openApi.ts b/src/api/notifications/openApi.ts index ffc4c0f49..3de54479f 100644 --- a/src/api/notifications/openApi.ts +++ b/src/api/notifications/openApi.ts @@ -3,6 +3,6 @@ import notificationsApiDoc from './openApi.json' export const notificationsApiRecord = { name: 'notifications', - path: '/signalk/v1/api/vessels/self/notifications', + path: '/signalk/v2/api/notifications', apiDoc: notificationsApiDoc as unknown as OpenApiDescription } diff --git a/src/api/notificationsv1/openApi.json b/src/api/notificationsv1/openApi.json new file mode 100644 index 000000000..a28e33d58 --- /dev/null +++ b/src/api/notificationsv1/openApi.json @@ -0,0 +1,331 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Notifications API", + "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." + }, + "servers": [ + { + "url": "/signalk/v1/api/vessels/self/notifications" + } + ], + "tags": [ + { + "name": "notifications", + "description": "Root path" + }, + { + "name": "special", + "description": "Special Alarms" + }, + { + "name": "course", + "description": "Course notifications" + } + ], + "components": { + "schemas": { + "AlarmState": { + "type": "string", + "description": "Value describing the current state of the alarm.", + "example": "alert", + "enum": ["normal", "nominal", "alert", "warning", "alarm", "emergency"] + }, + "AlarmMethod": { + "type": "array", + "minimum": 0, + "maximum": 2, + "uniqueItems": true, + "description": "Methods to use to raise the alarm.", + "example": ["sound"], + "items": { + "type": "string", + "enum": ["visual", "sound"] + } + }, + "Alarm": { + "type": "object", + "required": ["state", "method", "message"], + "properties": { + "state": { + "$ref": "#/components/schemas/AlarmState" + }, + "method": { + "$ref": "#/components/schemas/AlarmMethod" + }, + "message": { + "type": "string" + } + } + }, + "Notification": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "$ref": "#/components/schemas/Alarm" + } + } + }, + "Notifications": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Alarm" + } + } + }, + "responses": { + "ListResponse": { + "description": "Collection of notifications", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notifications" + } + } + } + }, + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + } + }, + "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"] + } + } + } + } + } + }, + "paths": { + "/": { + "get": { + "tags": ["notifications"], + "summary": "Notifications endpoint.", + "description": "Root path for notifications.", + "responses": { + "200": { + "$ref": "#/components/responses/ListResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/mob": { + "get": { + "tags": ["special"], + "summary": "Man overboard alarm.", + "description": "Alarm indicating person(s) overboard.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/fire": { + "get": { + "tags": ["special"], + "summary": "Fire onboard alarm.", + "description": "Alarm indicating there is a fire onboard.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/sinking": { + "get": { + "tags": ["special"], + "summary": "Sinking vessel alarm.", + "description": "Alarm indicating vessel is sinking.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/flooding": { + "get": { + "tags": ["special"], + "summary": "Floodingalarm.", + "description": "Alarm indicating that veseel is taking on water.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/collision": { + "get": { + "tags": ["special"], + "summary": "Collision alarm.", + "description": "Alarm indicating vessel has been involved in a collision.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/grounding": { + "get": { + "tags": ["special"], + "summary": "Grounding alarm.", + "description": "Alarm indicating vessel has run aground.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/listing": { + "get": { + "tags": ["special"], + "summary": "Listing alarm.", + "description": "Alarm indicating vessel is listing beyond acceptable parameters.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/adrift": { + "get": { + "tags": ["special"], + "summary": "Adrift alarm.", + "description": "Alarm indicating that the vessel is set adrift.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/piracy": { + "get": { + "tags": ["special"], + "summary": "Piracy alarm.", + "description": "Alarm indicating pirates have been encountered / boarded.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/abandon": { + "get": { + "tags": ["special"], + "summary": "Abandon alarm.", + "description": "Alarm indicating vessel has been abandoned.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/navigation/course/arrivalCircleEntered": { + "get": { + "tags": ["course"], + "summary": "Arrival circle entered.", + "description": "Set when arrival circle around destination point has been entered.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/navigation/course/perpendicularPassed": { + "get": { + "tags": ["course"], + "summary": "Perpendicular passed.", + "description": "Set when line perpendicular to destination point has been passed by the vessel.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } +} diff --git a/src/api/notificationsv1/openApi.ts b/src/api/notificationsv1/openApi.ts new file mode 100644 index 000000000..ffc4c0f49 --- /dev/null +++ b/src/api/notificationsv1/openApi.ts @@ -0,0 +1,8 @@ +import { OpenApiDescription } from '../swagger' +import notificationsApiDoc from './openApi.json' + +export const notificationsApiRecord = { + name: 'notifications', + path: '/signalk/v1/api/vessels/self/notifications', + apiDoc: notificationsApiDoc as unknown as OpenApiDescription +} diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 2ad70fb54..233c90b43 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -2,7 +2,6 @@ import { IRouter, NextFunction, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' -import { alarmsApiRecord } from './alarms/openApi' import { courseApiRecord } from './course/openApi' import { notificationsApiRecord } from './notifications/openApi' import { resourcesApiRecord } from './resources/openApi' @@ -25,7 +24,6 @@ interface ApiRecords { } const apiDocs = [ - alarmsApiRecord, appsApiRecord, courseApiRecord, discoveryApiRecord, diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index c1d894e81..e21a30a4d 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -30,6 +30,7 @@ import fs from 'fs' import _ from 'lodash' import path from 'path' import { ResourcesApi } from '../api/resources' +import { NotificationsApi } from '../api/notifications' import { SERVERROUTESPREFIX } from '../constants' import { createDebug } from '../debug' import { listAllSerialPorts } from '../serialports' @@ -509,6 +510,13 @@ module.exports = (theApp: any) => { resourcesApi.register(plugin.id, provider) } + const notificationsApi: NotificationsApi = app.notificationsApi + _.omit(appCopy, 'notificationsApi') // don't expose the actual notifications api manager + // eslint-disable-next-line @typescript-eslint/no-explicit-any + appCopy.notify = (path: string, value: any, source: string) => { + notificationsApi.notify(path, value, source) + } + try { const pluginConstructor: ( app: ServerAPI From e014545b45883d177037f0abdaf49f6e5486375b Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:04:25 +0930 Subject: [PATCH 5/5] chore: format --- src/api/notifications/index.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/api/notifications/index.ts b/src/api/notifications/index.ts index 468722c3b..59c8e44b4 100644 --- a/src/api/notifications/index.ts +++ b/src/api/notifications/index.ts @@ -11,7 +11,6 @@ import { v4 as uuidv4 } from 'uuid' import { SignalKMessageHub, WithConfig } from '../../app' import { WithSecurityStrategy } from '../../security' -import { ApiResponse } from '../' import { ALARM_METHOD, @@ -120,7 +119,7 @@ export class NotificationsApi { this.server.put(`${NOTI_API_PATH}/ack/*`, (req: Request, res: Response) => { debug(`** PUT ${NOTI_API_PATH}/ack/${req.params[0]}`) debug(`** params ${JSON.stringify(req.query)}`) - const source = req.query.source as string ?? $SRC + const source = (req.query.source as string) ?? $SRC try { const id = this.pathIsUuid(req.params[0]) @@ -163,7 +162,7 @@ export class NotificationsApi { statusCode: 400, message: (e as Error).message }) - } + } }) // Create / update notification @@ -319,7 +318,7 @@ export class NotificationsApi { } else { const notiPath = `notifications.` + req.params[0].split('/').join('.') let noti - if(source) { + if (source) { debug(`** filtering results by source: ${source}`) noti = this.getNotificationByPath(notiPath, source) } else { @@ -347,8 +346,8 @@ export class NotificationsApi { /** Clear Notification with provided id * @param id: UUID of notification to clear - */ - private clearNotificationWithId(id: string){ + */ + private clearNotificationWithId(id: string) { if (!this.idToPathMap.has(id)) { throw new Error(`Notification with id = ${id} NOT found!`) } @@ -360,10 +359,10 @@ export class NotificationsApi { this.idToPathMap.delete(id) } - /** Clear Notification at `path` raised by the specified $source + /** Clear Notification at `path` raised by the specified $source * @param path: signal k path in dot notation * @param source: $source value to use. - */ + */ private clearNotificationAtPath(path: string, source: string) { debug(`** path supplied: Clear ${path} from $source= ${source}`) // Get notification value for the supplied source @@ -387,7 +386,7 @@ export class NotificationsApi { * @param value: value to assign to path * @param source: source identifier * @returns id assigned to notification - */ + */ private setNotificationAtPath( path: string, value: Notification, @@ -419,7 +418,7 @@ export class NotificationsApi { * @param path: signal k path in dot notation * @param value: value to assign to path * @param source: source identifier - */ + */ private updateModel = ( path: string, value: Notification | null, @@ -472,9 +471,9 @@ export class NotificationsApi { } /** Get Signal K object from `self` at supplied path. - * @param path: signal k path in dot notation + * @param path: signal k path in dot notation * @returns signal k object - */ + */ private getSelfPath(path: string) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return _.get((this.server.signalk as any).self, path) @@ -486,7 +485,7 @@ export class NotificationsApi { @param incPath: If true includes a path attribute containing the signal k path. @returns signal k object or null */ - private getNotificationById(id: string, incPath?: boolean) { + private getNotificationById(id: string, incPath?: boolean) { if (this.idToPathMap.has(id)) { const path = this.idToPathMap.get(id) debug(`getNotificationById(${id}) => ${path}`) @@ -538,7 +537,7 @@ export class NotificationsApi { * @param o: signal k object * @param id: notification id * @returns astring containing the value of $source | undefined - */ + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private sourceOfId(o: any, id: string) { let src @@ -562,7 +561,7 @@ export class NotificationsApi { * @param o: signal k object * @param source: $source identifier of desired value. * @returns Notification | null - */ + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private valueWithSource(o: any, source: string) { let v