Compare commits

...

2 Commits

15 changed files with 528 additions and 163 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.21", "version": "1.2.22",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.21", "version": "1.2.22",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.21", "version": "1.2.22",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+53 -8
View File
@@ -6,6 +6,7 @@ import json
import logging import logging
import math import math
import os import os
import re
import shutil import shutil
import sqlite3 import sqlite3
import struct import struct
@@ -207,6 +208,8 @@ class SlamNode(Node):
self._fallback_point_cloud: Optional[PointCloud2] = None self._fallback_point_cloud: Optional[PointCloud2] = None
self._color_point_cloud: Optional[PointCloud2] = None self._color_point_cloud: Optional[PointCloud2] = None
self._camera_image: Optional[CompressedImage] = None self._camera_image: Optional[CompressedImage] = None
self._camera_frames = {}
self._camera_subscriptions = {}
self._icp_odom: Optional[Odometry] = None self._icp_odom: Optional[Odometry] = None
self._map: Optional[OccupancyGrid] = None self._map: Optional[OccupancyGrid] = None
self._plan: Optional[NavPath] = None self._plan: Optional[NavPath] = None
@@ -261,6 +264,8 @@ class SlamNode(Node):
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data) self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
if not os.environ.get("CAMERA_IMAGE_TOPIC"): if not os.environ.get("CAMERA_IMAGE_TOPIC"):
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data) self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
self._camera_discovery_timer = self.create_timer(
1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME))
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos) self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
@@ -625,11 +630,45 @@ class SlamNode(Node):
self._color_point_cloud_count += 1 self._color_point_cloud_count += 1
self._last_color_point_cloud_wall_time = time.time() self._last_color_point_cloud_wall_time = time.time()
def _on_camera_image(self, msg: CompressedImage): def _discover_cameras(self):
try:
topics = sorted(name for name, types in self.get_topic_names_and_types()
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
except Exception:
return
for topic in topics:
camera_id = topic.split('/')[-2]
if camera_id not in self._camera_subscriptions:
self._camera_subscriptions[camera_id] = self.create_subscription(
CompressedImage, topic,
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
qos_profile_sensor_data)
active = {topic.split('/')[-2] for topic in topics}
for camera_id in list(self._camera_subscriptions):
if camera_id not in active:
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
with self._lock:
self._camera_frames.pop(camera_id, None)
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
with self._lock: with self._lock:
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
return
self._camera_image = msg self._camera_image = msg
self._camera_image_count += 1 self._camera_image_count += 1
self._last_camera_image_wall_time = time.time() self._last_camera_image_wall_time = time.time()
self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time,
topic or self.camera_image_topic)
def camera_sources(self):
now = time.time()
with self._lock:
frames = list(self._camera_frames.items())
return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera',
"topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS
and bool(image.data) and 'jpeg' in image.format.lower()}
for key, (image, received, topic) in sorted(frames)]
def _active_point_cloud(self) -> Optional[PointCloud2]: def _active_point_cloud(self) -> Optional[PointCloud2]:
with self._lock: with self._lock:
@@ -687,7 +726,6 @@ class SlamNode(Node):
point_cloud_last = self._last_point_cloud_wall_time point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
color_point_cloud_last = self._last_color_point_cloud_wall_time color_point_cloud_last = self._last_color_point_cloud_wall_time
camera_image_last = self._last_camera_image_wall_time
map_count = self._map_count map_count = self._map_count
plan_count = self._plan_count plan_count = self._plan_count
@@ -727,7 +765,7 @@ class SlamNode(Node):
}, },
"camera_image": { "camera_image": {
"name": self.camera_image_topic, "name": self.camera_image_topic,
"available": received_recently(camera_image_last), "available": any(camera['available'] for camera in self.camera_sources()),
}, },
"plan": {"name": self.plan_topic, "available": plan_available}, "plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available}, "cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
@@ -1238,6 +1276,7 @@ class SlamNode(Node):
"sensor_point_cloud": sensor_point_cloud_payload, "sensor_point_cloud": sensor_point_cloud_payload,
"color_point_cloud": color_point_cloud_payload, "color_point_cloud": color_point_cloud_payload,
"camera_image": camera_image_payload, "camera_image": camera_image_payload,
"cameras": self.camera_sources(),
"map": map_payload, "map": map_payload,
"tf": { "tf": {
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
@@ -1302,10 +1341,16 @@ class SlamNode(Node):
payload["received_at"] = received_at payload["received_at"] = received_at
return payload return payload
def camera_frame_jpeg(self) -> Optional[bytes]: def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
with self._lock: with self._lock:
image = self._camera_image if camera_id is not None:
received_at = self._last_camera_image_wall_time entry = self._camera_frames.get(camera_id)
if entry is None:
return None
image, received_at, _topic = entry
else:
image = self._camera_image
received_at = self._last_camera_image_wall_time
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS: if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
return None return None
if not image.data or "jpeg" not in image.format.lower(): if not image.data or "jpeg" not in image.format.lower():
@@ -1531,10 +1576,10 @@ def color_point_cloud():
@app.get("/camera-frame") @app.get("/camera-frame")
def camera_frame(): def camera_frame(camera_id: Optional[str] = None):
if not slam_node: if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready") raise HTTPException(status_code=503, detail="ROS node is not ready")
frame = slam_node.camera_frame_jpeg() frame = slam_node.camera_frame_jpeg(camera_id)
if frame is None: if frame is None:
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.") raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"}) return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
+67
View File
@@ -0,0 +1,67 @@
import ast
from pathlib import Path
import re
import threading
from types import SimpleNamespace
from typing import Optional
import unittest
class CameraTests(unittest.TestCase):
def setUp(self):
self.now = 10.0
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
'qos_profile_sensor_data': object()}
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
self.node._lock = threading.Lock()
self.node._camera_frames = {}
self.node._camera_subscriptions = {}
self.node._camera_image = None
self.node._last_camera_image_wall_time = None
self.node._camera_image_count = 0
self.node.camera_image_topic = '/camera_test/image_jpeg'
self.topics = []
self.callbacks = {}
self.node.get_topic_names_and_types = lambda: self.topics
def subscribe(kind, topic, callback, qos):
self.callbacks[topic] = callback
return topic
self.node.create_subscription = subscribe
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
def test_independent_frames_and_disconnect(self):
for key in ['video0', 'video2']:
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
self.node._discover_cameras()
self.assertEqual(len(self.callbacks), 2)
for topic, callback in self.callbacks.items():
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
self.now += 4
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
self.topics = self.topics[:1]
self.node._discover_cameras()
self.assertNotIn('video2', self.node._camera_frames)
def test_single_camera_compatibility_and_non_jpeg(self):
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
self.assertFalse(self.node.camera_sources()[0]['available'])
if __name__ == '__main__':
unittest.main()
+45
View File
@@ -98,6 +98,51 @@ test('legacy cancelled references can be opened with the same read-only navigati
assert.equal(h.message().codeReferenceStates.location.status, 'opened') assert.equal(h.message().codeReferenceStates.location.status, 'opened')
}) })
test('code navigation does not block or abort a pending model response', async () => {
const h = consentHarness()
const controller = new AbortController()
h.globals.abortControllerRef.current = controller
let opened = 0
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
assert.equal(isCurrent(), true)
opened++
})
assert.equal(opened, 1)
assert.equal(controller.signal.aborted, false)
assert.equal(h.globals.abortControllerRef.current, controller)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('an arriving model response does not cancel an in-flight code navigation', async () => {
const h = consentHarness()
h.globals.abortControllerRef.current = new AbortController()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise(resolve => { finish = resolve })
})
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
h.globals.abortControllerRef.current = null
assert.equal(current(), true)
finish()
await pending
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
})
test('starting file application invalidates a navigation that is still reading', async () => {
const h = consentHarness()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
})
h.globals.applyingRef.current = true
assert.equal(current(), false)
finish()
await pending
assert.equal(h.message().codeReferenceStates.location.status, 'error')
})
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => { test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
const h = consentHarness() const h = consentHarness()
let finish, opened = 0 let finish, opened = 0
+23
View File
@@ -22,6 +22,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text)) const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content) const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name }))) const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
const AgentCodeReferences = () => null
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), { const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} } '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
}) })
@@ -36,6 +37,7 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
ChatEmptyState: widgets.ChatEmptyState, ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse, ProgressiveChatResponse,
ChatImageAttachments, ChatImageAttachments,
AgentCodeReferences,
AgentProgressBubble, AgentProgressBubble,
AgentWorkDuration, AgentWorkDuration,
MessageCopyButton: () => null, MessageCopyButton: () => null,
@@ -43,6 +45,27 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg') Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
}) })
test('existing code locations stay usable during model and command work but not file application', () => {
for (const applying of [false, true]) {
const onOpen = () => {}
const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
onOpenCodeReference: onOpen
})
let references
function visit(element) {
if (!React.isValidElement(element)) return
if (element.type === AgentCodeReferences) references = element
React.Children.toArray(element.props.children).forEach(visit)
}
visit(tree)
assert.ok(references)
assert.equal(references.props.busy, applying)
assert.equal(typeof references.props.onOpen, 'function')
}
})
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => { test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
const tree = messages.AgentMessages({ const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: false, dark: true, enabled: true, statusLoading: false, pending: false,
+29 -2
View File
@@ -2563,6 +2563,23 @@ async function startRemoteVisualizationBridge(
].join('\n'), 900_000) ].join('\n'), 900_000)
} }
const command = bridgeContainerCommand(stack) const command = bridgeContainerCommand(stack)
let bridgeContextRevision: string | null = null
if (stack !== 'autoware') {
const context = resolveResourceContextPath('resources/slam-ros-bridge')
if (!context) throw new Error('Bundled SLAM bridge source is missing')
const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') }
bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex')
await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([
'import json, os, pathlib, sys',
'data = json.load(sys.stdin)',
'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]',
'root.mkdir(parents=True, exist_ok=True)',
'for name in ("bridge.py", "requirements.txt"):',
' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")',
' temporary.write_text(data["files"][name], encoding="utf-8")',
' temporary.replace(root / name)',
].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files }))
}
reportVisualizationProgress('시각화 시작 중...') reportVisualizationProgress('시각화 시작 중...')
@@ -2580,6 +2597,7 @@ async function startRemoteVisualizationBridge(
'docker run -d --name "$CONTAINER" --net=host', 'docker run -d --name "$CONTAINER" --net=host',
'-v /dev/shm:/dev/shm', '-v /dev/shm:/dev/shm',
'-v "$ROOT:/workspace"', '-v "$ROOT:/workspace"',
...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []),
...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []), ...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []),
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"', '-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
'-e ROS_LOCALHOST_ONLY=0', '-e ROS_LOCALHOST_ONLY=0',
@@ -4777,10 +4795,19 @@ function buildNav2DockerRunScript(): string {
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
...yahboomDevicePreflight(), ...yahboomDevicePreflight(),
'CAMERA_DEVICE=""', 'CAMERA_DEVICE=""',
'CAMERA_DEVICES=()',
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then', 'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
' CAMERA_DEVICE=/dev/video0', ' CAMERA_DEVICE=/dev/video0',
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done', ' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }', ' if [ "$CAMERA_DEVICE" = /dev ]; then',
' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"',
' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }',
' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1',
' mapfile -t CAMERA_DEVICES <<< "$camera_devices"',
' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi',
' for camera_device in "${CAMERA_DEVICES[@]}"; do',
' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }',
' done',
'fi', 'fi',
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'', '',
@@ -4800,7 +4827,7 @@ function buildNav2DockerRunScript(): string {
')', ')',
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
'if [ -n "$CAMERA_DEVICE" ]; then', 'if [ -n "$CAMERA_DEVICE" ]; then',
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")', ' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done',
' for capture_device in /dev/capture-vi-channel*; do', ' for capture_device in /dev/capture-vi-channel*; do',
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi', ' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
' done', ' done',
@@ -0,0 +1,113 @@
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
const cameras = new Map();
let enabled = false;
let timer = null;
function sources() {
const { state, receivedAt } = getState();
if (!enabled || Date.now() - receivedAt >= 3000) return [];
const list = Array.isArray(state?.cameras) ? state.cameras
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
const seen = new Set();
return list.filter(item => {
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
seen.add(item.id);
return true;
}).slice(0, 8);
}
function clearFrame(camera) {
if (camera.url) URL.revokeObjectURL(camera.url);
camera.url = null;
camera.image.removeAttribute('src');
}
function remove(id, camera) {
cameras.delete(id);
camera.controller?.abort();
clearFrame(camera);
camera.element.remove();
}
async function frame(id, camera) {
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
const controller = new AbortController();
camera.controller = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error('Camera frame unavailable');
const blob = await response.blob();
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
const previous = camera.url;
camera.url = URL.createObjectURL(blob);
camera.image.src = camera.url;
camera.retryAt = 0;
if (previous) URL.revokeObjectURL(previous);
} catch (_) {
if (cameras.get(id) === camera) {
camera.retryAt = Date.now() + 5000;
clearFrame(camera);
}
} finally {
window.clearTimeout(timeout);
if (camera.controller === controller) camera.controller = null;
}
}
function refresh() {
const live = sources();
const ids = new Set(live.map(item => item.id));
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
for (const item of live) {
let camera = cameras.get(item.id);
if (!camera) {
const element = document.createElement('div');
element.className = 'camera-preview-item';
element.dataset.cameraId = item.id;
const label = document.createElement('div');
label.className = 'camera-preview-label';
const image = document.createElement('img');
image.className = 'camera-preview-image';
element.append(label, image);
root.append(element);
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
image.addEventListener('error', () => {
if (cameras.get(item.id) === camera) {
clearFrame(camera);
camera.retryAt = Date.now() + 5000;
}
});
cameras.set(item.id, camera);
}
camera.label.textContent = item.name || item.id;
camera.image.alt = item.name || item.id;
void frame(item.id, camera);
}
onAvailable(enabled && cameras.size > 0);
}
function visibilityChanged() {
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
else refresh();
}
document.addEventListener('visibilitychange', visibilityChanged);
function setEnabled(value) {
enabled = value;
if (timer) window.clearInterval(timer);
timer = null;
refresh();
if (enabled) timer = window.setInterval(refresh, 200);
}
return {
setEnabled,
refresh,
dispose() {
setEnabled(false);
document.removeEventListener('visibilitychange', visibilityChanged);
},
};
}
+28 -76
View File
@@ -61,14 +61,29 @@
#cameraPreviewWrap.is-visible { display:block; } #cameraPreviewWrap.is-visible { display:block; }
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; } #cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; } #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview { #cameraPreviewList {
max-height:min(60vh, 600px);
overflow-y:auto;
scrollbar-width:thin;
scrollbar-color:rgba(148,163,184,.65) transparent;
}
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
.camera-preview-label {
padding:5px 8px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:rgba(255,255,255,.85);
font:11px sans-serif;
}
.camera-preview-image {
display:block; display:block;
width:100%; width:100%;
aspect-ratio:16 / 9; aspect-ratio:16 / 9;
object-fit:contain; object-fit:contain;
background:#111520; background:#111520;
} }
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
#cameraPreviewToggle { #cameraPreviewToggle {
position:absolute; position:absolute;
left:0; left:0;
@@ -426,7 +441,7 @@
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button> <button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
<div id="cameraPreviewWrap"> <div id="cameraPreviewWrap">
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button> <button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" /> <div id="cameraPreviewList"></div>
</div> </div>
<div id="keyGuide"> <div id="keyGuide">
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div> <div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
@@ -463,6 +478,7 @@
<script type="module" src="./locale.js"></script> <script type="module" src="./locale.js"></script>
<script type="module"> <script type="module">
import { t, setText } from './locale.js'; import { t, setText } from './locale.js';
import { createCameraPreview } from './camera-preview.js';
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -502,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
const miniCtx = miniMap.getContext('2d'); const miniCtx = miniMap.getContext('2d');
const miniMapToggle = document.getElementById('miniMapToggle'); const miniMapToggle = document.getElementById('miniMapToggle');
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap'); const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
const cameraPreview = document.getElementById('cameraPreview'); const cameraPreviewList = document.getElementById('cameraPreviewList');
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle'); const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
const cameraToggleButton = document.getElementById('cameraToggle'); const cameraToggleButton = document.getElementById('cameraToggle');
const topRightControls = document.getElementById('topRightControls'); const topRightControls = document.getElementById('topRightControls');
@@ -937,12 +953,7 @@ let pointCloudRefreshTimer = null;
let savedMapPointCloudLoaded = false; let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false; let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null; let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW; let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
let cameraFrameAbortController = null;
let pathVisible = false; let pathVisible = false;
// SLAM always renders its live scan, including the forward beam. Navigation // SLAM always renders its live scan, including the forward beam. Navigation
// keeps the sensor opt-in so a saved-map view starts cleanly. // keeps the sensor opt-in so a saved-map view starts cleanly.
@@ -2087,84 +2098,27 @@ async function refreshSensorPointCloud() {
} }
} }
function setCameraPreviewAvailable(available) { const cameraPreviewController = createCameraPreview({
available = available && cameraPreviewEnabled; root: cameraPreviewList,
cameraPreviewWrap?.classList.toggle('is-visible', available); baseUrl: BACKEND_URL,
if (available) return; getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
if (cameraFrameObjectUrl) { onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
URL.revokeObjectURL(cameraFrameObjectUrl); });
cameraFrameObjectUrl = null;
}
if (cameraPreview) cameraPreview.removeAttribute('src');
}
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() {
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
const controller = new AbortController();
cameraFrameAbortController = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const frame = await response.blob();
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
window.clearTimeout(timeout);
cameraFrameAbortController = null;
cameraFrameRequestInFlight = false;
}
}
function setCameraPreviewEnabled(enabled) { function setCameraPreviewEnabled(enabled) {
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled; cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled); cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled)); cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); cameraPreviewController.setEnabled(cameraPreviewEnabled);
cameraFrameRefreshTimer = null;
if (!cameraPreviewEnabled) { if (!cameraPreviewEnabled) {
cameraFrameAbortController?.abort();
setCameraPreviewAvailable(false);
return; return;
} }
cameraFrameRetryAt = 0;
cameraPreviewWrap.classList.remove('is-collapsed'); cameraPreviewWrap.classList.remove('is-collapsed');
cameraPreviewToggle.setAttribute('aria-expanded', 'true'); cameraPreviewToggle.setAttribute('aria-expanded', 'true');
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기')); cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
} }
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled)); cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) { function drawMiniMap(state) {
pendingMiniMapState = state; pendingMiniMapState = state;
@@ -3299,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer); if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer); if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer); if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); cameraPreviewController.dispose();
cameraFrameAbortController?.abort();
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
}); });
function connectControlSocket() { function connectControlSocket() {
+1 -1
View File
@@ -3690,7 +3690,7 @@ function LocalPcWorkspaceMockup({
} }
} }
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => { const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.') if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
validateCodeReference(reference) validateCodeReference(reference)
const scope = agentNavigationScope const scope = agentNavigationScope
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
@@ -429,6 +429,8 @@ function AgentMessages({
) )
} }
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
{messages.map((message, index) => message.role === 'user' ? ( {messages.map((message, index) => message.role === 'user' ? (
@@ -447,7 +449,7 @@ function AgentMessages({
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}> <ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && ( {message.plan && (
<> <>
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />} {!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
{!!message.plan.evidence?.length && ( {!!message.plan.evidence?.length && (
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}> <details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary> <summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
@@ -460,11 +460,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => { const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
const message = messageStateRef.current.messages.find(message => message.id === messageId) const message = messageStateRef.current.messages.find(message => message.id === messageId)
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId) const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
if (!reference) return if (!reference) return
const latestMessageId = messageStateRef.current.messages.at(-1)?.id const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId) const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
const update = (state: AgentCodeReferenceState): void => { const update = (state: AgentCodeReferenceState): void => {
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message)) if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
@@ -472,7 +472,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
navigatingRef.current = true navigatingRef.current = true
update({ status: 'opening' }) update({ status: 'opening' })
try { try {
await open(reference, () => isCurrent() && messageStateRef.current.messages.at(-1)?.id === latestMessageId && !abortControllerRef.current) await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
update({ status: 'opened' }) update({ status: 'opened' })
} catch (error) { } catch (error) {
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' }) update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
+55
View File
@@ -0,0 +1,55 @@
const {test} = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const vm = require('node:vm')
const ts = require('typescript')
const {createHash} = require('node:crypto')
const {spawnSync} = require('node:child_process')
test('remote visualization stages the bundled bridge over stdin and mounts that revision read-only', async () => {
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'startRemoteVisualizationBridge')
assert.ok(fn)
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-camera-bridge-'))
const resource = path.resolve(__dirname, '../resources/slam-ros-bridge')
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
let launch, transfers = 0
const context = {
normalizeVisualizationStack: () => 'slam3d', normalizeRemoteTarget: target => target,
prepareRemoteRosNetworkConfig: async () => null, visualizationBridgeContainerName: () => 'slam-ros-bridge',
resolveVisualizationBridgeImage: () => 'fixture:arm64', bridgeArtifact: () => null,
slamRosBridgeImage: 'slam:local',
bridgeContainerCommand: () => 'python3 /app/bridge.py', resolveResourceContextPath: () => resource,
readFile: fs.readFile, join: path.join, createHash, quoteShellSingle: quote,
expandRemotePathVar: () => [], reportVisualizationProgress() {},
runUnqueuedSshOutput: async (_target, command, timeout, input) => {
transfers++
assert.equal(timeout, 30000)
assert.ok(input.length > 1000)
const {spawn} = require('node:child_process')
await new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', command], {env: {...process.env, HOME: dir}, stdio:['pipe','ignore','pipe']})
let error = ''
child.stderr.on('data', data => {error += data})
child.on('error', reject)
child.on('close', code => code === 0 ? resolve() : reject(Error(error)))
child.stdin.end(input)
})
return ''
},
runSsh: async (_target, command) => { launch = command }
}
vm.runInNewContext(ts.transpileModule(fn.getText(source), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText, context)
try {
await context.startRemoteVisualizationBridge({workspaceRoot:'/workspace'}, 'slam3d', '77')
assert.equal(transfers, 1)
assert.match(launch, /-v "\$HOME\/\.autonomy-studio\/runtime\/bridges\/[a-f0-9]{64}:\/app:ro"/)
const roots = await fs.readdir(path.join(dir, '.autonomy-studio/runtime/bridges'))
for (const name of ['bridge.py','requirements.txt']) assert.equal(await fs.readFile(path.join(dir,'.autonomy-studio/runtime/bridges',roots[0],name),'utf8'), await fs.readFile(path.join(resource,name),'utf8'))
const syntax = spawnSync('bash', ['-n'], {input:launch,encoding:'utf8'})
assert.equal(syntax.status, 0, syntax.stderr)
assert.ok(launch.includes('docker run'))
} finally {await fs.rm(dir,{recursive:true,force:true})}
})
+4 -1
View File
@@ -35,7 +35,10 @@ for (const script of [run, build]) {
} }
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/); assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/); assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/); assert.match(run, /--device "\$camera_device:\$camera_device"/);
assert.match(run, /CAMERA_DEVICE" = \/dev/);
assert.match(run, /mapfile -t CAMERA_DEVICES/);
assert.doesNotMatch(run, /-v \/dev:\/dev/);
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/); assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
assert.match(run, /--device "\$capture_device:\$capture_device"/); assert.match(run, /--device "\$capture_device:\$capture_device"/);
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/); assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
+101 -68
View File
@@ -1,70 +1,103 @@
const assert = require('node:assert/strict'); const { test } = require('node:test')
const fs = require('node:fs'); const assert = require('node:assert/strict')
const path = require('node:path'); const fs = require('node:fs')
const vm = require('node:vm'); const vm = require('node:vm')
const ts = require('typescript')
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8'); function harness() {
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'")); const exports = {}, requests = [], revoked = [], pending = new Map()
let now = 10000; let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
let requests = 0; let sequence = 0
let ok = true; function element() {
let visible = false; return { children: [], dataset: {}, src: '', append(...children) { this.children.push(...children); children.forEach(child => { child.parent = this }) }, remove() { this.parent.children = this.parent.children.filter(child => child !== this) }, removeAttribute() { this.src = '' }, addEventListener() {} }
let afterBlob = () => {}; }
const context = vm.createContext({ const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
ENABLE_CAMERA_PREVIEW: true, const globals = { exports, document, AbortController, Date: { now: () => now },
cameraFrameRequestInFlight: false, window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
cameraFrameRetryAt: 0, URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
cameraFrameObjectUrl: null, fetch: async (url, options) => {
cameraPreview: {}, requests.push({ url, signal: options.signal })
latestSlamState: null, if (pending.has(url)) await pending.get(url)
latestSlamStateReceivedAt: now, return { ok: enabledFetch, blob: async () => ({}) }
document: { hidden: false }, }
Date: { now: () => now }, }
BACKEND_URL: 'http://localhost:8765', vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/public/slam-viewer/camera-preview.js'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, globals)
AbortSignal, const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} }, return { root, requests, revoked, pending, document, controller,
setCameraPreviewAvailable(value) { visible = value; }, state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
async fetch() { settle: () => new Promise(resolve => setImmediate(resolve)) }
requests++; }
return { ok, async blob() { afterBlob(); return {}; } };
},
});
vm.runInContext(source, context);
const refresh = () => context.refreshCameraFrame();
const available = (value) => {
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
context.latestSlamStateReceivedAt = now;
};
(async () => { test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
await refresh(); const h = harness()
available(false); h.controller.setEnabled(true)
await refresh(); assert.equal(h.requests.length, 0)
assert.equal(requests, 0, 'Absent camera must not fetch frames'); h.state({ topic_status: { camera_image: { available: true } } })
available(true); h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
assert.equal(requests, 1); assert.equal(h.root.children[0].children[1].src, 'blob:1')
assert.equal(context.cameraPreview.src, 'blob:frame'); h.document.hidden = true
now += 3001; h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.requests.length, 1)
assert.equal(requests, 1, 'Stale websocket state must stop polling'); h.advance(3001)
assert.equal(visible, false); h.controller.refresh()
available(true); assert.equal(h.visible(), false)
ok = false; assert.equal(h.root.children.length, 0)
await refresh(); assert.deepEqual(h.revoked, ['blob:1'])
await refresh(); })
assert.equal(requests, 2, 'HTTP errors must back off');
now += 5000; test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
available(true); const h = harness()
ok = true; h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
afterBlob = () => available(false); h.controller.setEnabled(true); await h.settle()
context.cameraPreview.src = ''; assert.equal(h.root.children.length, 2)
await refresh(); assert.equal(h.requests.length, 2)
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera'); assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
assert.equal(context.cameraFrameRequestInFlight, false); const first = h.root.children[0].children[1].src
afterBlob = () => {}; h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
available(true); h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.root.children.length, 1)
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames'); assert.equal(h.root.children[0].dataset.cameraId, 'video2')
console.log('camera preview polling tests passed'); assert.ok(h.revoked.includes(first))
})().catch((error) => { console.error(error); process.exitCode = 1; }); assert.equal(h.visible(), true)
h.controller.dispose()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('in-flight requests are bounded and late frames cannot resurrect a removed or disabled camera', async () => {
const h = harness()
let release
h.pending.set('http://camera.test/camera-frame?camera_id=video0', new Promise(resolve => { release = resolve }))
h.state({ cameras: [{ id: 'video0', available: true }] })
h.controller.setEnabled(true)
h.controller.refresh()
assert.equal(h.requests.length, 1)
h.controller.setEnabled(false)
assert.equal(h.requests[0].signal.aborted, true)
release(); await h.settle()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('HTTP failures back off per camera and reconnecting restores frames', async () => {
const h = harness()
h.state({ cameras: [{ id: 'video0', available: true }] })
h.fail(true)
h.controller.setEnabled(true); await h.settle()
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 1)
h.advance(5000); h.state({ cameras: [{ id: 'video0', available: true }] }); h.fail(false)
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 2)
assert.ok(h.root.children[0].children[1].src.startsWith('blob:'))
})
test('camera identifiers are validated, deduplicated and limited without executing names as markup', async () => {
const h = harness()
h.state({ cameras: [{ id: '../etc', available: true }, ...Array.from({ length: 12 }, (_, i) => ({ id: `video${i}`, available: true, name: '<script>unsafe</script>' })), { id: 'video0', available: true }] })
h.controller.setEnabled(true); await h.settle()
assert.equal(h.requests.length, 8)
assert.equal(h.root.children.length, 8)
assert.equal(h.root.children[0].children[0].textContent, '<script>unsafe</script>')
})