diff --git a/docs/src/develop/plugins/autopilot_provider_plugins.md b/docs/src/develop/plugins/autopilot_provider_plugins.md index b8692ab4e..01ea757d1 100644 --- a/docs/src/develop/plugins/autopilot_provider_plugins.md +++ b/docs/src/develop/plugins/autopilot_provider_plugins.md @@ -1,12 +1,451 @@ -# Autopilot Provider Plugins +# Autopilot Provider plugins -#### (Under Development) +_This document should be read in conjunction with the [SERVER PLUGINS](./server_plugin.md) document as it contains additional information regarding the development of plugins that implement the Signal K Autopilot API._ -_Note: This API is currently under development and the information provided here is likely to change._ +--- +## Overview -The Signal K server [Autopilot API](../rest-api/autopilot_api.md) will provide a common set of operations for interacting with autopilot devices and (like the Resources API) will rely on a "provider plugin" to facilitate communication with the autopilot device. +The Signal K Autopilot API defines endpoints under the path `/signalk/v2/api/vessels/self/steering/autopilots` providing a way for all Signal K clients to perform common autopilot operations independent of the autopilot device in use. The API is defined in an [OpenAPI](/doc/openapi/?urls.primaryName=autopilot) document. -By de-coupling the operation requests from device communication provides the ability to support a wide variety of devices. +Requests made to the Autopilot API are received by the Signal K Server, where they are validated and an authorisation check performed, before being passed on to a **provider plugin** to action the request on the autopilot device. -[View the PR](https://github.com/SignalK/signalk-server/pull/1596) for more details. +This de-coupling of request handling and autopilot communication provides the flexibility to support a variety of autopilot devices and ensures interoperability and reliabilty. + +Autopilot API requests are passed to a **provider plugin** which will process and action the request facilitating communication with the autopilot device. + +The following diagram provides an overview of the Autopilot API architectue. + + + +_Autopilot API architecture_ + + +## Provider Plugins: + +An autopilot provider plugin is a Signal K server plugin that implements the **Autopilot Provider Interface** which: +- Tells server the autopilot devices provided for by the plugin +- Registers the methods used to action requests passed from the server to perform autopilot operations. + +Note: multiple providers can be registered and each provider can manage one or more autopilot devices. + +The `AutopilotProvider` interface is defined as follows in _`@signalk/server-api`_: + +```typescript +interface AutopilotProvider { + getData(deviceId: string): Promise + getState(deviceId: string): Promise + setState(state: string, deviceId: string): Promise + getMode(deviceId: string): Promise + setMode(mode: string, deviceId: string): Promise + getTarget(deviceId: string): Promise + setTarget(value: number, deviceId: string): Promise + adjustTarget(value: number, deviceId: string): Promise + engage(deviceId: string): Promise + disengage(deviceId: string): Promise + tack(direction: TackGybeDirection, deviceId: string): Promise + gybe(direction: TackGybeDirection, deviceId: string): Promise + dodge(direction: TackGybeDirection, deviceId: string): Promise +} +``` + +**Note: An Autopilot Provider plugin MUST:** +- Implement all Autopilot API interface methods. +- Facilitate communication on the target autopilot device to send commands and retrieve both status and configuration information +- Ensure the `engaged` path attribute value is maintained to reflect the operational status of the autopilot. +- Map the `engage` and `disengage` operations to an appropriate autopilot device `state`. +- Set the state as `off-line` if the autopilot device is not connected or unreachable. + + +### Registering as an Autopilot Provider + +A provider plugin must register itself with the Autopilot API during start up by calling the `registerAutopilotProvider`. + +The function has the following signature: + +```typescript +app.registerAutopilotProvider(provider: AutopilotProvider, devices: string[]) +``` +where: + +- `provider`: is a valid **AutopilotProvider** object +- `devices`: is an array of identifiers for the autopilot devices managed by the plugin. + +_Example: Plugin registering as a routes & waypoints provider._ +```javascript +import { AutopilotProvider } from '@signalk/server-api' + +module.exports = function (app) { + + const plugin = { + id: 'mypluginid', + name: 'My autopilot Provider plugin' + } + + const autopilotProvider: AutopilotProvider = { + getData: (deviceId: string) => { return ... }, + getState: (deviceId: string) => { return ... }, + setState: (state: string, deviceId: string) => { return true }, + getMode: (deviceId: string) => { return ... }, + setMode: (mode: string, deviceId: string) => { ... }, + getTarget: (deviceId: string) => { return ... }, + setTarget(value: number, deviceId: string) => { ... }, + adjustTarget(value: number, deviceId: string) => { ... }, + engage: (deviceId: string) => { ... }, + disengage: (deviceId: string) => { ... }, + tack:(direction: TackGybeDirection, deviceId: string) => { ... }, + gybe:(direction: TackGybeDirection, deviceId: string) => { ... } + } + + const pilots = ['pilot1', 'pilot2'] + + plugin.start = function(options) { + ... + try { + app.registerAutopilotProvider(autopilotProvider, pilots) + } + catch (error) { + // handle error + } + } + + return plugin +} +``` + +### Updates from Autopilot device + +Updates from an autopilot device are sent to the Autopillot API via the `autopilotUpdate` interface method. + +Typically an autopilot provider plugin will call `autopilotUpdate` when receiving data from the autopilot device. + +_Note: All updates originating from the autopilot device, regardless of the communications protocol (NMEA2000, etc) should be sent to the Autopilot API using `autopilotUpdate`._ + +The function has the following signature: + +```typescript +app.autopilotUpdate(deviceID: string, attrib: AutopilotUpdateAttrib, value: Value) +``` +where: + +- `deviceId`: is the autopilot device identifier +- `attrib`: is the attribute / path being updated +- `value`: the new value. + +_Example:_ +```javascript +app.autopilotUpdate('my-pilot', 'target', 1.52789) +app.autopilotUpdate('my-pilot', 'mode', 'compass') +``` + +### Alarms from Autopilot device + +Alarms from an autopilot device are sent to the Autopillot API via the `autopilotAlarm` interface method. + +An autopilot provider plugin will call `autopilotAlarm` when the data received data from the autopilot device is an alarm. + +_Note: A set of normalised alarm names are defined and alarm messages from the autopilot device should be mapped to one of the following:_ + +- `waypointAdvance` +- `waypointArrival` +- `routeComplete` +- `xte` +- `heading` +- `wind` + +The function has the following signature: + +```typescript +app.autopilotAlarm(deviceID: string, alarmName: AutopilotAlarm, value: Notification) +``` +where: + +- `deviceId`: is the autopilot device identifier +- `alarmName`: string containing a normalised alarm name. +- `value`: is a Signal K Notification object. + +_Example:_ +```javascript +app.autopilotAlarm('my-pilot', 'waypointAdvance', { + state: 'alert' + method: ['sound'] + message: 'Waypoint Advance' +}) +``` + + +### Provider Methods: + +**`getData(deviceId)`**: This method returns an AutopilotInfo object containing the current data values and valid options for the supplied autopilot device identifier. + +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{AutopilotInfo}>` + +_Note: It is the responsibility of the autopilot provider plugin to map the value of `engaged` to the current `state`._ + + +_Example:_ +``` +GET signalk/v2/api/vessels/self/steering/autopilots/mypilot1 +``` +_AutopilotProvider method invocation:_ +```javascript +getData('mypilot1'); +``` + +_Returns:_ +```javascript +{ + options: { + states: [ + { + name: 'auto' // autopilot state name + engaged: true // actively steering + }, + { + name: 'standby' // autopilot state name + engaged: false // not actively steering + } + ] + modes: ['compass', 'gps', 'wind'] +}, + target: 0.326 + mode: 'compass' + state: 'auto' + engaged: true +} +``` + +--- +**`getState(deviceId)`**: This method returns the current state of the supplied autopilot device identifier. If the autopilot device is not connected or unreachable then `off-line` should be returned. + +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{string}>` + +_Example:_ +``` +GET signalk/v2/api/vessels/self/steering/autopilots/mypilot1/state +``` +_AutopilotProvider method invocation:_ +```javascript +getState('mypilot1'); +``` + +_Returns:_ +```javascript +'auto' +``` + +--- +**`setState(state, deviceI?)`**: This method sets the autopilot device with the supplied identifier to the supplied state value. + +- `state:` state value to set. Must be a valid state value. +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{boolean}>` indicating the new value of `engaged`. + +throws on error or if supplied state value is invalid. + +_Example:_ +```javascript +PUT signalk/v2/api/vessels/self/steering/autopilots/mypilot1/state {value: "standby"} +``` +_AutopilotProvider method invocation:_ +```javascript +setState('standby', 'mypilot1'); +``` + +_Returns:_ +```javascript +false +``` + +--- +**`getMode(deviceId)`**: This method returns the current mode of the supplied autopilot device identifier. + +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{string}>` + +_Example:_ +``` +GET signalk/v2/api/vessels/self/steering/autopilots/mypilot1/mode +``` +_AutopilotProvider method invocation:_ +```javascript +getMode('mypilot1'); +``` + +_Returns:_ +```javascript +'compass' +``` + +--- +**`setMode(mode, deviceId)`**: This method sets the autopilot device with the supplied identifier to the supplied mode value. + +- `mode:` mode value to set. Must be a valid mode value. +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error or if supplied mode value is invalid. + +_Example:_ +```javascript +PUT signalk/v2/api/vessels/self/steering/autopilots/mypilot1/mode {value: "gps"} +``` +_AutopilotProvider method invocation:_ +```javascript +setMode('gps', 'mypilot1'); +``` + +--- +**`setTarget(value, deviceId)`**: This method sets target for the autopilot device with the supplied identifier to the supplied value. + +- `value:` target value in radians. +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error or if supplied target value is outside the valid range. + +_Example:_ +```javascript +PUT signalk/v2/api/vessels/self/steering/autopilots/mypilot1/target {value: 0.361} +``` +_AutopilotProvider method invocation:_ +```javascript +setTarget(0.361, 'mypilot1'); +``` + +--- +**`adjustTarget(value, deviceId)`**: This method adjusts target for the autopilot device with the supplied identifier by the supplied value. + +- `value:` value in radians to add to current target value. +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error or if supplied target value is outside the valid range. + +_Example:_ +```javascript +PUT signalk/v2/api/vessels/self/steering/autopilots/mypilot1/target {value: 0.361} +``` +_AutopilotProvider method invocation:_ +```javascript +adjustTarget(0.0276, 'mypilot1'); +``` + +--- +**`engage(deviceId)`**: This method sets the state of the autopilot device with the supplied identifier to a state that is actively steering the vessel. + +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error. + +_Example:_ +```javascript +POST signalk/v2/api/vessels/self/steering/autopilots/mypilot1/engage +``` +_AutopilotProvider method invocation:_ +```javascript +engage('mypilot1'); +``` + +--- +**`disengage(deviceId)`**: This method sets the state of the autopilot device with the supplied identifier to a state that is NOT actively steering the vessel. + +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error. + +_Example:_ +```javascript +POST signalk/v2/api/vessels/self/steering/autopilots/mypilot1/disengage +``` +_AutopilotProvider method invocation:_ +```javascript +disengage('mypilot1'); +``` + +--- +**`tack(direction, deviceId)`**: This method instructs the autopilot device with the supplied identifier to perform a tack in the supplied direction. + +- `direction`: 'port' or 'starboard' +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error. + +_Example:_ +```javascript +POST signalk/v2/api/vessels/self/steering/autopilots/mypilot1/tack/port +``` +_AutopilotProvider method invocation:_ +```javascript +tack('port', 'mypilot1'); +``` + +--- +**`gybe(direction, deviceId)`**: This method instructs the autopilot device with the supplied identifier to perform a gybe in the supplied direction. + +- `direction`: 'port' or 'starboard' +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error. + +_Example:_ +```javascript +POST signalk/v2/api/vessels/self/steering/autopilots/mypilot1/gybe/starboard +``` +_AutopilotProvider method invocation:_ +```javascript +gybe('starboard', 'mypilot1'); +``` + +--- +**`dodge(direction, deviceId)`**: This method instructs the autopilot device with the supplied identifier to manually override the rudder position by two (2) degrees in the supplied direction. + +- `direction`: 'port' or 'starboard' +- `deviceId:` identifier of the autopilot device to query. + +returns: `Promise<{void}>` + +throws on error. + +_Example:_ +```javascript +POST signalk/v2/api/vessels/self/steering/autopilots/mypilot1/dodge/starboard +``` +_AutopilotProvider method invocation:_ +```javascript +dodge('starboard', 'mypilot1'); +``` + + +### Unhandled Operations + +A provider plugin **MUST** implement **ALL** Autopilot API interface methods, regardless of whether the operation is supported or not. + +For an operation that is not supported by the autopilot device, then the plugin should `throw` an exception. + +_Example:_ +```typescript +{ + // unsupported operation method definition + gybe: async (d, id) => { + throw new Error('Unsupprted operation!) + } +} +``` diff --git a/docs/src/develop/rest-api/autopilot_api.md b/docs/src/develop/rest-api/autopilot_api.md index 21e5229a7..eea6923e2 100644 --- a/docs/src/develop/rest-api/autopilot_api.md +++ b/docs/src/develop/rest-api/autopilot_api.md @@ -1,38 +1,334 @@ -# Autopilot API +# Working with the Autopilot API -#### (Under Development) -_Note: This API is currently under development and the information provided here is likely to change._ +## Overview -The Signal K server Autopilot API will provide a common set of operations for interacting with autopilot devices and (like the Resources API) will rely on a "provider plugin" to facilitate communication with the autopilot device. +The Autopilot API defines the `autopilots` path under the `steering` schema group _(e.g. `/signalk/v2/api/vessels/self/steering/autopilots`)_ for representing information from one or more autopilot devices. -The Autopilot API will handle requests to `/steering/autopilot` paths and pass them to an Autopilot Provider plugin which will send the commands to the autopilot device. +The Autopilot API provides a mechanism for applications to issue requests to autopilot devices to perform common operations. Additionally, when multiple autopilot devices are present, each autopilot device is individually addressable. -The following operations are an example of the operations identified for implementation via HTTP `GET` and `PUT` requests: + _Note: Autopilot provider plugins are required to enable the API operation and provide communication with autopilot devices. See [Autopilot Provider Plugins](../plugins/autopilot_provider_plugins.md) for details._ -PUT `/steering/autopilot/engage` (engage / activate the autopilot) +## The `Default` Autopilot -PUT `/steering/autopilot/disengage` (disengage / deactivate the autopilot) +To ensure a consistent API calling profile and to simplify client operations, the Autopilot API will assign a `default` autopilot device which is accessible using the path `./steering/autopilots/default`. -GET `/steering/autopilot/state` (retrieve the current autopilot state) +- When only one autopilot is present, it will be assigned as the `default`. -PUT `/steering/autopilot/state` (set the autopilot state) +- When multiple autopilots are present, and a `default` is yet to be assigned, one will be assigned when: + - An update is received from a provider plugin, the autopilot which is the source of the update will be assigned as the `default`. + - An API request is received, the first autopilot device registered, is assigned as the `default`. + - A request is sent to the `defaultPilot` API endpoint _(see [Setting the Default Autopilot](#setting-the-default-provider))_. -GET `/steering/autopilot/mode` (retrieve the current autopilot mode) +### Setting the Default Autopilot -PUT `/steering/autopilot/mode` (set autopilot mode) +To set / change the `default` autopilot, submit an HTTP `POST` request to `/signalk/v2/api/vessels/self/steering/autopilots/defaultPilot/{id}` where `{id}` is the identifier of the autopilot to use as the default. -GET `/steering/autopilot/target` (get currrent target value) +_Example:_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/defaultPilot/raymarine-id" +``` -PUT `/steering/autopilot/target` (set the target value based on the selected mode) +The autopilot with the supplied id will now be the target of requests made to `./steering/autopilots/default/*`. -PUT `/steering/autopilot/target/adjust` (increment / decrement target value) -PUT `/steering/autopilot/tack/port` (perform a port tack) +### Getting the Default Autopilot -PUT `/steering/autopilot/tack/starboard` (perform a starboard tack) +To get the id of the `default` autopilot, submit an HTTP `GET` request to `/signalk/v2/api/vessels/self/steering/autopilots/defaultPilot`. +_Example:_ +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/defaultPilot" +``` +_Response:_ +```JSON +{ + "id":"raymarine-id" +} +``` -[View the PR](https://github.com/SignalK/signalk-server/pull/1596) for more details. +## Listing the available Autopilots +To retrieve a list of installed autopilot devices, submit an HTTP `GET` request to `/signalk/v2/api/vessels/self/steering/autopilots`. + +The response will be an object containing all the registered autopilot devices, keyed by their identifier, detailing the `provider` it is registered by and whether it is assigned as the `default`. + +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots" +``` +_Example response: list of registered autopilots showing that `pypilot-id` is assigned as the `default`._ + +```JSON +{ + "pypilot-id": { + "provider":"pypilot-provider", + "isDefault": true + }, + "raymarine-id": { + "provider":"raymarine-provider", + "isDefault": false + } +} +``` + +## Autopilot Deltas + +Deltas emitted by the Autopilot API will have the base path `steering.autopilot` with the `$source` containing the autopilot device identifier. + +_Example: Deltas for `autopilot.engaged` from two autopilots (`raymarine-id`)._ +```JSON +{ + "context":"vessels.self", + "updates":[ + { + "$source":"pypilot-id", + "timestamp":"2023-11-19T06:12:47.820Z", + "values":[ + {"path":"steering.autopilot.engaged","value":false} + ] + }, + { + "$source":"raymarine-id", + "timestamp":"2023-11-19T06:12:47.820Z", + "values":[ + {"path":"steering.autopilot.engaged","value":true} + ] + } + ] +} +``` + + +## Autopilot Notifications + +The Autopilot API will provide notifications under the path `notifications.steering.autopilot` with the `$source` containing the autopilot device identifier. + +A set of normalised notification paths are defined to provide a consistant way for client apps to receive and process alarm messages. + +- `waypointAdvance` +- `waypointArrival` +- `routeComplete` +- `xte` +- `heading` +- `wind` + +_Example:_ +```JSON +{ + "context":"vessels.self", + "updates":[ + { + "$source":"pypilot-id", + "timestamp":"2023-11-19T06:12:47.820Z", + "values":[ + { + "path": "notifications.steering.autopilot.waypointAdvance", + "value": { + "state": "alert", + "method": ["sound"], + "message": "Waypoint Advance" + } + } + ] + } + ] +} + +``` + +## Autopilot offline / unreachable + +If an autopilot device is not connected or unreachable, the provider for that autopilot device will set the `state` of the device to `off-line`. + + +## Autopilot Operations + +All API operations are invoked by issuing a request to: +1. `/signalk/v2/api/vessels/self/steering/autopilots/default` +2. `/signalk/v2/api/vessels/self/steering/autopilots/{id}` + +_Example:_ +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/default/state" + +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/pypilot-id/mode" +``` + +### Retrieving Autopilot Status + +To retrieve the current autopilot configuration as well as a list of available options for `state` and `mode` selections, submit an HTTP `GET` request to `/signalk/v2/api/vessels/self/steering/autopilots/{id}`. + +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/{id}" +``` +_Response:_ + +```JSON +{ + "options":{ + "state":["enabled","disabled"], + "mode":["gps","compass","wind"] + }, + "state":"disabled", + "mode":"gps", + "target": 0, + "engaged": false +} +``` + +Where: +- `options` contains arrays of valid `state` and `mode` selection options +- `state` represents the current state of the device +- `mode` represents the current mode of the device +- `target` represents the current target value with respect to the selected `mode` +- `engaged` will be true when the autopilot is actively steering the vessel. + + +### Setting the Autopilot State + +Autopilot state can be set by submitting an HTTP `PUT` request to the `state` endpoint containing a value from the list of available states. + +```typescript +HTTP PUT "/signalk/v2/api/vessels/self/steering/autopilots/{id}/state" {"value": "disabled"} +``` + +### Getting the Autopilot State + +The current autopilot state can be retrieved by submitting an HTTP `GET` request to the `state` endpoint. + +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/{id}/state" +``` + +_Response:_ + +```JSON +{ + "value":"enabled", +} +``` + +### Setting the Autopilot Mode + +Autopilot mode can be set by submitting an HTTP `PUT` request to the `mode` endpoint containing a value from the list of available modes. + +```typescript +HTTP PUT "/signalk/v2/api/vessels/self/steering/autopilots/{id}/mode" {"value": "gps"} +``` + +### Getting the Autopilot Mode + +The current autopilot mode can be retrieved by submitting an HTTP `GET` request to the `mode` endpoint. + +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/{id}/mode" +``` + +_Response:_ + +```JSON +{ + "value":"gps", +} +``` + +### Setting the Target value + +Autopilot target value can be set by submitting an HTTP `PUT` request to the `target` endpoint containing the desired value in radians. + +_Note: The value supplied should be a number within the valid range for the selected `mode`._ + +```typescript +HTTP PUT "signalk/v2/api/vessels/self/steering/autopilots/{id}/target" {"value": 1.1412} +``` + +The target value can be adjusted a +/- value by submitting an HTTP `PUT` request to the `target/adjust` endpoint with the value to add to the current `target` value in radians. + +```typescript +HTTP PUT "signalk/v2/api/vessels/self/steering/autopilots/{id}/target/adjust" {"value": -0.1412} +``` + +### Getting the current Target value + +The current autopilot target value _(in radians)_ can be retrieved by submitting an HTTP `GET` request to the `target` endpoint. + +```typescript +HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/{id}/target" +``` + +_Response:_ + +```JSON +{ + "value":"3.14", +} +``` + +### Engaging / Disengaging the Autopilot + +#### Engaging the autopilot + +An autopilot can be engaged by [setting it to a speciifc `state`](#setting-the-state) but it can also be engaged more generically by submitting an HTTP `POST` request to the `engage` endpoint. + +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/engage" +``` + +_Note: The resultant `state` into which the autopilot is placed will be determined by the **provider plugin** and the autopilot device it is communicating with._ + +#### Disengaging the autopilot + +An autopilot can be disengaged by [setting it to a speciifc `state`](#setting-the-state) but it can also be disengaged more generically by submitting an HTTP `POST` request to the `disengage` endpoint. + +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/disengage" +``` + +_Note: The resultant `state` into which the autopilot is placed will be determined by the **provider plugin** and the autopilot device it is communicating with._ + +### Perform a Tack + +To send a command to the autopilot to perform a tack in the required direction, submit an HTTP `POST` request to `/tack/{direction}` where _direction_ is either `port` or `starboard`. + +_Example: Tack to Port_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/tack/port" +``` + +_Example: Tack to Starboard_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/tack/starboard" +``` + + +### Perform a Gybe + +To send a command to the autopilot to perform a gybe in the required direction, submit an HTTP `POST` request to `/gybe/{direction}` where _direction_ is either `port` or `starboard`. + +_Example: Gybe to Port_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/gybe/port" +``` + +_Example: Gybe to Starboard_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/gybe/starboard" +``` + + + +### Dodging / overriding + +To send a command to the autopilot to manually override the rudder position two (2) degrees in the requested direction in order to avoid an obstacle, +submit an HTTP `POST` request to `/dodge/{direction}` where _direction_ is either `port` or `starboard`. + +_Example: Dodge to Port_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/dodge/port" +``` + +_Example: Gybe to Starboard_ +```typescript +HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/{id}/dodge/starboard" +``` \ No newline at end of file diff --git a/docs/src/img/autopilot_provider.dia b/docs/src/img/autopilot_provider.dia new file mode 100644 index 000000000..7518cc3a1 Binary files /dev/null and b/docs/src/img/autopilot_provider.dia differ diff --git a/docs/src/img/autopilot_provider.svg b/docs/src/img/autopilot_provider.svg new file mode 100644 index 000000000..cdf601e15 --- /dev/null +++ b/docs/src/img/autopilot_provider.svg @@ -0,0 +1,705 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/server-api/.gitignore b/packages/server-api/.gitignore index 1521c8b76..a6c9ee411 100644 --- a/packages/server-api/.gitignore +++ b/packages/server-api/.gitignore @@ -1 +1,2 @@ dist +src/autopilotapi.guard.ts diff --git a/packages/server-api/package.json b/packages/server-api/package.json index f54d487eb..ca18fc07d 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc --declaration", + "generate": "ts-auto-guard src/autopilotapi.ts 2>/dev/null", + "build": "npm run generate && tsc --declaration", "watch": "tsc --declaration --watch", "prepublishOnly": "npm run build", "typedoc": "typedoc --out docs src", @@ -33,6 +34,7 @@ "express": "^4.10.4", "mocha": "^10.2.0", "prettier": "^2.8.4", + "ts-auto-guard": "^4.1.4", "ts-node": "^10.9.1", "typedoc": "^0.23.23", "typescript": "^4.1.5" diff --git a/packages/server-api/src/autopilotapi.ts b/packages/server-api/src/autopilotapi.ts index 8c9bf54ac..cf8c28d8a 100644 --- a/packages/server-api/src/autopilotapi.ts +++ b/packages/server-api/src/autopilotapi.ts @@ -1,27 +1,112 @@ +import { Notification, Value } from './deltas' + +export type AutopilotUpdateAttrib = + | 'mode' + | 'state' + | 'target' + | 'engaged' + | 'options' + | 'alarm' + +const AUTOPILOTUPDATEATTRIBS: AutopilotUpdateAttrib[] = [ + 'mode', + 'state', + 'target', + 'engaged', + 'options', + 'alarm' +] + +export const isAutopilotUpdateAttrib = (s: string) => + AUTOPILOTUPDATEATTRIBS.includes(s as AutopilotUpdateAttrib) + +export type AutopilotAlarm = + | 'waypointAdvance' + | 'waypointArrival' + | 'routeComplete' + | 'xte' + | 'heading' + | 'wind' + +const AUTOPILOTALARMS: AutopilotAlarm[] = [ + 'waypointAdvance', + 'waypointArrival', + 'routeComplete', + 'xte', + 'heading', + 'wind' +] + +export const isAutopilotAlarm = (s: string) => + AUTOPILOTALARMS.includes(s as AutopilotAlarm) + +export type TackGybeDirection = 'port' | 'starboard' + export interface AutopilotApi { - register: (pluginId: string, provider: AutopilotProvider) => void - unRegister: (pluginId: string) => void + register(pluginId: string, provider: AutopilotProvider): void + unRegister(pluginId: string): void + apUpdate( + pluginId: string, + deviceId: string, + attrib: AutopilotUpdateAttrib, + value: Value + ): void + apAlarm( + pluginId: string, + deviceId: string, + alarmName: AutopilotAlarm, + value: Notification + ): void } +/** @see {isAutopilotProvider} ts-auto-guard:type-guard */ export interface AutopilotProvider { - pilotType: string - methods: AutopilotProviderMethods + getData(deviceId: string): Promise + getState(deviceId: string): Promise + setState(state: string, deviceId: string): Promise + getMode(deviceId: string): Promise + setMode(mode: string, deviceId: string): Promise + getTarget(deviceId: string): Promise + setTarget(value: number, deviceId: string): Promise + adjustTarget(value: number, deviceId: string): Promise + engage(deviceId: string): Promise + disengage(deviceId: string): Promise + tack(direction: TackGybeDirection, deviceId: string): Promise + gybe(direction: TackGybeDirection, deviceId: string): Promise + dodge(direction: TackGybeDirection, deviceId: string): Promise +} + +export interface AutopilotStateDef { + name: string // autopilot state + engaged: boolean // true if state indicates actively steering +} + +export interface AutopilotOptions { + states: AutopilotStateDef[] + modes: string[] } -export interface AutopilotProviderMethods { - pluginId?: string - engage: (enable: boolean) => Promise - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getConfig: () => Promise<{ [key: string]: any }> - getState: () => Promise - setState: (state: string) => Promise - getMode: () => Promise - setMode: (mode: string) => Promise - setTarget: (value: number) => Promise - adjustTarget: (value: number) => Promise - tack: (port: boolean) => Promise +export interface AutopilotInfo { + options: AutopilotOptions + target: number | null + mode: string | null + state: string | null + engaged: boolean } export interface AutopilotProviderRegistry { - registerAutopilotProvider: (provider: AutopilotProvider) => void + registerAutopilotProvider( + provider: AutopilotProvider, + devices: string[] + ): void + autopilotUpdate( + deviceId: string, + attrib: AutopilotUpdateAttrib, + value: Value + ): void + autopilotAlarm( + deviceId: string, + alarmName: AutopilotAlarm, + value: Notification + ): void } diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index fc7a6c532..87930f2be 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -27,9 +27,11 @@ export * from './resourcetypes' export * from './resourcesapi' export { ResourceProviderRegistry } from './resourcesapi' import { ResourceProviderRegistry } from './resourcesapi' -import { PointDestination, RouteDestination, CourseInfo } from './coursetypes' - export * from './autopilotapi' +import { AutopilotProviderRegistry } from './autopilotapi' +export { AutopilotProviderRegistry } from './autopilotapi' +export * from './autopilotapi.guard' +import { PointDestination, RouteDestination, CourseInfo } from './coursetypes' export type SignalKApiId = | 'resources' @@ -63,7 +65,8 @@ export interface PropertyValuesEmitter { export interface PluginServerApp extends PropertyValuesEmitter, - ResourceProviderRegistry {} + ResourceProviderRegistry, + AutopilotProviderRegistry {} /** * This is the API that a [server plugin](https://github.com/SignalK/signalk-server/blob/master/SERVERPLUGINS.md) must implement. diff --git a/src/api/autopilot/index.ts b/src/api/autopilot/index.ts new file mode 100644 index 000000000..8c6649459 --- /dev/null +++ b/src/api/autopilot/index.ts @@ -0,0 +1,695 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:autopilot') + +import { IRouter, NextFunction, Request, Response } from 'express' +import { WithSecurityStrategy } from '../../security' + +import { Responses } from '../' +import { SignalKMessageHub } from '../../app' + +import { + AutopilotProvider, + AutopilotInfo, + SKVersion, + Path, + Value, + Notification, + Delta, + isAutopilotProvider, + AutopilotUpdateAttrib, + isAutopilotUpdateAttrib, + AutopilotAlarm, + isAutopilotAlarm +} from '@signalk/server-api' + +const AUTOPILOT_API_PATH = `/signalk/v2/api/vessels/self/steering/autopilots` +const DEFAULTIDPATH = 'default' + +interface AutopilotApplication + extends WithSecurityStrategy, + SignalKMessageHub, + IRouter {} + +interface AutopilotList { + [id: string]: { provider: string; isDefault: boolean } +} + +export class AutopilotApi { + private autopilotProviders: Map = new Map() + + private defaultProviderId?: string + private defaultDeviceId?: string + private deviceToProvider: Map = new Map() + + constructor(private server: AutopilotApplication) {} + + async start() { + this.initApiEndpoints() + return Promise.resolve() + } + + // ***** Plugin Interface methods ***** + + // Register plugin as provider. + register(pluginId: string, provider: AutopilotProvider, devices: string[]) { + debug(`** Registering provider(s)....${pluginId} ${provider}`) + + if (!provider) { + throw new Error(`Error registering provider ${pluginId}!`) + } + if (!devices) { + throw new Error(`${pluginId} has not supplied a device list!`) + } + if (!isAutopilotProvider(provider)) { + throw new Error( + `${pluginId} is missing AutopilotProvider properties/methods!` + ) + } else { + if (!this.autopilotProviders.has(pluginId)) { + this.autopilotProviders.set(pluginId, provider) + } + devices.forEach((id: string) => { + if (!this.deviceToProvider.has(id)) { + this.deviceToProvider.set(id, pluginId) + } + }) + } + debug( + `No. of AutoPilotProviders registered =`, + this.autopilotProviders.size + ) + } + + // Unregister plugin as provider. + unRegister(pluginId: string) { + if (!pluginId) { + return + } + debug(`** Request to un-register plugin.....${pluginId}`) + + if (!this.autopilotProviders.has(pluginId)) { + debug(`** NOT FOUND....${pluginId}... cannot un-register!`) + return + } + + debug(`** Un-registering autopilot provider....${pluginId}`) + this.autopilotProviders.delete(pluginId) + + debug(`** Update deviceToProvider Map .....${pluginId}`) + this.deviceToProvider.forEach((v: string, k: string) => { + debug('k', k, 'v', v) + if (v === pluginId) { + this.deviceToProvider.delete(k) + } + }) + + // update default if required + if (pluginId === this.defaultProviderId) { + debug(`** Resetting defaults .....`) + this.defaultDeviceId = undefined + this.defaultProviderId = undefined + this.emitDeltaMsg('defaultPilot', this.defaultDeviceId, 'autopilotApi') + } + + debug( + `Remaining number of AutoPilot Providers registered =`, + this.autopilotProviders.size, + 'defaultProvider =', + this.defaultProviderId + ) + } + + // Pass changed attribute / value from autopilot. + apUpdate( + pluginId: string, + deviceId: string = pluginId + '.default', + attrib: AutopilotUpdateAttrib, + value: Value + ) { + if (deviceId && !this.deviceToProvider.has(deviceId)) { + this.deviceToProvider.set(deviceId, pluginId) + } + if (isAutopilotUpdateAttrib(attrib)) { + try { + if (!this.defaultDeviceId) { + this.initDefaults(deviceId) + } + this.emitDeltaMsg(attrib, value, deviceId) + } catch (err) { + debug(`ERROR apUpdate(): ${pluginId}->${deviceId}`, err) + } + } else { + debug( + `ERROR apUpdate(): ${pluginId}->${deviceId}`, + `${attrib} is NOT an AutopilotUpdateAttrib!` + ) + } + } + + // Pass alarm / notification from autopilot. + apAlarm( + pluginId: string, + deviceId: string = pluginId + '.default', + alarmName: AutopilotAlarm, + value: Notification + ) { + if (isAutopilotAlarm(alarmName)) { + debug(`Alarm -> ${deviceId}:`, value) + this.server.handleMessage(deviceId, { + updates: [ + { + values: [ + { + path: `notifications.steering.autopilot.${alarmName}` as Path, + value: value + } + ] + } + ] + }) + } + } + + // ***** /Plugin Interface methods ***** + + private updateAllowed(request: Request): boolean { + return this.server.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'steering.autopilot' + ) + } + + private initApiEndpoints() { + debug(`** Initialise ${AUTOPILOT_API_PATH} endpoints. **`) + + this.server.use( + `${AUTOPILOT_API_PATH}/*`, + (req: Request, res: Response, next: NextFunction) => { + debug(`Autopilot path`, req.method, req.params) + try { + if (['PUT', 'POST'].includes(req.method)) { + debug(`Autopilot`, req.method, req.path, req.body) + if (!this.updateAllowed(req)) { + res.status(403).json(Responses.unauthorised) + } else { + next() + } + } else { + debug(`Autopilot`, req.method, req.path, req.query, req.body) + next() + } + } catch (err: any) { + res.status(500).json({ + state: 'FAILED', + statusCode: 500, + message: err.message ?? 'No autopilots available!' + }) + } + } + ) + + // get autopilot provider information + this.server.get(`${AUTOPILOT_API_PATH}`, (req: Request, res: Response) => { + res.status(200).json(this.getDevices()) + }) + + // get default autopilot device + this.server.get( + `${AUTOPILOT_API_PATH}/defaultPilot`, + (req: Request, res: Response) => { + debug(`params:`, req.params) + res.status(Responses.ok.statusCode).json({ id: this.defaultDeviceId }) + } + ) + + // set default autopilot device + this.server.post( + `${AUTOPILOT_API_PATH}/defaultPilot/:id`, + (req: Request, res: Response) => { + debug(`params:`, req.params) + if (!this.deviceToProvider.has(req.params.id)) { + debug('** Invalid device id supplied...') + res.status(Responses.invalid.statusCode).json(Responses.invalid) + return + } + this.initDefaults(req.params.id) + res.status(Responses.ok.statusCode).json(Responses.ok) + } + ) + + // get default autopilot status & options + this.server.get( + `${AUTOPILOT_API_PATH}/:id`, + (req: Request, res: Response) => { + this.useProvider(req) + .getData(req.params.id) + .then((data: AutopilotInfo) => { + res.json(data) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // get autopilot options + this.server.get( + `${AUTOPILOT_API_PATH}/:id/options`, + (req: Request, res: Response) => { + this.useProvider(req) + .getData(req.params.id) + .then((r: AutopilotInfo) => { + res.json(r.options) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // engage / enable the autopilot + this.server.post( + `${AUTOPILOT_API_PATH}/:id/engage`, + (req: Request, res: Response) => { + this.useProvider(req) + .engage(req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // disengage / disable the autopilot + this.server.post( + `${AUTOPILOT_API_PATH}/:id/disengage`, + (req: Request, res: Response) => { + this.useProvider(req) + .disengage(req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // get state + this.server.get( + `${AUTOPILOT_API_PATH}/:id/state`, + (req: Request, res: Response) => { + this.useProvider(req) + .getState(req.params.id) + .then((r: string) => { + res.json({ value: r }) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // set state + this.server.put( + `${AUTOPILOT_API_PATH}/:id/state`, + (req: Request, res: Response) => { + if (typeof req.body.value === 'undefined') { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + return + } + this.useProvider(req) + .setState(req.body.value, req.params.id) + .then((r: boolean) => { + debug('engaged =', r) + this.emitDeltaMsg('engaged', r, req.params.id) + if (req.params.id === this.defaultDeviceId) { + this.emitDeltaMsg('engaged', r, DEFAULTIDPATH) + } + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch(() => { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + }) + } + ) + + // get mode + this.server.get( + `${AUTOPILOT_API_PATH}/:id/mode`, + (req: Request, res: Response) => { + this.useProvider(req) + .getMode(req.params.id) + .then((r: string) => { + res.json({ value: r }) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // set mode + this.server.put( + `${AUTOPILOT_API_PATH}/:id/mode`, + (req: Request, res: Response) => { + if (typeof req.body.value === 'undefined') { + res.status(400).json(Responses.invalid) + return + } + this.useProvider(req) + .setMode(req.body.value, req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch(() => { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + }) + } + ) + + // get target + this.server.get( + `${AUTOPILOT_API_PATH}/:id/target`, + (req: Request, res: Response) => { + this.useProvider(req) + .getTarget(req.params.id) + .then((r: number) => { + res.json({ value: r }) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // set target + this.server.put( + `${AUTOPILOT_API_PATH}/:id/target`, + (req: Request, res: Response) => { + if (typeof req.body.value !== 'number') { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + return + } + if (req.body.value < 0 - Math.PI || req.body.value > 2 * Math.PI) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: `Error: Value supplied is outside of the valid range (-PI < value < 2*PI radians).` + }) + return + } + this.useProvider(req) + .setTarget(req.body.value, req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch(() => { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + }) + } + ) + + // adjust target + this.server.put( + `${AUTOPILOT_API_PATH}/:id/target/adjust`, + (req: Request, res: Response) => { + if (typeof req.body.value !== 'number') { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + return + } + this.useProvider(req) + .adjustTarget(req.body.value, req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch(() => { + res.status(Responses.invalid.statusCode).json(Responses.invalid) + }) + } + ) + + // port tack + this.server.post( + `${AUTOPILOT_API_PATH}/:id/tack/port`, + (req: Request, res: Response) => { + this.useProvider(req) + .tack('port', req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // starboard tack + this.server.post( + `${AUTOPILOT_API_PATH}/:id/tack/starboard`, + (req: Request, res: Response) => { + this.useProvider(req) + .tack('starboard', req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // port gybe + this.server.post( + `${AUTOPILOT_API_PATH}/:id/gybe/port`, + (req: Request, res: Response) => { + this.useProvider(req) + .gybe('port', req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // starboard gybe + this.server.post( + `${AUTOPILOT_API_PATH}/:id/gybe/starboard`, + (req: Request, res: Response) => { + this.useProvider(req) + .gybe('starboard', req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // dodge to port + this.server.post( + `${AUTOPILOT_API_PATH}/:id/dodge/port`, + (req: Request, res: Response) => { + this.useProvider(req) + .dodge('port', req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // dodge to starboard + this.server.post( + `${AUTOPILOT_API_PATH}/:id/dodge/starboard`, + (req: Request, res: Response) => { + this.useProvider(req) + .dodge('starboard', req.params.id) + .then(() => { + res.status(Responses.ok.statusCode).json(Responses.ok) + }) + .catch((err) => { + res.status(err.statusCode ?? 500).json({ + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + }) + }) + } + ) + + // error response + this.server.use( + `${AUTOPILOT_API_PATH}/*`, + (err: any, req: Request, res: Response, next: NextFunction) => { + const msg = { + state: err.state ?? 'FAILED', + statusCode: err.statusCode ?? 500, + message: err.message ?? 'No autopilots available!' + } + if (res.headersSent) { + console.log('EXCEPTION: headersSent') + return next(msg) + } + res.status(500).json(msg) + } + ) + } + + // returns provider to use. + private useProvider(req: Request): AutopilotProvider { + debug(`useProvider(${req.params.id})`) + + if (req.params.id === DEFAULTIDPATH) { + if (!this.defaultDeviceId) { + this.initDefaults() + } + if ( + this.defaultProviderId && + this.autopilotProviders.has(this.defaultProviderId) + ) { + debug(`Using default device provider...`) + return this.autopilotProviders.get( + this.defaultProviderId + ) as AutopilotProvider + } else { + debug(`No default device provider...`) + throw Responses.invalid + } + } else { + const pid = this.deviceToProvider.get(req.params.id) as string + if (this.autopilotProviders.has(pid)) { + debug(`Found provider...using ${pid}`) + return this.autopilotProviders.get(pid) as AutopilotProvider + } else { + debug('Cannot get Provider!') + throw Responses.invalid + } + } + } + + // Returns an array of provider info + private getDevices(): AutopilotList { + const pilots: AutopilotList = {} + this.deviceToProvider.forEach((providerId: string, deviceId: string) => { + pilots[deviceId] = { + provider: providerId, + isDefault: deviceId === this.defaultDeviceId + } + }) + return pilots + } + + /** Initialises the value of default device / provider. + * If id is not supplied sets first registered device as the default. + **/ + private initDefaults(deviceId?: string) { + debug(`initDefaults()...${deviceId}`) + + // set to supplied deviceId + if (deviceId && this.deviceToProvider.has(deviceId)) { + this.defaultDeviceId = deviceId + this.defaultProviderId = this.deviceToProvider.get( + this.defaultDeviceId + ) as string + this.emitDeltaMsg('defaultPilot', this.defaultDeviceId, 'autopilotApi') + debug(`Default Device = ${this.defaultDeviceId}`) + debug(`Default Provider = ${this.defaultProviderId}`) + return + } + + // else set to first AP device registered + if (this.deviceToProvider.size !== 0) { + const k = this.deviceToProvider.keys() + this.defaultDeviceId = k.next().value as string + this.defaultProviderId = this.deviceToProvider.get( + this.defaultDeviceId + ) as string + this.emitDeltaMsg('defaultPilot', this.defaultDeviceId, 'autopilotApi') + debug(`Default Device = ${this.defaultDeviceId}`) + debug(`Default Provider = ${this.defaultProviderId}`) + return + } else { + throw new Error( + 'Cannot set defaultDevice....No autopilot devices registered!' + ) + } + } + + // emit delta updates on operation success + private emitDeltaMsg(path: string, value: any, source: string) { + const msg: Delta = { + updates: [ + { + values: [ + { + path: `steering.autopilot${path ? '.' + path : ''}` as Path, + value: value + } + ] + } + ] + } + debug(`delta -> ${source}:`, msg.updates[0]) + this.server.handleMessage(source, msg, SKVersion.v2) + this.server.handleMessage(source, msg, SKVersion.v1) + } +} diff --git a/src/api/autopilot/openApi.json b/src/api/autopilot/openApi.json new file mode 100644 index 000000000..663a18881 --- /dev/null +++ b/src/api/autopilot/openApi.json @@ -0,0 +1,716 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Autopilot API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "url": "/signalk/v2/api/vessels/self/steering/autopilots" + } + ], + "tags": [ + { + "name": "autopilot", + "description": "Signal K Autopilot API" + } + ], + "components": { + "schemas": { + "autopilotStateOption": { + "type": "object", + "title": "Autopilot state option definition", + "description": "Autopilot `state` option and indication whether pilot is actively steering.", + "properties": { + "name": { + "type": "string", + "description": "State name / label", + "example": "enabled" + }, + "engaged": { + "type": "boolean", + "description": "Set `true` if pilot is actively steering when in this `state`.", + "example": true + } + }, + "example": [ + { "name": "auto", "engaged": true }, + { "name": "standby", "engaged": false } + ] + }, + "autopilotOptions": { + "type": "object", + "title": "Autopilot configuration options", + "description": "A collection of configuration options and their valid values", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "states": { + "type": "array", + "items": { + "$ref": "#/components/schemas/autopilotStateOption" + }, + "description": "List of valid autopilot states." + }, + "modes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of valid Mode values.", + "example": ["compass", "gps"] + } + } + } + }, + "responses": { + "200ActionResponse": { + "description": "PUT OK response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["COMPLETED"] + }, + "statusCode": { + "type": "number", + "enum": [200] + } + }, + "required": ["statusCode", "state"] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": ["FAILED"] + }, + "statusCode": { + "type": "number", + "enum": [400, 404] + }, + "message": { + "type": "string" + } + }, + "required": ["state", "statusCode", "message"] + } + } + } + } + }, + "parameters": { + "AutopilotIdParam": { + "name": "id", + "in": "path", + "description": "autopilot id", + "required": true, + "schema": { + "type": "string" + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], + "paths": { + "/": { + "get": { + "tags": ["autopilot"], + "summary": "Retrieve list of autopilots.", + "description": "Returns a list of autopilots indexed by their identifier.", + "responses": { + "default": { + "description": "Autopilot device list response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["provider", "isDefault"], + "properties": { + "provider": { + "type": "string", + "description": "Provider plugin managing the autopilot device.", + "example": "my-pilot-provider" + }, + "isDefault": { + "type": "boolean", + "description": "Set to true when the autopilot is currently set as the default.", + "example": "false" + } + } + } + } + } + } + }, + "error": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/defaultPilot/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "get": { + "tags": ["autopilot"], + "summary": "Get the default autopilot device id.", + "description": "Returns the device id of the autopilot assigned as the default.", + "responses": { + "default": { + "description": "Autopilot configuration response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string" + } + } + } + } + } + } + } + }, + "post": { + "tags": ["autopilot"], + "summary": "Set the default autopilot device.", + "description": "Sets the autopilot with the supplied `id` as the default.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "get": { + "tags": ["autopilot"], + "summary": "Retrieve autopilot information.", + "description": "Returns the current state autopilot information including the available options for `state` and `mode`.", + "responses": { + "default": { + "description": "Autopilot configuration response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["state", "mode", "target", "engaged"], + "properties": { + "engaged": { + "type": "boolean", + "description": "Autopilot is engaged and actively steering the vessel", + "example": "true" + }, + "state": { + "type": "string", + "description": "Autopilot state", + "example": "auto" + }, + "mode": { + "type": "string", + "description": "Autopilot operational mode", + "example": "compass" + }, + "target": { + "description": "Current target value (radians)", + "type": "number", + "example": 1.25643 + }, + "options": { + "$ref": "#/components/schemas/autopilotOptions" + } + } + } + } + } + }, + "error": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/options": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "get": { + "tags": ["autopilot"], + "summary": "Retrieve autopilot options.", + "description": "Returns the selectable options and the values that can be applied (e.g. for`state` and `mode`).", + "responses": { + "default": { + "description": "Autopilot configuration response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/autopilotOptions" + } + } + } + }, + "error": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/engage": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Engage autopilot to steer vessel", + "description": "Provider plugin will set the autopilot to a `state` where it is actively steering the vessel. `state` selected is determined by the provider plugin.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/disengage": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Disengage autopilot from steering vessel.", + "description": "Provider plugin will set the autopilot to a `state` where it is NOT actively steering the vessel. `state` selected is determined by the provider plugin.", + + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/state": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "get": { + "tags": ["autopilot"], + "summary": "Retrieve the current state.", + "description": "Returns the current `state` value from the autopilot.", + "responses": { + "default": { + "description": "Autopilot value response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "example": "standby" + } + } + } + } + } + }, + "error": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": ["autopilot"], + "summary": "Set autopilot state.", + "description": "Set the autopilot to the supplied valid `state` value.", + "requestBody": { + "description": "Supply valid `state` value (as per response from autopilot information request).", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value representing the `state` the autopilot is to enter.", + "example": "enabled" + } + } + } + } + } + }, + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/mode": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "get": { + "tags": ["autopilot"], + "summary": "Retrieve the current mode.", + "description": "Returns the current `mode` value from the autopilot.", + "responses": { + "default": { + "description": "Autopilot value response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "example": "compass" + } + } + } + } + } + }, + "error": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": ["autopilot"], + "summary": "Set autopilot mode", + "description": "Set the autopilot to the supplied valid `mode` value.", + "requestBody": { + "description": "Supply valid `mode` value (as per response from autopilot information request).", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value representing the `mode` the autopilot is to enter.", + "example": "compass" + } + } + } + } + } + }, + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/target": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "get": { + "tags": ["autopilot"], + "summary": "Retrieve the current target value (radians).", + "description": "Value in radians of the current target value.", + "responses": { + "default": { + "description": "Autopilot value response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "type": "number", + "description": "Value in radians", + "min": -3.141592653589793, + "max": 6.283185307179586, + "example": -1.23789 + } + } + } + } + } + }, + "error": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "put": { + "tags": ["autopilot"], + "summary": "Set autopilot `target` value (radians).", + "description": "Value supplied must be in the valid range between -PI & 2*PI.", + "requestBody": { + "description": "Value in radians within the bounds of the current autopilot mode. (i.e. compass: 0 to 2*Pi, wind angle: -Pi to Pi)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "number", + "description": "Value in radians", + "min": -3.141592653589793, + "max": 6.283185307179586, + "example": -1.23789 + } + } + } + } + } + }, + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/target/adjust": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "put": { + "tags": ["autopilot"], + "summary": "Adjust autopilot target value by +/- radians.", + "description": "Value supplied will be added to the current target value. The result must be in the valid range between -PI & 2*PI.", + "requestBody": { + "description": "A value in radians to add to the current `target` value.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "number", + "description": "Value in radians.", + "min": -0.1745, + "max": 0.1745, + "example": -0.23789 + } + } + } + } + } + }, + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/tack/port": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Tack to port.", + "description": "Execute a port tack.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/tack/starboard": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Tack to starboard.", + "description": "Execute a starboard tack.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/gybe/port": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Gybe to port.", + "description": "Execute a gybe to port.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/gybe/starboard": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Gybe to starboard.", + "description": "Execute a gybe to starboard.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/dodge/port": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Turn to port.", + "description": "Manual override turn to port.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/dodge/starboard": { + "parameters": [ + { + "$ref": "#/components/parameters/AutopilotIdParam" + } + ], + "post": { + "tags": ["autopilot"], + "summary": "Turn to starboard.", + "description": "Manual override turn to starboard.", + "responses": { + "200ActionResponse": { + "$ref": "#/components/responses/200ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } +} diff --git a/src/api/autopilot/openApi.ts b/src/api/autopilot/openApi.ts new file mode 100644 index 000000000..e8425c96d --- /dev/null +++ b/src/api/autopilot/openApi.ts @@ -0,0 +1,8 @@ +import { OpenApiDescription } from '../swagger' +import autopilotApiDoc from './openApi.json' + +export const autopilotApiRecord = { + name: 'autopilot', + path: '/signalk/v2/api/vessels/self/steering/autopilot', + apiDoc: autopilotApiDoc as unknown as OpenApiDescription +} diff --git a/src/api/index.ts b/src/api/index.ts index a3ccff4ea..c629ccb39 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,6 +4,7 @@ import { WithSecurityStrategy } from '../security' import { CourseApi } from './course' import { FeaturesApi } from './discovery' import { ResourcesApi } from './resources' +import { AutopilotApi } from './autopilot' import { SignalKApiId } from '@signalk/server-api' export interface ApiResponse { @@ -56,8 +57,18 @@ export const startApis = ( ;(app as any).courseApi = courseApi apiList.push('course') + const autopilotApi = new AutopilotApi(app) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(app as any).autopilotApi = autopilotApi + apiList.push('autopilot') + const featuresApi = new FeaturesApi(app) - Promise.all([resourcesApi.start(), courseApi.start(), featuresApi.start()]) + Promise.all([ + resourcesApi.start(), + courseApi.start(), + featuresApi.start(), + autopilotApi.start() + ]) return apiList } diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 028b8719e..ed2842545 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -5,6 +5,7 @@ import { SERVERROUTESPREFIX } from '../constants' import { courseApiRecord } from './course/openApi' import { notificationsApiRecord } from './notifications/openApi' import { resourcesApiRecord } from './resources/openApi' +import { autopilotApiRecord } from './autopilot/openApi' import { securityApiRecord } from './security/openApi' import { discoveryApiRecord } from './discovery/openApi' import { appsApiRecord } from './apps/openApi' @@ -26,10 +27,11 @@ interface ApiRecords { const apiDocs = [ discoveryApiRecord, appsApiRecord, - securityApiRecord, + autopilotApiRecord, courseApiRecord, notificationsApiRecord, - resourcesApiRecord + resourcesApiRecord, + securityApiRecord ].reduce((acc, apiRecord: OpenApiRecord) => { acc[apiRecord.name] = apiRecord return acc diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index 71ab1f9bf..772c3cf29 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -21,8 +21,13 @@ import { PropertyValues, PropertyValuesCallback, ResourceProvider, + AutopilotProvider, ServerAPI, RouteDestination, + AutopilotUpdateAttrib, + AutopilotAlarm, + Value, + Notification, SignalKApiId } from '@signalk/server-api' // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -32,6 +37,7 @@ import express, { Request, Response } from 'express' import fs from 'fs' import _ from 'lodash' import path from 'path' +import { AutopilotApi } from '../api/autopilot' import { CourseApi } from '../api/course' import { ResourcesApi } from '../api/resources' import { SERVERROUTESPREFIX } from '../constants' @@ -469,9 +475,10 @@ module.exports = (theApp: any) => { console.error(`${plugin.id}:no configuration data`) safeConfiguration = {} } - onStopHandlers[plugin.id].push(() => + onStopHandlers[plugin.id].push(() => { app.resourcesApi.unRegister(plugin.id) - ) + app.autopilotApi.unRegister(plugin.id) + }) plugin.start(safeConfiguration, restart) debug('Started plugin ' + plugin.name) setPluginStartedMessage(plugin) @@ -557,6 +564,29 @@ module.exports = (theApp: any) => { resourcesApi.register(plugin.id, provider) } + const autopilotApi: AutopilotApi = app.autopilotApi + _.omit(appCopy, 'autopilotApi') // don't expose the actual autopilot api manager + appCopy.registerAutopilotProvider = ( + provider: AutopilotProvider, + devices: string[] + ) => { + autopilotApi.register(plugin.id, provider, devices) + } + appCopy.autopilotUpdate = ( + deviceId: string, + attrib: AutopilotUpdateAttrib, + value: Value + ) => { + autopilotApi.apUpdate(plugin.id, deviceId, attrib, value) + } + appCopy.autopilotAlarm = ( + deviceId: string, + alarmName: AutopilotAlarm, + value: Notification + ) => { + autopilotApi.apAlarm(plugin.id, deviceId, alarmName, value) + } + _.omit(appCopy, 'apiList') // don't expose the actual apiList const courseApi: CourseApi = app.courseApi