├── .eslintrc.cjs ├── .gitignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── src ├── api.class.ts ├── datamap.class.ts ├── driver │ └── modbus-serial.driver.ts ├── enum.ts ├── index.ts ├── modbusdb.class.ts ├── modbusdb.interface.ts ├── register.ts ├── transaction.class.ts ├── types.ts └── utils.ts └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 'latest', // Allows the use of modern ECMAScript features 5 | sourceType: 'module' // Allows for the use of imports 6 | }, 7 | extends: ['plugin:@typescript-eslint/recommended'], // Uses the linting rules from @typescript-eslint/eslint-plugin 8 | env: { 9 | node: true // Enable Node.js global variables 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .idea 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 130 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # modbusdb 2 | An abstraction layer over the modbus protocol 3 | 4 | ![npm](https://img.shields.io/npm/dm/modbusdb) 5 | ![npm](https://img.shields.io/npm/v/modbusdb) 6 | 7 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/C1C2RHVTE) 8 | 9 | ### Install 10 | `npm install modbusdb` 11 | 12 | ## The main thing 13 | ```javascript 14 | 15 | // Create a KEYs for modbus register 16 | // 1 - Discrete Inputs 17 | // 2 - Coils 18 | // 3 - Input Registers 19 | // 4 - Holding Registers 20 | // createRegisterKey(UnitAddress: 1-254, ModbusTable: 1-4, RegisterAddress: 1-65000, BitIndex: 0-15 (optional)) 21 | const temperature = createRegisterKey(1, 4, 10) // Unit=1, Table=Holding Registers, Address=10 22 | const speed = createRegisterKey(1, 3, 500) // Unit=1, Table=Input Registers, Address=500 23 | const mode = createRegisterKey(1, 4, 856) // Unit=1, Table=Holding Registers, Address=856 24 | 25 | const db = new Modbusdb(...); 26 | 27 | // Read multiple values from slave modbus device: 28 | const result = await db.mget([ 29 | temperature, 30 | speed, 31 | mode 32 | ]) 33 | 34 | // Write values into modbus device: 35 | const result = await db.mset( 36 | [ 37 | [speed, 60], 38 | [mode, 10], 39 | ] 40 | ) 41 | 42 | // That`s it! As easy as possible and developer friendly:) 43 | 44 | ``` 45 | 46 | ### Example 47 | 48 | ```typescript 49 | import { Modbusdb, ModbusSerialDriver, Datamap, createRegisterKey, TypeEnum, ScopeEnum } from "modbusdb"; 50 | 51 | import ModbusRTU from 'modbus-serial'; 52 | 53 | const bootstrap = async () => { 54 | const client = new ModbusRTU(); 55 | 56 | // open connection to a serial port 57 | // await client.connectRTUBuffered("/dev/ttyUSB0", { baudRate: 9600 }); 58 | await client.connectTcpRTUBuffered("127.0.0.1", { port: 8502 }) 59 | // .... or any possible way in `modbus-serial` 60 | // connection issues are not handled by `modbusdb` 61 | 62 | const units = [ 63 | { 64 | address: 1, 65 | forceWriteMany: true, // Use 15(0x0f) and 16(0x10) functions for single register, default: false 66 | bigEndian: true, // You can use BigEndian for byte order, default: false 67 | swapWords: false, // This is applicable only for multi-registers types such as int32, float etc, default: false 68 | requestWithGaps: true, // If requesting address 10 and 13 allow to send one request to the device, default: true 69 | maxRequestSize: 32, // How many registers to be requested in one round-trip with device, default: 1 70 | } 71 | ]; 72 | 73 | // 1 -> ScopeEnum.PhysicalState -> Discrete Inputs 74 | // 2 -> ScopeEnum.InternalState -> Coils 75 | // 3 -> ScopeEnum.PhysicalRegister -> Input Registers 76 | // 4 -> ScopeEnum.InternalRegister -> Holding Registers 77 | 78 | // Define a schema for a database: 79 | const schema = [ 80 | // Every data item has a unique int32 key (like ID), this key depends on unit,table,address... 81 | { 82 | key: createRegisterKey(1, ScopeEnum.InternalRegister, 10), // Encode [unit,table,address,bit index] into a single 32bit integer key 83 | type: TypeEnum.Int16, // default: UInt16 84 | scale: 2, // 123 int will become a float number 1.23, so it means multiply a register value by 10^-2, default: 0 85 | freq: 12 // How often to poll this register, just remember "polling frequency" for now:), default: 0 86 | }, 87 | { 88 | key: createRegisterKey(1, ScopeEnum.InternalRegister, 11), 89 | type: TypeEnum.Int32, 90 | freq: 6 91 | }, 92 | { 93 | key: createRegisterKey(1, ScopeEnum.PhysicalRegister, 99) 94 | }, 95 | { 96 | key: createRegisterKey(1, ScopeEnum.InternalRegister, 15, 2), // Here is a SINGLE third-bit of the register (not coil and not discrete input) 97 | type: TypeEnum.Bit 98 | }, 99 | ] 100 | 101 | const db = new Modbusdb({ 102 | driver: new ModbusSerialDriver(client), 103 | datamap: new Datamap(schema, units), 104 | interval: 60, // 60 seconds 105 | timeout: 15, // 15 seconds 106 | roundSize: 12 // interval 60 divided by 12 is 5sec, so every 5 seconds modbusdb will poll for data 107 | // when round size is 12 we can divide interval by 12, and we have six integer "chunks of time" in the given "interval" 108 | // cause 12 has 6 Divisors (https://en.wikipedia.org/wiki/Table_of_divisors) 109 | // ---> time ---> 110 | // 0sec |5sec |10sec |15sec|20sec|25sec|.... 111 | // |<- round=60sec ->| 112 | // 1-----2-----3-----4-----5-----6-----7-----8-----9-----10-----11-----12 113 | // X-----X-----X-----X-----X-----X-----X-----X-----X------X------X-----X <- polling frequency is 12 114 | // X-----------X-----------X-----------X-----------X-------------X------ <- polling frequency is 6 115 | // X-----------------X-----------------X------------------X------------- <- polling frequency is 4 116 | // X-----------------------X-----------------------X-------------------- <- polling frequency is 3 117 | // X-----------------------------------X-------------------------------- <- polling frequency is 2 118 | // X-------------------------------------------------------------------- <- polling frequency is 1 119 | // X this is when request are made 120 | // so "polling frequency" means how many times request a register in a given "interval" 121 | // this pattern repeats over time :) 122 | }) 123 | 124 | db.on('tick', () => { 125 | console.log('tick') // this is a round tick, triggered every [interval/roundSize] seconds 126 | }); 127 | 128 | db.on('response', (t) => { 129 | console.log('transaction', t) 130 | }); 131 | 132 | db.on('data', (data) => { 133 | console.log('data', data) 134 | }); 135 | 136 | db.watch() // Start polling (if you wish to) 137 | 138 | // request three registers: unit=1, table=HoldingRegister,InputRegister, address=10,11,99 139 | const result = await db.mget([ 140 | createRegisterKey(1, ScopeEnum.InternalRegister, 10), 141 | createRegisterKey(1, ScopeEnum.InternalRegister, 11), 142 | createRegisterKey(1, ScopeEnum.PhysicalRegister, 99) 143 | ]) 144 | 145 | console.log('mget result', result) 146 | } 147 | 148 | // Start out app: 149 | bootstrap(); 150 | 151 | ``` 152 | 153 | ## TODO 154 | 1. Create documentation 155 | 2. Add unit tests 156 | 3. Make more examples 157 | 158 | ## Links 159 | 1. https://github.com/yaacov/node-modbus-serial 160 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "modbusdb", 3 | "version": "1.0.2", 4 | "description": "An abstraction layer over the modbus protocol", 5 | "types": "./dist/index.d.ts", 6 | "type": "module", 7 | "exports": "./dist/index.js", 8 | "engines": { 9 | "node": ">=12" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "build": "rimraf ./dist && tsc --declaration", 16 | "lint": "eslint src", 17 | "lint:fix": "eslint src --fix", 18 | "format": "prettier --config .prettierrc 'src/**/*.ts' --write", 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/yarosdev/modbusdb.git" 24 | }, 25 | "keywords": [ 26 | "nodejs", 27 | "modbus", 28 | "db" 29 | ], 30 | "author": "yaroslavhoisa@gmail.com", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/yarosdev/modbusdb/issues" 34 | }, 35 | "homepage": "https://github.com/yarosdev/modbusdb#readme", 36 | "dependencies": { 37 | "p-queue": "^7.3.0", 38 | "p-retry": "^5.1.1", 39 | "p-timeout": "^6.0.0" 40 | }, 41 | "peerDependencies": { 42 | "modbus-serial": "^8" 43 | }, 44 | "devDependencies": { 45 | "@tsconfig/node16": "^1.0.3", 46 | "@types/node": "^18.8.3", 47 | "@typescript-eslint/eslint-plugin": "^5.40.0", 48 | "@typescript-eslint/parser": "^5.40.0", 49 | "eslint": "8.22.0", 50 | "prettier": "^2.7.1", 51 | "rimraf": "^3.0.2", 52 | "ts-node": "^10.9.1", 53 | "typescript": "^4.8.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/api.class.ts: -------------------------------------------------------------------------------- 1 | import { ScopeEnum, TypeEnum } from './enum.js'; 2 | import assert from 'node:assert'; 3 | import { 4 | arrayFirst, 5 | arrayLast, 6 | bufferSlice, 7 | countRegisters, 8 | isArrayOfBuffers, 9 | isArrayOfNumbers, 10 | isReadableScope, 11 | isWritableScope, 12 | mapGet, 13 | readRegister, 14 | bufferSwapWords, 15 | writeRegister 16 | } from './utils.js'; 17 | import { DriverInterface } from './modbusdb.interface'; 18 | import { Buffer } from 'node:buffer'; 19 | 20 | const mapData = (addresses: number[], data: number[]): Map => { 21 | const startAddress = arrayFirst(addresses); 22 | 23 | return new Map( 24 | addresses.map((address) => { 25 | return [address, data[address - startAddress] ?? 0]; 26 | }) 27 | ); 28 | }; 29 | 30 | type MapBufferOptionsType = { 31 | bigEndian: boolean; 32 | swapWords: boolean; 33 | }; 34 | 35 | const mapBuffer = ( 36 | addresses: number[], 37 | buffer: Buffer, 38 | types: Map, 39 | options: MapBufferOptionsType = { bigEndian: true, swapWords: false } 40 | ): Map => { 41 | const startAddress = arrayFirst(addresses); 42 | 43 | return new Map( 44 | addresses.map((address) => { 45 | const offset = address - startAddress; 46 | const type = types.get(address) ?? TypeEnum.UInt16; 47 | 48 | let data = bufferSlice(buffer, offset * 2, countRegisters(type) * 2); 49 | 50 | if (countRegisters(type) === 2 && options.swapWords) { 51 | data = bufferSwapWords(data); 52 | } 53 | 54 | return [address, readRegister(data, type, options.bigEndian)]; 55 | }) 56 | ); 57 | }; 58 | 59 | const mapStates = (addresses: number[], data: Map): number[] => { 60 | return addresses.map((address) => mapGet(data, address, `Value not defined for address=${address}`)); 61 | }; 62 | 63 | type MapNumbersOptionsType = { 64 | bigEndian: boolean; 65 | swapWords: boolean; 66 | }; 67 | 68 | const mapNumbers = ( 69 | addresses: number[], 70 | data: Map, 71 | types: Map, 72 | options: MapNumbersOptionsType = { 73 | bigEndian: true, 74 | swapWords: false 75 | } 76 | ): Buffer[] => { 77 | return addresses.map((address) => { 78 | const value = mapGet(data, address, `Value not defined for address=${address}`); 79 | const type = types.get(address) ?? TypeEnum.UInt16; 80 | const buf = writeRegister(value, type, options.bigEndian); 81 | return countRegisters(type) === 2 && options.swapWords ? bufferSwapWords(buf) : buf; 82 | }); 83 | }; 84 | 85 | const createRequest = (map: Map) => { 86 | assert.ok(map.size > 0, 'Map is empty'); 87 | 88 | const addresses = Array.from(map.keys()); 89 | 90 | addresses.sort((a, b) => a - b); 91 | 92 | const startAddress = arrayFirst(addresses); 93 | const lastAddress = arrayLast(addresses); 94 | const lastType = mapGet(map, lastAddress, `Type not defined for address=${lastAddress}`); 95 | 96 | const registersCount = lastAddress + countRegisters(lastType) - startAddress; 97 | 98 | return { addresses, startAddress, registersCount }; 99 | }; 100 | 101 | interface OptionsInterface { 102 | bigEndian?: boolean; 103 | swapWords?: boolean; 104 | forceWriteMany?: boolean; 105 | } 106 | 107 | export class Api { 108 | constructor(private readonly driver: DriverInterface, public readonly bigEndian = true, public readonly swapWords = true) {} 109 | 110 | private useBigEndian(options?: OptionsInterface) { 111 | return options?.bigEndian ?? this.bigEndian; 112 | } 113 | 114 | private useSwapWords(options?: OptionsInterface) { 115 | return options?.swapWords ?? this.swapWords; 116 | } 117 | 118 | async read( 119 | unit: number, 120 | scope: ScopeEnum, 121 | map: Map, 122 | options?: OptionsInterface 123 | ): Promise> { 124 | assert.ok(isReadableScope(scope), 'This scope is not readable'); 125 | 126 | const { addresses, startAddress, registersCount } = createRequest(map); 127 | 128 | assert.ok(registersCount > 0 && registersCount <= 999, 'Count is out of range [1, 999]'); 129 | 130 | if (scope === ScopeEnum.InternalRegister) { 131 | return this.driver.readOutputRegisters(unit, startAddress, registersCount).then(({ buffer }) => 132 | mapBuffer(addresses, buffer, map, { 133 | bigEndian: this.useBigEndian(options), 134 | swapWords: this.useSwapWords(options) 135 | }) 136 | ); 137 | } 138 | 139 | if (scope === ScopeEnum.PhysicalRegister) { 140 | return this.driver.readInputRegisters(unit, startAddress, registersCount).then(({ buffer }) => 141 | mapBuffer(addresses, buffer, map, { 142 | bigEndian: this.useBigEndian(options), 143 | swapWords: this.useSwapWords(options) 144 | }) 145 | ); 146 | } 147 | 148 | if (scope === ScopeEnum.InternalState) { 149 | return this.driver.readOutputStates(unit, startAddress, registersCount).then(({ data }) => mapData(addresses, data)); 150 | } 151 | 152 | if (scope === ScopeEnum.PhysicalState) { 153 | return this.driver.readInputStates(unit, startAddress, registersCount).then(({ data }) => mapData(addresses, data)); 154 | } 155 | 156 | throw new Error('Scope not supported'); 157 | } 158 | 159 | async write( 160 | unit: number, 161 | scope: ScopeEnum, 162 | map: Map, 163 | data: Map, 164 | options?: OptionsInterface 165 | ): Promise { 166 | assert.ok(isWritableScope(scope), 'This scope is not writable'); 167 | 168 | const { addresses, startAddress } = createRequest(map); 169 | const forceMultiWrite = options?.forceWriteMany ?? false; 170 | 171 | if (scope === ScopeEnum.InternalRegister) { 172 | const body = mapNumbers(addresses, data, map, { 173 | bigEndian: this.useBigEndian(options), 174 | swapWords: this.useSwapWords(options) 175 | }); 176 | 177 | assert.ok(isArrayOfBuffers(body), 'Invalid data, expected array of buffers'); 178 | 179 | const buffer = Buffer.concat(body); 180 | 181 | return buffer.length > 2 || forceMultiWrite 182 | ? this.driver.writeRegisters(unit, startAddress, buffer) 183 | : this.driver.writeRegister(unit, startAddress, buffer); 184 | } 185 | 186 | if (scope === ScopeEnum.InternalState) { 187 | const body = mapStates(addresses, data); 188 | assert.ok(isArrayOfNumbers(body), 'Invalid data, expected array of numbers'); 189 | return body.length > 1 || forceMultiWrite 190 | ? this.driver.writeStates(unit, startAddress, body) 191 | : this.driver.writeState(unit, startAddress, arrayFirst(body)); 192 | } 193 | 194 | throw new Error('Scope not supported'); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/datamap.class.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { TypeEnum } from './enum.js'; 3 | import { DatamapInterface, SelectInterface, UnitConfigInterface } from './modbusdb.interface'; 4 | import { parseRegisterKey } from './register.js'; 5 | import { DatamapType, KeyType, MethodType, UnitType } from './types'; 6 | import { countRegisters, isNumber, isRegisterScope, isStateScope } from './utils.js'; 7 | 8 | export class Datamap { 9 | private readonly _map: Map; 10 | private readonly _units: Map; 11 | private readonly _watch: Map>; 12 | 13 | constructor(datamap?: DatamapType[], units?: UnitConfigInterface[]) { 14 | this._map = new Map(); 15 | this._units = new Map(); 16 | this._watch = new Map(); 17 | 18 | if (units != undefined && units.length > 0) { 19 | units.forEach((unit) => { 20 | this._units.set(unit.address, unit); 21 | }); 22 | } 23 | 24 | if (datamap !== undefined) { 25 | this.fill(datamap); 26 | } 27 | } 28 | 29 | unit(address: UnitType): UnitConfigInterface { 30 | const unit = this._units.get(address); 31 | 32 | assert.ok(unit != undefined, `Unit ${address} not found.`); 33 | 34 | return unit; 35 | } 36 | 37 | selectOne(method: MethodType, keys: number[]) { 38 | const selects = this.selectAll(method, keys); 39 | 40 | assert.ok(selects.length === 1, 'SelectOne has more than one result.'); 41 | 42 | return selects[0]; 43 | } 44 | 45 | selectAll(method: MethodType, keys: number[]) { 46 | assert.ok(keys.length, 'Select: Keys length should be greater then 0'); 47 | 48 | const items = keys.sort((a, b) => a - b).map((key) => this.get(key)); 49 | 50 | const selected: SelectInterface[] = []; 51 | 52 | const select = { 53 | stages: [], 54 | stage: [], 55 | current: items[0], 56 | prev: items[0], 57 | goToStage: false 58 | }; 59 | 60 | const addSelect = () => { 61 | if (select.stage.length === 0) return; 62 | 63 | const selectedUnit = this.unit(select.current.unit); 64 | 65 | selected.push({ 66 | method, 67 | datamap: select.stage, 68 | unit: selectedUnit.address, 69 | scope: select.current.scope, 70 | useBigEndian: selectedUnit.bigEndian, 71 | swapWords: selectedUnit.swapWords, 72 | forceWriteMulti: selectedUnit.forceWriteMany 73 | }); 74 | }; 75 | 76 | for (const item of items) { 77 | const unit = this.unit(item.unit); 78 | 79 | const maxGap = 80 | unit.requestWithGaps && unit.maxRequestSize > 2 && method === 'read' ? Math.round(unit.maxRequestSize * 0.25) : 0; 81 | 82 | select.goToStage = select.current.scope === item.scope; 83 | select.goToStage = select.goToStage && select.current.unit === item.unit; 84 | 85 | if (select.prev !== null && item.address !== select.prev.address) { 86 | const gap = item.address - (select.prev.address + countRegisters(select.prev.type)); 87 | select.goToStage = select.goToStage && gap <= maxGap; 88 | } 89 | 90 | select.goToStage = 91 | select.goToStage && item.address - select.current.address + countRegisters(item.type) <= unit.maxRequestSize; 92 | 93 | if (select.goToStage) { 94 | select.stage.push(item); 95 | select.prev = item; 96 | } else { 97 | addSelect(); 98 | 99 | select.stages.push(select.stage); 100 | select.stage = [item]; 101 | select.current = item; 102 | select.prev = item; 103 | } 104 | } 105 | 106 | if (select.stage.length > 0) { 107 | addSelect(); 108 | 109 | select.stages.push(select.stage); 110 | select.stage = []; 111 | } 112 | 113 | return selected; 114 | } 115 | 116 | get(key: number): DatamapInterface { 117 | const current = this._map.get(key); 118 | 119 | assert.ok(current !== undefined, `Unable to get key '${key}'`); 120 | 121 | return current; 122 | } 123 | 124 | // find(unit: UnitType, scope: ScopeType, address: AddressType): DatamapInterface[] { 125 | // return Array(16) 126 | // .fill(0) 127 | // .map((_, i) => createRegisterKey(unit, scope, address, i)) 128 | // .map((key) => this._map.get(key)) 129 | // .filter((item) => item !== undefined) as DatamapInterface[]; 130 | // } 131 | 132 | get map() { 133 | return this._map; 134 | } 135 | 136 | items() { 137 | return this._map.values(); 138 | } 139 | 140 | get watch() { 141 | return this._watch; 142 | } 143 | 144 | get size() { 145 | return this._map.size; 146 | } 147 | 148 | clear() { 149 | this._map.clear(); 150 | this._units.clear(); 151 | this._watch.clear(); 152 | } 153 | 154 | private fill(datamap: DatamapType[]): void { 155 | datamap.forEach((item) => { 156 | const { key, type, scale, freq } = item; 157 | 158 | const [unit, scope, address, bit] = parseRegisterKey(key); 159 | 160 | assert.ok(unit > 0 && unit <= 250, `Invalid key = ${key}. Unit is out of range [1,250]`); 161 | assert.ok([1, 2, 3, 4].includes(scope), `Invalid key = ${key}. Provided scope not supported`); 162 | assert.ok(address >= 0 && address < 65535, `Invalid key = ${key}. Register is out of range [1,65535]`); 163 | 164 | if (isStateScope(scope)) { 165 | assert.ok(bit === 0, `Invalid key = ${key}. Provided scope does not supports 'bit' address`); 166 | assert.ok(type === undefined || type === TypeEnum.Bit, `Invalid key = ${key}. Provided scope supports only Bit type`); 167 | assert.ok(scale === undefined || scale === 0, `Invalid key = ${key}. Provided scope does not supports 'scale' option`); 168 | } 169 | 170 | if (isRegisterScope(scope)) { 171 | assert.ok(bit >= 0 && bit < 16, `Invalid key = ${key}. Bit is out of range [0,15]`); 172 | 173 | if (type !== TypeEnum.Bit) { 174 | assert.ok(bit === 0, `Invalid key = ${key}. Bit (${bit}) not allowed for the type ${type}`); 175 | } 176 | } 177 | 178 | if (scale !== undefined && isNumber(scale)) { 179 | assert.ok(scale >= 0 && scale <= 3, `Invalid key = ${key}. Scale is out of range [0,3]`); 180 | } 181 | 182 | if (freq !== undefined && isNumber(freq)) { 183 | assert.ok(freq >= 0 && freq <= 60, `Invalid key = ${key}. Frequency is out of range [0,60]`); 184 | } 185 | 186 | if (!this._units.has(unit)) { 187 | // TODO: provide global default config for all units... 188 | this._units.set(unit, { 189 | address: unit, 190 | maxRequestSize: 1, 191 | forceWriteMany: false, 192 | bigEndian: false, 193 | swapWords: false, 194 | requestWithGaps: true 195 | }); 196 | } 197 | 198 | if (freq !== undefined && freq > 0) { 199 | const set = this._watch.get(freq) ?? new Set(); 200 | set.add(key); 201 | this._watch.set(freq, set); 202 | } 203 | 204 | this._map.set(key, { 205 | key, 206 | unit, 207 | address, 208 | bit, 209 | scope, 210 | type: isStateScope(scope) ? TypeEnum.Bit : type ?? TypeEnum.UInt16, 211 | scale, 212 | freq 213 | }); 214 | }); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/driver/modbus-serial.driver.ts: -------------------------------------------------------------------------------- 1 | import { DriverInterface, ReadResultInterface } from '../modbusdb.interface'; 2 | import ModbusRTU from 'modbus-serial'; 3 | import { Buffer } from 'buffer'; 4 | 5 | export class ModbusSerialDriver implements DriverInterface { 6 | constructor(private readonly client: ModbusRTU) {} 7 | 8 | readInputRegisters(unit: number, startAddress: number, count: number): Promise { 9 | this.client.setID(unit); 10 | return this.client.readInputRegisters(startAddress, count).then(({ buffer, data }) => ({ buffer, data })); 11 | } 12 | 13 | readInputStates(unit: number, startAddress: number, count: number): Promise { 14 | this.client.setID(unit); 15 | return this.client 16 | .readDiscreteInputs(startAddress, count) 17 | .then(({ buffer, data }) => ({ buffer, data: data.slice(0, count).map((d) => (d ? 1 : 0)) })); 18 | } 19 | 20 | readOutputRegisters(unit: number, startAddress: number, count: number): Promise { 21 | this.client.setID(unit); 22 | return this.client.readHoldingRegisters(startAddress, count).then(({ buffer, data }) => ({ buffer, data })); 23 | } 24 | 25 | readOutputStates(unit: number, startAddress: number, count: number): Promise { 26 | this.client.setID(unit); 27 | return this.client 28 | .readCoils(startAddress, count) 29 | .then(({ buffer, data }) => ({ buffer, data: data.slice(0, count).map((d) => (d ? 1 : 0)) })); 30 | } 31 | 32 | writeRegister(unit: number, startAddress: number, data: Buffer): Promise { 33 | this.client.setID(unit); 34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 35 | // @ts-ignore 36 | return this.client.writeRegister(startAddress, data).then(() => { 37 | return; 38 | }); 39 | } 40 | 41 | writeRegisters(unit: number, startAddress: number, data: Buffer): Promise { 42 | this.client.setID(unit); 43 | return this.client.writeRegisters(startAddress, data).then(() => { 44 | return; 45 | }); 46 | } 47 | 48 | writeState(unit: number, startAddress: number, data: number): Promise { 49 | this.client.setID(unit); 50 | return this.client.writeCoil(startAddress, data > 0).then(() => { 51 | return; 52 | }); 53 | } 54 | 55 | writeStates(unit: number, startAddress: number, data: number[]): Promise { 56 | this.client.setID(unit); 57 | return this.client 58 | .writeCoils( 59 | startAddress, 60 | data.map((v) => v > 0) 61 | ) 62 | .then(() => { 63 | return; 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/enum.ts: -------------------------------------------------------------------------------- 1 | export enum ScopeEnum { 2 | PhysicalState = 1, 3 | InternalState, 4 | PhysicalRegister, 5 | InternalRegister 6 | } 7 | 8 | export enum TypeEnum { 9 | Bit = 1, 10 | // reserved: 11 | // Int8, 12 | // UInt8, 13 | Int16 = 4, 14 | UInt16, 15 | Int32, 16 | UInt32, 17 | Float 18 | } 19 | 20 | export enum PriorityEnum { 21 | LOW = 1, 22 | NORMAL = 3, 23 | HIGH = 5 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modbusdb.class.js'; 2 | export * from './enum.js'; 3 | export * from './datamap.class.js'; 4 | export * from './modbusdb.interface.js'; 5 | export * from './driver/modbus-serial.driver.js'; 6 | export * from './transaction.class.js'; 7 | export * from './register.js'; 8 | export * from './types.js'; 9 | -------------------------------------------------------------------------------- /src/modbusdb.class.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { EventEmitter } from 'node:events'; 3 | import PQueue from 'p-queue'; 4 | import timeout from 'p-timeout'; 5 | import { Api } from './api.class.js'; 6 | import { Datamap } from './datamap.class.js'; 7 | import { PriorityEnum, TypeEnum } from './enum.js'; 8 | import { ConfigInterface, SelectInterface, UnitConfigInterface } from './modbusdb.interface'; 9 | import { Transaction, TransactionOptionsInterface, TransactionTypeEnum } from './transaction.class.js'; 10 | import { KeyType, MapLikeType, SetLikeType } from './types'; 11 | import { 12 | createMap, 13 | getBit, 14 | getDivisorsOfNumber, 15 | isBool, 16 | isInt, 17 | isRegisterScope, 18 | isStateScope, 19 | mapGet, 20 | mergeMaps, 21 | setBit 22 | } from './utils.js'; 23 | 24 | const TICKS_PER_ROUND = 12; 25 | const TIMEOUT = 60; 26 | const INTERVAL = 60; 27 | 28 | interface WatchInterface { 29 | timer?: NodeJS.Timeout; 30 | round: number; 31 | tick: number; 32 | roundStartedAt: number; 33 | tickStartedAt: number; 34 | size: number; 35 | } 36 | 37 | export declare interface Modbusdb { 38 | unit(id: number): UnitConfigInterface & UnitStateInterface; 39 | on(event: 'data', listener: (data: Map) => void): this; 40 | on(event: 'request', listener: (t: Transaction) => void): this; 41 | on(event: 'response', listener: (t: Transaction) => void): this; 42 | on(event: 'tick', listener: () => void): this; 43 | } 44 | 45 | interface UnitStateInterface { 46 | timedOutTime: number; 47 | timeoutsCount: number; 48 | requestsCount: number; 49 | errorsCount: number; 50 | } 51 | 52 | interface DatabaseStateInterface { 53 | units: Map; 54 | requestsCount: number; 55 | errorsCount: number; 56 | } 57 | 58 | export class Modbusdb extends EventEmitter { 59 | private readonly _api: Api; 60 | private readonly _queue: PQueue; 61 | private readonly _datamap: Datamap; 62 | private readonly _watch: WatchInterface; 63 | 64 | private readonly _state: DatabaseStateInterface; 65 | 66 | private readonly timeout: number; 67 | private readonly interval: number; 68 | private readonly pendingTransactions: Set; 69 | private nextTransactionId: number; 70 | private responseTime: number[]; 71 | private _destroyed: boolean; 72 | 73 | constructor(config: ConfigInterface) { 74 | super(); 75 | 76 | this._api = new Api(config.driver); 77 | 78 | this._queue = new PQueue({ 79 | concurrency: 1 80 | }); 81 | 82 | this._datamap = config.datamap ?? new Datamap(); 83 | 84 | this._state = { 85 | units: new Map(), 86 | requestsCount: 0, 87 | errorsCount: 0 88 | }; 89 | 90 | this.timeout = Math.min(Math.max(config.timeout ?? TIMEOUT, 1), 900); 91 | this.interval = Math.min(Math.max(config.interval ?? INTERVAL, 60), 3600); 92 | 93 | this._watch = { 94 | round: 0, 95 | tick: 0, 96 | roundStartedAt: 0, 97 | tickStartedAt: 0, 98 | size: Math.min(Math.max(config.roundSize ?? TICKS_PER_ROUND, TICKS_PER_ROUND), 36) 99 | }; 100 | 101 | this.pendingTransactions = new Set(); 102 | 103 | this.nextTransactionId = 0; 104 | 105 | this.responseTime = []; 106 | 107 | this._destroyed = false; 108 | } 109 | 110 | get state() { 111 | return { 112 | reqCount: this._state.requestsCount, 113 | errCount: this._state.errorsCount, 114 | roundIndex: this._watch.round, 115 | tickIndex: Math.max(0, this._watch.tick), 116 | roundDuration: this._watch.roundStartedAt > 0 ? 1 + Date.now() - this._watch.roundStartedAt : 0, 117 | avgResTime: 118 | this.responseTime.length > 3 119 | ? Math.round(this.responseTime.reduce((total, time) => total + time, 0) / this.responseTime.length) 120 | : 0 121 | }; 122 | } 123 | 124 | watch(): this { 125 | if (this._watch.timer) { 126 | clearTimeout(this._watch.timer); 127 | } 128 | 129 | if (this.datamap.watch.size === 0) { 130 | return this; 131 | } 132 | 133 | const roundDivisors = getDivisorsOfNumber(this._watch.size); 134 | const roundMap = createMap(roundDivisors, [...roundDivisors].reverse()); 135 | const tickInterval = Math.floor(this.interval / this._watch.size) * 1000; 136 | 137 | const finishTick = () => { 138 | if (this._destroyed) return; 139 | 140 | this._watch.tick++; 141 | 142 | const tickTakenTime = Date.now() - this._watch.tickStartedAt; 143 | const nextTickIn = Math.max(tickInterval - tickTakenTime, 1000); 144 | 145 | this._watch.timer = setTimeout(run, nextTickIn); 146 | }; 147 | 148 | const run = () => { 149 | if (this._destroyed) return; 150 | 151 | if (this._watch.tick >= this._watch.size) { 152 | this._watch.roundStartedAt = Date.now(); 153 | this._watch.tickStartedAt = this._watch.roundStartedAt; 154 | this._watch.round++; 155 | this._watch.tick = 0; 156 | } else { 157 | this._watch.tickStartedAt = Date.now(); 158 | } 159 | 160 | this.emit('tick'); 161 | 162 | const keys = roundDivisors 163 | .filter((div) => (this._watch.tick + 1) % div === 0) 164 | .map((div) => this.datamap.watch.get(mapGet(roundMap, div))) 165 | .map((set) => (set != undefined ? Array.from(set.values()) : [])) 166 | .flat(); 167 | 168 | if (keys.length === 0) { 169 | return finishTick(); 170 | } 171 | 172 | const selects = this.datamap.selectAll('read', keys); 173 | 174 | const tasks = selects.map((select: SelectInterface) => 175 | this.request(TransactionTypeEnum.READ, select, { 176 | priority: PriorityEnum.LOW, 177 | timeout: this.timeout 178 | }) 179 | ); 180 | 181 | return this.run(tasks) 182 | .then(finishTick) 183 | .catch((err) => console.log('fatal error', err)); 184 | }; 185 | 186 | this._watch.timer = setTimeout(() => { 187 | this._watch.tick = 0; 188 | this._watch.round = 0; 189 | this._watch.roundStartedAt = Date.now(); 190 | this._watch.tickStartedAt = this._watch.roundStartedAt; 191 | run(); 192 | }, tickInterval); 193 | 194 | return this; 195 | } 196 | 197 | private onRequest(t: Transaction): void { 198 | if (this._destroyed) return; 199 | 200 | this.emit('request', t); 201 | } 202 | 203 | private onResponse(t: Transaction): void { 204 | if (this._destroyed) return; 205 | 206 | const unit = this.unit(t.unit); 207 | 208 | const hasError = t.error !== undefined; 209 | const isTimedOut = t.error !== undefined && t.error.isTimeout; 210 | 211 | this._state.requestsCount += 1; 212 | this._state.errorsCount += hasError ? 1 : 0; 213 | 214 | this._state.units.set(t.unit, { 215 | timedOutTime: isTimedOut ? Date.now() : 0, 216 | timeoutsCount: isTimedOut ? unit.timeoutsCount + 1 : 0, 217 | requestsCount: unit.requestsCount + 1, 218 | errorsCount: unit.errorsCount + (hasError ? 1 : 0) 219 | }); 220 | 221 | if (!isTimedOut) { 222 | this.responseTime.push(t.duration); 223 | } 224 | 225 | if (this.responseTime.length > 99) { 226 | this.responseTime.shift(); 227 | } 228 | 229 | this.emit('response', t); 230 | 231 | if (t.data !== undefined && t.data.size > 0) { 232 | this.emit('data', t.data); 233 | } 234 | } 235 | 236 | async mset(data: MapLikeType) { 237 | assert.ok(!this._destroyed, 'Instance is destroyed'); 238 | 239 | const dataMap = data instanceof Array ? new Map(data) : data; 240 | 241 | assert.ok(dataMap.size > 0, 'Data is empty'); 242 | 243 | const selects = this.datamap.selectAll('write', Array.from(dataMap.keys())); 244 | 245 | const request = (select: SelectInterface) => 246 | this.request(TransactionTypeEnum.WRITE, select, { 247 | body: dataMap, 248 | priority: PriorityEnum.HIGH, 249 | timeout: this.timeout 250 | }); 251 | 252 | return this.run(selects.map(request)); 253 | } 254 | 255 | async mget(keys: SetLikeType) { 256 | assert.ok(!this._destroyed, 'Instance is destroyed'); 257 | 258 | const keysSet = keys instanceof Array ? new Set(keys) : keys; 259 | 260 | assert.ok(keysSet.size > 0, 'Keys is empty'); 261 | 262 | const selects = this.datamap.selectAll('read', Array.from(keysSet.values())); 263 | 264 | const request = (select: SelectInterface) => 265 | this.request(TransactionTypeEnum.READ, select, { 266 | priority: PriorityEnum.NORMAL, 267 | timeout: this.timeout 268 | }); 269 | 270 | return this.run(selects.map(request)); 271 | } 272 | 273 | async get(key: KeyType) { 274 | assert.ok(!this._destroyed, 'Instance is destroyed'); 275 | 276 | const select = this.datamap.selectOne('read', [key]); 277 | 278 | return this.request(TransactionTypeEnum.READ, select, { 279 | priority: PriorityEnum.NORMAL, 280 | timeout: this.timeout 281 | }); 282 | } 283 | 284 | async set(key: KeyType, value: number) { 285 | assert.ok(!this._destroyed, 'Instance is destroyed'); 286 | 287 | const select = this.datamap.selectOne('write', [key]); 288 | 289 | return this.request(TransactionTypeEnum.WRITE, select, { 290 | body: new Map([[key, value]]), 291 | priority: PriorityEnum.HIGH, 292 | timeout: this.timeout 293 | }); 294 | } 295 | 296 | unit(id: number): UnitStateInterface & UnitConfigInterface { 297 | const unitConfig = this.datamap.unit(id); 298 | const unitState = this._state.units.get(id); 299 | 300 | return { 301 | ...unitConfig, 302 | requestsCount: unitState?.requestsCount ?? 0, 303 | errorsCount: unitState?.errorsCount ?? 0, 304 | timedOutTime: unitState?.timedOutTime ?? 0, 305 | timeoutsCount: unitState?.timeoutsCount ?? 0 306 | }; 307 | } 308 | 309 | request(type: TransactionTypeEnum, select: SelectInterface, options?: TransactionOptionsInterface) { 310 | const task = () => { 311 | this.nextTransactionId = (this.nextTransactionId + 1) % 1024; 312 | 313 | const t = new Transaction(this.nextTransactionId, type, select.datamap, { 314 | bigEndian: select.useBigEndian, 315 | swapWords: select.swapWords, 316 | forceWriteMany: select.forceWriteMulti, 317 | ...options 318 | }); 319 | 320 | if (this._destroyed) { 321 | return Promise.resolve(t.finish(new Error('Aborted'))); 322 | } 323 | 324 | const unit = this.unit(t.unit); 325 | 326 | if ( 327 | t.priority === PriorityEnum.LOW && 328 | unit.timeoutsCount > 2 && 329 | Date.now() - unit.timedOutTime < 3 * (options?.timeout ?? this.timeout) * 1000 330 | ) { 331 | return Promise.resolve(t.finish(new Error('Too many timeouts for this unit'))); 332 | } 333 | 334 | this.pendingTransactions.add(t); 335 | 336 | this.onRequest(t); 337 | 338 | return timeout(this.execute(t), { 339 | milliseconds: t.timeout * 1000 340 | }) 341 | .then((data) => { 342 | this.onResponse(t.finish(data)); 343 | 344 | return t; 345 | }) 346 | .catch((reason) => { 347 | this.onResponse(t.finish(reason)); 348 | 349 | return t; 350 | }) 351 | .finally(() => { 352 | this.pendingTransactions.delete(t); 353 | }); 354 | }; 355 | 356 | return this._queue.add(task, { priority: options?.priority }); 357 | } 358 | 359 | private async execute(t: Transaction): Promise> { 360 | if (t.done) { 361 | throw new Error('Unprocessable transaction'); 362 | } 363 | 364 | const map = new Map( 365 | t.map.map(({ address, type }) => { 366 | if (isStateScope(t.scope)) { 367 | return [address, TypeEnum.Bit]; 368 | } 369 | 370 | if (isRegisterScope(t.scope) && type === TypeEnum.Bit) { 371 | return [address, TypeEnum.UInt16]; 372 | } 373 | 374 | return [address, type]; 375 | }) 376 | ); 377 | 378 | const mapValues = (values: Map) => 379 | new Map( 380 | t.map.map((item) => { 381 | const value = mapGet(values, item.address, `No value in response for key=${item.key}`); 382 | 383 | if (isBool(item.type)) { 384 | if (isRegisterScope(item.scope)) { 385 | return [item.key, getBit(value, item.bit)]; 386 | } else { 387 | return [item.key, value]; 388 | } 389 | } else if (isInt(item.type)) { 390 | return [item.key, value / Math.pow(10, item.scale ?? 0)]; 391 | } else { 392 | return [item.key, value]; 393 | } 394 | }) 395 | ); 396 | 397 | const apiOptions = { 398 | bigEndian: t.bigEndian, 399 | forceWriteMany: t.forceWriteMany, 400 | swapWords: t.swapWords 401 | }; 402 | 403 | if (t.type === TransactionTypeEnum.READ) { 404 | return this.api.read(t.unit, t.scope, map, apiOptions).then(mapValues); 405 | } 406 | 407 | if (t.type === TransactionTypeEnum.WRITE) { 408 | const data = new Map(); 409 | 410 | if (t.map.some((i) => isRegisterScope(i.scope) && i.type === TypeEnum.Bit)) { 411 | await this.api.read(t.unit, t.scope, map, apiOptions).then((currentData) => { 412 | mergeMaps(data, currentData); 413 | }); 414 | } 415 | 416 | if (t.done) { 417 | throw new Error('Unprocessable transaction'); 418 | } 419 | 420 | t.map.forEach((item) => { 421 | const value = t.body?.get(item.key) ?? 0; 422 | 423 | if (isBool(item.type)) { 424 | if (isRegisterScope(item.scope)) { 425 | data.set(item.address, setBit(mapGet(data, item.address), item.bit, value === 1)); 426 | } else { 427 | data.set(item.address, value > 0 ? 1 : 0); 428 | } 429 | } else if (isInt(item.type)) { 430 | data.set(item.address, Math.floor(value * Math.pow(10, item.scale ?? 0))); 431 | } else { 432 | data.set(item.address, value); 433 | } 434 | }); 435 | 436 | return this.api.write(t.unit, t.scope, map, data, apiOptions).then(() => mapValues(data)); 437 | } 438 | 439 | throw new Error('Unprocessable transaction'); 440 | } 441 | 442 | private async run(tasks: Array>) { 443 | const startedAt = Date.now(); 444 | const payload = new Map(); 445 | 446 | const transactions = await Promise.all(tasks); 447 | 448 | transactions.forEach((t) => { 449 | if (!(t instanceof Transaction)) { 450 | throw new Error('Invalid task result, expected a Transaction'); 451 | } 452 | 453 | if (t.data == null) { 454 | return; 455 | } 456 | 457 | if (!(t.data instanceof Map)) { 458 | throw new Error('Invalid task result data, expected a data to be a Map'); 459 | } 460 | 461 | mergeMaps(payload, t.data); 462 | }); 463 | 464 | return { 465 | totalTime: Date.now() - startedAt, 466 | transactions, 467 | payload 468 | }; 469 | } 470 | 471 | destroy() { 472 | this._destroyed = true; 473 | 474 | clearTimeout(this._watch.timer); 475 | 476 | this.pendingTransactions.clear(); 477 | 478 | this._queue.removeAllListeners(); 479 | this._queue.clear(); 480 | 481 | this.datamap.clear(); 482 | 483 | this._state.units.clear(); 484 | } 485 | 486 | get datamap() { 487 | return this._datamap; 488 | } 489 | 490 | get api() { 491 | return this._api; 492 | } 493 | } 494 | -------------------------------------------------------------------------------- /src/modbusdb.interface.ts: -------------------------------------------------------------------------------- 1 | import { ScopeEnum, TypeEnum } from './enum.js'; 2 | import { KeyType, UnitType, BitType, AddressType, MethodType } from './types'; 3 | import { Buffer } from 'buffer'; 4 | import { Datamap } from './datamap.class.js'; 5 | 6 | export interface ReadResultInterface { 7 | buffer: Buffer; 8 | data: number[]; 9 | } 10 | 11 | export interface DriverInterface { 12 | // fc=1 13 | readOutputStates(unit: number, startAddress: number, count: number): Promise; 14 | // fc=2 15 | readInputStates(unit: number, startAddress: number, count: number): Promise; 16 | // fc=3 17 | readOutputRegisters(unit: number, startAddress: number, count: number): Promise; 18 | // fc=4 19 | readInputRegisters(unit: number, startAddress: number, count: number): Promise; 20 | // fc=5 21 | writeState(unit: number, startAddress: number, data: number): Promise; 22 | // fc=6 23 | writeRegister(unit: number, startAddress: number, data: Buffer): Promise; 24 | // fc=15 25 | writeStates(unit: number, startAddress: number, data: number[]): Promise; 26 | // fc=16 27 | writeRegisters(unit: number, startAddress: number, data: Buffer): Promise; 28 | } 29 | 30 | export interface DatamapInterface { 31 | key: KeyType; 32 | unit: UnitType; 33 | scope: ScopeEnum; 34 | address: AddressType; 35 | bit: BitType; 36 | type: TypeEnum; 37 | scale?: number; 38 | freq?: number; 39 | } 40 | 41 | export interface UnitConfigInterface { 42 | address: number; 43 | forceWriteMany: boolean; 44 | bigEndian: boolean; 45 | swapWords: boolean; 46 | requestWithGaps: boolean; 47 | maxRequestSize: number; 48 | } 49 | 50 | export interface ConfigInterface { 51 | driver: DriverInterface; 52 | datamap?: Datamap; 53 | interval?: number; 54 | timeout?: number; 55 | roundSize?: number; 56 | } 57 | 58 | export interface SelectInterface { 59 | method: MethodType; 60 | unit: UnitType; 61 | scope: ScopeEnum; 62 | datamap: DatamapInterface[]; 63 | useBigEndian: boolean; 64 | forceWriteMulti: boolean; 65 | swapWords: boolean; 66 | } 67 | -------------------------------------------------------------------------------- /src/register.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { AddressType, BitType, UnitType, TableType } from './types.js'; 3 | 4 | const KEY_SIZE = 32; 5 | const UNIT_SIZE = 8; 6 | const TABLE_SIZE = 4; 7 | const ADDRESS_SIZE = 16; 8 | 9 | export const createRegisterKey = (unit: UnitType, table: TableType, address: AddressType, bit: BitType = 0) => { 10 | assert(unit >= 0 && unit <= 255, 'Unit is out of range'); 11 | assert(table >= 0 && table < 16, 'Table is out of range'); 12 | assert(address >= 0 && address <= 65535, 'Address is out of range'); 13 | assert(bit >= 0 && bit < 16, 'Bit is out of range'); 14 | 15 | const num = 16 | (unit << (KEY_SIZE - UNIT_SIZE)) | 17 | (table << (KEY_SIZE - UNIT_SIZE - TABLE_SIZE)) | 18 | (address << (KEY_SIZE - UNIT_SIZE - TABLE_SIZE - ADDRESS_SIZE)) | 19 | bit; 20 | 21 | return num >>> 0; 22 | }; 23 | 24 | export const parseRegisterKey = (key: number): [UnitType, TableType, AddressType, BitType] => [ 25 | (key >> (KEY_SIZE - UNIT_SIZE)) & 0xff, 26 | (key >> (KEY_SIZE - UNIT_SIZE - TABLE_SIZE)) & 0xf, 27 | (key >> (KEY_SIZE - UNIT_SIZE - TABLE_SIZE - ADDRESS_SIZE)) & 0xffff, 28 | key & 0xf 29 | ]; 30 | -------------------------------------------------------------------------------- /src/transaction.class.ts: -------------------------------------------------------------------------------- 1 | import { TimeoutError } from 'p-timeout'; 2 | import { KeyType } from './types'; 3 | import { PriorityEnum, ScopeEnum } from './enum.js'; 4 | import assert from 'node:assert'; 5 | import { arrayFirst } from './utils.js'; 6 | import { DatamapInterface } from './modbusdb.interface.js'; 7 | 8 | class TransactionError extends Error { 9 | constructor(public readonly reason: Error) { 10 | super(reason.message); 11 | } 12 | 13 | get isTimeout() { 14 | return this.reason.name === 'TimeoutError'; 15 | } 16 | } 17 | 18 | export enum TransactionTypeEnum { 19 | READ = 1, 20 | WRITE 21 | } 22 | 23 | export interface TransactionOptionsInterface { 24 | body?: Map; 25 | priority?: PriorityEnum; 26 | timeout?: number; 27 | bigEndian?: boolean; 28 | swapWords?: boolean; 29 | forceWriteMany?: boolean; 30 | } 31 | 32 | export class Transaction { 33 | public readonly unit: number; 34 | public readonly scope: ScopeEnum; 35 | public readonly body?: Map; 36 | public readonly priority: PriorityEnum; 37 | 38 | public readonly bigEndian: boolean; 39 | public readonly swapWords: boolean; 40 | public readonly forceWriteMany: boolean; 41 | 42 | private _startedAt: number; 43 | private _finishedAt?: number; 44 | private _timeout: number; 45 | 46 | private _data?: Map; 47 | private _error?: TransactionError; 48 | 49 | constructor( 50 | public readonly id: number, 51 | public readonly type: TransactionTypeEnum, 52 | public readonly map: DatamapInterface[], 53 | options?: TransactionOptionsInterface 54 | ) { 55 | assert.ok(map.length > 0, 'Keys is empty'); 56 | 57 | const { unit, scope } = arrayFirst(map); 58 | 59 | assert.ok( 60 | map.every((i) => i.unit === unit && i.scope === scope), 61 | 'Cross unit/scope transaction error' 62 | ); 63 | 64 | this.unit = unit; 65 | this.scope = scope; 66 | this.bigEndian = options?.bigEndian ?? false; 67 | this.swapWords = options?.swapWords ?? false; 68 | this.forceWriteMany = options?.forceWriteMany ?? false; 69 | this.body = options?.body; 70 | this.priority = options?.priority ?? PriorityEnum.LOW; 71 | this._startedAt = Date.now(); 72 | this._timeout = options?.timeout ?? 60; 73 | } 74 | 75 | finish(result: Map | TransactionError | Error) { 76 | if (this.done) return this; 77 | 78 | this._finishedAt = Date.now(); 79 | 80 | if (result instanceof TransactionError) { 81 | this._error = result; 82 | } else if (result instanceof Error) { 83 | this._error = new TransactionError(result); 84 | } else { 85 | this._data = result; 86 | } 87 | 88 | return this; 89 | } 90 | 91 | get duration() { 92 | const finishedAt = this._finishedAt === undefined ? Date.now() : this._finishedAt; 93 | return finishedAt - this._startedAt; 94 | } 95 | 96 | get data() { 97 | return this._data; 98 | } 99 | 100 | get error() { 101 | return this._error; 102 | } 103 | 104 | get isTimedOut() { 105 | return this.error !== undefined && this.error.reason instanceof TimeoutError; 106 | } 107 | 108 | get done() { 109 | return this._finishedAt !== undefined; 110 | } 111 | 112 | get timeout() { 113 | return this._timeout; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ScopeEnum, TypeEnum } from './enum.js'; 2 | 3 | export type KeyType = number; 4 | export type UnitType = number; 5 | export type TableType = ScopeEnum; 6 | export type AddressType = number; 7 | export type BitType = number; 8 | 9 | export type DatamapType = { 10 | key: KeyType; 11 | type?: TypeEnum; 12 | scale?: number; 13 | freq?: number; 14 | }; 15 | 16 | export type MapLikeType = Map | Array<[T, K]>; 17 | export type SetLikeType = Set | Array; 18 | 19 | export type MethodType = 'read' | 'write'; 20 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | import { ScopeEnum, TypeEnum } from './enum.js'; 3 | import assert from 'node:assert'; 4 | 5 | export const countRegisters = (type: TypeEnum): number => { 6 | switch (type) { 7 | case TypeEnum.Int32: 8 | case TypeEnum.UInt32: 9 | case TypeEnum.Float: 10 | return 2; 11 | } 12 | 13 | return 1; 14 | }; 15 | 16 | export const isBool = (type: TypeEnum) => type === TypeEnum.Bit; 17 | export const isFloat = (type: TypeEnum) => type === TypeEnum.Float; 18 | export const isInt = (type: TypeEnum) => !isFloat(type) && !isBool(type); 19 | 20 | export const isStateScope = (scope: ScopeEnum) => scope == ScopeEnum.InternalState || scope == ScopeEnum.PhysicalState; 21 | export const isRegisterScope = (scope: ScopeEnum) => scope == ScopeEnum.InternalRegister || scope == ScopeEnum.PhysicalRegister; 22 | export const isReadableScope = (scope: ScopeEnum) => 23 | scope == ScopeEnum.InternalRegister || 24 | scope == ScopeEnum.InternalState || 25 | scope == ScopeEnum.PhysicalRegister || 26 | scope == ScopeEnum.PhysicalState; 27 | export const isWritableScope = (scope: ScopeEnum) => scope == ScopeEnum.InternalRegister || scope == ScopeEnum.InternalState; 28 | 29 | export const readRegister = (buffer: Buffer, type: TypeEnum, bigEndian = true) => { 30 | if (type === TypeEnum.Int16) { 31 | return bigEndian ? buffer.readInt16BE() : buffer.readInt16LE(); 32 | } else if (type === TypeEnum.UInt16) { 33 | return bigEndian ? buffer.readUInt16BE() : buffer.readUInt16LE(); 34 | } else if (type === TypeEnum.Int32) { 35 | return bigEndian ? buffer.readInt32BE() : buffer.readInt32LE(); 36 | } else if (type === TypeEnum.UInt32) { 37 | return bigEndian ? buffer.readUInt32BE() : buffer.readUInt32LE(); 38 | } else if (type === TypeEnum.Float) { 39 | return bigEndian ? buffer.readFloatBE() : buffer.readFloatLE(); 40 | } else { 41 | throw new Error('Read register type not supported'); 42 | } 43 | }; 44 | 45 | export const writeRegister = (value: number, type: TypeEnum, bigEndian = true) => { 46 | const buffer = Buffer.alloc(countRegisters(type) * 2); 47 | 48 | if (type === TypeEnum.Int16) { 49 | if (bigEndian) { 50 | buffer.writeInt16BE(value); 51 | } else { 52 | buffer.writeInt16LE(value); 53 | } 54 | } else if (type === TypeEnum.UInt16) { 55 | if (bigEndian) { 56 | buffer.writeUInt16BE(value); 57 | } else { 58 | buffer.writeUInt16LE(value); 59 | } 60 | } else if (type === TypeEnum.Int32) { 61 | if (bigEndian) { 62 | buffer.writeInt32BE(value); 63 | } else { 64 | buffer.writeInt32LE(value); 65 | } 66 | } else if (type === TypeEnum.UInt32) { 67 | if (bigEndian) { 68 | buffer.writeUInt32BE(value); 69 | } else { 70 | buffer.writeUInt32LE(value); 71 | } 72 | } else if (type === TypeEnum.Float) { 73 | if (bigEndian) { 74 | buffer.writeFloatBE(value); 75 | } else { 76 | buffer.writeFloatLE(value); 77 | } 78 | } else { 79 | throw new Error(`Write register type[${type}] not supported`); 80 | } 81 | 82 | return buffer; 83 | }; 84 | 85 | export const getBit = (value: number, bit: number): number => { 86 | assert(bit >= 0 && bit < 16, 'BitIndex is out of range'); 87 | return (value & Math.pow(2, bit)) > 0 ? 1 : 0; 88 | }; 89 | 90 | export const setBit = (value: number, bit: number, state: boolean): number => { 91 | assert(bit >= 0 && bit < 16, 'BitIndex is out of range'); 92 | return state ? value | Math.pow(2, bit) : ~(~value | Math.pow(2, bit)); 93 | }; 94 | 95 | export const arrayFirst = (arr: Array, message = 'First element of the array not found'): T => { 96 | const val = arr.at(0); 97 | assert.ok(val !== undefined, message); 98 | return val; 99 | }; 100 | export const arrayLast = (arr: Array, message = 'Last element of the array not found'): T => { 101 | const val = arr.at(-1); 102 | assert.ok(val !== undefined, message); 103 | return val; 104 | }; 105 | 106 | export const mapGet = (map: Map, key: T, message = 'Key not found in the map'): K => { 107 | const val = map.get(key); 108 | assert.ok(val !== undefined, message); 109 | return val; 110 | }; 111 | 112 | export const mergeMaps = (map1: Map, map2: Map): void => { 113 | for (const [key, val] of map2.entries()) { 114 | map1.set(key, val); 115 | } 116 | }; 117 | 118 | export const isArrayOfNumbers = (data: Array): data is NonNullable> => { 119 | return data.every((value) => typeof value === 'number'); 120 | }; 121 | 122 | export const isArrayOfBuffers = (data: Array): data is NonNullable> => { 123 | return data.every((value) => value instanceof Buffer); 124 | }; 125 | 126 | export const bufferSlice = (buffer: Buffer, offset: number, size: number) => { 127 | assert.ok(buffer.length >= offset + size, 'Buffer slice is out of bounds'); 128 | return buffer.subarray(offset, offset + size); 129 | }; 130 | 131 | export const isNumber = (value: unknown): value is number => typeof value === 'number'; 132 | 133 | export const getDivisorsOfNumber = (number: number): Array => 134 | number > 0 ? Array.from({ length: number }, (_, i) => i + 1).filter((div) => number % div === 0) : []; 135 | 136 | export const createMap = (keys: Array, values: Array): Map => { 137 | assert.ok(keys.length === values.length, 'Diff length of keys and values'); 138 | const map = new Map(); 139 | keys.forEach((key, i) => { 140 | map.set(key, values[i]); 141 | }); 142 | return map; 143 | }; 144 | 145 | export const bufferSwapWords = (buf: Buffer): Buffer => { 146 | if (buf.length !== 4) { 147 | throw new Error(`swap words buf length must be 4, got: ${buf.length}`); 148 | } 149 | 150 | return Buffer.concat([buf.subarray(2, 4), buf.subarray(0, 2)]); 151 | }; 152 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "ts-node": { 4 | // Tell ts-node CLI to install the --loader automatically, explained below 5 | "esm": true 6 | }, 7 | "compilerOptions": { 8 | "module": "ESNext", 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | /* Strict Type-Checking Options */ 12 | "strict": true, /* Enable all strict type-checking options. */ 13 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 14 | /* Advanced Options */ 15 | "resolveJsonModule": true 16 | }, 17 | "include": ["src"], 18 | "exclude": ["node_modules"] 19 | } 20 | --------------------------------------------------------------------------------