diff --git a/docs/src/develop/plugins/server_plugin_api.md b/docs/src/develop/plugins/server_plugin_api.md index 629780961..7a12a4cc0 100644 --- a/docs/src/develop/plugins/server_plugin_api.md +++ b/docs/src/develop/plugins/server_plugin_api.md @@ -625,12 +625,10 @@ Retrieves the current course information. - returns: Resolved Promise on success containing the same course information returned by the [`/course`](/doc/openapi/?urls.primaryName=course#/course/get_course) API endpoint. -#### `app.clearDestination(apiMode?)` +#### `app.clearDestination()` Cancels navigation to the current point or route being followed. -- `apiMode`: (optional) If true causes deltas to be ignored. To re-enable delta input, call this method without specifying `apiMode`. - - returns: Resolved Promise on success. diff --git a/docs/src/develop/rest-api/course_api.md b/docs/src/develop/rest-api/course_api.md index 00821c414..912cd1421 100644 --- a/docs/src/develop/rest-api/course_api.md +++ b/docs/src/develop/rest-api/course_api.md @@ -9,7 +9,7 @@ Additionally, the Course API persists course information on the server to ensure Client applications use `HTTP` requests (`PUT`, `GET`,`DELETE`) to perform operations and retrieve course data. -The Course API also listens for destination information in the NMEA stream and will set / clear the destination accordingly _(e.g. RMB sentence)_. +The Course API also listens for destination information in the NMEA stream and will set / clear the destination accordingly _(e.g. RMB sentence)_. See [Configuration](#Configuration) for more details. _Note: You can view the _Course API_ OpenAPI definition in the Admin UI (Documentation => OpenApi)._ @@ -128,53 +128,6 @@ HTTP GET 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course' The contents of the response will reflect the operation used to set the current course. The `nextPoint` & `previousPoint` sections will always contain values but `activeRoute` will only contain values when a route is being followed. -#### Determining the source which set the destination - -When a destination is set, you can retrieve the source that set the destination by submitting a HTTP `GET` request to `/signalk/v2/api/vessels/self/navigation/course/commandSource`. - -```typescript -HTTP GET 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/commandSource' -``` - -_Example: Set by NMEA0183 source_ -```json -{ - "type": "NMEA0183", - "id": "nmeasim.GP", - "msg": "RMB", - "path": "navigation.courseRhumbline.nextPoint.position" -} -``` - -_Example: Set by NMEA2000 source_ -```json -{ - "type": "NMEA2000", - "id": "raymarineAP.3", - "msg": "129284", - "path": "navigation.courseGreatCircle.nextPoint.position" -} -``` - -_Example: Set via API request._ -```json -{ - "type": "API" -} -``` - -#### Clearing the current source - -When a destination is set, only updates from the source that set it are accepted. -To allow updates from another source you need to clear the current soource which is done by submitting a HTTP `DELETE` request to `/signalk/v2/api/vessels/self/navigation/course/commandSource`. - -_Note: This command does not change the destination!_ - -```typescript -HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/commandSource' -``` - - #### 1. Operation: Navigate to a location _(lat, lon)_ _Example response:_ @@ -288,18 +241,74 @@ _Example response:_ To cancel the current course navigation and clear the course data. ```typescript -HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/' +HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course' +``` + +_Note: This operation will NOT change the destination information coming from the NMEA input stream! If the NMEA source device is still emitting destination data this will reappear as the current destination._ + +To ignore destination data from NMEA sources see [Configuration](#configuration) below. + + + +## Configuration + +The default configuration of the Course API will accept destination information from both API requests and NMEA stream data. + +HTTP requests are prioritised over NMEA stream data, so making an API request will overwrite destination information received from and NMEA source. + +But, when the destination cleared using an API request, if the NMEA stream is emitting an active destination position, this will then be used by the Course API to populate course data. + +#### Ignoring NMEA Destination Information + +The Course API can be configured to ignore destination data in the NMEA stream by enabling `apiOnly` mode. + +In `apiOnly` mode destination information can only be set / cleared using HTTP API requests. + +- **`apiOnly` Mode = Off _(default)_** + + - Destination data is accepted from both _HTTP API_ and _NMEA_ sources + - Setting a destination using the HTTP API will override the destination data received via NMEA + - When clearing the destination using the HTTP API, if destination data is received via NMEA this will then be used as the active destination. + - To clear destination sourced via NMEA, clear the destination on the source device. + +- **`apiOnly` Mode = On** + + - Course operations are only accepted via the HTTP API + - NMEA stream data is ignored + - Switching to `apiOnly` mode when an NMEA sourced destination is active will clear the destination. + + +#### Retrieving Course API Configuration + +To retrieve the Course API configuration settings, submit a HTTP `GET` request to `/signalk/v2/api/vessels/self/navigation/course/_config`. + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config' +``` + +_Example response:_ +```JSON +{ + "apiOnly": false +} ``` -To clear the current destination and stop deltas from setting the destination, specify the `apiMode` parameter. +#### Enable / Disable `apiOnly` mode +To enable `apiOnly` mode, submit a HTTP `POST` request to `/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly`. + +_Enable apiOnly mode:_ ```typescript -HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course?apiMode=true' +HTTP POST 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly' ``` -_Note: To re-enable NMEA stream input, make the DELETE request without the `force` parameter._ +To disable `apiOnly` mode, submit a HTTP `DELETE` request to `/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly`. + +_Disable apiOnly mode:_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly' +``` ---- ## Course Calculations @@ -307,5 +316,3 @@ Whilst not performing course calculations, the _Course API_ defines the paths to Click [here](./course_calculations.md) for details. - - diff --git a/src/api/course/index.ts b/src/api/course/index.ts index d2ac2e43c..d5e45c5ac 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -35,6 +35,7 @@ import { Store } from '../../serverstate/store' import { buildSchemaSync } from 'api-schema-builder' import courseOpenApi from './openApi.json' import { ResourcesApi } from '../resources' +import { writeSettingsFile } from '../../config/config' const COURSE_API_SCHEMA = buildSchemaSync(courseOpenApi) @@ -59,6 +60,10 @@ interface CommandSource { path?: string } +interface CourseSettings { + apiOnly: boolean +} + export class CourseApi { private courseInfo: CourseInfo = { startTime: null, @@ -72,12 +77,14 @@ export class CourseApi { private store: Store private cmdSource: CommandSource | null = null // source which set the destination private unsubscribes: Unsubscribes = [] + private settings!: CourseSettings constructor( private app: CourseApplication, private resourcesApi: ResourcesApi ) { this.store = new Store(app, 'course') + this.parseSettings() } async start() { @@ -90,10 +97,19 @@ export class CourseApi { storeData = await this.store.read() debug('Found persisted course data') this.courseInfo = this.validateCourseInfo(storeData) + this.cmdSource = + this.settings.apiOnly && this.courseInfo.nextPoint + ? { type: 'API' } + : null } catch (error) { debug('No persisted course data (using default)') } - debug(this.courseInfo) + debug( + '** courseInfo **', + this.courseInfo, + '** cmdSource **', + this.cmdSource + ) if (storeData) { this.emitCourseInfo(true) } @@ -114,7 +130,7 @@ export class CourseApi { }, this.unsubscribes, (err: Error) => { - console.error(`Course API: Subscribe failed:${err}`) + console.log(`Course API: Subscribe failed: ${err}`) }, (msg: ValuesDelta) => { this.processV1DestinationDeltas(msg) @@ -124,6 +140,34 @@ export class CourseApi { }) } + // parse server settings + private parseSettings() { + const defaultSettings: CourseSettings = { + apiOnly: false + } + if (!('courseApi' in this.app.config.settings)) { + debug('***** Applying Default Settings ********') + ;(this.app.config.settings as any)['courseApi'] = defaultSettings + } + if ( + typeof (this.app.config.settings as any)['courseApi'].apiOnly === + 'undefined' + ) { + debug('***** Applying missing apiOnly attribute to Settings ********') + ;(this.app.config.settings as any)['courseApi'].apiOnly = false + } + this.settings = (this.app.config.settings as any)['courseApi'] + debug('** Parsed App Settings ***', this.app.config.settings) + debug('** Applied cmdSource ***', this.cmdSource) + } + + // write to server settings file + private saveSettings() { + writeSettingsFile(this.app as any, this.app.config.settings, () => + debug('***SETTINGS SAVED***') + ) + } + /** Process deltas for .nextPoint data * Note: Delta source cannot override destination set by API! * Destination is set when: @@ -132,36 +176,38 @@ export class CourseApi { * 3. Destination Position is changed. */ private async processV1DestinationDeltas(delta: ValuesDelta) { - if (!Array.isArray(delta.updates)) { + if ( + !Array.isArray(delta.updates) || + this.cmdSource?.type === 'API' || + (!this.cmdSource && this.settings.apiOnly) + ) { return } - if (!(this.cmdSource?.type === 'API')) { - delta.updates.forEach((update: Update) => { - if (!Array.isArray(update.values)) { - return + delta.updates.forEach((update: Update) => { + if (!Array.isArray(update.values)) { + return + } + update.values.forEach((pathValue: PathValue) => { + if ( + update.source && + update.source.type && + ['NMEA0183', 'NMEA2000'].includes(update.source.type) + ) { + this.parseStreamValue( + { + type: update.source.type, + id: getSourceId(update.source), + msg: + update.source.type === 'NMEA0183' + ? `${update.source.sentence}` + : `${update.source.pgn}`, + path: pathValue.path + }, + pathValue.value as Position + ) } - update.values.forEach((pathValue: PathValue) => { - if ( - update.source && - update.source.type && - ['NMEA0183', 'NMEA2000'].includes(update.source.type) - ) { - this.parseStreamValue( - { - type: update.source.type, - id: getSourceId(update.source), - msg: - update.source.type === 'NMEA0183' - ? `${update.source.sentence}` - : `${update.source.pgn}`, - path: pathValue.path - }, - pathValue.value as Position - ) - } - }) }) - } + }) } /** Process stream value and take action @@ -219,21 +265,14 @@ export class CourseApi { return this.courseInfo } - /** Clear destination / route (exposed to plugins) - * @params force When true will set source.type to API. This will froce API only mode (NMEA sources are ignored). - * Call with force=false to clear API only mode. - */ - async clearDestination(apiMode?: boolean): Promise { + /** Clear destination / route (exposed to plugins) */ + async clearDestination(): Promise { this.courseInfo.startTime = null this.courseInfo.targetArrivalTime = null this.courseInfo.activeRoute = null this.courseInfo.nextPoint = null this.courseInfo.previousPoint = null - if (apiMode) { - this.cmdSource = { type: 'API' } - } else { - this.cmdSource = null - } + this.cmdSource = null this.emitCourseInfo() } @@ -306,22 +345,53 @@ export class CourseApi { res.json(this.courseInfo) }) - // Source that set the destination + // Return course api config this.app.get( - `${COURSE_API_PATH}/commandSource`, + `${COURSE_API_PATH}/_config`, + async (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path}`) + res.json((this.app.config.settings as any)['courseApi']) + } + ) + + // Set apiOnly mode + this.app.post( + `${COURSE_API_PATH}/_config/apiOnly`, async (req: Request, res: Response) => { debug(`** ${req.method} ${req.path}`) - res.json(this.cmdSource) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + try { + this.settings.apiOnly = true + if (this.cmdSource?.type !== 'API') { + this.clearDestination() + } + this.saveSettings() + res.status(200).json(Responses.ok) + } catch { + res.status(400).json(Responses.invalid) + } } ) - // Clear the commandSource. + // Clear apiOnly mode this.app.delete( - `${COURSE_API_PATH}/commandSource`, + `${COURSE_API_PATH}/_config/apiOnly`, async (req: Request, res: Response) => { debug(`** ${req.method} ${req.path}`) - this.cmdSource = null - res.status(200).json(Responses.ok) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + try { + this.settings.apiOnly = false + this.saveSettings() + res.status(200).json(Responses.ok) + } catch { + res.status(400).json(Responses.invalid) + } } ) @@ -423,19 +493,12 @@ export class CourseApi { this.app.delete( `${COURSE_API_PATH}`, async (req: Request, res: Response) => { - debug(`** ${req.method} ${req.path}`, req.query) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return } - if ( - req.query.apiMode && - (req.query.apiMode === '1' || req.query.apiMode === 'true') - ) { - this.clearDestination(true) - } else { - this.clearDestination() - } + this.clearDestination() res.status(200).json(Responses.ok) } ) @@ -485,7 +548,7 @@ export class CourseApi { } try { const result = await this.activateRoute(req.body) - console.log(this.courseInfo) + debug(this.courseInfo) if (result) { this.emitCourseInfo() res.status(200).json(Responses.ok) @@ -606,7 +669,7 @@ export class CourseApi { return false } } catch (err) { - console.log(`** Error: unable to retrieve vessel position!`) + console.log(`** Course API: Unable to retrieve vessel position!`) res.status(400).json(Responses.invalid) return false } @@ -702,7 +765,7 @@ export class CourseApi { } this.courseInfo = newCourse - this.cmdSource = src ? src : { type: 'API' } + this.cmdSource = src ?? { type: 'API' } return true } @@ -781,7 +844,7 @@ export class CourseApi { } this.courseInfo = newCourse - this.cmdSource = src ? src : { type: 'API' } + this.cmdSource = src ?? { type: 'API' } return true } @@ -1039,7 +1102,8 @@ export class CourseApi { this.app.handleMessage('courseApi', this.buildDeltaMsg(paths), SKVersion.v2) if (!noSave) { this.store.write(this.courseInfo).catch((error) => { - console.log(error) + console.log('Course API: Unable to persist destination details!') + debug(error) }) } } diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 6383ef822..839add7f8 100644 --- a/src/api/course/openApi.json +++ b/src/api/course/openApi.json @@ -34,6 +34,10 @@ { "name": "calculations", "description": "Calculated course data" + }, + { + "name": "configuration", + "description": "Course API settings." } ], "components": { @@ -378,19 +382,6 @@ "tags": ["course"], "summary": "Cancel / clear course.", "description": "Clear all course information.", - "parameters": [ - { - "name": "apiMode", - "in": "query", - "description": "Destination information from delta updates is ignored and only requests via API endpoints / interfaces are accepted. To clear this mode, call this endpoint without specifying `apiMode` or DELETE request to `commandSource`.", - "example": "/course?apiMode=true", - "required": false, - "explode": false, - "schema": { - "type": "boolean" - } - } - ], "responses": { "200": { "$ref": "#/components/responses/200Ok" @@ -401,48 +392,6 @@ } } }, - "/course/commandSource": { - "get": { - "tags": ["course"], - "summary": "Retrieve the source which set the current destination.", - "description": "Returns details of the source which set the destination.", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": ["NMEA0183", "NMEA2000", "API"], - "description": "Source input type." - }, - "id": { - "type": "string", - "description": "Source identifier." - }, - "msg": { - "type": "string", - "description": "PGN / Sentence that was the source of destination information." - }, - "path": { - "type": "string", - "description": "Signal K path containing the source path with the destination position." - } - } - } - } - } - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, "/course/arrivalCircle": { "put": { "tags": ["course"], @@ -741,6 +690,62 @@ } } } + }, + "/course/_config": { + "get": { + "tags": ["configuration"], + "summary": "Retrieve Course API configuration.", + "description": "Returns the current Course API configuration settings.", + "responses": { + "200": { + "description": "Course data.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "apiOnly": { + "type": "boolean" + } + }, + "required": ["apiOnly"] + } + } + } + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/course/_config/apiOnly": { + "post": { + "tags": ["configuration"], + "summary": "Set API Only mode.", + "description": "Accept REST API requests only. Ignores NMEA sources.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": ["configuration"], + "summary": "Clear API Only mode.", + "description": "Accept both REST API requests and NMEA source data.", + "responses": { + "200": { + "$ref": "#/components/responses/200Ok" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } } } } diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index d77179c34..71ab1f9bf 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -564,8 +564,8 @@ module.exports = (theApp: any) => { appCopy.getCourse = () => { return courseApi.getCourse() } - appCopy.clearDestination = (apiMode?: boolean) => { - return courseApi.clearDestination(apiMode) + appCopy.clearDestination = () => { + return courseApi.clearDestination() } appCopy.setDestination = ( dest: (PointDestination & { arrivalCircle?: number }) | null