diff --git a/package.json b/package.json index dfcdbf4..c7b4726 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.28", + "version": "1.1.29", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/resources/slam-ros-bridge/bridge.py b/resources/slam-ros-bridge/bridge.py index bb306c1..5adfe70 100644 --- a/resources/slam-ros-bridge/bridge.py +++ b/resources/slam-ros-bridge/bridge.py @@ -27,6 +27,7 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath from nav2_msgs.action import NavigateToPose from pydantic import BaseModel from rclpy.action import ActionClient +from rclpy.clock import Clock, ClockType from rclpy.executors import MultiThreadedExecutor from rclpy.node import Node from rclpy.parameter import Parameter @@ -132,6 +133,7 @@ def resolve_2d_transform(transforms: dict, target_frame: str, source_frame: str) class VelocityPayload(BaseModel): linear: float = 0.0 angular: float = 0.0 + deadman: bool = False class SteeringPayload(BaseModel): @@ -257,6 +259,8 @@ class SlamNode(Node): if self.color_point_cloud_topic not in (self.point_cloud_topic, self.fallback_point_cloud_topic): self.create_subscription(PointCloud2, self.color_point_cloud_topic, self._on_color_point_cloud, 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"): + self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data) 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(OccupancyGrid, self.map_topic, self._on_map, map_qos) @@ -268,6 +272,10 @@ class SlamNode(Node): self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data) self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos) self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10) + self._teleop_lock = threading.Lock() + self._teleop_deadline = None + self._teleop_timer = self.create_timer( + 0.1, self._check_teleop_timeout, clock=Clock(clock_type=ClockType.STEADY_TIME)) self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10) self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10) self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action) @@ -817,13 +825,27 @@ class SlamNode(Node): } self._tf_count += 1 - def publish_cmd_vel(self, linear: float, angular: float): + def publish_cmd_vel(self, linear: float, angular: float, deadman: bool = False): if self.read_only: raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.") + if not math.isfinite(linear) or not math.isfinite(angular): + raise RuntimeError("Invalid velocity") + if deadman: + linear = max(-0.1, min(0.1, linear)) + angular = max(-0.5, min(0.5, angular)) msg = Twist() msg.linear.x = float(linear) msg.angular.z = float(angular) - self._cmd_vel_pub.publish(msg) + with self._teleop_lock: + self._teleop_deadline = time.monotonic() + 0.5 if deadman and (linear or angular) else None + self._cmd_vel_pub.publish(msg) + + def _check_teleop_timeout(self): + # Only stop expired manual commands, never idle autonomous navigation. + with self._teleop_lock: + if self._teleop_deadline is not None and time.monotonic() >= self._teleop_deadline: + self._teleop_deadline = None + self._cmd_vel_pub.publish(Twist()) def set_simulator_steering(self, angle: float) -> None: if self.read_only: @@ -1192,6 +1214,7 @@ class SlamNode(Node): return { "ok": True, + "teleop_deadman": not self.read_only, "topics": { "odom": self.odom_topic, "scan": self.scan_topic, @@ -1549,7 +1572,7 @@ def debug_topics(): def cmd_vel(payload: VelocityPayload): if not slam_node: raise HTTPException(status_code=503, detail="ROS node is not ready") - slam_node.publish_cmd_vel(payload.linear, payload.angular) + slam_node.publish_cmd_vel(payload.linear, payload.angular, payload.deadman) return {"ok": True} @@ -1643,11 +1666,12 @@ async def ws_control(websocket: WebSocket): velocity = VelocityPayload( linear=payload.get("linear", 0.0), angular=payload.get("angular", 0.0), + deadman=payload.get("deadman", False), ) except (TypeError, ValueError): continue try: - slam_node.publish_cmd_vel(velocity.linear, velocity.angular) + slam_node.publish_cmd_vel(velocity.linear, velocity.angular, velocity.deadman) except RuntimeError: continue except WebSocketDisconnect: diff --git a/resources/slam-ros-bridge/test_teleop.py b/resources/slam-ros-bridge/test_teleop.py new file mode 100644 index 0000000..3dece97 --- /dev/null +++ b/resources/slam-ros-bridge/test_teleop.py @@ -0,0 +1,70 @@ +import ast +import math +from pathlib import Path +import threading +from types import SimpleNamespace +import unittest + + +class Twist: + def __init__(self): + self.linear = SimpleNamespace(x=0.0) + self.angular = SimpleNamespace(z=0.0) + + +class TeleopTest(unittest.TestCase): + def setUp(self): + self.now = 10.0 + self.messages = [] + 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') + methods = [item for item in node.body if isinstance(item, ast.FunctionDef) + and item.name in ('publish_cmd_vel', '_check_teleop_timeout')] + scope = {'math': math, 'Twist': Twist, 'time': SimpleNamespace(monotonic=lambda: self.now)} + exec(compile(ast.Module(body=methods, type_ignores=[]), 'bridge-teleop', 'exec'), scope) + cls = type('Teleop', (), {name: scope[name] for name in ('publish_cmd_vel', '_check_teleop_timeout')}) + self.node = cls() + self.node.read_only = False + self.node._teleop_lock = threading.Lock() + self.node._teleop_deadline = None + self.node._cmd_vel_pub = SimpleNamespace(publish=lambda msg: self.messages.append((msg.linear.x, msg.angular.z))) + + def test_low_speed_and_expiry(self): + self.node.publish_cmd_vel(5.0, -5.0, True) + self.assertEqual(self.messages[-1], (0.1, -0.5)) + self.now += 0.6 + self.node._check_teleop_timeout() + self.assertEqual(self.messages[-1], (0.0, 0.0)) + count = len(self.messages) + self.node._check_teleop_timeout() + self.assertEqual(len(self.messages), count) + + def test_heartbeat_and_release(self): + self.node.publish_cmd_vel(0.1, 0, True) + self.now += 0.4 + self.node.publish_cmd_vel(0.1, 0, True) + self.now += 0.2 + self.node._check_teleop_timeout() + self.assertEqual(len(self.messages), 2) + self.node.publish_cmd_vel(0, 0, True) + self.assertIsNone(self.node._teleop_deadline) + + def test_simulation_and_idle_navigation_unchanged(self): + self.node._check_teleop_timeout() + self.assertEqual(self.messages, []) + self.node.publish_cmd_vel(0.22, 2.84) + self.now += 2 + self.node._check_teleop_timeout() + self.assertEqual(self.messages, [(0.22, 2.84)]) + + def test_invalid_and_readonly_rejected(self): + with self.assertRaises(RuntimeError): + self.node.publish_cmd_vel(float('nan'), 0, True) + self.node.read_only = True + with self.assertRaises(RuntimeError): + self.node.publish_cmd_vel(0.1, 0, True) + self.assertEqual(self.messages, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/main/index.ts b/src/main/index.ts index cdcf4ef..b009c99 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,8 @@ import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { checkDockerStatus } from './dockerStatus' +import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' +import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress' import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts' import type { WebContents } from 'electron' import { spawn } from 'child_process' @@ -1359,6 +1361,8 @@ function resolveNav2RuntimeImage( folderName?: string | null, rosParams = '', ): string { + const yahboomImage = yahboomRuntimeImage(folderName) + if (yahboomImage) return yahboomImage // NAV2-MKMini normally targets the real platform image. Gazebo is an // explicit local opt-in, so existing platform launches keep their image. const mkminiGazebo = /lms-nav2-mkmini/i.test(folderName ?? '') @@ -1699,7 +1703,24 @@ function remoteRepositoryUrl(repositoryUrl: string): string { return repositoryUrl } +function handleModuleOperation(channel: string, handler: Parameters[1]): void { + ipcMain.handle(channel, (event, ...args) => { + const remote = channel.startsWith('remote-') + const folderName = String(args[2] || args[remote ? 1 : 0]) + const send = (message: string): void => { + if (!event.sender.isDestroyed()) event.sender.send('module:progress', folderName, message) + } + return moduleProgress.run(send, () => visualizationProgress.run((message) => { + if (!message.startsWith('시각화')) send(message) + }, async () => { + send('모듈 작업 준비 중...') + try { return await handler(event, ...args) } finally { send('') } + })) + }) +} + async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise { + const readProgress = createModuleProgressParser() return new Promise((resolvePromise, rejectPromise) => { let output = '' let terminated = false @@ -1729,8 +1750,10 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e rejectPromise(new Error(`${command} ${args.join(' ')} timed out`)) }, timeoutMs) - child.stdout.on('data', (data: Buffer) => { + child.stdout.setEncoding('utf8') + child.stdout.on('data', (data: string) => { output += data.toString() + readProgress(data.toString()) }) child.stderr.on('data', (data: Buffer) => { output += data.toString() @@ -2331,18 +2354,18 @@ async function syncRemoteSavedMapArtifacts( 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, `MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`, - 'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then', - ' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then', - ' IMAGE="future-nav2:humble-jetson_orin"', - ' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then', - ' IMAGE="future-nav2:humble"', - ' else', - ' IMAGE=""', - ' fi', + // A missing directory is normal on first install, not a permission failure. + 'mkdir -p "$MAPS_DIRECTORY" 2>/dev/null || true', + 'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ] || [ ! -x "$MAPS_DIRECTORY" ]; then', + ' IMAGE=""', + ' for CANDIDATE in future-nav2:humble-yahboom future-nav2:humble-yahboom-agent future-nav2:humble-jetson_orin future-nav2:humble; do', + ' if docker image inspect "$CANDIDATE" >/dev/null 2>&1; then IMAGE="$CANDIDATE"; break; fi', + ' done', ' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi', ' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"', 'fi', 'mkdir -p "$MAPS_DIRECTORY"', + 'test -w "$MAPS_DIRECTORY" && test -x "$MAPS_DIRECTORY" || { echo "저장된 맵 디렉터리 권한 복구에 실패했습니다: $MAPS_DIRECTORY" >&2; exit 1; }', ...downloads.map((artifact) => ( `curl -fsSL --retry 2 ${quoteShellSingle(artifact.url)} -o ${quoteShellSingle(`${mapsDirectory}/${artifact.fileName}`)}` )), @@ -4573,6 +4596,7 @@ function buildNav2DockerRunScript(): string { ' )', 'else', 'case "$MODULE_FOLDER" in', + ...yahboomModuleCases(), ' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', ' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', @@ -4609,6 +4633,7 @@ function buildNav2DockerRunScript(): string { 'fi', '', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', + ...yahboomDevicePreflight(), 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', '', 'args=(', @@ -4625,6 +4650,7 @@ function buildNav2DockerRunScript(): string { ' -v "$(dirname "$0"):/runtime/nav2/docker:ro"', ' -v /etc/localtime:/etc/localtime:ro', ')', + 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', 'fi', @@ -4667,6 +4693,7 @@ function buildNav2DockerBuildScript(): string { ' )', 'else', 'case "$MODULE_FOLDER" in', + ...yahboomModuleCases(true), ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;', @@ -4897,7 +4924,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise match[1]) const robotModel = modelNames.find((modelName) => ( - /turtlebot|burger|mkmini|mk-mini|robot/i.test(modelName) && !/world/i.test(modelName) + /turtlebot|burger|mkmini|mk-mini|yahboom|robot/i.test(modelName) && !/world/i.test(modelName) )) if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.') return robotModel @@ -5017,6 +5044,29 @@ async function clearNav2RuntimeObstacles(): Promise<{ ok: boolean; message: stri return { ok: true, message: '장애물을 제거했습니다.' } } +async function resolveLocalModuleRuntime( + moduleRoot: string, + folderName: string, + rosParams: string, + runtimeImages?: RuntimeImageArtifact[] | null, +): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> { + if (yahboomSimulation(folderName, rosParams)) { + const artifact = runtimeImages?.find((item) => + item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim()) + ?? defaultNav2GazeboRuntimeImage + return { image: 'future-nav2-gazebo:humble', artifacts: [artifact] } + } + const image = await resolveCustomModuleRuntimeImage( + moduleRoot, resolveNav2RuntimeImage(runtimeImages, folderName, rosParams), + ) + return { + image, + artifacts: yahboomRuntimeImage(folderName) + ? yahboomRuntimeArtifacts(folderName, image, runtimeImages) + : requiredRuntimeImages(runtimeImages, image), + } +} + async function startWorkspaceModule( moduleName: string, workspaceRoot?: string | null, @@ -5044,11 +5094,10 @@ async function startWorkspaceModule( const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const scriptPath = resolveNav2DockerRunScript() - const runtimeImage = await resolveCustomModuleRuntimeImage( - moduleRoot, - resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), - ) - await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage)) + const runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages) + const runtimeImage = runtime.image + await prepareRuntimeImages(runtime.artifacts) + reportModuleProgress("모듈 실행 중...") await runCommand( 'bash', [ @@ -5072,6 +5121,7 @@ async function startWorkspaceModule( } await prepareRuntimeImages(runtimeImages) + reportModuleProgress("모듈 실행 중...") await ensureStudioRuntimeAssets() const scriptPath = resolveAutowareDockerRunScript() if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) @@ -5129,11 +5179,10 @@ async function buildWorkspaceModule( if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) - const runtimeImage = await resolveCustomModuleRuntimeImage( - moduleRoot, - resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), - ) - await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage)) + const runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages) + const runtimeImage = runtime.image + await prepareRuntimeImages(runtime.artifacts) + reportModuleProgress("모듈 빌드 중...") await runCommand( 'bash', [ @@ -5151,6 +5200,7 @@ async function buildWorkspaceModule( } await prepareRuntimeImages(runtimeImages) + reportModuleProgress("모듈 빌드 중...") await runCommand( 'docker', [ @@ -5173,6 +5223,7 @@ async function prepareRemoteRuntimeImages( target: RemoteWorkspaceTarget, runtimeImages?: RuntimeImageArtifact[] | null, ): Promise { + reportModuleProgress('이미지 준비 대기 중...') const artifacts = (runtimeImages ?? []) .filter((image) => image?.image?.trim()) .map((image) => ({ @@ -5208,12 +5259,18 @@ async function prepareRemoteRuntimeImages( ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"', ' path = os.path.join(cache, name)', ' if os.path.exists(path): os.unlink(path)', + ' print("LMS_MODULE_PROGRESS:이미지 다운로드 중...", flush=True)', ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', + ' print("LMS_MODULE_PROGRESS:이미지 파일 확인 중...", flush=True)', ' expected = (item.get("checksumSha256") or "").strip().lower()', ' if expected:', ' import hashlib', - ' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', + ' digest = hashlib.sha256()', + ' with open(path, "rb") as fp:', + ' for chunk in iter(lambda: fp.read(1024 * 1024), b""): digest.update(chunk)', + ' actual = digest.hexdigest().lower()', ' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")', + ' print("LMS_MODULE_PROGRESS:이미지 불러오는 중...", flush=True)', ' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):', ' import shlex', ' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', @@ -5255,14 +5312,15 @@ async function startRemoteWorkspaceModule( ? await resolveRemoteCustomModuleRuntimeImage( target, targetFolderName, - await resolveRemoteNav2RuntimeImage(runtimeImages), + yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages), ) : null const runtimeRoot = nav2Module ? await ensureRemoteNav2RuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target) const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) - await prepareRemoteRuntimeImages(target, runtimeImages) + await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) + reportModuleProgress("모듈 실행 중...") const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (currentStatus.trim()) { @@ -5352,9 +5410,10 @@ async function buildRemoteWorkspaceModule( const runtimeImage = await resolveRemoteCustomModuleRuntimeImage( target, targetFolderName, - await resolveRemoteNav2RuntimeImage(runtimeImages), + yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages), ) - await prepareRemoteRuntimeImages(target, runtimeImages) + await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) + reportModuleProgress("모듈 빌드 중...") await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, @@ -6286,8 +6345,8 @@ function registerLocalWorkspaceHandlers(): void { return importCustomWorkspaceModule(workspaceRoot, input) }) ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) - ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) - ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) + handleModuleOperation('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) + handleModuleOperation('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) @@ -6349,8 +6408,8 @@ function registerLocalWorkspaceHandlers(): void { if (!moduleName?.trim()) throw new Error('전송할 모듈명이 없습니다.') return transferLocalWorkspaceModuleToRemote(target, sourceWorkspaceRoot, moduleName, folderName) }) - ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) - ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages)) + handleModuleOperation('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) + handleModuleOperation('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName)) ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules)) ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => resetRemoteWorkspaceSession(target, modules)) @@ -6535,8 +6594,8 @@ app.whenReady().then(() => { ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) - ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) - ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) + handleModuleOperation('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) + handleModuleOperation('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) diff --git a/src/main/moduleProgress.test.ts b/src/main/moduleProgress.test.ts new file mode 100644 index 0000000..885d6e0 --- /dev/null +++ b/src/main/moduleProgress.test.ts @@ -0,0 +1,28 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { createRequire } from 'node:module' + +const { moduleProgress, createModuleProgressParser } = createRequire(import.meta.url)('./moduleProgress.ts') as typeof import('./moduleProgress') + +test('progress parser handles split lines and ignores normal command output', () => { + const messages: string[] = [] + const parse = moduleProgress.run((message) => messages.push(message), createModuleProgressParser) + parse('ordinary output\nLMS_MODULE_PRO') + parse('GRESS:download\nLMS_MODULE_PROGRESS:load\n') + assert.deepEqual(messages, ['download', 'load']) +}) + +test('concurrent operations retain their own progress callbacks', () => { + const first: string[] = [] + const second: string[] = [] + const a = moduleProgress.run((message) => first.push(message), createModuleProgressParser) + const b = moduleProgress.run((message) => second.push(message), createModuleProgressParser) + b('LMS_MODULE_PROGRESS:build\n') + a('LMS_MODULE_PROGRESS:download\n') + assert.deepEqual(first, ['download']) + assert.deepEqual(second, ['build']) +}) + +test('commands outside a module operation do not report progress', () => { + assert.doesNotThrow(() => createModuleProgressParser()('LMS_MODULE_PROGRESS:ignored\n')) +}) diff --git a/src/main/moduleProgress.ts b/src/main/moduleProgress.ts new file mode 100644 index 0000000..92d40b3 --- /dev/null +++ b/src/main/moduleProgress.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +export const moduleProgress = new AsyncLocalStorage<(message: string) => void>() + +export function reportModuleProgress(message: string): void { + moduleProgress.getStore()?.(message) +} + +export function createModuleProgressParser(): (chunk: string) => void { + const report = moduleProgress.getStore() + let pending = '' + return (chunk) => { + if (!report) return + pending += chunk + const lines = pending.split('\n') + pending = lines.pop()!.slice(-4096) + for (const line of lines) { + if (line.startsWith('LMS_MODULE_PROGRESS:')) report(line.slice('LMS_MODULE_PROGRESS:'.length).trim()) + } + } +} diff --git a/src/main/yahboomRuntime.test.ts b/src/main/yahboomRuntime.test.ts new file mode 100644 index 0000000..c5f2269 --- /dev/null +++ b/src/main/yahboomRuntime.test.ts @@ -0,0 +1,115 @@ +import { strict as assert } from 'node:assert' +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { test } from 'node:test' +import { createRequire } from 'node:module' +import ts from 'typescript' +const { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } = createRequire(import.meta.url)('./yahboomRuntime.ts') as typeof import('./yahboomRuntime') + +test('simulation is an explicit opt-in and excludes physical device modules', () => { + for (const role of ['vehicle', 'slam', 'map', 'localization', 'planning', 'control']) { + const folder = `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws` + assert.equal(yahboomSimulation(folder, ''), false) + assert.equal(yahboomSimulation(folder, 'sim_mode:=false'), false) + assert.equal(yahboomSimulation(folder, 'foo:=bar sim_mode:=true'), true) + } + for (const role of ['agent', 'camera']) { + assert.equal(yahboomSimulation(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`, 'sim_mode:=true'), false) + } + assert.equal(yahboomSimulation('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', 'sim_mode:=true'), false) +}) + +test('local simulation overrides ARM metadata and downloads only the PC image', async () => { + const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true) + const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === 'resolveLocalModuleRuntime') + assert.ok(declaration) + const artifact = { image: 'future-nav2-gazebo:humble', url: 'https://example.com/gazebo.tar.zst' } + const resolver = new Function('yahboomSimulation', 'defaultNav2GazeboRuntimeImage', 'resolveCustomModuleRuntimeImage', + ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) + '; return resolveLocalModuleRuntime;' + )(yahboomSimulation, artifact, () => { throw new Error('ARM metadata must not override simulation') }) + const result = await resolver('/module', '[lms-nav2-yahboom-vehicle]yahboom_vehicle_v1.0.0_ws', 'sim_mode:=true', + [{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/arm.tar.zst' }]) + assert.deepEqual(result, { image: artifact.image, artifacts: [artifact] }) +}) + +test('remote Yahboom downloads only its own image and preserves explicit overrides', () => { + const configured = [{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/jetson.tar.zst' }] + assert.equal(yahboomRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', configured[0].image, configured), configured) + const folder = '[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws' + const image = 'future-nav2:humble-yahboom-agent' + const selected = yahboomRuntimeArtifacts(folder, image, configured) + assert.equal(selected?.length, 1) + assert.equal(selected?.[0].image, image) + assert.match(selected?.[0].checksumSha256 ?? '', /^[a-f0-9]{64}$/) + const override = { image, url: 'https://example.com/override.tar.zst' } + assert.deepEqual(yahboomRuntimeArtifacts(folder, image, [...configured, override]), [override]) + assert.deepEqual(yahboomRuntimeArtifacts(folder, 'custom:tag', configured), []) +}) + +function runtimeScript(name: string): string { + const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true) + const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === name) + assert.ok(declaration) + const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) + return new Function('yahboomModuleCases', 'yahboomDevicePreflight', `${code}; return ${name}();`)(yahboomModuleCases, yahboomDevicePreflight) +} + +test('Yahboom image routing does not change MKMini, Gazebo or custom modules', () => { + assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'), 'future-nav2:humble-yahboom-agent') + assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-slam]yahboom_slam_v1.0.0_ws'), 'future-nav2:humble-yahboom') + for (const name of ['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', '[lms-nav2-custom]yahboom_agent_v1.0.0_ws', '[lms-nav2-yahboom-agent]other_v1.0.0_ws']) { + assert.equal(yahboomRuntimeImage(name), null) + } +}) + +test('all eight templates map to their own package in both runtime scripts', () => { + for (const build of [false, true]) { + for (const role of ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera']) { + const script = ['case "$1" in', ...yahboomModuleCases(build), 'esac', build ? 'printf "%s" "$TARGET"' : 'printf "%s %s" "$PACKAGE" "$LAUNCH"'].join('\n') + const result = spawnSync('bash', ['-c', script, '_', `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`], { encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stdout, build ? `yahboom_${role}` : `yahboom_${role} ${role}.launch.py`) + } + } +}) + +test('generated build and run shell scripts are syntactically valid', () => { + for (const name of ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript']) { + const result = spawnSync('bash', ['-n'], { input: runtimeScript(name), encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + } +}) + +test('invalid hardware path fails before Docker is called; MKMini still launches without extra device', () => { + const root = mkdtempSync(join(tmpdir(), 'yahboom-runtime-')) + try { + const bin = join(root, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'docker'), '#!/bin/bash\nprintf "%s\\n" "$@"\n', { mode: 0o755 }) + const script = join(root, 'docker_run_nav2.sh') + writeFileSync(script, runtimeScript('buildNav2DockerRunScript')) + for (const [folder, parameter] of [ + ['[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws', 'serial_device:=/etc/passwd'], + ['[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws', 'video_device:=/dev/../etc/passwd'], + ['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', ''], + ]) { + mkdirSync(join(root, folder, 'install'), { recursive: true }) + writeFileSync(join(root, folder, 'install/setup.bash'), '') + const result = spawnSync('bash', [script, 'test-container', root, folder, 'test-image', '77', 'false', parameter], { + encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DISPLAY: '', FASTDDS_CONFIG_PATH: '' }, + }) + if (parameter) { + assert.equal(result.status, 64) + assert.equal(result.stdout, '') + } else { + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /vehicle_pkg\nvehicle.launch.py/) + assert.doesNotMatch(result.stdout, /ttyUSB|video0|privileged/) + } + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/yahboomRuntime.ts b/src/main/yahboomRuntime.ts new file mode 100644 index 0000000..9869747 --- /dev/null +++ b/src/main/yahboomRuntime.ts @@ -0,0 +1,70 @@ +const roles = ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera'] as const + +export function yahboomSimulation(folderName: string | null | undefined, rosParams: string): boolean { + return Boolean(yahboomRuntimeImage(folderName)) + && !/yahboom-(agent|camera)\]/.test(folderName ?? '') + && /(?:^|\s)sim_mode:=true(?:\s|$)/.test(rosParams) +} + +type RuntimeArtifact = { + image?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + required?: boolean | null +} + +const artifactRoot = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/yahboom/arm64' +const artifacts: RuntimeArtifact[] = [ + ['future-nav2:humble-yahboom', 'future-nav2-humble-yahboom.tar.zst', 'b2f168c45b961ab96badeeb7df68d1c997996c75dcb5e0cc677b0b6669b1ffd2'], + ['future-nav2:humble-yahboom-agent', 'future-nav2-humble-yahboom-agent.tar.zst', 'e30404506c320af1d53c8733177eaf27997dcb2864019b8fcc6888874f02d191'], +].map(([image, fileName, checksumSha256]) => ({ + image, fileName, checksumSha256, required: true, + url: `${artifactRoot}/${checksumSha256.slice(0, 16)}/${fileName}`, +})) + +export function yahboomRuntimeArtifacts( + folderName: string, + runtimeImage: string | null, + configured?: RuntimeArtifact[] | null, +): RuntimeArtifact[] | null | undefined { + if (!yahboomRuntimeImage(folderName)) return configured + const selected = configured?.find((item) => item.image?.trim() === runtimeImage && item.url?.trim()) + ?? artifacts.find((item) => item.image === runtimeImage) + // Yahboom must not download unrelated Jetson images from an existing profile. + return selected ? [selected] : [] +} + +export function yahboomRuntimeImage(folderName?: string | null): string | null { + const role = folderName?.match(/^\[lms-nav2-yahboom-([a-z]+)\]yahboom_\1_v[^/]+_ws$/)?.[1] + if (!roles.some((value) => value === role)) return null + return role === 'agent' ? 'future-nav2:humble-yahboom-agent' : 'future-nav2:humble-yahboom' +} + +export function yahboomModuleCases(build = false): string[] { + return roles.map((role) => ( + ` "[lms-nav2-yahboom-${role}]yahboom_${role}_v"*_ws) ${build ? `TARGET=yahboom_${role}` : `PACKAGE=yahboom_${role}; LAUNCH=${role}.launch.py`} ;;` + )) +} + +// Keep hardware exposure limited to the two hardware-facing Yahboom templates. +export function yahboomDevicePreflight(): string[] { + return [ + 'YAHBOOM_DEVICE=""', + 'case "$MODULE_FOLDER" in', + ' "[lms-nav2-yahboom-agent]yahboom_agent_v"*_ws)', + ' YAHBOOM_DEVICE=/dev/ttyUSB0', + ' for arg in "$@"; do case "$arg" in serial_device:=*) YAHBOOM_DEVICE=${arg#serial_device:=} ;; esac; done', + ' case "$YAHBOOM_DEVICE" in /dev/ttyUSB*|/dev/ttyACM*|/dev/serial/by-id/*) ;; *) echo "Invalid Yahboom serial device" >&2; exit 64 ;; esac', + ' ;;', + ' "[lms-nav2-yahboom-camera]yahboom_camera_v"*_ws)', + ' YAHBOOM_DEVICE=/dev/video0', + ' for arg in "$@"; do case "$arg" in video_device:=*) YAHBOOM_DEVICE=${arg#video_device:=} ;; esac; done', + ' [[ "$YAHBOOM_DEVICE" =~ ^/dev/video[0-9]+$ ]] || { echo "Invalid Yahboom camera device" >&2; exit 64; }', + ' ;;', + 'esac', + 'if [ -n "$YAHBOOM_DEVICE" ]; then', + ' [[ "$YAHBOOM_DEVICE" != *".."* ]] && [ -c "$YAHBOOM_DEVICE" ] || { echo "Yahboom device not found: $YAHBOOM_DEVICE" >&2; exit 1; }', + 'fi', + ] +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b3ee838..f2c4337 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -197,6 +197,11 @@ contextBridge.exposeInMainWorld('visualization', { stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile) }) contextBridge.exposeInMainWorld('localWorkspace', { + onModuleProgress: (callback: (folderName: string, message: string) => void) => { + const listener = (_event: Electron.IpcRendererEvent, folderName: string, message: string) => callback(folderName, message) + ipcRenderer.on('module:progress', listener) + return () => ipcRenderer.removeListener('module:progress', listener) + }, docker: { status: () => ipcRenderer.invoke('local-workspace:docker:status'), openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'), diff --git a/src/renderer/public/assets/images/module-guide-remote-3d.png b/src/renderer/public/assets/images/module-guide-remote-3d.png index ad6c36f..ea36b4c 100644 Binary files a/src/renderer/public/assets/images/module-guide-remote-3d.png and b/src/renderer/public/assets/images/module-guide-remote-3d.png differ diff --git a/src/renderer/public/assets/images/module-guide-yahboom.png b/src/renderer/public/assets/images/module-guide-yahboom.png new file mode 100644 index 0000000..5a180be Binary files /dev/null and b/src/renderer/public/assets/images/module-guide-yahboom.png differ diff --git a/src/renderer/public/slam-viewer/assets/yahboom-pi5.glb b/src/renderer/public/slam-viewer/assets/yahboom-pi5.glb new file mode 100644 index 0000000..7408b29 Binary files /dev/null and b/src/renderer/public/slam-viewer/assets/yahboom-pi5.glb differ diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index bb7f9a0..a6dc9aa 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -48,11 +48,11 @@ } #cameraPreviewWrap { position:absolute; - right:16px; - bottom:256px; + left:16px; + top:48px; z-index:10; display:none; - width:260px; + width:min(260px, calc(100vw - 32px)); border:1px solid rgba(0,0,0,.45); background:#111520; box-shadow:0 10px 26px rgba(0,0,0,.16); @@ -61,16 +61,16 @@ #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; } #cameraPreview { display:block; - width:260px; + width:100%; aspect-ratio:16 / 9; - object-fit:cover; + object-fit:contain; background:#111520; } #cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewToggle { position:absolute; right:0; - top:-28px; + top:100%; display:grid; width:30px; height:28px; @@ -86,8 +86,8 @@ box-shadow:0 10px 28px rgba(0,0,0,.16); } #cameraPreviewToggle:hover { color:#fff; background:rgba(255,255,255,.06); } - #cameraPreviewChevron { font-size:13px; line-height:1; } - #cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(180deg); } + #cameraPreviewChevron { font-size:13px; line-height:1; transform:rotate(180deg); } + #cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(0deg); } #topRightControls { position:absolute; right:16px; @@ -369,6 +369,8 @@ .nav2-navigation #rosTopicStatusWrap { bottom:16px; } .real-runtime #topLeftControls { display:none; } .real-runtime #keyGuide { display:none; } + .real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; } + .real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; } @@ -441,12 +443,12 @@ const REAL_RUNTIME = params.get('runtime') === 'real'; const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase(); // Keep the prior real-platform default when an older LMS URL has no platform. const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME); +const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom'; +const REAL_YAHBOOM_TELEOP = REAL_RUNTIME && USE_YAHBOOM_MODEL; const SHOW_POINT_CLOUD = params.get('pointCloud') === '1'; const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD; const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000; -// Camera preview is kept for the RGB test module, but is paused for now so -// regular visualization sessions never poll `/camera-frame`. -const ENABLE_CAMERA_PREVIEW = false; +const ENABLE_CAMERA_PREVIEW = params.get('editor') !== '1'; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map'; const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; @@ -493,6 +495,12 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList'); if (EDITOR_MODE) document.body.classList.add('editor-mode'); if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`); if (REAL_RUNTIME) document.body.classList.add('real-runtime'); + if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) { + document.body.classList.add('real-yahboom-teleop'); + const hint = document.createElement('div'); + hint.textContent = '키를 누르는 동안 이동 · 놓으면 정지'; + document.getElementById('keyGuide')?.appendChild(hint); + } if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); let visualizationResponseReceived = EDITOR_MODE; @@ -868,6 +876,7 @@ let laserFrontBeam = null; let slamSocket = null; let controlSocket = null; let latestSlamState = null; +let latestSlamStateReceivedAt = 0; let currentRobotPose = { x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 }; let targetRobotPose = { ...currentRobotPose }; let hasRobotPose = false; @@ -1385,6 +1394,42 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0 sdfRoot.add(posePreviewModel); } +async function loadYahboomModel(sdfPose) { + const gltf = await new GLTFLoader().loadAsync('./assets/yahboom-pi5.glb'); + const robotGroup = new THREE.Group(); + robotGroup.name = 'yahboom'; + applySdfPose(robotGroup, sdfPose); + // GLB is meter-scaled Y-up; the viewer's SDF root expects ROS Z-up. + // The exported origin is already base_footprint, so do not recenter it. + gltf.scene.rotation.x = Math.PI / 2; + gltf.scene.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + // The world-scale shadow map causes self-shadow acne on small CAD parts. + child.receiveShadow = false; + }); + robotGroup.add(gltf.scene); + sdfRoot.add(robotGroup); + robotModel = robotGroup; + robotModel.visible = shouldRenderRobot(); + posePreviewModel = robotGroup.clone(true); + posePreviewModel.name = 'yahboom_pose_preview'; + posePreviewModel.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = false; + child.material = Array.isArray(child.material) + ? child.material.map((material) => material.clone()) : child.material.clone(); + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const material of materials) { + material.transparent = true; + material.opacity = 0.38; + material.depthWrite = false; + } + }); + posePreviewModel.visible = false; + sdfRoot.add(posePreviewModel); +} + function createMkMiniFrontWheelPivots(model) { const pivots = []; model.updateMatrixWorld(true); @@ -1411,10 +1456,10 @@ const LASER_HOLE_R = 0.14; const LASER_RENDER_INTERVAL_MS = 100; const LASER_OPEN_SPACE_RAY_STRIDE = 4; const MINI_MAP_RENDER_INTERVAL_MS = 100; -const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : -0.032; +const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : USE_YAHBOOM_MODEL ? 0 : -0.032; const LASER_OFFSET_Y = 0; // Fallback height when an explicit ROS scan pose is unavailable. -const LASER_Z = USE_MKMINI_MODEL ? 0.5 : 0.181; +const LASER_Z = USE_MKMINI_MODEL ? 0.5 : USE_YAHBOOM_MODEL ? 0.124183557 : 0.181; const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03; function setRobotPose(pose) { @@ -2010,10 +2055,10 @@ function setCameraPreviewAvailable(available) { } async function refreshCameraFrame() { - if (!ENABLE_CAMERA_PREVIEW || !REAL_RUNTIME || cameraFrameRequestInFlight) return; + if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return; cameraFrameRequestInFlight = true; try { - const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store' }); + const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) }); if (!response.ok) { setCameraPreviewAvailable(false); return; @@ -2023,7 +2068,6 @@ async function refreshCameraFrame() { cameraFrameObjectUrl = nextUrl; if (cameraPreview) cameraPreview.src = nextUrl; if (previousUrl) URL.revokeObjectURL(previousUrl); - setCameraPreviewAvailable(true); } catch (_) { setCameraPreviewAvailable(false); } finally { @@ -2031,6 +2075,9 @@ async function refreshCameraFrame() { } } +cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true)); +cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false)); + function drawMiniMap(state) { pendingMiniMapState = state; if (miniMapRenderTimer) return; @@ -3063,6 +3110,7 @@ renderer.domElement.addEventListener('click', async (event) => { function applySlamState(state) { latestSlamState = state; + latestSlamStateReceivedAt = Date.now(); resetNavigationPoseForNewSession(state); restoreNavigationPoseFromState(state); updateNavigationLocalizationState(state); @@ -3155,7 +3203,7 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) { void refreshSensorPointCloud(); sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750); } -if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) { +if (ENABLE_CAMERA_PREVIEW) { void refreshCameraFrame(); cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200); } @@ -3172,6 +3220,10 @@ function connectControlSocket() { if (!CONTROL_WS) return; controlSocket = new WebSocket(CONTROL_WS); controlSocket.addEventListener('close', () => { + if (REAL_YAHBOOM_TELEOP) { + activeMoveKey = null; + stopRobot(); + } setTimeout(connectControlSocket, 1000); }); controlSocket.addEventListener('error', () => { @@ -3179,8 +3231,8 @@ function connectControlSocket() { }); } -let maxLinear = 0.22; -let maxAngular = 2.84; +let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22; +let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84; let activeMoveKey = null; let teleopInterval = null; let singleMoveStopTimer = null; @@ -3204,12 +3256,19 @@ const speedBindings = { }; function canUseTeleop(event) { - if (REAL_RUNTIME) return false; + if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false; if (!document.hasFocus()) return false; + if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false; const tag = event.target?.tagName?.toLowerCase(); return !['input', 'textarea', 'select', 'button'].includes(tag); } +function realTeleopReady() { + return latestSlamState?.teleop_deadman === true + && Date.now() - latestSlamStateReceivedAt < 1000 + && controlSocket?.readyState === WebSocket.OPEN; +} + function flushQueuedCmdVel() { if (cmdVelRequestInFlight || !queuedCmdVel) return; const command = queuedCmdVel; @@ -3236,6 +3295,20 @@ function flushQueuedCmdVel() { function sendCmdVel(linear, angular) { const payload = { type:'cmd_vel', linear, angular }; + if (REAL_YAHBOOM_TELEOP) { + // Never queue physical motion for replay after a connection recovers. + if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return; + if ((linear || angular) && !realTeleopReady()) return; + payload.deadman = true; + payload.linear = Math.max(-0.1, Math.min(0.1, linear)); + payload.angular = Math.max(-0.5, Math.min(0.5, angular)); + if (controlSocket.bufferedAmount > 0) { + controlSocket.close(); + return; + } + controlSocket.send(JSON.stringify(payload)); + return; + } if (controlSocket?.readyState === WebSocket.OPEN) { controlSocket.send(JSON.stringify(payload)); return; @@ -3289,6 +3362,11 @@ function stopRobot() { function sendActiveMoveCommand() { if (!activeMoveKey) return; + if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) { + activeMoveKey = null; + stopRobot(); + return; + } const [linearSign, angularSign] = moveBindings[activeMoveKey]; if (linearSign === 0 && angularSign === 0) return; @@ -3340,6 +3418,7 @@ window.addEventListener('keydown', (event) => { if (speedBindings[key]) { event.preventDefault(); + if (REAL_YAHBOOM_TELEOP) return; const [linearScale, angularScale] = speedBindings[key]; maxLinear = Math.max(0.01, maxLinear * linearScale); maxAngular = Math.max(0.1, maxAngular * angularScale); @@ -3355,6 +3434,11 @@ window.addEventListener('keydown', (event) => { stopRobot(); return; } + if (REAL_YAHBOOM_TELEOP) { + activeMoveKey = key; + startActiveMoveCommand(); + return; + } if (USE_MKMINI_MODEL && !REAL_RUNTIME && (key === 'j' || key === 'l')) { activeMoveKey = key; sendActiveMoveCommand(); @@ -3370,6 +3454,11 @@ window.addEventListener('keydown', (event) => { }); window.addEventListener('keyup', (event) => { + if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) { + activeMoveKey = null; + stopRobot(); + return; + } if (!canUseTeleop(event)) return; const key = event.key.toLowerCase(); if (moveBindings[key]) event.preventDefault(); @@ -3381,6 +3470,14 @@ window.addEventListener('blur', () => { stopRobot(); }); +function releaseRealTeleop() { + if (!REAL_YAHBOOM_TELEOP || !activeMoveKey) return; + activeMoveKey = null; + stopRobot(); +} +document.addEventListener('visibilitychange', () => { if (document.hidden) releaseRealTeleop(); }); +window.addEventListener('pagehide', releaseRealTeleop); + // A static Gazebo model can contain assets that the browser cannot render. // Keep the ROS stream available even when the decorative world mesh fails. const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME @@ -3389,7 +3486,7 @@ const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME const remoteNavigationSceneTasks = []; Promise.all(EDITOR_MODE ? worldSceneTasks - : [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })] + : [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_YAHBOOM_MODEL ? loadYahboomModel : USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })] ).then(() => { if (EDITOR_MODE) { window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*'); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9ed48e6..94dc7de 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1437,7 +1437,13 @@ function LocalPcWorkspaceMockup({ ?.slice('platform:='.length) .trim() .toLowerCase() - if (!value) return null + if (!value) { + // The Yahboom vehicle publishes its platform through /vehicle_config, + // so its module parameters need not include platform:=yahboom. + return /^\[lms-nav2-yahboom-vehicle\]yahboom_vehicle_v[^/]+_ws$/.test(module?.folderName ?? '') + ? 'yahboom' + : null + } return value === 'mk-mini' ? 'mkmini' : value } diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index fc38f01..f1facac 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -258,6 +258,7 @@ interface Window { }> } localWorkspace: { + onModuleProgress?: (callback: (folderName: string, message: string) => void) => () => void docker: { status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }> openGuide: () => Promise diff --git a/src/renderer/src/features/workspace/ModuleListRow.tsx b/src/renderer/src/features/workspace/ModuleListRow.tsx index 0041658..f62d691 100644 --- a/src/renderer/src/features/workspace/ModuleListRow.tsx +++ b/src/renderer/src/features/workspace/ModuleListRow.tsx @@ -5,6 +5,7 @@ import type React from 'react' import { createPortal } from 'react-dom' export type ModuleListRowItem = { + folderName?: string | null state: 'running' | 'stopped' | 'uninstalled' category: string name: string @@ -181,6 +182,11 @@ export default function ModuleListRow({ }: ModuleListRowProps): React.JSX.Element { const selectable = module.state !== 'uninstalled' const busy = installing || controlling + const [progress, setProgress] = useState('') + useEffect(() => window.localWorkspace?.onModuleProgress?.((folderName, message) => { + if (folderName === (module.folderName || module.name)) setProgress(message) + }), [module.folderName, module.name]) + useEffect(() => { if (!busy) setProgress('') }, [busy]) const stateStyle: Record = { running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]', stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]', @@ -278,6 +284,12 @@ export default function ModuleListRow({ {module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'} + {busy ? ( + + + {progress || (installing ? '모듈 설치 중...' : '모듈 작업 준비 중...')} + + ) : <> {module.cpu === null ? ( - @@ -309,6 +321,7 @@ export default function ModuleListRow({ {module.runtime} + } {selectable && (