diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 9d1d96cd9..fa7c316fb 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -5,6 +5,7 @@ on: branches: - master - latest + - "build-docker" tags: - '*' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d6c343cc..8490309cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [10.x, 16.x] + node-version: [16.x] steps: - uses: actions/checkout@v2 @@ -18,6 +18,23 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + + - name: streams + working-directory: ./packages/streams + run: | + npm run ci + + - name: server-api + working-directory: ./packages/server-api + run: | + npm run build + + - name: server-admin-ui + working-directory: ./packages/server-admin-ui + run: | + npm run ci + npm run build + - run: npm test env: CI: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0422ce694..96c493872 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ RUN mkdir -p /home/node/signalk WORKDIR /home/node/signalk COPY --chown=node:node . . + +WORKDIR /home/node/signalk/packages/server-api +RUN npm install && npm run build + +WORKDIR /home/node/signalk RUN npm install RUN npm run build RUN mkdir -p /home/node/.signalk diff --git a/RESOURCE_PROVIDER_PLUGINS.md b/RESOURCE_PROVIDER_PLUGINS.md new file mode 100644 index 000000000..fc8efa00b --- /dev/null +++ b/RESOURCE_PROVIDER_PLUGINS.md @@ -0,0 +1,340 @@ +# Resource Provider plugins + +_This document should be read in conjunction with [SERVERPLUGINS.md](./SERVERPLUGINS.md) as it contains additional information regarding the development of plugins that facilitate the storage and retrieval of resource data._ + +To see an example of a resource provider plugin see [resources-provider-plugin](https://github.com/SignalK/resources-provider-plugin/) + +--- + +## Overview + +The SignalK specification defines the path `/signalk/v1/api/resources` for accessing resources to aid in navigation and operation of the vessel. + +It also defines the schema for the following __Common__ resource types: +- routes +- waypoints +- notes +- regions +- charts + +each with its own path under the root `resources` path _(e.g. `/signalk/v1/api/resources/routes`)_. + +It should also be noted that the `/signalk/v1/api/resources` path can also host other types of resource data which can be grouped within a __Custom__ path name _(e.g. `/signalk/v1/api/resources/fishingZones`)_. + +The SignalK server does not natively provide the ability to store or retrieve resource data for either __Common__ and __Custom__ resource types. +This functionality needs to be provided by one or more server plugins that handle the data for specific resource types. + +These plugins are called __Resource Providers__. + +This de-coupling of resource request handling and storage / retrieval provides great flexibility to ensure that an appropriate resource storage solution can be configured for your SignalK implementation. + +SignalK server handles requests for both __Common__ and __Custom__ resource types in a similar manner, the only difference being that it does not perform any validation on __Custom__ resource data, so a plugin can act a s a provider for both types. + +--- +## Server Operation: + +The Signal K server handles all requests to `/signalk/v1/api/resources` and all sub-paths, before passing on the request to the registered resource provider plugin. + +The following operations are performed by the server when a request is received: +- Checks for a registered provider for the resource type +- Checks that ResourceProvider methods are defined +- For __Common__ resource types, checks the validity of the: + - Resource id + - Submitted resource data. + +Upon successful completion of these operations the request will then be passed to the registered resource provider plugin. + +--- +## Resource Provider plugin: + +For a plugin to be considered a Resource Provider it needs to implement the `ResourceProvider` interface. + +By implementing this interface the plugin is able to register with the SignalK server the: +- Resource types provided for by the plugin +- Methods to used to action requests. + +It is these methods that perform the retrival, saving and deletion of resources from storage. + + +### Resource Provider Interface + +--- +The `ResourceProvider` interface defines the contract between the the Resource Provider plugin and the SignalK server and has the following definition _(which it and other related types can be imported from `@signalk/server-api`)_: + +```typescript +interface ResourceProvider: { + types: string[], + methods: { + listResources: (type:string, query: {[key:string]:any})=> Promise + getResource: (type:string, id:string)=> Promise + setResource: (type:string, id:string, value:{[key:string]:any})=> Promise + deleteResource: (type:string, id:string)=> Promise + } +} +``` +where: + +- `types`: An array containing a list of resource types provided for by the plugin. These can be a mixture of both __Common__ and __Custom__ resource types. +- `methods`: An object containing the methods resource requests are passed to by the SignalK server. The plugin __MUST__ implement each method, even if that operation is not supported by the plugin! + +#### __Method Details:__ + +--- +__`listResources(type, query)`__: This method is called when a request is made for resource entries of a specific resource type that match a specifiec criteria. + +_Note: It is the responsibility of the resource provider plugin to filter the resources returned as per the supplied query parameters._ + +`type:` String containing the type of resource to retrieve. + +`query:` Object contining `key | value` pairs repesenting the parameters by which to filter the returned entries. _e.g. {distance,'50000}_ + +`returns:` +- Resolved Promise containing a list of resource entries on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + + +_Example resource request:_ +``` +GET /signalk/v1/api/resources/waypoints?bbox=5.4,25.7,6.9,31.2&distance=30000 +``` +_ResourceProvider method invocation:_ + +```javascript +listResources( + 'waypoints', + { + bbox: '5.4,25.7,6.9,31.2', + distance: 30000 + } +); +``` + +--- +__`getResource(type, id)`__: This method is called when a request is made for a specific resource entry of the supplied resource type and id. + +`type:` String containing the type of resource to retrieve. + +`id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +`returns:` +- Resolved Promise containing the resource entry on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + +_Example resource request:_ +``` +GET /signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +getResource( + 'routes', + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +--- +__`setResource(type, id, value)`__: This method is called when a request is made to save / update a resource entry of the specified resource type, with the supplied id and data. + +`type:` String containing the type of resource to store. + +`id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +`value:` Resource data to be stored. + +`returns:` +- Resolved Promise containing a list of resource entries on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + +_Example PUT resource request:_ +``` +PUT /signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + 'routes', + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99', + {} +); +``` + +_Example POST resource request:_ +``` +POST /signalk/v1/api/resources/routes {resource_data} +``` +_ResourceProvider method invocation:_ + +```javascript +setResource( + 'routes', + '', + {} +); +``` + +--- +__`deleteResource(type, id)`__: This method is called when a request is made to remove the specific resource entry of the supplied resource type and id. + +`type:` String containing the type of resource to delete. + +`id:` String containing the target resource entry id. _e.g. 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99'_ + +`returns:` +- Resolved Promise on completion. +- Rejected Promise containing an Error if incomplete or not implemented. + +_Example resource request:_ +``` +DELETE /signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99 +``` +_ResourceProvider method invocation:_ + +```javascript +deleteResource( + 'routes', + 'urn:mrn:signalk:uuid:07894aba-f151-4099-aa4f-5e5773734b99' +); +``` + +### Registering a Resource Provider: +--- + +To register the resource provider plugin with the SignalK server, the server's `resourcesApi.register()` function should be called during plugin startup. + +The server `resourcesApi.register()` function has the following signature: + +```typescript +app.resourcesApi.register(pluginId: string, resourceProvider: ResourceProvider) +``` +where: +- `pluginId`: is the plugin's id +- `resourceProvider`: is a reference to the plugins ResourceProvider interface. + +_Note: A resource type can only have one registered plugin, so if more than one plugin attempts to register as a provider for the same resource type, the first plugin to call the `register()` function will be registered by the server for the resource types defined in the ResourceProvider interface!_ + +_Example:_ +```javascript +module.exports = function (app) { + + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: ['routes','waypoints'], + methods: { + listResources: (type, params)=> { ... }, + getResource: (type:string, id:string)=> { ... } , + setResource: (type:string, id:string, value:any)=> { ... }, + deleteResource: (type:string, id:string)=> { ... } + } + } + } + + plugin.start = function(options) { + ... + app.resourcesApi.register(plugin.id, plugin.resourceProvider); + } +} +``` + +### Un-registering the Resource Provider: +--- + +When a resource provider plugin is disabled, it should un-register itself to ensure resource requests are no longer directed to it by calling the SignalK server. This should be done by calling the server's `resourcesApi.unRegister()` function during shutdown. + +The server `resourcesApi.unRegister()` function has the following signature: + +```typescript +app.resourcesApi.unRegister(pluginId: string) +``` +where: +- `pluginId`: is the plugin's id + + +_Example:_ +```javascript +module.exports = function (app) { + + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: [ ... ], + methods: { ... } + } + } + + plugin.stop = function(options) { + app.resourcesApi.unRegister(plugin.id); + ... + } +} +``` + +--- + +### __Example:__ + +Resource Provider plugin providing for the retrieval of routes & waypoints. + +```javascript +// SignalK server plugin +module.exports = function (app) { + + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + // ResourceProvider interface + resourceProvider: { + types: ['routes','waypoints'], + methods: { + listResources: (type, params)=> { + return new Promise( (resolve, reject) => { + // fetch resource entries from storage + .... + if(ok) { // success + resolve({ + 'id1': { ... }, + 'id2': { ... }, + }); + } else { // error + reject(new Error('Error encountered!') + } + } + }, + getResource: (type, id)=> { + // fetch resource entries from storage + .... + if(ok) { // success + return Promise.resolve({ + ... + }); + } else { // error + reject(new Error('Error encountered!') + } + }, + setResource: (type, id, value)=> { + // not implemented + return Promise.reject(new Error('NOT IMPLEMENTED!')); + }, + deleteResource: (type, id)=> { + // not implemented + return Promise.reject(new Error('NOT IMPLEMENTED!')); + } + } + }, + + start: (options)=> { + ... + app.resourceApi.register(this.id, this.resourceProvider); + }, + + stop: ()=> { + app.resourceApi.unRegister(this.id); + ... + } + } +} +``` diff --git a/SERVERPLUGINS.md b/SERVERPLUGINS.md index 72e8f7634..1d8a53797 100644 --- a/SERVERPLUGINS.md +++ b/SERVERPLUGINS.md @@ -22,7 +22,9 @@ The plugin module must export a single `function(app)` that must return an objec ## Getting Started with Plugin Development -To get started with SignalK plugin development, you can follow the following guide. +To get started with SignalK plugin development, you can follow this guide. + +_Note: For plugins acting as a provider for one or more of the SignalK resource types listed in the specification (`routes`, `waypoints`, `notes`, `regions` or `charts`) please refer to __[RESOURCE_PROVIDER_PLUGINS.md](./RESOURCE_PROVIDER_PLUGINS.md)__ for additional details._ ### Project setup @@ -567,6 +569,8 @@ Log debug messages. This is the debug method from the [debug module](https://www `app.debug()` can take any type and will serialize it before outputting. +*Do not use `debug` directly*. Using the debug function provided by the server makes sure that the plugin taps into the server's debug logging system, including the helper switches in Admin UI's Server Log page. + ### `app.savePluginOptions(options, callback)` Save changes to the plugin's options. @@ -698,6 +702,83 @@ app.registerDeltaInputHandler((delta, next) => { }) ``` +### `app.resourcesApi.getResource(resource_type, resource_id)` + +Retrieve resource data for the supplied SignalK resource type and resource id. + +_Valid resource types are `routes`, `waypoints`, `notes`, `regions` & `charts`._ + + +This method invokes the `registered Resource Provider` for the supplied `resource_type` and returns a `resovled` __Promise__ containing the resource data if successful or +a `rejected` __Promise__ containing an __Error__ object if unsuccessful. + +_Example:_ +```javascript +let resource= app.resourcesApi.getResource('routes', 'urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a'); + +resource.then ( (data)=> { + // route data + console.log(data); + ... +}).catch (error) { + // handle error + console.log(error.message); + ... +} +``` + + +### `app.resourcesApi.register(pluginId, resourceProvider)` + +If a plugin wants to act as a resource provider, it will need to register its provider methods during startup using this function. + +See [`RESOURCE_PROVIDER_PLUGINS.md`](./RESOURCE_PROVIDER_PLUGINS.md) for details. + + +```javascript +module.exports = function (app) { + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: ['routes','waypoints'], + methods: { ... } + } + start: function(options) { + // do plugin start up + app.resourcesApi.register(this.id, this.resourceProvider); + } + ... + } +} +``` + +### `app.resourcesApi.unRegister(pluginId)` + +When a resource provider plugin is disabled it will need to un-register its provider methods for all of the resource types it manages. This should be done in the plugin's `stop()` function. + +See [`RESOURCE_PROVIDER_PLUGINS.md`](./RESOURCE_PROVIDER_PLUGINS.md) for details. + + +```javascript +module.exports = function (app) { + let plugin= { + id: 'mypluginid', + name: 'My Resource Providerplugin', + resourceProvider: { + types: ['routes','waypoints'], + methods: { ... } + } + ... + stop: function(options) { + app.resourcesApi.unRegister(this.id); + // do plugin shutdown + } + } +} +``` + + ### `app.setPluginStatus(msg)` Set the current status of the plugin. The `msg` should be a short message describing the current status of the plugin and will be displayed in the plugin configuration UI and the Dashboard. diff --git a/WORKING_WITH_RESOURCES_API.md b/WORKING_WITH_RESOURCES_API.md new file mode 100644 index 000000000..5656391cf --- /dev/null +++ b/WORKING_WITH_RESOURCES_API.md @@ -0,0 +1,319 @@ +# Working with the Resources API + + +## Overview + +The SignalK specification defines a number of resources (routes, waypoints, notes, regions & charts) each with its path under the root `resources` path _(e.g. `/signalk/v1/api/resources/routes`)_. + +The SignalK server handles requests to these resource paths to enable the retrieval, creation, updating and deletion of resources. + +--- +## Operation: + +For resources to be stored and retrieved, the Signal K server requires that a [Resource Provider plugin](RESOURCE_PROVIDER_PLUGINS.md) be installed and registered to manage each of the resource types your implementation requires. _You can find plugins in the `App Store` section of the server admin UI._ + +Client applications can then use HTTP requests to resource paths to store and retrieve resource entries. _Note: the ability to store resource entries is controlled by the server security settings so client applications may need to authenticate for write / delete operations to complete successfully._ + + +### Retrieving Resources +--- + +Resource entries are retrived by submitting an HTTP `GET` request to the relevant path. + +_Example:_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/routes' +``` +to return a list of available routes OR +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` +to retrieve a specific resource entry. + +When retrieving a list of entries these can be filtered based on certain criteria such as: + +- being within a bounded area +- distance from vessel +- total entries returned. + +This is done by supplying query string key | value pairs in the request. + +_Example 1: Retrieve waypoints within 50km of the vessel_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/waypoints?distance=50000' +``` +_Note: the distance supplied is in meters_. + +_Example 2: Retrieve the first 20 waypoints within 90km of the vessel_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/waypoints?distance=90000&limit=20' +``` +_Note: the distance supplied is in meters_. + +_Example 3: Retrieve waypoints within a bounded area._ +```typescript +HTTP GET 'http://hostname:3000/signalk/v1/api/resources/waypoints?bbox=-135.5,38,-134,38.5' +``` +_Note: the bounded area is supplied as bottom left & top right corner coordinates in the form swLongitude,swLatitude,neLongitude,neLatitude_. + + +### Deleting Resources +--- + +Resource entries are deleted by submitting an HTTP `DELETE` request to a path containing the id of the resource to delete. + +_Example:_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v1/api/resources/routes/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' +``` + +In this example the route with the supplied id is deleted from storage. + +### Creating / updating Resources +--- + +Resource entries are created by submitting an HTTP `POST` request to the relevant API path that does NOT include a resource `id`. In this instance the resource is created with an `id` that is generated by the server. + +___Note: Each `POST` will generate a new `id` so if the resource data remains the same duplicate resources will be created.___ + +_Example: Create a new route._ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/route' {..} +``` + +Resource entries are updated by submitting an HTTP `PUT` request to path that includes a resource `id`. + +_Example: Update a route entry._ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/route/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' {..} +``` + +The body of both `POST` and `PUT` requests should contain valid resource data for the specific resource type in the API path. + +Each resource type has a specific set of attributes that are required to be supplied before the resource entry can be created or updated. + +___Note: When submitting data to create or update a resource entry, the submitted resource data is validated against the Signal K schema for that resource type. If the submitted data is deemed to be invalid then the operation is aborted.___ + +___Additionally when supplying an id to assign to or identify the resource on which to perform the operation, the id must be valid for the type of resource as defined in the Signal K schema.___ + +--- +#### __Routes:__ + +To create / update a route entry the body of the request must contain data in the following format: +```javascript +{ + name: 'route name', + description: 'description of the route', + attributes: { + ... + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` +where: +- name: is text detailing the name of the route +- description (optional): is text describing the route +- attributes (optional): object containing key | value pairs of attributes associated with the route +- points: is an array of route points (latitude and longitude) + + +_Example: Create new route entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/route' { + name: 'route name', + description: 'description of the route', + attributes: { + distance: 6580 + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` + +_Example: Create new route entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/route/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + name: 'route name', + description: 'description of the route', + attributes: { + distance: 6580 + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467} + ] +} +``` + +--- +#### __Waypoints:__ + +To create / update a waypoint entry the body of the request must contain data in the following format: +```javascript +{ + name: 'waypoint name', + description: 'description of the waypoint', + attributes: { + ... + }, + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` +where: +- name: is text detailing the name of the waypoint +- description (optional): is text describing the waypoint +- attributes (optional): object containing key | value pairs of attributes associated with the waypoint +- position: the latitude and longitude of the waypoint + + +_Example: Create new waypoint entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/waypoint' { + name: 'waypoint #1', + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +_Example: Create new waypoint entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/waypoint/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + name: 'waypoint #1', + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +--- +#### __Regions:__ + +To create / update a region entry the body of the request must contain data in the following format: +```javascript +{ + name: 'region name', + description: 'description of the region', + attributes: { + ... + }, + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` +where: +- name: is text detailing the name of the region +- description (optional): is text describing the region +- attributes (optional): object containing key | value pairs of attributes associated with the region +- points: is an array of points (latitude and longitude) defining an area. + + +_Example: Create new region entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/region' { + name: 'region name', + description: 'description of the region', + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` + +_Example: Create new region entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/region/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + name: 'region name', + description: 'description of the region', + points: [ + {latitude: -38.567,longitude: 135.9467}, + {latitude: -38.967,longitude: 135.2467}, + {latitude: -39.367,longitude: 134.7467}, + {latitude: -39.567,longitude: 134.4467}, + {latitude: -38.567,longitude: 135.9467} + ] +} +``` + +--- +#### __Notes:__ + +To create / update a note entry the body of the request must contain data in the following format: +```javascript +{ + title: 'note title text', + description: 'description of the note', + attributes: { + attribute1: 'attribute1 value', + attribute2: 258, + ... + }, + url: 'link to note content', + mimeType: 'text/plain, text/html, etc.', + position: { + latitude: -38.567, + longitude: 135.9467 + }, + href: 'reference to resource entry' +} +``` +where: +- name: is text detailing the name of the note +- description (optional): is text describing the note +- attributes (optional): object containing key | value pairs of attributes associated with the note +- url (optional): link to the note contents +- mimeType (optional): the mime type of the note contents + +and either: +- position: the latitude and longitude associated with the note + +OR +- href: text containing a reference to a resource associated with the note _e.g. '/resources/regions/urn:mrn:signalk:uuid:35052456-65fa-48ce-a85d-41b78a9d2a61'_ + + +_Example: Create new note entry (with server generated id)_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v1/api/resources/set/note' { + title: 'note title', + description: 'text containing brief description', + url: 'http:notehost.com/notes/mynote.html', + mimeType: 'text/plain', + position: { + latitude: -38.567, + longitude: 135.9467 + } +} +``` + +_Example: Create new note entry (with supplied id)_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v1/api/resources/set/note/urn:mrn:signalk:uuid:94052456-65fa-48ce-a85d-41b78a9d2111' { + title: 'note title', + description: 'text containing brief description', + href: '/resources/regions/urn:mrn:signalk:uuid:35052456-65fa-48ce-a85d-41b78a9d2a61' +} +``` diff --git a/package.json b/package.json index 616a59ad1..9955e043e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "signalk-server", - "version": "1.40.0", + "version": "1.41.0", "description": "An implementation of a [Signal K](http://signalk.org) server for boats.", "main": "index.js", "scripts": { @@ -67,11 +67,19 @@ "engines": { "node": ">=10" }, + "workspaces": [ + "packages/server-admin-ui-dependencies", + "packages/server-admin-ui", + "packages/streams", + "packages/server-api" + ], "dependencies": { + "@signalk/n2k-signalk": "^2.0.0", + "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/server-admin-ui": "1.39.1", "@signalk/server-api": "1.39.x", "@signalk/signalk-schema": "1.5.1", - "@signalk/streams": "1.19.x", + "@signalk/streams": "2.x", "@types/debug": "^4.1.5", "baconjs": "^1.0.1", "bcryptjs": "^2.4.3", @@ -85,7 +93,7 @@ "cookie": "^0.4.0", "cookie-parser": "^1.4.3", "cors": "^2.5.2", - "debug": "4.2.0", + "debug": "^4.3.3", "deep-get-set": "^1.1.0", "dev-null-stream": "0.0.1", "dnssd2": "1.0.0", @@ -96,6 +104,7 @@ "figlet": "^1.2.0", "file-timestamp-stream": "^2.1.2", "flatmap": "0.0.3", + "geojson-validation": "^1.0.2", "geolib": "3.2.2", "get-installed-path": "^4.0.8", "inquirer": "^7.0.0", @@ -109,6 +118,7 @@ "morgan": "^1.5.0", "ms": "^2.1.2", "ncp": "^2.0.0", + "ngeohash": "^0.6.3", "node-fetch": "^2.6.0", "pem": "^1.14.3", "primus": "^7.0.0", @@ -135,7 +145,9 @@ "@types/express": "^4.17.1", "@types/lodash": "^4.14.139", "@types/mocha": "^8.2.0", + "@types/ngeohash": "^0.6.4", "@types/node-fetch": "^2.5.3", + "@types/pem": "^1.9.6", "@types/semver": "^7.1.0", "@types/serialport": "^8.0.1", "@types/split": "^1.0.0", diff --git a/packages/server-admin-ui/package.json b/packages/server-admin-ui/package.json index c187501f7..647b46c52 100644 --- a/packages/server-admin-ui/package.json +++ b/packages/server-admin-ui/package.json @@ -55,9 +55,10 @@ }, "scripts": { "prepublishOnly": "npm run clean && npm run build", - "dev": "webpack --watch --mode development", + "watch": "webpack --watch --mode development", "build": "webpack --mode=production", "format": "prettier --write src/", + "ci": "prettier --check src/", "clean": "rimraf ./public", "bundle-analyzer": "webpack-bundle-analyzer --port 4200 public/stats.json" } diff --git a/packages/server-admin-ui/scss/style.scss b/packages/server-admin-ui/scss/style.scss index 571b6ea7f..d1f706553 100644 --- a/packages/server-admin-ui/scss/style.scss +++ b/packages/server-admin-ui/scss/style.scss @@ -10,7 +10,7 @@ @import "bootstrap-variables"; // Import Bootstrap source files -@import "node_modules/bootstrap/scss/bootstrap"; +@import "~bootstrap/scss/bootstrap"; // Override core variables @import "core-variables"; diff --git a/packages/server-api/package.json b/packages/server-api/package.json index 0526be60b..89970ba90 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -18,7 +18,9 @@ "author": "teppo.kurki@iki.fi", "license": "Apache-2.0", "devDependencies": { + "express": "^4.10.4", "@types/chai": "^4.2.15", + "@types/express": "^4.17.1", "@types/mocha": "^8.2.0", "chai": "^4.3.0", "mocha": "^8.3.0", diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 7b9489495..57b321985 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -3,6 +3,33 @@ import { PropertyValues, PropertyValuesCallback } from './propertyvalues' export { PropertyValue, PropertyValues, PropertyValuesCallback } from './propertyvalues' + +export type SignalKResourceType= 'routes' | 'waypoints' |'notes' |'regions' |'charts' +export type ResourceTypes= SignalKResourceType[] | string[] + +export interface ResourcesApi { + register: (pluginId: string, provider: ResourceProvider) => void; + unRegister: (pluginId: string) => void; + getResource: (resType: SignalKResourceType, resId: string) => any; +} + +export interface ResourceProvider { + types: ResourceTypes + methods: ResourceProviderMethods +} + +export interface ResourceProviderMethods { + pluginId?: string + listResources: (type: string, query: { [key: string]: any }) => Promise + getResource: (type: string, id: string) => Promise + setResource: ( + type: string, + id: string, + value: { [key: string]: any } + ) => Promise + deleteResource: (type: string, id: string) => Promise +} + type Unsubscribe = () => {} export interface PropertyValuesEmitter { emitPropertyValue: (name: string, value: any) => void @@ -54,4 +81,5 @@ export interface Plugin { registerWithRouter?: (router: IRouter) => void signalKApiRoutes?: (router: IRouter) => IRouter enabledByDefault?: boolean + resourceProvider?: ResourceProvider } diff --git a/packages/streams/.prettierrc.json b/packages/streams/.prettierrc.json new file mode 100644 index 000000000..b2095be81 --- /dev/null +++ b/packages/streams/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "semi": false, + "singleQuote": true +} diff --git a/packages/streams/README.md b/packages/streams/README.md index 40798a9df..c3f65643f 100644 --- a/packages/streams/README.md +++ b/packages/streams/README.md @@ -6,9 +6,9 @@ The code is not compiled and is not more effective, but allows using the streams ## Development -* Install dev packages with `npm i`. -* Edit files with `/src`. -* `npm link` -* `cd ../../` -* `npm link @signalk/streams` -* Restart signalk `npm start` +- Install dev packages with `npm i`. +- Edit files with `/src`. +- `npm link` +- `cd ../../` +- `npm link @signalk/streams` +- Restart signalk `npm start` diff --git a/packages/streams/autodetect.js b/packages/streams/autodetect.js index 8780352c4..3c002417e 100644 --- a/packages/streams/autodetect.js +++ b/packages/streams/autodetect.js @@ -45,12 +45,12 @@ A => actisense-serial format N2K data 1471172400153;A;2016-07-16T12:00:00.000Z,2,130306,105,255,8,00,d1,03,c9,23,fa,ff,ff */ -function DeMultiplexer (options) { +function DeMultiplexer(options) { Writable.call(this) this.toTimestamped = new ToTimestamped(this, options) this.timestampThrottle = new TimestampThrottle({ - getMilliseconds: msg => msg.timestamp + getMilliseconds: (msg) => msg.timestamp, }) this.splitter = new Splitter(this, options) this.options = options @@ -66,7 +66,7 @@ DeMultiplexer.prototype.write = function (chunk, encoding, callback) { return this.toTimestamped.write(chunk, encoding, callback) } -function Splitter (deMultiplexer, options) { +function Splitter(deMultiplexer, options) { Transform.call(this, { objectMode: true }) this.demuxEmitData = function (msg) { deMultiplexer.emit('data', msg) @@ -91,7 +91,7 @@ Splitter.prototype._transform = function (msg, encoding, _done) { let done = _done try { switch (msg.discriminator) { - case 'A': { + case 'A': { msg.fromFile = true const result = this.fromActisenseSerial.write(msg, encoding) if (!result) { @@ -130,7 +130,7 @@ Splitter.prototype.pipe = function (target) { return Transform.prototype.pipe.call(this, target) } -function ToTimestamped (deMultiplexer, options) { +function ToTimestamped(deMultiplexer, options) { Transform.call(this, { objectMode: true }) this.deMultiplexer = deMultiplexer this.options = options diff --git a/packages/streams/canboatjs.js b/packages/streams/canboatjs.js index 3ca7caf98..f5d806f4d 100644 --- a/packages/streams/canboatjs.js +++ b/packages/streams/canboatjs.js @@ -16,21 +16,24 @@ const Transform = require('stream').Transform const FromPgn = require('@canboat/canboatjs').FromPgn -const debug = require('debug')('signalk:streams:canboatjs') const _ = require('lodash') -function CanboatJs (options) { +function CanboatJs(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.fromPgn = new FromPgn(options) + const createDebug = options.createDebug || require('debug') + const debug = createDebug('signalk:streams:nmea0183-signalk') this.fromPgn.on('warning', (pgn, warning) => { debug(`[warning] ${pgn.pgn} ${warning}`) }) - this.fromPgn.on('error', (pgn, err) => {console.log(err)} ) + this.fromPgn.on('error', (pgn, err) => { + console.log(err) + }) this.app = options.app } @@ -40,14 +43,14 @@ require('util').inherits(CanboatJs, Transform) CanboatJs.prototype._transform = function (chunk, encoding, done) { if (_.isObject(chunk) && chunk.fromFile) { const pgnData = this.fromPgn.parse(chunk.data) - if ( pgnData ) { + if (pgnData) { pgnData.timestamp = new Date(Number(chunk.timestamp)).toISOString() this.push(pgnData) this.app.emit('N2KAnalyzerOut', pgnData) } } else { const pgnData = this.fromPgn.parse(chunk) - if ( pgnData ) { + if (pgnData) { this.push(pgnData) this.app.emit('N2KAnalyzerOut', pgnData) } diff --git a/packages/streams/execute.js b/packages/streams/execute.js index f6b83ead1..1fa1d118a 100644 --- a/packages/streams/execute.js +++ b/packages/streams/execute.js @@ -39,10 +39,11 @@ const Transform = require('stream').Transform const { pgnToActisenseSerialFormat } = require('@canboat/canboatjs') -function Execute (options) { +function Execute(options) { Transform.call(this, {}) this.options = options - this.debug = options.debug || require('debug')('signalk:streams:execute') + const createDebug = options.createDebug || require('debug') + this.debug = options.debug || createDebug('signalk:streams:execute') } require('util').inherits(Execute, Transform) @@ -52,7 +53,7 @@ Execute.prototype._transform = function (chunk, encoding, done) { this.analyzerProcess.stdin.write(chunk.toString()) done() } -function start (command, that) { +function start(command, that) { that.debug(`starting |${command}|`) if (process.platform === 'win32') { that.childProcess = require('child_process').spawn('cmd', ['/c', command]) @@ -75,7 +76,7 @@ function start (command, that) { that.push(data) }) - that.childProcess.on('close', code => { + that.childProcess.on('close', (code) => { const msg = `|${command}| exited with ${code}` // that.options.app.setProviderError(that.options.providerId, msg) console.error(msg) @@ -106,7 +107,9 @@ Execute.prototype.pipe = function (pipeTo) { start(this.options.command, this) const stdOutEvent = this.options.toChildProcess || 'toChildProcess' - this.debug('Using event ' + stdOutEvent + " for output to child process's stdin") + this.debug( + 'Using event ' + stdOutEvent + " for output to child process's stdin" + ) const that = this that.options.app.on(stdOutEvent, function (d) { try { @@ -117,7 +120,7 @@ Execute.prototype.pipe = function (pipeTo) { }) if (stdOutEvent === 'nmea2000out') { - that.options.app.on('nmea2000JsonOut', pgn => { + that.options.app.on('nmea2000JsonOut', (pgn) => { that.childProcess.stdin.write(pgnToActisenseSerialFormat(pgn) + '\r\n') }) that.options.app.emit('nmea2000OutAvailable') diff --git a/packages/streams/filestream.js b/packages/streams/filestream.js index 9f382874c..a57e368ab 100644 --- a/packages/streams/filestream.js +++ b/packages/streams/filestream.js @@ -37,7 +37,7 @@ const path = require('path') const PassThrough = require('stream').PassThrough const fs = require('fs') -function EndIgnoringPassThrough () { +function EndIgnoringPassThrough() { PassThrough.call(this) } @@ -72,7 +72,7 @@ FileStream.prototype.startStream = function () { } this.filestream = require('fs').createReadStream(filename) - this.filestream.on('error', err => { + this.filestream.on('error', (err) => { console.error(err.message) this.keepRunning = false }) diff --git a/packages/streams/folderstream.js b/packages/streams/folderstream.js index f62c6540c..9bc515b36 100644 --- a/packages/streams/folderstream.js +++ b/packages/streams/folderstream.js @@ -1,39 +1,36 @@ -const Transform = require("stream").Transform; -const fs = require("fs"); +const Transform = require('stream').Transform +const fs = require('fs') function FolderStreamProvider(folder) { Transform.call(this, { objectMode: false, - }); - this.folder = folder; - this.fileIndex = 0; + }) + this.folder = folder + this.fileIndex = 0 } -require("util").inherits(FolderStreamProvider, Transform); +require('util').inherits(FolderStreamProvider, Transform) -FolderStreamProvider.prototype.pipe = function(pipeTo) { - const files = fs.readdirSync(this.folder); - pipeNextFile.bind(this)(); +FolderStreamProvider.prototype.pipe = function (pipeTo) { + const files = fs.readdirSync(this.folder) + pipeNextFile.bind(this)() function pipeNextFile() { const fileStream = fs.createReadStream( - this.folder + "/" + files[this.fileIndex] - ); - fileStream.pipe( - pipeTo, - { end: false } - ); - fileStream.on("end", () => { - this.fileIndex++; + this.folder + '/' + files[this.fileIndex] + ) + fileStream.pipe(pipeTo, { end: false }) + fileStream.on('end', () => { + this.fileIndex++ if (this.fileIndex === files.length) { - pipeTo.end(); + pipeTo.end() } else { - pipeNextFile.bind(this)(); + pipeNextFile.bind(this)() } - }); + }) } - return pipeTo; -}; + return pipeTo +} -module.exports = FolderStreamProvider; +module.exports = FolderStreamProvider diff --git a/packages/streams/from_json.js b/packages/streams/from_json.js index 15ec80227..24c582c14 100644 --- a/packages/streams/from_json.js +++ b/packages/streams/from_json.js @@ -27,9 +27,9 @@ const Transform = require('stream').Transform -function FromJson () { +function FromJson() { Transform.call(this, { - objectMode: true + objectMode: true, }) } diff --git a/packages/streams/gpsd.js b/packages/streams/gpsd.js index 51122706d..b26e41bd4 100644 --- a/packages/streams/gpsd.js +++ b/packages/streams/gpsd.js @@ -32,35 +32,39 @@ const Transform = require('stream').Transform const gpsd = require('node-gpsd') -const debug = require('debug')('signalk:streams:gpsd') -function Gpsd (options) { +function Gpsd(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) - + const port = options.port || 2947 const hostname = options.hostname || options.host || 'localhost' function setProviderStatus(msg) { options.app.setProviderStatus(options.providerId, msg) } - + + const createDebug = options.createDebug || require('debug') + this.listener = new gpsd.Listener({ port, hostname, logger: { - info: debug, + info: createDebug('signalk:streams:gpsd'), warn: console.warn, error: (msg) => { - options.app.setProviderError(options.providerId, `${hostname}:${port}: ` + msg) - } + options.app.setProviderError( + options.providerId, + `${hostname}:${port}: ` + msg + ) + }, }, - parse: false + parse: false, }) setProviderStatus(`Connecting to ${hostname}:${port}`) - + this.listener.connect(function () { setProviderStatus(`Connected to ${hostname}:${port}`) }) @@ -72,7 +76,7 @@ function Gpsd (options) { this.listener.watch({ class: 'WATCH', - nmea: true + nmea: true, }) } diff --git a/packages/streams/keys-filter.js b/packages/streams/keys-filter.js index 27e9c1b64..bffc7d1c8 100644 --- a/packages/streams/keys-filter.js +++ b/packages/streams/keys-filter.js @@ -1,13 +1,14 @@ 'use strict' const Transform = require('stream').Transform -const debug = require('debug')('signalk:streams:keys-filter') -function ToSignalK (options) { +function ToSignalK(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) + const createDebug = options.createDebug || require('debug') + this.debug = createDebug('signalk:streams:keys-filter') this.exclude = options.excludeMatchingPaths } @@ -25,17 +26,17 @@ ToSignalK.prototype._transform = function (chunk, encoding, done) { delta = JSON.parse(chunk) string = true } catch (e) { - debug(`Error parsing chunk: ${e.message}`) + this.debug(`Error parsing chunk: ${e.message}`) } } if (Array.isArray(delta.updates)) { const updates = [] - delta.updates.forEach(update => { + delta.updates.forEach((update) => { if (Array.isArray(update.values)) { const values = [] - update.values.forEach(value => { + update.values.forEach((value) => { if (this.exclude.includes(value.path) !== true) { values.push(value) } @@ -43,7 +44,7 @@ ToSignalK.prototype._transform = function (chunk, encoding, done) { if (values.length > 0) { const upd = { - values + values, } if (update.hasOwnProperty('$source')) { diff --git a/packages/streams/liner.js b/packages/streams/liner.js index 6750f5a7f..8ad669950 100644 --- a/packages/streams/liner.js +++ b/packages/streams/liner.js @@ -28,9 +28,9 @@ const Transform = require('stream').Transform require('util').inherits(Liner, Transform) -function Liner (options) { +function Liner(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.doPush = this.push.bind(this) this.lineSeparator = options.lineSeparator || '\n' diff --git a/packages/streams/log.js b/packages/streams/log.js index 14b6adcda..48d2cb16c 100644 --- a/packages/streams/log.js +++ b/packages/streams/log.js @@ -32,9 +32,9 @@ const Transform = require('stream').Transform const getLogger = require('./logging').getLogger -function Log (options) { +function Log(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.logger = getLogger(options.app, options.discriminator, options.logdir) @@ -48,7 +48,7 @@ Log.prototype._transform = function (msg, encoding, done) { done() } -function pad (num) { +function pad(num) { return (num > 9 ? '' : '0') + num } diff --git a/packages/streams/logging.js b/packages/streams/logging.js index aae29574f..39a65b88b 100644 --- a/packages/streams/logging.js +++ b/packages/streams/logging.js @@ -16,7 +16,7 @@ const { FileTimestampStream } = require('file-timestamp-stream') const path = require('path') -const debug = require('debug')('signalk:streams:logging') +let debug = require('debug')('signalk:streams:logging') const fs = require('fs') const filenamePattern = /skserver\-raw\_\d\d\d\d\-\d\d\-\d\dT\d\d\.log/ @@ -25,57 +25,58 @@ const loggers = {} module.exports = { getLogger, getFullLogDir, - listLogFiles + listLogFiles, } class FileTimestampStreamWithDelete extends FileTimestampStream { - constructor(app, fullLogDir, filesToKeep, options){ + constructor(app, fullLogDir, filesToKeep, options) { super(options) this.app = app this.filesToKeep = filesToKeep this.fullLogDir = fullLogDir this.prevFilename = undefined + debug = (options.createDebug || require('debug'))('signalk:streams:logging') } // This method of base class is called when new file name is contemplated // So let's override it to check how many files are there and delete the oldest ones newFilename() { - if (this.prevFilename !== this.currentFilename){ // Only do that after new file created + if (this.prevFilename !== this.currentFilename) { + // Only do that after new file created this.prevFilename = this.currentFilename this.deleteOldFiles() } return super.newFilename() } - deleteOldFiles(){ + deleteOldFiles() { debug(`Checking for old log files`) listLogFiles(this.app, (err, files) => { if (err) { - console.error(err); - }else{ + console.error(err) + } else { if (files.length > this.filesToKeep) { - const sortedFiles = files.sort(); - const numToDelete = files.length - this.filesToKeep; + const sortedFiles = files.sort() + const numToDelete = files.length - this.filesToKeep debug(`Will delete ${numToDelete} files`) - for(let i = 0; i < numToDelete; i++){ + for (let i = 0; i < numToDelete; i++) { const fileName = path.join(this.fullLogDir, sortedFiles[i]) debug(`Deleting ${fileName}`) fs.unlink(fileName, (err) => { - if (err){ + if (err) { console.error(err) - } - else { + } else { debug(`${fileName} was deleted`) } - }); + }) } } } - }); + }) } } -function getLogger (app, discriminator = '', logdir) { +function getLogger(app, discriminator = '', logdir) { const fullLogdir = getFullLogDir(app, logdir) if (!loggers[fullLogdir]) { @@ -84,26 +85,28 @@ function getLogger (app, discriminator = '', logdir) { debug(`logging to ${fileName}`) let fileTimestampStream - if (app.config.settings.keepMostRecentLogsOnly){ // Delete old logs + if (app.config.settings.keepMostRecentLogsOnly) { + // Delete old logs fileTimestampStream = new FileTimestampStreamWithDelete( - app, fullLogdir, app.config.settings.logCountToKeep, - { path: fileName } - ) - }else{ // Don't delete any logs - fileTimestampStream = new FileTimestampStream( + app, + fullLogdir, + app.config.settings.logCountToKeep, { path: fileName } ) + } else { + // Don't delete any logs + fileTimestampStream = new FileTimestampStream({ path: fileName }) } loggers[fullLogdir] = fileTimestampStream } const logger = loggers[fullLogdir] - logger.on('error', err => { + logger.on('error', (err) => { console.error(`Error opening data logging file: ${err.message}`) }) - return msg => { + return (msg) => { try { logger.write( Date.now() + @@ -119,7 +122,7 @@ function getLogger (app, discriminator = '', logdir) { } } -function getFullLogDir (app, logdir) { +function getFullLogDir(app, logdir) { if (!logdir) { logdir = app.config.settings.loggingDirectory || app.config.configPath } @@ -128,10 +131,13 @@ function getFullLogDir (app, logdir) { : path.join(app.config.configPath, logdir) } -function listLogFiles (app, cb) { +function listLogFiles(app, cb) { fs.readdir(getFullLogDir(app), (err, files) => { if (!err) { - cb(undefined, files.filter(filename => filename.match(filenamePattern))) + cb( + undefined, + files.filter((filename) => filename.match(filenamePattern)) + ) } else { cb(err) } diff --git a/packages/streams/mdns-ws.js b/packages/streams/mdns-ws.js index 01bf56538..7025c2245 100644 --- a/packages/streams/mdns-ws.js +++ b/packages/streams/mdns-ws.js @@ -18,14 +18,11 @@ const Transform = require('stream').Transform const SignalK = require('@signalk/client') -const debug = require('debug')('signalk:streams:mdns-ws') -const dataDebug = require('debug')('signalk:streams:mdns-ws-data') - const WebSocket = require('ws') -function MdnsWs (options) { +function MdnsWs(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.options = options this.selfHost = options.app.config.getExternalHostname() + '.' @@ -33,9 +30,13 @@ function MdnsWs (options) { this.remoteServers = {} this.remoteServers[this.selfHost + ':' + this.selfPort] = {} const deltaStreamBehaviour = options.subscription ? 'none' : 'all' + + const createDebug = options.createDebug || require('debug') + this.debug = createDebug('signalk:streams:mdns-ws') + this.dataDebug = createDebug('signalk:streams:mdns-ws-data') debug(`deltaStreamBehaviour:${deltaStreamBehaviour}`) - this.handleContext = () => { } + this.handleContext = () => {} if (options.selfHandling === 'manualSelf') { if (options.remoteSelf) { debug(`Using manual remote self ${options.remoteSelf}`) @@ -45,12 +46,14 @@ function MdnsWs (options) { } } } else { - console.error('Manual self handling speficied but no remoteSelf configured') + console.error( + 'Manual self handling speficied but no remoteSelf configured' + ) } } if (options.ignoreServers) { - options.ignoreServers.forEach(s => { + options.ignoreServers.forEach((s) => { this.remoteServers[s] = {} }) } @@ -62,18 +65,21 @@ function MdnsWs (options) { reconnect: true, autoConnect: false, deltaStreamBehaviour, - rejectUnauthorized: !(options.selfsignedcert === true) + rejectUnauthorized: !(options.selfsignedcert === true), }) this.connect(this.signalkClient) } else { - this.options.app.setProviderError(this.options.providerId, 'This connection is deprecated and must be deleted') + this.options.app.setProviderError( + this.options.providerId, + 'This connection is deprecated and must be deleted' + ) return } } require('util').inherits(MdnsWs, Transform) -function setProviderStatus (that, providerId, message, isError) { +function setProviderStatus(that, providerId, message, isError) { if (!isError) { that.options.app.setProviderStatus(providerId, message) console.log(message) @@ -89,50 +95,70 @@ MdnsWs.prototype.connect = function (client) { client .connect() .then(() => { - setProviderStatus(that, that.options.providerId, `ws connection connected to ${client.options.hostname}:${client.options.port}`) + setProviderStatus( + that, + that.options.providerId, + `ws connection connected to ${client.options.hostname}:${client.options.port}` + ) if (this.options.selfHandling === 'useRemoteSelf') { - client.API().then(api => api.get('/self')).then(selfFromServer => { - debug(`Mapping context ${selfFromServer} to self (empty context)`) - this.handleContext = (delta) => { - if (delta.context === selfFromServer) { - delete delta.context + client + .API() + .then((api) => api.get('/self')) + .then((selfFromServer) => { + that.debug( + `Mapping context ${selfFromServer} to self (empty context)` + ) + this.handleContext = (delta) => { + if (delta.context === selfFromServer) { + delete delta.context + } } - } - }).catch(err => { - console.error('Error retrieving self from remote server') - console.error(err) - }) + }) + .catch((err) => { + console.error('Error retrieving self from remote server') + console.error(err) + }) } - that.remoteServers[client.options.hostname + ':' + client.options.port] = client - if ( that.options.subscription ) { - let parsed + that.remoteServers[client.options.hostname + ':' + client.options.port] = + client + if (that.options.subscription) { + let parsed try { parsed = JSON.parse(that.options.subscription) - } catch ( ex ) { - setProviderStatus(that, that.options.providerId, `unable to parse subscription json: ${that.options.subscription}: ${ex.message}`, true) + } catch (ex) { + setProviderStatus( + that, + that.options.providerId, + `unable to parse subscription json: ${that.options.subscription}: ${ex.message}`, + true + ) } - if ( !Array.isArray(parsed) ) { - parsed = [ parsed ] + if (!Array.isArray(parsed)) { + parsed = [parsed] } parsed.forEach((sub, idx) => { - debug('sending subscription %j', sub) + that.debug('sending subscription %j', sub) client.subscribe(sub, String(idx)) }) } }) - .catch(err => { + .catch((err) => { setProviderStatus(that, that.options.providerId, err.message, true) }) - + client.on('delta', (data) => { if (data && data.updates) { that.handleContext(data) - if (dataDebug.enabled) { dataDebug(JSON.stringify(data)) } + if (that.dataDebug.enabled) { + that.dataDebug(JSON.stringify(data)) + } data.updates.forEach(function (update) { - update['$source'] = `${that.options.providerId}.${client.options.hostname}:${client.options.port}` + update[ + '$source' + ] = `${that.options.providerId}.${client.options.hostname}:${client.options.port}` }) } - + that.push(data) }) } diff --git a/packages/streams/n2k-signalk.js b/packages/streams/n2k-signalk.js index 908718ad4..6a209df8e 100644 --- a/packages/streams/n2k-signalk.js +++ b/packages/streams/n2k-signalk.js @@ -20,22 +20,22 @@ const N2kMapper = require('@signalk/n2k-signalk').N2kMapper require('util').inherits(ToSignalK, Transform) -function ToSignalK (options) { +function ToSignalK(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) const n2kOutEvent = 'nmea2000JsonOut' this.sourceMeta = {} this.notifications = {} this.options = options this.app = options.app - if ( options.filters && options.filtersEnabled ) { - this.filters = options.filters.filter(f => { + if (options.filters && options.filtersEnabled) { + this.filters = options.filters.filter((f) => { return (f.source && f.source.length) || (f.pgn && f.pgn.length) }) } - this.n2kMapper = new N2kMapper({...options, sendMetaData: true}) + this.n2kMapper = new N2kMapper({ ...options, sendMetaData: true }) this.n2kMapper.on('n2kOut', (pgn) => this.app.emit('nmea2000JsonOut', pgn)) @@ -43,7 +43,7 @@ function ToSignalK (options) { const existing = this.sourceMeta[n2k.src] || {} this.sourceMeta[n2k.src] = { ...existing, - ...meta + ...meta, } const delta = { context: this.app.selfContext, @@ -54,21 +54,24 @@ function ToSignalK (options) { label: this.options.providerId, type: 'NMEA2000', pgn: Number(n2k.pgn), - src: n2k.src.toString() + src: n2k.src.toString(), }, timestamp: n2k.timestamp.substring(0, 10) + 'T' + n2k.timestamp.substring(11, n2k.timestamp.length), - values: [] - } - ] + values: [], + }, + ], } - this.app.deltaCache.setSourceDelta(`${this.options.providerId}.${n2k.src}`, delta) + this.app.deltaCache.setSourceDelta( + `${this.options.providerId}.${n2k.src}`, + delta + ) }) this.n2kMapper.on('n2kSourceMetadataTimeout', (pgn, src) => { - if ( pgn == 60928 ) { + if (pgn == 60928) { console.warn(`n2k-signalk: unable to detect can name for src ${src}`) this.sourceMeta[src].unknowCanName = true } @@ -76,86 +79,110 @@ function ToSignalK (options) { this.n2kMapper.on('n2kSourceChanged', (src, from, to) => { console.warn(`n2k-signalk: address ${src} changed from ${from} ${to}`) - if ( this.sourceMeta[src] ) { + if (this.sourceMeta[src]) { delete this.sourceMeta[src] } }) - if ( this.app.isNmea2000OutAvailable ) { + if (this.app.isNmea2000OutAvailable) { this.n2kMapper.n2kOutIsAvailable(this.app, n2kOutEvent) } else { this.app.on('nmea2000OutAvailable', () => - this.n2kMapper.n2kOutIsAvailable(this.app, n2kOutEvent)) + this.n2kMapper.n2kOutIsAvailable(this.app, n2kOutEvent) + ) } } -ToSignalK.prototype.isFiltered = function(source) { - return this.filters && this.filters.find(filter => { - const sFilter = this.options.useCanName ? source.canName : source.src - return (!filter.source || filter.source.length === 0 || filter.source == sFilter) && (!filter.pgn || filter.pgn.length === 0 || filter.pgn == source.pgn) - }) +ToSignalK.prototype.isFiltered = function (source) { + return ( + this.filters && + this.filters.find((filter) => { + const sFilter = this.options.useCanName ? source.canName : source.src + return ( + (!filter.source || + filter.source.length === 0 || + filter.source == sFilter) && + (!filter.pgn || filter.pgn.length === 0 || filter.pgn == source.pgn) + ) + }) + ) } ToSignalK.prototype._transform = function (chunk, encoding, done) { try { const delta = this.n2kMapper.toDelta(chunk) - + const src = Number(chunk.src) - if ( !this.sourceMeta[src] ) { + if (!this.sourceMeta[src]) { this.sourceMeta[src] = {} - } + } - if (delta && delta.updates[0].values.length > 0 && !this.isFiltered(delta.updates[0].source) ) { - if ( !this.options.useCanName ) { + if ( + delta && + delta.updates[0].values.length > 0 && + !this.isFiltered(delta.updates[0].source) + ) { + if (!this.options.useCanName) { delete delta.updates[0].source.canName } const canName = delta.updates[0].source.canName - - if ( this.options.useCanName && !canName && !this.sourceMeta[src].unknowCanName ) { + + if ( + this.options.useCanName && + !canName && + !this.sourceMeta[src].unknowCanName + ) { done() return } - delta.updates.forEach(update => { - update.values.forEach(kv => { - if ( kv.path && kv.path.startsWith('notifications.') ) { - if ( kv.value.state === 'normal' && this.notifications[kv.path] && this.notifications[kv.path][src]) { - clearInterval(this.notifications[kv.path][src].interval) - delete this.notifications[kv.path][src] - } else if ( kv.value.state !== 'normal' ) { - if ( !this.notifications[kv.path] ) { - this.notifications[kv.path] = {} - } - if ( !this.notifications[kv.path][src] ) { - const interval = setInterval(() => { - if (Date.now() - this.notifications[kv.path][src].lastTime > 10000) { - const copy = JSON.parse(JSON.stringify(kv)) - copy.value.state = 'normal' - const normalDelta = { - context: delta.context, - updates: [ - { - source: update.source, - values: [ copy ] - } - ] - } - delete this.notifications[kv.path][src] - clearInterval(interval) - this.app.handleMessage(this.options.providerId, normalDelta) + delta.updates.forEach((update) => { + update.values.forEach((kv) => { + if (kv.path && kv.path.startsWith('notifications.')) { + if ( + kv.value.state === 'normal' && + this.notifications[kv.path] && + this.notifications[kv.path][src] + ) { + clearInterval(this.notifications[kv.path][src].interval) + delete this.notifications[kv.path][src] + } else if (kv.value.state !== 'normal') { + if (!this.notifications[kv.path]) { + this.notifications[kv.path] = {} + } + if (!this.notifications[kv.path][src]) { + const interval = setInterval(() => { + if ( + Date.now() - this.notifications[kv.path][src].lastTime > + 10000 + ) { + const copy = JSON.parse(JSON.stringify(kv)) + copy.value.state = 'normal' + const normalDelta = { + context: delta.context, + updates: [ + { + source: update.source, + values: [copy], + }, + ], } - }, 5000) - this.notifications[kv.path][src] = { - lastTime: Date.now(), - interval: interval + delete this.notifications[kv.path][src] + clearInterval(interval) + this.app.handleMessage(this.options.providerId, normalDelta) } - } else { - this.notifications[kv.path][src].lastTime = Date.now() + }, 5000) + this.notifications[kv.path][src] = { + lastTime: Date.now(), + interval: interval, } + } else { + this.notifications[kv.path][src].lastTime = Date.now() } } - }) + } + }) }) this.push(delta) } diff --git a/packages/streams/n2kAnalyzer.js b/packages/streams/n2kAnalyzer.js index a0441e563..14326237c 100644 --- a/packages/streams/n2kAnalyzer.js +++ b/packages/streams/n2kAnalyzer.js @@ -15,21 +15,20 @@ */ const Transform = require('stream').Transform -const debug = require('debug')('signalk:streams:n2k-analyzer') -function N2KAnalyzer (options) { +function N2KAnalyzer(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) if (process.platform === 'win32') { this.analyzerProcess = require('child_process').spawn('cmd', [ '/c', - 'analyzer -json -si' + 'analyzer -json -si', ]) } else { this.analyzerProcess = require('child_process').spawn('sh', [ '-c', - 'analyzer -json -si' + 'analyzer -json -si', ]) } this.analyzerProcess.stderr.on('data', function (data) { @@ -68,7 +67,7 @@ N2KAnalyzer.prototype.pipe = function (pipeTo) { } N2KAnalyzer.prototype.end = function () { - debug('end, killing child analyzer process') + console.log('end, killing child analyzer process') this.analyzerProcess.kill() this.pipeTo.end() } diff --git a/packages/streams/nmea0183-signalk.js b/packages/streams/nmea0183-signalk.js index 05f9b4b33..531f4945a 100644 --- a/packages/streams/nmea0183-signalk.js +++ b/packages/streams/nmea0183-signalk.js @@ -32,14 +32,16 @@ const Transform = require('stream').Transform const Parser = require('@signalk/nmea0183-signalk') const utils = require('@signalk/nmea0183-utilities') -const debug = require('debug')('signalk:streams:nmea0183-signalk') const n2kToDelta = require('@signalk/n2k-signalk').toDelta const FromPgn = require('@canboat/canboatjs').FromPgn -function Nmea0183ToSignalK (options) { +function Nmea0183ToSignalK(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) + this.debug = (options.createDebug || require('debug'))( + 'signalk:streams:nmea0183-signalk' + ) this.parser = new Parser(options) this.n2kParser = new FromPgn(options) @@ -50,7 +52,7 @@ function Nmea0183ToSignalK (options) { // Prepare a list of events to send for each sentence received this.sentenceEvents = options.suppress0183event ? [] : ['nmea0183'] - this.appendChecksum = options.appendChecksum; + this.appendChecksum = options.appendChecksum if (options.sentenceEvent) { if (Array.isArray(options.sentenceEvent)) { @@ -79,19 +81,19 @@ Nmea0183ToSignalK.prototype._transform = function (chunk, encoding, done) { try { if (sentence !== undefined) { if (this.appendChecksum) { - sentence = utils.appendChecksum(sentence); + sentence = utils.appendChecksum(sentence) } // Send 'sentences' event to the app for each sentence - this.sentenceEvents.forEach(eventName => { + this.sentenceEvents.forEach((eventName) => { this.app.emit(eventName, sentence) this.app.signalk.emit(eventName, sentence) }) let delta = null - if ( this.n2kParser.isN2KOver0183(sentence) ) { + if (this.n2kParser.isN2KOver0183(sentence)) { const pgn = this.n2kParser.parseN2KOver0183(sentence) - if ( pgn ) { - delta = n2kToDelta(pgn, this.state, {sendMetaData: true}) + if (pgn) { + delta = n2kToDelta(pgn, this.state, { sendMetaData: true }) } } else { delta = this.parser.parse(sentence) @@ -99,7 +101,7 @@ Nmea0183ToSignalK.prototype._transform = function (chunk, encoding, done) { if (delta !== null) { if (timestamp !== null) { - delta.updates.forEach(update => { + delta.updates.forEach((update) => { update.timestamp = timestamp }) } @@ -108,7 +110,7 @@ Nmea0183ToSignalK.prototype._transform = function (chunk, encoding, done) { } } } catch (e) { - debug(`[error] ${e.message}`) + this.debug(e) } done() diff --git a/packages/streams/nullprovider.js b/packages/streams/nullprovider.js index 0fc702285..e73c364a2 100644 --- a/packages/streams/nullprovider.js +++ b/packages/streams/nullprovider.js @@ -20,9 +20,9 @@ const Transform = require('stream').Transform -function NullProvider (options) { +function NullProvider(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) } diff --git a/packages/streams/package.json b/packages/streams/package.json index bb81db625..9596ae501 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -1,10 +1,11 @@ { "name": "@signalk/streams", - "version": "1.19.0", + "version": "2.0.2", "description": "Utilities for handling streams of Signal K data", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "ci": "prettier --check .", + "format": "prettier --write ." }, "repository": { "type": "git", @@ -28,7 +29,6 @@ "@signalk/nmea0183-utilities": "^0.8.0", "@signalk/signalk-schema": "^1.5.0", "any-shell-escape": "^0.1.1", - "aws-sdk": "^2.413.0", "file-timestamp-stream": "^2.1.2", "lodash": "^4.17.4", "moment": "^2.24.0", @@ -38,5 +38,8 @@ }, "optionalDependencies": { "serialport": "^9.0.1" + }, + "devDependencies": { + "prettier": "2.5.1" } } diff --git a/packages/streams/pigpio-seatalk.js b/packages/streams/pigpio-seatalk.js index 283a4fdb0..88bebcb0d 100644 --- a/packages/streams/pigpio-seatalk.js +++ b/packages/streams/pigpio-seatalk.js @@ -15,14 +15,13 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see . - * + * * 2020-06-24 Original Python code from @Thomas-GeDaD https://github.com/Thomas-GeDaD/Seatalk1-Raspi-reader * and finetuned by @MatsA * */ const Execute = require('./execute') -const debug = require('debug')('signalk:streams:pigpio-seatalk') const cmd = ` import pigpio, time, signal, sys @@ -79,13 +78,13 @@ if __name__ == "__main__": print ("exit") ` -function PigpioSeatalk (options) { - Execute.call(this, {debug}) +function PigpioSeatalk(options) { + const createDebug = options.createDebug || require('debug') + Execute.call(this, { debug: createDebug('signalk:streams:pigpio-seatalk') }) this.options = options this.options.command = `python -u -c '${cmd}' ${options.gpio} ${options.gpioInvert} ` } require('util').inherits(PigpioSeatalk, Execute) - module.exports = PigpioSeatalk diff --git a/packages/streams/replacer.js b/packages/streams/replacer.js index 6ee141791..8dcae5d09 100644 --- a/packages/streams/replacer.js +++ b/packages/streams/replacer.js @@ -14,21 +14,19 @@ * limitations under the License. */ - const Transform = require('stream').Transform require('util').inherits(Replacer, Transform) -function Replacer (options) { +function Replacer(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.doPush = this.push.bind(this) this.regexp = new RegExp(options.regexp, 'gu') this.template = options.template } - Replacer.prototype._transform = function (chunk, encoding, done) { this.doPush(chunk.toString().replace(this.regexp, this.template)) done() diff --git a/packages/streams/s3.js b/packages/streams/s3.js index 202cdf3e4..ef76270b2 100644 --- a/packages/streams/s3.js +++ b/packages/streams/s3.js @@ -15,12 +15,19 @@ */ var Transform = require('stream').Transform +/* + aws-sdk is not included in dependencies because of the + persistent deprecation warnings caused by its transitive + dependencies. This feature is not in wide use, especially + not in signalk-server where people encounter the scary looking + deprecation warnings. + Known to work with ^2.413.0 +*/ const AWS = require('aws-sdk') -const debug = require('debug')('signalk:streams:s3-provider') -function S3Provider ({ bucket, prefix }) { +function S3Provider({ bucket, prefix }) { Transform.call(this, { - objectMode: false + objectMode: false, }) this.Bucket = bucket this.Prefix = prefix @@ -34,12 +41,12 @@ S3Provider.prototype.pipe = function (pipeTo) { const s3 = new AWS.S3() const params = { Bucket: this.Bucket, - Prefix: this.Prefix + Prefix: this.Prefix, } console.log('listobjects') s3.listObjects(params) .promise() - .then(data => { + .then((data) => { // console.log(data) const jobs = data.Contents.map( (item, i) => @@ -48,18 +55,15 @@ S3Provider.prototype.pipe = function (pipeTo) { console.log('Starting key ' + item.Key) const objectParams = { Bucket: params.Bucket, - Key: item.Key + Key: item.Key, } const request = s3.getObject(objectParams) - request.on('error', err => { + request.on('error', (err) => { console.log(err) }) const stream = request.createReadStream() stream.on('end', resolve) - stream.pipe( - pipeTo, - { end: i === data.Contents.length-1 } - ) + stream.pipe(pipeTo, { end: i === data.Contents.length - 1 }) }) } ) @@ -67,14 +71,14 @@ S3Provider.prototype.pipe = function (pipeTo) { let i = 0 function startNext() { if (i < jobs.length) { - jobs[i++]().then(startNext); + jobs[i++]().then(startNext) } else { doEnd() } } - startNext(); + startNext() }) - .catch(error => { + .catch((error) => { console.error(error) }) return pipeTo diff --git a/packages/streams/serialport.js b/packages/streams/serialport.js index bb2ae56f3..1c80b92ef 100644 --- a/packages/streams/serialport.js +++ b/packages/streams/serialport.js @@ -62,10 +62,9 @@ const child_process = require('child_process') const shellescape = require('any-shell-escape') const SerialPort = require('serialport') const isArray = require('lodash').isArray -const debug = require('debug')('signalk:streams:serialport') const isBuffer = require('lodash').isBuffer -function SerialStream (options) { +function SerialStream(options) { if (!(this instanceof SerialStream)) { return new SerialStream(options) } @@ -79,11 +78,16 @@ function SerialStream (options) { this.maxPendingWrites = options.maxPendingWrites || 5 this.start() this.isFirstError = true + + const createDebug = options.createDebug || require('debug') + this.debug = createDebug('signalk:streams:serialport') } require('util').inherits(SerialStream, Transform) SerialStream.prototype.start = function () { + const that = this + if (this.serial !== null) { this.serial.unpipe(this) this.serial.removeAllListeners() @@ -101,7 +105,7 @@ SerialStream.prototype.start = function () { } this.serial = new SerialPort(this.options.device, { - baudRate: this.options.baudrate + baudRate: this.options.baudrate, }) this.serial.on( @@ -125,7 +129,7 @@ SerialStream.prototype.start = function () { if (this.isFirstError) { console.log(x.message) } - debug(x.message) + this.debug(x.message) this.isFirstError = false this.scheduleReconnect() }.bind(this) @@ -141,26 +145,25 @@ SerialStream.prototype.start = function () { }.bind(this) ) - const that = this let pendingWrites = 0 const stdOutEvent = this.options.toStdout if (stdOutEvent) { - (isArray(stdOutEvent) ? stdOutEvent : [stdOutEvent]).forEach(event => { + ;(isArray(stdOutEvent) ? stdOutEvent : [stdOutEvent]).forEach((event) => { const onDrain = () => { pendingWrites-- } - that.options.app.on(event, d => { + that.options.app.on(event, (d) => { if (pendingWrites > that.maxPendingWrites) { - debug('Buffer overflow, not writing:' + d) + that.debug('Buffer overflow, not writing:' + d) return } - debug('Writing:' + d) - if ( isBuffer(d) ) { + that.debug('Writing:' + d) + if (isBuffer(d)) { that.serial.write(d) } else { that.serial.write(d + '\r\n') - } + } pendingWrites++ that.serial.drain(onDrain) }) @@ -182,7 +185,7 @@ SerialStream.prototype.scheduleReconnect = function () { const msg = `Not connected (retry delay ${( this.reconnectDelay / 1000 ).toFixed(0)} s)` - debug(msg) + this.debug(msg) this.options.app.setProviderStatus(this.options.providerId, msg) setTimeout(this.start.bind(this), this.reconnectDelay) } diff --git a/packages/streams/simple.js b/packages/streams/simple.js index b9530ce62..fedd7c518 100644 --- a/packages/streams/simple.js +++ b/packages/streams/simple.js @@ -1,7 +1,6 @@ const Transform = require('stream').Transform const Writable = require('stream').Writable const _ = require('lodash') -const debug = require('debug')('signalk:simple') const N2kAnalyzer = require('./n2kAnalyzer') const FromJson = require('./from_json') const MultiplexedLog = require('./multiplexedlog') @@ -24,12 +23,17 @@ const Ydwg02 = require('@canboat/canboatjs').Ydwg02 const gpsd = require('./gpsd') const pigpioSeatalk = require('./pigpio-seatalk') -function Simple (options) { +function Simple(options) { Transform.call(this, { objectMode: true }) - const { emitPropertyValue, onPropertyValues } = options + const { emitPropertyValue, onPropertyValues, createDebug } = options options = { ...options } - options.subOptions = { ...options.subOptions, emitPropertyValue, onPropertyValues } + options.subOptions = { + ...options.subOptions, + emitPropertyValue, + onPropertyValues, + createDebug, + } options.subOptions.providerId = options.providerId const dataType = options.subOptions.dataType || options.type @@ -57,14 +61,18 @@ function Simple (options) { options.subOptions.type === 'canbus-canboatjs' ) { mappingType = 'NMEA2000JS' - } else if (options.subOptions.type === 'ikonvert-canboatjs' || - options.subOptions.type === 'navlink2-tcp-canboatjs' ) { + } else if ( + options.subOptions.type === 'ikonvert-canboatjs' || + options.subOptions.type === 'navlink2-tcp-canboatjs' + ) { mappingType = 'NMEA2000IK' - } else if (options.subOptions.type === 'ydwg02-canboatjs' || - options.subOptions.type === 'ydwg02-udp-canboatjs' || - options.subOptions.type === 'ydwg02-usb-canboatjs') { + } else if ( + options.subOptions.type === 'ydwg02-canboatjs' || + options.subOptions.type === 'ydwg02-udp-canboatjs' || + options.subOptions.type === 'ydwg02-usb-canboatjs' + ) { mappingType = 'NMEA2000YD' - } + } } const pipeline = [].concat( @@ -99,8 +107,8 @@ const getLogger = (app, logging, discriminator) => ? [ new Log({ app: app, - discriminator - }) + discriminator, + }), ] : [] @@ -111,73 +119,77 @@ const discriminatorByDataType = { NMEA2000: 'A', NMEA0183: 'N', SignalK: 'I', - Seatalk: 'N' + Seatalk: 'N', } const dataTypeMapping = { - SignalK: options => + SignalK: (options) => options.subOptions.type !== 'wss' && options.subOptions.type !== 'ws' ? [new FromJson(options.subOptions)] : [], - Seatalk: options => [new nmea0183_signalk({...options.subOptions, validateChecksum: false})], - NMEA0183: options => { + Seatalk: (options) => [ + new nmea0183_signalk({ ...options.subOptions, validateChecksum: false }), + ], + NMEA0183: (options) => { const result = [new nmea0183_signalk(options.subOptions)] if (options.type === 'FileStream') { result.unshift( new Throttle({ - rate: options.subOptions.throttleRate || 1000 + rate: options.subOptions.throttleRate || 1000, }) ) } return result }, - NMEA2000: options => { + NMEA2000: (options) => { const result = [new N2kAnalyzer(options.subOptions)] if (options.type === 'FileStream') { result.push(new TimestampThrottle()) } return result.concat([new N2kToSignalK(options.subOptions)]) }, - NMEA2000JS: options => { + NMEA2000JS: (options) => { const result = [new CanboatJs(options.subOptions)] if (options.type === 'FileStream') { result.push(new TimestampThrottle()) } return result.concat([new N2kToSignalK(options.subOptions)]) }, - NMEA2000IK: options => { + NMEA2000IK: (options) => { const result = [new CanboatJs(options.subOptions)] if (options.type === 'FileStream') { result.push( new TimestampThrottle({ - getMilliseconds: msg => { + getMilliseconds: (msg) => { return msg.timer * 1000 - } + }, }) ) } // else { let subOptions - if ( options.subOptions.type === 'navlink2-tcp-canboatjs' ) - { - subOptions = {...options.subOptions, tcp: true} - } - else - { + if (options.subOptions.type === 'navlink2-tcp-canboatjs') { + subOptions = { ...options.subOptions, tcp: true } + } else { subOptions = options.subOptions } result.unshift(new iKonvert(subOptions)) } return result.concat([new N2kToSignalK(options.subOptions)]) }, - NMEA2000YD: options => { - const result = [new Ydwg02(options.subOptions, options.subOptions.type === 'ydwg02-usb-canboatjs' ? 'usb' : 'network')] + NMEA2000YD: (options) => { + const result = [ + new Ydwg02( + options.subOptions, + options.subOptions.type === 'ydwg02-usb-canboatjs' ? 'usb' : 'network' + ), + ] if (options.type === 'FileStream') { result.push(new TimestampThrottle()) } return result.concat([new N2kToSignalK(options.subOptions)]) }, - Multiplexed: options => [new MultiplexedLog(options.subOptions)] + Multiplexed: (options) => [new MultiplexedLog(options.subOptions)], } const pipeStartByType = { @@ -186,28 +198,28 @@ const pipeStartByType = { Execute: executeInput, FileStream: fileInput, SignalK: signalKInput, - Seatalk: seatalkInput + Seatalk: seatalkInput, } -function nmea2000input (subOptions, logging) { +function nmea2000input(subOptions, logging) { if (subOptions.type === 'ngt-1-canboatjs') { const actisenseSerial = require('./actisense-serial') - if ( ! actisenseSerial ) { + if (!actisenseSerial) { throw new Error('unable to load actisense serial') } return [ new actisenseSerial({ ...subOptions, outEvent: 'nmea2000out', - plainText: logging - }) + plainText: logging, + }), ] } else if (subOptions.type === 'canbus-canboatjs') { return [ new require('./canbus')({ ...subOptions, canDevice: subOptions.interface, - }) + }), ] } else if (subOptions.type === 'ikonvert-canboatjs') { const serialport = require('./serialport') @@ -215,22 +227,28 @@ function nmea2000input (subOptions, logging) { new serialport({ ...subOptions, baudrate: 230400, - toStdout: 'ikonvertOut' - }) + toStdout: 'ikonvertOut', + }), ] } else if (subOptions.type === 'ydwg02-canboatjs') { - return [new Tcp({ - ...subOptions, - outEvent: 'ydwg02-out' - }), new Liner(subOptions)] + return [ + new Tcp({ + ...subOptions, + outEvent: 'ydwg02-out', + }), + new Liner(subOptions), + ] } else if (subOptions.type === 'ydwg02-udp-canboatjs') { return [new Udp(subOptions), new Liner(subOptions)] } else if (subOptions.type === 'navlink2-tcp-canboatjs') { - return [new Tcp({ - ...subOptions, - outEvent: 'navlink2-out' - }), new Liner(subOptions)] - } else if (subOptions.type === 'navlink2-udp-canboatjs' ) { + return [ + new Tcp({ + ...subOptions, + outEvent: 'navlink2-out', + }), + new Liner(subOptions), + ] + } else if (subOptions.type === 'navlink2-udp-canboatjs') { return [new Udp(subOptions), new Liner(subOptions)] } else if (subOptions.type === 'ydwg02-usb-canboatjs') { const serialport = require('./serialport') @@ -238,8 +256,8 @@ function nmea2000input (subOptions, logging) { new serialport({ ...subOptions, baudrate: 38400, - toStdout: 'ydwg02-out' - }) + toStdout: 'ydwg02-out', + }), ] } else { let command @@ -260,14 +278,14 @@ function nmea2000input (subOptions, logging) { command: command, toChildProcess: toChildProcess, app: subOptions.app, - providerId: subOptions.providerId + providerId: subOptions.providerId, }), - new Liner(subOptions) + new Liner(subOptions), ] } } -function nmea0183input (subOptions) { +function nmea0183input(subOptions) { let pipePart if (subOptions.type === 'tcp') { pipePart = [new Tcp(subOptions), new Liner(subOptions)] @@ -284,19 +302,23 @@ function nmea0183input (subOptions) { if (pipePart) { if (subOptions.removeNulls) { - pipePart.push(new Replacer({ - regexp: '\u0000', - template: '' - })) + pipePart.push( + new Replacer({ + regexp: '\u0000', + template: '', + }) + ) } if (subOptions.ignoredSentences) { console.log(subOptions.ignoredSentences) - subOptions.ignoredSentences.forEach(sentence => { + subOptions.ignoredSentences.forEach((sentence) => { if (sentence.length > 0) { - pipePart.push(new Replacer({ - regexp: `^...${sentence}.*`, - template: '' - })) + pipePart.push( + new Replacer({ + regexp: `^...${sentence}.*`, + template: '', + }) + ) } }) } @@ -306,17 +328,17 @@ function nmea0183input (subOptions) { } } -function executeInput (subOptions) { +function executeInput(subOptions) { return [new execute(subOptions), new Liner(subOptions)] } -function fileInput (subOptions) { +function fileInput(subOptions) { const result = [new FileStream(subOptions)] result.push(new Liner(subOptions)) return result } -function signalKInput (subOptions) { +function signalKInput(subOptions) { if (subOptions.type === 'ws' || subOptions.type === 'wss') { const mdns_ws = require('./mdns-ws') return [new mdns_ws(subOptions)] diff --git a/packages/streams/splitting-liner.js b/packages/streams/splitting-liner.js index f05a61922..4100dae8e 100644 --- a/packages/streams/splitting-liner.js +++ b/packages/streams/splitting-liner.js @@ -28,9 +28,9 @@ const Transform = require('stream').Transform require('util').inherits(SplittingLiner, Transform) -function SplittingLiner (options) { +function SplittingLiner(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.doPush = this.push.bind(this) this.lineSeparator = options.lineSeparator || '\n' diff --git a/packages/streams/tcp.js b/packages/streams/tcp.js index 42a80802b..a865df1ac 100644 --- a/packages/streams/tcp.js +++ b/packages/streams/tcp.js @@ -32,23 +32,25 @@ const net = require('net') const Transform = require('stream').Transform const isArray = require('lodash').isArray -const debug = require('debug')('signalk-server:streams:tcp') -const debugData = require('debug')('signalk-server:streams:tcp-data') - -function TcpStream (options) { +function TcpStream(options) { Transform.call(this, options) this.options = options - this.noDataReceivedTimeout = Number.parseInt((this.options.noDataReceivedTimeout + '').trim()) * 1000 + this.noDataReceivedTimeout = + Number.parseInt((this.options.noDataReceivedTimeout + '').trim()) * 1000 + this.debug = (options.createDebug || require('debug'))('signalk:streams:tcp') + this.debugData = (options.createDebug || require('debug'))( + 'signalk:streams:tcp-data' + ) } require('util').inherits(TcpStream, Transform) TcpStream.prototype.pipe = function (pipeTo) { - if ( this.options.outEvent ) { - const that = this + const that = this + if (this.options.outEvent) { that.options.app.on(that.options.outEvent, function (d) { - if ( that.tcpStream ) { - debug('sending %s', d) + if (that.tcpStream) { + that.debug('sending %s', d) that.tcpStream.write(d) } }) @@ -56,57 +58,57 @@ TcpStream.prototype.pipe = function (pipeTo) { const stdOutEvent = this.options.toStdout if (stdOutEvent) { - const that = this; //semicolon required here - (isArray(stdOutEvent) ? stdOutEvent : [stdOutEvent]).forEach(stdEvent => { - that.options.app.on(stdEvent, function (d) { - if (that.tcpStream) { - that.tcpStream.write(d + '\r\n') - debug('event %s sending %s', stdEvent, d) - } - }) - }) + const that = this //semicolon required here + ;(isArray(stdOutEvent) ? stdOutEvent : [stdOutEvent]).forEach( + (stdEvent) => { + that.options.app.on(stdEvent, function (d) { + if (that.tcpStream) { + that.tcpStream.write(d + '\r\n') + that.debug('event %s sending %s', stdEvent, d) + } + }) + } + ) } const re = require('reconnect-core')(function () { return net.connect.apply(null, arguments) - })({ maxDelay: 5 * 1000 }, tcpStream => { + })({ maxDelay: 5 * 1000 }, (tcpStream) => { if (!isNaN(this.noDataReceivedTimeout)) { tcpStream.setTimeout(this.noDataReceivedTimeout) - debug( + that.debug( `Setting socket idle timeout ${this.options.host}:${this.options.port} ${this.noDataReceivedTimeout}` ) tcpStream.on('timeout', () => { - debug( + that.debug( `Idle timeout, closing socket ${this.options.host}:${this.options.port}` ) tcpStream.end() }) } - tcpStream.on('data', data => { - if (debugData.enabled) { - debugData(data.toString()) + tcpStream.on('data', (data) => { + if (that.debugData.enabled) { + that.debugData(data.toString()) } this.write(data) }) }) - .on('connect', con => { + .on('connect', (con) => { this.tcpStream = con const msg = `Connected to ${this.options.host} ${this.options.port}` this.options.app.setProviderStatus(this.options.providerId, msg) - debug(msg) + that.debug(msg) }) .on('reconnect', (n, delay) => { - const msg = `Reconnect ${this.options.host} ${ - this.options.port - } retry ${n} delay ${delay}` + const msg = `Reconnect ${this.options.host} ${this.options.port} retry ${n} delay ${delay}` this.options.app.setProviderError(this.options.providerId, msg) - debug(msg) + that.debug(msg) }) - .on('disconnect', err => { + .on('disconnect', (err) => { delete this.tcpStream - debug(`Disconnected ${this.options.host} ${this.options.port}`) + that.debug(`Disconnected ${this.options.host} ${this.options.port}`) }) - .on('error', err => { + .on('error', (err) => { this.options.app.setProviderError(this.options.providerId, err.message) console.error('TcpProvider:' + err.message) }) diff --git a/packages/streams/tcpserver.js b/packages/streams/tcpserver.js index 4916ebfb7..c48e749c4 100644 --- a/packages/streams/tcpserver.js +++ b/packages/streams/tcpserver.js @@ -21,7 +21,7 @@ const Transform = require('stream').Transform -function TcpServer (options) { +function TcpServer(options) { Transform.call(this) this.options = options } @@ -29,7 +29,7 @@ function TcpServer (options) { require('util').inherits(TcpServer, Transform) TcpServer.prototype.pipe = function (pipeTo) { - this.options.app.on('tcpserver0183data', d => this.write(d)) + this.options.app.on('tcpserver0183data', (d) => this.write(d)) Transform.prototype.pipe.call(this, pipeTo) } diff --git a/packages/streams/timestamp-throttle.js b/packages/streams/timestamp-throttle.js index ce39bd6a4..3ee8d8e4c 100644 --- a/packages/streams/timestamp-throttle.js +++ b/packages/streams/timestamp-throttle.js @@ -23,9 +23,9 @@ so that throughput rate is real time. Aimed at canboat analyzer output rate control */ -function TimestampThrottle (options) { +function TimestampThrottle(options) { Transform.call(this, { - objectMode: true + objectMode: true, }) this.lastMsgMillis = new Date().getTime() this.getMilliseconds = @@ -55,7 +55,7 @@ TimestampThrottle.prototype._transform = function (msg, encoding, done) { } } -function getMilliseconds (msg) { +function getMilliseconds(msg) { // 2014-08-15-16:00:00.083 return moment(msg.timestamp, 'YYYY-MM-DD-HH:mm:ss.SSS').valueOf() } diff --git a/packages/streams/udp.js b/packages/streams/udp.js index e33939f91..72ead3ddd 100644 --- a/packages/streams/udp.js +++ b/packages/streams/udp.js @@ -34,13 +34,13 @@ */ const Transform = require('stream').Transform -const debug = require('debug')('signalk:streams:udp') -function Udp (options) { +function Udp(options) { Transform.call(this, { - objectMode: false + objectMode: false, }) this.options = options + this.debug = (options.createDebug || require('debug'))('signalk:streams:udp') } require('util').inherits(Udp, Transform) @@ -52,7 +52,7 @@ Udp.prototype.pipe = function (pipeTo) { const socket = require('dgram').createSocket('udp4') const self = this socket.on('message', function (message, remote) { - debug(message.toString()) + self.debug(message.toString()) self.push(message) }) socket.bind(this.options.port) diff --git a/raspberry_pi_installation.md b/raspberry_pi_installation.md index 1663a3fca..0f14b7aed 100644 --- a/raspberry_pi_installation.md +++ b/raspberry_pi_installation.md @@ -1,12 +1,12 @@ # Getting Started -If you already have a Raspberry Pi up and running go direct to [Installing Signal K.](https://github.com/SignalK/signalk-server/blob/master/raspberry_pi_installation.md#installing-signal-k) +If you are updating the Signal K server, especially moving from a version <= 1.40.0, [please check here.](https://github.com/SignalK/signalk-server/blob/master/raspberry_pi_installation.md#update-signal-k-node-server) -Instructions to install the operating system, Raspberry Pi OS, [is found here.](https://www.raspberrypi.org/documentation/computers/getting-started.html#setting-up-your-raspberry-pi) +Instructions to install the operating system, Raspberry Pi OS, [is found here.](https://www.raspberrypi.org/documentation/computers/getting-started.html#setting-up-your-raspberry-pi) Please use Buster OS! Bullseye, is not fully tested. -If you are familiar with a "headless install", with Raspberry Pi OS Lite, it's also possible since the GUI for Signal K is browser based. +If you are familiar with a "headless install" using Raspberry Pi OS Lite it's also possible since the GUI for Signal K is browser based. -After everything has been configured you should be presented with the RPi Desktop up and running, just waiting for you to install Signal K. +After everything has been configured, using the GUI install, you should be presented with the RPi Desktop up and running, just waiting for you to install Signal K. # Installing Signal K @@ -24,12 +24,19 @@ Raspbian, the Linux distribution for RPi, is based on Debian, which has a powerf Now, install node and npm - $ sudo apt install nodejs npm + $ curl -sL https://deb.nodesource.com/setup_16.x | sudo -E bash - + $ sudo apt-get install -y nodejs We want to make sure that we're using the latest version of npm: $ sudo npm install -g npm@latest +Use the following command to check the install + + node -v && npm -v + +which will report, something like, `v16.13.2, 8.3.1` which are the versions of "node" and "npm" + Finally we need to install a Bonjour (mDNS) service for Linux called Avahi, which allows Apps and other network devices to Discover the Signal K server. To do this we will use "apt" again ... $ sudo apt install libnss-mdns avahi-utils libavahi-compat-libdnssd-dev @@ -284,9 +291,10 @@ Click on that row and You will open next window Click on update and the installation will start -If below version 1.8.0 use this command instead +**Please note !** - $ sudo npm install -g signalk-server +Starting with Signal K server version 1.41.0 the recommended Node.js version is 16. [16 is the active LTS](https://nodejs.org/en/about/releases/) (Long Term Support) version in Jan 2022, with End of Life set at 2024-04-30. Node 10 is past its end of life and won't receive any (security) updates. +So if you are updating from a Signal K version <= V 1.40.0 [check out the Wiki](https://github.com/SignalK/signalk-server/wiki/Updating-to-Node.js-16) on how to. ![server_during_update](https://user-images.githubusercontent.com/16189982/51401178-71a9e400-1b4a-11e9-86b9-1148442ba59c.png) diff --git a/src/@types/geojson-validation.d.ts b/src/@types/geojson-validation.d.ts new file mode 100644 index 000000000..a2e92c5c9 --- /dev/null +++ b/src/@types/geojson-validation.d.ts @@ -0,0 +1 @@ +declare module 'geojson-validation' diff --git a/src/api/course/index.ts b/src/api/course/index.ts new file mode 100644 index 000000000..3d6d570c1 --- /dev/null +++ b/src/api/course/index.ts @@ -0,0 +1,757 @@ +import { createDebug } from '../../debug' +const debug = createDebug('signalk:courseApi') + +import { Application, Request, Response } from 'express' +import _ from 'lodash' +import path from 'path' +import { WithConfig, WithSecurityStrategy, WithSignalK } from '../../app' +import { Position } from '../../types' + +import { Responses } from '../' +import { Store } from '../../serverstate/store' +import { Route } from '../resources/types' + +const SIGNALK_API_PATH = `/signalk/v1/api` +const COURSE_API_PATH = `${SIGNALK_API_PATH}/vessels/self/navigation/course` + +interface CourseApplication + extends Application, + WithConfig, + WithSignalK, + WithSecurityStrategy { + resourcesApi: { + getResource: (resourceType: string, resourceId: string) => any + } +} + +interface DestinationBase { + href?: string + arrivalCircle?: number +} +interface Destination extends DestinationBase { + position?: Position + type?: string +} +interface ActiveRoute extends DestinationBase { + pointIndex?: number + reverse?: boolean +} + +interface CourseInfo { + activeRoute: { + href: string | null + startTime: string | null + pointIndex: number + pointTotal: number + reverse: boolean + } + nextPoint: { + href: string | null + type: string | null + position: Position | null + arrivalCircle: number + } + previousPoint: { + href: string | null + type: string | null + position: Position | null + } +} + +export class CourseApi { + private server: CourseApplication + + private courseInfo: CourseInfo = { + activeRoute: { + href: null, + startTime: null, + pointIndex: 0, + pointTotal: 0, + reverse: false + }, + nextPoint: { + href: null, + type: null, + position: null, + arrivalCircle: 0 + }, + previousPoint: { + href: null, + type: null, + position: null + } + } + + private store: Store + + constructor(app: CourseApplication) { + this.server = app + this.store = new Store( + path.join(app.config.configPath, 'serverstate/course') + ) + this.start(app).catch(error => { + console.log(error) + }) + } + + private getVesselPosition() { + return _.get((this.server.signalk as any).self, 'navigation.position') + } + + private async start(app: any) { + debug(`** Initialise ${COURSE_API_PATH} path handler **`) + this.initCourseRoutes() + + try { + const storeData = await this.store.read() + this.courseInfo = this.validateCourseInfo(storeData) + } catch (error) { + debug('** No persisted course data (using default) **') + } + debug(this.courseInfo) + this.emitCourseInfo(true) + } + + private validateCourseInfo(info: CourseInfo) { + if (info.activeRoute && info.nextPoint && info.previousPoint) { + return info + } else { + debug(`** Error: Loaded course data is invalid!! (using default) **`) + return this.courseInfo + } + } + + private updateAllowed(request: Request): boolean { + return this.server.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'navigation.course' + ) + } + + private initCourseRoutes() { + // return current course information + this.server.get( + `${COURSE_API_PATH}`, + async (req: Request, res: Response) => { + debug(`** GET ${COURSE_API_PATH}`) + res.json(this.courseInfo) + } + ) + + this.server.put( + `${COURSE_API_PATH}/restart`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/restart`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (!this.courseInfo.nextPoint.position) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `No active destination!` + }) + return + } + // set previousPoint to vessel position + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Vessel position unavailable!` + }) + } + } catch (err) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Vessel position unavailable!` + }) + } + } + ) + + this.server.put( + `${COURSE_API_PATH}/arrivalCircle`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/arrivalCircle`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (this.isValidArrivalCircle(req.body.value)) { + this.courseInfo.nextPoint.arrivalCircle = req.body.value + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + res.status(406).json(Responses.invalid) + } + } + ) + + // set destination + this.server.put( + `${COURSE_API_PATH}/destination`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/destination`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if (!req.body) { + debug(`** Error: req.body is null || undefined!`) + res.status(406).json(Responses.invalid) + return + } + const result = await this.setDestination(req.body) + if (result) { + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + this.clearDestination() + this.emitCourseInfo() + res.status(406).json(Responses.invalid) + } + } + ) + + // clear destination + this.server.delete( + `${COURSE_API_PATH}/destination`, + async (req: Request, res: Response) => { + debug(`** DELETE ${COURSE_API_PATH}/destination`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + this.clearDestination() + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + + // set activeRoute + this.server.put( + `${COURSE_API_PATH}/activeRoute`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/activeRoute`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + const result = await this.activateRoute(req.body) + if (result) { + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } else { + this.clearDestination() + this.emitCourseInfo() + res.status(406).json(Responses.invalid) + } + } + ) + + // clear activeRoute /destination + this.server.delete( + `${COURSE_API_PATH}/activeRoute`, + async (req: Request, res: Response) => { + debug(`** DELETE ${COURSE_API_PATH}/activeRoute`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + this.clearDestination() + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + + this.server.put( + `${COURSE_API_PATH}/activeRoute/:action`, + async (req: Request, res: Response) => { + debug(`** PUT ${COURSE_API_PATH}/activeRoute/${req.params.action}`) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + // fetch active route data + if (!this.courseInfo.activeRoute.href) { + res.status(406).json(Responses.invalid) + return + } + const rte = await this.getRoute(this.courseInfo.activeRoute.href) + if (!rte) { + res.status(406).json(Responses.invalid) + return + } + + if (req.params.action === 'nextPoint') { + if ( + typeof req.body.value === 'number' && + (req.body.value === 1 || req.body.value === -1) + ) { + this.courseInfo.activeRoute.pointIndex = this.parsePointIndex( + this.courseInfo.activeRoute.pointIndex + req.body.value, + rte + ) + } else { + res.status(406).json(Responses.invalid) + return + } + } + + if (req.params.action === 'pointIndex') { + if (typeof req.body.value === 'number') { + this.courseInfo.activeRoute.pointIndex = this.parsePointIndex( + req.body.value, + rte + ) + } else { + res.status(406).json(Responses.invalid) + return + } + } + // reverse direction from current point + if (req.params.action === 'reverse') { + if (typeof req.body.pointIndex === 'number') { + this.courseInfo.activeRoute.pointIndex = req.body.pointIndex + } else { + this.courseInfo.activeRoute.pointIndex = this.calcReversedIndex() + } + this.courseInfo.activeRoute.reverse = !this.courseInfo.activeRoute + .reverse + } + + if (req.params.action === 'refresh') { + this.courseInfo.activeRoute.pointTotal = + rte.feature.geometry.coordinates.length + let idx = -1 + for (let i = 0; i < rte.feature.geometry.coordinates.length; i++) { + if ( + rte.feature.geometry.coordinates[i][0] === + this.courseInfo.nextPoint.position?.longitude && + rte.feature.geometry.coordinates[i][1] === + this.courseInfo.nextPoint.position?.latitude + ) { + idx = i + } + } + if (idx !== -1) { + this.courseInfo.activeRoute.pointIndex = idx + } + this.emitCourseInfo() + res.status(200).json(Responses.ok) + return + } + + // set new destination + this.courseInfo.nextPoint.position = this.getRoutePoint( + rte, + this.courseInfo.activeRoute.pointIndex, + this.courseInfo.activeRoute.reverse + ) + this.courseInfo.nextPoint.type = `RoutePoint` + this.courseInfo.nextPoint.href = null + + // set previousPoint + if (this.courseInfo.activeRoute.pointIndex === 0) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.courseInfo.previousPoint.type = `VesselPosition` + } else { + res.status(406).json(Responses.invalid) + return false + } + } catch (err) { + console.log(`** Error: unable to retrieve vessel position!`) + res.status(406).json(Responses.invalid) + return false + } + } else { + this.courseInfo.previousPoint.position = this.getRoutePoint( + rte, + this.courseInfo.activeRoute.pointIndex - 1, + this.courseInfo.activeRoute.reverse + ) + this.courseInfo.previousPoint.type = `RoutePoint` + } + this.courseInfo.previousPoint.href = null + this.emitCourseInfo() + res.status(200).json(Responses.ok) + } + ) + } + + private calcReversedIndex(): number { + return ( + this.courseInfo.activeRoute.pointTotal - + 1 - + this.courseInfo.activeRoute.pointIndex + ) + } + + private async activateRoute(route: ActiveRoute): Promise { + let rte: any + + if (route.href) { + rte = await this.getRoute(route.href) + if (!rte) { + console.log(`** Could not retrieve route information for ${route.href}`) + return false + } + if (!Array.isArray(rte.feature?.geometry?.coordinates)) { + debug(`** Invalid route coordinate data! (${route.href})`) + return false + } + } else { + return false + } + + const newCourse: CourseInfo = { ...this.courseInfo } + + // set activeroute + newCourse.activeRoute.href = route.href + + if (this.isValidArrivalCircle(route.arrivalCircle as number)) { + newCourse.nextPoint.arrivalCircle = route.arrivalCircle as number + } + + newCourse.activeRoute.startTime = new Date().toISOString() + + if (typeof route.reverse === 'boolean') { + newCourse.activeRoute.reverse = route.reverse + } + + newCourse.activeRoute.pointIndex = this.parsePointIndex( + route.pointIndex as number, + rte + ) + newCourse.activeRoute.pointTotal = rte.feature.geometry.coordinates.length + + // set nextPoint + newCourse.nextPoint.position = this.getRoutePoint( + rte, + newCourse.activeRoute.pointIndex, + newCourse.activeRoute.reverse + ) + newCourse.nextPoint.type = `RoutePoint` + newCourse.nextPoint.href = null + + // set previousPoint + if (newCourse.activeRoute.pointIndex === 0) { + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + this.courseInfo.previousPoint.position = position.value + this.courseInfo.previousPoint.type = `VesselPosition` + } else { + console.log(`** Error: unable to retrieve vessel position!`) + return false + } + } catch (err) { + return false + } + } else { + newCourse.previousPoint.position = this.getRoutePoint( + rte, + newCourse.activeRoute.pointIndex - 1, + newCourse.activeRoute.reverse + ) + newCourse.previousPoint.type = `RoutePoint` + } + newCourse.previousPoint.href = null + + this.courseInfo = newCourse + return true + } + + private async setDestination(dest: Destination): Promise { + const newCourse: CourseInfo = { ...this.courseInfo } + + // set nextPoint + if (this.isValidArrivalCircle(dest.arrivalCircle)) { + newCourse.nextPoint.arrivalCircle = dest.arrivalCircle as number + } + + newCourse.nextPoint.type = + typeof dest.type !== 'undefined' ? dest.type : null + + if (dest.href) { + const href = this.parseHref(dest.href) + if (href) { + // fetch waypoint resource details + try { + const r = await this.server.resourcesApi.getResource( + href.type, + href.id + ) + if ( + typeof r.position?.latitude !== 'undefined' && + typeof r.position?.longitude !== 'undefined' + ) { + newCourse.nextPoint.position = r.position + newCourse.nextPoint.href = dest.href + newCourse.nextPoint.type = 'Waypoint' + } else { + debug(`** Invalid waypoint coordinate data! (${dest.href})`) + return false + } + } catch (err) { + console.log( + `** Could not retrieve waypoint information for ${dest.href}` + ) + return false + } + } else { + debug(`** Invalid href! (${dest.href})`) + return false + } + } else if (dest.position) { + newCourse.nextPoint.href = null + newCourse.nextPoint.type = 'Location' + if (typeof dest.position.latitude !== 'undefined') { + newCourse.nextPoint.position = dest.position + } else { + debug(`** Error: position.latitude is undefined!`) + return false + } + } else { + return false + } + + // clear activeRoute values + newCourse.activeRoute.href = null + newCourse.activeRoute.startTime = null + newCourse.activeRoute.pointIndex = 0 + newCourse.activeRoute.pointTotal = 0 + newCourse.activeRoute.reverse = false + + // set previousPoint + try { + const position: any = this.getVesselPosition() + if (position && position.value) { + newCourse.previousPoint.position = position.value + newCourse.previousPoint.type = `VesselPosition` + newCourse.previousPoint.href = null + } else { + debug(`** Error: navigation.position.value is undefined! (${position})`) + return false + } + } catch (err) { + console.log(`** Error: unable to retrieve vessel position!`) + return false + } + + this.courseInfo = newCourse + return true + } + + private clearDestination() { + this.courseInfo.activeRoute.href = null + this.courseInfo.activeRoute.startTime = null + this.courseInfo.activeRoute.pointIndex = 0 + this.courseInfo.activeRoute.pointTotal = 0 + this.courseInfo.activeRoute.reverse = false + this.courseInfo.nextPoint.href = null + this.courseInfo.nextPoint.type = null + this.courseInfo.nextPoint.position = null + this.courseInfo.previousPoint.href = null + this.courseInfo.previousPoint.type = null + this.courseInfo.previousPoint.position = null + } + + private isValidArrivalCircle(value: number | undefined): boolean { + return typeof value === 'number' && value >= 0 + } + + private parsePointIndex(index: number, rte: any): number { + if (typeof index !== 'number' || !rte) { + return 0 + } + if (!rte.feature?.geometry?.coordinates) { + return 0 + } + if (!Array.isArray(rte.feature?.geometry?.coordinates)) { + return 0 + } + if (index < 0) { + return 0 + } + if (index > rte.feature?.geometry?.coordinates.length - 1) { + return rte.feature?.geometry?.coordinates.length - 1 + } + return index + } + + private parseHref(href: string): { type: string; id: string } | undefined { + if (!href) { + return undefined + } + + const ref: string[] = href.split('/').slice(-3) + if (ref.length < 3) { + return undefined + } + if (ref[0] !== 'resources') { + return undefined + } + return { + type: ref[1], + id: ref[2] + } + } + + private getRoutePoint(rte: any, index: number, reverse: boolean) { + const pos = reverse + ? rte.feature.geometry.coordinates[ + rte.feature.geometry.coordinates.length - (index + 1) + ] + : rte.feature.geometry.coordinates[index] + return { + latitude: pos[1], + longitude: pos[0], + altitude: pos.length === 3 ? pos[2] : 0 + } + } + + private async getRoute(href: string): Promise { + const h = this.parseHref(href) + if (h) { + try { + return await this.server.resourcesApi.getResource(h.type, h.id) + } catch (err) { + debug(`** Unable to fetch resource: ${h.type}, ${h.id}`) + return undefined + } + } else { + debug(`** Unable to parse href: ${href}`) + return undefined + } + } + + private buildDeltaMsg(): any { + const values: Array<{ path: string; value: any }> = [] + const navPath = [ + 'navigation.courseGreatCircle', + 'navigation.courseRhumbline' + ] + + let course = null + if (this.courseInfo.activeRoute.href) { + course = this.courseInfo + } else if (this.courseInfo.nextPoint.position) { + course = { + nextPoint: this.courseInfo.nextPoint, + previousPoint: this.courseInfo.previousPoint + } + } + + debug(course) + + values.push({ + path: `navigation.course`, + value: course + }) + + values.push({ + path: `${navPath[0]}.activeRoute.href`, + value: this.courseInfo.activeRoute.href + }) + values.push({ + path: `${navPath[1]}.activeRoute.href`, + value: this.courseInfo.activeRoute.href + }) + values.push({ + path: `${navPath[0]}.activeRoute.startTime`, + value: this.courseInfo.activeRoute.startTime + }) + values.push({ + path: `${navPath[1]}.activeRoute.startTime`, + value: this.courseInfo.activeRoute.startTime + }) + values.push({ + path: `${navPath[0]}.nextPoint.href`, + value: this.courseInfo.nextPoint.href + }) + values.push({ + path: `${navPath[1]}.nextPoint.href`, + value: this.courseInfo.nextPoint.href + }) + values.push({ + path: `${navPath[0]}.nextPoint.position`, + value: this.courseInfo.nextPoint.position + }) + values.push({ + path: `${navPath[1]}.nextPoint.position`, + value: this.courseInfo.nextPoint.position + }) + values.push({ + path: `${navPath[0]}.nextPoint.type`, + value: this.courseInfo.nextPoint.type + }) + values.push({ + path: `${navPath[1]}.nextPoint.type`, + value: this.courseInfo.nextPoint.type + }) + values.push({ + path: `${navPath[0]}.nextPoint.arrivalCircle`, + value: this.courseInfo.nextPoint.arrivalCircle + }) + values.push({ + path: `${navPath[1]}.nextPoint.arrivalCircle`, + value: this.courseInfo.nextPoint.arrivalCircle + }) + values.push({ + path: `${navPath[0]}.previousPoint.href`, + value: this.courseInfo.previousPoint.href + }) + values.push({ + path: `${navPath[1]}.previousPoint.href`, + value: this.courseInfo.previousPoint.href + }) + values.push({ + path: `${navPath[0]}.previousPoint.position`, + value: this.courseInfo.previousPoint.position + }) + values.push({ + path: `${navPath[1]}.previousPoint.position`, + value: this.courseInfo.previousPoint.position + }) + values.push({ + path: `${navPath[0]}.previousPoint.type`, + value: this.courseInfo.previousPoint.type + }) + values.push({ + path: `${navPath[1]}.previousPoint.type`, + value: this.courseInfo.previousPoint.type + }) + + return { + updates: [ + { + values + } + ] + } + } + + private emitCourseInfo(noSave = false) { + this.server.handleMessage('courseApi', this.buildDeltaMsg()) + if (!noSave) { + this.store.write(this.courseInfo).catch(error => { + console.log(error) + }) + } + } +} diff --git a/src/api/course/openApi.json b/src/api/course/openApi.json new file mode 100644 index 000000000..6fde0b6cd --- /dev/null +++ b/src/api/course/openApi.json @@ -0,0 +1,625 @@ +{ + "openapi": "3.0.2", + "info": { + "version": "1.0.0", + "title": "Signal K Course API" + }, + + "paths": { + + "/vessels/self/navigation/course/": { + "get": { + "tags": ["course"], + "summary": "Get course information", + "responses": { + "default": { + "description": "Course data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "activeRoute": { + "type": "object", + "properties": { + "href": { + "type": "string", + "example": "/resources/routes/urn:mrn:signalk:uuid:0d95e282-3e1f-4521-8c30-8288addbdb69" + }, + "startTime": { + "type": "string", + "example": "2021-10-23T05:17:20.065Z" + }, + "pointIndex": { + "type": "number", + "format": "int64", + "example": 2 + }, + "pointTotal": { + "type": "number", + "format": "int64", + "example": 9 + }, + "reverse": { + "type": "boolean", + "default": false + } + } + }, + "nextPoint": { + "type": "object", + "properties": { + "href": { + "type": "string", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:0d95e282-3e1f-4521-8c30-8288addbdbab" + }, + "type": { + "type": "string", + "example": "RoutePoint" + }, + "position": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + }, + "example": {"latitude":-29.5,"longitude":137.5} + } + } + }, + "previousPoint": { + "type": "object", + "properties": { + "href": { + "type": "string", + "example": null + }, + "type": { + "type": "string", + "example": "Location" + }, + "position": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + }, + "example": {"longitude":29.821001582434413,"latitude":70.7014589462524} + } + } + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/restart": { + "put": { + "tags": ["course"], + "summary": "Restart course calculations", + "description": "Sets previousPoint value to current vessel location", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/destination": { + "put": { + "tags": ["course/destination"], + "summary": "Set destination", + "description": "Set destination path from supplied details", + "requestBody": { + "description": "Destination details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "position": { + "type": "object", + "description": "Destination position", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + }, + "example": {"latitude":-29.5,"longitude":137.5} + }, + "href": { + "type": "string", + "description": "A reference (URL) to an object (under /resources) this point is related to", + "example": "/resources/waypoints/urn:mrn:signalk:uuid:3dd34dcc-36bf-4d61-ba80-233799b25672" + }, + "type": { + "type": "string", + "description": "Type of point", + "example": "POI" + }, + "arrivalCircle": { + "type": "number", + "format": "float", + "example": 500 + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "tags": ["course/destination"], + "summary": "Clear destination", + "description": "Sets activeRoute, nextPoint & previousPoint values to null", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/arrivalCircle": { + "put": { + "tags": ["course"], + "summary": "Set arrival circle radius (m)", + "description": "Sets an arrival circle radius (in meters)", + "requestBody": { + "description": "Arrival circle payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "format": "float", + "example": 500 + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/activeRoute": { + "put": { + "tags": ["course/activeRoute"], + "summary": "Set active route", + "description": "Sets activeRoute path and sets nextPoint to first point in the route", + "requestBody": { + "description": "Active route details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "href": { + "type": "string", + "description": "Path to route resource", + "example": "/resources/routes/urn:mrn:signalk:uuid:3dd34dcc-36bf-4d61-ba80-233799b25672" + }, + "pointIndex": { + "type": "number", + "format": "int64", + "description": "Zero based index of route point to use as destination", + "default": 0, + "minimum": 0, + "example": 2 + }, + "reverse": { + "type": "boolean", + "default": false, + "description": "If true performs operations on route points in reverse order", + "example": 2 + }, + "arrivalCircle": { + "type": "number", + "format": "float", + "example": 500 + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "tags": ["course/activeRoute"], + "summary": "Clear active route", + "description": "Sets activeRoute, nextPoint & previousPoint values to null", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/activeRoute/nextPoint": { + "put": { + "tags": ["course/activeRoute"], + "summary": "Increment / decrement point in route as destination", + "description": "Increment / decrement point in the route as destination", + "requestBody": { + "description": "Increment / decrement", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "description": "increment (1) / decrement (-1) index of point in route to use as destination", + "enum": [-1,1], + "example": -1 + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/activeRoute/pointIndex": { + "put": { + "tags": ["course/activeRoute"], + "summary": "Set point in route as destination", + "description": "Sets the specified point in the route as destination", + "requestBody": { + "description": "Next point index", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "description": "Index of point in route to use as destination", + "minimum": 0, + "example": 2 + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/activeRoute/refresh": { + "put": { + "tags": ["course/activeRoute"], + "summary": "Refresh route details.", + "description": "Use after active route is modified to refresh pointIndex and pointsTotal values.", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/vessels/self/navigation/course/activeRoute/reverse": { + "put": { + "tags": ["course/activeRoute"], + "summary": "Reverse direction of route", + "description": "Reverse direction of route from current point or from supplied pointIndex.", + "requestBody": { + "description": "Reverse route", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pointIndex": { + "type": "number", + "description": "Index of point in route to use as destination", + "minimum": 0, + "example": 2 + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + + } + +} + + + + + \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 000000000..584c3645a --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,43 @@ +export interface ApiResponse { + state: 'FAILED' | 'COMPLETED' | 'PENDING' + statusCode: number + message: string + requestId?: string + href?: string + token?: string +} + +export const Responses = { + ok: { + state: 'COMPLETED', + statusCode: 200, + message: 'OK' + }, + invalid: { + state: 'FAILED', + statusCode: 406, + message: `Invalid Data supplied.` + }, + unauthorised: { + state: 'FAILED', + statusCode: 403, + message: 'Unauthorised' + }, + notFound: { + state: 'FAILED', + statusCode: 404, + message: 'Resource not found.' + } +} + +// returns true if target path is an API request +export function isApiRequest(path: string): boolean { + if ( + path.split('/')[4] === 'resources' || // resources API + path.indexOf('/navigation/course/') !== -1 // course API + ) { + return true + } else { + return false + } +} diff --git a/src/api/resources/index.ts b/src/api/resources/index.ts new file mode 100644 index 000000000..80f15e95e --- /dev/null +++ b/src/api/resources/index.ts @@ -0,0 +1,579 @@ +import { createDebug } from '../../debug' +const debug = createDebug('signalk:resourcesApi') + +import { + ResourceProvider, + ResourceProviderMethods, + SignalKResourceType +} from '@signalk/server-api' + +import { Application, NextFunction, Request, Response } from 'express' +import { v4 as uuidv4 } from 'uuid' +import { WithSecurityStrategy, WithSignalK } from '../../app' + +import { Responses } from '../' +import { buildResource } from './resources' +import { validate } from './validate' + +const SIGNALK_API_PATH = `/signalk/v1/api` +const UUID_PREFIX = 'urn:mrn:signalk:uuid:' + +interface ResourceApplication + extends Application, + WithSignalK, + WithSecurityStrategy {} + +export class Resources { + private resProvider: { [key: string]: ResourceProviderMethods | null } = {} + private server: ResourceApplication + + private signalkResTypes: SignalKResourceType[] = [ + 'routes', + 'waypoints', + 'notes', + 'regions', + 'charts' + ] + + constructor(app: ResourceApplication) { + this.server = app + this.start(app) + } + + register(pluginId: string, provider: ResourceProvider) { + debug(`** Registering provider(s)....${provider?.types}`) + if (!provider) { + return + } + if (provider.types && !Array.isArray(provider.types)) { + return + } + provider.types.forEach((i: string) => { + if (!this.resProvider[i]) { + if ( + !provider.methods.listResources || + !provider.methods.getResource || + !provider.methods.setResource || + !provider.methods.deleteResource || + typeof provider.methods.listResources !== 'function' || + typeof provider.methods.getResource !== 'function' || + typeof provider.methods.setResource !== 'function' || + typeof provider.methods.deleteResource !== 'function' + ) { + console.error( + `Error: Could not register Resource Provider for ${i.toUpperCase()} due to missing provider methods!` + ) + return + } else { + provider.methods.pluginId = pluginId + this.resProvider[i] = provider.methods + } + } + }) + debug(this.resProvider) + } + + unRegister(pluginId: string) { + if (!pluginId) { + return + } + debug(`** Un-registering ${pluginId} resource provider(s)....`) + for (const resourceType in this.resProvider) { + if (this.resProvider[resourceType]?.pluginId === pluginId) { + debug(`** Un-registering ${resourceType}....`) + delete this.resProvider[resourceType] + } + } + debug(JSON.stringify(this.resProvider)) + } + + getResource(resType: SignalKResourceType, resId: string) { + debug(`** getResource(${resType}, ${resId})`) + if (!this.checkForProvider(resType)) { + return Promise.reject(new Error(`No provider for ${resType}`)) + } + return this.resProvider[resType]?.getResource(resType, resId) + } + + private start(app: any) { + debug(`** Initialise ${SIGNALK_API_PATH}/resources path handler **`) + this.server = app + this.initResourceRoutes() + } + + private updateAllowed(req: Request): boolean { + return this.server.securityStrategy.shouldAllowPut( + req, + 'vessels.self', + null, + 'resources' + ) + } + + private initResourceRoutes() { + // list all serviced paths under resources + this.server.get( + `${SIGNALK_API_PATH}/resources`, + (req: Request, res: Response) => { + res.json(this.getResourcePaths()) + } + ) + + // facilitate retrieval of a specific resource + this.server.get( + `${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.getResource(req.params.resourceType, req.params.resourceId) + res.json(retVal) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Resource not found! (${req.params.resourceId})` + }) + } + } + ) + + // facilitate retrieval of a collection of resource entries + this.server.get( + `${SIGNALK_API_PATH}/resources/:resourceType`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** GET ${SIGNALK_API_PATH}/resources/:resourceType`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.listResources(req.params.resourceType, req.query) + res.json(retVal) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Error retrieving resources!` + }) + } + } + ) + + // facilitate creation of new resource entry of supplied type + this.server.post( + `${SIGNALK_API_PATH}/resources/:resourceType`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** POST ${SIGNALK_API_PATH}/resources/:resourceType`) + + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if ( + this.signalkResTypes.includes( + req.params.resourceType as SignalKResourceType + ) + ) { + if (!validate.resource(req.params.resourceType, req.body)) { + res.status(406).json(Responses.invalid) + return + } + } + + let id: string + if (req.params.resourceType === 'charts') { + id = req.body.identifier + } else { + id = UUID_PREFIX + uuidv4() + } + + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.setResource(req.params.resourceType, id, req.body) + + this.server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + id, + req.body + ) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: id + }) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Error saving ${req.params.resourceType} resource (${id})!` + }) + } + } + ) + + // facilitate creation / update of resource entry at supplied id + this.server.put( + `${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug(`** PUT ${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + if ( + this.signalkResTypes.includes( + req.params.resourceType as SignalKResourceType + ) + ) { + let isValidId: boolean + if (req.params.resourceType === 'charts') { + isValidId = validate.chartId(req.params.resourceId) + } else { + isValidId = validate.uuid(req.params.resourceId) + } + if (!isValidId) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Invalid resource id provided (${req.params.resourceId})` + }) + return + } + + debug('req.body') + debug(req.body) + if (!validate.resource(req.params.resourceType, req.body)) { + res.status(406).json(Responses.invalid) + return + } + } + + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.setResource( + req.params.resourceType, + req.params.resourceId, + req.body + ) + + this.server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + req.body + ) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: req.params.resourceId + }) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `Error saving ${req.params.resourceType} resource (${req.params.resourceId})!` + }) + } + } + ) + + // facilitate deletion of specific of resource entry at supplied id + this.server.delete( + `${SIGNALK_API_PATH}/resources/:resourceType/:resourceId`, + async (req: Request, res: Response, next: NextFunction) => { + debug( + `** DELETE ${SIGNALK_API_PATH}/resources/:resourceType/:resourceId` + ) + if ( + !this.checkForProvider(req.params.resourceType as SignalKResourceType) + ) { + debug('** No provider found... calling next()...') + next() + return + } + + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + try { + const retVal = await this.resProvider[ + req.params.resourceType + ]?.deleteResource(req.params.resourceType, req.params.resourceId) + + this.server.handleMessage( + this.resProvider[req.params.resourceType]?.pluginId as string, + this.buildDeltaMsg( + req.params.resourceType as SignalKResourceType, + req.params.resourceId, + null + ) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: req.params.resourceId + }) + } catch (err) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Error deleting resource (${req.params.resourceId})!` + }) + } + } + ) + + // facilitate API requests + this.server.post( + `${SIGNALK_API_PATH}/resources/set/:resourceType`, + async (req: Request, res: Response) => { + debug(`** POST ${SIGNALK_API_PATH}/resources/set/:resourceType`) + + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + + const apiData = this.processApiRequest(req) + debug(apiData) + + if (!this.checkForProvider(apiData.type)) { + res.status(501).json({ + state: 'FAILED', + statusCode: 501, + message: `No provider for ${apiData.type}!` + }) + return + } + if (!apiData.value) { + res.status(406).json(Responses.invalid) + return + } + if (apiData.type === 'charts') { + if (!validate.chartId(apiData.id)) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Invalid chart resource id supplied!` + }) + return + } + } else { + if (!validate.uuid(apiData.id)) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Invalid resource id supplied!` + }) + return + } + } + + try { + await this.resProvider[apiData.type]?.setResource( + apiData.type, + apiData.id, + apiData.value + ) + this.server.handleMessage( + this.resProvider[apiData.type]?.pluginId as string, + this.buildDeltaMsg(apiData.type, apiData.id, apiData.value) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: apiData.id + }) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `ERROR: Could not create ${req.params.resourceType} resource!` + }) + } + } + ) + this.server.put( + `${SIGNALK_API_PATH}/resources/set/:resourceType/:resourceId`, + async (req: Request, res: Response) => { + debug( + `** PUT ${SIGNALK_API_PATH}/resources/set/:resourceType/:resourceId` + ) + + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + return + } + + const apiData = this.processApiRequest(req) + + if (!this.checkForProvider(apiData.type)) { + res.status(501).json({ + state: 'FAILED', + statusCode: 501, + message: `No provider for ${apiData.type}!` + }) + return + } + if (!apiData.value) { + res.status(406).json(Responses.invalid) + return + } + if (apiData.type === 'charts') { + if (!validate.chartId(apiData.id)) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Invalid chart resource id supplied!` + }) + return + } + } else { + if (!validate.uuid(apiData.id)) { + res.status(406).json({ + state: 'FAILED', + statusCode: 406, + message: `Invalid resource id supplied!` + }) + return + } + } + + try { + await this.resProvider[apiData.type]?.setResource( + apiData.type, + apiData.id, + apiData.value + ) + this.server.handleMessage( + this.resProvider[apiData.type]?.pluginId as string, + this.buildDeltaMsg(apiData.type, apiData.id, apiData.value) + ) + res.status(200).json({ + state: 'COMPLETED', + statusCode: 200, + message: apiData.id + }) + } catch (err) { + res.status(404).json({ + state: 'FAILED', + statusCode: 404, + message: `ERROR: ${req.params.resourceType}/${apiData.id} could not be updated!` + }) + } + } + ) + } + + private processApiRequest(req: Request) { + const apiReq: any = { + type: undefined, + id: undefined, + value: undefined + } + + if (req.params.resourceType.toLowerCase() === 'waypoint') { + apiReq.type = 'waypoints' + } + if (req.params.resourceType.toLowerCase() === 'route') { + apiReq.type = 'routes' + } + if (req.params.resourceType.toLowerCase() === 'note') { + apiReq.type = 'notes' + } + if (req.params.resourceType.toLowerCase() === 'region') { + apiReq.type = 'regions' + } + if (req.params.resourceType.toLowerCase() === 'charts') { + apiReq.type = 'charts' + } + + apiReq.value = buildResource(apiReq.type, req.body) + + apiReq.id = req.params.resourceId + ? req.params.resourceId + : apiReq.type === 'charts' + ? apiReq.value.identifier + : UUID_PREFIX + uuidv4() + + return apiReq + } + + private getResourcePaths(): { [key: string]: any } { + const resPaths: { [key: string]: any } = {} + for (const i in this.resProvider) { + if (this.resProvider.hasOwnProperty(i)) { + resPaths[i] = { + description: `Path containing ${ + i.slice(-1) === 's' ? i.slice(0, i.length - 1) : i + } resources`, + $source: this.resProvider[i]?.pluginId + } + } + } + return resPaths + } + + private checkForProvider(resType: SignalKResourceType): boolean { + debug(`** checkForProvider(${resType})`) + debug(this.resProvider[resType]) + return this.resProvider[resType] ? true : false + } + + private buildDeltaMsg( + resType: SignalKResourceType, + resid: string, + resValue: any + ): any { + return { + updates: [ + { + values: [ + { + path: `resources.${resType}.${resid}`, + value: resValue + } + ] + } + ] + } + } +} diff --git a/src/api/resources/openApi.json b/src/api/resources/openApi.json new file mode 100644 index 000000000..e70e60d87 --- /dev/null +++ b/src/api/resources/openApi.json @@ -0,0 +1,2505 @@ +{ + "openapi": "3.0.2", + "info": { + "version": "1.0.0", + "title": "Signal K Resources API" + }, + + "paths": { + + "/resources": { + "get": { + "tags": ["resources"], + "summary": "List available resource types", + "responses": { + "200": { + "description": "List of available resource types", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + + "/resources/{resourceType}": { + "get": { + "tags": ["resources"], + "summary": "Retrieve resources", + "parameters": [ + { + "name": "resourceType", + "in": "path", + "description": "Type of resources to retrieve. Valid values are: routes, waypoints, notes, regions, charts", + "required": true, + "schema": { + "type": "string", + "enum": ["routes", "waypoints", "notes", "regions", "charts"], + "example": "waypoints" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of records to return", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1, + "example": 100 + } + }, + { + "name": "distance", + "in": "query", + "description": "Limit results to resources that fall within a square area, centered around the vessel's position, the edges of which are the sepecified distance in meters from the vessel.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 100, + "example": 2000 + } + }, + { + "name": "bbox", + "in": "query", + "description": "Limit results to resources that fall within the bounded area defined as lower left (south west) and upper right (north east) coordinates [swlon,swlat,nelon,nelat]", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number", + "format": "float", + "example": [135.5,-25.2,138.1,-28.0] + } + } + } + ], + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, + + "/resources/routes/": { + "post": { + "tags": ["resources/routes"], + "summary": "Add a new Route", + "requestBody": { + "description": "Route details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Route resource", + "required": ["feature"], + "properties": { + "name": { + "type": "string", + "description": "Route's common name" + }, + "description": { + "type": "string", + "description": "A description of the route" + }, + "distance": { + "description": "Total distance from start to end", + "type": "number" + }, + "start": { + "description": "The waypoint UUID at the start of the route", + "type": "string", + "pattern": "/resources/waypoints/urn:mrn:signalk:uuid:[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}$" + }, + "end": { + "description": "The waypoint UUID at the end of the route", + "type": "string", + "pattern": "/resources//waypoints/urn:mrn:signalk:uuid:[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}$" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a route", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["LineString"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/routes/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "route id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[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}$" + } + }, + + "get": { + "tags": ["resources/routes"], + "summary": "Retrieve route with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/routes"], + "summary": "Add / update a new Route with supplied id", + "requestBody": { + "description": "Route details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Route resource", + "required": ["feature"], + "properties": { + "name": { + "type": "string", + "description": "Route's common name" + }, + "description": { + "type": "string", + "description": "A description of the route" + }, + "distance": { + "description": "Total distance from start to end", + "type": "number" + }, + "start": { + "description": "The waypoint UUID at the start of the route", + "type": "string", + "pattern": "/resources/waypoints/urn:mrn:signalk:uuid:[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}$" + }, + "end": { + "description": "The waypoint UUID at the end of the route", + "type": "string", + "pattern": "/resources/waypoints/urn:mrn:signalk:uuid:[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}$" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a route", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["LineString"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/routes"], + "summary": "Remove Route with supplied id", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + + }, + + "/resources/waypoints/": { + "post": { + "tags": ["resources/waypoints"], + "summary": "Add a new Waypoint", + "requestBody": { + "description": "Waypoint details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Waypoint resource", + "required": ["feature"], + "properties": { + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a waypoint", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Point"] + } + }, + "coordinates": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/waypoints/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "waypoint id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[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}$" + } + }, + + "get": { + "tags": ["resources/waypoints"], + "summary": "Retrieve waypoint with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/waypoints"], + "summary": "Add / update a new Waypoint with supplied id", + "requestBody": { + "description": "Waypoint details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Waypoint resource", + "required": ["feature"], + "properties": { + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A GeoJSON feature object which describes a waypoint", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Point"] + } + }, + "coordinates": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/waypoints"], + "summary": "Remove Waypoint with supplied id", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + + }, + + "/resources/notes/": { + "post": { + "tags": ["resources/notes"], + "summary": "Add a new Note", + "requestBody": { + "description": "Note details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Note resource", + "required": ["feature"], + "properties": { + "title": { + "type": "string", + "description": "Common Note name" + }, + "description": { + "type": "string", + "description": "A description of the note" + }, + "position": { + "description": "Position related to note. Alternative to region or geohash.", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "description": "Pointer / path to Region related to note (e.g. /resources/routes/{uuid}. Alternative to position or geohash", + "type": "string", + "pattern": "/resources/regions/urn:mrn:signalk:uuid:[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}$" + }, + "geohash": { + "description": "Area related to note. Alternative to region or position", + "type": "string" + }, + "mimeType": { + "description": "MIME type of the note", + "type": "string" + }, + "url": { + "description": "Location of the note content", + "type": "string" + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/notes/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "note id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[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}$" + } + }, + + "get": { + "tags": ["resources/notes"], + "summary": "Retrieve Note with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/notes"], + "summary": "Add / update a new Note with supplied id", + "requestBody": { + "description": "Note details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Note resource", + "required": ["feature"], + "properties": { + "title": { + "type": "string", + "description": "Common Note name" + }, + "description": { + "type": "string", + "description": "A description of the note" + }, + "position": { + "description": "Position related to note. Alternative to region or geohash.", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "description": "Pointer / path to Region related to note (e.g. /resources/routes/{uuid}. Alternative to position or geohash", + "type": "string", + "pattern": "/resources/regions/urn:mrn:signalk:uuid:[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}$" + }, + "geohash": { + "description": "Area related to note. Alternative to region or position", + "type": "string" + }, + "mimeType": { + "description": "MIME type of the note", + "type": "string" + }, + "url": { + "description": "Location of the note content", + "type": "string" + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/notes"], + "summary": "Remove Note with supplied id", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + + }, + + "/resources/regions/": { + "post": { + "tags": ["resources/regions"], + "summary": "Add a new Region", + "requestBody": { + "description": "Region details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Region resource", + "required": ["feature"], + "properties": { + "geohash": { + "description": "geohash of the approximate boundary of this region", + "type": "string" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes the regions boundary", + "properties": { + "geometry": { + "type": "object", + "properties": { + "oneOf": [ + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Polygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + }, + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["MultiPolygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + } + } + ] + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/regions/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "region id", + "required": true, + "schema": { + "type": "string", + "pattern": "urn:mrn:signalk:uuid:[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}$" + } + }, + + "get": { + "tags": ["resources/regions"], + "summary": "Retrieve Region with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/regions"], + "summary": "Add / update a new Region with supplied id", + "requestBody": { + "description": "Region details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Region resource", + "required": ["feature"], + "properties": { + "geohash": { + "description": "geohash of the approximate boundary of this region", + "type": "string" + }, + "feature": { + "type": "object", + "title": "Feature", + "description": "A Geo JSON feature object which describes the regions boundary", + "properties": { + "geometry": { + "type": "object", + "properties": { + "oneOf": [ + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["Polygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + }, + { + "type": "object", + "description": "GeoJSon geometry", + "properties": { + "type": { + "type": "string", + "enum": ["MultiPolygon"] + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "maxItems": 3, + "minItems": 2, + "items": { + "type": "number" + } + } + } + } + } + } + } + ] + } + }, + "properties": { + "description": "Additional feature properties", + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/regions"], + "summary": "Remove Region with supplied id", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + + }, + + "/resources/charts/": { + "post": { + "tags": ["resources/charts"], + "summary": "Add a new Chart", + "requestBody": { + "description": "Chart details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["feature"], + "properties": { + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to geohash" + }, + "geohash": { + "description": "Position related to chart. Alternative to region", + "type": "string" + }, + "chartUrl": { + "type": "string", + "description": "A url to the chart file's storage location", + "example":"file:///home/pi/freeboard/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "chartLayers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounds of the chart. An array containing the position of the upper left corner, and the lower right corner. Useful when the chart isn't inherently geo-referenced.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + } + }, + "chartFormat": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "gif", + "geotiff", + "kap", + "png", + "jpg", + "kml", + "wkt", + "topojson", + "geojson", + "gpx", + "tms", + "wms", + "S-57", + "S-63", + "svg", + "other" + ] + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/charts/{id}": { + "parameters": { + "name": "id", + "in": "path", + "description": "Chart id", + "required": true, + "schema": { + "type": "string", + "pattern": "(^[A-Za-z0-9_-]{8,}$)" + } + }, + + "get": { + "tags": ["resources/charts"], + "summary": "Retrieve Chart with supplied id", + "responses": { + "200": { + "description": "List of resources identified by their UUID", + "content": { + "application/json": { + "schema": { + "description": "List of Signal K resources", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + + "put": { + "tags": ["resources/charts"], + "summary": "Add / update a new Chart with supplied id", + "requestBody": { + "description": "Chart details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["feature"], + "properties": { + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to geohash" + }, + "geohash": { + "description": "Position related to chart. Alternative to region", + "type": "string" + }, + "chartUrl": { + "type": "string", + "description": "A url to the chart file's storage location", + "example":"file:///home/pi/freeboard/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "chartLayers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounds of the chart. An array containing the position of the upper left corner, and the lower right corner. Useful when the chart isn't inherently geo-referenced.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + }, + "altitude": { + "type": "number", + "format": "float" + } + } + } + }, + "chartFormat": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "gif", + "geotiff", + "kap", + "png", + "jpg", + "kml", + "wkt", + "topojson", + "geojson", + "gpx", + "tms", + "wms", + "S-57", + "S-63", + "svg", + "other" + ] + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + + "delete": { + "tags": ["resources/charts"], + "summary": "Remove Chart with supplied id", + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + + }, + + "/resources/set/waypoint": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Waypoint", + "requestBody": { + "description": "Waypoint attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Waypoint name" + }, + "description": { + "type": "string", + "description": "Textual description of the waypoint" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/waypoint/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Waypoint", + "requestBody": { + "description": "Waypoint attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Waypoint name" + }, + "description": { + "type": "string", + "description": "Textual description of the waypoint" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "position": { + "description": "The waypoint position", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/route": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Route", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Route name" + }, + "description": { + "type": "string", + "description": "Textual description of the route" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Route points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/route/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Route", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Route name" + }, + "description": { + "type": "string", + "description": "Textual description of the route" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Route points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/note": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Note", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "Note's common name" + }, + "description": { + "type": "string", + "description": " Textual description of the note" + }, + "oneOf":[ + { + "position": { + "description": "Position related to note. Alternative to region or geohash", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to position or geohash", + "example": "/resources/regions/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + } + } + ], + "mimeType": { + "type": "string", + "description": "MIME type of the note" + }, + "url": { + "type": "string", + "description": "Location of the note contents" + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/note/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Note", + "requestBody": { + "description": "Note attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "Note's common name" + }, + "description": { + "type": "string", + "description": " Textual description of the note" + }, + "oneOf":[ + { + "position": { + "description": "Position related to note. Alternative to region or geohash", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + }, + "region": { + "type": "string", + "description": "Region related to note. A pointer to a region UUID. Alternative to position or geohash", + "example": "/resources/regions/urn:mrn:signalk:uuid:ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + } + } + ], + "mimeType": { + "type": "string", + "description": "MIME type of the note" + }, + "url": { + "type": "string", + "description": "Location of the note contents" + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/region": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Region", + "requestBody": { + "description": "Region attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Region name" + }, + "description": { + "type": "string", + "description": "Textual description of region" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Region boundary points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/region/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Region", + "requestBody": { + "description": "Region attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["position"], + "properties": { + "name": { + "type": "string", + "description": "Region name" + }, + "description": { + "type": "string", + "description": "Textual description of region" + }, + "attributes": { + "type": "object", + "description": "Additional attributes as name:value pairs.", + "additionalProperties": { + "type": "string" + } + }, + "points": { + "description": "Region boundary points", + "type": "array", + "items": { + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/chart": { + "post": { + "tags": ["resources/api"], + "summary": "Add / update a Chart", + "requestBody": { + "description": "Chart attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["identifier", "tilemapUrl"], + "properties": { + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "layers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounding rectangle of the chart defined by the position of the lower left corner, and the upper right corner.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + }, + "format": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "png", + "jpg" + ] + }, + "serverType": { + "type": "string", + "description": "Chart server type", + "enum": [ + "tilelayer", + "WMS" + ] + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + + "/resources/set/chart/{id}": { + "put": { + "tags": ["resources/api"], + "summary": "Add / update a Chart", + "requestBody": { + "description": "Chart attributes", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K Chart resource", + "required": ["identifier", "tilemapUrl"], + "properties": { + "identifier": { + "type": "string", + "description": "Chart number", + "example":"NZ615" + }, + "name": { + "description": "Chart common name", + "example":"NZ615 Marlborough Sounds", + "type": "string" + }, + "description": { + "type": "string", + "description": "A description of the chart" + }, + "tilemapUrl": { + "type": "string", + "description": "A url to the tilemap of the chart for use in TMS chartplotting apps", + "example":"http://{server}:8080/mapcache/NZ615" + }, + "scale": { + "type": "integer", + "description": "The scale of the chart, the larger number from 1:200000" + }, + "layers": { + "type": "array", + "description": "If the chart format is WMS, the layers enabled for the chart.", + "items": { + "type": "string", + "description": "Identifier for the layer." + } + }, + "bounds": { + "type": "array", + "description": "The bounding rectangle of the chart defined by the position of the lower left corner, and the upper right corner.", + "items": { + "description": "Position of a corner of the chart", + "type": "object", + "required": [ + "latitude", + "longitude" + ], + "properties": { + "latitude": { + "type": "number", + "format": "float" + }, + "longitude": { + "type": "number", + "format": "float" + } + } + } + }, + "format": { + "type": "string", + "description": "The format of the chart", + "enum": [ + "png", + "jpg" + ] + }, + "serverType": { + "type": "string", + "description": "Chart server type", + "enum": [ + "tilelayer", + "WMS" + ] + } + } + } + } + } + }, + "responses": { + "default": { + "description": "Default response format", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Signal K response", + "required": ["statusCode", "status", "message"], + "properties": { + "statusCode": { + "type": "number", + "format": "int64" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED"] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + + } + +} + \ No newline at end of file diff --git a/src/api/resources/resources.ts b/src/api/resources/resources.ts new file mode 100644 index 000000000..709d964f3 --- /dev/null +++ b/src/api/resources/resources.ts @@ -0,0 +1,267 @@ +import { SignalKResourceType } from '@signalk/server-api' +import { getDistance, isValidCoordinate } from 'geolib' + +export const buildResource = (resType: SignalKResourceType, data: any): any => { + if (resType === 'routes') { + return buildRoute(data) + } + if (resType === 'waypoints') { + return buildWaypoint(data) + } + if (resType === 'notes') { + return buildNote(data) + } + if (resType === 'regions') { + return buildRegion(data) + } + if (resType === 'charts') { + return buildChart(data) + } + return null +} + +const buildRoute = (rData: any): any => { + const rte: any = { + feature: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [] + }, + properties: {} + } + } + if (typeof rData.name !== 'undefined') { + rte.name = rData.name + rte.feature.properties.name = rData.name + } + if (typeof rData.description !== 'undefined') { + rte.description = rData.description + rte.feature.properties.description = rData.description + } + if (typeof rData.attributes !== 'undefined') { + Object.assign(rte.feature.properties, rData.attributes) + } + + if (typeof rData.points === 'undefined') { + return null + } + if (!Array.isArray(rData.points)) { + return null + } + let isValid = true + rData.points.forEach((p: any) => { + if (!isValidCoordinate(p)) { + isValid = false + } + }) + if (!isValid) { + return null + } + rte.feature.geometry.coordinates = rData.points.map((p: any) => { + return [p.longitude, p.latitude] + }) + + rte.distance = 0 + for (let i = 0; i < rData.points.length; i++) { + if (i !== 0) { + rte.distance = + rte.distance + getDistance(rData.points[i - 1], rData.points[i]) + } + } + return rte +} + +const buildWaypoint = (rData: any): any => { + const wpt: any = { + position: { + latitude: 0, + longitude: 0 + }, + feature: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [] + }, + properties: {} + } + } + if (typeof rData.name !== 'undefined') { + wpt.feature.properties.name = rData.name + } + if (typeof rData.description !== 'undefined') { + wpt.feature.properties.description = rData.description + } + if (typeof rData.attributes !== 'undefined') { + Object.assign(wpt.feature.properties, rData.attributes) + } + + if (typeof rData.position === 'undefined') { + return null + } + if (!isValidCoordinate(rData.position)) { + return null + } + + wpt.position = rData.position + wpt.feature.geometry.coordinates = [ + rData.position.longitude, + rData.position.latitude + ] + + return wpt +} + +const buildNote = (rData: any): any => { + const note: any = {} + if (typeof rData.title !== 'undefined') { + note.title = rData.title + note.feature.properties.title = rData.title + } + if (typeof rData.description !== 'undefined') { + note.description = rData.description + note.feature.properties.description = rData.description + } + if ( + typeof rData.position === 'undefined' && + typeof rData.href === 'undefined' + ) { + return null + } + + if (typeof rData.position !== 'undefined') { + if (!isValidCoordinate(rData.position)) { + return null + } + note.position = rData.position + } + if (typeof rData.href !== 'undefined') { + note.region = rData.href + } + if (typeof rData.url !== 'undefined') { + note.url = rData.url + } + if (typeof rData.mimeType !== 'undefined') { + note.mimeType = rData.mimeType + } + + return note +} + +const buildRegion = (rData: any): any => { + const reg: any = { + feature: { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [] + }, + properties: {} + } + } + let coords: Array<[number, number]> = [] + + if (typeof rData.name !== 'undefined') { + reg.feature.properties.name = rData.name + } + if (typeof rData.description !== 'undefined') { + reg.feature.properties.description = rData.description + } + if (typeof rData.attributes !== 'undefined') { + Object.assign(reg.feature.properties, rData.attributes) + } + + if (typeof rData.points !== 'undefined') { + return null + } + if (!Array.isArray(rData.points)) { + return null + } + let isValid = true + rData.points.forEach((p: any) => { + if (!isValidCoordinate(p)) { + isValid = false + } + }) + if (!isValid) { + return null + } + if ( + rData.points[0].latitude !== + rData.points[rData.points.length - 1].latitude && + rData.points[0].longitude !== + rData.points[rData.points.length - 1].longitude + ) { + rData.points.push(rData.points[0]) + } + coords = rData.points.map((p: any) => { + return [p.longitude, p.latitude] + }) + reg.feature.geometry.coordinates.push(coords) + + return reg +} + +const buildChart = (rData: any): any => { + const chart: any = { + identifier: '', + name: '', + description: '', + minzoom: 1, + maxzoom: 28, + type: 'tilelayer', + format: 'png', + tilemapUrl: '', + chartLayers: [], + scale: 250000, + bounds: [-180, -90, 180, 90] + } + + if (typeof rData.identifier === 'undefined') { + return null + } else { + chart.identifier = rData.identifier + } + if (typeof rData.url === 'undefined') { + return null + } else { + chart.tilemapUrl = rData.url + } + if (typeof rData.name !== 'undefined') { + chart.name = rData.name + } else { + chart.name = rData.identifier + } + if (typeof rData.description !== 'undefined') { + chart.description = rData.description + } + if (typeof rData.minZoom === 'number') { + chart.minzoom = rData.minZoom + } + if (typeof rData.maxZoom === 'number') { + chart.maxzoom = rData.maxZoom + } + if (typeof rData.serverType !== 'undefined') { + chart.type = rData.serverType + } + if (typeof rData.format !== 'undefined') { + chart.format = rData.format + } + if (typeof rData.layers !== 'undefined' && Array.isArray(rData.layers)) { + chart.chartLayers = rData.layers + } + if (typeof rData.scale === 'number') { + chart.scale = rData.scale + } + if (typeof rData.bounds !== 'undefined' && Array.isArray(rData.bounds)) { + chart.bounds = [ + rData.bounds[0].longitude, + rData.bounds[0].latitude, + rData.bounds[1].longitude, + rData.bounds[1].latitude + ] + } + + return chart +} diff --git a/src/api/resources/types.ts b/src/api/resources/types.ts new file mode 100644 index 000000000..2a6b0d1d7 --- /dev/null +++ b/src/api/resources/types.ts @@ -0,0 +1,85 @@ +import { Position } from '../../types' + +export interface Route { + name?: string + description?: string + distance?: number + start?: string + end?: string + feature: { + type: 'Feature' + geometry: { + type: 'LineString' + coordinates: GeoJsonLinestring + } + properties?: object + id?: string + } +} + +export interface Waypoint { + position?: Position + feature: { + type: 'Feature' + geometry: { + type: 'Point' + coords: GeoJsonPoint + } + properties?: object + id?: string + } +} + +export interface Note { + title?: string + description?: string + region?: string + position?: Position + geohash?: string + mimeType?: string + url?: string +} + +export interface Region { + geohash?: string + feature: Polygon | MultiPolygon +} + +export interface Chart { + name: string + identifier: string + description?: string + tilemapUrl?: string + chartUrl?: string + geohash?: string + region?: string + scale?: number + chartLayers?: string[] + bounds?: [[number, number], [number, number]] + chartFormat: string +} + +type GeoJsonPoint = [number, number, number?] +type GeoJsonLinestring = GeoJsonPoint[] +type GeoJsonPolygon = GeoJsonLinestring[] +type GeoJsonMultiPolygon = GeoJsonPolygon[] + +interface Polygon { + type: 'Feature' + geometry: { + type: 'Polygon' + coordinates: GeoJsonPolygon + } + properties?: object + id?: string +} + +interface MultiPolygon { + type: 'Feature' + geometry: { + type: 'MultiPolygon' + coordinates: GeoJsonMultiPolygon + } + properties?: object + id?: string +} diff --git a/src/api/resources/validate.ts b/src/api/resources/validate.ts new file mode 100644 index 000000000..305c885be --- /dev/null +++ b/src/api/resources/validate.ts @@ -0,0 +1,143 @@ +import geoJSON from 'geojson-validation' +import { isValidCoordinate } from 'geolib' +import { Chart, Note, Region, Route, Waypoint } from '../resources/types' + +export const validate = { + resource: (type: string, value: any): boolean => { + if (!type) { + return false + } + switch (type) { + case 'routes': + return validateRoute(value) + break + case 'waypoints': + return validateWaypoint(value) + break + case 'notes': + return validateNote(value) + break + case 'regions': + return validateRegion(value) + break + case 'charts': + return validateChart(value) + break + default: + return true + } + }, + + // returns true if id is a valid Signal K UUID + uuid: (id: string): boolean => { + const uuid = RegExp( + '^urn:mrn:signalk:uuid:[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) + }, + + // returns true if id is a valid Signal K Chart resource id + chartId: (id: string): boolean => { + const uuid = RegExp('(^[A-Za-z0-9_-]{8,}$)') + return uuid.test(id) + } +} + +const validateRoute = (r: Route): boolean => { + if (r.start) { + const l = r.start.split('/') + if (!validate.uuid(l[l.length - 1])) { + return false + } + } + if (r.end) { + const l = r.end.split('/') + if (!validate.uuid(l[l.length - 1])) { + return false + } + } + try { + if (!r.feature || !geoJSON.valid(r.feature)) { + return false + } + if (r.feature.geometry.type !== 'LineString') { + return false + } + } catch (err) { + return false + } + return true +} + +const validateWaypoint = (r: Waypoint): boolean => { + if (typeof r.position === 'undefined') { + return false + } + if (!isValidCoordinate(r.position)) { + return false + } + try { + if (!r.feature || !geoJSON.valid(r.feature)) { + return false + } + if (r.feature.geometry.type !== 'Point') { + return false + } + } catch (e) { + return false + } + return true +} + +// validate note data +const validateNote = (r: Note): boolean => { + if (!r.region && !r.position && !r.geohash) { + return false + } + if (typeof r.position !== 'undefined') { + if (!isValidCoordinate(r.position)) { + return false + } + } + if (r.region) { + const l = r.region.split('/') + if (!validate.uuid(l[l.length - 1])) { + return false + } + } + return true +} + +const validateRegion = (r: Region): boolean => { + if (!r.geohash && !r.feature) { + return false + } + if (r.feature) { + try { + if (!geoJSON.valid(r.feature)) { + return false + } + if ( + r.feature.geometry.type !== 'Polygon' && + r.feature.geometry.type !== 'MultiPolygon' + ) { + return false + } + } catch (e) { + return false + } + } + return true +} + +const validateChart = (r: Chart): boolean => { + if (!r.name || !r.identifier || !r.chartFormat) { + return false + } + + if (!r.tilemapUrl && !r.chartUrl) { + return false + } + + return true +} diff --git a/src/app.ts b/src/app.ts index 633e3dc34..d00a80c23 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,8 @@ import { FullSignalK } from '@signalk/signalk-schema' +import { EventEmitter } from 'events' import { Config } from './config/config' import DeltaCache from './deltacache' +import { SecurityStrategy } from './security' export interface ServerApp { started: boolean @@ -15,17 +17,21 @@ export interface ServerApp { clients: number } -export interface SignalKMessageHub { - emit: any - on: any +export interface WithSignalK { signalk: FullSignalK handleMessage: (id: string, data: any) => void } +export interface SignalKMessageHub extends EventEmitter, WithSignalK {} + export interface WithConfig { config: Config } +export interface WithSecurityStrategy { + securityStrategy: SecurityStrategy +} + export interface SelfIdentity { selfType: string selfId: string diff --git a/src/categories.ts b/src/categories.ts index e0cc84bef..cb9ef3022 100644 --- a/src/categories.ts +++ b/src/categories.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import Debug from 'debug' -const debug = Debug('signalk:categories') +import { createDebug } from './debug' +const debug = createDebug('signalk:categories') // tslint:disable-next-line:no-var-requires const { getKeywords } = require('./modules') diff --git a/src/config/config.ts b/src/config/config.ts index e107744f1..28243c5db 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -15,15 +15,15 @@ */ 'use strict' -import Debug from 'debug' -import path from 'path' -import { SelfIdentity, SignalKMessageHub, WithConfig } from '../app' -import DeltaEditor from '../deltaeditor' -const debug = Debug('signalk-server:config') import fs from 'fs' import _ from 'lodash' +import path from 'path' import semver from 'semver' import { v4 as uuidv4 } from 'uuid' +import { SelfIdentity, SignalKMessageHub, WithConfig } from '../app' +import { createDebug } from '../debug' +import DeltaEditor from '../deltaeditor' +const debug = createDebug('signalk-server:config') let disableWriteSettings = false @@ -58,6 +58,8 @@ export interface Config { hostname?: string pruneContextsMinutes?: number mdns?: boolean + sslport?: number + port?: number } defaults: object } diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 000000000..6d2f69212 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,12 @@ +import coreDebug from 'debug' + +const knownDebugs = new Set() + +export function createDebug(debugName: string) { + knownDebugs.add(debugName) + return coreDebug(debugName) +} + +export function listKnownDebugs() { + return Array.from(knownDebugs) +} diff --git a/src/deltaPriority.ts b/src/deltaPriority.ts index fab06f4cd..0f70bb870 100644 --- a/src/deltaPriority.ts +++ b/src/deltaPriority.ts @@ -1,5 +1,5 @@ -import Debug from 'debug' -const debug = Debug('signalk-server:sourcepriorities') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:sourcepriorities') type Brand = K & { __brand: T } @@ -63,7 +63,7 @@ export type ToPreferredDelta = ( export const getToPreferredDelta = ( sourcePrioritiesData: SourcePrioritiesData, - unknownSourceTimeout: number = 10000 + unknownSourceTimeout = 10000 ): ToPreferredDelta => { if (!sourcePrioritiesData) { debug('No priorities data') diff --git a/src/deltacache.ts b/src/deltacache.ts index 7af8eacfc..be1b92f05 100644 --- a/src/deltacache.ts +++ b/src/deltacache.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import Debug from 'debug' -const debug = Debug('signalk-server:deltacache') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:deltacache') import { FullSignalK, getSourceId } from '@signalk/signalk-schema' import _, { isUndefined } from 'lodash' import { toDelta } from './streambundle' diff --git a/src/deltachain.js b/src/deltachain.js deleted file mode 100644 index 59e7fd54a..000000000 --- a/src/deltachain.js +++ /dev/null @@ -1,38 +0,0 @@ -function DeltaChain(dispatchMessage) { - const chain = [] - let next = [] - - this.process = function(msg) { - return doProcess(0, msg) - } - - function doProcess(index, msg) { - if (index >= chain.length) { - dispatchMessage(msg) - return - } - chain[index](msg, next[index]) - } - - this.register = function(handler) { - chain.push(handler) - updateNexts() - return () => { - const handlerIndex = chain.indexOf(handler) - if (handlerIndex >= 0) { - chain.splice(handlerIndex, 1) - updateNexts() - } - } - } - - function updateNexts() { - next = chain.map((chainElement, index) => { - return msg => { - doProcess(index + 1, msg) - } - }) - } -} - -module.exports = DeltaChain diff --git a/src/deltachain.ts b/src/deltachain.ts new file mode 100644 index 000000000..cad8eb5ae --- /dev/null +++ b/src/deltachain.ts @@ -0,0 +1,45 @@ +export type DeltaInputHandler = ( + delta: object, + next: (delta: object) => void +) => void + +export default class DeltaChain { + chain: any + next: any + constructor(private dispatchMessage: any) { + this.chain = [] + this.next = [] + } + + process(msg: any) { + return this.doProcess(0, msg) + } + + doProcess(index: number, msg: any) { + if (index >= this.chain.length) { + this.dispatchMessage(msg) + return + } + this.chain[index](msg, this.next[index]) + } + + register(handler: DeltaInputHandler) { + this.chain.push(handler) + this.updateNexts() + return () => { + const handlerIndex = this.chain.indexOf(handler) + if (handlerIndex >= 0) { + this.chain.splice(handlerIndex, 1) + this.updateNexts() + } + } + } + + updateNexts() { + this.next = this.chain.map((chainElement: any, index: number) => { + return (msg: any) => { + this.doProcess(index + 1, msg) + } + }) + } +} diff --git a/src/deltastats.js b/src/deltastats.js deleted file mode 100644 index 56c1d45a2..000000000 --- a/src/deltastats.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2017 Teppo Kurki, Scott Bender - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. -*/ - -const { isUndefined, values } = require('lodash') - -module.exports = { - startDeltaStatistics: function(app) { - app.deltaCount = 0 - app.lastIntervalDeltaCount = 0 - app.providerStatistics = {} - - return setInterval(() => { - updateProviderPeriodStats(app) - app.emit('serverevent', { - type: 'SERVERSTATISTICS', - from: 'signalk-server', - data: { - deltaRate: (app.deltaCount - app.lastIntervalDeltaCount) / 5, - numberOfAvailablePaths: app.streambundle.getAvailablePaths().length, - wsClients: app.interfaces.ws ? app.interfaces.ws.numClients() : 0, - providerStatistics: app.providerStatistics, - uptime: process.uptime() - } - }) - app.lastIntervalDeltaCount = app.deltaCount - }, 5 * 1000) - }, - - incDeltaStatistics: function(app, providerId) { - app.deltaCount++ - - const stats = - app.providerStatistics[providerId] || - (app.providerStatistics[providerId] = { - deltaCount: 0 - }) - stats.deltaCount++ - } -} - -function updateProviderPeriodStats(app) { - app.providers.forEach(provider => { - if (isUndefined(app.providerStatistics[provider.id])) { - app.providerStatistics[provider.id] = { - deltaCount: 0, - deltaRate: 0 - } - } - }) - - values(app.providerStatistics).forEach(stats => { - stats.deltaRate = (stats.deltaCount - stats.lastIntervalDeltaCount) / 5 - stats.lastIntervalDeltaCount = stats.deltaCount - }) -} diff --git a/src/deltastats.ts b/src/deltastats.ts new file mode 100644 index 000000000..94fed689f --- /dev/null +++ b/src/deltastats.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2017 Teppo Kurki, Scott Bender + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { isUndefined, values } from 'lodash' + +export function startDeltaStatistics(app: any) { + app.deltaCount = 0 + app.lastIntervalDeltaCount = 0 + app.providerStatistics = {} + + return setInterval(() => { + updateProviderPeriodStats(app) + app.emit('serverevent', { + type: 'SERVERSTATISTICS', + from: 'signalk-server', + data: { + deltaRate: (app.deltaCount - app.lastIntervalDeltaCount) / 5, + numberOfAvailablePaths: app.streambundle.getAvailablePaths().length, + wsClients: app.interfaces.ws ? app.interfaces.ws.numClients() : 0, + providerStatistics: app.providerStatistics, + uptime: process.uptime() + } + }) + app.lastIntervalDeltaCount = app.deltaCount + }, 5 * 1000) +} + +export function incDeltaStatistics(app: any, providerId: any) { + app.deltaCount++ + + const stats = + app.providerStatistics[providerId] || + (app.providerStatistics[providerId] = { + deltaCount: 0 + }) + stats.deltaCount++ +} + +function updateProviderPeriodStats(app: any) { + app.providers.forEach((provider: any) => { + if (isUndefined(app.providerStatistics[provider.id])) { + app.providerStatistics[provider.id] = { + deltaCount: 0, + deltaRate: 0 + } + } + }) + + values(app.providerStatistics).forEach((stats: any) => { + stats.deltaRate = (stats.deltaCount - stats.lastIntervalDeltaCount) / 5 + stats.lastIntervalDeltaCount = stats.deltaCount + }) +} diff --git a/src/discovery.js b/src/discovery.js index 8a8fd6c4b..ab2589a87 100644 --- a/src/discovery.js +++ b/src/discovery.js @@ -14,7 +14,8 @@ * limitations under the License. */ -const debug = require('debug')('signalk-server:discovery') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:discovery') const canboatjs = require('@canboat/canboatjs') const dgram = require('dgram') const mdns = require('mdns-js') diff --git a/src/dummysecurity.js b/src/dummysecurity.ts similarity index 54% rename from src/dummysecurity.js rename to src/dummysecurity.ts index 68a9039ac..def94bc0c 100644 --- a/src/dummysecurity.js +++ b/src/dummysecurity.ts @@ -14,23 +14,23 @@ * limitations under the License. */ - /* tslint:disable */ - -module.exports = function(app, config) { +/* tslint:disable */ + +export default function() { return { getConfiguration: () => { return {} }, - allowRestart: req => { + allowRestart: (_req: any) => { return false }, - allowConfigure: req => { + allowConfigure: (_req: any) => { return false }, - getLoginStatus: req => { + getLoginStatus: (_req: any) => { return { status: 'notLoggedIn', readOnlyAccess: false, @@ -38,45 +38,66 @@ module.exports = function(app, config) { } }, - getConfig: config => { - return config + getConfig: (_config: any) => { + return _config }, - setConfig: (config, newConfig) => {}, + setConfig: (_config: any, _newConfig: any) => {}, - getUsers: config => { + getUsers: (_config: any) => { return [] }, - updateUser: (config, username, updates, callback) => {}, + updateUser: ( + _config: any, + _username: any, + _updates: any, + _callback: any + ) => {}, - addUser: (config, user, callback) => {}, + addUser: (_config: any, _user: any, _callback: any) => {}, - setPassword: (config, username, password, callback) => {}, + setPassword: ( + _config: any, + _username: any, + _password: any, + _callback: any + ) => {}, - deleteUser: (config, username, callback) => {}, + deleteUser: (_config: any, _username: any, _callback: any) => {}, - shouldAllowWrite: function(req, delta) { + shouldAllowWrite: function(_req: any, _delta: any) { return true }, - shouldAllowPut: function(req, context, source, path) { + shouldAllowPut: function( + _req: any, + _context: any, + _source: any, + _path: any + ) { return true }, - filterReadDelta: (user, delta) => { + filterReadDelta: (_user: any, delta: any) => { return delta }, - verifyWS: spark => {}, + verifyWS: (_spark: any) => {}, - authorizeWS: req => {}, + authorizeWS: (_req: any) => {}, anyACLs: () => { return false }, - checkACL: (id, context, path, source, operation) => { + checkACL: ( + _id: any, + _context: any, + _path: any, + _source: any, + _operation: any + ) => { return true }, @@ -97,7 +118,7 @@ module.exports = function(app, config) { addAdminWriteMiddleware: () => {}, addWriteMiddleware: () => {}, - + allowReadOnly: () => { return true }, @@ -108,6 +129,10 @@ module.exports = function(app, config) { return 'never' }, - validateConfiguration: (configuration) => {} + validateConfiguration: (_configuration: any) => {}, + + configFromArguments: false, + securityConfig: undefined, + requestAccess: () => undefined } } diff --git a/src/index.ts b/src/index.ts index 6286c2be9..e932ac21e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,37 +19,35 @@ if (typeof [].includes !== 'function') { process.exit(-1) } -import Debug from 'debug' -import express from 'express' -import _ from 'lodash' -const debug = Debug('signalk-server') +import { PropertyValues } from '@signalk/server-api' import { FullSignalK, getSourceId } from '@signalk/signalk-schema' +import { Debugger } from 'debug' +import express, { Request, Response } from 'express' import http from 'http' import https from 'https' +import _ from 'lodash' import path from 'path' -import ports from './ports' -import SubscriptionManager from './subscriptionmanager' -const getPrimaryPort = ports.getPrimaryPort -const getSecondaryPort = ports.getSecondaryPort -const getExternalPort = ports.getExternalPort -import { PropertyValues } from '@signalk/server-api' -import { Request, Response } from 'express' import { SelfIdentity, ServerApp, SignalKMessageHub, WithConfig } from './app' -import { Config, ConfigApp } from './config/config' +import { ConfigApp, load, sendBaseDeltas } from './config/config' +import { createDebug } from './debug' import DeltaCache from './deltacache' -import DeltaChain from './deltachain' +import DeltaChain, { DeltaInputHandler } from './deltachain' import { getToPreferredDelta, ToPreferredDelta } from './deltaPriority' -import { checkForNewServerVersion } from './modules' -import { Delta } from './types' - -import { load, sendBaseDeltas } from './config/config' import { incDeltaStatistics, startDeltaStatistics } from './deltastats' +import { checkForNewServerVersion } from './modules' +import { getExternalPort, getPrimaryPort, getSecondaryPort } from './ports' import { getCertificateOptions, getSecurityConfig, saveSecurityConfig, startSecurity } from './security.js' +import SubscriptionManager from './subscriptionmanager' +import { Delta } from './types' +const debug = createDebug('signalk-server') + +import { CourseApi } from './api/course' +import { Resources } from './api/resources' // tslint:disable-next-line: no-var-requires const { StreamBundle } = require('./streambundle') @@ -81,12 +79,16 @@ class Server { require('./serverroutes')(app, saveSecurityConfig, getSecurityConfig) require('./put').start(app) + app.resourcesApi = new Resources(app) + const courseApi = new CourseApi(app) + app.signalk = new FullSignalK(app.selfId, app.selfType) app.propertyValues = new PropertyValues() const deltachain = new DeltaChain(app.signalk.addDelta.bind(app.signalk)) - app.registerDeltaInputHandler = deltachain.register + app.registerDeltaInputHandler = (handler: DeltaInputHandler) => + deltachain.register(handler) app.providerStatus = {} @@ -255,21 +257,21 @@ class Server { const self = this const app = this.app - const eventDebugs: { [key: string]: Debug.Debugger } = {} - const emit = app.emit - app.emit = (eventName: string) => { + const eventDebugs: { [key: string]: Debugger } = {} + const expressAppEmit = app.emit.bind(app) + app.emit = (eventName: string, ...args: any[]): any => { if (eventName !== 'serverlog') { let eventDebug = eventDebugs[eventName] if (!eventDebug) { - eventDebugs[eventName] = eventDebug = Debug( + eventDebugs[eventName] = eventDebug = createDebug( `signalk-server:events:${eventName}` ) } if (eventDebug.enabled) { - eventDebug([...arguments].slice(1)) + eventDebug(args) } } - emit.apply(app, arguments) + expressAppEmit(eventName, ...args) } this.app.intervals = [] diff --git a/src/interfaces/applicationData.js b/src/interfaces/applicationData.js index 41d7c2b61..8a75d090d 100644 --- a/src/interfaces/applicationData.js +++ b/src/interfaces/applicationData.js @@ -15,7 +15,8 @@ */ const _ = require('lodash') -const debug = require('debug')('signalk-server:interfaces:applicationData') +import { createDebug } from '../debug' +const debug = createDebug('signalk-server:interfaces:applicationData') const fs = require('fs') const path = require('path') const jsonpatch = require('json-patch') diff --git a/src/interfaces/appstore.js b/src/interfaces/appstore.js index dbfb00fbd..d3051bb26 100644 --- a/src/interfaces/appstore.js +++ b/src/interfaces/appstore.js @@ -14,7 +14,8 @@ * limitations under the License. */ -const debug = require('debug')('signalk:interfaces:appstore') +import { createDebug } from '../debug' +const debug = createDebug('signalk:interfaces:appstore') const _ = require('lodash') const compareVersions = require('compare-versions') const { installModule, removeModule } = require('../modules') diff --git a/src/interfaces/logfiles.js b/src/interfaces/logfiles.js index a937db61c..8029a4289 100644 --- a/src/interfaces/logfiles.js +++ b/src/interfaces/logfiles.js @@ -14,7 +14,8 @@ * limitations under the License. */ -const debug = require('debug')('signalk:interfaces:logfiles') +import { createDebug } from '../debug' +const debug = createDebug('signalk:interfaces:logfiles') const moment = require('moment') const fs = require('fs') const path = require('path') diff --git a/src/interfaces/nmea-tcp.js b/src/interfaces/nmea-tcp.js index 391729590..03f736fa6 100644 --- a/src/interfaces/nmea-tcp.js +++ b/src/interfaces/nmea-tcp.js @@ -15,6 +15,9 @@ const _ = require('lodash') +import { createDebug } from '../debug' +const debug = createDebug('signalk-server:interfaces:tcp:nmea0183') + module.exports = function(app) { 'use strict' const net = require('net') @@ -24,7 +27,6 @@ module.exports = function(app) { const port = process.env.NMEA0183PORT || 10110 const api = {} - const debug = require('debug')('signalk-server:interfaces:tcp:nmea0183') api.start = function() { debug('Starting tcp interface') diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index a29e48b64..786f142ce 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -13,9 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import Debug from 'debug' -import { Request, Response } from 'express' -const debug = Debug('signalk:interfaces:plugins') import { PluginServerApp, PropertyValues, @@ -23,12 +20,15 @@ import { } from '@signalk/server-api' // @ts-ignore import { getLogger } from '@signalk/streams/logging' -import express from 'express' +import express, { Request, Response } from 'express' import fs from 'fs' import _ from 'lodash' import path from 'path' import { SERVERROUTESPREFIX } from '../constants' +import { createDebug } from '../debug' +import { DeltaInputHandler } from '../deltachain' import { listAllSerialPorts, Ports } from '../serialports' +const debug = createDebug('signalk:interfaces:plugins') // tslint:disable-next-line:no-var-requires const modulesWithKeyword = require('../modules').modulesWithKeyword @@ -90,9 +90,7 @@ export interface ServerAPI extends PluginServerApp { queryRequest: (requestId: string) => Promise error: (msg: string) => void debug: (msg: string) => void - registerDeltaInputHandler: ( - handler: (delta: object, next: (delta: object) => void) => void - ) => void + registerDeltaInputHandler: (handler: DeltaInputHandler) => void setProviderStatus: (msg: string) => void handleMessage: (id: string, msg: any) => void setProviderError: (msg: string) => void @@ -512,7 +510,7 @@ module.exports = (theApp: any) => { console.error(msg.stack) } }, - debug: require('debug')(packageName), + debug: createDebug(packageName), registerDeltaInputHandler: (handler: any) => { onStopHandlers[plugin.id].push(app.registerDeltaInputHandler(handler)) }, diff --git a/src/interfaces/rest.js b/src/interfaces/rest.js index 9f65126bf..72bea7075 100644 --- a/src/interfaces/rest.js +++ b/src/interfaces/rest.js @@ -14,7 +14,8 @@ * limitations under the License. */ -const debug = require('debug')('signalk-server:interfaces:rest') +const { createDebug } = require('../debug') +const debug = createDebug('signalk-server:interfaces:rest') const express = require('express') const { getMetadata, getUnits } = require('@signalk/signalk-schema') const ports = require('../ports') diff --git a/src/interfaces/tcp.ts b/src/interfaces/tcp.ts index 7721dee17..0b8e609c6 100644 --- a/src/interfaces/tcp.ts +++ b/src/interfaces/tcp.ts @@ -13,12 +13,11 @@ * limitations under the License. */ -import Debug from 'debug' -import { values } from 'lodash' import { createServer, Server, Socket } from 'net' import split from 'split' -const debug = Debug('signalk-server:interfaces:tcp:signalk') +import { createDebug } from '../debug' import { Interface, SignalKServer, Unsubscribes } from '../types' +const debug = createDebug('signalk-server:interfaces:tcp:signalk') interface SocketWithId extends Socket { id?: number diff --git a/src/interfaces/webapps.js b/src/interfaces/webapps.js index 232b77e91..8d2676833 100644 --- a/src/interfaces/webapps.js +++ b/src/interfaces/webapps.js @@ -14,7 +14,8 @@ * limitations under the License. */ -const debug = require('debug')('signalk:interfaces:webapps') +import { createDebug } from '../debug' +const debug = createDebug('signalk:interfaces:webapps') const fs = require('fs') const path = require('path') const express = require('express') diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js index 65a40b695..f3bf4f5fe 100644 --- a/src/interfaces/ws.js +++ b/src/interfaces/ws.js @@ -26,10 +26,9 @@ const { } = require('../requestResponse') const { putPath } = require('../put') const skConfig = require('../config/config') -const debug = require('debug')('signalk-server:interfaces:ws') -const debugConnection = require('debug')( - 'signalk-server:interfaces:ws:connections' -) +import { createDebug } from '../debug' +const debug = createDebug('signalk-server:interfaces:ws') +const debugConnection = createDebug('signalk-server:interfaces:ws:connections') const Primus = require('primus') const supportedQuerySubscribeValues = ['self', 'all'] diff --git a/src/mdns.js b/src/mdns.js index ee7a6f100..cb3f0bc67 100644 --- a/src/mdns.js +++ b/src/mdns.js @@ -17,7 +17,8 @@ 'use strict' const _ = require('lodash') -const debug = require('debug')('signalk-server:mdns') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:mdns') const dnssd = require('dnssd2') const ports = require('./ports') diff --git a/src/modules.ts b/src/modules.ts index b2bea5f11..5727fe1e4 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -15,16 +15,14 @@ */ import { spawn } from 'child_process' -import Debug from 'debug' import fs from 'fs' -import fetch from 'node-fetch' -import { Response } from 'node-fetch' -const debug = Debug('signalk:modules') import _ from 'lodash' +import fetch, { Response } from 'node-fetch' import path from 'path' import semver, { SemVer } from 'semver' -import { WithConfig } from './app' import { Config } from './config/config' +import { createDebug } from './debug' +const debug = createDebug('signalk:modules') interface ModuleData { module: string diff --git a/src/pipedproviders.js b/src/pipedproviders.js index 5974c40ae..51f3ea45e 100644 --- a/src/pipedproviders.js +++ b/src/pipedproviders.js @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createDebug } from './debug' const deep = require('deep-get-set') const DevNull = require('dev-null-stream') const _ = require('lodash') @@ -84,7 +85,10 @@ module.exports = function(app) { const efectiveElementType = elementConfig.type.startsWith('providers/') ? elementConfig.type.replace('providers/', '@signalk/streams/') : elementConfig.type - return new (require(efectiveElementType))(elementConfig.options) + return new (require(efectiveElementType))({ + ...elementConfig.options, + createDebug + }) } function startProviders() { diff --git a/src/ports.js b/src/ports.js deleted file mode 100644 index b1f6fec80..000000000 --- a/src/ports.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2017 Teppo Kurki - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. -*/ - -const SD_LISTEN_FDS_START = 3 - -const getSslPort = app => - Number(process.env.SSLPORT) || app.config.settings.sslport || 3443 - -const getHttpPort = app => - Number(process.env.PORT) || app.config.settings.port || 3000 - -module.exports = { - getPrimaryPort: function(app) { - if (process.env.LISTEN_FDS > 0) { - return { - fd: SD_LISTEN_FDS_START - } - } - return app.config.settings.ssl ? getSslPort(app) : getHttpPort(app) - }, - - getSecondaryPort: function(app) { - if (process.env.LISTEN_FDS > 0) { - if (process.env.LISTEN_FDS !== 2) { - return false - } - return { - fd: SD_LISTEN_FDS_START + 1 - } - } - return app.config.settings.ssl ? getHttpPort(app) : -7777 - }, - - getExternalPort: function(app) { - if (process.env.EXTERNALPORT > 0) { - return Number(process.env.EXTERNALPORT) - } - return app.config.settings.ssl ? getSslPort(app) : getHttpPort(app) - }, - - getSslPort, - getHttpPort -} diff --git a/src/ports.ts b/src/ports.ts new file mode 100644 index 000000000..cadc7a5ba --- /dev/null +++ b/src/ports.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2017 Teppo Kurki + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { WithConfig } from './app' + +const SD_LISTEN_FDS_START = 3 + +export const getSslPort = (app: WithConfig) => + Number(process.env?.SSLPORT) || app.config.settings.sslport || 3443 + +export const getHttpPort = (app: WithConfig) => + Number(process.env?.PORT) || app.config.settings.port || 3000 + +export function getPrimaryPort(app: WithConfig) { + if (Number(process.env.LISTEN_FDS) > 0) { + return { + fd: SD_LISTEN_FDS_START + } + } + return app.config.settings.ssl ? getSslPort(app) : getHttpPort(app) +} + +export function getSecondaryPort(app: WithConfig): any { + if (Number(process.env.LISTEN_FDS) > 0) { + if (Number(process.env.LISTEN_FDS) !== 2) { + return false + } + return { + fd: SD_LISTEN_FDS_START + 1 + } + } + return app.config.settings.ssl ? getHttpPort(app) : -7777 +} + +export function getExternalPort(app: WithConfig) { + if (Number(process.env?.EXTERNALPORT) > 0) { + return Number(process.env?.EXTERNALPORT) + } + return app.config.settings.ssl ? getSslPort(app) : getHttpPort(app) +} diff --git a/src/put.js b/src/put.js index 8db74fac8..ce1bd8af1 100644 --- a/src/put.js +++ b/src/put.js @@ -1,9 +1,12 @@ const _ = require('lodash') -const debug = require('debug')('signalk-server:put') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:put') const { v4: uuidv4 } = require('uuid') const { createRequest, updateRequest } = require('./requestResponse') const skConfig = require('./config/config') +const { isApiRequest } = require('./api') + const pathPrefix = '/signalk' const versionPrefix = '/v1' const apiPathPrefix = pathPrefix + versionPrefix + '/api/' @@ -30,6 +33,11 @@ module.exports = { app.deRegisterActionHandler = deRegisterActionHandler app.put(apiPathPrefix + '*', function(req, res, next) { + // check for resources API, course API, etc request + if (isApiRequest(req.path)) { + next() + return + } let path = String(req.path).replace(apiPathPrefix, '') const value = req.body diff --git a/src/requestResponse.js b/src/requestResponse.js index 52230f8f8..bb4703dd1 100644 --- a/src/requestResponse.js +++ b/src/requestResponse.js @@ -1,5 +1,6 @@ const { v4: uuidv4 } = require('uuid') -const debug = require('debug')('signalk-server:requestResponse') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:requestResponse') const _ = require('lodash') const requests = {} diff --git a/src/security.js b/src/security.ts similarity index 61% rename from src/security.js rename to src/security.ts index dc5417e49..75a60fbd8 100644 --- a/src/security.js +++ b/src/security.ts @@ -14,22 +14,56 @@ * limitations under the License. */ -const fs = require('fs') -const path = require('path') -const Mode = require('stat-mode') -const pem = require('pem') -const debug = require('debug')('signalk-server:security') -const _ = require('lodash') -const dummysecurity = require('./dummysecurity') - -class InvalidTokenError extends Error { - constructor(...args) { +import { Request } from 'express' +import { + chmodSync, + existsSync, + readFileSync, + Stats, + statSync, + writeFile, + writeFileSync +} from 'fs' +import _ from 'lodash' +import path from 'path' +import pem from 'pem' +import { Mode } from 'stat-mode' +import { WithConfig } from './app' +import { createDebug } from './debug' +import dummysecurity from './dummysecurity' +const debug = createDebug('signalk-server:security') + +export interface WithSecurityStrategy { + securityStrategy: SecurityStrategy +} + +export interface SecurityStrategy { + isDummy: () => boolean + allowReadOnly: () => boolean + shouldFilterDeltas: () => boolean + filterReadDelta: (user: any, delta: any) => any + configFromArguments: boolean + securityConfig: any + requestAccess: (config: any, request: any, ip: any, updateCb: any) => any + shouldAllowPut: ( + req: Request, + context: string, + source: any, + path: string + ) => boolean +} + +export class InvalidTokenError extends Error { + constructor(...args: any[]) { super(...args) Error.captureStackTrace(this, InvalidTokenError) } } -function startSecurity(app, securityConfig) { +export function startSecurity( + app: WithSecurityStrategy & WithConfig, + securityConfig: any +) { let securityStrategyModuleName = process.env.SECURITYSTRATEGY || _.get(app, 'config.settings.security.strategy') @@ -58,15 +92,15 @@ function startSecurity(app, securityConfig) { } } -function getSecurityConfig(app, forceRead = false) { +export function getSecurityConfig( + app: WithConfig & WithSecurityStrategy, + forceRead = false +) { if (!forceRead && app.securityStrategy.configFromArguments) { return app.securityStrategy.securityConfig } else { try { - const optionsAsString = fs.readFileSync( - pathForSecurityConfig(app), - 'utf8' - ) + const optionsAsString = readFileSync(pathForSecurityConfig(app), 'utf8') return JSON.parse(optionsAsString) } catch (e) { console.error('Could not parse security config') @@ -76,11 +110,15 @@ function getSecurityConfig(app, forceRead = false) { } } -function pathForSecurityConfig(app) { +export function pathForSecurityConfig(app: WithConfig) { return path.join(app.config.configPath, 'security.json') } -function saveSecurityConfig(app, data, callback) { +export function saveSecurityConfig( + app: WithSecurityStrategy & WithConfig, + data: any, + callback: any +) { if (app.securityStrategy.configFromArguments) { app.securityStrategy.securityConfig = data if (callback) { @@ -89,9 +127,9 @@ function saveSecurityConfig(app, data, callback) { } else { const config = JSON.parse(JSON.stringify(data)) const configPath = pathForSecurityConfig(app) - fs.writeFile(configPath, JSON.stringify(data, null, 2), err => { + writeFile(configPath, JSON.stringify(data, null, 2), err => { if (!err) { - fs.chmodSync(configPath, '600') + chmodSync(configPath, '600') } if (callback) { callback(err) @@ -100,10 +138,10 @@ function saveSecurityConfig(app, data, callback) { } } -function getCertificateOptions(app, cb) { +export function getCertificateOptions(app: WithConfig, cb: any) { let certLocation - if (!app.config.configPath || fs.existsSync('./settings/ssl-cert.pem')) { + if (!app.config.configPath || existsSync('./settings/ssl-cert.pem')) { certLocation = './settings' } else { certLocation = app.config.configPath @@ -113,8 +151,8 @@ function getCertificateOptions(app, cb) { const keyFile = path.join(certLocation, 'ssl-key.pem') const chainFile = path.join(certLocation, 'ssl-chain.pem') - if (fs.existsSync(certFile) && fs.existsSync(keyFile)) { - if (!hasStrictPermissions(fs.statSync(keyFile))) { + if (existsSync(certFile) && existsSync(keyFile)) { + if (!hasStrictPermissions(statSync(keyFile))) { cb( new Error( `${keyFile} must be accessible only by the user that is running the server, refusing to start` @@ -122,7 +160,7 @@ function getCertificateOptions(app, cb) { ) return } - if (!hasStrictPermissions(fs.statSync(certFile))) { + if (!hasStrictPermissions(statSync(certFile))) { cb( new Error( `${certFile} must be accessible only by the user that is running the server, refusing to start` @@ -131,15 +169,15 @@ function getCertificateOptions(app, cb) { return } let ca - if (fs.existsSync(chainFile)) { + if (existsSync(chainFile)) { debug('Found ssl-chain.pem') ca = getCAChainArray(chainFile) debug(JSON.stringify(ca, null, 2)) } debug(`Using certificate ssl-key.pem and ssl-cert.pem in ${certLocation}`) cb(null, { - key: fs.readFileSync(keyFile), - cert: fs.readFileSync(certFile), + key: readFileSync(keyFile), + cert: readFileSync(certFile), ca }) } else { @@ -147,7 +185,7 @@ function getCertificateOptions(app, cb) { } } -function hasStrictPermissions(stat) { +function hasStrictPermissions(stat: Stats) { if (process.platform === 'win32') { return true } else { @@ -155,10 +193,9 @@ function hasStrictPermissions(stat) { } } -function getCAChainArray(filename) { - let chainCert = [] - return fs - .readFileSync(filename, 'utf8') +export function getCAChainArray(filename: string) { + let chainCert = new Array() + return readFileSync(filename, 'utf8') .split('\n') .reduce((ca, line) => { chainCert.push(line) @@ -167,10 +204,15 @@ function getCAChainArray(filename) { chainCert = [] } return ca - }, []) + }, new Array()) } -function createCertificateOptions(app, certFile, keyFile, cb) { +export function createCertificateOptions( + app: WithConfig, + certFile: string, + keyFile: string, + cb: any +) { const location = app.config.configPath ? app.config.configPath : './settings' debug(`Creating certificate files in ${location}`) pem.createCertificate( @@ -178,15 +220,15 @@ function createCertificateOptions(app, certFile, keyFile, cb) { days: 360, selfSigned: true }, - function(err, keys) { + (err: any, keys: any) => { if (err) { console.error('Could not create SSL certificate:' + err.message) throw err } else { - fs.writeFileSync(keyFile, keys.serviceKey) - fs.chmodSync(keyFile, '600') - fs.writeFileSync(certFile, keys.certificate) - fs.chmodSync(certFile, '600') + writeFileSync(keyFile, keys.serviceKey) + chmodSync(keyFile, '600') + writeFileSync(certFile, keys.certificate) + chmodSync(certFile, '600') cb(null, { key: keys.serviceKey, cert: keys.certificate @@ -196,16 +238,12 @@ function createCertificateOptions(app, certFile, keyFile, cb) { ) } -function requestAccess(app, request, ip, updateCb) { +export function requestAccess( + app: WithSecurityStrategy & WithConfig, + request: any, + ip: any, + updateCb: any +) { const config = getSecurityConfig(app) return app.securityStrategy.requestAccess(config, request, ip, updateCb) } - -module.exports = { - startSecurity, - getCertificateOptions, - getSecurityConfig, - saveSecurityConfig, - requestAccess, - InvalidTokenError -} diff --git a/src/serverroutes.js b/src/serverroutes.js index eb5e65a96..75a1d48f9 100644 --- a/src/serverroutes.js +++ b/src/serverroutes.js @@ -18,7 +18,8 @@ const fs = require('fs') const os = require('os') const readdir = require('util').promisify(fs.readdir) const page = require('./page') -const debug = require('debug')('signalk-server:serverroutes') +import { createDebug, listKnownDebugs } from './debug' +const debug = createDebug('signalk-server:serverroutes') const path = require('path') const _ = require('lodash') const skConfig = require('./config/config') @@ -760,7 +761,7 @@ module.exports = function(app, saveSecurityConfig, getSecurityConfig) { }) app.get(`${SERVERROUTESPREFIX}/debugKeys`, (req, res) => { - res.json(_.uniq(require('debug').instances.map(i => i.namespace))) + res.json(listKnownDebugs()) }) app.post(`${SERVERROUTESPREFIX}/rememberDebug`, (req, res) => { diff --git a/src/serverstate/store.ts b/src/serverstate/store.ts new file mode 100644 index 000000000..d6db82f7b --- /dev/null +++ b/src/serverstate/store.ts @@ -0,0 +1,52 @@ +import { constants } from 'fs' +import { access, mkdir, readFile, writeFile } from 'fs/promises' +import path from 'path' + +export class Store { + private filePath = '' + private fileName = '' + + constructor(filePath: string, fileName = 'settings.json') { + this.filePath = filePath + this.fileName = fileName + this.init().catch(error => { + console.log( + `Could not initialise ${path.join(this.filePath, this.fileName)}` + ) + console.log(error) + }) + } + + async read(): Promise { + try { + const data = await readFile( + path.join(this.filePath, this.fileName), + 'utf8' + ) + return JSON.parse(data) + } catch (error) { + throw error + } + } + + write(data: any): Promise { + return writeFile( + path.join(this.filePath, this.fileName), + JSON.stringify(data) + ) + } + + private async init() { + try { + /* tslint:disable:no-bitwise */ + await access(this.filePath, constants.R_OK | constants.W_OK) + /* tslint:enable:no-bitwise */ + } catch (error) { + try { + await mkdir(this.filePath, { recursive: true }) + } catch (error) { + console.log(`Error: Unable to create ${this.filePath}`) + } + } + } +} diff --git a/src/subscriptionmanager.ts b/src/subscriptionmanager.ts index 32089c1c3..766fd7cbf 100644 --- a/src/subscriptionmanager.ts +++ b/src/subscriptionmanager.ts @@ -15,13 +15,13 @@ */ import Bacon from 'baconjs' -import Debug from 'debug' import { isPointWithinRadius } from 'geolib' import _, { forOwn, get, isString } from 'lodash' -const debug = Debug('signalk-server:subscriptionmanager') +import { createDebug } from './debug' import DeltaCache from './deltacache' import { toDelta } from './streambundle' import { ContextMatcher, Position, Unsubscribes, WithContext } from './types' +const debug = createDebug('signalk-server:subscriptionmanager') interface BusesMap { [key: string]: any diff --git a/src/tokensecurity.js b/src/tokensecurity.js index f79872651..24d701652 100644 --- a/src/tokensecurity.js +++ b/src/tokensecurity.js @@ -14,7 +14,8 @@ * limitations under the License. */ -const debug = require('debug')('signalk-server:tokensecurity') +import { createDebug } from './debug' +const debug = createDebug('signalk-server:tokensecurity') const util = require('util') const jwt = require('jsonwebtoken') const _ = require('lodash') diff --git a/src/types.ts b/src/types.ts index 4e6f0c5b0..19e0a30f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ import { FullSignalK } from '@signalk/signalk-schema' +import { Request } from 'express' +import { SecurityStrategy } from './security' import SubscriptionManager from './subscriptionmanager' export interface HelloMessage { @@ -9,13 +11,6 @@ export interface HelloMessage { timestamp: Date } -export interface SecurityStrategy { - isDummy: () => boolean - allowReadOnly: () => boolean - shouldFilterDeltas: () => boolean - filterReadDelta: (user: any, delta: any) => any -} - export interface Bus { onValue: (callback: (value: any) => any) => () => void push: (v: any) => void @@ -81,4 +76,5 @@ export type Value = object | number | string | null export interface Position { latitude: number longitude: number + altitude?: number } diff --git a/tslint.js b/tslint.js index 36884a0e2..dc1a72635 100644 --- a/tslint.js +++ b/tslint.js @@ -3,7 +3,9 @@ const TSRULES = { 'member-access': [true, 'no-public'], 'interface-name': false, 'max-classes-per-file': false, - 'no-any': false + 'no-any': false, + 'no-inferrable-types': true, + 'prefer-object-spread': true } const UNIVERSAL_RULES = {