@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,82 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('ui-ur5e-arm', {
3
+ category: RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.label.category') || 'dashboard 2',
4
+ color: '#3D7EA6',
5
+ defaults: {
6
+ name: { value: '' },
7
+ group: { type: 'ui-group', required: true },
8
+ order: { value: 0 },
9
+ width: {
10
+ value: 6,
11
+ validate: function (v) {
12
+ const width = v || 0;
13
+ const currentGroup = $('#node-input-group').val() || this.group;
14
+ const groupNode = RED.nodes.node(currentGroup);
15
+ const valid = !groupNode || +width <= +groupNode.width;
16
+ $('#node-input-size').toggleClass('input-error', !valid);
17
+ return valid;
18
+ }
19
+ },
20
+ height: { value: 6 },
21
+ robot: { value: '', required: true }
22
+ },
23
+ inputs: 0,
24
+ outputs: 0,
25
+ icon: 'font-awesome/fa-cube',
26
+ paletteLabel: 'ui-ur5e-arm',
27
+ label: function () {
28
+ return this.name || 'ui-ur5e-arm';
29
+ },
30
+ oneditprepare: function () {
31
+ $('#node-input-size').elementSizer({
32
+ width: '#node-input-width',
33
+ height: '#node-input-height',
34
+ group: '#node-input-group'
35
+ });
36
+ const select = $('#node-input-robot');
37
+ const current = this.robot;
38
+ select.empty();
39
+ select.append($('<option></option>').val('').text('— select robot node —'));
40
+ RED.nodes.eachNode(function (n) {
41
+ if (n.type === 'robot-arm') {
42
+ const label = (n.name || 'robot-arm') + ' [' + n.id + ']';
43
+ select.append($('<option></option>').val(n.id).text(label));
44
+ }
45
+ });
46
+ select.val(current || '');
47
+ }
48
+ });
49
+ </script>
50
+
51
+ <script type="text/html" data-template-name="ui-ur5e-arm">
52
+ <div class="form-row">
53
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
54
+ <input type="text" id="node-input-name" placeholder="Name">
55
+ </div>
56
+ <div class="form-row">
57
+ <label for="node-input-group"><i class="fa fa-table"></i> Group</label>
58
+ <input type="text" id="node-input-group">
59
+ </div>
60
+ <div class="form-row">
61
+ <label><i class="fa fa-object-group"></i> Size</label>
62
+ <input type="hidden" id="node-input-width">
63
+ <input type="hidden" id="node-input-height">
64
+ <button class="editor-button" id="node-input-size"></button>
65
+ </div>
66
+ <div class="form-row">
67
+ <label for="node-input-robot"><i class="fa fa-android"></i> Robot</label>
68
+ <select id="node-input-robot"></select>
69
+ </div>
70
+ <div class="form-tips">
71
+ Embeds the 3D URDF viewer for the selected <b>robot</b> node (same-origin iframe of
72
+ <code>/robot-arm/view/&lt;id&gt;</code>). Place on the same Dashboard 2 page as
73
+ <b>ui-ur5e-controller</b>.
74
+ </div>
75
+ </script>
76
+
77
+ <script type="text/html" data-help-name="ui-ur5e-arm">
78
+ <p>Dashboard 2 widget: 3D UR5e viewer bound to a <b>robot-arm</b> palette node.</p>
79
+ <p>Requires <code>@flowfuse/node-red-dashboard</code>. Standalone HTTP view still works without Dashboard.</p>
80
+ <h3>More</h3>
81
+ <p><a href="https://github.com/t-teja/node-red-contrib-robot-arm#readme" target="_blank">Usage guide on GitHub</a></p>
82
+ </script>
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ module.exports = function (RED) {
4
+ function UIRobotArmNode (config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ const group = RED.nodes.getNode(config.group);
8
+
9
+ const evts = {
10
+ onAction: true,
11
+ onInput: function (msg, send, done) {
12
+ send(msg);
13
+ if (done) done();
14
+ }
15
+ };
16
+
17
+ if (group) {
18
+ group.register(node, config, evts);
19
+ } else {
20
+ node.error('No group configured — add ui-ur5e-arm to a Dashboard 2 ui-group');
21
+ }
22
+ }
23
+
24
+ RED.nodes.registerType('ui-ur5e-arm', UIRobotArmNode);
25
+ };
@@ -0,0 +1,81 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('ui-ur5e-controller', {
3
+ category: RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.label.category') || 'dashboard 2',
4
+ color: '#5B8C5A',
5
+ defaults: {
6
+ name: { value: '' },
7
+ group: { type: 'ui-group', required: true },
8
+ order: { value: 0 },
9
+ width: {
10
+ value: 4,
11
+ validate: function (v) {
12
+ const width = v || 0;
13
+ const currentGroup = $('#node-input-group').val() || this.group;
14
+ const groupNode = RED.nodes.node(currentGroup);
15
+ const valid = !groupNode || +width <= +groupNode.width;
16
+ $('#node-input-size').toggleClass('input-error', !valid);
17
+ return valid;
18
+ }
19
+ },
20
+ height: { value: 6 },
21
+ robot: { value: '', required: true }
22
+ },
23
+ inputs: 0,
24
+ outputs: 0,
25
+ icon: 'font-awesome/fa-sliders',
26
+ paletteLabel: 'ui-ur5e-controller',
27
+ label: function () {
28
+ return this.name || 'ui-ur5e-controller';
29
+ },
30
+ oneditprepare: function () {
31
+ $('#node-input-size').elementSizer({
32
+ width: '#node-input-width',
33
+ height: '#node-input-height',
34
+ group: '#node-input-group'
35
+ });
36
+ const select = $('#node-input-robot');
37
+ const current = this.robot;
38
+ select.empty();
39
+ select.append($('<option></option>').val('').text('— select robot node —'));
40
+ RED.nodes.eachNode(function (n) {
41
+ if (n.type === 'robot-arm') {
42
+ const label = (n.name || 'robot-arm') + ' [' + n.id + ']';
43
+ select.append($('<option></option>').val(n.id).text(label));
44
+ }
45
+ });
46
+ select.val(current || '');
47
+ }
48
+ });
49
+ </script>
50
+
51
+ <script type="text/html" data-template-name="ui-ur5e-controller">
52
+ <div class="form-row">
53
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
54
+ <input type="text" id="node-input-name" placeholder="Name">
55
+ </div>
56
+ <div class="form-row">
57
+ <label for="node-input-group"><i class="fa fa-table"></i> Group</label>
58
+ <input type="text" id="node-input-group">
59
+ </div>
60
+ <div class="form-row">
61
+ <label><i class="fa fa-object-group"></i> Size</label>
62
+ <input type="hidden" id="node-input-width">
63
+ <input type="hidden" id="node-input-height">
64
+ <button class="editor-button" id="node-input-size"></button>
65
+ </div>
66
+ <div class="form-row">
67
+ <label for="node-input-robot"><i class="fa fa-android"></i> Robot</label>
68
+ <select id="node-input-robot"></select>
69
+ </div>
70
+ <div class="form-tips">
71
+ Teach pendant bound to the selected <b>robot</b> node (iframe of
72
+ <code>/robot-arm/pendant/&lt;id&gt;</code>). Sends joint commands directly to that robot —
73
+ no separate controller wiring required for Dashboard use.
74
+ </div>
75
+ </script>
76
+
77
+ <script type="text/html" data-help-name="ui-ur5e-controller">
78
+ <p>Dashboard 2 teach-pendant widget bound to a <b>robot-arm</b> node. Soft Freeze is UI-only — not SIL.</p>
79
+ <h3>More</h3>
80
+ <p><a href="https://github.com/t-teja/node-red-contrib-robot-arm#readme" target="_blank">Usage guide on GitHub</a></p>
81
+ </script>
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ module.exports = function (RED) {
4
+ function UIRobotControllerNode (config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ const group = RED.nodes.getNode(config.group);
8
+
9
+ const evts = {
10
+ onAction: true,
11
+ onInput: function (msg, send, done) {
12
+ send(msg);
13
+ if (done) done();
14
+ }
15
+ };
16
+
17
+ if (group) {
18
+ group.register(node, config, evts);
19
+ } else {
20
+ node.error('No group configured — add ui-ur5e-controller to a Dashboard 2 ui-group');
21
+ }
22
+ }
23
+
24
+ RED.nodes.registerType('ui-ur5e-controller', UIRobotControllerNode);
25
+ };
package/package.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "@tteja/node-red-dashboard-2-robot-arm",
3
+ "version": "0.3.0",
4
+ "description": "Node-RED + FlowFuse Dashboard 2 robot arm: realistic UR5e URDF/STL viewer, teach pendant, optional ROS2 rosbridge. Palette: robot-arm, robot-arm-controller, ui-ur5e-arm, ui-ur5e-controller.",
5
+ "author": "Teja",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "node-red",
9
+ "node-red-dashboard-2",
10
+ "robotics",
11
+ "urdf",
12
+ "ros2",
13
+ "three.js",
14
+ "robot-arm",
15
+ "ur5e"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/t-teja/node-red-contrib-robot-arm.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/t-teja/node-red-contrib-robot-arm/issues"
23
+ },
24
+ "homepage": "https://github.com/t-teja/node-red-contrib-robot-arm#readme",
25
+ "files": [
26
+ "nodes/*",
27
+ "lib/*",
28
+ "models/**/*",
29
+ "resources/*",
30
+ "ui/components/*",
31
+ "ui/index.js",
32
+ "examples/*",
33
+ "docs/*",
34
+ "LICENSE",
35
+ "README.md"
36
+ ],
37
+ "node-red": {
38
+ "version": ">=3.0.0",
39
+ "nodes": {
40
+ "robot-arm": "nodes/robot.js",
41
+ "robot-arm-controller": "nodes/controller.js",
42
+ "ui-ur5e-arm": "nodes/ui-robot-arm.js",
43
+ "ui-ur5e-controller": "nodes/ui-robot-controller.js"
44
+ },
45
+ "examples": {
46
+ "Inject demo": "examples/01-inject-demo.json",
47
+ "Controller to robot": "examples/02-controller-to-robot.json",
48
+ "Rosbridge live": "examples/03-rosbridge-live.json",
49
+ "Controller and ROS2": "examples/04-controller-and-ros2.json",
50
+ "Dashboard 2": "examples/05-dashboard2.json"
51
+ }
52
+ },
53
+ "node-red-dashboard-2": {
54
+ "version": "1.0.0",
55
+ "widgets": {
56
+ "ui-ur5e-arm": {
57
+ "output": "robot-arm-widgets.umd.js",
58
+ "component": "UIRobotArm"
59
+ },
60
+ "ui-ur5e-controller": {
61
+ "output": "robot-arm-widgets.umd.js",
62
+ "component": "UIRobotController"
63
+ }
64
+ }
65
+ },
66
+ "dependencies": {
67
+ "three": "^0.186.1",
68
+ "urdf-loader": "^0.13.1",
69
+ "vue": "^3.3.8",
70
+ "vuex": "^4.1.0",
71
+ "ws": "^8.18.0"
72
+ },
73
+ "peerDependencies": {
74
+ "@flowfuse/node-red-dashboard": ">=1.0.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@flowfuse/node-red-dashboard": {
78
+ "optional": true
79
+ }
80
+ },
81
+ "optionalDependencies": {
82
+ "@flowfuse/node-red-dashboard": ">=1.0.0"
83
+ },
84
+ "devDependencies": {
85
+ "@vitejs/plugin-vue": "^5.2.1",
86
+ "vite": "^6.0.0",
87
+ "vite-plugin-css-injected-by-js": "^3.5.2"
88
+ },
89
+ "scripts": {
90
+ "build": "vite build",
91
+ "build:dev": "NODE_ENV=development vite build",
92
+ "test": "node --test test/*.test.js"
93
+ },
94
+ "engines": {
95
+ "node": ">=18"
96
+ }
97
+ }
@@ -0,0 +1,64 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Teach Pendant</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1419; --panel: #1a2332; --border: #2a3548;
10
+ --text: #e7ecf1; --muted: #8b9bb4; --accent: #3d9a6a; --danger: #c44;
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body {
14
+ margin: 0; min-height: 100vh; background: var(--bg); color: var(--text);
15
+ font-family: system-ui, sans-serif; padding: 16px;
16
+ }
17
+ h1 { font-size: 18px; margin: 0 0 4px; }
18
+ .sub { color: var(--muted); font-size: 12px; margin-bottom: 16px; }
19
+ .panel {
20
+ background: var(--panel); border: 1px solid var(--border);
21
+ border-radius: 10px; padding: 14px; max-width: 520px;
22
+ }
23
+ .row {
24
+ display: grid; grid-template-columns: 100px 1fr 64px;
25
+ gap: 8px; align-items: center; margin-bottom: 10px;
26
+ }
27
+ .row label { font-size: 12px; color: var(--muted); }
28
+ .row input[type=range] { width: 100%; }
29
+ .row .val { font-variant-numeric: tabular-nums; font-size: 12px; text-align: right; }
30
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
31
+ button {
32
+ border: 0; border-radius: 8px; padding: 8px 14px; cursor: pointer;
33
+ background: #2a3f5f; color: var(--text); font-weight: 600; font-size: 13px;
34
+ }
35
+ button.primary { background: var(--accent); color: #062016; }
36
+ button.danger { background: var(--danger); }
37
+ button.armed { background: #2d6a4f; }
38
+ button.disarmed { background: #5c4d1a; }
39
+ .status { margin-top: 12px; font-size: 12px; color: var(--muted); }
40
+ .status.frozen { color: #f88; font-weight: 700; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <h1>Teach Pendant</h1>
45
+ <div class="sub">robot: __ROBOT__ · unit: __UNIT__ · node __NODE_ID__</div>
46
+ <div class="panel">
47
+ <div id="sliders"></div>
48
+ <div class="actions">
49
+ <button class="primary" id="btn-home">Home</button>
50
+ <button class="danger" id="btn-freeze">Freeze</button>
51
+ <button class="armed" id="btn-arm">Armed</button>
52
+ </div>
53
+ <div class="status" id="status">Ready</div>
54
+ </div>
55
+ <script>
56
+ window.__PENDANT__ = {
57
+ nodeId: '__NODE_ID__',
58
+ unit: '__UNIT__',
59
+ robot: '__ROBOT__'
60
+ };
61
+ </script>
62
+ <script src="/robot-arm/static/controller.js"></script>
63
+ </body>
64
+ </html>
@@ -0,0 +1,114 @@
1
+ (function () {
2
+ const cfg = window.__PENDANT__ || {};
3
+ const nodeId = cfg.nodeId;
4
+ const slidersEl = document.getElementById('sliders');
5
+ const statusEl = document.getElementById('status');
6
+ const btnHome = document.getElementById('btn-home');
7
+ const btnFreeze = document.getElementById('btn-freeze');
8
+ const btnArm = document.getElementById('btn-arm');
9
+
10
+ let meta = null;
11
+ let frozen = false;
12
+ let manualArmed = true;
13
+ let joints = {};
14
+ let timer = null;
15
+
16
+ async function loadMeta() {
17
+ const r = await fetch(`/robot-arm/api/controller/${encodeURIComponent(nodeId)}/meta`);
18
+ if (!r.ok) throw new Error('meta ' + r.status);
19
+ meta = await r.json();
20
+ joints = Object.assign({}, meta.current || {});
21
+ frozen = !!meta.frozen;
22
+ manualArmed = meta.manualArmed !== false;
23
+ render();
24
+ updateButtons();
25
+ }
26
+
27
+ function render() {
28
+ slidersEl.innerHTML = '';
29
+ (meta.joints || []).forEach((j) => {
30
+ if (j.name === 'finger_joint_right') return;
31
+ const row = document.createElement('div');
32
+ row.className = 'row';
33
+ const label = document.createElement('label');
34
+ label.textContent = j.label || j.name;
35
+ const input = document.createElement('input');
36
+ input.type = 'range';
37
+ input.min = j.min;
38
+ input.max = j.max;
39
+ input.step = meta.stepSize || 0.01;
40
+ input.value = joints[j.name] != null ? joints[j.name] : (j.home || 0);
41
+ const val = document.createElement('div');
42
+ val.className = 'val';
43
+ const fmt = (n) => (j.unit === 'm' ? Number(n).toFixed(3) : Number(n).toFixed(3));
44
+ val.textContent = fmt(input.value);
45
+ input.addEventListener('input', () => {
46
+ joints[j.name] = Number(input.value);
47
+ val.textContent = fmt(input.value);
48
+ scheduleSend();
49
+ });
50
+ row.appendChild(label);
51
+ row.appendChild(input);
52
+ row.appendChild(val);
53
+ slidersEl.appendChild(row);
54
+ });
55
+ }
56
+
57
+ function updateButtons() {
58
+ btnFreeze.textContent = frozen ? 'UNFREEZE' : 'Freeze';
59
+ btnArm.textContent = manualArmed ? 'Armed' : 'Disarmed';
60
+ btnArm.className = manualArmed ? 'armed' : 'disarmed';
61
+ statusEl.textContent = frozen ? 'SOFT E-STOP (freeze) — commands still tagged freeze' : 'Ready';
62
+ statusEl.className = 'status' + (frozen ? ' frozen' : '');
63
+ }
64
+
65
+ async function send(extra) {
66
+ const body = Object.assign({
67
+ joints,
68
+ freeze: frozen,
69
+ manualArmed,
70
+ force: !!(extra && extra.force)
71
+ }, extra || {});
72
+ try {
73
+ await fetch(`/robot-arm/api/controller/${encodeURIComponent(nodeId)}/command`, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify(body)
77
+ });
78
+ } catch (e) {
79
+ statusEl.textContent = 'Send failed: ' + e.message;
80
+ }
81
+ }
82
+
83
+ function scheduleSend() {
84
+ if (timer) return;
85
+ timer = setTimeout(() => {
86
+ timer = null;
87
+ send();
88
+ }, 50);
89
+ }
90
+
91
+ btnHome.addEventListener('click', () => {
92
+ (meta.joints || []).forEach((j) => {
93
+ joints[j.name] = typeof j.home === 'number' ? j.home : 0;
94
+ });
95
+ render();
96
+ send({ home: true, force: true });
97
+ });
98
+
99
+ btnFreeze.addEventListener('click', () => {
100
+ frozen = !frozen;
101
+ updateButtons();
102
+ send({ freeze: frozen, force: true });
103
+ });
104
+
105
+ btnArm.addEventListener('click', () => {
106
+ manualArmed = !manualArmed;
107
+ updateButtons();
108
+ send({ manualArmed, force: true });
109
+ });
110
+
111
+ loadMeta().catch((e) => {
112
+ statusEl.textContent = 'Failed to load: ' + e.message;
113
+ });
114
+ })();
@@ -0,0 +1,66 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Teach Pendant</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1419; --panel: #1a2332; --border: #2a3548;
10
+ --text: #e7ecf1; --muted: #8b9bb4; --accent: #3d9a6a; --danger: #c44;
11
+ }
12
+ * { box-sizing: border-box; }
13
+ html, body { margin: 0; height: 100%; }
14
+ body {
15
+ background: var(--bg); color: var(--text);
16
+ font-family: system-ui, sans-serif; padding: 12px;
17
+ }
18
+ h1 { font-size: 16px; margin: 0 0 4px; }
19
+ .sub { color: var(--muted); font-size: 11px; margin-bottom: 12px; }
20
+ .panel {
21
+ background: var(--panel); border: 1px solid var(--border);
22
+ border-radius: 10px; padding: 12px;
23
+ }
24
+ .row {
25
+ display: grid; grid-template-columns: 90px 1fr 56px;
26
+ gap: 8px; align-items: center; margin-bottom: 8px;
27
+ }
28
+ .row label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
29
+ .row input[type=range] { width: 100%; }
30
+ .row .val { font-variant-numeric: tabular-nums; font-size: 11px; text-align: right; }
31
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
32
+ button {
33
+ border: 0; border-radius: 8px; padding: 8px 12px; cursor: pointer;
34
+ background: #2a3f5f; color: var(--text); font-weight: 600; font-size: 12px;
35
+ }
36
+ button.primary { background: var(--accent); color: #062016; }
37
+ button.danger { background: var(--danger); }
38
+ button.armed { background: #2d6a4f; }
39
+ button.disarmed { background: #5c4d1a; }
40
+ .status { margin-top: 10px; font-size: 11px; color: var(--muted); }
41
+ .status.frozen { color: #f88; font-weight: 700; }
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <h1>Teach Pendant</h1>
46
+ <div class="sub">robot: __ROBOT__ · unit: __UNIT__ · robot node __NODE_ID__</div>
47
+ <div class="panel">
48
+ <div id="sliders"></div>
49
+ <div class="actions">
50
+ <button class="primary" id="btn-home">Home</button>
51
+ <button class="danger" id="btn-freeze">Freeze</button>
52
+ <button class="armed" id="btn-arm">Armed</button>
53
+ </div>
54
+ <div class="status" id="status">Ready</div>
55
+ </div>
56
+ <script>
57
+ window.__PENDANT__ = {
58
+ mode: 'robot',
59
+ nodeId: '__NODE_ID__',
60
+ unit: '__UNIT__',
61
+ robot: '__ROBOT__'
62
+ };
63
+ </script>
64
+ <script src="/robot-arm/static/pendant.js"></script>
65
+ </body>
66
+ </html>
@@ -0,0 +1,129 @@
1
+ (function () {
2
+ const cfg = window.__PENDANT__ || {};
3
+ const robotId = cfg.nodeId;
4
+ const slidersEl = document.getElementById('sliders');
5
+ const statusEl = document.getElementById('status');
6
+ const btnHome = document.getElementById('btn-home');
7
+ const btnFreeze = document.getElementById('btn-freeze');
8
+ const btnArm = document.getElementById('btn-arm');
9
+
10
+ let metaJoints = [];
11
+ let frozen = false;
12
+ let manualArmed = true;
13
+ let joints = {};
14
+ let unit = cfg.unit || 'rad';
15
+ let timer = null;
16
+ let stepSize = 0.01;
17
+
18
+ async function loadState() {
19
+ const r = await fetch(`/robot-arm/api/${encodeURIComponent(robotId)}/state`);
20
+ if (!r.ok) throw new Error('state ' + r.status);
21
+ const data = await r.json();
22
+ metaJoints = (data.jointsMeta || []).filter((j) => j.name !== 'finger_joint_right');
23
+ unit = data.unit || unit;
24
+ joints = Object.assign({}, data.joints || {});
25
+ for (const j of metaJoints) {
26
+ if (joints[j.name] == null) joints[j.name] = typeof j.home === 'number' ? j.home : 0;
27
+ }
28
+ render();
29
+ updateButtons();
30
+ }
31
+
32
+ function render() {
33
+ slidersEl.innerHTML = '';
34
+ metaJoints.forEach((j) => {
35
+ const row = document.createElement('div');
36
+ row.className = 'row';
37
+ const label = document.createElement('label');
38
+ label.textContent = j.label || j.name;
39
+ const input = document.createElement('input');
40
+ input.type = 'range';
41
+ let min = j.min != null ? j.min : -3.14;
42
+ let max = j.max != null ? j.max : 3.14;
43
+ let home = typeof j.home === 'number' ? j.home : 0;
44
+ if (unit === 'deg' && j.unit !== 'm') {
45
+ const f = 180 / Math.PI;
46
+ min *= f; max *= f; home *= f;
47
+ }
48
+ input.min = min;
49
+ input.max = max;
50
+ input.step = j.unit === 'm' ? 0.001 : stepSize;
51
+ const cur = joints[j.name] != null ? joints[j.name] : home;
52
+ input.value = cur;
53
+ const val = document.createElement('div');
54
+ val.className = 'val';
55
+ const fmt = (n) => Number(n).toFixed(j.unit === 'm' ? 3 : 3);
56
+ val.textContent = fmt(input.value);
57
+ input.addEventListener('input', () => {
58
+ joints[j.name] = Number(input.value);
59
+ val.textContent = fmt(input.value);
60
+ scheduleSend();
61
+ });
62
+ row.appendChild(label);
63
+ row.appendChild(input);
64
+ row.appendChild(val);
65
+ slidersEl.appendChild(row);
66
+ });
67
+ }
68
+
69
+ function updateButtons() {
70
+ btnFreeze.textContent = frozen ? 'UNFREEZE' : 'Freeze';
71
+ btnArm.textContent = manualArmed ? 'Armed' : 'Disarmed';
72
+ btnArm.className = manualArmed ? 'armed' : 'disarmed';
73
+ statusEl.textContent = frozen ? 'SOFT E-STOP (freeze) — UI only, not SIL' : 'Ready';
74
+ statusEl.className = 'status' + (frozen ? ' frozen' : '');
75
+ }
76
+
77
+ async function send(extra) {
78
+ const body = Object.assign({
79
+ joints,
80
+ unit,
81
+ source: 'pendant',
82
+ freeze: frozen,
83
+ manualArmed
84
+ }, extra || {});
85
+ try {
86
+ await fetch(`/robot-arm/api/${encodeURIComponent(robotId)}/joints`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify(body)
90
+ });
91
+ } catch (e) {
92
+ statusEl.textContent = 'Send failed: ' + e.message;
93
+ }
94
+ }
95
+
96
+ function scheduleSend() {
97
+ if (timer) return;
98
+ timer = setTimeout(() => {
99
+ timer = null;
100
+ send();
101
+ }, 50);
102
+ }
103
+
104
+ btnHome.addEventListener('click', () => {
105
+ metaJoints.forEach((j) => {
106
+ let home = typeof j.home === 'number' ? j.home : 0;
107
+ if (unit === 'deg' && j.unit !== 'm') home = home * 180 / Math.PI;
108
+ joints[j.name] = home;
109
+ });
110
+ render();
111
+ send({ force: true });
112
+ });
113
+
114
+ btnFreeze.addEventListener('click', () => {
115
+ frozen = !frozen;
116
+ updateButtons();
117
+ send({ freeze: frozen, force: true });
118
+ });
119
+
120
+ btnArm.addEventListener('click', () => {
121
+ manualArmed = !manualArmed;
122
+ updateButtons();
123
+ send({ manualArmed, force: true });
124
+ });
125
+
126
+ loadState().catch((e) => {
127
+ statusEl.textContent = 'Failed to load: ' + e.message;
128
+ });
129
+ })();