@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/docs/ARCHITECTURE.md +51 -0
  4. package/examples/01-inject-demo.json +133 -0
  5. package/examples/02-controller-to-robot.json +72 -0
  6. package/examples/03-rosbridge-live.json +54 -0
  7. package/examples/04-controller-and-ros2.json +72 -0
  8. package/examples/05-dashboard2.json +143 -0
  9. package/lib/normalize.js +146 -0
  10. package/lib/robot-runtime.js +144 -0
  11. package/lib/rosbridge.js +210 -0
  12. package/models/ur5e/LICENSE +33 -0
  13. package/models/ur5e/README.md +15 -0
  14. package/models/ur5e/joints.json +12 -0
  15. package/models/ur5e/meshes/base.stl +0 -0
  16. package/models/ur5e/meshes/forearm.stl +0 -0
  17. package/models/ur5e/meshes/shoulder.stl +0 -0
  18. package/models/ur5e/meshes/upperarm.stl +0 -0
  19. package/models/ur5e/meshes/wrist1.stl +0 -0
  20. package/models/ur5e/meshes/wrist2.stl +0 -0
  21. package/models/ur5e/meshes/wrist3.stl +0 -0
  22. package/models/ur5e/ur5e.urdf +138 -0
  23. package/models/ur5e-gripper/LICENSE +33 -0
  24. package/models/ur5e-gripper/README-ur5e.md +15 -0
  25. package/models/ur5e-gripper/README.md +6 -0
  26. package/models/ur5e-gripper/joints.json +13 -0
  27. package/models/ur5e-gripper/meshes/base.stl +0 -0
  28. package/models/ur5e-gripper/meshes/forearm.stl +0 -0
  29. package/models/ur5e-gripper/meshes/shoulder.stl +0 -0
  30. package/models/ur5e-gripper/meshes/upperarm.stl +0 -0
  31. package/models/ur5e-gripper/meshes/wrist1.stl +0 -0
  32. package/models/ur5e-gripper/meshes/wrist2.stl +0 -0
  33. package/models/ur5e-gripper/meshes/wrist3.stl +0 -0
  34. package/models/ur5e-gripper/ur5e_gripper.urdf +182 -0
  35. package/nodes/controller.html +76 -0
  36. package/nodes/controller.js +237 -0
  37. package/nodes/robot.html +109 -0
  38. package/nodes/robot.js +347 -0
  39. package/nodes/ui-robot-arm.html +82 -0
  40. package/nodes/ui-robot-arm.js +25 -0
  41. package/nodes/ui-robot-controller.html +81 -0
  42. package/nodes/ui-robot-controller.js +25 -0
  43. package/package.json +97 -0
  44. package/resources/controller.html +64 -0
  45. package/resources/controller.js +114 -0
  46. package/resources/pendant.html +66 -0
  47. package/resources/pendant.js +129 -0
  48. package/resources/robot-arm-widgets.umd.js +2 -0
  49. package/resources/viewer.html +54 -0
  50. package/resources/viewer.js +195 -0
  51. package/ui/components/UIRobotArm.vue +63 -0
  52. package/ui/components/UIRobotController.vue +62 -0
  53. 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
+ };
@@ -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/&lt;node-id&gt;</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/&lt;id&gt;</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;