@agenticros/eyes 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 +25 -0
- package/package.json +49 -0
- package/public/eyes.js +246 -0
- package/public/index.html +14 -0
- package/public/style.css +22 -0
- package/public/teleop.js +102 -0
- package/src/index.js +410 -0
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @agenticros/eyes
|
|
2
|
+
|
|
3
|
+
Fullscreen robot eyes for an Ubuntu tablet or robot display, driven by ROS 2 `cmd_vel` (`geometry_msgs/Twist`).
|
|
4
|
+
|
|
5
|
+
Part of the [AgenticROS](https://github.com/agenticros/agenticros) monorepo. Prefer launching via the CLI:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
agenticros eyes
|
|
9
|
+
agenticros eyes --no-browser
|
|
10
|
+
agenticros eyes --no-teleop # gaze only (no WASD publish)
|
|
11
|
+
agenticros up real --eyes # start eyes after the real-robot stack
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
See [docs/eyes.md](../../docs/eyes.md) for setup, config, and keyboard teleop.
|
|
15
|
+
|
|
16
|
+
## Direct run (development)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
source /opt/ros/jazzy/setup.bash
|
|
20
|
+
cd packages/robot-eyes
|
|
21
|
+
pnpm install # from monorepo root is preferred
|
|
22
|
+
pnpm start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Node 18+, ROS 2, and a graphical display (`DISPLAY`) for kiosk mode.
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agenticros/eyes",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fullscreen robot eyes for tablet/display devices, driven by ROS 2 cmd_vel Twist",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agenticros-eyes": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"public",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "node -e \"process.exit(0)\"",
|
|
17
|
+
"typecheck": "node -e \"process.exit(0)\"",
|
|
18
|
+
"start": "node src/index.js",
|
|
19
|
+
"start:no-browser": "node src/index.js --no-browser",
|
|
20
|
+
"test": "node -e \"process.exit(0)\""
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"ws": "^8.18.0"
|
|
27
|
+
},
|
|
28
|
+
"optionalDependencies": {
|
|
29
|
+
"rclnodejs": "^1.9.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"agenticros",
|
|
33
|
+
"ros2",
|
|
34
|
+
"robot",
|
|
35
|
+
"eyes",
|
|
36
|
+
"cmd_vel",
|
|
37
|
+
"teleop"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/agenticros/agenticros.git",
|
|
45
|
+
"directory": "packages/robot-eyes"
|
|
46
|
+
},
|
|
47
|
+
"author": "PlaiPin",
|
|
48
|
+
"license": "MIT"
|
|
49
|
+
}
|
package/public/eyes.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const canvas = document.getElementById('eyes');
|
|
2
|
+
const ctx = canvas.getContext('2d');
|
|
3
|
+
|
|
4
|
+
const state = {
|
|
5
|
+
// Target from ROS (+/-1); idle offsets applied on top when not driving
|
|
6
|
+
rosGazeX: 0,
|
|
7
|
+
driving: false,
|
|
8
|
+
// Smoothed pupil position in [-1, 1]
|
|
9
|
+
gazeX: 0,
|
|
10
|
+
gazeY: 0,
|
|
11
|
+
// Idle wander target
|
|
12
|
+
idleX: 0,
|
|
13
|
+
idleY: 0,
|
|
14
|
+
nextIdleAt: 0,
|
|
15
|
+
idleHoldUntil: 0,
|
|
16
|
+
// Blink: 0 open → 1 closed
|
|
17
|
+
blink: 0,
|
|
18
|
+
blinkPhase: 'open', // open | closing | closed | opening
|
|
19
|
+
nextBlinkAt: 0,
|
|
20
|
+
blinkHoldUntil: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const BLINK_CLOSE_MS = 90;
|
|
24
|
+
const BLINK_OPEN_MS = 110;
|
|
25
|
+
const BLINK_HOLD_MS = 40;
|
|
26
|
+
const GAZE_LERP = 0.12;
|
|
27
|
+
const IDLE_LERP = 0.04;
|
|
28
|
+
|
|
29
|
+
function rand(min, max) {
|
|
30
|
+
return min + Math.random() * (max - min);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scheduleBlink(now) {
|
|
34
|
+
state.nextBlinkAt = now + rand(2200, 5200);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scheduleIdle(now) {
|
|
38
|
+
state.nextIdleAt = now + rand(2500, 6000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
scheduleBlink(performance.now());
|
|
42
|
+
scheduleIdle(performance.now());
|
|
43
|
+
|
|
44
|
+
function connectWs() {
|
|
45
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
46
|
+
const ws = new WebSocket(`${proto}://${location.host}`);
|
|
47
|
+
|
|
48
|
+
ws.addEventListener('message', (ev) => {
|
|
49
|
+
try {
|
|
50
|
+
const msg = JSON.parse(ev.data);
|
|
51
|
+
if (msg.type !== 'gaze') return;
|
|
52
|
+
state.rosGazeX = Number(msg.gazeX) || 0;
|
|
53
|
+
state.driving = Boolean(msg.driving);
|
|
54
|
+
if (state.driving) {
|
|
55
|
+
state.idleX = 0;
|
|
56
|
+
state.idleY = 0;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore malformed
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
ws.addEventListener('close', () => {
|
|
64
|
+
setTimeout(connectWs, 1000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
ws.addEventListener('error', () => {
|
|
68
|
+
ws.close();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function enterFullscreen() {
|
|
73
|
+
const el = document.documentElement;
|
|
74
|
+
if (document.fullscreenElement) return;
|
|
75
|
+
try {
|
|
76
|
+
if (el.requestFullscreen) await el.requestFullscreen();
|
|
77
|
+
} catch {
|
|
78
|
+
// Kiosk browsers often already cover the screen
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
document.addEventListener('click', () => {
|
|
83
|
+
enterFullscreen();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
document.addEventListener('keydown', (e) => {
|
|
87
|
+
if (e.key === 'Escape') {
|
|
88
|
+
// Allow escape while developing; kiosk may ignore it
|
|
89
|
+
window.close();
|
|
90
|
+
}
|
|
91
|
+
if (e.key === 'f' || e.key === 'F') {
|
|
92
|
+
enterFullscreen();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function resize() {
|
|
97
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
98
|
+
canvas.width = Math.floor(window.innerWidth * dpr);
|
|
99
|
+
canvas.height = Math.floor(window.innerHeight * dpr);
|
|
100
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
window.addEventListener('resize', resize);
|
|
104
|
+
resize();
|
|
105
|
+
|
|
106
|
+
function updateBlink(now, dt) {
|
|
107
|
+
if (state.blinkPhase === 'open') {
|
|
108
|
+
if (now >= state.nextBlinkAt) {
|
|
109
|
+
state.blinkPhase = 'closing';
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (state.blinkPhase === 'closing') {
|
|
115
|
+
state.blink = Math.min(1, state.blink + dt / BLINK_CLOSE_MS);
|
|
116
|
+
if (state.blink >= 1) {
|
|
117
|
+
state.blink = 1;
|
|
118
|
+
state.blinkPhase = 'closed';
|
|
119
|
+
state.blinkHoldUntil = now + BLINK_HOLD_MS;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state.blinkPhase === 'closed') {
|
|
125
|
+
if (now >= state.blinkHoldUntil) {
|
|
126
|
+
state.blinkPhase = 'opening';
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (state.blinkPhase === 'opening') {
|
|
132
|
+
state.blink = Math.max(0, state.blink - dt / BLINK_OPEN_MS);
|
|
133
|
+
if (state.blink <= 0) {
|
|
134
|
+
state.blink = 0;
|
|
135
|
+
state.blinkPhase = 'open';
|
|
136
|
+
scheduleBlink(now);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function updateIdle(now) {
|
|
142
|
+
if (state.driving) {
|
|
143
|
+
state.idleX = 0;
|
|
144
|
+
state.idleY = 0;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (now >= state.nextIdleAt && now >= state.idleHoldUntil) {
|
|
149
|
+
// Sometimes glance around, sometimes return to center
|
|
150
|
+
if (Math.random() < 0.35) {
|
|
151
|
+
state.idleX = 0;
|
|
152
|
+
state.idleY = 0;
|
|
153
|
+
} else {
|
|
154
|
+
state.idleX = rand(-0.45, 0.45);
|
|
155
|
+
state.idleY = rand(-0.25, 0.25);
|
|
156
|
+
}
|
|
157
|
+
state.idleHoldUntil = now + rand(600, 1800);
|
|
158
|
+
scheduleIdle(now);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updateGaze() {
|
|
163
|
+
const targetX = state.driving ? state.rosGazeX : state.idleX;
|
|
164
|
+
const targetY = state.driving ? 0 : state.idleY;
|
|
165
|
+
const lerp = state.driving ? GAZE_LERP : IDLE_LERP;
|
|
166
|
+
state.gazeX += (targetX - state.gazeX) * lerp;
|
|
167
|
+
state.gazeY += (targetY - state.gazeY) * lerp;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function drawEye(cx, cy, eyeW, eyeH, gazeX, gazeY, blink) {
|
|
171
|
+
const rx = eyeW / 2;
|
|
172
|
+
const ry = eyeH / 2;
|
|
173
|
+
|
|
174
|
+
// sclera
|
|
175
|
+
ctx.beginPath();
|
|
176
|
+
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
177
|
+
ctx.fillStyle = '#f5f5f5';
|
|
178
|
+
ctx.fill();
|
|
179
|
+
|
|
180
|
+
// iris + pupil
|
|
181
|
+
const maxOffsetX = rx * 0.32;
|
|
182
|
+
const maxOffsetY = ry * 0.28;
|
|
183
|
+
const px = cx + gazeX * maxOffsetX;
|
|
184
|
+
const py = cy + gazeY * maxOffsetY;
|
|
185
|
+
const irisR = Math.min(rx, ry) * 0.42;
|
|
186
|
+
const pupilR = irisR * 0.55;
|
|
187
|
+
|
|
188
|
+
ctx.beginPath();
|
|
189
|
+
ctx.arc(px, py, irisR, 0, Math.PI * 2);
|
|
190
|
+
ctx.fillStyle = '#1a1a1a';
|
|
191
|
+
ctx.fill();
|
|
192
|
+
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
ctx.arc(px, py, pupilR, 0, Math.PI * 2);
|
|
195
|
+
ctx.fillStyle = '#000';
|
|
196
|
+
ctx.fill();
|
|
197
|
+
|
|
198
|
+
// highlight
|
|
199
|
+
ctx.beginPath();
|
|
200
|
+
ctx.arc(px - irisR * 0.35, py - irisR * 0.35, pupilR * 0.35, 0, Math.PI * 2);
|
|
201
|
+
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
|
202
|
+
ctx.fill();
|
|
203
|
+
|
|
204
|
+
// eyelids (cover from top and bottom)
|
|
205
|
+
if (blink > 0) {
|
|
206
|
+
const cover = ry * blink + 2;
|
|
207
|
+
ctx.fillStyle = '#000';
|
|
208
|
+
// clip to eye bounds roughly with rects over the ellipse region
|
|
209
|
+
ctx.fillRect(cx - rx - 2, cy - ry - 2, eyeW + 4, cover + 2);
|
|
210
|
+
ctx.fillRect(cx - rx - 2, cy + ry - cover, eyeW + 4, cover + 2);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function draw() {
|
|
215
|
+
const w = window.innerWidth;
|
|
216
|
+
const h = window.innerHeight;
|
|
217
|
+
|
|
218
|
+
ctx.fillStyle = '#000';
|
|
219
|
+
ctx.fillRect(0, 0, w, h);
|
|
220
|
+
|
|
221
|
+
const eyeW = Math.min(w * 0.28, h * 0.55);
|
|
222
|
+
const eyeH = eyeW * 0.62;
|
|
223
|
+
const gap = w * 0.14;
|
|
224
|
+
const cy = h * 0.5;
|
|
225
|
+
const leftCx = w * 0.5 - gap / 2 - eyeW / 2;
|
|
226
|
+
const rightCx = w * 0.5 + gap / 2 + eyeW / 2;
|
|
227
|
+
|
|
228
|
+
drawEye(leftCx, cy, eyeW, eyeH, state.gazeX, state.gazeY, state.blink);
|
|
229
|
+
drawEye(rightCx, cy, eyeW, eyeH, state.gazeX, state.gazeY, state.blink);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let last = performance.now();
|
|
233
|
+
|
|
234
|
+
function frame(now) {
|
|
235
|
+
const dt = Math.min(50, now - last);
|
|
236
|
+
last = now;
|
|
237
|
+
updateBlink(now, dt);
|
|
238
|
+
updateIdle(now);
|
|
239
|
+
updateGaze();
|
|
240
|
+
draw();
|
|
241
|
+
requestAnimationFrame(frame);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
connectWs();
|
|
245
|
+
enterFullscreen();
|
|
246
|
+
requestAnimationFrame(frame);
|
|
@@ -0,0 +1,14 @@
|
|
|
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, maximum-scale=1, user-scalable=no" />
|
|
6
|
+
<title>Robot Eyes</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<canvas id="eyes"></canvas>
|
|
11
|
+
<script type="module" src="/eyes.js"></script>
|
|
12
|
+
<script type="module" src="/teleop.js"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html,
|
|
8
|
+
body {
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
background: #000;
|
|
13
|
+
cursor: none;
|
|
14
|
+
touch-action: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
canvas#eyes {
|
|
18
|
+
display: block;
|
|
19
|
+
width: 100vw;
|
|
20
|
+
height: 100vh;
|
|
21
|
+
background: #000;
|
|
22
|
+
}
|
package/public/teleop.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invisible keyboard teleop — no on-screen UI.
|
|
3
|
+
* WASD drive, Q faster, Z slower. Messages go to the server over WebSocket.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MOVE_KEYS = new Set(['w', 'a', 's', 'd']);
|
|
7
|
+
|
|
8
|
+
/** @type {WebSocket | null} */
|
|
9
|
+
let socket = null;
|
|
10
|
+
let reconnectTimer = 0;
|
|
11
|
+
|
|
12
|
+
const pressed = {
|
|
13
|
+
w: false,
|
|
14
|
+
a: false,
|
|
15
|
+
s: false,
|
|
16
|
+
d: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function send(payload) {
|
|
20
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
21
|
+
socket.send(JSON.stringify(payload));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sendKeys() {
|
|
26
|
+
send({
|
|
27
|
+
type: 'keys',
|
|
28
|
+
keys: { ...pressed },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function connect() {
|
|
33
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
34
|
+
const ws = new WebSocket(`${proto}://${location.host}`);
|
|
35
|
+
socket = ws;
|
|
36
|
+
|
|
37
|
+
ws.addEventListener('open', () => {
|
|
38
|
+
sendKeys();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
ws.addEventListener('close', () => {
|
|
42
|
+
socket = null;
|
|
43
|
+
clearTimeout(reconnectTimer);
|
|
44
|
+
reconnectTimer = setTimeout(connect, 1000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
ws.addEventListener('error', () => {
|
|
48
|
+
ws.close();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeKey(e) {
|
|
53
|
+
return e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
window.addEventListener('keydown', (e) => {
|
|
57
|
+
const key = normalizeKey(e);
|
|
58
|
+
|
|
59
|
+
if (MOVE_KEYS.has(key)) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
if (!pressed[key]) {
|
|
62
|
+
pressed[key] = true;
|
|
63
|
+
sendKeys();
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (key === 'q' && !e.repeat) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
send({ type: 'speed', delta: 1 });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (key === 'z' && !e.repeat) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
send({ type: 'speed', delta: -1 });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
window.addEventListener('keyup', (e) => {
|
|
81
|
+
const key = normalizeKey(e);
|
|
82
|
+
if (!MOVE_KEYS.has(key)) return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (pressed[key]) {
|
|
85
|
+
pressed[key] = false;
|
|
86
|
+
sendKeys();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Release all keys if the window loses focus (safety)
|
|
91
|
+
window.addEventListener('blur', () => {
|
|
92
|
+
let changed = false;
|
|
93
|
+
for (const k of MOVE_KEYS) {
|
|
94
|
+
if (pressed[k]) {
|
|
95
|
+
pressed[k] = false;
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (changed) sendKeys();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
connect();
|
package/src/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @agenticros/eyes — fullscreen robot face driven by ROS 2 cmd_vel Twist.
|
|
4
|
+
*
|
|
5
|
+
* Subscribes to CMD_VEL_TOPIC for gaze (left/right turns). Optionally publishes
|
|
6
|
+
* the same topic from invisible WASD keyboard teleop (disabled with --no-teleop).
|
|
7
|
+
*
|
|
8
|
+
* Env (set by `agenticros eyes` from ~/.agenticros/config.json):
|
|
9
|
+
* CMD_VEL_TOPIC, MAX_LINEAR_VELOCITY, MAX_ANGULAR_VELOCITY, PORT, …
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "module";
|
|
12
|
+
import http from "http";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
import { spawn, execFileSync } from "child_process";
|
|
17
|
+
import { WebSocketServer } from "ws";
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
let rclnodejs;
|
|
21
|
+
try {
|
|
22
|
+
rclnodejs = require("rclnodejs");
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.error(
|
|
25
|
+
"Failed to load rclnodejs. Source ROS 2 and reinstall deps on the robot:\n" +
|
|
26
|
+
" source /opt/ros/<distro>/setup.bash && pnpm install --filter @agenticros/eyes\n" +
|
|
27
|
+
String(e instanceof Error ? e.message : e),
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
33
|
+
const __dirname = path.dirname(__filename);
|
|
34
|
+
const PUBLIC_DIR = path.join(__dirname, "..", "public");
|
|
35
|
+
|
|
36
|
+
const PORT = Number(process.env.PORT || 8765);
|
|
37
|
+
const TOPIC = process.env.CMD_VEL_TOPIC || "/cmd_vel";
|
|
38
|
+
const ANGULAR_DEADZONE = Number(process.env.ANGULAR_DEADZONE || 0.05);
|
|
39
|
+
const CMD_TIMEOUT_MS = Number(process.env.CMD_TIMEOUT_MS || 300);
|
|
40
|
+
const NO_BROWSER = process.argv.includes("--no-browser");
|
|
41
|
+
const NO_TELEOP =
|
|
42
|
+
process.argv.includes("--no-teleop") ||
|
|
43
|
+
process.env.AGENTICROS_EYES_NO_TELEOP === "1";
|
|
44
|
+
|
|
45
|
+
const MAX_LINEAR = Number(process.env.MAX_LINEAR_VELOCITY || 1.0);
|
|
46
|
+
const MAX_ANGULAR = Number(process.env.MAX_ANGULAR_VELOCITY || 1.5);
|
|
47
|
+
|
|
48
|
+
const TELOP_LINEAR = Math.min(
|
|
49
|
+
Number(process.env.TELOP_LINEAR || 0.25),
|
|
50
|
+
MAX_LINEAR,
|
|
51
|
+
);
|
|
52
|
+
const TELOP_ANGULAR = Math.min(
|
|
53
|
+
Number(process.env.TELOP_ANGULAR || 0.9),
|
|
54
|
+
MAX_ANGULAR,
|
|
55
|
+
);
|
|
56
|
+
const TELOP_SCALE_STEP = Number(process.env.TELOP_SCALE_STEP || 0.15);
|
|
57
|
+
const TELOP_SCALE_MIN = Number(process.env.TELOP_SCALE_MIN || 0.2);
|
|
58
|
+
const TELOP_SCALE_MAX = Number(process.env.TELOP_SCALE_MAX || 3);
|
|
59
|
+
const TELOP_RATE_HZ = Number(process.env.TELOP_RATE_HZ || 20);
|
|
60
|
+
|
|
61
|
+
/** @type {{ gazeX: number, driving: boolean, lastCmdAt: number }} */
|
|
62
|
+
const state = {
|
|
63
|
+
gazeX: 0,
|
|
64
|
+
driving: false,
|
|
65
|
+
lastCmdAt: 0,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const teleop = {
|
|
69
|
+
/** @type {Map<object, { w: boolean, a: boolean, s: boolean, d: boolean }>} */
|
|
70
|
+
clients: new Map(),
|
|
71
|
+
scale: 1,
|
|
72
|
+
publishing: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function mimeType(filePath) {
|
|
76
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
77
|
+
switch (ext) {
|
|
78
|
+
case ".html":
|
|
79
|
+
return "text/html; charset=utf-8";
|
|
80
|
+
case ".js":
|
|
81
|
+
return "application/javascript; charset=utf-8";
|
|
82
|
+
case ".css":
|
|
83
|
+
return "text/css; charset=utf-8";
|
|
84
|
+
case ".svg":
|
|
85
|
+
return "image/svg+xml";
|
|
86
|
+
case ".png":
|
|
87
|
+
return "image/png";
|
|
88
|
+
default:
|
|
89
|
+
return "application/octet-stream";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createHttpServer() {
|
|
94
|
+
return http.createServer((req, res) => {
|
|
95
|
+
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
|
96
|
+
const rel = urlPath === "/" ? "/index.html" : urlPath;
|
|
97
|
+
const filePath = path.normalize(path.join(PUBLIC_DIR, rel));
|
|
98
|
+
|
|
99
|
+
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
100
|
+
res.writeHead(403).end("Forbidden");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Gaze-only mode: serve HTML without the teleop script.
|
|
105
|
+
if (NO_TELEOP && rel === "/index.html") {
|
|
106
|
+
fs.readFile(filePath, "utf8", (err, html) => {
|
|
107
|
+
if (err) {
|
|
108
|
+
res.writeHead(404).end("Not found");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const stripped = html.replace(
|
|
112
|
+
/\s*<script type="module" src="\/teleop\.js"><\/script>\s*/i,
|
|
113
|
+
"\n",
|
|
114
|
+
);
|
|
115
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
116
|
+
res.end(stripped);
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fs.readFile(filePath, (err, data) => {
|
|
122
|
+
if (err) {
|
|
123
|
+
res.writeHead(404).end("Not found");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
res.writeHead(200, { "Content-Type": mimeType(filePath) });
|
|
127
|
+
res.end(data);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function broadcast(wss, payload) {
|
|
133
|
+
const raw = JSON.stringify(payload);
|
|
134
|
+
for (const client of wss.clients) {
|
|
135
|
+
if (client.readyState === 1) {
|
|
136
|
+
client.send(raw);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function gazeFromTwist(msg) {
|
|
142
|
+
const z = msg?.angular?.z ?? 0;
|
|
143
|
+
if (Math.abs(z) < ANGULAR_DEADZONE) {
|
|
144
|
+
return { gazeX: 0, driving: false };
|
|
145
|
+
}
|
|
146
|
+
// +angular.z = left turn → eyes look right (screen +X)
|
|
147
|
+
return { gazeX: z > 0 ? 1 : -1, driving: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mergedKeys() {
|
|
151
|
+
const keys = { w: false, a: false, s: false, d: false };
|
|
152
|
+
for (const k of teleop.clients.values()) {
|
|
153
|
+
keys.w ||= k.w;
|
|
154
|
+
keys.a ||= k.a;
|
|
155
|
+
keys.s ||= k.s;
|
|
156
|
+
keys.d ||= k.d;
|
|
157
|
+
}
|
|
158
|
+
return keys;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clampTwist(twist) {
|
|
162
|
+
const { linear, angular } = twist;
|
|
163
|
+
const lx = linear?.x ?? 0;
|
|
164
|
+
const ly = linear?.y ?? 0;
|
|
165
|
+
const lz = linear?.z ?? 0;
|
|
166
|
+
const ax = angular?.x ?? 0;
|
|
167
|
+
const ay = angular?.y ?? 0;
|
|
168
|
+
const az = angular?.z ?? 0;
|
|
169
|
+
|
|
170
|
+
const linMag = Math.sqrt(lx * lx + ly * ly + lz * lz);
|
|
171
|
+
const scaleLin = linMag > MAX_LINEAR && linMag > 0 ? MAX_LINEAR / linMag : 1;
|
|
172
|
+
const angMag = Math.abs(az);
|
|
173
|
+
const scaleAng = angMag > MAX_ANGULAR && angMag > 0 ? MAX_ANGULAR / angMag : 1;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
linear: { x: lx * scaleLin, y: ly * scaleLin, z: lz * scaleLin },
|
|
177
|
+
angular: {
|
|
178
|
+
x: ax * scaleAng,
|
|
179
|
+
y: ay * scaleAng,
|
|
180
|
+
z: Math.max(-MAX_ANGULAR, Math.min(MAX_ANGULAR, az)),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function twistFromKeys(keys) {
|
|
186
|
+
const linear = TELOP_LINEAR * teleop.scale;
|
|
187
|
+
const angular = TELOP_ANGULAR * teleop.scale;
|
|
188
|
+
let x = 0;
|
|
189
|
+
let z = 0;
|
|
190
|
+
if (keys.w) x += linear;
|
|
191
|
+
if (keys.s) x -= linear;
|
|
192
|
+
if (keys.a) z += angular;
|
|
193
|
+
if (keys.d) z -= angular;
|
|
194
|
+
return clampTwist({
|
|
195
|
+
linear: { x, y: 0, z: 0 },
|
|
196
|
+
angular: { x: 0, y: 0, z },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function anyKeyDown(keys) {
|
|
201
|
+
return keys.w || keys.a || keys.s || keys.d;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function findBrowser() {
|
|
205
|
+
const candidates = [
|
|
206
|
+
process.env.BROWSER,
|
|
207
|
+
"firefox",
|
|
208
|
+
"chromium-browser",
|
|
209
|
+
"chromium",
|
|
210
|
+
"google-chrome",
|
|
211
|
+
"google-chrome-stable",
|
|
212
|
+
].filter(Boolean);
|
|
213
|
+
|
|
214
|
+
for (const bin of candidates) {
|
|
215
|
+
try {
|
|
216
|
+
execFileSync("which", [bin], { stdio: "ignore" });
|
|
217
|
+
return bin;
|
|
218
|
+
} catch {
|
|
219
|
+
// try next
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function launchKiosk(url) {
|
|
226
|
+
const bin = findBrowser();
|
|
227
|
+
if (!bin) {
|
|
228
|
+
console.warn("No browser found. Open this URL fullscreen manually:", url);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const args = bin.includes("firefox")
|
|
233
|
+
? ["--kiosk", url]
|
|
234
|
+
: ["--kiosk", "--noerrdialogs", "--disable-infobars", `--app=${url}`, url];
|
|
235
|
+
|
|
236
|
+
console.log(`Launching ${bin} in kiosk mode → ${url}`);
|
|
237
|
+
const child = spawn(bin, args, {
|
|
238
|
+
stdio: "ignore",
|
|
239
|
+
detached: true,
|
|
240
|
+
env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" },
|
|
241
|
+
});
|
|
242
|
+
child.unref();
|
|
243
|
+
child.on("error", (err) => {
|
|
244
|
+
console.warn(`Failed to launch ${bin}:`, err.message);
|
|
245
|
+
console.warn("Open this URL fullscreen manually:", url);
|
|
246
|
+
});
|
|
247
|
+
return child;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function main() {
|
|
251
|
+
await rclnodejs.init();
|
|
252
|
+
const node = rclnodejs.createNode("robot_eyes");
|
|
253
|
+
const publisher = NO_TELEOP
|
|
254
|
+
? null
|
|
255
|
+
: node.createPublisher("geometry_msgs/msg/Twist", TOPIC);
|
|
256
|
+
|
|
257
|
+
const server = createHttpServer();
|
|
258
|
+
const wss = new WebSocketServer({ server });
|
|
259
|
+
|
|
260
|
+
const publishStop = () => {
|
|
261
|
+
if (!publisher) return;
|
|
262
|
+
publisher.publish({
|
|
263
|
+
linear: { x: 0, y: 0, z: 0 },
|
|
264
|
+
angular: { x: 0, y: 0, z: 0 },
|
|
265
|
+
});
|
|
266
|
+
teleop.publishing = false;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const tickTeleop = () => {
|
|
270
|
+
if (NO_TELEOP || !publisher) return;
|
|
271
|
+
const keys = mergedKeys();
|
|
272
|
+
if (!anyKeyDown(keys)) {
|
|
273
|
+
if (teleop.publishing) {
|
|
274
|
+
publishStop();
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
publisher.publish(twistFromKeys(keys));
|
|
279
|
+
teleop.publishing = true;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
if (!NO_TELEOP) {
|
|
283
|
+
setInterval(tickTeleop, Math.max(10, Math.round(1000 / TELOP_RATE_HZ)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
wss.on("connection", (ws) => {
|
|
287
|
+
if (!NO_TELEOP) {
|
|
288
|
+
teleop.clients.set(ws, { w: false, a: false, s: false, d: false });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
ws.send(
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
type: "gaze",
|
|
294
|
+
gazeX: state.gazeX,
|
|
295
|
+
driving: state.driving,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
ws.on("message", (raw) => {
|
|
300
|
+
if (NO_TELEOP) return;
|
|
301
|
+
let msg;
|
|
302
|
+
try {
|
|
303
|
+
msg = JSON.parse(String(raw));
|
|
304
|
+
} catch {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (msg.type === "keys" && msg.keys && typeof msg.keys === "object") {
|
|
309
|
+
teleop.clients.set(ws, {
|
|
310
|
+
w: Boolean(msg.keys.w),
|
|
311
|
+
a: Boolean(msg.keys.a),
|
|
312
|
+
s: Boolean(msg.keys.s),
|
|
313
|
+
d: Boolean(msg.keys.d),
|
|
314
|
+
});
|
|
315
|
+
tickTeleop();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (msg.type === "speed" && (msg.delta === 1 || msg.delta === -1)) {
|
|
320
|
+
const next = teleop.scale + msg.delta * TELOP_SCALE_STEP;
|
|
321
|
+
teleop.scale = Math.min(
|
|
322
|
+
TELOP_SCALE_MAX,
|
|
323
|
+
Math.max(TELOP_SCALE_MIN, next),
|
|
324
|
+
);
|
|
325
|
+
console.log(
|
|
326
|
+
`teleop speed scale: ${teleop.scale.toFixed(2)} ` +
|
|
327
|
+
`(linear≈${(TELOP_LINEAR * teleop.scale).toFixed(2)} m/s, ` +
|
|
328
|
+
`angular≈${(TELOP_ANGULAR * teleop.scale).toFixed(2)} rad/s)`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
ws.on("close", () => {
|
|
334
|
+
if (!NO_TELEOP) {
|
|
335
|
+
teleop.clients.delete(ws);
|
|
336
|
+
tickTeleop();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
node.createSubscription("geometry_msgs/msg/Twist", TOPIC, (msg) => {
|
|
342
|
+
const next = gazeFromTwist(msg);
|
|
343
|
+
state.gazeX = next.gazeX;
|
|
344
|
+
state.driving = next.driving;
|
|
345
|
+
state.lastCmdAt = Date.now();
|
|
346
|
+
broadcast(wss, {
|
|
347
|
+
type: "gaze",
|
|
348
|
+
gazeX: state.gazeX,
|
|
349
|
+
driving: state.driving,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
setInterval(() => {
|
|
354
|
+
if (!state.driving) return;
|
|
355
|
+
if (Date.now() - state.lastCmdAt < CMD_TIMEOUT_MS) return;
|
|
356
|
+
state.gazeX = 0;
|
|
357
|
+
state.driving = false;
|
|
358
|
+
broadcast(wss, {
|
|
359
|
+
type: "gaze",
|
|
360
|
+
gazeX: 0,
|
|
361
|
+
driving: false,
|
|
362
|
+
});
|
|
363
|
+
}, 50);
|
|
364
|
+
|
|
365
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
366
|
+
const url = `http://127.0.0.1:${PORT}/`;
|
|
367
|
+
console.log(`robot-eyes listening on ${url}`);
|
|
368
|
+
console.log(
|
|
369
|
+
`${NO_TELEOP ? "Subscribed" : "Subscribed + publishing"} ${TOPIC} ` +
|
|
370
|
+
`(deadzone=${ANGULAR_DEADZONE}, maxLin=${MAX_LINEAR}, maxAng=${MAX_ANGULAR})`,
|
|
371
|
+
);
|
|
372
|
+
if (NO_TELEOP) {
|
|
373
|
+
console.log("Keyboard teleop disabled (--no-teleop / gaze-only)");
|
|
374
|
+
} else {
|
|
375
|
+
console.log(
|
|
376
|
+
`Keyboard teleop: WASD drive, Q faster, Z slower ` +
|
|
377
|
+
`(base linear=${TELOP_LINEAR}, angular=${TELOP_ANGULAR})`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (!NO_BROWSER) {
|
|
381
|
+
launchKiosk(url);
|
|
382
|
+
} else {
|
|
383
|
+
console.log("--no-browser: open the URL yourself for fullscreen");
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
rclnodejs.spin(node);
|
|
388
|
+
|
|
389
|
+
const shutdown = async () => {
|
|
390
|
+
console.log("\nShutting down…");
|
|
391
|
+
try {
|
|
392
|
+
publishStop();
|
|
393
|
+
wss.close();
|
|
394
|
+
server.close();
|
|
395
|
+
node.destroy();
|
|
396
|
+
await rclnodejs.shutdown();
|
|
397
|
+
} catch {
|
|
398
|
+
// ignore
|
|
399
|
+
}
|
|
400
|
+
process.exit(0);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
process.on("SIGINT", shutdown);
|
|
404
|
+
process.on("SIGTERM", shutdown);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
main().catch((err) => {
|
|
408
|
+
console.error(err);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
});
|