signalk-mcp-server
Version:
MCP server for SignalK marine data integration
1,174 lines (1,170 loc) • 49.3 kB
JavaScript
import { Client } from '@signalk/client';
import { EventEmitter } from 'events';
export class SignalKClient extends EventEmitter {
hostname;
port;
useTLS;
originalUrl;
context;
connected;
latestValues;
availablePaths;
aisTargets;
activeAlarms;
client;
token;
constructor(options = {}) {
super();
// Set SignalK connection configuration directly from environment variables
this.setSignalKConfig(options);
// Store authentication token for HTTP requests
this.token = options.token || process.env.SIGNALK_TOKEN;
// WEBSOCKET CLIENT PRESERVED FOR FUTURE STREAMING SUPPORT
// When MCP servers support streaming, this WebSocket client will enable
// real-time data updates for live vessel tracking, sensor monitoring, etc.
// Currently operating in HTTP-only mode for guaranteed data freshness.
this.client = new Client({
hostname: this.hostname,
port: this.port,
useTLS: this.useTLS,
reconnect: true,
autoConnect: false, // WebSocket connection disabled for HTTP-only mode
notifications: true,
token: this.token,
subscribe: 'all',
useHttp: false,
subscriptions: [
{
context: '*',
subscribe: [
{
path: '*',
period: 1000,
format: 'delta',
policy: 'ideal',
minPeriod: 200,
},
],
},
],
});
this.context =
options.context || process.env.SIGNALK_CONTEXT || 'vessels.self';
this.connected = false;
// DATA STRUCTURES PRESERVED FOR FUTURE STREAMING SUPPORT
// These Maps/Sets would be populated via WebSocket deltas when streaming is enabled
this.latestValues = new Map(); // Would cache real-time sensor values
this.availablePaths = new Set(); // Would track discovered paths from deltas
this.aisTargets = new Map(); // Would track real-time AIS vessel movements
this.activeAlarms = new Map(); // Would track alarm state changes in real-time
this.setupEventHandlers();
}
/**
* Set SignalK connection configuration from environment variables with sensible defaults
*
* Environment Variables:
* - SIGNALK_HOST: Hostname/IP (default: 'localhost')
* - SIGNALK_PORT: Port number (default: 3000)
* - SIGNALK_TLS: Use secure connections - true/false (default: false)
*
* Sets instance properties:
* - hostname: The server hostname/IP (e.g., 'localhost', '192.168.1.100')
* - port: The server port (e.g., 3000, 443, 80)
* - useTLS: Whether to use secure connections (WSS/HTTPS vs WS/HTTP)
*
* @param options - Override options
*/
setSignalKConfig(options = {}) {
// Set configuration directly from environment variables with defaults
this.hostname = options.hostname || process.env.SIGNALK_HOST || 'localhost';
const portValue = parseInt(String(options.port || process.env.SIGNALK_PORT || '3000'));
this.port = isNaN(portValue) ? 3000 : portValue;
this.useTLS = options.useTLS || process.env.SIGNALK_TLS === 'true' || false;
// Build original URL for display purposes
const protocol = this.useTLS ? 'wss://' : 'ws://';
this.originalUrl = `${protocol}${this.hostname}:${this.port}`;
}
/**
* Build WebSocket URL for streaming connections
* @returns WebSocket URL (ws:// or wss://)
*/
buildWebSocketUrl() {
const protocol = this.useTLS ? 'wss:' : 'ws:';
const port = this.port === (this.useTLS ? 443 : 80) ? '' : `:${this.port}`;
return `${protocol}//${this.hostname}${port}`;
}
/**
* Build HTTP URL for REST API calls
* @returns HTTP URL (http:// or https://)
*/
buildHttpUrl() {
const protocol = this.useTLS ? 'https:' : 'http:';
const port = this.port === (this.useTLS ? 443 : 80) ? '' : `:${this.port}`;
return `${protocol}//${this.hostname}${port}`;
}
/**
* Build SignalK REST API URL for a specific vessel and path
* @param vesselContext - Vessel context (e.g., 'self', 'urn:mrn:imo:mmsi:123456789')
* @param path - SignalK path in dot notation (e.g., 'navigation.position')
* @returns Complete REST API URL
*/
buildRestApiUrl(vesselContext = 'self', path = '') {
const baseUrl = this.buildHttpUrl();
const restPath = path ? `/${path.replace(/\./g, '/')}` : '';
return `${baseUrl}/signalk/v1/api/vessels/${vesselContext}${restPath}`;
}
/**
* Build fetch options with authentication headers if token is configured
* @returns RequestInit object with authorization header if token exists
*/
buildFetchOptions() {
const options = {};
if (this.token) {
options.headers = {
'Authorization': `Bearer ${this.token}`,
};
}
return options;
}
/**
* Sets up WebSocket event handlers for connection, disconnection, errors, and delta messages
*
* Event handlers:
* - 'connect': Sets connected flag and emits 'connected' event
* - 'disconnect': Clears connected flag and emits 'disconnected' event
* - 'error': Logs errors and emits 'error' event
* - 'delta': Processes incoming SignalK delta messages with vessel data updates
*
* @example
* const client = new SignalKClient();
* client.on('connected', () => console.log('Connected to SignalK'));
* client.on('delta', (delta) => console.log('Received data:', delta));
*/
setupEventHandlers() {
this.client.on('connect', () => {
this.connected = true;
console.error('SignalK client connected');
this.emit('connected');
});
this.client.on('disconnect', () => {
this.connected = false;
console.error('SignalK client disconnected');
this.emit('disconnected');
});
this.client.on('error', (error) => {
console.error('SignalK client error:', error);
this.emit('error', error);
});
this.client.on('delta', (delta) => {
this.handleDelta(delta);
});
}
/**
* Establishes connection to SignalK server (HTTP-only mode)
*
* This method now operates in HTTP-only mode for maximum data freshness.
* WebSocket functionality is preserved but disabled for future streaming capabilities
* when MCP servers support real-time data streams.
*
* Features:
* - Tests HTTP connectivity to SignalK server
* - Sets connected status based on HTTP availability
* - WebSocket code preserved for future streaming implementation
*
* @returns Promise that resolves when HTTP connection is verified
*
* @example
* const client = new SignalKClient({ hostname: 'localhost', port: 3000 });
* try {
* await client.connect();
* console.log('Connected successfully');
* } catch (error) {
* console.error('Connection failed:', error);
* }
*/
async connect() {
// Test HTTP connectivity
try {
const apiUrl = this.buildRestApiUrl('self');
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.connected = true;
console.error('SignalK HTTP connection verified');
this.emit('connected');
// WEBSOCKET CONNECTION DISABLED FOR HTTP-ONLY MODE
// The WebSocket client code below is preserved for future use when
// MCP servers support streaming. This will enable real-time data
// updates for features like live AIS tracking, sensor monitoring, etc.
/* PRESERVED FOR FUTURE STREAMING SUPPORT:
return new Promise((resolve, reject) => {
if (this.connected) {
resolve();
return;
}
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
}, 10000);
this.client.once('connect', async () => {
clearTimeout(timeout);
resolve();
});
this.client.once('error', (error: any) => {
clearTimeout(timeout);
reject(error);
});
this.client.connect();
});
*/
}
catch (error) {
this.connected = false;
console.error('SignalK HTTP connection failed:', error.message);
throw new Error(`Failed to connect to SignalK server: ${error.message}`);
}
}
/**
* Fetches initial complete vessel state via HTTP API to populate cache immediately
*
* This method is called after WebSocket connection to ensure getVesselState()
* has immediate access to complete vessel data instead of waiting for deltas.
*
* Features:
* - HTTP GET to /signalk/v1/api/vessels/self for complete state
* - Populates latestValues Map with all available paths
* - Preserves current timestamp for each value
* - Updates availablePaths Set automatically
* - Graceful error handling - logs errors but doesn't throw
*
* @returns Promise that resolves when initial state is fetched and cached
*
* @private
*/
async fetchInitialVesselState() {
try {
const apiUrl = this.buildRestApiUrl('self');
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Recursively populate latestValues Map from the HTTP response
this.populateLatestValuesFromData(data, this.context);
}
catch (error) {
console.error('Failed to fetch initial vessel state via HTTP:', error.message);
throw error; // Re-throw to be caught in connect() method
}
}
/**
* Recursively populates latestValues Map from SignalK API response data
*
* This helper method traverses the nested SignalK data structure and
* extracts all value objects, storing them in the latestValues Map
* with proper full path keys (context.path).
*
* @param obj - The SignalK data object to traverse
* @param context - The vessel context (e.g., 'vessels.self')
* @param pathPrefix - Current path prefix being built
*
* @private
*/
populateLatestValuesFromData(obj, context, pathPrefix = '') {
if (!obj || typeof obj !== 'object') {
return;
}
for (const [key, value] of Object.entries(obj)) {
// Skip metadata fields and null/undefined keys
if (!key ||
key.startsWith('$') ||
key === 'meta' ||
key === 'timestamp') {
continue;
}
const currentPath = pathPrefix ? `${pathPrefix}.${key}` : key;
// If this object has a 'value' property, it's a SignalK data point
if (value && typeof value === 'object' && 'value' in value) {
const fullPath = `${context}.${currentPath}`;
// Store in latestValues Map with SignalK structure
this.latestValues.set(fullPath, {
value: value.value,
timestamp: value.timestamp || new Date().toISOString(),
source: value.source,
});
// Add to available paths (only if currentPath is valid)
if (currentPath && currentPath !== 'undefined') {
this.availablePaths.add(currentPath);
}
}
// If it's an object without 'value', recurse deeper
else if (value && typeof value === 'object' && !Array.isArray(value)) {
this.populateLatestValuesFromData(value, context, currentPath);
}
}
}
/**
* Processes incoming SignalK delta messages and updates internal data stores
*
* Delta message processing:
* - Updates latest values cache with timestamps
* - Tracks AIS targets from other vessels
* - Monitors system notifications and alarms
* - Discovers available data paths automatically
* - Emits 'delta' event for external listeners
*
* @param delta - SignalK delta message with vessel updates
*
* @example
* // Delta messages are received automatically via WebSocket
* client.on('delta', (delta) => {
* console.log('Vessel context:', delta.context);
* console.log('Updates:', delta.updates);
* });
*/
handleDelta(delta) {
try {
if (delta.updates) {
this.processUpdates(delta);
}
this.emit('delta', delta);
}
catch (error) {
console.error('Error processing delta:', error);
}
}
/**
* Processes individual value updates from SignalK delta messages
*
* Update processing:
* - Stores latest values with full path keys (context.path)
* - Maintains set of available data paths
* - Updates AIS target data for other vessels
* - Processes notification/alarm state changes
* - Preserves timestamps and source information
*
* @param message - SignalK delta message containing updates array
*
* @example
* // Updates are processed automatically from delta messages:
* // {
* // "context": "vessels.self",
* // "updates": [{
* // "timestamp": "2023-06-22T10:30:15Z",
* // "values": [{
* // "path": "navigation.position",
* // "value": {"latitude": 37.8199, "longitude": -122.4783}
* // }]
* // }]
* // }
*/
processUpdates(message) {
const context = message.context || this.context;
message.updates.forEach((update) => {
if (update.values) {
update.values.forEach((value) => {
const fullPath = `${context}.${value.path}`;
this.latestValues.set(fullPath, {
value: value.value,
timestamp: update.timestamp || new Date().toISOString(),
source: update.source,
});
this.availablePaths.add(value.path);
if (context.startsWith('vessels.') && context !== this.context) {
this.updateAISTarget(context, value.path, value.value, update.timestamp);
}
if (value.path.startsWith('notifications.')) {
this.updateAlarms(value.path, value.value, update.timestamp);
}
});
}
});
}
/**
* Updates AIS target information for other vessels detected in the area
*
* AIS data tracking:
* - Creates new target entries for unknown vessels
* - Updates existing targets with latest position/course/speed data
* - Maintains MMSI identifier and last update timestamp
* - Supports any SignalK path (position, course, speed, name, etc.)
*
* @param vesselContext - Vessel context (e.g., 'vessels.urn:mrn:imo:mmsi:123456789')
* @param path - SignalK data path (e.g., 'navigation.position')
* @param value - The data value for this path
* @param timestamp - ISO timestamp of the update
*
* @example
* // AIS targets are updated automatically from delta messages:
* // Context: "vessels.urn:mrn:imo:mmsi:123456789"
* // Path: "navigation.position"
* // Value: {"latitude": 37.8200, "longitude": -122.4800}
*
* const targets = client.getAISTargets();
* console.log('Nearby vessels:', targets.targets.length);
*/
updateAISTarget(vesselContext, path, value, timestamp) {
// Only process vessels with proper MMSI format (AIS targets)
// Example: "vessels.urn:mrn:imo:mmsi:123456789"
const mmsiMatch = vesselContext.match(/urn:mrn:imo:mmsi:(\d+)/);
if (!mmsiMatch) {
// Skip non-MMSI vessels (UUID-based vessels, other formats)
return;
}
const mmsi = mmsiMatch[1]; // Extract the MMSI number
const vesselId = vesselContext.replace('vessels.', '');
if (!this.aisTargets.has(vesselId)) {
this.aisTargets.set(vesselId, {
mmsi: mmsi, // Use the extracted MMSI number
lastUpdate: timestamp,
});
}
const target = this.aisTargets.get(vesselId);
if (target) {
target[path] = value;
target.lastUpdate = timestamp;
}
}
/**
* Updates active alarm and notification states from SignalK notification paths
*
* Alarm processing:
* - Adds alarms when state is not 'normal' (alert, warn, alarm, emergency)
* - Removes alarms when state returns to 'normal' or null
* - Preserves alarm message and metadata
* - Tracks timestamp of alarm state changes
*
* @param path - Notification path (e.g., 'notifications.engines.temperature')
* @param value - Notification object with state and message
* @param timestamp - ISO timestamp of the notification
*
* @example
* // Alarms are updated automatically from notification paths:
* // Path: "notifications.engines.temperature"
* // Value: {
* // "state": "alert",
* // "message": "Engine temperature high",
* // "method": ["visual", "sound"]
* // }
*
* const alarms = client.getActiveAlarms();
* console.log('Active alarms:', alarms.count);
*/
updateAlarms(path, value, timestamp) {
if (value && value.state) {
this.activeAlarms.set(path, {
path,
state: value.state,
message: value.message,
timestamp,
});
}
else if (value === null || value === undefined) {
// Only delete if value is truly null/undefined (path no longer exists)
this.activeAlarms.delete(path);
}
// Keep alarms in normal state - do not delete them
}
/**
* Returns current vessel state with all available sensor data, navigation information, and vessel identity
*
* This method fetches fresh data directly from the SignalK HTTP API on each request,
* ensuring that stale cached data is never returned. The response includes:
* - All SignalK paths for the current vessel context (vessels.self by default)
* - Vessel identity information (name, MMSI, call sign)
* - Position, heading, speed, wind, engine data, etc.
* - Latest values with timestamps and source information
* - Connection status and context information
*
* @returns Promise<VesselState> object with fresh data from SignalK server
*
* @example
* const state = await client.getVesselState();
* console.log('Vessel name:', state.data['name']?.value);
* console.log('Position:', state.data['navigation.position']?.value);
* console.log('Speed:', state.data['navigation.speedOverGround']?.value);
* console.log('Wind:', state.data['environment.wind']?.value);
*
* // Example response:
* // {
* // "connected": true,
* // "context": "vessels.self",
* // "timestamp": "2023-06-22T10:30:15.123Z",
* // "data": {
* // "name": {
* // "value": "My Vessel",
* // "timestamp": "2023-06-22T10:30:15.000Z",
* // "source": "vessel-identity"
* // },
* // "navigation.position": {
* // "value": {"latitude": 37.8199, "longitude": -122.4783},
* // "timestamp": "2023-06-22T10:30:15.000Z",
* // "source": {"label": "GPS1", "type": "NMEA0183"}
* // },
* // "navigation.speedOverGround": {
* // "value": 5.2,
* // "timestamp": "2023-06-22T10:30:15.000Z"
* // }
* // }
* // }
*/
async getVesselState() {
try {
const apiUrl = this.buildRestApiUrl('self');
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const vesselData = await response.json();
const state = {};
// Format the vessel data into the expected structure
const formatData = (obj, prefix = '') => {
for (const [key, value] of Object.entries(obj)) {
// Skip metadata fields
if (key.startsWith('$') || key === 'meta' || key === 'timestamp') {
continue;
}
const currentPath = prefix ? `${prefix}.${key}` : key;
// If this object has a 'value' property, it's a SignalK data point
if (value && typeof value === 'object' && 'value' in value) {
state[currentPath] = {
value: value.value,
timestamp: value.timestamp || new Date().toISOString(),
source: value.source,
};
}
// If it's an object without 'value', recurse deeper
else if (value && typeof value === 'object' && !Array.isArray(value)) {
formatData(value, currentPath);
}
}
};
// Process the nested vessel data
formatData(vesselData);
// Add top-level vessel properties as synthetic paths
if (vesselData.name) {
state['name'] = {
value: vesselData.name,
timestamp: new Date().toISOString(),
source: 'vessel-identity',
};
}
if (vesselData.mmsi) {
state['mmsi'] = {
value: vesselData.mmsi,
timestamp: new Date().toISOString(),
source: 'vessel-identity',
};
}
if (vesselData.communication?.callsignVhf) {
state['communication.callsignVhf'] = {
value: vesselData.communication.callsignVhf,
timestamp: new Date().toISOString(),
source: 'vessel-identity',
};
}
return {
connected: this.connected,
context: this.context,
data: state,
timestamp: new Date().toISOString(),
};
}
catch (error) {
// Return empty state with error indication
return {
connected: false,
context: this.context,
data: {},
timestamp: new Date().toISOString(),
error: `Failed to fetch vessel state: ${error.message}`,
};
}
}
/**
* Calculates the distance between two geographic coordinates using the Haversine formula
*
* @param lat1 - Latitude of first point
* @param lon1 - Longitude of first point
* @param lat2 - Latitude of second point
* @param lon2 - Longitude of second point
* @returns Distance in meters
*/
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // Earth's radius in meters
const phi1 = lat1 * Math.PI / 180;
const phi2 = lat2 * Math.PI / 180;
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
const deltaLambda = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Pattern-based filtering for AIS data fields
* Determines if a SignalK path should be included in AIS target data
*
* @param path - SignalK data path to check
* @returns true if the path should be included, false otherwise
*/
shouldIncludeAISPath(path) {
// Include patterns for navigation-relevant data
const includePatterns = [
/^navigation\./, // All navigation data (position, speed, course, heading, etc.)
/^design\./, // Vessel characteristics (length, beam, type, etc.)
/^name$/, // Vessel name (top-level)
/^communication\./, // Call signs and communication identifiers
/^registrations\./, // IMO numbers and other registrations
/^destination\./, // Voyage-related information
];
// Exclude patterns for internal vessel systems
// Note: We don't need explicit exclude patterns since we're using a whitelist approach
// Only paths matching include patterns will be included
// Check if path matches any include pattern
return includePatterns.some(pattern => pattern.test(path));
}
/**
* Returns nearby AIS targets (other vessels) with their position and navigation data
*
* This method fetches fresh AIS data directly from the SignalK HTTP API on each request,
* ensuring that stale cached data is never returned. The response includes:
* - Only vessels with proper MMSI identifiers (true AIS targets)
* - Position, course, speed, and vessel identification
* - Distance in meters from self vessel (when positions available)
* - Sorted by proximity (closest vessels first)
* - Supports pagination with configurable page size
* - Only includes targets updated within last 5 minutes
*
* @param page - Page number (1-based, default: 1)
* @param pageSize - Number of targets per page (default: 10, max: 50)
* @returns Promise<AISTargetsResponse> with array of nearby vessels
*
* @example
* const targets = await client.getAISTargets(1, 10);
* console.log(`Found ${targets.count} nearby vessels`);
* console.log(`Page ${targets.pagination.page} of ${targets.pagination.totalPages}`);
*
* targets.targets.forEach(target => {
* console.log(`MMSI: ${target.mmsi}`);
* if (target.distanceMeters) {
* console.log(`Distance: ${target.distanceMeters}m`);
* }
* if (target['navigation.position']) {
* console.log(`Position: ${target['navigation.position'].value.latitude}, ${target['navigation.position'].value.longitude}`);
* }
* });
*
* // Example response:
* // {
* // "connected": true,
* // "count": 2,
* // "timestamp": "2023-06-22T10:30:15.123Z",
* // "targets": [
* // {
* // "mmsi": "123456789",
* // "distanceMeters": 1852.5,
* // "navigation.position": {
* // "value": {"latitude": 37.8200, "longitude": -122.4800},
* // "timestamp": "2023-06-22T10:29:45.000Z"
* // },
* // "lastUpdate": "2023-06-22T10:29:45.000Z"
* // }
* // ],
* // "pagination": {
* // "page": 1,
* // "pageSize": 10,
* // "totalCount": 15,
* // "totalPages": 2,
* // "hasNextPage": true,
* // "hasPreviousPage": false
* // }
* // }
*/
async getAISTargets(page = 1, pageSize = 10) {
try {
// Validate pagination parameters
pageSize = Math.min(Math.max(1, pageSize), 50); // Clamp between 1 and 50
page = Math.max(1, page);
// First, get self vessel position and MMSI for distance calculation and filtering
let selfPosition = undefined;
let selfMmsi = undefined;
try {
const selfData = await this.getVesselState();
const positionData = selfData.data['navigation.position'];
if (positionData && positionData.value &&
typeof positionData.value === 'object' &&
'latitude' in positionData.value &&
'longitude' in positionData.value) {
selfPosition = positionData.value;
}
// Get self vessel's MMSI if available
if (selfData.data['mmsi'] && selfData.data['mmsi'].value) {
selfMmsi = String(selfData.data['mmsi'].value);
}
}
catch {
// Failed to get self vessel data - continue without distance calculation
}
// Fetch all vessels from the API
const apiUrl = `${this.buildHttpUrl()}/signalk/v1/api/vessels`;
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const vesselsData = await response.json();
const targets = [];
const now = Date.now();
// Process each vessel
for (const [vesselId, vesselData] of Object.entries(vesselsData)) {
// Skip self and non-object entries
if (vesselId === 'self' || typeof vesselData !== 'object') {
continue;
}
// Only process vessels with proper MMSI format (true AIS targets)
const mmsiMatch = vesselId.match(/urn:mrn:imo:mmsi:(\d+)/);
if (!mmsiMatch) {
continue;
}
const mmsi = mmsiMatch[1];
// Skip if this MMSI matches self vessel's MMSI
if (selfMmsi && mmsi === selfMmsi) {
continue;
}
const target = {
mmsi: mmsi,
lastUpdate: new Date().toISOString(),
};
// Extract vessel data and check freshness
let mostRecentTimestamp = 0;
let targetLatitude = undefined;
let targetLongitude = undefined;
const extractData = (obj, prefix = '') => {
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('$') || key === 'meta') {
continue;
}
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && 'value' in value) {
// Only include paths that match our AIS data patterns
if (this.shouldIncludeAISPath(path)) {
target[path] = value;
}
// Always capture position for distance calculation (even if not included in output)
if (path === 'navigation.position' && value.value &&
typeof value.value === 'object' &&
'latitude' in value.value &&
'longitude' in value.value) {
targetLatitude = Number(value.value.latitude);
targetLongitude = Number(value.value.longitude);
}
// Track most recent update
if (value.timestamp) {
const timestamp = new Date(value.timestamp).getTime();
if (timestamp > mostRecentTimestamp) {
mostRecentTimestamp = timestamp;
target.lastUpdate = value.timestamp;
}
}
}
else if (value && typeof value === 'object' && !Array.isArray(value)) {
extractData(value, path);
}
}
};
extractData(vesselData);
// Handle top-level vessel properties that don't have value structure
if (vesselData && typeof vesselData === 'object' && 'name' in vesselData && this.shouldIncludeAISPath('name')) {
target.name = vesselData.name;
}
// Only include if data is less than 5 minutes old
if (mostRecentTimestamp > 0 && (now - mostRecentTimestamp) < 300000) {
// Calculate distance if both positions are available
if (selfPosition && targetLatitude !== undefined && targetLongitude !== undefined) {
target.distanceMeters = this.calculateDistance(selfPosition.latitude, selfPosition.longitude, targetLatitude, targetLongitude);
}
targets.push(target);
}
}
// Sort by distance (closest first)
targets.sort((a, b) => {
// Targets with distance come first
if (a.distanceMeters !== undefined && b.distanceMeters !== undefined) {
return a.distanceMeters - b.distanceMeters;
}
if (a.distanceMeters !== undefined)
return -1;
if (b.distanceMeters !== undefined)
return 1;
// For targets without distance, sort by last update
return new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime();
});
// Calculate pagination
const totalCount = targets.length;
const totalPages = Math.ceil(totalCount / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedTargets = targets.slice(startIndex, endIndex);
return {
connected: this.connected,
count: paginatedTargets.length,
targets: paginatedTargets,
timestamp: new Date().toISOString(),
pagination: {
page,
pageSize,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
catch (error) {
// Return empty targets list with error indication
return {
connected: false,
count: 0,
targets: [],
timestamp: new Date().toISOString(),
error: `Failed to fetch AIS targets: ${error.message}`,
};
}
}
/**
* Returns all alarms and system notifications including resolved (normal state) alarms
*
* This method fetches fresh alarm data directly from the SignalK HTTP API on each request,
* ensuring that stale cached data is never returned. The response includes:
* - All notification paths from the current vessel
* - Alarm states: alert, warn, alarm, emergency, and normal (resolved)
* - Notification messages and metadata
* - Fresh timestamps for each notification
*
* @returns Promise<ActiveAlarmsResponse> with array of all notifications
*
* @example
* const alarms = await client.getActiveAlarms();
* console.log(`${alarms.count} total alarms (including resolved)`);
*
* // Filter for only critical alarms
* const criticalAlarms = alarms.alarms.filter(alarm =>
* alarm.state !== 'normal'
* );
* console.log(`${criticalAlarms.length} critical alarms`);
*
* alarms.alarms.forEach(alarm => {
* console.log(`${alarm.state}: ${alarm.message || 'No message'}`);
* console.log(`Path: ${alarm.path}`);
* console.log(`Time: ${alarm.timestamp}`);
* });
*
* // Example response:
* // {
* // "connected": true,
* // "count": 2,
* // "timestamp": "2023-06-22T10:30:15.123Z",
* // "alarms": [
* // {
* // "path": "notifications.engines.temperature",
* // "state": "normal",
* // "message": "Engine temperature normal",
* // "timestamp": "2023-06-22T10:25:30.000Z"
* // },
* // {
* // "path": "notifications.battery.voltage",
* // "state": "alert",
* // "message": "Battery voltage low",
* // "timestamp": "2023-06-22T10:28:45.000Z"
* // }
* // ]
* // }
*/
async getActiveAlarms() {
try {
const apiUrl = this.buildRestApiUrl('self');
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const vesselData = await response.json();
const alarms = [];
// Extract notifications from vessel data
const extractNotifications = (obj, pathPrefix = '') => {
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('$') || key === 'meta') {
continue;
}
const currentPath = pathPrefix ? `${pathPrefix}.${key}` : key;
// Check if this is a notification path
if (currentPath.startsWith('notifications.') && value && typeof value === 'object' && 'value' in value) {
const notifValue = value.value;
if (notifValue && notifValue.state) {
alarms.push({
path: currentPath,
state: notifValue.state,
message: notifValue.message || '',
timestamp: value.timestamp || new Date().toISOString(),
});
}
}
else if (value && typeof value === 'object' && !Array.isArray(value)) {
extractNotifications(value, currentPath);
}
}
};
// Extract all notifications
if (vesselData.notifications) {
extractNotifications({ notifications: vesselData.notifications });
}
return {
connected: this.connected,
count: alarms.length,
alarms: alarms,
timestamp: new Date().toISOString(),
};
}
catch (error) {
// Return empty alarms list with error indication
return {
connected: false,
count: 0,
alarms: [],
timestamp: new Date().toISOString(),
error: `Failed to fetch alarms: ${error.message}`,
};
}
}
/**
* Discovers and returns all available SignalK data paths on the server
*
* Path discovery:
* - Primary: Uses HTTP REST API to get complete path list from server
* - Fallback: Uses WebSocket-discovered paths if HTTP fails
* - Filters out metadata fields ($schema, meta, timestamp)
* - Returns sorted alphabetical list of available data paths
*
* @returns Promise<AvailablePathsResponse> with array of available paths
*
* @example
* const pathsResponse = await client.listAvailablePaths();
* console.log(`${pathsResponse.count} paths available`);
*
* pathsResponse.paths.forEach(path => {
* console.log(`Available: ${path}`);
* });
*
* // Example response:
* // {
* // "connected": true,
* // "count": 25,
* // "timestamp": "2023-06-22T10:30:15.123Z",
* // "paths": [
* // "electrical.batteries.house.voltage",
* // "environment.wind.speedApparent",
* // "navigation.courseOverGround",
* // "navigation.position",
* // "navigation.speedOverGround",
* // "propulsion.main.temperature"
* // ]
* // }
*/
async listAvailablePaths() {
const paths = new Set();
try {
// Use helper method to build REST API URL
const apiUrl = this.buildRestApiUrl('self');
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Recursively extract all paths from the data
const extractPaths = (obj, prefix = '') => {
for (const [key, value] of Object.entries(obj)) {
// Skip metadata fields
if (key.startsWith('$') || key === 'meta' || key === 'timestamp') {
continue;
}
const currentPath = prefix ? `${prefix}.${key}` : key;
// If this object has a 'value' property, it's a data point
if (value && typeof value === 'object' && 'value' in value) {
paths.add(currentPath);
}
// If it's an object without 'value', recurse deeper
else if (value &&
typeof value === 'object' &&
!Array.isArray(value)) {
extractPaths(value, currentPath);
}
}
};
extractPaths(data);
return {
connected: this.connected,
count: paths.size,
paths: Array.from(paths).sort(),
timestamp: new Date().toISOString(),
};
}
catch (error) {
console.error('Failed to fetch paths via HTTP:', error.message);
// Fallback to WebSocket-discovered paths
return {
connected: this.connected,
count: this.availablePaths.size,
paths: Array.from(this.availablePaths).sort(),
timestamp: new Date().toISOString(),
error: `HTTP fetch failed: ${error.message}, using WebSocket-discovered paths`,
};
}
}
/**
* Gets the latest value for a specific SignalK data path
*
* Value retrieval:
* - Primary: Uses HTTP REST API for real-time data from server
* - Fallback: Uses WebSocket-cached value if HTTP fails
* - Returns complete value object with metadata
* - Supports any valid SignalK path
*
* @param path - SignalK data path in dot notation (e.g., 'navigation.position')
* @returns Promise<PathValueResponse> with latest value and metadata
*
* @example
* // Get current position
* const position = await client.getPathValue('navigation.position');
* console.log('Latitude:', position.data.value.latitude);
* console.log('Longitude:', position.data.value.longitude);
*
* // Get wind speed
* const windSpeed = await client.getPathValue('environment.wind.speedApparent');
* console.log('Wind speed:', windSpeed.data.value, 'm/s');
*
* // Get engine temperature
* const engineTemp = await client.getPathValue('propulsion.main.temperature');
* console.log('Engine temp:', engineTemp.data.value, 'K');
*
* // Example response:
* // {
* // "connected": true,
* // "path": "navigation.position",
* // "timestamp": "2023-06-22T10:30:15.123Z",
* // "data": {
* // "value": {
* // "latitude": 37.8199,
* // "longitude": -122.4783
* // },
* // "timestamp": "2023-06-22T10:30:15.000Z",
* // "source": {
* // "label": "GPS1",
* // "type": "NMEA0183"
* // }
* // }
* // }
*/
async getPathValue(path) {
try {
// Use helper method to build REST API URL for the specific path
const apiUrl = this.buildRestApiUrl('self', path);
const response = await fetch(apiUrl, this.buildFetchOptions());
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
connected: this.connected,
path,
data: data,
timestamp: new Date().toISOString(),
};
}
catch (error) {
console.error(`Failed to fetch path ${path} via HTTP:`, error.message);
// Fallback to WebSocket-cached value
const fullPath = `${this.context}.${path}`;
const cachedData = this.latestValues.get(fullPath);
return {
connected: this.connected,
path,
data: cachedData || null,
timestamp: new Date().toISOString(),
error: `HTTP fetch failed: ${error.message}, using cached value`,
};
}
}
/**
* Returns comprehensive connection status and client configuration information
*
* This method now reflects HTTP-only mode status. WebSocket information is
* preserved for future streaming support but not actively used.
*
* Status information:
* - HTTP connection state (verified during connect())
* - Server URLs (both WebSocket and HTTP for reference)
* - Configuration details (hostname, port, TLS)
* - Vessel context being monitored
*
* Note: Cache statistics (pathCount, aisTargetCount, activeAlarmCount) will
* always be 0 in HTTP-only mode as data is fetched fresh on each request.
*
* @returns ConnectionStatus object with detailed connection information
*
* @example
* const status = client.getConnectionStatus();
* console.log('Connected:', status.connected);
* console.log('Server:', status.hostname + ':' + status.port);
* console.log('TLS:', status.useTLS);
* console.log('HTTP URL:', status.httpUrl);
*
* // Example response:
* // {
* // "connected": true,
* // "url": "http://localhost:3000",
* // "wsUrl": "ws://localhost:3000", // Preserved for future use
* // "httpUrl": "http://localhost:3000",
* // "hostname": "localhost",
* // "port": 3000,
* // "useTLS": false,
* // "context": "vessels.self",
* // "pathCount": 0, // Always 0 in HTTP-only mode
* // "aisTargetCount": 0, // Always 0 in HTTP-only mode
* // "activeAlarmCount": 0, // Always 0 in HTTP-only mode
* // "timestamp": "2023-06-22T10:30:15.123Z"
* // }
*/
getConnectionStatus() {
return {
connected: this.connected,
url: this.buildHttpUrl(), // Changed to show HTTP URL as primary
wsUrl: this.buildWebSocketUrl(), // Preserved for future streaming
httpUrl: this.buildHttpUrl(),
hostname: this.hostname,
port: this.port,
useTLS: this.useTLS,
context: this.context,
pathCount: 0, // Always 0 in HTTP-only mode (no cache)
aisTargetCount: 0, // Always 0 in HTTP-only mode (no cache)
activeAlarmCount: 0, // Always 0 in HTTP-only mode (no cache)
timestamp: new Date().toISOString(),
};
}
/**
* Cleanly disconnects from the SignalK server
*
* In HTTP-only mode, this simply sets the connected flag to false.
* The WebSocket disconnect is preserved for future streaming support.
*
* @example
* // Disconnect when done
* client.disconnect();
* console.log('Disconnected from SignalK server');
*/
disconnect() {
this.connected = false;
// WebSocket disconnect preserved for future streaming support
// this.client.disconnect();
}
}
//# sourceMappingURL=signalk-client.js.map