From 41593c56f65708487c978f13f856a47f7934fcc1 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 10 Nov 2024 19:58:56 +0200 Subject: [PATCH 01/10] feature: redirect to webapp by disiplayName (#1822) Signal K webapps have displayName property. This PR adds automatic redirect to that webapp when the server's root is accessed with a hostname whose first element is the webapp's displayName. So if you have a webapp whose displayName is foo and you access the server using url like http://foo.bar.org:3000/ you will be redirected to that webapp instead of the default landingPage that is the Admin webapp. --- docs/src/develop/webapps.md | 2 +- src/serverroutes.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/src/develop/webapps.md b/docs/src/develop/webapps.md index e99d403ad..95155a901 100644 --- a/docs/src/develop/webapps.md +++ b/docs/src/develop/webapps.md @@ -42,7 +42,7 @@ You can also include the following section in `package.json` to control how your where: - `appIcon` is the path (relative to the `public` directory) to an image within the package to display in the webapp list. The image should be at least 72x72 pixels in size. -- `displayName` is the text you want to appear as the name in the webapp list. _(By default the _name_ attribute in the `package.json` is used.)_ +- `displayName` is the text you want to appear as the name in the webapp list. _(By default the _name_ attribute in the `package.json` is used.)_. Displayname is also used in an automatic redirect from the root of the server: if you have a webapp with displayName `foo` and you access it using for example the url http://foo.bar.org:3000 the first part of the hostname matches the webapp's displayName and you will be redirected to it instead of the default landingPage, the Admin webapp. With this mechanism you can add easy to access DNS names to each webapp, including .local names. See also [Working Offline](./developer_notes.md#offline-use). diff --git a/src/serverroutes.ts b/src/serverroutes.ts index ccc29592b..8a8e256e3 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -157,7 +157,24 @@ module.exports = function ( ) app.get('/', (req: Request, res: Response) => { - res.redirect(app.config.settings.landingPage || '/admin/') + let landingPage = '/admin/' + + // if accessed with hostname that starts with a webapp's displayName redirect there + //strip possible port number + const firstHostName = (req.headers?.host || '') + .split(':')[0] + .split('.')[0] + .toLowerCase() + const targetWebapp = app.webapps.find( + (webapp) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (webapp as any).signalk?.displayName.toLowerCase() === firstHostName + ) + if (targetWebapp) { + landingPage = `/${targetWebapp.name}/` + } + + res.redirect(app.config.settings.landingPage || landingPage) }) app.get('/@signalk/server-admin-ui', (req: Request, res: Response) => { From 88c71cbbbc9d904e18c6ef404f67836eeee38913 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 10 Nov 2024 19:59:19 +0200 Subject: [PATCH 02/10] doc: data logging (#1827) --- docs/src/SUMMARY.md | 1 + docs/src/features/datalogging/datalogging.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 docs/src/features/datalogging/datalogging.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 045bdfb45..adaf5b516 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -15,6 +15,7 @@ # Feature How Tos * [Anchor Alarm](./features/anchoralarm/anchoralarm.md) * [NMEA0183 Server](./features/navdataserver/navdataserver.md) + * [Data Logging](./features/datalogging/datalogging.md) # Support * [Help & Support](./support/help.md) * [FAQs](./support/faq.md) diff --git a/docs/src/features/datalogging/datalogging.md b/docs/src/features/datalogging/datalogging.md new file mode 100644 index 000000000..f408e8c67 --- /dev/null +++ b/docs/src/features/datalogging/datalogging.md @@ -0,0 +1,13 @@ +# Data Logging + +Signal K server can log all input data from the configure input connections to a hourly data log files. + +You can activate data logging for each connection by switching on Data Logging under Server / Data Connections, saving the connection settings. The setting takes effect after restarting the server. + +The log files are downloadable in the Admin UI under Server / Server Logs. + +The logs contain the data that the server has processed in the raw, original format (prior to conversion to Signal K) and each message is timestamped. + +Log files can be used for archiving, to later play back the data or for debugging purposes. The server can play them back by creating a Data Connection with Data Type `File Stream` and secondary Data Type as `Multiplexed Log`. + + From 2260cf64b45928735c593ad250917969410109b8 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Tue, 12 Nov 2024 22:09:59 +0200 Subject: [PATCH 03/10] fix: bin/log2sk (#1828) Fix the conversion utility by restructuring it to match the current pipedProviders - app interface. --- bin/log2sk | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/log2sk b/bin/log2sk index 37d5c36b3..398d0a890 100755 --- a/bin/log2sk +++ b/bin/log2sk @@ -14,10 +14,13 @@ const app = { handleMessage: (id, delta) => console.log(JSON.stringify(delta)), propertyValues: { onPropertyValues: () => undefined + }, + wrappedEmitter: { + bindMethodsById: () => {} } } -new require('../lib/pipedproviders')(app).createPipedProvider({ +new require('../lib/pipedproviders').pipedProviders(app).createPipedProvider({ pipeElements: [ { type: 'providers/simple', From 88fcfd99878ad7a962adc224f9867e4a866efe51 Mon Sep 17 00:00:00 2001 From: AdrianP <38519157+panaaj@users.noreply.github.com> Date: Sun, 17 Nov 2024 09:31:07 +1030 Subject: [PATCH 04/10] feature: Add NMEA stream handling to Course API. (#1750) * feature: add type discrimators for values & meta Add type discriminators for narrowing Update down to having values or meta. * feature : add NMEA to course API integration - add the ability to control command source to course API - handle NMEA0183 and NMEA2000 messages that contain nextPoint data to update course API data structures --------- Co-authored-by: Teppo Kurki --- docs/src/develop/rest-api/course_api.md | 82 +++- .../src/views/ServerConfig/Settings.js | 43 ++ packages/server-api/src/deltas.ts | 8 + src/api/course/index.ts | 393 ++++++++++++++---- src/api/course/openApi.json | 60 +++ src/config/config.ts | 3 + src/serverroutes.ts | 11 +- test/ts-servertestutilities.ts | 22 +- 8 files changed, 533 insertions(+), 89 deletions(-) diff --git a/docs/src/develop/rest-api/course_api.md b/docs/src/develop/rest-api/course_api.md index 3d752164c..c99ef2069 100644 --- a/docs/src/develop/rest-api/course_api.md +++ b/docs/src/develop/rest-api/course_api.md @@ -5,9 +5,11 @@ The _Course API_ provides common course operations under the path `/signalk/v2/api/vessels/self/navigation/course` ensuring that all related Signal K data model values are maintained and consistent. This provides a set of data that can be confidently used for _course calculations_ and _autopilot operation_. +Additionally, the Course API persists course information on the server to ensure data is not lost in the event of a server restart. + Client applications use `HTTP` requests (`PUT`, `GET`,`DELETE`) to perform operations and retrieve course data. -Additionally, the Course API persists course information on the server to ensure data is not lost in the event of a server restart. +The Course API also listens for destination information in the NMEA stream and will set / clear the destination accordingly _(e.g. NMEA0183 RMB sentence, NMEA2000 PGN 129284)_. See [Configuration](#Configuration) for more details. _Note: You can view the _Course API_ OpenAPI definition in the Admin UI (Documentation => OpenApi)._ @@ -125,6 +127,7 @@ 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. + #### 1. Operation: Navigate to a location _(lat, lon)_ _Example response:_ @@ -235,13 +238,82 @@ _Example response:_ ## Cancelling navigation -To cancel the current course navigation and clear the course data +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 sources. + +For NMEA sources, Course API monitors the the following Signal K paths populated by both the `nmea0183-to-signalk` and `n2k-to-signalk` plugins: +- _navigation.courseRhumbline.nextPoint.position_ +- _navigation.courseGreatCircle.nextPoint.position_ + +HTTP requests are prioritised over NMEA data sources, so making an API request will overwrite the destination information received from and NMEA source. + +Note: 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 +} +``` + +#### 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 POST 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly' +``` + +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 @@ -249,5 +321,3 @@ Whilst not performing course calculations, the _Course API_ defines the paths to Click [here](./course_calculations.md) for details. - - diff --git a/packages/server-admin-ui/src/views/ServerConfig/Settings.js b/packages/server-admin-ui/src/views/ServerConfig/Settings.js index 4c41aa22e..d8bb02de1 100644 --- a/packages/server-admin-ui/src/views/ServerConfig/Settings.js +++ b/packages/server-admin-ui/src/views/ServerConfig/Settings.js @@ -36,6 +36,7 @@ class ServerSettings extends Component { } this.fetchSettings = fetchSettings.bind(this) this.handleChange = this.handleChange.bind(this) + this.handleCourseApiChange = this.handleCourseApiChange.bind(this) this.handleOptionChange = this.handleOptionChange.bind(this) this.handleInterfaceChange = this.handleInterfaceChange.bind(this) this.handleSaveSettings = this.handleSaveSettings.bind(this) @@ -53,6 +54,15 @@ class ServerSettings extends Component { this.setState({ [event.target.name]: value }) } + handleCourseApiChange(event) { + const value = + event.target.type === 'checkbox' + ? event.target.checked + : event.target.value + this.state.courseApi[event.target.name] = value + this.setState({ courseApi: this.state.courseApi }) + } + handleOptionChange(event) { const value = event.target.type === 'checkbox' @@ -303,6 +313,39 @@ class ServerSettings extends Component { + + + + + + + + + Accept course operations only via HTTP requests. + Destination data from NMEA sources is not used. + + + + diff --git a/packages/server-api/src/deltas.ts b/packages/server-api/src/deltas.ts index e8e5320ff..456da98ab 100644 --- a/packages/server-api/src/deltas.ts +++ b/packages/server-api/src/deltas.ts @@ -52,6 +52,14 @@ export type Update = { $source?: SourceRef } & ({ values: PathValue[] } | { meta: Meta[] }) // require either values or meta or both +export function hasValues(u: Update): u is Update & { values: PathValue[] } { + return 'values' in u && Array.isArray(u.values) +} + +export function hasMeta(u: Update): u is Update & { meta: Meta[] } { + return 'meta' in u && Array.isArray(u.meta) +} + // Update delta export interface PathValue { path: Path diff --git a/src/api/course/index.ts b/src/api/course/index.ts index b97adb49c..037f2c35d 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -7,9 +7,10 @@ import _ from 'lodash' import { SignalKMessageHub, WithConfig } from '../../app' import { WithSecurityStrategy } from '../../security' +import { getSourceId } from '@signalk/signalk-schema' +import { Unsubscribes } from '../../types' import { - Delta, GeoJsonPoint, PathValue, Position, @@ -20,7 +21,11 @@ import { ActiveRoute, RouteDestination, CourseInfo, - COURSE_POINT_TYPES + COURSE_POINT_TYPES, + Update, + Delta, + hasValues, + SourceRef } from '@signalk/server-api' const { Location, RoutePoint, VesselPosition } = COURSE_POINT_TYPES @@ -31,12 +36,18 @@ 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) const SIGNALK_API_PATH = `/signalk/v2/api` const COURSE_API_PATH = `${SIGNALK_API_PATH}/vessels/self/navigation/course` +const API_CMD_SRC: CommandSource = { + $source: 'courseApi' as SourceRef, + type: 'API' +} + export const COURSE_API_V2_DELTA_COUNT = 13 export const COURSE_API_V1_DELTA_COUNT = 8 export const COURSE_API_INITIAL_DELTA_COUNT = @@ -48,6 +59,13 @@ interface CourseApplication WithSecurityStrategy, SignalKMessageHub {} +interface CommandSource { + type: string + $source: SourceRef + msg?: string + path?: string +} + export class CourseApi { private courseInfo: CourseInfo = { startTime: null, @@ -59,12 +77,16 @@ export class CourseApi { } private store: Store + private cmdSource: CommandSource | null = null // source which set the destination + private unsubscribes: Unsubscribes = [] + private settings!: { apiOnly?: boolean } constructor( - private server: CourseApplication, + private app: CourseApplication, private resourcesApi: ResourcesApi ) { - this.store = new Store(server, 'course') + this.store = new Store(app, 'course') + this.parseSettings() } async start() { @@ -77,31 +99,172 @@ export class CourseApi { storeData = await this.store.read() debug('Found persisted course data') this.courseInfo = this.validateCourseInfo(storeData) + this.cmdSource = this.courseInfo.nextPoint ? API_CMD_SRC : null } catch (error) { debug('No persisted course data (using default)') } - debug(this.courseInfo) - if (storeData) { + debug( + '** courseInfo **', + this.courseInfo, + '** cmdSource **', + this.cmdSource + ) + if (this.courseInfo.nextPoint) { this.emitCourseInfo(true) } + + ;(this.app as any).subscriptionmanager.subscribe( + { + context: 'vessels.self', + subscribe: [ + { + path: 'navigation.courseRhumbline.nextPoint.position', + period: 500 + }, + { + path: 'navigation.courseGreatCircle.nextPoint.position', + period: 500 + } + ] + }, + this.unsubscribes, + (err: Error) => { + console.log(`Course API: Subscribe failed: ${err}`) + }, + (msg: Delta) => { + this.processV1DestinationDeltas(msg) + } + ) resolve() }) } + // parse server settings + private parseSettings() { + const defaultSettings = { + apiOnly: false + } + if (!('courseApi' in this.app.config.settings)) { + debug('***** Applying Default Settings ********') + this.app.config.settings.courseApi = defaultSettings + } + if ( + this.app.config.settings.courseApi && + typeof this.app.config.settings.courseApi.apiOnly === 'undefined' + ) { + debug('***** Applying missing apiOnly attribute to Settings ********') + this.app.config.settings.courseApi.apiOnly = false + } + this.settings = this.app.config.settings.courseApi ?? { apiOnly: false } + 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: + * 1. There is no current destination + * 2. msg source matches current Destination source + * 3. Destination Position is changed. + */ + private async processV1DestinationDeltas(delta: Delta) { + if ( + !Array.isArray(delta.updates) || + this.isAPICmdSource() || + (!this.cmdSource && this.settings?.apiOnly) + ) { + return + } + delta.updates.forEach((update: Update) => { + if (hasValues(update)) { + update.values.forEach((pathValue: PathValue) => { + if ( + update.source && + update.source.type && + ['NMEA0183', 'NMEA2000'].includes(update.source.type) + ) { + this.parseStreamValue( + { + type: update.source.type, + $source: update.$source || 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 + * @param cmdSource Object describing the source of the update + * @param pos Destination location value in the update + */ + private async parseStreamValue(cmdSource: CommandSource, pos: Position) { + if (!this.cmdSource) { + // New source + if (!pos) { + return + } + debug('parseStreamValue:', 'Setting Destination...') + const result = await this.setDestination({ position: pos }, cmdSource) + debug('parseStreamValue: Source set...', this.cmdSource) + if (result) { + this.emitCourseInfo() + return + } + } + + if (this.isCurrentCmdSource(cmdSource)) { + if (!pos) { + debug('parseStreamValue:', 'No position... Clear Destination...') + this.clearDestination() + return + } + + if ( + this.courseInfo.nextPoint?.position?.latitude !== pos.latitude || + this.courseInfo.nextPoint?.position?.longitude !== pos.longitude + ) { + debug( + 'parseStreamValue:', + 'Position changed... Updating Destination...' + ) + const result = await this.setDestination({ position: pos }, cmdSource) + if (result) { + this.emitCourseInfo() + } + } + } + } + /** Get course (exposed to plugins) */ async getCourse(): Promise { debug(`** getCourse()`) return this.courseInfo } - /** Clear destination / route (exposed to plugins) */ - async clearDestination(): Promise { + /** Clear destination / route (exposed to plugins) */ + async clearDestination(persistState?: boolean): Promise { this.courseInfo.startTime = null this.courseInfo.targetArrivalTime = null this.courseInfo.activeRoute = null this.courseInfo.nextPoint = null this.courseInfo.previousPoint = null - this.emitCourseInfo() + this.emitCourseInfo(!persistState) + this.cmdSource = null } /** Set course (exposed to plugins) @@ -139,11 +302,15 @@ export class CourseApi { } private getVesselPosition() { - return _.get((this.server.signalk as any).self, 'navigation.position') + return _.get((this.app.signalk as any).self, 'navigation.position') } private validateCourseInfo(info: CourseInfo) { - if (info.activeRoute && info.nextPoint && info.previousPoint) { + if ( + typeof info.activeRoute !== 'undefined' && + typeof info.nextPoint !== 'undefined' && + typeof info.previousPoint !== 'undefined' + ) { return info } else { debug(`** Error: Loaded course data is invalid!! (using default) **`) @@ -152,7 +319,7 @@ export class CourseApi { } private updateAllowed(request: Request): boolean { - return this.server.securityStrategy.shouldAllowPut( + return this.app.securityStrategy.shouldAllowPut( request, 'vessels.self', null, @@ -163,20 +330,67 @@ export class CourseApi { private initCourseRoutes() { debug(`** Initialise ${COURSE_API_PATH} path handlers **`) - // return current course information - this.server.get( - `${COURSE_API_PATH}`, + // Return current course information + this.app.get(`${COURSE_API_PATH}`, async (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path}`) + res.json(this.courseInfo) + }) + + // Return course api config + this.app.get( + `${COURSE_API_PATH}/_config`, async (req: Request, res: Response) => { - debug(`** GET ${COURSE_API_PATH}`) - res.json(this.courseInfo) + 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}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + try { + ;(this.settings as any).apiOnly = true + if (!this.isAPICmdSource()) { + this.clearDestination(true) + } + this.saveSettings() + res.status(200).json(Responses.ok) + } catch { + res.status(400).json(Responses.invalid) + } + } + ) + + // Clear apiOnly mode + this.app.delete( + `${COURSE_API_PATH}/_config/apiOnly`, + async (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + try { + ;(this.settings as any).apiOnly = false + this.saveSettings() + res.status(200).json(Responses.ok) + } catch { + res.status(400).json(Responses.invalid) + } } ) // course metadata - this.server.get( + this.app.get( `${COURSE_API_PATH}/arrivalCircle/meta`, async (req: Request, res: Response) => { - debug(`** GET ${COURSE_API_PATH}/arrivalCircle/meta`) + debug(`** ${req.method} ${req.path}`) res.json({ arrivalCircle: { description: @@ -187,10 +401,10 @@ export class CourseApi { } ) - this.server.put( + this.app.put( `${COURSE_API_PATH}/arrivalCircle`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/arrivalCircle`) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return @@ -205,10 +419,10 @@ export class CourseApi { } ) - this.server.put( + this.app.put( `${COURSE_API_PATH}/restart`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/restart`) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return @@ -248,10 +462,10 @@ export class CourseApi { } ) - this.server.put( + this.app.put( `${COURSE_API_PATH}/targetArrivalTime`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/targetArrivalTime`) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return @@ -267,24 +481,24 @@ export class CourseApi { ) // clear / cancel course - this.server.delete( + this.app.delete( `${COURSE_API_PATH}`, async (req: Request, res: Response) => { - debug(`** DELETE ${COURSE_API_PATH}`) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return } - this.clearDestination() + this.clearDestination(true) res.status(200).json(Responses.ok) } ) // set destination - this.server.put( + this.app.put( `${COURSE_API_PATH}/destination`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/destination`) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return @@ -315,10 +529,10 @@ export class CourseApi { ) // set activeRoute - this.server.put( + this.app.put( `${COURSE_API_PATH}/activeRoute`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/activeRoute`) + debug(`** ${req.method} ${req.path}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return @@ -342,10 +556,10 @@ export class CourseApi { } ) - this.server.put( + this.app.put( `${COURSE_API_PATH}/activeRoute/:action`, async (req: Request, res: Response) => { - debug(`** PUT ${COURSE_API_PATH}/activeRoute/${req.params.action}`) + debug(`** ${req.method} ${req.path}, ${req.params.action}`) if (!this.updateAllowed(req)) { res.status(403).json(Responses.unauthorised) return @@ -446,7 +660,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 } @@ -474,7 +688,10 @@ export class CourseApi { ) } - private async activateRoute(route: RouteDestination): Promise { + private async activateRoute( + route: RouteDestination, + src: CommandSource = API_CMD_SRC + ): Promise { const { href, reverse } = route let rte: any @@ -538,12 +755,17 @@ export class CourseApi { } } + if (this.isSourceChange(src)) { + this.clearDestination(true) + } this.courseInfo = newCourse + this.cmdSource = src return true } private async setDestination( - dest: PointDestination & { arrivalCircle?: number } + dest: PointDestination & { arrivalCircle?: number }, + src: CommandSource = API_CMD_SRC ): Promise { const newCourse: CourseInfo = { ...this.courseInfo } @@ -615,7 +837,11 @@ export class CourseApi { throw new Error(`Error: Unable to retrieve vessel position!`) } + if (this.isSourceChange(src)) { + this.clearDestination(true) + } this.courseInfo = newCourse + this.cmdSource = src return true } @@ -719,8 +945,6 @@ export class CourseApi { const values: Array<{ path: string; value: any }> = [] const navPath = 'navigation.course' - debug(this.courseInfo) - if ( paths.length === 0 || (paths && (paths.includes('activeRoute') || paths.includes('nextPoint'))) @@ -745,13 +969,6 @@ export class CourseApi { }) } - if (paths.length === 0 || (paths && paths.includes('nextPoint'))) { - values.push({ - path: `${navPath}.nextPoint`, - value: this.courseInfo.nextPoint - }) - } - if (paths.length === 0 || (paths && paths.includes('arrivalCircle'))) { values.push({ path: `${navPath}.arrivalCircle`, @@ -780,17 +997,14 @@ export class CourseApi { const navGC = 'navigation.courseGreatCircle' const navRL = 'navigation.courseRhumbline' - if ( - this.courseInfo.activeRoute && - (paths.length === 0 || (paths && paths.includes('activeRoute'))) - ) { + if (paths.length === 0 || (paths && paths.includes('activeRoute'))) { values.push({ path: `${navGC}.activeRoute.href`, - value: this.courseInfo.activeRoute.href + value: this.courseInfo.activeRoute?.href ?? null }) values.push({ path: `${navRL}.activeRoute.href`, - value: this.courseInfo.activeRoute.href + value: this.courseInfo.activeRoute?.href ?? null }) values.push({ @@ -802,35 +1016,32 @@ export class CourseApi { value: this.courseInfo.startTime }) } - if ( - this.courseInfo.nextPoint && - (paths.length === 0 || (paths && paths.includes('nextPoint'))) - ) { + if (paths.length === 0 || (paths && paths.includes('nextPoint'))) { values.push({ path: `${navGC}.nextPoint.value.href`, - value: this.courseInfo.nextPoint.href ?? null + value: this.courseInfo.nextPoint?.href ?? null }) values.push({ path: `${navRL}.nextPoint.value.href`, - value: this.courseInfo.nextPoint.href ?? null + value: this.courseInfo.nextPoint?.href ?? null }) values.push({ path: `${navGC}.nextPoint.value.type`, - value: this.courseInfo.nextPoint.type + value: this.courseInfo.nextPoint?.type ?? null }) values.push({ path: `${navRL}.nextPoint.value.type`, - value: this.courseInfo.nextPoint.type + value: this.courseInfo.nextPoint?.type ?? null }) values.push({ path: `${navGC}.nextPoint.position`, - value: this.courseInfo.nextPoint.position + value: this.courseInfo.nextPoint?.position ?? null }) values.push({ path: `${navRL}.nextPoint.position`, - value: this.courseInfo.nextPoint.position + value: this.courseInfo.nextPoint?.position ?? null }) } if (paths.length === 0 || (paths && paths.includes('arrivalCircle'))) { @@ -843,26 +1054,23 @@ export class CourseApi { value: this.courseInfo.arrivalCircle }) } - if ( - this.courseInfo.previousPoint && - (paths.length === 0 || (paths && paths.includes('previousPoint'))) - ) { + if (paths.length === 0 || (paths && paths.includes('previousPoint'))) { values.push({ path: `${navGC}.previousPoint.position`, - value: this.courseInfo.previousPoint.position + value: this.courseInfo.previousPoint?.position ?? null }) values.push({ path: `${navRL}.previousPoint.position`, - value: this.courseInfo.previousPoint.position + value: this.courseInfo.previousPoint?.position ?? null }) values.push({ path: `${navGC}.previousPoint.value.type`, - value: this.courseInfo.previousPoint.type + value: this.courseInfo.previousPoint?.type ?? null }) values.push({ path: `${navRL}.previousPoint.value.type`, - value: this.courseInfo.previousPoint.type + value: this.courseInfo.previousPoint?.type ?? null }) } @@ -875,21 +1083,50 @@ export class CourseApi { } } - private emitCourseInfo(noSave = false, ...paths: string[]) { - this.server.handleMessage( - 'courseApi', + private emitCourseInfo(noSave?: boolean, ...paths: string[]) { + this.app.handleMessage( + API_CMD_SRC.$source, this.buildV1DeltaMsg(paths), SKVersion.v1 ) - this.server.handleMessage( - 'courseApi', - this.buildDeltaMsg(paths), + + const v2Delta = this.buildDeltaMsg(paths) + v2Delta.updates[0].$source = API_CMD_SRC.$source + v2Delta.updates.push({ + $source: this.cmdSource ? this.cmdSource.$source : API_CMD_SRC.$source, + values: [ + { + path: `navigation.course.nextPoint`, + value: this.courseInfo.nextPoint + } + ] + }) + this.app.handleMessage( + 'N/A', //no-op as updates already have $source + v2Delta, SKVersion.v2 ) - if (!noSave) { + + const p = typeof noSave === 'undefined' ? this.isAPICmdSource() : !noSave + if (p) { + debug('*** persisting state **') this.store.write(this.courseInfo).catch((error) => { - console.log(error) + console.log('Course API: Unable to persist destination details!') + debug(error) }) } } + + private isAPICmdSource = () => this.cmdSource?.type === API_CMD_SRC.type + + private isSourceChange = (newSource: CommandSource): boolean => + this.cmdSource !== null && + (this.cmdSource.type !== newSource.type || + this.cmdSource.$source !== newSource.$source) + + private isCurrentCmdSource = (cmdSource: CommandSource) => + this.cmdSource?.type === cmdSource.type && + this.cmdSource?.$source === cmdSource.$source && + this.cmdSource?.path === cmdSource.path && + this.cmdSource?.msg === cmdSource.msg } diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json index 90b8c0e56..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": { @@ -686,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/config/config.ts b/src/config/config.ts index 0b9d587e4..e11ec0aaf 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -75,6 +75,9 @@ export interface Config { enablePluginLogging?: boolean loggingDirectory?: string sourcePriorities?: any + courseApi?: { + apiOnly?: boolean + } } defaults: object } diff --git a/src/serverroutes.ts b/src/serverroutes.ts index 8a8e256e3..6fd14764e 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -494,7 +494,10 @@ module.exports = function ( isUndefined(app.config.settings.keepMostRecentLogsOnly) || app.config.settings.keepMostRecentLogsOnly, logCountToKeep: app.config.settings.logCountToKeep || 24, - runFromSystemd: process.env.RUN_FROM_SYSTEMD === 'true' + runFromSystemd: process.env.RUN_FROM_SYSTEMD === 'true', + courseApi: { + apiOnly: app.config.settings.courseApi?.apiOnly || false + } } if (!settings.runFromSystemd) { @@ -638,6 +641,12 @@ module.exports = function ( app.config.settings.logCountToKeep = Number(settings.logCountToKeep) } + forIn(settings.courseApi, (enabled, name) => { + const courseApi: { [index: string]: boolean | string | number } = + app.config.settings.courseApi || (app.config.settings.courseApi = {}) + courseApi[name] = enabled + }) + writeSettingsFile(app, app.config.settings, (err: Error) => { if (err) { res.status(500).send('Unable to save to settings file') diff --git a/test/ts-servertestutilities.ts b/test/ts-servertestutilities.ts index 8246e4210..b8b373d2c 100644 --- a/test/ts-servertestutilities.ts +++ b/test/ts-servertestutilities.ts @@ -10,6 +10,7 @@ import { } from './servertestutilities' import { SERVERSTATEDIRNAME } from '../src/serverstate/store' import { expect } from 'chai' +import { Delta, hasValues, PathValue } from '@signalk/server-api' export const DATETIME_REGEX = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)Z?$/ @@ -100,13 +101,26 @@ export const startServer = async () => { } } -export const deltaHasPathValue = (delta: any, path: string, value: any) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const deltaHasPathValue = (delta: Delta, path: string, value: any) => { try { - const pathValue = delta.updates[0].values.find((x: any) => x.path === path) - expect(pathValue.value).to.deep.equal(value) + const pathValue = delta.updates.reduce( + (acc, update) => { + if (!acc && hasValues(update)) { + acc = update.values.find((x: PathValue) => x.path === path) + } + return acc + }, + undefined + ) + expect(pathValue?.value).to.deep.equal(value) } catch (e) { throw new Error( - `No such pathValue ${path}:${JSON.stringify(value)} in ${JSON.stringify(delta, null, 2)}` + `No such pathValue ${path}:${JSON.stringify(value)} in ${JSON.stringify( + delta, + null, + 2 + )}` ) } } From 2b5c2cb699244cdbb5e67d01f1e994f716735d2a Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 17 Nov 2024 09:21:50 +0200 Subject: [PATCH 05/10] chore: resources-provider@1.3, admin-ui@2.11, api 2.5 --- package.json | 6 +++--- packages/resources-provider-plugin/package.json | 4 ++-- packages/server-admin-ui/package.json | 2 +- packages/server-api/package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index a8d3d6650..a53a35dc9 100644 --- a/package.json +++ b/package.json @@ -74,9 +74,9 @@ "@signalk/course-provider": "^1.0.0", "@signalk/n2k-signalk": "^3.0.0", "@signalk/nmea0183-signalk": "^3.0.0", - "@signalk/resources-provider": "^1.0.0", - "@signalk/server-admin-ui": "2.10.x", - "@signalk/server-api": "2.4.x", + "@signalk/resources-provider": "^1.3.0", + "@signalk/server-admin-ui": "2.11.x", + "@signalk/server-api": "2.5.x", "@signalk/signalk-schema": "^1.7.1", "@signalk/streams": "^4.3.0", "api-schema-builder": "^2.0.11", diff --git a/packages/resources-provider-plugin/package.json b/packages/resources-provider-plugin/package.json index b8a9e2c4f..1913a52f1 100644 --- a/packages/resources-provider-plugin/package.json +++ b/packages/resources-provider-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/resources-provider", - "version": "1.2.1", + "version": "1.3.0", "description": "Resources provider plugin for Signal K server.", "main": "plugin/index.js", "keywords": [ @@ -33,7 +33,7 @@ "test": "npm run build && npm run build-declaration && npm run ci-lint" }, "dependencies": { - "@signalk/server-api": "^2.1.0", + "@signalk/server-api": "^2.5.0", "geojson-validation": "^0.2.0", "geolib": "^3.3.3", "ngeohash": "^0.6.3" diff --git a/packages/server-admin-ui/package.json b/packages/server-admin-ui/package.json index b025f99d6..fe042b647 100644 --- a/packages/server-admin-ui/package.json +++ b/packages/server-admin-ui/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/server-admin-ui", - "version": "2.10.0", + "version": "2.11.0", "description": "Signal K server admin webapp", "author": "Scott Bender, Teppo Kurki", "contributors": [ diff --git a/packages/server-api/package.json b/packages/server-api/package.json index 9ece5a9b3..583720fef 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/server-api", - "version": "2.4.0", + "version": "2.5.0", "description": "signalk-server Typescript API for plugins etc with relevant implementation classes", "main": "dist/index.js", "types": "dist/index.d.ts", From b857dfff7175b30b7599cd2876170943cd0113b4 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Sun, 17 Nov 2024 10:30:26 +0200 Subject: [PATCH 06/10] 2.12.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a53a35dc9..ed547ab53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "signalk-server", - "version": "2.11.0", + "version": "2.12.0", "description": "An implementation of a [Signal K](http://signalk.org) server for boats.", "main": "index.js", "scripts": { From 8fdca1a25a88634c9e1408aa9bd449fb8075a733 Mon Sep 17 00:00:00 2001 From: Scott Bender Date: Thu, 21 Nov 2024 10:17:38 -0500 Subject: [PATCH 07/10] fix: communication.callsignVhf not set properly in baseDeltas.json --- src/config/config.ts | 13 ++++++++++++- src/serverroutes.ts | 7 +++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index e11ec0aaf..562e13d6e 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -330,8 +330,19 @@ function getFullDefaults(app: ConfigApp) { function setBaseDeltas(app: ConfigApp) { const defaultsPath = getBaseDeltasPath(app) try { - app.config.baseDeltaEditor.load(defaultsPath) + const de = app.config.baseDeltaEditor + + de.load(defaultsPath) debug(`Found default deltas at ${defaultsPath.toString()}`) + + //fix old, incorrectly done callsignVhf + const communication = de.getSelfValue('communication') + if (communication) { + if (communication.callsignVhf) { + de.setSelfValue('communication.callsignVhf', communication.callsignVhf) + } + de.setSelfValue('communication', undefined) + } } catch (e) { if ((e as any)?.code === 'ENOENT') { debug(`No default deltas found at ${defaultsPath.toString()}`) diff --git a/src/serverroutes.ts b/src/serverroutes.ts index 6fd14764e..894d079a8 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -658,7 +658,6 @@ module.exports = function ( app.get(`${SERVERROUTESPREFIX}/vessel`, (req: Request, res: Response) => { const de = app.config.baseDeltaEditor - const communication = de.getSelfValue('communication') const draft = de.getSelfValue('design.draft') const length = de.getSelfValue('design.length') const type = de.getSelfValue('design.aisShipType') @@ -673,7 +672,7 @@ module.exports = function ( gpsFromBow: de.getSelfValue('sensors.gps.fromBow'), gpsFromCenter: de.getSelfValue('sensors.gps.fromCenter'), aisShipType: type && type.id, - callsignVhf: communication && communication.callsignVhf + callsignVhf: de.getSelfValue('communication.callsignVhf') } res.json(json) @@ -814,9 +813,9 @@ module.exports = function ( : undefined ) de.setSelfValue( - 'communication', + 'communication.callsignVhf', !isUndefined(vessel.callsignVhf) && vessel.callsignVhf.length - ? { callsignVhf: vessel.callsignVhf } + ? vessel.callsignVhf : undefined ) From 1f329d4c7684d544fd12f7b9bf4661174fc8c347 Mon Sep 17 00:00:00 2001 From: Scott Bender Date: Sat, 23 Nov 2024 15:33:44 -0500 Subject: [PATCH 08/10] feature: show list of apps being installed in the app store (#1790) * show list of apps being installed in the app store * fix: show count of apps still installing * feature: show Waiting separately * Show newly installed apps in Installed * show newly installed version --------- Co-authored-by: Teppo Kurki --- .../src/views/appstore/Apps/Apps.js | 26 ++++++++++++++++++- .../Grid/cell-renderers/ActionCellRenderer.js | 6 ++++- .../cell-renderers/VersionCellRenderer.js | 5 +++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/server-admin-ui/src/views/appstore/Apps/Apps.js b/packages/server-admin-ui/src/views/appstore/Apps/Apps.js index 06c8f5409..b2607acd0 100644 --- a/packages/server-admin-ui/src/views/appstore/Apps/Apps.js +++ b/packages/server-admin-ui/src/views/appstore/Apps/Apps.js @@ -123,6 +123,22 @@ const Apps = function (props) { )} + {props.appStore.installing.length > 0 && ( + <> + + {props.appStore.installing.length > 0 && '(Pending restart)'} + + )} @@ -176,11 +192,19 @@ const Apps = function (props) { ) } +const installingCount = (appStore) => { + return appStore.installing.filter((app) => { + return app.isWaiting || app.isInstalling + }).length +} + const selectedViewToFilter = (selectedView, appStore) => { if (selectedView === 'Installed') { - return (app) => app.installing || app.installedVersion + return (app) => app.installedVersion || app.installing } else if (selectedView === 'Updates') { return (app) => updateAvailable(app, appStore) + } else if (selectedView === 'Installing') { + return (app) => app.installing } return () => true } diff --git a/packages/server-admin-ui/src/views/appstore/Grid/cell-renderers/ActionCellRenderer.js b/packages/server-admin-ui/src/views/appstore/Grid/cell-renderers/ActionCellRenderer.js index 40d76272a..628adb1d8 100644 --- a/packages/server-admin-ui/src/views/appstore/Grid/cell-renderers/ActionCellRenderer.js +++ b/packages/server-admin-ui/src/views/appstore/Grid/cell-renderers/ActionCellRenderer.js @@ -36,7 +36,11 @@ function ActionCellRenderer(props) { // If the app has progressed we show the status if (app.isInstalling || app.isRemoving || app.isWaiting) { - status = app.isRemove ? 'Removing' : 'Installing' + status = app.isRemove + ? 'Removing' + : app.isWaiting + ? 'Waiting..' + : 'Installing' progress = (
- v{props.data.installedVersion || props.data.version} + v + {props.data.newVersion + ? props.data.installedVersion + : props.data.version} {/* From 7dda8bc33d2f3631655702687ed522133fb9df90 Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Wed, 27 Nov 2024 21:54:02 +0200 Subject: [PATCH 09/10] chore: switch form node-sass to sass (#1835) Node-sass is deprecated per https://github.com/sass/node-sass. --- packages/server-admin-ui/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server-admin-ui/package.json b/packages/server-admin-ui/package.json index fe042b647..02cf100a7 100644 --- a/packages/server-admin-ui/package.json +++ b/packages/server-admin-ui/package.json @@ -38,7 +38,6 @@ "lodash.set": "^4.3.2", "lodash.uniq": "^4.5.0", "moment": "^2.29.1", - "node-sass": "^8.0.0", "prettier": "^2.3.2", "react": "^16.13.1", "react-copy-to-clipboard": "^5.0.3", @@ -53,10 +52,11 @@ "reconnecting-websocket": "^4.4.0", "redux": "^3.7.2", "redux-thunk": "2.3.0", - "sass-loader": "^13.2.0", + "sass": "^1.81.0", + "sass-loader": "^16.0.3", "simple-line-icons": "^2.5.5", "style-loader": "^2.0.0", - "webpack": "^5.0.0", + "webpack": "^5.96.1", "webpack-bundle-analyzer": "^3.9.0", "webpack-cli": "^4.2.0" }, From d7aca7129842de1bcb5849867d031d2267061dd0 Mon Sep 17 00:00:00 2001 From: AdrianP <38519157+panaaj@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:34:24 +1030 Subject: [PATCH 10/10] update test for valid destination point (#1840) --- src/api/course/index.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/api/course/index.ts b/src/api/course/index.ts index 037f2c35d..cb3297907 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -208,6 +208,18 @@ export class CourseApi { }) } + /** Test for valid Signal K position */ + private isValidPosition(position: Position): boolean { + return ( + typeof position?.latitude === 'number' && + typeof position?.latitude === 'number' && + position?.latitude >= -90 && + position?.latitude <= 90 && + position?.longitude >= -180 && + position?.longitude <= 180 + ) + } + /** Process stream value and take action * @param cmdSource Object describing the source of the update * @param pos Destination location value in the update @@ -215,7 +227,7 @@ export class CourseApi { private async parseStreamValue(cmdSource: CommandSource, pos: Position) { if (!this.cmdSource) { // New source - if (!pos) { + if (!this.isValidPosition(pos)) { return } debug('parseStreamValue:', 'Setting Destination...') @@ -805,7 +817,7 @@ export class CourseApi { throw new Error(`Invalid href! (${dest.href})`) } } else if ('position' in dest) { - if (isValidCoordinate(dest.position)) { + if (this.isValidPosition(dest.position)) { newCourse.nextPoint = { position: dest.position, type: Location