From 06dcd82f484e92b55093633cc19c6ef9eaabcd58 Mon Sep 17 00:00:00 2001 From: Milo Turner Date: Mon, 17 Aug 2026 14:58:26 -0400 Subject: [PATCH] Auto-detect Desoutter controllers during the handshake Desoutter controllers reject any stationID/spindleID other than blanks. The serializer could already blank them out, but only via a constructor option decided before we know what we're talking to. Make the mode settable at runtime through the whole stack (SessionControlClient -> LinkLayer -> OpenProtocolSerializer), and turn it on automatically when the MID 2 reply of the handshake reports a supplier code starting with "DE". Detection only ever enables the mode, and is skipped entirely when the caller passed an explicit value, so other controllers keep their current behavior. Note that MID 2 revision 1 carries no supplier code, so detection needs revision 2 or higher. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 9 ++- src/linkLayer.js | 10 +++ src/openProtocolSerializer.js | 11 ++++ src/sessionControlClient.js | 47 ++++++++++++++ test/openProtocolSerialzer.spec.js | 47 ++++++++++++++ test/sessionControlClient.spec.js | 101 +++++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ad7360..421cdde 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,14 @@ let options = { retryTimes: 3, // A list of MIDs for which we'll not parse the payload - disableMidParsing: {} + disableMidParsing: {}, + + // Weather to send the stationID and spindleID header fields as blanks, + // which is the only value Desoutter controllers accept + //true: enforce activation + //false: enforce deactivation + //undefined: auto detect from the supplier code of the MID 0002 reply + desoutterCompatibilityMode: undefined } let controllerIp = "127.0.0.1"; diff --git a/src/linkLayer.js b/src/linkLayer.js index baef51c..f29550f 100644 --- a/src/linkLayer.js +++ b/src/linkLayer.js @@ -44,6 +44,7 @@ class LinkLayer extends Duplex { * @param {number} opts.retryTimes * @param {boolean} opts.rawData * @param {boolean} opts.disableMidParsing + * @param {boolean} opts.desoutterCompatibilityMode */ constructor(opts) { debug("new LinkLayer", opts); @@ -114,6 +115,15 @@ class LinkLayer extends Duplex { //RECEIVER DATA } + /** + * @description Enables or disables the Desoutter compatibility mode at runtime. + * @param {boolean} enabled + */ + setDesoutterCompatibilityMode(enabled) { + debug("LinkLayer setDesoutterCompatibilityMode", enabled); + this.opSerializer.setDesoutterCompatibilityMode(enabled); + } + _onErrorSerializer(err) { debug("LinkLayer _onErrorSerializer", err); diff --git a/src/openProtocolSerializer.js b/src/openProtocolSerializer.js index 7a4d277..0e8672b 100644 --- a/src/openProtocolSerializer.js +++ b/src/openProtocolSerializer.js @@ -58,6 +58,17 @@ class OpenProtocolSerializer extends Transform { this.desoutterCompatibilityMode = opts.desoutterCompatibilityMode || false; } + /** + * @description Enables or disables the Desoutter compatibility mode at runtime. + * In this mode the stationID and spindleID header fields are serialized as + * blanks, as Desoutter controllers reject any other value. + * @param {boolean} enabled + */ + setDesoutterCompatibilityMode(enabled) { + debug("openProtocolSerializer setDesoutterCompatibilityMode", enabled); + this.desoutterCompatibilityMode = !!enabled; + } + _transform(chunk, encoding, cb) { debug("openProtocolSerializer _transform", chunk); diff --git a/src/sessionControlClient.js b/src/sessionControlClient.js index ac46ad3..a2a5fdc 100644 --- a/src/sessionControlClient.js +++ b/src/sessionControlClient.js @@ -38,6 +38,9 @@ const REQUEST = "request"; const MANUAL = "manual"; const GENERIC = "generic"; +//Supplier code prefix reported by Desoutter controllers on MID 2 +const DESOUTTER_SUPPLIER_PREFIX = "DE"; + //Status Connect const CONN_NOT_CONNECT = 0; const CONN_CONNECTING = 1; @@ -125,6 +128,7 @@ class SessionControlClient extends EventEmitter { * @param {object} [opts.disableMidParsing = {}] * @param {number} [opts.timeOut = 3000] * @param {number} [opts.retryTimes = 3] + * @param {boolean} [opts.desoutterCompatibilityMode] true = always send blank stationID/spindleID / false = never / undefined = auto-detect from the supplier code of the MID 2 reply * * @example * // Instantiate SessionControlClient with default values @@ -202,6 +206,11 @@ class SessionControlClient extends EventEmitter { desoutterCompatibilityMode: opts.desoutterCompatibilityMode, }); + //Desoutter compatibility mode + //If the caller didn't take an explicit position, we auto-detect it on + //the MID 2 reply of the handshake. An explicit value is always honored. + this.autoDetectDesoutter = opts.desoutterCompatibilityMode === undefined; + this.ll.on("error", (err) => this._onErrorLinkLayer(err)); this.changeRevisionGeneric = false; @@ -248,6 +257,42 @@ class SessionControlClient extends EventEmitter { return !!this.useLinkLayer; } + /** + * @description Enables or disables the Desoutter compatibility mode at runtime. + * In this mode the stationID and spindleID header fields are sent as blanks, + * as Desoutter controllers reject any other value. + * @param {boolean} enabled + */ + setDesoutterCompatibilityMode(enabled) { + debug("SessionControlClient setDesoutterCompatibilityMode", enabled); + this.ll.setDesoutterCompatibilityMode(enabled); + } + + /** + * @description Turns on the Desoutter compatibility mode if the controller + * identified itself as a Desoutter one on the MID 2 reply. Never turns it + * off, so that an explicitly requested mode can't be undone by a controller + * that reports an unexpected supplier code. + * @private + * @param {object} data the MID 2 message + */ + _detectDesoutter(data) { + if (!this.autoDetectDesoutter) { + return; + } + + let supplierCode = data.payload && data.payload.supplierCode; + + if (typeof supplierCode !== "string") { + return; + } + + if (supplierCode.trim().toUpperCase().startsWith(DESOUTTER_SUPPLIER_PREFIX)) { + debug("SessionControlClient _detectDesoutter detected", supplierCode); + this.setDesoutterCompatibilityMode(true); + } + } + /** * @description This method makes a connection with the controller. * If add a callback function, it will add as listener of connect @event. @@ -358,6 +403,8 @@ class SessionControlClient extends EventEmitter { this.ll.removeAllListeners(); + this._detectDesoutter(data); + this.statusConnection = CONN_CONNECTED; this.controllerData = data; diff --git a/test/openProtocolSerialzer.spec.js b/test/openProtocolSerialzer.spec.js index b90e90b..04da398 100644 --- a/test/openProtocolSerialzer.spec.js +++ b/test/openProtocolSerialzer.spec.js @@ -275,4 +275,51 @@ describe("Open Protocol Serializer", () => { }); }); + it('should toggle the desoutter compatibility mode at runtime', (done) => { + let serializer = new OpenProtocolSerializer(); + let received = []; + + //00230240001001010000250[Null] + const normal = Buffer.from('00230240001001010000250'); + //002302400010 0000250[Null] + const desoutter = Buffer.from('002302400010 0000250'); + + let write = () => serializer.write({ + mid: 240, + revision: 1, + noAck: false, + stationID: 1, + spindleID: 1, + sequenceNumber: 0, + messageParts: 0, + messageNumber: 0, + payload: "250" + }); + + serializer.on('data', (data) => { + received.push(data); + + switch (received.length) { + case 1: + expect(data).to.be.deep.equal(normal); + serializer.setDesoutterCompatibilityMode(true); + write(); + break; + + case 2: + expect(data).to.be.deep.equal(desoutter); + serializer.setDesoutterCompatibilityMode(false); + write(); + break; + + case 3: + expect(data).to.be.deep.equal(normal); + done(); + break; + } + }); + + write(); + }); + }); diff --git a/test/sessionControlClient.spec.js b/test/sessionControlClient.spec.js index 6da43a4..bf2ce7c 100644 --- a/test/sessionControlClient.spec.js +++ b/test/sessionControlClient.spec.js @@ -1059,4 +1059,105 @@ describe("Session Control Client", () => { sessionControlClient.connect(); }); + it("Should enable the desoutter compatibility mode when the supplier code starts with DE", (done) => { + + let writes = []; + let sessionControlClient; + + let stream = createStreamHelper((data) => { + + writes.push(data); + + if (writes.length === 1) { + //MID 2 revision 2, supplier code "DE3" + stream.push(Buffer.from("00620002002000000000010001020103Airbag1 04DE3\u0000")); + return; + } + + if (writes.length === 2) { + expect(data.toString("ascii", 12, 16)).to.be.equal(" "); + sessionControlClient.close(); + done(); + } + }); + + sessionControlClient = new SessionControlClient({ + stream: stream + }); + + sessionControlClient.on("connect", (data) => { + expect(data.payload.supplierCode).to.be.equal("DE3"); + sessionControlClient.sendMid(9999, {}, () => {}); + }); + + sessionControlClient.connect(); + }); + + it("Should keep sending the stationID and spindleID for a non desoutter supplier code", (done) => { + + let writes = []; + let sessionControlClient; + + let stream = createStreamHelper((data) => { + + writes.push(data); + + if (writes.length === 1) { + //MID 2 revision 2, supplier code "AC " + stream.push(Buffer.from("00620002002000000000010001020103Airbag1 04AC \u0000")); + return; + } + + if (writes.length === 2) { + expect(data.toString("ascii", 12, 16)).to.be.equal("0101"); + sessionControlClient.close(); + done(); + } + }); + + sessionControlClient = new SessionControlClient({ + stream: stream + }); + + sessionControlClient.on("connect", () => { + sessionControlClient.sendMid(9999, {}, () => {}); + }); + + sessionControlClient.connect(); + }); + + it("Should not auto detect the desoutter compatibility mode when it is explicitly disabled", (done) => { + + let writes = []; + let sessionControlClient; + + let stream = createStreamHelper((data) => { + + writes.push(data); + + if (writes.length === 1) { + //MID 2 revision 2, supplier code "DE3" + stream.push(Buffer.from("00620002002000000000010001020103Airbag1 04DE3\u0000")); + return; + } + + if (writes.length === 2) { + expect(data.toString("ascii", 12, 16)).to.be.equal("0101"); + sessionControlClient.close(); + done(); + } + }); + + sessionControlClient = new SessionControlClient({ + stream: stream, + desoutterCompatibilityMode: false + }); + + sessionControlClient.on("connect", () => { + sessionControlClient.sendMid(9999, {}, () => {}); + }); + + sessionControlClient.connect(); + }); + });