Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ dist/
.DS_Store
.vscode/
*.db
*.db-shm
*.db-wal
logs/*
bower_components
settings/ssl-key.pem
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
],
"license": "Apache-2.0",
"engines": {
"node": ">=22"
"node": ">=22.13.0"
},
"workspaces": [
"packages/server-admin-ui-dependencies",
Expand Down
49 changes: 49 additions & 0 deletions packages/server-api/src/communications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it } from 'mocha'
import { strict as assert } from 'assert'
import {
MessageLogEntry,
MessageLogEntryInput,
MessageLogStore
} from './communications'

describe('communications types', () => {
it('an input entry is assignable and an entry carries id + disposition', () => {
const input: MessageLogEntryInput = {
type: 'dsc',
priority: 'distress',
sender: { mmsi: '316123456' },
summary: 'DSC distress alert: MMSI 316123456',
payload: {
format: '12',
category: 'distress',
natureOfDistress: 'sinking'
},
raw: '$CDDSC,12,3161234560,...'
}
const entry: MessageLogEntry = {
...input,
id: 'abc',
receivedAt: '2026-06-25T00:00:00.000Z',
sourceRef: 'ais.GP',
transport: 'nmea0183',
subject: undefined,
position: undefined,
notificationId: undefined,
disposition: {}
}
assert.equal(entry.type, 'dsc')
assert.equal(entry.disposition.acknowledgedAt, undefined)
})

it('MessageLogStore shape is implementable', () => {
const store: Partial<MessageLogStore> = {
append: async (e) => ({
...e,
id: 'x',
receivedAt: '2026-06-25T00:00:00.000Z',
disposition: {}
})
}
assert.equal(typeof store.append, 'function')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
143 changes: 143 additions & 0 deletions packages/server-api/src/communications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Received-message log types. Generic envelope; DSC is the only v1 `type`.
* @category Communications API
*/

export type MessageType = 'dsc'
export type MessagePriority = 'distress' | 'urgency' | 'safety' | 'routine'
export type MessageTransport = 'nmea0183' | 'nmea2000'

export interface MessageSender {
mmsi?: string
name?: string
}

/** The vessel a relay/ack/cancel refers to (distinct from the sender). */
export interface MessageSubject {
mmsi?: string
}

export interface MessagePosition {
latitude: number
longitude: number
}

/** Audit trail mirroring the linked notification's ack/clear lifecycle. */
export interface MessageDisposition {
acknowledgedAt?: string
clearedAt?: string
}

export interface MessageLogEntryCommon {
/** ISO 8601; defaults to server receive time when omitted. */
receivedAt?: string
/** `$source` — connection/device. */
sourceRef?: string
transport?: MessageTransport
priority: MessagePriority
sender: MessageSender
subject?: MessageSubject
position?: MessagePosition
summary: string
/** Original sentence(s) / PGN text. */
raw: string
/** Link to a live notification, if one was raised. */
notificationId?: string
}

/**
* What a producer submits to `app.logMessage()`. Server assigns
* id/receivedAt/disposition. Discriminated on `type` so each message type
* carries its own typed `payload`; new types add an arm without touching storage.
*/
export type MessageLogEntryInput = MessageLogEntryCommon & {
type: 'dsc'
payload: DscPayload
}

/**
* The input/persisted split keeps `id`, `receivedAt` and `disposition`
* server-assigned — producers cannot forge them on the regulatory record.
*/
export type MessageLogEntry = MessageLogEntryInput & {
id: string
receivedAt: string
disposition: MessageDisposition
}

export interface MessageLogQuery {
/** ISO 8601 — inclusive lower bound on receivedAt. */
from?: string
/** ISO 8601 — inclusive upper bound on receivedAt. */
to?: string
type?: MessageType
priority?: MessagePriority
/** Filter by sender MMSI. */
sender?: string
limit?: number
order?: 'asc' | 'desc'
}

/** Mutable patch shape applied via `MessageLogStore.update`; distinct from `MessageDisposition`, the stored audit state. */
export interface DispositionPatch {
acknowledgedAt?: string
clearedAt?: string
}

/**
* Storage seam. Only `SqliteMessageLogStore` ships in v1; a Postgres-class
* backend can implement this and be registered later.
* @category Communications API
*/
export interface MessageLogStore {
append(entry: MessageLogEntryInput): Promise<MessageLogEntry>
get(id: string): Promise<MessageLogEntry | undefined>
query(filter: MessageLogQuery): Promise<MessageLogEntry[]>
update(
id: string,
patch: DispositionPatch
): Promise<MessageLogEntry | undefined>
}

/**
* App surface added by the Communications API: the single ingestion door
* for all message producers (parsers, manual entries, future NAVTEX/AIS-text).
* @category Communications API
*/
export type WithMessageLog = {
logMessage?: (entry: MessageLogEntryInput) => Promise<MessageLogEntry>
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ---- DSC type-specific payload + parser contract ----

/** Structured DSC call emitted by a transport parser (NMEA0183 or N2K PGN 129808). */
export interface DscCall {
/** Format specifier symbol minus leading 1 (e.g. '12' = distress alert). */
format: string
category: 'distress' | 'urgency' | 'safety' | 'routine' | 'unknown'
/** Sender MMSI as a string (preserve leading zeros). */
mmsi?: string
/** Nature of distress (distress/relay calls). */
natureOfDistress?: string
/** MMSI of the vessel in distress on relays/acks/cancellations. */
distressMmsi?: string
position?: MessagePosition
/** UTC time reported in the call, if present. */
reportedTime?: string
transport: MessageTransport
/** Human one-liner summary. */
summary: string
/** Original sentence(s) / PGN text. */
raw: string
/** `$source` of the producing connection. */
sourceRef?: string
}

/** DSC content stored in `MessageLogEntry.payload`. */
export interface DscPayload {
format: string
category: DscCall['category']
natureOfDistress?: string
distressMmsi?: string
reportedTime?: string
}
1 change: 1 addition & 0 deletions packages/server-api/src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ export type SignalKApiId =
| 'historysnapshot' //https://signalk.org/specification/1.7.0/doc/rest_api.html#history-snapshot-retrieval
| 'notifications'
| 'sensors'
| 'communications'
2 changes: 2 additions & 0 deletions packages/server-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export * from './subscriptionmanager'
export * as history from './history'
/** @category Notifications API */
export * from './notificationsapi'
/** @category Communications API */
export * from './communications'
export { FullSignalK, SourceMetaEntry } from './fullsignalk'
export { getSourceId, fillIdentity, fillIdentityField } from './sourceutil'

Expand Down
2 changes: 2 additions & 0 deletions packages/server-api/src/serverapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { RadarProviderRegistry, WithRadarApi } from './radarapi'
import { CourseApi } from './course'
import { HistoryProviderRegistry, WithHistoryApi } from './history'
import { WithMessageLog } from './communications'
import { StreamBundle } from './streambundle'
import { SubscriptionManager } from './subscriptionmanager'

Expand Down Expand Up @@ -45,6 +46,7 @@ export interface ServerAPI
WithFeatures,
CourseApi,
WithNotificationsApi,
WithMessageLog,
SelfIdentity {
/**
* Returns the entry for the provided path starting from `vessels.self` in the full data model.
Expand Down
56 changes: 56 additions & 0 deletions packages/server-api/src/typebox/communications-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Type, Static } from '@sinclair/typebox'

export const MessageDispositionSchema = Type.Object({
acknowledgedAt: Type.Optional(Type.String({ format: 'date-time' })),
clearedAt: Type.Optional(Type.String({ format: 'date-time' }))
})

export const DscPayloadSchema = Type.Object({
format: Type.String(),
category: Type.Union([
Type.Literal('distress'),
Type.Literal('urgency'),
Type.Literal('safety'),
Type.Literal('routine'),
Type.Literal('unknown')
]),
natureOfDistress: Type.Optional(Type.String()),
distressMmsi: Type.Optional(Type.String()),
reportedTime: Type.Optional(Type.String())
})

export const MessageLogEntrySchema = Type.Object({
id: Type.String(),
type: Type.Literal('dsc'),
receivedAt: Type.String({ format: 'date-time' }),
sourceRef: Type.Optional(Type.String()),
transport: Type.Optional(
Type.Union([Type.Literal('nmea0183'), Type.Literal('nmea2000')])
),
priority: Type.Union([
Type.Literal('distress'),
Type.Literal('urgency'),
Type.Literal('safety'),
Type.Literal('routine')
]),
sender: Type.Object({
mmsi: Type.Optional(Type.String()),
name: Type.Optional(Type.String())
}),
subject: Type.Optional(Type.Object({ mmsi: Type.Optional(Type.String()) })),
position: Type.Optional(
Type.Object({
latitude: Type.Number(),
longitude: Type.Number()
})
),
summary: Type.String(),
payload: DscPayloadSchema,
raw: Type.String(),
notificationId: Type.Optional(Type.String()),
disposition: MessageDispositionSchema
})

export const MessageLogListSchema = Type.Array(MessageLogEntrySchema)

export type MessageLogEntryStatic = Static<typeof MessageLogEntrySchema>
1 change: 1 addition & 0 deletions packages/server-api/src/typebox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './shared-schemas'
export * from './protocol-schemas'
export * from './autopilot-schemas'
export * from './course-schemas'
export * from './communications-schemas'
export * from './discovery-schemas'
export * from './history-schemas'
export * from './notifications-schemas'
Expand Down
62 changes: 62 additions & 0 deletions src/api/communications/dscAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
DscCall,
DscPayload,
MessageLogEntryInput,
MessagePriority
} from '@signalk/server-api'
import { createDebug } from '../../debug'

const debug = createDebug('signalk-server:api:communications')

function priorityForCategory(category: DscCall['category']): MessagePriority {
switch (category) {
case 'distress':
return 'distress'
case 'urgency':
return 'urgency'
case 'safety':
return 'safety'
default:
return 'routine'
}
}

export function dscToMessageInput(call: DscCall): MessageLogEntryInput {
return {
type: 'dsc',
// use the call's reported UTC when present; the store falls back to server receive time when undefined
receivedAt: call.reportedTime,
sourceRef: call.sourceRef,
transport: call.transport,
priority: priorityForCategory(call.category),
sender: { mmsi: call.mmsi },
subject: call.distressMmsi ? { mmsi: call.distressMmsi } : undefined,
position: call.position,
summary: call.summary,
payload: {
format: call.format,
category: call.category,
natureOfDistress: call.natureOfDistress,
distressMmsi: call.distressMmsi,
reportedTime: call.reportedTime
} satisfies DscPayload,
raw: call.raw
}
}

/**
* Registration seam: parser libs (or the legacy plugin during migration) call
* `onCall` with a typed {@link DscCall}; we map it and submit via `app.logMessage`.
* Returns the bound handler so callers/tests can invoke it directly.
*/
export function registerDscAdapter(app: {
logMessage?: (e: MessageLogEntryInput) => Promise<unknown>
}): (call: DscCall) => Promise<void> {
return async (call: DscCall) => {
if (!app.logMessage) {
debug('logMessage not available; dropping DSC call')
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
await app.logMessage(dscToMessageInput(call))
}
}
Loading
Loading