node-red-contrib-mitsubishi 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/nodes/mitsubishi-config.html +62 -0
- package/nodes/mitsubishi-config.js +21 -0
- package/nodes/mitsubishi-read.html +152 -0
- package/nodes/mitsubishi-read.js +468 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# node-red-contrib-mitsubishi
|
|
2
|
+
|
|
3
|
+
三菱 MC Protocol 3E/4E 以太网采集节点,零依赖纯 TCP 实现。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- **独立可用** — 脱离任何后端,拖拽配置即可采集 PLC 数据
|
|
8
|
+
- **表格编辑器** — 一个节点读 N 个点位,自动聚类合并批量读取
|
|
9
|
+
- **6 种数据类型** — INT16/UINT16/INT32/UINT32/FLOAT32/BOOL
|
|
10
|
+
- **8 种软元件** — D/W/R/X/Y/M/L/B,位元件自动拆包
|
|
11
|
+
- **斜率偏移变换** — `engValue = rawValue × slope + offset`,节点直接出最终值
|
|
12
|
+
- **错误诊断** — 19 个 MC 错误码映射为中文可读信息
|
|
13
|
+
- **模拟模式** — 全局 `mcSimulationMode=true` 不连 PLC 直接返回仿真数据
|
|
14
|
+
- **零依赖** — 仅使用 Node.js 内置 `net` + `Buffer`
|
|
15
|
+
|
|
16
|
+
## 支持的 PLC
|
|
17
|
+
|
|
18
|
+
| 系列 | 帧格式 | 状态 |
|
|
19
|
+
|------|--------|------|
|
|
20
|
+
| Q 系列 (QnU/QnUDV) | 3E/4E | ✅ |
|
|
21
|
+
| L 系列 | 3E/4E | ✅ |
|
|
22
|
+
| iQ-R | 4E | ✅ |
|
|
23
|
+
| iQ-F (FX5U) | 4E | ✅ |
|
|
24
|
+
| FX3U + 以太网模块 | 3E | ✅ |
|
|
25
|
+
| A 系列 | 1E/2E | ❌ |
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd ~/.node-red
|
|
31
|
+
npm install node-red-contrib-mitsubishi
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
重启 Node-RED,左侧节点栏出现 **"三菱 PLC"** 分类。
|
|
35
|
+
|
|
36
|
+
## 使用方法
|
|
37
|
+
|
|
38
|
+
拖入 `MC 读取` 节点 → 添加 `PLC 连接配置` → 在表格中添加点位 → 部署 → inject 触发。
|
|
39
|
+
|
|
40
|
+
**输入**: empty(使用节点表格配置)或 `msg.tags`(动态覆盖)
|
|
41
|
+
|
|
42
|
+
**输出**: `msg.payload.data[tagId] = { rawValue, engValue, quality, ts }`
|
|
43
|
+
|
|
44
|
+
## 与 node-red-contrib-mcprotocol 对比
|
|
45
|
+
|
|
46
|
+
| | 本节点 | mcprotocol 插件 |
|
|
47
|
+
|------|---------|-----------|
|
|
48
|
+
| 点位数量 | N 个/节点 | 1 个/节点 |
|
|
49
|
+
| 批量优化 | ✅ 智能聚类 | ❌ |
|
|
50
|
+
| dataType | 6 种 | INT16 |
|
|
51
|
+
| 位元件 | ✅ 拆包 | ❌ |
|
|
52
|
+
| eng 变换 | ✅ | ❌ |
|
|
53
|
+
| 错误诊断 | 中文可读 | "timeout" |
|
|
54
|
+
| 模拟模式 | ✅ | ❌ |
|
|
55
|
+
|
|
56
|
+
## 许可证
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script type="text/x-red" data-template-name="mitsubishi-config">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
4
|
+
<input type="text" id="node-config-input-name" placeholder="PLC-1">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-config-input-host"><i class="fa fa-globe"></i> IP 地址</label>
|
|
8
|
+
<input type="text" id="node-config-input-host" placeholder="192.168.1.10">
|
|
9
|
+
</div>
|
|
10
|
+
<div class="form-row">
|
|
11
|
+
<label for="node-config-input-port"><i class="fa fa-plug"></i> 端口</label>
|
|
12
|
+
<input type="number" id="node-config-input-port" placeholder="5007" min="1" max="65535">
|
|
13
|
+
</div>
|
|
14
|
+
<div class="form-row">
|
|
15
|
+
<label for="node-config-input-frame"><i class="fa fa-exchange"></i> 帧格式</label>
|
|
16
|
+
<select id="node-config-input-frame">
|
|
17
|
+
<option value="3E">3E (Q/L/FX3U)</option>
|
|
18
|
+
<option value="4E">4E (iQ-R/iQ-F/FX5U)</option>
|
|
19
|
+
</select>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="form-row">
|
|
22
|
+
<label for="node-config-input-networkNo"><i class="fa fa-sitemap"></i> 网络号</label>
|
|
23
|
+
<input type="number" id="node-config-input-networkNo" value="0" min="0" max="255">
|
|
24
|
+
</div>
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-config-input-stationNo"><i class="fa fa-server"></i> 站号</label>
|
|
27
|
+
<input type="number" id="node-config-input-stationNo" value="0" min="0" max="255">
|
|
28
|
+
</div>
|
|
29
|
+
<div class="form-row">
|
|
30
|
+
<label for="node-config-input-timeout"><i class="fa fa-clock-o"></i> 超时 (ms)</label>
|
|
31
|
+
<input type="number" id="node-config-input-timeout" value="3000" min="500" max="30000">
|
|
32
|
+
</div>
|
|
33
|
+
<div class="form-row">
|
|
34
|
+
<label for="node-config-input-maxRetries"><i class="fa fa-repeat"></i> 重试次数</label>
|
|
35
|
+
<input type="number" id="node-config-input-maxRetries" value="2" min="0" max="10">
|
|
36
|
+
</div>
|
|
37
|
+
<div class="form-row">
|
|
38
|
+
<label for="node-config-input-retryInterval"><i class="fa fa-hourglass-half"></i> 重试间隔 (ms)</label>
|
|
39
|
+
<input type="number" id="node-config-input-retryInterval" value="300" min="50" max="10000">
|
|
40
|
+
</div>
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script type="text/javascript">
|
|
44
|
+
RED.nodes.registerType('mitsubishi-config', {
|
|
45
|
+
category: 'config',
|
|
46
|
+
defaults: {
|
|
47
|
+
name: { value: 'PLC-1' },
|
|
48
|
+
host: { value: '192.168.1.10', required: true },
|
|
49
|
+
port: { value: 5007, required: true, validate: RED.validators.number() },
|
|
50
|
+
frame: { value: '3E', required: true },
|
|
51
|
+
networkNo: { value: 0, validate: RED.validators.number() },
|
|
52
|
+
stationNo: { value: 0, validate: RED.validators.number() },
|
|
53
|
+
timeout: { value: 3000, validate: RED.validators.number() },
|
|
54
|
+
maxRetries: { value: 2, validate: RED.validators.number() },
|
|
55
|
+
retryInterval: { value: 300, validate: RED.validators.number() }
|
|
56
|
+
},
|
|
57
|
+
label: function () {
|
|
58
|
+
return this.name || 'PLC-1';
|
|
59
|
+
},
|
|
60
|
+
paletteLabel: 'PLC 连接配置'
|
|
61
|
+
});
|
|
62
|
+
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mitsubishi-config — 三菱 PLC 连接配置节点
|
|
3
|
+
* 保存 IP、端口、帧格式等连接参数,供 mitsubishi-read 节点引用
|
|
4
|
+
*/
|
|
5
|
+
module.exports = function (RED) {
|
|
6
|
+
function MitsubishiConfigNode(config) {
|
|
7
|
+
RED.nodes.createNode(this, config);
|
|
8
|
+
this.name = config.name || 'PLC-1';
|
|
9
|
+
this.host = config.host || '192.168.1.10';
|
|
10
|
+
this.port = parseInt(config.port, 10) || 5007;
|
|
11
|
+
this.frame = config.frame || '3E';
|
|
12
|
+
this.networkNo = parseInt(config.networkNo, 10) || 0;
|
|
13
|
+
this.stationNo = parseInt(config.stationNo, 10) || 0;
|
|
14
|
+
this.timeout = parseInt(config.timeout, 10) || 3000;
|
|
15
|
+
this.maxRetries = parseInt(config.maxRetries, 10);
|
|
16
|
+
if (isNaN(this.maxRetries)) this.maxRetries = 2;
|
|
17
|
+
this.retryInterval = parseInt(config.retryInterval, 10);
|
|
18
|
+
if (isNaN(this.retryInterval)) this.retryInterval = 300;
|
|
19
|
+
}
|
|
20
|
+
RED.nodes.registerType('mitsubishi-config', MitsubishiConfigNode);
|
|
21
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script type="text/x-red" data-template-name="mitsubishi-read">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="三菱MC读取">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-plc"><i class="fa fa-microchip"></i> PLC 配置</label>
|
|
8
|
+
<input type="text" id="node-input-plc" placeholder="选择 mitsubishi-config 节点">
|
|
9
|
+
</div>
|
|
10
|
+
<div class="form-row">
|
|
11
|
+
<label for="node-input-serialNo"><i class="fa fa-hashtag"></i> 序列号 (4E帧, 0=不启用)</label>
|
|
12
|
+
<input type="number" id="node-input-serialNo" value="0" min="0" max="65535" style="width:100px">
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<!-- 点位编辑表格 -->
|
|
16
|
+
<div class="form-row" style="margin-top:8px">
|
|
17
|
+
<label><i class="fa fa-list"></i> 默认点位 (msg.tags 传入时覆盖)</label>
|
|
18
|
+
</div>
|
|
19
|
+
<div style="margin-bottom:4px">
|
|
20
|
+
<table style="width:100%;font-size:12px;border-collapse:collapse">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr style="color:#888">
|
|
23
|
+
<th style="width:11%;text-align:left">寄存器</th>
|
|
24
|
+
<th style="width:10%;text-align:left">地址</th>
|
|
25
|
+
<th style="width:16%;text-align:left">数据类型</th>
|
|
26
|
+
<th style="width:11%;text-align:left">斜率</th>
|
|
27
|
+
<th style="width:11%;text-align:left">偏移</th>
|
|
28
|
+
<th style="width:24%;text-align:left">名称</th>
|
|
29
|
+
<th style="width:6%"></th>
|
|
30
|
+
<th style="width:11%;text-align:left">ID</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody id="tags-tbody"></tbody>
|
|
34
|
+
</table>
|
|
35
|
+
<button class="red-ui-button" onclick="addTagRow()" style="margin-top:4px;font-size:11px">
|
|
36
|
+
<i class="fa fa-plus"></i> 添加点位
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
<input type="hidden" id="node-input-tags">
|
|
40
|
+
|
|
41
|
+
<hr>
|
|
42
|
+
<div style="color:#888;font-size:11px;margin-bottom:4px">
|
|
43
|
+
<i class="fa fa-info-circle"></i> 静态: 表格配置 | 动态: msg.tags 传入覆盖<br>
|
|
44
|
+
输出: msg.payload.data[点位ID] = {rawValue, engValue, quality, ts}<br>
|
|
45
|
+
变换: engValue = rawValue × slope + offset
|
|
46
|
+
</div>
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<script type="text/javascript">
|
|
50
|
+
var tagEditor = {
|
|
51
|
+
rows: [],
|
|
52
|
+
init: function () {
|
|
53
|
+
try { this.rows = JSON.parse($('#node-input-tags').val() || '[]'); } catch (e) { this.rows = []; }
|
|
54
|
+
this.render();
|
|
55
|
+
},
|
|
56
|
+
add: function () {
|
|
57
|
+
this.rows.push({ id: '', regType: 'D', addr: '', dataType: 'INT16', slope: '1', offset: '0', name: '' });
|
|
58
|
+
this.render();
|
|
59
|
+
this.save();
|
|
60
|
+
},
|
|
61
|
+
remove: function (idx) {
|
|
62
|
+
this.rows.splice(idx, 1);
|
|
63
|
+
this.render();
|
|
64
|
+
this.save();
|
|
65
|
+
},
|
|
66
|
+
update: function (idx, field, val) {
|
|
67
|
+
this.rows[idx][field] = val;
|
|
68
|
+
this.save();
|
|
69
|
+
},
|
|
70
|
+
save: function () {
|
|
71
|
+
$('#node-input-tags').val(JSON.stringify(this.rows));
|
|
72
|
+
},
|
|
73
|
+
render: function () {
|
|
74
|
+
var self = this;
|
|
75
|
+
var tbody = $('#tags-tbody');
|
|
76
|
+
tbody.empty();
|
|
77
|
+
this.rows.forEach(function (row, idx) {
|
|
78
|
+
var tr = $('<tr>');
|
|
79
|
+
var mkOpt = function (vals, cur) {
|
|
80
|
+
var s = '';
|
|
81
|
+
vals.forEach(function (v) { s += '<option' + (cur === v ? ' selected' : '') + '>' + v + '</option>'; });
|
|
82
|
+
return s;
|
|
83
|
+
};
|
|
84
|
+
var mkSel = function (vals, cur) { return $('<select style="width:95%">' + mkOpt(vals, cur) + '</select>'); };
|
|
85
|
+
|
|
86
|
+
// 寄存器
|
|
87
|
+
var reg = mkSel(['D','W','R','X','Y','M','L','B'], row.regType || 'D');
|
|
88
|
+
reg.on('change', function () { self.update(idx, 'regType', $(this).val()); });
|
|
89
|
+
tr.append($('<td>').append(reg));
|
|
90
|
+
|
|
91
|
+
// 地址
|
|
92
|
+
var addr = $('<input type="text" style="width:90%" placeholder="100">').val(row.addr || '');
|
|
93
|
+
addr.on('change', function () { self.update(idx, 'addr', $(this).val()); });
|
|
94
|
+
tr.append($('<td>').append(addr));
|
|
95
|
+
|
|
96
|
+
// 数据类型
|
|
97
|
+
var dt = mkSel(['INT16','UINT16','INT32','UINT32','FLOAT32','BOOL'], row.dataType || 'INT16');
|
|
98
|
+
dt.on('change', function () { self.update(idx, 'dataType', $(this).val()); });
|
|
99
|
+
tr.append($('<td>').append(dt));
|
|
100
|
+
|
|
101
|
+
// 斜率
|
|
102
|
+
var slope = $('<input type="number" style="width:90%" step="any" placeholder="1">').val(row.slope || '1');
|
|
103
|
+
slope.on('change', function () { self.update(idx, 'slope', $(this).val()); });
|
|
104
|
+
tr.append($('<td>').append(slope));
|
|
105
|
+
|
|
106
|
+
// 偏移
|
|
107
|
+
var off = $('<input type="number" style="width:90%" step="any" placeholder="0">').val(row.offset || '0');
|
|
108
|
+
off.on('change', function () { self.update(idx, 'offset', $(this).val()); });
|
|
109
|
+
tr.append($('<td>').append(off));
|
|
110
|
+
|
|
111
|
+
// 名称
|
|
112
|
+
var nm = $('<input type="text" style="width:95%" placeholder="温度">').val(row.name || '');
|
|
113
|
+
nm.on('change', function () { self.update(idx, 'name', $(this).val()); });
|
|
114
|
+
tr.append($('<td>').append(nm));
|
|
115
|
+
|
|
116
|
+
// 删除
|
|
117
|
+
var del = $('<button class="red-ui-button" style="font-size:10px;padding:1px 5px">✕</button>');
|
|
118
|
+
del.on('click', function (e) { e.preventDefault(); self.remove(idx); });
|
|
119
|
+
tr.append($('<td>').append(del));
|
|
120
|
+
|
|
121
|
+
// ID (自动生成或手动填)
|
|
122
|
+
var id = $('<input type="text" style="width:95%" placeholder="auto">').val(row.id || '');
|
|
123
|
+
id.on('change', function () { self.update(idx, 'id', $(this).val()); });
|
|
124
|
+
tr.append($('<td>').append(id));
|
|
125
|
+
|
|
126
|
+
tbody.append(tr);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
RED.nodes.registerType('mitsubishi-read', {
|
|
132
|
+
category: '三菱 PLC',
|
|
133
|
+
color: '#C7E9C0',
|
|
134
|
+
defaults: {
|
|
135
|
+
name: { value: '三菱MC读取' },
|
|
136
|
+
plc: { type: 'mitsubishi-config', required: true },
|
|
137
|
+
serialNo: { value: 0 },
|
|
138
|
+
tags: { value: '[]' }
|
|
139
|
+
},
|
|
140
|
+
inputs: 1,
|
|
141
|
+
outputs: 1,
|
|
142
|
+
paletteLabel: 'MC 读取',
|
|
143
|
+
label: function () { return this.name || '三菱MC读取'; },
|
|
144
|
+
oneditprepare: function () {
|
|
145
|
+
tagEditor.init();
|
|
146
|
+
window.addTagRow = function () { tagEditor.add(); };
|
|
147
|
+
},
|
|
148
|
+
oneditsave: function () {
|
|
149
|
+
delete window.addTagRow;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
</script>
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mitsubishi-read — 三菱 MC Protocol 3E/4E 读取节点 v2.0
|
|
3
|
+
*
|
|
4
|
+
* 独立使用: 节点表格配置点位 → inject触发 → 输出eng值
|
|
5
|
+
* 集成使用: msg.tags 传入覆盖节点配置
|
|
6
|
+
*
|
|
7
|
+
* 变换: engValue = rawValue * slope + offset
|
|
8
|
+
* 输出: msg.payload.data[tagId] = {rawValue, engValue, quality, ts}
|
|
9
|
+
*/
|
|
10
|
+
module.exports = function (RED) {
|
|
11
|
+
var net = require('net');
|
|
12
|
+
|
|
13
|
+
// ===== MC 软元件代码映射 =====
|
|
14
|
+
var MC_DEVICE_CODES = {
|
|
15
|
+
'D': 0xA8, 'W': 0xB4, 'X': 0x9C, 'Y': 0x9D,
|
|
16
|
+
'M': 0x90, 'L': 0x92, 'B': 0xA0, 'R': 0xAF
|
|
17
|
+
};
|
|
18
|
+
var BIT_DEVICES = { 'X': true, 'Y': true, 'M': true, 'L': true, 'B': true };
|
|
19
|
+
|
|
20
|
+
// ===== MC 错误码映射 =====
|
|
21
|
+
var MC_ERROR_CODES = {
|
|
22
|
+
0xC050: 'CPU busy', 0xC051: 'Device not supported', 0xC052: 'Address out of range',
|
|
23
|
+
0xC053: 'Batch size out of range', 0xC054: 'Write protect error',
|
|
24
|
+
0xC055: 'Remote operation error', 0xC056: 'File not found', 0xC057: 'File name error',
|
|
25
|
+
0xC059: 'Points out of range', 0xC05B: 'CPU type mismatch', 0xC05C: 'Remote password error',
|
|
26
|
+
0xC05F: 'CPU module error', 0xC061: 'Monitor timer timeout',
|
|
27
|
+
0xC06F: 'ASCII code error', 0xC070: 'Frame length error',
|
|
28
|
+
0xC0D0: 'PLC not running', 0x4004: '4E: Device not supported',
|
|
29
|
+
0x401A: '4E: Address out of range', 0x4028: '4E: Points out of range'
|
|
30
|
+
};
|
|
31
|
+
function mcErrorText(code) {
|
|
32
|
+
return MC_ERROR_CODES[code] || ('Unknown MC error 0x' + code.toString(16).toUpperCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function clampInt(v, def, min, max) {
|
|
36
|
+
var n = parseInt(v, 10);
|
|
37
|
+
if (isNaN(n)) n = def;
|
|
38
|
+
return Math.max(min, Math.min(n, max));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ===== 帧构造 =====
|
|
42
|
+
function build3EFrame(startAddr, wordCount, stationNo, regType, networkNo) {
|
|
43
|
+
var deviceCode = MC_DEVICE_CODES[regType] || 0xA8;
|
|
44
|
+
var buf = Buffer.alloc(21);
|
|
45
|
+
buf[0] = 0x50; buf[1] = 0x00;
|
|
46
|
+
buf[2] = networkNo || 0; buf[3] = 0xFF; buf[4] = 0xFF; buf[5] = 0x03;
|
|
47
|
+
buf[6] = stationNo || 0; buf[7] = 0x0C; buf[8] = 0x00;
|
|
48
|
+
buf[9] = 0x10; buf[10] = 0x00;
|
|
49
|
+
buf[11] = 0x01; buf[12] = 0x04; buf[13] = 0x00; buf[14] = 0x00;
|
|
50
|
+
buf[15] = startAddr & 0xFF; buf[16] = (startAddr >> 8) & 0xFF;
|
|
51
|
+
buf[17] = (startAddr >> 16) & 0xFF;
|
|
52
|
+
buf[18] = deviceCode;
|
|
53
|
+
buf.writeUInt16LE(wordCount, 19);
|
|
54
|
+
return buf;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function build4EFrame(startAddr, wordCount, stationNo, regType, networkNo, serialNo) {
|
|
58
|
+
var deviceCode = MC_DEVICE_CODES[regType] || 0xA8;
|
|
59
|
+
var hasSN = (serialNo !== 0);
|
|
60
|
+
var buf = Buffer.alloc(hasSN ? 24 : 22);
|
|
61
|
+
buf[0] = 0x54; buf[1] = 0x00;
|
|
62
|
+
buf[2] = networkNo || 0; buf[3] = 0xFF; buf[4] = 0xFF; buf[5] = 0x03;
|
|
63
|
+
buf[6] = stationNo || 0;
|
|
64
|
+
buf[7] = hasSN ? 0x0F : 0x0D; buf[8] = 0x00;
|
|
65
|
+
buf[9] = 0x10; buf[10] = 0x00;
|
|
66
|
+
var off = 11;
|
|
67
|
+
if (hasSN) { buf[off] = serialNo & 0xFF; buf[off + 1] = (serialNo >> 8) & 0xFF; off += 2; }
|
|
68
|
+
buf[off] = 0x01; buf[off + 1] = 0x04; off += 2;
|
|
69
|
+
buf[off] = 0x00; buf[off + 1] = 0x00; off += 2;
|
|
70
|
+
buf.writeUInt32LE(startAddr, off); off += 4;
|
|
71
|
+
buf[off] = deviceCode; off += 1;
|
|
72
|
+
buf.writeUInt16LE(wordCount, off);
|
|
73
|
+
return buf;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ===== 响应解析 =====
|
|
77
|
+
function parseMCResponse(buf, startAddr, regType, frameType, sentSN) {
|
|
78
|
+
if (!buf || buf.length < 11) return { err: 'Buffer too short' };
|
|
79
|
+
var subheader = (buf[0] === 0xD0) ? 0x50 : ((buf[0] === 0xD4) ? 0xD4 : 0);
|
|
80
|
+
if (subheader === 0) return { err: 'Invalid subheader' };
|
|
81
|
+
|
|
82
|
+
var endCode = buf.readUInt16LE(9);
|
|
83
|
+
if (endCode !== 0) return { mcError: endCode, mcErrorText: mcErrorText(endCode) };
|
|
84
|
+
|
|
85
|
+
var dataLen = buf.readUInt16LE(7) - ((frameType === '4E') ? 4 : 2);
|
|
86
|
+
if (dataLen < 0 || dataLen > 2000) return { err: 'Bad dataLen: ' + dataLen };
|
|
87
|
+
|
|
88
|
+
var dataStart = (frameType === '4E') ? 13 : 11;
|
|
89
|
+
if (frameType === '4E' && sentSN > 0 && buf.length >= 13) {
|
|
90
|
+
if (buf.readUInt16LE(11) !== sentSN) return { err: 'SerialNo mismatch' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (buf.length < dataStart + dataLen) return { err: 'Incomplete frame' };
|
|
94
|
+
|
|
95
|
+
var result = {};
|
|
96
|
+
if (BIT_DEVICES[regType]) {
|
|
97
|
+
for (var w = 0; w < dataLen / 2; w++) {
|
|
98
|
+
var wordVal = buf.readUInt16LE(dataStart + w * 2);
|
|
99
|
+
for (var b = 0; b < 16; b++) {
|
|
100
|
+
result[regType + (startAddr + w * 16 + b)] = (wordVal >> b) & 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
for (var i = 0; i < dataLen / 2; i++) {
|
|
105
|
+
result[regType + (startAddr + i)] = buf.readInt16LE(dataStart + i * 2);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ===== 斜率偏移变换 =====
|
|
112
|
+
function applyTransform(rawValue, tagDef) {
|
|
113
|
+
if (rawValue === null || rawValue === undefined) return null;
|
|
114
|
+
var slope = parseFloat(tagDef.slope || tagDef.transformSlopeA || 1);
|
|
115
|
+
var offset = parseFloat(tagDef.offset || tagDef.transformOffsetB || 0);
|
|
116
|
+
if (isNaN(slope)) slope = 1;
|
|
117
|
+
if (isNaN(offset)) offset = 0;
|
|
118
|
+
var eng = rawValue * slope + offset;
|
|
119
|
+
return parseFloat(eng.toFixed(4));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ===== 主节点 =====
|
|
123
|
+
function MitsubishiReadNode(config) {
|
|
124
|
+
RED.nodes.createNode(this, config);
|
|
125
|
+
var node = this;
|
|
126
|
+
|
|
127
|
+
node.plcConfig = RED.nodes.getNode(config.plc);
|
|
128
|
+
if (!node.plcConfig) {
|
|
129
|
+
node.error('未关联 PLC 配置节点');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
node.serialNo = parseInt(config.serialNo, 10) || 0;
|
|
133
|
+
|
|
134
|
+
// 解析节点表格配置的默认点位
|
|
135
|
+
var configTags = [];
|
|
136
|
+
try { configTags = JSON.parse(config.tags || '[]'); } catch (e) { configTags = []; }
|
|
137
|
+
|
|
138
|
+
node.on('input', function (msg) {
|
|
139
|
+
var plc = node.plcConfig;
|
|
140
|
+
|
|
141
|
+
// === 读取点位: msg.tags → msg.payload.tags → 节点表格配置 ===
|
|
142
|
+
var rawTags = msg.tags || (msg.payload && msg.payload.tags);
|
|
143
|
+
if (!rawTags || (Array.isArray(rawTags) && rawTags.length === 0)) {
|
|
144
|
+
rawTags = configTags;
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(rawTags)) rawTags = [rawTags];
|
|
147
|
+
var tags = rawTags;
|
|
148
|
+
|
|
149
|
+
// === 校验并清洗标签 ===
|
|
150
|
+
var validTags = [];
|
|
151
|
+
for (var i = 0; i < tags.length; i++) {
|
|
152
|
+
var t = tags[i];
|
|
153
|
+
var rt = t.regType || 'D';
|
|
154
|
+
if (!MC_DEVICE_CODES[rt]) {
|
|
155
|
+
if (rt) node.warn('[MC] Unknown regType: ' + rt + ', using D');
|
|
156
|
+
rt = 'D';
|
|
157
|
+
}
|
|
158
|
+
// 地址: 兼容数字和 "D100" 格式
|
|
159
|
+
var rawAddr = (t.addr !== undefined) ? t.addr : (t.regAddr || t.tag_address || t.registerAddress || '');
|
|
160
|
+
if (typeof rawAddr === 'string') rawAddr = String(rawAddr).replace(/\D/g, '');
|
|
161
|
+
var addr = parseInt(rawAddr, 10);
|
|
162
|
+
if (isNaN(addr) || addr < 0) {
|
|
163
|
+
node.warn('[MC] Invalid addr for tag ' + (t.id || t.name || ('#' + i)));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// ID: 优先用 t.id,其次 t.name,最后自生成
|
|
167
|
+
var tagId = t.id || t.name || (rt + addr);
|
|
168
|
+
validTags.push({
|
|
169
|
+
id: String(tagId),
|
|
170
|
+
regType: rt,
|
|
171
|
+
addr: addr,
|
|
172
|
+
dataType: t.dataType || 'INT16',
|
|
173
|
+
slope: t.slope || t.transformSlopeA || 1,
|
|
174
|
+
offset: t.offset || t.transformOffsetB || 0,
|
|
175
|
+
name: t.name || (rt + addr)
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (validTags.length === 0) {
|
|
180
|
+
node.status({ fill: 'grey', shape: 'dot', text: '0 valid tags' });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// === 参数 ===
|
|
185
|
+
var timeout = clampInt(plc.timeout, 3000, 500, 30000);
|
|
186
|
+
var maxRetries = clampInt(plc.maxRetries, 2, 0, 10);
|
|
187
|
+
var retryInterval = clampInt(plc.retryInterval, 300, 50, 10000);
|
|
188
|
+
var frameType = plc.frame || '3E';
|
|
189
|
+
var stationNo = plc.stationNo || 0;
|
|
190
|
+
var networkNo = plc.networkNo || 0;
|
|
191
|
+
var roundStart = Date.now();
|
|
192
|
+
|
|
193
|
+
// === 模拟模式 ===
|
|
194
|
+
var SIM_MODE = false;
|
|
195
|
+
try { SIM_MODE = RED.settings.mcSimulationMode || false; } catch (e) {}
|
|
196
|
+
|
|
197
|
+
if (SIM_MODE) {
|
|
198
|
+
var simOut = {};
|
|
199
|
+
validTags.forEach(function (t) {
|
|
200
|
+
var raw = BIT_DEVICES[t.regType] ? (Math.random() > 0.5 ? 1 : 0) : Math.floor(Math.random() * 1000);
|
|
201
|
+
simOut[t.id] = { rawValue: raw, engValue: applyTransform(raw, t), quality: 0, ts: new Date().toISOString() };
|
|
202
|
+
});
|
|
203
|
+
msg.payload = { success: true, data: simOut, error: null, roundTimeMs: Date.now() - roundStart };
|
|
204
|
+
node.status({ fill: 'green', shape: 'dot', text: 'SIM ' + validTags.length + ' tags' });
|
|
205
|
+
node.send(msg);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// === 去重: 同 regType + addr 保留最后一个 ===
|
|
210
|
+
var seen = {};
|
|
211
|
+
var deduped = [];
|
|
212
|
+
for (var di = validTags.length - 1; di >= 0; di--) {
|
|
213
|
+
var dt = validTags[di];
|
|
214
|
+
var dk = dt.regType + '|' + dt.addr;
|
|
215
|
+
if (!seen[dk]) { seen[dk] = true; deduped.unshift(dt); }
|
|
216
|
+
}
|
|
217
|
+
if (deduped.length < validTags.length) {
|
|
218
|
+
node.warn('[MC] Deduped ' + (validTags.length - deduped.length) + ' duplicate tags');
|
|
219
|
+
}
|
|
220
|
+
validTags = deduped;
|
|
221
|
+
|
|
222
|
+
// === 聚类分组 ===
|
|
223
|
+
var byRegType = {};
|
|
224
|
+
validTags.forEach(function (t) {
|
|
225
|
+
if (!byRegType[t.regType]) byRegType[t.regType] = [];
|
|
226
|
+
byRegType[t.regType].push(t);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
var groups = [];
|
|
230
|
+
Object.keys(byRegType).forEach(function (rt) {
|
|
231
|
+
var sorted = byRegType[rt].slice().sort(function (a, b) { return a.addr - b.addr; });
|
|
232
|
+
var cluster = [sorted[0]];
|
|
233
|
+
for (var i = 1; i < sorted.length; i++) {
|
|
234
|
+
var gap = sorted[i].addr - cluster[cluster.length - 1].addr;
|
|
235
|
+
if (gap <= 20 && cluster.length < 50) {
|
|
236
|
+
cluster.push(sorted[i]);
|
|
237
|
+
} else {
|
|
238
|
+
groups.push({ regType: rt, tags: cluster });
|
|
239
|
+
cluster = [sorted[i]];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (cluster.length > 0) groups.push({ regType: rt, tags: cluster });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (groups.length === 0) {
|
|
246
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no groups' });
|
|
247
|
+
msg.payload = { success: false, data: {}, error: 'No valid register groups' };
|
|
248
|
+
node.send(msg);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// === 全局锁 (按 host:port) ===
|
|
253
|
+
var lockKey = 'edge_mc_lock_' + plc.host + '_' + plc.port;
|
|
254
|
+
if (!global._mcLocks) global._mcLocks = {};
|
|
255
|
+
if (global._mcLocks[lockKey] && (Date.now() - global._mcLocks[lockKey] < 60000)) {
|
|
256
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'busy' });
|
|
257
|
+
setTimeout(function () { node.send(msg); }, 200);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
global._mcLocks[lockKey] = Date.now();
|
|
261
|
+
|
|
262
|
+
// === 顺序处理 groups ===
|
|
263
|
+
var allRaw = {}; // raw values from PLC
|
|
264
|
+
var hasFailed = false;
|
|
265
|
+
var firstError = '';
|
|
266
|
+
var currentSN = node.serialNo;
|
|
267
|
+
|
|
268
|
+
function processGroup(gi) {
|
|
269
|
+
if (gi >= groups.length) {
|
|
270
|
+
// 全部完成 → 构建输出(含斜率偏移变换)
|
|
271
|
+
var output = {};
|
|
272
|
+
validTags.forEach(function (t) {
|
|
273
|
+
var entry = allRaw[t.id];
|
|
274
|
+
if (entry) {
|
|
275
|
+
output[t.id] = {
|
|
276
|
+
rawValue: entry.rawValue,
|
|
277
|
+
engValue: applyTransform(entry.rawValue, t),
|
|
278
|
+
quality: entry.quality,
|
|
279
|
+
ts: entry.ts
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
msg.payload = {
|
|
284
|
+
success: !hasFailed,
|
|
285
|
+
data: output,
|
|
286
|
+
error: hasFailed ? firstError : null,
|
|
287
|
+
driverType: 'driver-mc-protocol',
|
|
288
|
+
plcIp: plc.host,
|
|
289
|
+
plcPort: plc.port,
|
|
290
|
+
roundTimeMs: Date.now() - roundStart
|
|
291
|
+
};
|
|
292
|
+
node.status({
|
|
293
|
+
fill: hasFailed ? 'red' : 'green',
|
|
294
|
+
shape: 'dot',
|
|
295
|
+
text: (plc.name || plc.host) + ' ' + Object.keys(output).length + ' vals ' + (Date.now() - roundStart) + 'ms'
|
|
296
|
+
});
|
|
297
|
+
delete global._mcLocks[lockKey];
|
|
298
|
+
node.send(msg);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
var grp = groups[gi];
|
|
303
|
+
var addrs = grp.tags.map(function (t) { return t.addr; });
|
|
304
|
+
var startA = addrs[0];
|
|
305
|
+
var isBit = BIT_DEVICES[grp.regType] || false;
|
|
306
|
+
if (isBit) startA = startA - (startA % 16);
|
|
307
|
+
|
|
308
|
+
var wordCount;
|
|
309
|
+
if (isBit) {
|
|
310
|
+
wordCount = Math.ceil((addrs[addrs.length - 1] - startA + 1) / 16);
|
|
311
|
+
} else {
|
|
312
|
+
wordCount = addrs[addrs.length - 1] - startA + 1;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 字数上限 + 自动拆包
|
|
316
|
+
var MAX_WORDS = isBit ? 15360 : 960;
|
|
317
|
+
if (wordCount > MAX_WORDS) {
|
|
318
|
+
var newGroups = [];
|
|
319
|
+
var ss = 0;
|
|
320
|
+
while (ss < grp.tags.length) {
|
|
321
|
+
var se = ss;
|
|
322
|
+
while (se < grp.tags.length && (grp.tags[se].addr - grp.tags[ss].addr) < MAX_WORDS) se++;
|
|
323
|
+
newGroups.push({ regType: grp.regType, tags: grp.tags.slice(ss, se) });
|
|
324
|
+
ss = se;
|
|
325
|
+
}
|
|
326
|
+
groups.splice.apply(groups, [gi, 1].concat(newGroups));
|
|
327
|
+
setTimeout(function () { processGroup(gi); }, 0);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function attemptGroup(attempt) {
|
|
332
|
+
if (attempt > maxRetries) {
|
|
333
|
+
hasFailed = true;
|
|
334
|
+
if (!firstError) firstError = 'MC read failed for ' + grp.regType + startA;
|
|
335
|
+
setTimeout(function () { processGroup(gi + 1); }, 0);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (frameType === '4E' && currentSN > 0) currentSN = (currentSN + 1) & 0xFFFF;
|
|
340
|
+
var sentSN = (frameType === '4E' && currentSN > 0) ? currentSN : 0;
|
|
341
|
+
|
|
342
|
+
var client = new net.Socket();
|
|
343
|
+
var buf = Buffer.alloc(0);
|
|
344
|
+
var resolved = false;
|
|
345
|
+
client.setTimeout(timeout);
|
|
346
|
+
|
|
347
|
+
client.connect(plc.port, plc.host, function () {
|
|
348
|
+
try {
|
|
349
|
+
var frame = (frameType === '4E')
|
|
350
|
+
? build4EFrame(startA, wordCount, stationNo, grp.regType, networkNo, sentSN)
|
|
351
|
+
: build3EFrame(startA, wordCount, stationNo, grp.regType, networkNo);
|
|
352
|
+
client.write(frame);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
node.warn('[MC] connect error: ' + e.message);
|
|
355
|
+
if (!resolved) { resolved = true; try { client.destroy(); } catch (e2) {} }
|
|
356
|
+
setTimeout(function () { attemptGroup(attempt + 1); }, retryInterval);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
client.on('data', function (chunk) {
|
|
361
|
+
try {
|
|
362
|
+
buf = Buffer.concat([buf, chunk]);
|
|
363
|
+
var hdrLen = (frameType === '4E') ? 13 : 11;
|
|
364
|
+
if (!resolved && buf.length >= hdrLen) {
|
|
365
|
+
var dataLen = buf.readUInt16LE(7) - ((frameType === '4E') ? 4 : 2);
|
|
366
|
+
if (dataLen < 0 || dataLen > 2000) {
|
|
367
|
+
resolved = true; try { client.destroy(); } catch (e) {}
|
|
368
|
+
setTimeout(function () { attemptGroup(attempt + 1); }, retryInterval);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (buf.length >= hdrLen + dataLen) {
|
|
372
|
+
resolved = true;
|
|
373
|
+
var raw = parseMCResponse(buf, startA, grp.regType, frameType, sentSN);
|
|
374
|
+
|
|
375
|
+
if (raw && raw.mcError) {
|
|
376
|
+
hasFailed = true;
|
|
377
|
+
if (!firstError) firstError = '[PLC 0x' + raw.mcError.toString(16).toUpperCase() + '] ' + raw.mcErrorText;
|
|
378
|
+
_destroyedByUs = true; try { client.destroy(); } catch (e) {}
|
|
379
|
+
setTimeout(function () { processGroup(gi + 1); }, 0);
|
|
380
|
+
} else if (raw && !raw.err) {
|
|
381
|
+
grp.tags.forEach(function (t) {
|
|
382
|
+
var key = grp.regType + t.addr;
|
|
383
|
+
var rv = raw[key];
|
|
384
|
+
var q = 0;
|
|
385
|
+
if (rv === undefined || rv === null) { q = 2; rv = null; }
|
|
386
|
+
else if (!isBit) {
|
|
387
|
+
var dt = t.dataType || 'INT16';
|
|
388
|
+
if (dt === 'UINT16') { if (rv < 0) rv += 65536; }
|
|
389
|
+
else if (dt === 'INT32' || dt === 'UINT32' || dt === 'FLOAT32') {
|
|
390
|
+
var adj = raw[grp.regType + (t.addr + 1)];
|
|
391
|
+
if (adj === undefined || adj === null) { q = 2; rv = null; }
|
|
392
|
+
else {
|
|
393
|
+
var hi = rv, lo = adj;
|
|
394
|
+
var combined = (hi << 16) | (lo & 0xFFFF);
|
|
395
|
+
if (dt === 'INT32') rv = (combined > 0x7FFFFFFF) ? combined - 0x100000000 : combined;
|
|
396
|
+
else if (dt === 'UINT32') rv = combined >>> 0;
|
|
397
|
+
else if (dt === 'FLOAT32') {
|
|
398
|
+
var b32 = Buffer.alloc(4);
|
|
399
|
+
b32.writeInt16LE(hi, 0); b32.writeInt16LE(lo, 2);
|
|
400
|
+
rv = parseFloat(b32.readFloatLE(0).toFixed(4));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
allRaw[t.id] = { rawValue: rv, quality: q, ts: new Date().toISOString() };
|
|
406
|
+
});
|
|
407
|
+
_destroyedByUs = true; try { client.destroy(); } catch (e) {}
|
|
408
|
+
setTimeout(function () { processGroup(gi + 1); }, 0);
|
|
409
|
+
} else {
|
|
410
|
+
_destroyedByUs = true; try { client.destroy(); } catch (e) {}
|
|
411
|
+
setTimeout(function () { attemptGroup(attempt + 1); }, retryInterval);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (e) {
|
|
416
|
+
node.warn('[MC] data handler error: ' + e.message);
|
|
417
|
+
if (!resolved) { resolved = true; try { client.destroy(); } catch (e2) {} }
|
|
418
|
+
setTimeout(function () { processGroup(gi + 1); }, 0);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
client.on('timeout', function () {
|
|
423
|
+
if (resolved) return;
|
|
424
|
+
resolved = true;
|
|
425
|
+
try { client.destroy(); } catch (e) {}
|
|
426
|
+
setTimeout(function () { attemptGroup(attempt + 1); }, retryInterval);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
client.on('error', function () {
|
|
430
|
+
if (resolved) return;
|
|
431
|
+
resolved = true;
|
|
432
|
+
try { client.destroy(); } catch (e) {}
|
|
433
|
+
setTimeout(function () { attemptGroup(attempt + 1); }, retryInterval);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
var _destroyedByUs = false;
|
|
437
|
+
client.on('close', function () {
|
|
438
|
+
if (_destroyedByUs) return; // 我们自己关的,不做错误处理
|
|
439
|
+
if (!resolved) {
|
|
440
|
+
resolved = true;
|
|
441
|
+
if (buf.length === 0) {
|
|
442
|
+
hasFailed = true;
|
|
443
|
+
if (!firstError) firstError = '[NETWORK] TCP closed (no data)';
|
|
444
|
+
setTimeout(function () { processGroup(gi + 1); }, 0);
|
|
445
|
+
} else {
|
|
446
|
+
// 收到部分数据但帧不完整,重试
|
|
447
|
+
setTimeout(function () { attemptGroup(attempt + 1); }, retryInterval);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
attemptGroup(0);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
processGroup(0);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
node.warn('[MC] Exception: ' + e.message);
|
|
459
|
+
delete global._mcLocks[lockKey];
|
|
460
|
+
msg.payload = { success: false, data: {}, error: e.message };
|
|
461
|
+
node.status({ fill: 'red', shape: 'ring', text: 'exception' });
|
|
462
|
+
node.send(msg);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
RED.nodes.registerType('mitsubishi-read', MitsubishiReadNode);
|
|
468
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-mitsubishi",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "三菱 MC Protocol 3E/4E 采集节点 — 纯TCP零依赖, 支持Q/L/iQ-R/iQ-F/FX5U",
|
|
5
|
+
"main": "nodes/mitsubishi-read.js",
|
|
6
|
+
"node-red": {
|
|
7
|
+
"nodes": {
|
|
8
|
+
"mitsubishi-config": "nodes/mitsubishi-config.js",
|
|
9
|
+
"mitsubishi-read": "nodes/mitsubishi-read.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"node-red",
|
|
14
|
+
"mitsubishi",
|
|
15
|
+
"mc-protocol",
|
|
16
|
+
"plc",
|
|
17
|
+
"3e",
|
|
18
|
+
"4e",
|
|
19
|
+
"melsec",
|
|
20
|
+
"industrial",
|
|
21
|
+
"iot",
|
|
22
|
+
"edgelink"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=14.0.0"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|