UNPKG

node-fxplc

Version:

Node.js library for low-level Mitsubishi FX (MELSEC) PLC framed protocol communication

385 lines (384 loc) 16 kB
// ESM source version of FXPLCClient with simplified lazy debug loader import { Commands, RegisterDef, registersMapBits, registersMapData } from './registers.js'; import { EventEmitter } from 'events'; import { NumberType, NumberTypeConverters } from './number-types.js'; import { AsyncLock } from './async-lock.js'; import { calcChecksum } from './protocol-utils.js'; import { NoResponseError, ResponseMalformedError, NotSupportedCommandError } from './errors.js'; const STX = 0x02; const ETX = 0x03; const NAK = 0x15; const ACK = 0x06; // Minimal lazy loader: tries require (CJS build) else falls back to no-op. let _dbgFn = null; function getDbg() { if (_dbgFn) return _dbgFn; if (typeof require === 'function') { try { _dbgFn = require('debug')('fxplc:client'); return _dbgFn; } catch { /* ignore */ } } _dbgFn = (..._a) => { }; return _dbgFn; } function bufferToHex(buf) { return buf.toString('hex'); } /** * FXPLCClient - High-level helper for Mitsubishi FX0N / FX1N style framed protocol. * Options: * - retry: { count: number, delayMs: number } (default: {1, 100}) * - debug: boolean (enable frame logging) * - timeoutMs: per-operation timeout (default 2000ms) */ export class FXPLCClient extends EventEmitter { constructor(transport, opts = {}) { super(); this.transport = transport; this._lock = new AsyncLock(); this._retry = opts.retry || { count: 1, delayMs: 100 }; this._debug = opts.debug || false; this._timeoutMs = opts.timeoutMs || 2000; if (transport && typeof transport.on === 'function') { transport.on('connect', (...a) => this.emit('connect', ...a)); transport.on('disconnect', (...a) => this.emit('disconnect', ...a)); transport.on('error', (e) => this.emit('error', e)); } } /** Close underlying transport (if it supports close) and emit disconnect */ close() { if (this.transport && this.transport.close) this.transport.close(); this.emit('disconnect'); } /** Batch read registers; coalesces consecutive addresses where possible */ batchRead(registers, numberType = NumberType.WordSigned, cb) { if (typeof numberType === 'function') { cb = numberType; numberType = NumberType.WordSigned; } const prom = (async () => { if (!Array.isArray(registers) || registers.length === 0) return []; if (!NumberTypeConverters[numberType]) throw new Error('batchRead: numberType non supportato'); const regObjs = registers.map(r => { try { return r instanceof RegisterDef ? r : RegisterDef.parse(r); } catch { throw new Error('batchRead: registro non valido: ' + r); } }); const type = regObjs[0].type; if (!registersMapData[type]) throw new Error('batchRead: tipo registro non supportato: ' + type); const nums = regObjs.map(r => r.num); const isConsecutive = regObjs.every(r => r.type === type) && nums.every((n, i, a) => i === 0 || n === a[i - 1] + 1); if (isConsecutive) { const base = registersMapData[type]; const conv = NumberTypeConverters[numberType]; const addr = base + regObjs[0].num * 2; const data = await this.readBytes(addr, conv.size * regObjs.length); const out = []; for (let i = 0; i < regObjs.length; ++i) out.push(conv.read(data, i * conv.size)); return out; } else { return await Promise.all(regObjs.map(r => this.readNumber(r, numberType))); } })(); if (cb) prom.then(r => cb(null, r)).catch(e => cb(e)); else return prom; } /** Batch write registers; packs consecutive region into single write */ batchWrite(registers, values, numberType = NumberType.WordSigned, cb) { if (typeof numberType === 'function') { cb = numberType; numberType = NumberType.WordSigned; } const prom = (async () => { if (!Array.isArray(registers) || registers.length === 0) return; if (!Array.isArray(values) || values.length !== registers.length) throw new Error('batchWrite: valori non corrispondenti ai registri'); if (!NumberTypeConverters[numberType]) throw new Error('batchWrite: numberType non supportato'); const regObjs = registers.map(r => { try { return r instanceof RegisterDef ? r : RegisterDef.parse(r); } catch { throw new Error('batchWrite: registro non valido: ' + r); } }); const type = regObjs[0].type; if (!registersMapData[type]) throw new Error('batchWrite: tipo registro non supportato: ' + type); const nums = regObjs.map(r => r.num); const isConsecutive = regObjs.every(r => r.type === type) && nums.every((n, i, a) => i === 0 || n === a[i - 1] + 1); if (isConsecutive) { const base = registersMapData[type]; const conv = NumberTypeConverters[numberType]; const addr = base + regObjs[0].num * 2; const buf = Buffer.alloc(conv.size * regObjs.length); for (let i = 0; i < regObjs.length; ++i) conv.write(buf, i * conv.size, values[i]); await this.writeBytes(addr, buf); } else { await Promise.all(regObjs.map((r, i) => this.writeNumber(r, values[i], numberType))); } })(); if (cb) prom.then(() => cb(null)).catch(e => cb(e)); else return prom; } /** Read a single bit (e.g. 'M10') */ readBit(register, cb) { const prom = (async () => { if (!register) throw new Error('readBit: registro mancante'); let reg; try { reg = register instanceof RegisterDef ? register : RegisterDef.parse(register); } catch { throw new Error('readBit: registro non valido: ' + register); } return await this._withRetry(async () => { const [addr, bit] = reg.getBitImageAddress(); const bytes = await this.readBytes(addr, 1); if (bytes.length !== 1) throw new ResponseMalformedError(); return (bytes[0] & (1 << bit)) !== 0; }, 'readBit'); })(); if (cb) prom.then(r => cb(null, r)).catch(e => cb(e)); else return prom; } /** Force a single bit ON/OFF (e.g. 'M10', true) */ writeBit(register, value, cb) { const prom = (async () => { if (!register) throw new Error('writeBit: registro mancante'); let reg; try { reg = register instanceof RegisterDef ? register : RegisterDef.parse(register); } catch { throw new Error('writeBit: registro non valido: ' + register); } return await this._withRetry(async () => { const [topAddress, denominator] = registersMapBits[reg.type]; if (!topAddress) throw new Error('writeBit: tipo registro non supportato: ' + reg.type); const addr = topAddress + (Math.floor(reg.num / denominator) * 8 + (reg.num % denominator)); const buf = Buffer.alloc(2); buf.writeUInt16LE(addr, 0); await this._sendCommand(value ? Commands.FORCE_ON : Commands.FORCE_OFF, buf); }, 'writeBit'); })(); if (cb) prom.then(() => cb(null)).catch(e => cb(e)); else return prom; } /** Convenience: read signed word */ readInt(register, cb) { if (cb) this.readNumber(register, NumberType.WordSigned).then(r => cb(null, r)).catch(e => cb(e)); else return this.readNumber(register, NumberType.WordSigned); } /** Read a numeric value using a given NumberType */ readNumber(register, numberType, cb) { if (typeof numberType === 'function') { cb = numberType; numberType = NumberType.WordSigned; } const prom = (async () => { if (!register) throw new Error('readNumber: registro mancante'); if (!NumberTypeConverters[numberType]) throw new Error('readNumber: numberType non supportato'); let reg; try { reg = register instanceof RegisterDef ? register : RegisterDef.parse(register); } catch { throw new Error('readNumber: registro non valido: ' + register); } return await this._withRetry(async () => { const base = registersMapData[reg.type]; if (base === undefined) throw new Error('readNumber: tipo registro non supportato: ' + reg.type); const conv = NumberTypeConverters[numberType]; const addr = base + reg.num * 2; const data = await this.readBytes(addr, conv.size); if (data.length !== conv.size) throw new ResponseMalformedError(); return conv.read(data, 0); }, 'readNumber'); })(); if (cb) prom.then(r => cb(null, r)).catch(e => cb(e)); else return prom; } /** Write a numeric value using a given NumberType */ writeNumber(register, value, numberType, cb) { if (typeof numberType === 'function') { cb = numberType; numberType = NumberType.WordSigned; } const prom = (async () => { if (!register) throw new Error('writeNumber: registro mancante'); if (!NumberTypeConverters[numberType]) throw new Error('writeNumber: numberType non supportato'); let reg; try { reg = register instanceof RegisterDef ? register : RegisterDef.parse(register); } catch { throw new Error('writeNumber: registro non valido: ' + register); } return await this._withRetry(async () => { const base = registersMapData[reg.type]; if (base === undefined) throw new Error('writeNumber: tipo registro non supportato: ' + reg.type); const conv = NumberTypeConverters[numberType]; const addr = base + reg.num * 2; const buf = Buffer.alloc(conv.size); conv.write(buf, 0, value); await this.writeBytes(addr, buf); }, 'writeNumber'); })(); if (cb) prom.then(() => cb(null)).catch(e => cb(e)); else return prom; } /** Read raw bytes from absolute PLC address */ readBytes(addr, count = 1, cb) { const prom = this._withRetry(async () => { const req = Buffer.alloc(3); // struct ">HB" big endian: H=2 bytes, B=1 req.writeUInt16BE(addr, 0); req.writeUInt8(count, 2); return await this._sendCommand(Commands.BYTE_READ, req); }, 'readBytes'); if (typeof count === 'function') { cb = count; count = 1; } if (cb) prom.then(r => cb(null, r)).catch(e => cb(e)); else return prom; } /** Write raw bytes to absolute PLC address */ writeBytes(addr, values, cb) { const prom = this._withRetry(async () => { const req = Buffer.alloc(3 + values.length); req.writeUInt16BE(addr, 0); req.writeUInt8(values.length, 2); values.copy(req, 3); await this._sendCommand(Commands.BYTE_WRITE, req); }, 'writeBytes'); if (typeof values === 'function') { cb = values; values = null; } if (cb) prom.then(() => cb(null)).catch(e => cb(e)); else return prom; } /** Convenience: write signed word */ writeInt(register, value, cb) { if (cb) this.writeNumber(register, value, NumberType.WordSigned).then(() => cb(null)).catch(e => cb(e)); else return this.writeNumber(register, value, NumberType.WordSigned); } // --- Internals --- async _withRetry(fn, opname) { let lastErr; for (let i = 0; i < this._retry.count; ++i) { try { return await this._withTimeout(fn, this._timeoutMs); } catch (e) { lastErr = e; if (this._debug) console.warn(`[FXPLCClient] ${opname} errore:`, e); this.emit('error', e); if (i < this._retry.count - 1) await new Promise(r => setTimeout(r, this._retry.delayMs)); } } throw lastErr; } async _withTimeout(fn, timeoutMs) { return await Promise.race([ fn(), new Promise((_, reject) => setTimeout(() => reject(new NoResponseError('Timeout operazione')), timeoutMs)) ]); } async _sendCommand(cmd, data) { return this._lock.run(async () => { const cmdAscii = Buffer.from([(0x30) + cmd]); const payloadHex = data.toString('hex').toUpperCase(); const payload = Buffer.concat([cmdAscii, Buffer.from(payloadHex, 'ascii')]); const frameNoChecksum = Buffer.concat([Buffer.from([STX]), payload, Buffer.from([ETX])]); const checksum = calcChecksum(Buffer.concat([payload, Buffer.from([ETX])])); const frame = Buffer.concat([frameNoChecksum, checksum]); if (this._debug) getDbg()('TX', bufferToHex(frame)); await this.transport.write(frame); return await this._readResponse(); }); } async _readResponse() { const first = await this.transport.read(1); if (first.length === 0) throw new NoResponseError(); const code = first[0]; if (code === STX) { const hexParts = []; while (true) { const b = await this.transport.read(1); if (b.length === 0) throw new ResponseMalformedError(); if (b[0] === ETX) break; hexParts.push(b); } const hexBuf = Buffer.concat(hexParts); const checksum = await this.transport.read(2); if (checksum.length !== 2) throw new ResponseMalformedError(); if (!calcChecksum(Buffer.concat([hexBuf, Buffer.from([ETX])])).equals(checksum)) throw new ResponseMalformedError('Wrong checksum'); if (hexBuf.length === 0) return Buffer.alloc(0); const out = Buffer.from(hexBuf.toString('ascii'), 'hex'); if (this._debug) getDbg()('RX', hexBuf.toString('ascii'), out.toString('hex')); return out; } else if (code === NAK) { throw new NotSupportedCommandError(); } else if (code === ACK) { if (this._debug) getDbg()('RX ACK'); return Buffer.alloc(0); } else { throw new NoResponseError(); } } } // end class