@tteja/node-red-dashboard-2-robot-arm 0.3.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/LICENSE +21 -0
- package/README.md +142 -0
- package/docs/ARCHITECTURE.md +51 -0
- package/examples/01-inject-demo.json +133 -0
- package/examples/02-controller-to-robot.json +72 -0
- package/examples/03-rosbridge-live.json +54 -0
- package/examples/04-controller-and-ros2.json +72 -0
- package/examples/05-dashboard2.json +143 -0
- package/lib/normalize.js +146 -0
- package/lib/robot-runtime.js +144 -0
- package/lib/rosbridge.js +210 -0
- package/models/ur5e/LICENSE +33 -0
- package/models/ur5e/README.md +15 -0
- package/models/ur5e/joints.json +12 -0
- package/models/ur5e/meshes/base.stl +0 -0
- package/models/ur5e/meshes/forearm.stl +0 -0
- package/models/ur5e/meshes/shoulder.stl +0 -0
- package/models/ur5e/meshes/upperarm.stl +0 -0
- package/models/ur5e/meshes/wrist1.stl +0 -0
- package/models/ur5e/meshes/wrist2.stl +0 -0
- package/models/ur5e/meshes/wrist3.stl +0 -0
- package/models/ur5e/ur5e.urdf +138 -0
- package/models/ur5e-gripper/LICENSE +33 -0
- package/models/ur5e-gripper/README-ur5e.md +15 -0
- package/models/ur5e-gripper/README.md +6 -0
- package/models/ur5e-gripper/joints.json +13 -0
- package/models/ur5e-gripper/meshes/base.stl +0 -0
- package/models/ur5e-gripper/meshes/forearm.stl +0 -0
- package/models/ur5e-gripper/meshes/shoulder.stl +0 -0
- package/models/ur5e-gripper/meshes/upperarm.stl +0 -0
- package/models/ur5e-gripper/meshes/wrist1.stl +0 -0
- package/models/ur5e-gripper/meshes/wrist2.stl +0 -0
- package/models/ur5e-gripper/meshes/wrist3.stl +0 -0
- package/models/ur5e-gripper/ur5e_gripper.urdf +182 -0
- package/nodes/controller.html +76 -0
- package/nodes/controller.js +237 -0
- package/nodes/robot.html +109 -0
- package/nodes/robot.js +347 -0
- package/nodes/ui-robot-arm.html +82 -0
- package/nodes/ui-robot-arm.js +25 -0
- package/nodes/ui-robot-controller.html +81 -0
- package/nodes/ui-robot-controller.js +25 -0
- package/package.json +97 -0
- package/resources/controller.html +64 -0
- package/resources/controller.js +114 -0
- package/resources/pendant.html +66 -0
- package/resources/pendant.js +129 -0
- package/resources/robot-arm-widgets.umd.js +2 -0
- package/resources/viewer.html +54 -0
- package/resources/viewer.js +195 -0
- package/ui/components/UIRobotArm.vue +63 -0
- package/ui/components/UIRobotController.vue +62 -0
- package/ui/index.js +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { DEFAULT_JOINT_NAMES } = require('../lib/normalize');
|
|
6
|
+
|
|
7
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
8
|
+
|
|
9
|
+
/** @type {Map<string, object>} */
|
|
10
|
+
const controllers = new Map();
|
|
11
|
+
|
|
12
|
+
module.exports = function (RED) {
|
|
13
|
+
let httpMounted = false;
|
|
14
|
+
|
|
15
|
+
function ensureHttpRoutes() {
|
|
16
|
+
if (httpMounted) return;
|
|
17
|
+
httpMounted = true;
|
|
18
|
+
const express = RED.httpNode;
|
|
19
|
+
const resourcesDir = path.join(PKG_ROOT, 'resources');
|
|
20
|
+
|
|
21
|
+
express.get('/robot-arm/controller/:id', function (req, res) {
|
|
22
|
+
const id = req.params.id;
|
|
23
|
+
const inst = controllers.get(id);
|
|
24
|
+
if (!inst) {
|
|
25
|
+
res.status(404).send('Unknown controller node id. Deploy the flow first.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const htmlPath = path.join(resourcesDir, 'controller.html');
|
|
29
|
+
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
30
|
+
html = html
|
|
31
|
+
.replace(/__NODE_ID__/g, id)
|
|
32
|
+
.replace(/__UNIT__/g, inst.unit || 'rad')
|
|
33
|
+
.replace(/__ROBOT__/g, inst.robotTag || 'robot');
|
|
34
|
+
res.type('html').send(html);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
express.get('/robot-arm/api/controller/:id/meta', function (req, res) {
|
|
38
|
+
const id = req.params.id;
|
|
39
|
+
const inst = controllers.get(id);
|
|
40
|
+
if (!inst) {
|
|
41
|
+
res.status(404).json({ error: 'unknown' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
res.json(inst.getMeta());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function readJson(req, cb) {
|
|
48
|
+
if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
|
|
49
|
+
cb(null, req.body);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let raw = '';
|
|
53
|
+
req.setEncoding('utf8');
|
|
54
|
+
req.on('data', (c) => { raw += c; });
|
|
55
|
+
req.on('end', () => {
|
|
56
|
+
if (!raw) return cb(null, {});
|
|
57
|
+
try { cb(null, JSON.parse(raw)); }
|
|
58
|
+
catch (e) { cb(e); }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
express.post('/robot-arm/api/controller/:id/command', function (req, res) {
|
|
63
|
+
const id = req.params.id;
|
|
64
|
+
const inst = controllers.get(id);
|
|
65
|
+
if (!inst) {
|
|
66
|
+
res.status(404).json({ error: 'unknown' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
readJson(req, (err, body) => {
|
|
70
|
+
if (err) {
|
|
71
|
+
res.status(400).json({ error: 'invalid json' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
inst.emitCommand(body || {});
|
|
75
|
+
res.json({ ok: true });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadPresetJoints(preset) {
|
|
81
|
+
const metaPath = path.join(PKG_ROOT, 'models', preset || 'ur5e', 'joints.json');
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(metaPath)) {
|
|
84
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
85
|
+
}
|
|
86
|
+
} catch (_) { /* ignore */ }
|
|
87
|
+
return {
|
|
88
|
+
joints: DEFAULT_JOINT_NAMES.map((n) => ({
|
|
89
|
+
name: n,
|
|
90
|
+
label: n,
|
|
91
|
+
min: -3.14,
|
|
92
|
+
max: 3.14,
|
|
93
|
+
home: 0
|
|
94
|
+
}))
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ControllerNode(config) {
|
|
99
|
+
RED.nodes.createNode(this, config);
|
|
100
|
+
const node = this;
|
|
101
|
+
ensureHttpRoutes();
|
|
102
|
+
|
|
103
|
+
node.name = config.name || '';
|
|
104
|
+
node.robotTag = (config.robotTag || 'robot').trim() || 'robot';
|
|
105
|
+
node.modelPreset = config.modelPreset || 'ur5e';
|
|
106
|
+
node.unit = config.unit === 'deg' ? 'deg' : 'rad';
|
|
107
|
+
node.stepSize = Number(config.stepSize) || 0.01;
|
|
108
|
+
|
|
109
|
+
const meta = loadPresetJoints(node.modelPreset);
|
|
110
|
+
let jointsDef = meta.joints || [];
|
|
111
|
+
|
|
112
|
+
if (config.jointList && String(config.jointList).trim()) {
|
|
113
|
+
const names = String(config.jointList).split(',').map((s) => s.trim()).filter(Boolean);
|
|
114
|
+
jointsDef = names.map((n) => {
|
|
115
|
+
const found = (meta.joints || []).find((j) => j.name === n);
|
|
116
|
+
return found || { name: n, label: n, min: -3.14, max: 3.14, home: 0 };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (node.unit === 'deg') {
|
|
121
|
+
jointsDef = jointsDef.map((j) => {
|
|
122
|
+
if (j.unit === 'm') return { ...j };
|
|
123
|
+
const f = 180 / Math.PI;
|
|
124
|
+
return {
|
|
125
|
+
...j,
|
|
126
|
+
min: j.min * f,
|
|
127
|
+
max: j.max * f,
|
|
128
|
+
home: (j.home || 0) * f
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const current = {};
|
|
134
|
+
for (const j of jointsDef) {
|
|
135
|
+
current[j.name] = typeof j.home === 'number' ? j.home : 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let frozen = false;
|
|
139
|
+
let manualArmed = true;
|
|
140
|
+
let lastEmit = 0;
|
|
141
|
+
const rateMs = 50;
|
|
142
|
+
|
|
143
|
+
const viewUrl = `/robot-arm/controller/${node.id}`;
|
|
144
|
+
|
|
145
|
+
function getMeta() {
|
|
146
|
+
return {
|
|
147
|
+
id: node.id,
|
|
148
|
+
name: node.name,
|
|
149
|
+
robot: node.robotTag,
|
|
150
|
+
unit: node.unit,
|
|
151
|
+
stepSize: node.stepSize,
|
|
152
|
+
joints: jointsDef,
|
|
153
|
+
current: { ...current },
|
|
154
|
+
frozen,
|
|
155
|
+
manualArmed,
|
|
156
|
+
controllerUrl: viewUrl
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function emitCommand(body) {
|
|
161
|
+
if (body.freeze != null) frozen = !!body.freeze;
|
|
162
|
+
if (body.manualArmed != null) manualArmed = !!body.manualArmed;
|
|
163
|
+
|
|
164
|
+
if (body.home) {
|
|
165
|
+
for (const j of jointsDef) {
|
|
166
|
+
current[j.name] = typeof j.home === 'number' ? j.home : 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (body.joints && typeof body.joints === 'object') {
|
|
171
|
+
for (const [k, v] of Object.entries(body.joints)) {
|
|
172
|
+
const n = Number(v);
|
|
173
|
+
if (Number.isFinite(n)) current[k] = n;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (current.finger_joint != null) {
|
|
178
|
+
current.finger_joint_right = current.finger_joint;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
if (!body.force && now - lastEmit < rateMs) {
|
|
183
|
+
node.status({ fill: 'blue', shape: 'ring', text: viewUrl });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
lastEmit = now;
|
|
187
|
+
|
|
188
|
+
const payload = {
|
|
189
|
+
robot: node.robotTag,
|
|
190
|
+
joints: { ...current },
|
|
191
|
+
unit: node.unit,
|
|
192
|
+
source: 'pendant',
|
|
193
|
+
manualArmed,
|
|
194
|
+
freeze: frozen,
|
|
195
|
+
ts: now
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
node.send({ payload, topic: frozen ? 'freeze' : 'joints' });
|
|
199
|
+
node.status({
|
|
200
|
+
fill: frozen ? 'red' : (manualArmed ? 'green' : 'yellow'),
|
|
201
|
+
shape: 'dot',
|
|
202
|
+
text: frozen ? 'FREEZE ' + viewUrl : viewUrl
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
controllers.set(node.id, {
|
|
207
|
+
unit: node.unit,
|
|
208
|
+
robotTag: node.robotTag,
|
|
209
|
+
getMeta,
|
|
210
|
+
emitCommand
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
node.status({ fill: 'blue', shape: 'dot', text: viewUrl });
|
|
214
|
+
|
|
215
|
+
node.on('input', function (msg, send, done) {
|
|
216
|
+
try {
|
|
217
|
+
const p = msg.payload;
|
|
218
|
+
if (p === 'home' || (p && p.home)) {
|
|
219
|
+
emitCommand({ home: true, force: true });
|
|
220
|
+
} else if (p && typeof p === 'object') {
|
|
221
|
+
emitCommand(Object.assign({ force: true }, p));
|
|
222
|
+
}
|
|
223
|
+
if (done) done();
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (done) done(err);
|
|
226
|
+
else node.error(err, msg);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
node.on('close', function (removed, done) {
|
|
231
|
+
controllers.delete(node.id);
|
|
232
|
+
if (done) done();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
RED.nodes.registerType('robot-arm-controller', ControllerNode);
|
|
237
|
+
};
|
package/nodes/robot.html
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('robot-arm', {
|
|
3
|
+
category: 'robotics',
|
|
4
|
+
color: '#3D7EA6',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
robotTag: { value: 'robot' },
|
|
8
|
+
modelPreset: { value: 'ur5e' },
|
|
9
|
+
urdfPath: { value: '' },
|
|
10
|
+
unit: { value: 'rad' },
|
|
11
|
+
rosbridgeEnable: { value: false },
|
|
12
|
+
rosbridgeUrl: { value: 'ws://localhost:9090' },
|
|
13
|
+
jointStatesTopic: { value: '/joint_states' },
|
|
14
|
+
publishTopic: { value: '' }
|
|
15
|
+
},
|
|
16
|
+
inputs: 1,
|
|
17
|
+
outputs: 1,
|
|
18
|
+
icon: 'font-awesome/fa-android',
|
|
19
|
+
label: function () {
|
|
20
|
+
return this.name || 'robot-arm';
|
|
21
|
+
},
|
|
22
|
+
labelStyle: function () {
|
|
23
|
+
return this.name ? 'node_label_italic' : '';
|
|
24
|
+
},
|
|
25
|
+
paletteLabel: 'robot-arm',
|
|
26
|
+
oneditprepare: function () {
|
|
27
|
+
function toggleRos() {
|
|
28
|
+
var on = $('#node-input-rosbridgeEnable').is(':checked');
|
|
29
|
+
$('.rosbridge-opt').toggle(on);
|
|
30
|
+
}
|
|
31
|
+
$('#node-input-rosbridgeEnable').on('change', toggleRos);
|
|
32
|
+
toggleRos();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script type="text/html" data-template-name="robot-arm">
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
40
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-row">
|
|
43
|
+
<label for="node-input-robotTag"><i class="fa fa-robot"></i> Robot tag</label>
|
|
44
|
+
<input type="text" id="node-input-robotTag" placeholder="robot">
|
|
45
|
+
</div>
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-modelPreset"><i class="fa fa-cube"></i> Model</label>
|
|
48
|
+
<select id="node-input-modelPreset">
|
|
49
|
+
<option value="ur5e">UR5e (STL meshes)</option>
|
|
50
|
+
<option value="ur5e-gripper">UR5e + simple gripper</option>
|
|
51
|
+
</select>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="form-row">
|
|
54
|
+
<label for="node-input-urdfPath"><i class="fa fa-file"></i> URDF override</label>
|
|
55
|
+
<input type="text" id="node-input-urdfPath" placeholder="optional (unused — use preset)">
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-row">
|
|
58
|
+
<label for="node-input-unit"><i class="fa fa-ruler"></i> Unit</label>
|
|
59
|
+
<select id="node-input-unit">
|
|
60
|
+
<option value="rad">radians</option>
|
|
61
|
+
<option value="deg">degrees</option>
|
|
62
|
+
</select>
|
|
63
|
+
</div>
|
|
64
|
+
<hr>
|
|
65
|
+
<div class="form-row">
|
|
66
|
+
<label for="node-input-rosbridgeEnable"><i class="fa fa-plug"></i> ROS2</label>
|
|
67
|
+
<input type="checkbox" id="node-input-rosbridgeEnable" style="display:inline-block; width:auto; vertical-align:baseline;">
|
|
68
|
+
<span> Enable rosbridge WebSocket</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="form-row rosbridge-opt">
|
|
71
|
+
<label for="node-input-rosbridgeUrl"><i class="fa fa-link"></i> URL</label>
|
|
72
|
+
<input type="text" id="node-input-rosbridgeUrl" placeholder="ws://host:9090">
|
|
73
|
+
</div>
|
|
74
|
+
<div class="form-row rosbridge-opt">
|
|
75
|
+
<label for="node-input-jointStatesTopic"><i class="fa fa-arrow-down"></i> Subscribe</label>
|
|
76
|
+
<input type="text" id="node-input-jointStatesTopic" placeholder="/joint_states">
|
|
77
|
+
</div>
|
|
78
|
+
<div class="form-row rosbridge-opt">
|
|
79
|
+
<label for="node-input-publishTopic"><i class="fa fa-arrow-up"></i> Publish</label>
|
|
80
|
+
<input type="text" id="node-input-publishTopic" placeholder="optional /joint_command">
|
|
81
|
+
</div>
|
|
82
|
+
<div class="form-tips">
|
|
83
|
+
After deploy, open the 3D view URL on the node status
|
|
84
|
+
(<code>/robot-arm/view/<node-id></code>). For Dashboard 2, place
|
|
85
|
+
<b>ui-ur5e-arm</b> + <b>ui-ur5e-controller</b> on the same page and select this robot-arm node.
|
|
86
|
+
</div>
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<script type="text/html" data-help-name="robot-arm">
|
|
90
|
+
<p>Robot arm node: loads a UR5e URDF with STL meshes, shows a Three.js 3D view, accepts joint commands on the input, and optionally mirrors ROS2 <code>JointState</code> via rosbridge.</p>
|
|
91
|
+
<h3>Inputs</h3>
|
|
92
|
+
<dl class="message-properties">
|
|
93
|
+
<dt>payload <span class="property-type">object | array</span></dt>
|
|
94
|
+
<dd>Canonical <code>{ joints, unit, source }</code>, ROS JointState-like <code>{ name, position }</code>, flat joint map, or positional array.</dd>
|
|
95
|
+
</dl>
|
|
96
|
+
<h3>Outputs</h3>
|
|
97
|
+
<dl class="message-properties">
|
|
98
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
99
|
+
<dd>Current joint state: <code>{ robot, joints, unit, source, ts }</code>.</dd>
|
|
100
|
+
</dl>
|
|
101
|
+
<h3>3D view</h3>
|
|
102
|
+
<p>Self-contained viewer at <code>/robot-arm/view/<id></code>. Dashboard 2 users can embed via <b>ui-ur5e-arm</b>.</p>
|
|
103
|
+
<h3>Priority</h3>
|
|
104
|
+
<p>Last-writer wins with source tags. Pendant/controller with manual armed briefly preferred over rosbridge (~2 s).</p>
|
|
105
|
+
<h3>Safety</h3>
|
|
106
|
+
<p><b>NOT SIL-rated.</b> Simulation / soft UI only. Do not use as a safety controller for real hardware.</p>
|
|
107
|
+
<h3>More</h3>
|
|
108
|
+
<p><a href="https://github.com/t-teja/node-red-contrib-robot-arm#readme" target="_blank">Usage guide on GitHub</a></p>
|
|
109
|
+
</script>
|
package/nodes/robot.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { DEFAULT_JOINT_NAMES } = require('../lib/normalize');
|
|
6
|
+
const { RosbridgeClient } = require('../lib/rosbridge');
|
|
7
|
+
const { RobotRuntime } = require('../lib/robot-runtime');
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
10
|
+
const MODEL_PRESETS = {
|
|
11
|
+
ur5e: {
|
|
12
|
+
dir: path.join(PKG_ROOT, 'models', 'ur5e'),
|
|
13
|
+
urdf: 'ur5e.urdf',
|
|
14
|
+
jointsMeta: 'joints.json'
|
|
15
|
+
},
|
|
16
|
+
'ur5e-gripper': {
|
|
17
|
+
dir: path.join(PKG_ROOT, 'models', 'ur5e-gripper'),
|
|
18
|
+
urdf: 'ur5e_gripper.urdf',
|
|
19
|
+
jointsMeta: 'joints.json'
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, object>} */
|
|
24
|
+
const instances = new Map();
|
|
25
|
+
|
|
26
|
+
function listRobotInstances() {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const [id, inst] of instances.entries()) {
|
|
29
|
+
out.push({
|
|
30
|
+
id,
|
|
31
|
+
name: inst.name || '',
|
|
32
|
+
robotTag: inst.robotTag || 'robot',
|
|
33
|
+
modelPreset: inst.modelPreset,
|
|
34
|
+
viewUrl: `/robot-arm/view/${id}`,
|
|
35
|
+
pendantUrl: `/robot-arm/pendant/${id}`
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getInstance(id) {
|
|
42
|
+
return instances.get(id) || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = function (RED) {
|
|
46
|
+
let httpMounted = false;
|
|
47
|
+
|
|
48
|
+
function ensureHttpRoutes() {
|
|
49
|
+
if (httpMounted) return;
|
|
50
|
+
httpMounted = true;
|
|
51
|
+
|
|
52
|
+
const express = RED.httpNode;
|
|
53
|
+
const resourcesDir = path.join(PKG_ROOT, 'resources');
|
|
54
|
+
const modelsDir = path.join(PKG_ROOT, 'models');
|
|
55
|
+
|
|
56
|
+
function allowEmbed(res) {
|
|
57
|
+
// Permit same-origin Dashboard 2 iframes; do not set X-Frame-Options: DENY.
|
|
58
|
+
res.removeHeader('X-Frame-Options');
|
|
59
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
express.get('/robot-arm/static/:file', function (req, res) {
|
|
63
|
+
const file = path.basename(req.params.file);
|
|
64
|
+
const full = path.join(resourcesDir, file);
|
|
65
|
+
if (!full.startsWith(resourcesDir) || !fs.existsSync(full)) {
|
|
66
|
+
res.status(404).send('Not found');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.sendFile(full);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Nested model assets: /robot-arm/models/ur5e/ur5e.urdf and .../meshes/base.stl
|
|
73
|
+
express.get(/^\/robot-arm\/models\/([^/]+)\/(.+)$/, function (req, res) {
|
|
74
|
+
const preset = path.basename(req.params[0]);
|
|
75
|
+
const rel = String(req.params[1] || '').replace(/^\/+/, '');
|
|
76
|
+
if (!rel || rel.split('/').some((p) => p === '..')) {
|
|
77
|
+
res.status(400).send('Bad path');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const base = path.join(modelsDir, preset);
|
|
81
|
+
const full = path.normalize(path.join(base, rel));
|
|
82
|
+
if (!full.startsWith(base) || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
|
|
83
|
+
res.status(404).send('Not found');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (/\.urdf$/i.test(rel) || /\.xml$/i.test(rel)) {
|
|
87
|
+
res.type('application/xml');
|
|
88
|
+
} else if (/\.json$/i.test(rel)) {
|
|
89
|
+
res.type('application/json');
|
|
90
|
+
} else if (/\.stl$/i.test(rel)) {
|
|
91
|
+
res.type('model/stl');
|
|
92
|
+
}
|
|
93
|
+
res.sendFile(full);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
express.get('/robot-arm/view/:id', function (req, res) {
|
|
97
|
+
const id = req.params.id;
|
|
98
|
+
const inst = instances.get(id);
|
|
99
|
+
if (!inst) {
|
|
100
|
+
res.status(404).send('Unknown robot node id. Deploy the flow first.');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const htmlPath = path.join(resourcesDir, 'viewer.html');
|
|
104
|
+
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
105
|
+
const preset = MODEL_PRESETS[inst.modelPreset] || MODEL_PRESETS.ur5e;
|
|
106
|
+
html = html
|
|
107
|
+
.replace(/__NODE_ID__/g, id)
|
|
108
|
+
.replace(/__PRESET__/g, inst.modelPreset || 'ur5e')
|
|
109
|
+
.replace(/__URDF__/g, preset.urdf)
|
|
110
|
+
.replace(/__UNIT__/g, inst.unit || 'rad');
|
|
111
|
+
allowEmbed(res);
|
|
112
|
+
res.type('html').send(html);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Robot-bound teach pendant (used by Dashboard 2 ui-ur5e-controller iframe)
|
|
116
|
+
express.get('/robot-arm/pendant/:id', function (req, res) {
|
|
117
|
+
const id = req.params.id;
|
|
118
|
+
const inst = instances.get(id);
|
|
119
|
+
if (!inst) {
|
|
120
|
+
res.status(404).send('Unknown robot node id. Deploy the flow first.');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const htmlPath = path.join(resourcesDir, 'pendant.html');
|
|
124
|
+
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
125
|
+
html = html
|
|
126
|
+
.replace(/__NODE_ID__/g, id)
|
|
127
|
+
.replace(/__UNIT__/g, inst.unit || 'rad')
|
|
128
|
+
.replace(/__ROBOT__/g, inst.robotTag || 'robot');
|
|
129
|
+
allowEmbed(res);
|
|
130
|
+
res.type('html').send(html);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
express.get('/robot-arm/api/robots', function (req, res) {
|
|
134
|
+
res.json({ robots: listRobotInstances() });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
express.get('/robot-arm/api/:id/state', function (req, res) {
|
|
138
|
+
const id = req.params.id;
|
|
139
|
+
const inst = instances.get(id);
|
|
140
|
+
if (!inst) {
|
|
141
|
+
res.status(404).json({ error: 'unknown' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const wantStream = req.query.stream === '1' || (req.headers.accept || '').includes('text/event-stream');
|
|
145
|
+
if (wantStream) {
|
|
146
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
147
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
148
|
+
res.setHeader('Connection', 'keep-alive');
|
|
149
|
+
res.flushHeaders && res.flushHeaders();
|
|
150
|
+
const send = () => {
|
|
151
|
+
res.write(`data: ${JSON.stringify(inst.getPublicState())}\n\n`);
|
|
152
|
+
};
|
|
153
|
+
send();
|
|
154
|
+
const onUpdate = () => send();
|
|
155
|
+
inst.clients.add(onUpdate);
|
|
156
|
+
req.on('close', () => {
|
|
157
|
+
inst.clients.delete(onUpdate);
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
res.json(inst.getPublicState());
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function readJson(req, cb) {
|
|
165
|
+
if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
|
|
166
|
+
cb(null, req.body);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let raw = '';
|
|
170
|
+
req.setEncoding('utf8');
|
|
171
|
+
req.on('data', (c) => { raw += c; });
|
|
172
|
+
req.on('end', () => {
|
|
173
|
+
if (!raw) return cb(null, {});
|
|
174
|
+
try { cb(null, JSON.parse(raw)); }
|
|
175
|
+
catch (e) { cb(e); }
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
express.post('/robot-arm/api/:id/joints', function (req, res) {
|
|
180
|
+
const id = req.params.id;
|
|
181
|
+
const inst = instances.get(id);
|
|
182
|
+
if (!inst) {
|
|
183
|
+
res.status(404).json({ error: 'unknown' });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
readJson(req, (err, body) => {
|
|
187
|
+
if (err || !body || typeof body !== 'object') {
|
|
188
|
+
res.status(400).json({ error: 'body required' });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
inst.applyCommand(body, body.source || 'http');
|
|
192
|
+
res.json({ ok: true });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function RobotNode(config) {
|
|
198
|
+
RED.nodes.createNode(this, config);
|
|
199
|
+
const node = this;
|
|
200
|
+
|
|
201
|
+
ensureHttpRoutes();
|
|
202
|
+
|
|
203
|
+
node.name = config.name || '';
|
|
204
|
+
node.modelPreset = config.modelPreset || 'ur5e';
|
|
205
|
+
if (!MODEL_PRESETS[node.modelPreset]) node.modelPreset = 'ur5e';
|
|
206
|
+
node.urdfPath = (config.urdfPath || '').trim();
|
|
207
|
+
node.unit = config.unit === 'deg' ? 'deg' : 'rad';
|
|
208
|
+
node.rosbridgeEnable = !!config.rosbridgeEnable;
|
|
209
|
+
node.rosbridgeUrl = (config.rosbridgeUrl || 'ws://localhost:9090').trim();
|
|
210
|
+
node.jointStatesTopic = (config.jointStatesTopic || '/joint_states').trim();
|
|
211
|
+
node.publishTopic = (config.publishTopic || '').trim();
|
|
212
|
+
node.robotTag = (config.robotTag || config.name || 'robot').trim() || 'robot';
|
|
213
|
+
|
|
214
|
+
const preset = MODEL_PRESETS[node.modelPreset] || MODEL_PRESETS.ur5e;
|
|
215
|
+
let jointsMeta = { joints: DEFAULT_JOINT_NAMES.map((n) => ({ name: n })) };
|
|
216
|
+
try {
|
|
217
|
+
const metaPath = path.join(preset.dir, preset.jointsMeta);
|
|
218
|
+
if (fs.existsSync(metaPath)) {
|
|
219
|
+
jointsMeta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
node.warn('Could not load joints meta: ' + err.message);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let ros = null;
|
|
226
|
+
let rosStatus = node.rosbridgeEnable ? 'connecting' : 'disabled';
|
|
227
|
+
|
|
228
|
+
const runtime = new RobotRuntime({
|
|
229
|
+
robotTag: node.robotTag,
|
|
230
|
+
unit: node.unit,
|
|
231
|
+
jointsMeta: jointsMeta.joints || [],
|
|
232
|
+
publishTopic: node.publishTopic,
|
|
233
|
+
getRosbridgeStatus: () => rosStatus,
|
|
234
|
+
onPublish: (topic, type, msg) => {
|
|
235
|
+
if (ros) ros.publish(topic, type, msg);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
function getPublicState() {
|
|
240
|
+
return runtime.getPublicState({
|
|
241
|
+
id: node.id,
|
|
242
|
+
name: node.name,
|
|
243
|
+
modelPreset: node.modelPreset,
|
|
244
|
+
viewUrl: `/robot-arm/view/${node.id}`,
|
|
245
|
+
pendantUrl: `/robot-arm/pendant/${node.id}`,
|
|
246
|
+
urdf: preset.urdf
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function setStatus() {
|
|
251
|
+
const rb = node.rosbridgeEnable ? ` | ros:${rosStatus}` : '';
|
|
252
|
+
node.status({
|
|
253
|
+
fill: rosStatus === 'connected' ? 'green' : (node.rosbridgeEnable ? 'yellow' : 'blue'),
|
|
254
|
+
shape: 'dot',
|
|
255
|
+
text: `view /robot-arm/view/${node.id}${rb}`
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function applyCommand(payload, defaultSource) {
|
|
260
|
+
const result = runtime.applyCommand(payload, defaultSource);
|
|
261
|
+
if (result.frozen) {
|
|
262
|
+
node.send({
|
|
263
|
+
payload: getPublicState(),
|
|
264
|
+
topic: 'status',
|
|
265
|
+
frozen: true
|
|
266
|
+
});
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (!result.applied) return false;
|
|
270
|
+
node.send({ payload: result.out, topic: 'joints' });
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const inst = {
|
|
275
|
+
id: node.id,
|
|
276
|
+
name: node.name,
|
|
277
|
+
robotTag: node.robotTag,
|
|
278
|
+
modelPreset: node.modelPreset,
|
|
279
|
+
unit: node.unit,
|
|
280
|
+
urdf: preset.urdf,
|
|
281
|
+
clients: runtime.clients,
|
|
282
|
+
runtime,
|
|
283
|
+
getPublicState,
|
|
284
|
+
applyCommand
|
|
285
|
+
};
|
|
286
|
+
instances.set(node.id, inst);
|
|
287
|
+
|
|
288
|
+
node.on('input', function (msg, send, done) {
|
|
289
|
+
send = send || node.send.bind(node);
|
|
290
|
+
try {
|
|
291
|
+
const payload = msg.payload;
|
|
292
|
+
if (msg.source && payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
293
|
+
payload.source = payload.source || msg.source;
|
|
294
|
+
}
|
|
295
|
+
applyCommand(payload, msg.source || 'input');
|
|
296
|
+
if (done) done();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (done) done(err);
|
|
299
|
+
else node.error(err, msg);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (node.rosbridgeEnable) {
|
|
304
|
+
ros = new RosbridgeClient({ url: node.rosbridgeUrl });
|
|
305
|
+
ros.on('status', (s) => {
|
|
306
|
+
rosStatus = s;
|
|
307
|
+
setStatus();
|
|
308
|
+
});
|
|
309
|
+
ros.on('error', (err) => {
|
|
310
|
+
node.warn('rosbridge: ' + (err && err.message ? err.message : String(err)));
|
|
311
|
+
});
|
|
312
|
+
if (node.publishTopic) {
|
|
313
|
+
ros.advertise(node.publishTopic, 'sensor_msgs/JointState');
|
|
314
|
+
}
|
|
315
|
+
ros.subscribe(node.jointStatesTopic, 'sensor_msgs/JointState', (rosMsg) => {
|
|
316
|
+
applyCommand({
|
|
317
|
+
name: rosMsg.name,
|
|
318
|
+
position: rosMsg.position,
|
|
319
|
+
source: 'rosbridge',
|
|
320
|
+
unit: 'rad'
|
|
321
|
+
}, 'rosbridge');
|
|
322
|
+
});
|
|
323
|
+
ros.connect();
|
|
324
|
+
rosStatus = 'connecting';
|
|
325
|
+
} else {
|
|
326
|
+
rosStatus = 'disabled';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
setStatus();
|
|
330
|
+
|
|
331
|
+
node.on('close', function (removed, done) {
|
|
332
|
+
instances.delete(node.id);
|
|
333
|
+
if (ros) {
|
|
334
|
+
ros.close();
|
|
335
|
+
ros = null;
|
|
336
|
+
}
|
|
337
|
+
runtime.clients.clear();
|
|
338
|
+
if (done) done();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
RED.nodes.registerType('robot-arm', RobotNode);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
module.exports.MODEL_PRESETS = MODEL_PRESETS;
|
|
346
|
+
module.exports.listRobotInstances = listRobotInstances;
|
|
347
|
+
module.exports.getInstance = getInstance;
|