UNPKG

@canboat/canboatjs

Version:

Native javascript version of canboat

483 lines 19.9 kB
"use strict"; /** * Copyright 2025 Scott Bender (scott@scottbender.net) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.N2kDevice = void 0; const ts_pgns_1 = require("@canboat/ts-pgns"); const node_events_1 = require("node:events"); const lodash_1 = __importDefault(require("lodash")); const int64_buffer_1 = require("int64-buffer"); const codes_1 = require("./codes"); const toPgn_1 = require("./toPgn"); const package_json_1 = __importDefault(require("../package.json")); const persist_1 = require("./persist"); const utilities_1 = require("./utilities"); const deviceTransmitPGNs = [60928, 59904, 126996, 126464]; class N2kDevice extends node_events_1.EventEmitter { addressClaim; productInfo; configurationInfo; options; address; cansend; foundConflict; heartbeatCounter; devices; sentAvailable; addressClaimDetectionTime; transmitPGNs; addressClaimSentAt; addressClaimChecker; heartbeatInterval; debug; constructor(options, debugName) { super(); this.options = options === undefined ? {} : options; this.debug = (0, utilities_1.createDebug)(debugName, options); let uniqueNumber; if (options.uniqueNumber !== undefined) { uniqueNumber = options.uniqueNumber; } else { uniqueNumber = this.getPersistedData('uniqueNumber'); if (uniqueNumber === undefined) { uniqueNumber = Math.floor(Math.random() * Math.floor(2097151)); this.savePersistedData('uniqueNumber', uniqueNumber); } } if (options.addressClaim) { this.addressClaim = options.addressClaim; this.addressClaim.pgn = 60928; this.addressClaim.dst = 255; this.addressClaim.prio = 6; } else { // Device Instance: 8-bit identifier built from a 3-bit "lower" // and a 5-bit "upper" field, mirroring the N2K standard. We // accept a single combined value 0-255 via options.deviceInstance // and split it; individual lower/upper overrides also work for // users that want to set them explicitly. // // Inputs that fall outside the documented range (or aren't a // finite integer-coercible value at all) are dropped silently // back to 0. We deliberately do NOT bit-mask out-of-range // numbers: silently emitting `255 → 0` from a user-set // deviceInstance of e.g. 257 would be more surprising than the // fallback. Numeric-string forms ("3") are accepted because // the admin UI's <input type="number"> ships values as strings. const toIntInRange = (v, min, max) => { let n; if (typeof v === 'number' && Number.isFinite(v)) n = Math.trunc(v); else if (typeof v === 'string' && v.trim() !== '') { const parsed = Number(v); if (Number.isFinite(parsed)) n = Math.trunc(parsed); } if (n === undefined || n < min || n > max) return undefined; return n; }; const combined = toIntInRange(options.deviceInstance, 0, 0xff) ?? 0; const explicitLower = toIntInRange(options.deviceInstanceLower, 0, 0x07); const explicitUpper = toIntInRange(options.deviceInstanceUpper, 0, 0x1f); const explicitSystem = toIntInRange(options.systemInstance, 0, 0x0f); const deviceInstanceLower = explicitLower !== undefined ? explicitLower : combined & 0x07; const deviceInstanceUpper = explicitUpper !== undefined ? explicitUpper : (combined >> 3) & 0x1f; const systemInstance = explicitSystem !== undefined ? explicitSystem : 0; this.addressClaim = new ts_pgns_1.PGN_60928({ manufacturerCode: options.manufacturerCode != undefined ? options.manufacturerCode : 999, deviceFunction: 130, // PC gateway deviceClass: 25, // Inter/Intranetwork Device deviceInstanceLower, deviceInstanceUpper, systemInstance, industryGroup: 4, // Marine arbitraryAddressCapable: ts_pgns_1.YesNo.Yes }, 255); } // PGN_60928 stores its NMEA fields in `.fields` (canboat camelCase Id // form). Older canboatjs code set a top-level `uniqueNumber` / // `'Unique Number'` property, which the encoder ignored — meaning every // signalk-server claim went out with the all-ones (0x1FFFFF) sentinel // unique number. Some N2K analyzers (e.g. Maretron) treat that value // as factory-default and hide the device. // // Honor any uniqueNumber the caller already set on the supplied // addressClaim — both the canonical `.fields.uniqueNumber` form and // the legacy top-level `uniqueNumber` / `'Unique Number'` shapes — // before backfilling from options/persistence. This preserves a // caller-supplied claim's identity instead of silently overwriting it. const ac = this.addressClaim; const fields = (ac.fields = ac.fields || {}); if (fields.uniqueNumber === undefined) { const legacy = ac.uniqueNumber ?? ac['Unique Number']; if (legacy !== undefined) { fields.uniqueNumber = legacy; } else { fields.uniqueNumber = uniqueNumber; } } const version = package_json_1.default ? package_json_1.default.version : '1.0'; if (options.productInfo) { this.productInfo = options.productInfo; this.productInfo.pgn = 126996; this.productInfo.dst = 255; } else { this.productInfo = new ts_pgns_1.PGN_126996({ nmea2000Version: 1300, productCode: 667, // Just made up.. modelId: 'signalk-server', softwareVersionCode: getServerVersion(options), modelVersion: version, modelSerialCode: uniqueNumber.toString(), certificationLevel: 0, loadEquivalency: 1 }); } const url = getServerURL(options); if (options.configurationInfo) { this.configurationInfo = options.configurationInfo; this.configurationInfo.pgn = 126998; this.configurationInfo.dst = 255; } else if (url) { this.configurationInfo = new ts_pgns_1.PGN_126998({ installationDescription1: url }); } let address = undefined; address = this.getPersistedData('lastAddress'); if (address === undefined) { address = lodash_1.default.isUndefined(options.preferredAddress) ? 100 : options.preferredAddress; } this.address = address; this.cansend = false; this.foundConflict = false; this.heartbeatCounter = 0; this.devices = {}; this.sentAvailable = false; this.addressClaimDetectionTime = options.addressClaimDetectionTime !== undefined ? options.addressClaimDetectionTime : 5000; if (!options.disableDefaultTransmitPGNs) { this.transmitPGNs = lodash_1.default.union(deviceTransmitPGNs, codes_1.defaultTransmitPGNs); } else { this.transmitPGNs = [...deviceTransmitPGNs]; } if (this.options.transmitPGNs) { this.transmitPGNs = lodash_1.default.union(this.transmitPGNs, this.options.transmitPGNs); } } start() { sendISORequest(this, 60928, 254); setTimeout(() => { sendAddressClaim(this); }, 1000); } stop() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; } if (this.addressClaimChecker) { clearTimeout(this.addressClaimChecker); this.addressClaimChecker = undefined; } this.cansend = false; } getPersistedData(key) { try { return (0, persist_1.getPersistedData)(this.options, this.options.providerId, key); } catch (err) { this.debug('reading persisted data %o', err); if (err.code !== 'ENOENT') { console.error(err); this.setError(err.message); } } } savePersistedData(key, value) { try { (0, persist_1.savePersistedData)(this.options, this.options.providerId, key, value); } catch (err) { console.error(err); this.setError(err.message); } } setStatus(msg) { if (this.options.app && this.options.app.setProviderStatus) { this.options.app.setProviderStatus(this.options.providerId, msg); } } setError(msg) { if (this.options.app && this.options.app.setProviderError) { this.options.app.setProviderError(this.options.providerId, msg); } } n2kMessage(pgn) { if (pgn.dst == 255 || (this.cansend && pgn.dst == this.address)) { try { if (pgn.pgn == 59904 && this.cansend) { handleISORequest(this, pgn); } else if (pgn.pgn == 126208 && this.cansend) { handleGroupFunction(this, pgn); } else if (pgn.pgn == 60928) { handleISOAddressClaim(this, pgn); } /*else if (pgn.pgn == 126996 && this.cansend) { handleProductInformation(this, pgn) }*/ } catch (err) { console.error(err); } /* var handler = this.handlers[pgn.pgn.toString()] if ( pgn.dst == this.address ) debug(`handler ${handler}`) if ( _.isFunction(handler) ) { debug(`got handled PGN %j ${handled}`, pgn) handler(pgn) } */ } } sendPGN(_pgn, _src = undefined) { } } exports.N2kDevice = N2kDevice; function getServerURL(options) { if (options.app?.config?.getExternalHostname !== undefined) { return `${options.app.config.ssl ? 'https' : 'http'}://${options.app.config.getExternalHostname()}:${options.app.config.getExternalPort()}`; } } function getServerVersion(options) { if (options.app?.config?.version !== undefined) { return options.app.config.version; } } function handleISORequest(device, n2kMsg) { device.debug('handleISORequest %j', n2kMsg); const PGN = Number(n2kMsg.fields.pgn); switch (PGN) { case 126996: // Product Information request sendProductInformation(device); break; case 126998: // Config Information request sendConfigInformation(device); break; case 60928: // ISO address claim request device.debug('sending address claim'); device.sendPGN(device.addressClaim); device.options?.app?.emit(device.options.analyzerOutEvent || 'N2KAnalyzerOut', device.addressClaim); break; case 126464: sendPGNList(device, n2kMsg.src); break; default: if (!device.options.disableNAKs) { device.debug(`Got unsupported ISO request for PGN ${PGN}. Sending NAK.`); sendNAKAcknowledgement(device, n2kMsg.src, PGN); } } } function handleGroupFunction(device, n2kMsg) { device.debug('handleGroupFunction %j', n2kMsg); const functionCode = n2kMsg.fields.functionCode; if (functionCode === 'Request') { handleRequestGroupFunction(device, n2kMsg); } else if (functionCode === 'Command') { handleCommandGroupFunction(device, n2kMsg); } else { device.debug('Got unsupported Group Function PGN: %j', n2kMsg); } function handleRequestGroupFunction(device, n2kMsg) { if (!device.options.disableNAKs) { // We really don't support group function requests for any PGNs yet -> always respond with pgnErrorCode 1 = "PGN not supported" const PGN = n2kMsg.fields.pgn; device.debug("Sending 'PGN Not Supported' Group Function response for requested PGN", PGN); const acknowledgement = new ts_pgns_1.PGN_126208_NmeaAcknowledgeGroupFunction({ pgn: PGN, pgnErrorCode: ts_pgns_1.PgnErrorCode.NotSupported, transmissionIntervalPriorityErrorCode: ts_pgns_1.TransmissionInterval.Acknowledge, numberOfParameters: 0, list: [] }, n2kMsg.src); device.sendPGN(acknowledgement); } } function handleCommandGroupFunction(device, n2kMsg) { if (!device.options.disableNAKs) { // We really don't support group function commands for any PGNs yet -> always respond with pgnErrorCode 1 = "PGN not supported" const PGN = n2kMsg.fields.pgn; device.debug("Sending 'PGN Not Supported' Group Function response for commanded PGN", PGN); const acknowledgement = new ts_pgns_1.PGN_126208_NmeaAcknowledgeGroupFunction({ pgn: PGN, pgnErrorCode: ts_pgns_1.PgnErrorCode.NotSupported, transmissionIntervalPriorityErrorCode: ts_pgns_1.TransmissionInterval.Acknowledge, numberOfParameters: 0, list: [] }, n2kMsg.src); device.sendPGN(acknowledgement); } } } function handleISOAddressClaim(device, n2kMsg) { if (device.cansend == false || n2kMsg.src != device.address) { if (!device.devices[n2kMsg.src]) { device.debug(`registering device ${n2kMsg.src}`); device.devices[n2kMsg.src] = { addressClaim: n2kMsg }; if (device.cansend) { //sendISORequest(device, 126996, undefined, n2kMsg.src) } } return; } device.debug('Checking ISO address claim. %j', n2kMsg); const uint64ValueFromReceivedClaim = getISOAddressClaimAsUint64(n2kMsg); const uint64ValueFromOurOwnClaim = getISOAddressClaimAsUint64(device.addressClaim); if (uint64ValueFromOurOwnClaim < uint64ValueFromReceivedClaim) { device.debug(`Address conflict detected! Kept our address as ${device.address}.`); sendAddressClaim(device); // We have smaller address claim data -> we can keep our address -> re-claim it } else if (uint64ValueFromOurOwnClaim > uint64ValueFromReceivedClaim) { device.foundConflict = true; increaseOwnAddress(device); // We have bigger address claim data -> we have to change our address device.debug(`Address conflict detected! trying address ${device.address}.`); sendAddressClaim(device); } } function increaseOwnAddress(device) { const start = device.address; do { device.address = (device.address + 1) % 253; } while (device.address != start && device.devices[device.address]); } /* function handleProductInformation(device: N2kDevice, n2kMsg: PGN_126996) { if (!device.devices[n2kMsg.src!]) { device.devices[n2kMsg.src!] = {} } device.debug('got product information %j', n2kMsg) device.devices[n2kMsg.src!].productInformation = n2kMsg } */ function sendHeartbeat(device) { device.heartbeatCounter = device.heartbeatCounter + 1; if (device.heartbeatCounter > 252) { device.heartbeatCounter = 0; } const hb = new ts_pgns_1.PGN_126993({ dataTransmitOffset: 60, sequenceCounter: device.heartbeatCounter, equipmentStatus: ts_pgns_1.EquipmentStatus.Operational }); device.sendPGN(hb); } function announceStartupMessages(device) { sendProductInformation(device); sendConfigInformation(device); } function sendAddressClaim(device) { if (device.devices[device.address]) { //someone already has this address, so find a free one increaseOwnAddress(device); } device.debug(`Sending address claim ${device.address}`); device.sendPGN(device.addressClaim); const version = package_json_1.default ? package_json_1.default.version : 'unknown'; device.setStatus(`Claimed address ${device.address} (canboatjs v${version})`); device.addressClaimSentAt = Date.now(); if (device.addressClaimChecker) { clearTimeout(device.addressClaimChecker); } device.addressClaimChecker = setTimeout(() => { //if ( Date.now() - device.addressClaimSentAt > 1000 ) { //device.addressClaimChecker = null device.debug('claimed address %d', device.address); device.savePersistedData('lastAddress', device.address); device.cansend = true; announceStartupMessages(device); if (!device.sentAvailable) { if (device.options.app) { device.options.app.emit('nmea2000OutAvailable'); } device.emit('nmea2000OutAvailable'); device.sentAvailable = true; } //sendISORequest(device, 126996) if (!device.heartbeatInterval) { device.heartbeatInterval = setInterval(() => { sendHeartbeat(device); }, 60 * 1000); } //} }, device.addressClaimDetectionTime); } function sendISORequest(device, pgn, src = undefined, dst = 255) { device.debug(`Sending iso request for ${pgn} to ${dst}`); const isoRequest = new ts_pgns_1.PGN_59904({ pgn }); device.sendPGN(isoRequest, src); } function sendProductInformation(device) { device.debug('Sending product info'); device.sendPGN(device.productInfo); } function sendConfigInformation(device) { if (device.configurationInfo) { device.debug('Sending config info..'); device.sendPGN(device.configurationInfo); } } function sendNAKAcknowledgement(device, src, requestedPGN) { const acknowledgement = new ts_pgns_1.PGN_59392({ control: ts_pgns_1.IsoControl.Nak, groupFunction: 255, pgn: requestedPGN }, src); device.sendPGN(acknowledgement); } function sendPGNList(device, dst) { //FIXME: for now, adding everything that signalk-to-nmea2000 supports //need a way for plugins, etc. to register the pgns they provide const pgnList = new ts_pgns_1.PGN_126464({ functionCode: ts_pgns_1.PgnListFunction.TransmitPgnList, list: device.transmitPGNs.map((num) => { return { pgn: num }; }) }, dst); device.sendPGN(pgnList); } function getISOAddressClaimAsUint64(pgn) { return new int64_buffer_1.Uint64LE((0, toPgn_1.toPgn)(pgn)); } //# sourceMappingURL=n2kDevice.js.map