fix: enable added week deletion and preserve learning concepts when cloning courses

This commit is contained in:
2026-09-22 15:32:22 +09:00
parent 1d959e2c51
commit 33aac68f16
18 changed files with 633 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.28", "version": "1.1.29",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+27 -3
View File
@@ -27,6 +27,7 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
from nav2_msgs.action import NavigateToPose from nav2_msgs.action import NavigateToPose
from pydantic import BaseModel from pydantic import BaseModel
from rclpy.action import ActionClient from rclpy.action import ActionClient
from rclpy.clock import Clock, ClockType
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node from rclpy.node import Node
from rclpy.parameter import Parameter from rclpy.parameter import Parameter
@@ -132,6 +133,7 @@ def resolve_2d_transform(transforms: dict, target_frame: str, source_frame: str)
class VelocityPayload(BaseModel): class VelocityPayload(BaseModel):
linear: float = 0.0 linear: float = 0.0
angular: float = 0.0 angular: float = 0.0
deadman: bool = False
class SteeringPayload(BaseModel): 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): 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(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) 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, 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)
@@ -268,6 +272,10 @@ class SlamNode(Node):
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data) 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.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._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._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10) self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action) self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
@@ -817,14 +825,28 @@ class SlamNode(Node):
} }
self._tf_count += 1 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: if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.") 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 = Twist()
msg.linear.x = float(linear) msg.linear.x = float(linear)
msg.angular.z = float(angular) msg.angular.z = float(angular)
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) 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: def set_simulator_steering(self, angle: float) -> None:
if self.read_only: if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.") raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
@@ -1192,6 +1214,7 @@ class SlamNode(Node):
return { return {
"ok": True, "ok": True,
"teleop_deadman": not self.read_only,
"topics": { "topics": {
"odom": self.odom_topic, "odom": self.odom_topic,
"scan": self.scan_topic, "scan": self.scan_topic,
@@ -1549,7 +1572,7 @@ def debug_topics():
def cmd_vel(payload: VelocityPayload): def cmd_vel(payload: VelocityPayload):
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")
slam_node.publish_cmd_vel(payload.linear, payload.angular) slam_node.publish_cmd_vel(payload.linear, payload.angular, payload.deadman)
return {"ok": True} return {"ok": True}
@@ -1643,11 +1666,12 @@ async def ws_control(websocket: WebSocket):
velocity = VelocityPayload( velocity = VelocityPayload(
linear=payload.get("linear", 0.0), linear=payload.get("linear", 0.0),
angular=payload.get("angular", 0.0), angular=payload.get("angular", 0.0),
deadman=payload.get("deadman", False),
) )
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
try: try:
slam_node.publish_cmd_vel(velocity.linear, velocity.angular) slam_node.publish_cmd_vel(velocity.linear, velocity.angular, velocity.deadman)
except RuntimeError: except RuntimeError:
continue continue
except WebSocketDisconnect: except WebSocketDisconnect:
+70
View File
@@ -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()
+90 -31
View File
@@ -1,6 +1,8 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { checkDockerStatus } from './dockerStatus' import { checkDockerStatus } from './dockerStatus'
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts' import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
import type { WebContents } from 'electron' import type { WebContents } from 'electron'
import { spawn } from 'child_process' import { spawn } from 'child_process'
@@ -1359,6 +1361,8 @@ function resolveNav2RuntimeImage(
folderName?: string | null, folderName?: string | null,
rosParams = '', rosParams = '',
): string { ): string {
const yahboomImage = yahboomRuntimeImage(folderName)
if (yahboomImage) return yahboomImage
// NAV2-MKMini normally targets the real platform image. Gazebo is an // NAV2-MKMini normally targets the real platform image. Gazebo is an
// explicit local opt-in, so existing platform launches keep their image. // explicit local opt-in, so existing platform launches keep their image.
const mkminiGazebo = /lms-nav2-mkmini/i.test(folderName ?? '') const mkminiGazebo = /lms-nav2-mkmini/i.test(folderName ?? '')
@@ -1699,7 +1703,24 @@ function remoteRepositoryUrl(repositoryUrl: string): string {
return repositoryUrl return repositoryUrl
} }
function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMain.handle>[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<string> { async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise<string> {
const readProgress = createModuleProgressParser()
return new Promise<string>((resolvePromise, rejectPromise) => { return new Promise<string>((resolvePromise, rejectPromise) => {
let output = '' let output = ''
let terminated = false 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`)) rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
}, timeoutMs) }, timeoutMs)
child.stdout.on('data', (data: Buffer) => { child.stdout.setEncoding('utf8')
child.stdout.on('data', (data: string) => {
output += data.toString() output += data.toString()
readProgress(data.toString())
}) })
child.stderr.on('data', (data: Buffer) => { child.stderr.on('data', (data: Buffer) => {
output += data.toString() output += data.toString()
@@ -2331,18 +2354,18 @@ async function syncRemoteSavedMapArtifacts(
'set -e', 'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`, `ROOT=${quoteShellSingle(target.workspaceRoot)}`,
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`, `MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then', // A missing directory is normal on first install, not a permission failure.
' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then', 'mkdir -p "$MAPS_DIRECTORY" 2>/dev/null || true',
' IMAGE="future-nav2:humble-jetson_orin"', 'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ] || [ ! -x "$MAPS_DIRECTORY" ]; then',
' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then',
' IMAGE="future-nav2:humble"',
' else',
' IMAGE=""', ' IMAGE=""',
' fi', ' 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', ' 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"', ' 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', 'fi',
'mkdir -p "$MAPS_DIRECTORY"', 'mkdir -p "$MAPS_DIRECTORY"',
'test -w "$MAPS_DIRECTORY" && test -x "$MAPS_DIRECTORY" || { echo "저장된 맵 디렉터리 권한 복구에 실패했습니다: $MAPS_DIRECTORY" >&2; exit 1; }',
...downloads.map((artifact) => ( ...downloads.map((artifact) => (
`curl -fsSL --retry 2 ${quoteShellSingle(artifact.url)} -o ${quoteShellSingle(`${mapsDirectory}/${artifact.fileName}`)}` `curl -fsSL --retry 2 ${quoteShellSingle(artifact.url)} -o ${quoteShellSingle(`${mapsDirectory}/${artifact.fileName}`)}`
)), )),
@@ -4573,6 +4596,7 @@ function buildNav2DockerRunScript(): string {
' )', ' )',
'else', 'else',
'case "$MODULE_FOLDER" in', 'case "$MODULE_FOLDER" in',
...yahboomModuleCases(),
' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *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-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
@@ -4609,6 +4633,7 @@ function buildNav2DockerRunScript(): string {
'fi', 'fi',
'', '',
'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(),
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'', '',
'args=(', 'args=(',
@@ -4625,6 +4650,7 @@ function buildNav2DockerRunScript(): string {
' -v "$(dirname "$0"):/runtime/nav2/docker:ro"', ' -v "$(dirname "$0"):/runtime/nav2/docker:ro"',
' -v /etc/localtime:/etc/localtime: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', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then',
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
'fi', 'fi',
@@ -4667,6 +4693,7 @@ function buildNav2DockerBuildScript(): string {
' )', ' )',
'else', 'else',
'case "$MODULE_FOLDER" in', 'case "$MODULE_FOLDER" in',
...yahboomModuleCases(true),
' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;',
' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;',
' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;',
@@ -4897,7 +4924,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
// Query Gazebo directly first. The ROS GetModelList service can be slow or // Query Gazebo directly first. The ROS GetModelList service can be slow or
// unavailable while DDS participants are still discovering each other, even // unavailable while DDS participants are still discovering each other, even
// though the simulator itself is already able to accept a pose update. // though the simulator itself is already able to accept a pose update.
const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger'] const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger', 'yahboom_gazebo']
for (const modelName of knownModels) { for (const modelName of knownModels) {
const pose = await runCommandOutput( const pose = await runCommandOutput(
'docker', 'docker',
@@ -4923,7 +4950,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? '' const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1]) const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
const robotModel = modelNames.find((modelName) => ( 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 로봇 모델을 찾을 수 없습니다.') if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.')
return robotModel return robotModel
@@ -5017,6 +5044,29 @@ async function clearNav2RuntimeObstacles(): Promise<{ ok: boolean; message: stri
return { ok: true, message: '장애물을 제거했습니다.' } 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( async function startWorkspaceModule(
moduleName: string, moduleName: string,
workspaceRoot?: string | null, workspaceRoot?: string | null,
@@ -5044,11 +5094,10 @@ async function startWorkspaceModule(
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId) const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId)
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
const scriptPath = resolveNav2DockerRunScript() const scriptPath = resolveNav2DockerRunScript()
const runtimeImage = await resolveCustomModuleRuntimeImage( const runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages)
moduleRoot, const runtimeImage = runtime.image
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), await prepareRuntimeImages(runtime.artifacts)
) reportModuleProgress("모듈 실행 중...")
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
await runCommand( await runCommand(
'bash', 'bash',
[ [
@@ -5072,6 +5121,7 @@ async function startWorkspaceModule(
} }
await prepareRuntimeImages(runtimeImages) await prepareRuntimeImages(runtimeImages)
reportModuleProgress("모듈 실행 중...")
await ensureStudioRuntimeAssets() await ensureStudioRuntimeAssets()
const scriptPath = resolveAutowareDockerRunScript() const scriptPath = resolveAutowareDockerRunScript()
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
@@ -5129,11 +5179,10 @@ async function buildWorkspaceModule(
if (isNav2ModuleFolder(targetFolderName)) { if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets() await ensureNav2RuntimeAssets()
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
const runtimeImage = await resolveCustomModuleRuntimeImage( const runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages)
moduleRoot, const runtimeImage = runtime.image
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), await prepareRuntimeImages(runtime.artifacts)
) reportModuleProgress("모듈 빌드 중...")
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
await runCommand( await runCommand(
'bash', 'bash',
[ [
@@ -5151,6 +5200,7 @@ async function buildWorkspaceModule(
} }
await prepareRuntimeImages(runtimeImages) await prepareRuntimeImages(runtimeImages)
reportModuleProgress("모듈 빌드 중...")
await runCommand( await runCommand(
'docker', 'docker',
[ [
@@ -5173,6 +5223,7 @@ async function prepareRemoteRuntimeImages(
target: RemoteWorkspaceTarget, target: RemoteWorkspaceTarget,
runtimeImages?: RuntimeImageArtifact[] | null, runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<void> { ): Promise<void> {
reportModuleProgress('이미지 준비 대기 중...')
const artifacts = (runtimeImages ?? []) const artifacts = (runtimeImages ?? [])
.filter((image) => image?.image?.trim()) .filter((image) => image?.image?.trim())
.map((image) => ({ .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"', ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
' path = os.path.join(cache, name)', ' path = os.path.join(cache, name)',
' if os.path.exists(path): os.unlink(path)', ' if os.path.exists(path): os.unlink(path)',
' print("LMS_MODULE_PROGRESS:이미지 다운로드 중...", flush=True)',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' print("LMS_MODULE_PROGRESS:이미지 파일 확인 중...", flush=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()', ' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:', ' if expected:',
' import hashlib', ' 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}")', ' 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"):', ' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
' import shlex', ' import shlex',
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', ' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
@@ -5255,14 +5312,15 @@ async function startRemoteWorkspaceModule(
? await resolveRemoteCustomModuleRuntimeImage( ? await resolveRemoteCustomModuleRuntimeImage(
target, target,
targetFolderName, targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages), yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
) )
: null : null
const runtimeRoot = nav2Module const runtimeRoot = nav2Module
? await ensureRemoteNav2RuntimeAssets(target) ? await ensureRemoteNav2RuntimeAssets(target)
: await ensureRemoteStudioRuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target)
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) 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) 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 (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
if (currentStatus.trim()) { if (currentStatus.trim()) {
@@ -5352,9 +5410,10 @@ async function buildRemoteWorkspaceModule(
const runtimeImage = await resolveRemoteCustomModuleRuntimeImage( const runtimeImage = await resolveRemoteCustomModuleRuntimeImage(
target, target,
targetFolderName, targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages), yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
) )
await prepareRemoteRuntimeImages(target, runtimeImages) await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 빌드 중...")
await runSsh(target, [ await runSsh(target, [
'set -e', 'set -e',
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
@@ -6286,8 +6345,8 @@ function registerLocalWorkspaceHandlers(): void {
return importCustomWorkspaceModule(workspaceRoot, input) 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: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)) 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))
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: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', (_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-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
@@ -6349,8 +6408,8 @@ function registerLocalWorkspaceHandlers(): void {
if (!moduleName?.trim()) throw new Error('전송할 모듈명이 없습니다.') if (!moduleName?.trim()) throw new Error('전송할 모듈명이 없습니다.')
return transferLocalWorkspaceModuleToRemote(target, sourceWorkspaceRoot, moduleName, folderName) 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)) 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))
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: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', (_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: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)) 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: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: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: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)) 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))
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: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', (_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-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers())
+28
View File
@@ -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'))
})
+21
View File
@@ -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())
}
}
}
+115
View File
@@ -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 })
}
})
+70
View File
@@ -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',
]
}
+5
View File
@@ -197,6 +197,11 @@ contextBridge.exposeInMainWorld('visualization', {
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile) stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
}) })
contextBridge.exposeInMainWorld('localWorkspace', { 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: { docker: {
status: () => ipcRenderer.invoke('local-workspace:docker:status'), status: () => ipcRenderer.invoke('local-workspace:docker:status'),
openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'), openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+118 -21
View File
@@ -48,11 +48,11 @@
} }
#cameraPreviewWrap { #cameraPreviewWrap {
position:absolute; position:absolute;
right:16px; left:16px;
bottom:256px; top:48px;
z-index:10; z-index:10;
display:none; display:none;
width:260px; width:min(260px, calc(100vw - 32px));
border:1px solid rgba(0,0,0,.45); border:1px solid rgba(0,0,0,.45);
background:#111520; background:#111520;
box-shadow:0 10px 26px rgba(0,0,0,.16); 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; } #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview { #cameraPreview {
display:block; display:block;
width:260px; width:100%;
aspect-ratio:16 / 9; aspect-ratio:16 / 9;
object-fit:cover; object-fit:contain;
background:#111520; background:#111520;
} }
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
#cameraPreviewToggle { #cameraPreviewToggle {
position:absolute; position:absolute;
right:0; right:0;
top:-28px; top:100%;
display:grid; display:grid;
width:30px; width:30px;
height:28px; height:28px;
@@ -86,8 +86,8 @@
box-shadow:0 10px 28px rgba(0,0,0,.16); box-shadow:0 10px 28px rgba(0,0,0,.16);
} }
#cameraPreviewToggle:hover { color:#fff; background:rgba(255,255,255,.06); } #cameraPreviewToggle:hover { color:#fff; background:rgba(255,255,255,.06); }
#cameraPreviewChevron { font-size:13px; line-height:1; } #cameraPreviewChevron { font-size:13px; line-height:1; transform:rotate(180deg); }
#cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(180deg); } #cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(0deg); }
#topRightControls { #topRightControls {
position:absolute; position:absolute;
right:16px; right:16px;
@@ -369,6 +369,8 @@
.nav2-navigation #rosTopicStatusWrap { bottom:16px; } .nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.real-runtime #topLeftControls { display:none; } .real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; } .real-runtime #keyGuide { display:none; }
.real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; }
</style> </style>
</head> </head>
<body> <body>
@@ -441,12 +443,12 @@ const REAL_RUNTIME = params.get('runtime') === 'real';
const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase(); const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform. // 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_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_POINT_CLOUD = params.get('pointCloud') === '1';
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD; const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000; const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
// Camera preview is kept for the RGB test module, but is paused for now so const ENABLE_CAMERA_PREVIEW = params.get('editor') !== '1';
// regular visualization sessions never poll `/camera-frame`.
const ENABLE_CAMERA_PREVIEW = false;
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map'; const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map';
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; 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('editor-mode');
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`); if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
if (REAL_RUNTIME) document.body.classList.add('real-runtime'); 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'); if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
let visualizationResponseReceived = EDITOR_MODE; let visualizationResponseReceived = EDITOR_MODE;
@@ -868,6 +876,7 @@ let laserFrontBeam = null;
let slamSocket = null; let slamSocket = null;
let controlSocket = null; let controlSocket = null;
let latestSlamState = 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 currentRobotPose = { x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 };
let targetRobotPose = { ...currentRobotPose }; let targetRobotPose = { ...currentRobotPose };
let hasRobotPose = false; 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); 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) { function createMkMiniFrontWheelPivots(model) {
const pivots = []; const pivots = [];
model.updateMatrixWorld(true); model.updateMatrixWorld(true);
@@ -1411,10 +1456,10 @@ const LASER_HOLE_R = 0.14;
const LASER_RENDER_INTERVAL_MS = 100; const LASER_RENDER_INTERVAL_MS = 100;
const LASER_OPEN_SPACE_RAY_STRIDE = 4; const LASER_OPEN_SPACE_RAY_STRIDE = 4;
const MINI_MAP_RENDER_INTERVAL_MS = 100; 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; const LASER_OFFSET_Y = 0;
// Fallback height when an explicit ROS scan pose is unavailable. // 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; const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03;
function setRobotPose(pose) { function setRobotPose(pose) {
@@ -2010,10 +2055,10 @@ function setCameraPreviewAvailable(available) {
} }
async function refreshCameraFrame() { async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || !REAL_RUNTIME || cameraFrameRequestInFlight) return; if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return;
cameraFrameRequestInFlight = true; cameraFrameRequestInFlight = true;
try { 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) { if (!response.ok) {
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
return; return;
@@ -2023,7 +2068,6 @@ async function refreshCameraFrame() {
cameraFrameObjectUrl = nextUrl; cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl; if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl); if (previousUrl) URL.revokeObjectURL(previousUrl);
setCameraPreviewAvailable(true);
} catch (_) { } catch (_) {
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
} finally { } finally {
@@ -2031,6 +2075,9 @@ async function refreshCameraFrame() {
} }
} }
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) { function drawMiniMap(state) {
pendingMiniMapState = state; pendingMiniMapState = state;
if (miniMapRenderTimer) return; if (miniMapRenderTimer) return;
@@ -3063,6 +3110,7 @@ renderer.domElement.addEventListener('click', async (event) => {
function applySlamState(state) { function applySlamState(state) {
latestSlamState = state; latestSlamState = state;
latestSlamStateReceivedAt = Date.now();
resetNavigationPoseForNewSession(state); resetNavigationPoseForNewSession(state);
restoreNavigationPoseFromState(state); restoreNavigationPoseFromState(state);
updateNavigationLocalizationState(state); updateNavigationLocalizationState(state);
@@ -3155,7 +3203,7 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
void refreshSensorPointCloud(); void refreshSensorPointCloud();
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750); sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
} }
if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) { if (ENABLE_CAMERA_PREVIEW) {
void refreshCameraFrame(); void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200); cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
} }
@@ -3172,6 +3220,10 @@ function connectControlSocket() {
if (!CONTROL_WS) return; if (!CONTROL_WS) return;
controlSocket = new WebSocket(CONTROL_WS); controlSocket = new WebSocket(CONTROL_WS);
controlSocket.addEventListener('close', () => { controlSocket.addEventListener('close', () => {
if (REAL_YAHBOOM_TELEOP) {
activeMoveKey = null;
stopRobot();
}
setTimeout(connectControlSocket, 1000); setTimeout(connectControlSocket, 1000);
}); });
controlSocket.addEventListener('error', () => { controlSocket.addEventListener('error', () => {
@@ -3179,8 +3231,8 @@ function connectControlSocket() {
}); });
} }
let maxLinear = 0.22; let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22;
let maxAngular = 2.84; let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84;
let activeMoveKey = null; let activeMoveKey = null;
let teleopInterval = null; let teleopInterval = null;
let singleMoveStopTimer = null; let singleMoveStopTimer = null;
@@ -3204,12 +3256,19 @@ const speedBindings = {
}; };
function canUseTeleop(event) { function canUseTeleop(event) {
if (REAL_RUNTIME) return false; if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false;
if (!document.hasFocus()) 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(); const tag = event.target?.tagName?.toLowerCase();
return !['input', 'textarea', 'select', 'button'].includes(tag); return !['input', 'textarea', 'select', 'button'].includes(tag);
} }
function realTeleopReady() {
return latestSlamState?.teleop_deadman === true
&& Date.now() - latestSlamStateReceivedAt < 1000
&& controlSocket?.readyState === WebSocket.OPEN;
}
function flushQueuedCmdVel() { function flushQueuedCmdVel() {
if (cmdVelRequestInFlight || !queuedCmdVel) return; if (cmdVelRequestInFlight || !queuedCmdVel) return;
const command = queuedCmdVel; const command = queuedCmdVel;
@@ -3236,6 +3295,20 @@ function flushQueuedCmdVel() {
function sendCmdVel(linear, angular) { function sendCmdVel(linear, angular) {
const payload = { type:'cmd_vel', 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) { if (controlSocket?.readyState === WebSocket.OPEN) {
controlSocket.send(JSON.stringify(payload)); controlSocket.send(JSON.stringify(payload));
return; return;
@@ -3289,6 +3362,11 @@ function stopRobot() {
function sendActiveMoveCommand() { function sendActiveMoveCommand() {
if (!activeMoveKey) return; if (!activeMoveKey) return;
if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
activeMoveKey = null;
stopRobot();
return;
}
const [linearSign, angularSign] = moveBindings[activeMoveKey]; const [linearSign, angularSign] = moveBindings[activeMoveKey];
if (linearSign === 0 && angularSign === 0) return; if (linearSign === 0 && angularSign === 0) return;
@@ -3340,6 +3418,7 @@ window.addEventListener('keydown', (event) => {
if (speedBindings[key]) { if (speedBindings[key]) {
event.preventDefault(); event.preventDefault();
if (REAL_YAHBOOM_TELEOP) return;
const [linearScale, angularScale] = speedBindings[key]; const [linearScale, angularScale] = speedBindings[key];
maxLinear = Math.max(0.01, maxLinear * linearScale); maxLinear = Math.max(0.01, maxLinear * linearScale);
maxAngular = Math.max(0.1, maxAngular * angularScale); maxAngular = Math.max(0.1, maxAngular * angularScale);
@@ -3355,6 +3434,11 @@ window.addEventListener('keydown', (event) => {
stopRobot(); stopRobot();
return; return;
} }
if (REAL_YAHBOOM_TELEOP) {
activeMoveKey = key;
startActiveMoveCommand();
return;
}
if (USE_MKMINI_MODEL && !REAL_RUNTIME && (key === 'j' || key === 'l')) { if (USE_MKMINI_MODEL && !REAL_RUNTIME && (key === 'j' || key === 'l')) {
activeMoveKey = key; activeMoveKey = key;
sendActiveMoveCommand(); sendActiveMoveCommand();
@@ -3370,6 +3454,11 @@ window.addEventListener('keydown', (event) => {
}); });
window.addEventListener('keyup', (event) => { window.addEventListener('keyup', (event) => {
if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) {
activeMoveKey = null;
stopRobot();
return;
}
if (!canUseTeleop(event)) return; if (!canUseTeleop(event)) return;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (moveBindings[key]) event.preventDefault(); if (moveBindings[key]) event.preventDefault();
@@ -3381,6 +3470,14 @@ window.addEventListener('blur', () => {
stopRobot(); 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. // A static Gazebo model can contain assets that the browser cannot render.
// Keep the ROS stream available even when the decorative world mesh fails. // Keep the ROS stream available even when the decorative world mesh fails.
const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME 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 = []; const remoteNavigationSceneTasks = [];
Promise.all(EDITOR_MODE Promise.all(EDITOR_MODE
? worldSceneTasks ? 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(() => { ).then(() => {
if (EDITOR_MODE) { if (EDITOR_MODE) {
window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*'); window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*');
+7 -1
View File
@@ -1437,7 +1437,13 @@ function LocalPcWorkspaceMockup({
?.slice('platform:='.length) ?.slice('platform:='.length)
.trim() .trim()
.toLowerCase() .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 return value === 'mk-mini' ? 'mkmini' : value
} }
+1
View File
@@ -258,6 +258,7 @@ interface Window {
}> }>
} }
localWorkspace: { localWorkspace: {
onModuleProgress?: (callback: (folderName: string, message: string) => void) => () => void
docker: { docker: {
status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }> status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }>
openGuide: () => Promise<void> openGuide: () => Promise<void>
@@ -5,6 +5,7 @@ import type React from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
export type ModuleListRowItem = { export type ModuleListRowItem = {
folderName?: string | null
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
category: string category: string
name: string name: string
@@ -181,6 +182,11 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
}: ModuleListRowProps<Module>): React.JSX.Element { }: ModuleListRowProps<Module>): React.JSX.Element {
const selectable = module.state !== 'uninstalled' const selectable = module.state !== 'uninstalled'
const busy = installing || controlling 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<ModuleListRowItem['state'], string> = { const stateStyle: Record<ModuleListRowItem['state'], string> = {
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]', running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]', stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
@@ -278,6 +284,12 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'} {module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
</button> </button>
</span> </span>
{busy ? (
<span role="status" className={['col-span-3 flex min-w-0 items-center justify-center gap-2 px-2 text-[11px]', dark ? 'text-white/65' : 'text-[#687284]'].join(' ')}>
<RefreshCw size={12} className="shrink-0 animate-spin" />
<span className="truncate" title={progress}>{progress || (installing ? '모듈 설치 중...' : '모듈 작업 준비 중...')}</span>
</span>
) : <>
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
{module.cpu === null ? ( {module.cpu === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span> <span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
@@ -309,6 +321,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}> <span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
{module.runtime} {module.runtime}
</span> </span>
</>}
<span className="flex justify-center gap-1.5"> <span className="flex justify-center gap-1.5">
{selectable && ( {selectable && (
<button <button
@@ -512,7 +512,18 @@ const moduleGuidePages = [
] ]
}, },
{ {
environment: '원격 플랫폼', environment: '원격 플랫폼(Yahboom Car)',
imageAlt: '야붐 실차 모듈 가이드',
imageSrc: './assets/images/module-guide-yahboom.png',
slamModules: ['NAV2-Yahboom-Agent', 'NAV2-Yahboom', 'NAV2-Yahboom-Slam'],
navigationModules: ['NAV2-Yahboom-Agent', 'NAV2-Yahboom', 'NAV2-Yahboom-Map', 'NAV2-Yahboom-Localization', 'NAV2-Yahboom-Planning', 'NAV2-Yahboom-Control'],
parameters: [
['NAV2-Yahboom', 'sim_mode:=false'],
['Agent', 'serial_device:=/dev/ttyUSB0, baudrate:=921600']
]
},
{
environment: '원격 플랫폼(MK-mini)',
imageAlt: '원격 플랫폼 환경 모듈 가이드', imageAlt: '원격 플랫폼 환경 모듈 가이드',
imageSrc: './assets/images/module-guide-remote-platform.png', imageSrc: './assets/images/module-guide-remote-platform.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'], slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
@@ -524,7 +535,7 @@ const moduleGuidePages = [
] ]
}, },
{ {
environment: '원격 플랫폼 3D', environment: '원격 플랫폼 3D(MK-mini)',
imageAlt: '원격 3D SLAM 환경 모듈 가이드', imageAlt: '원격 3D SLAM 환경 모듈 가이드',
imageSrc: './assets/images/module-guide-remote-3d.png', imageSrc: './assets/images/module-guide-remote-3d.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'], slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
+52
View File
@@ -0,0 +1,52 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
const code = html.slice(html.indexOf('let maxLinear ='), html.indexOf('// A static Gazebo model'));
function create(real = true, yahboom = true) {
const handlers = {}, messages = [], intervals = new Map();
let id = 0;
const context = vm.createContext({
REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_YAHBOOM_TELEOP: real && yahboom,
USE_MKMINI_MODEL: !yahboom, DIRECT_BRIDGE: true,
latestSlamState: {teleop_deadman: true}, latestSlamStateReceivedAt: Date.now(),
controlSocket: {readyState: 1, bufferedAmount: 0, send: data => messages.push(JSON.parse(data)), close() {this.readyState = 3;}},
WebSocket: {OPEN: 1}, document: {hidden: false, hasFocus: () => true, addEventListener: (name, fn) => handlers[name] = fn},
window: {addEventListener: (name, fn) => handlers[name] = fn,
setInterval: fn => {intervals.set(++id, fn); return id;}, clearInterval: id => intervals.delete(id),
setTimeout: () => ++id, clearTimeout: () => {}},
console, Date,
});
vm.runInContext(code, context);
const key = (type, value, extra = {}) => handlers[type]({key: value, target: {tagName: 'CANVAS'}, preventDefault() {}, ...extra});
return {context, handlers, messages, intervals, key};
}
let t = create();
t.key('keydown', 'i');
assert.equal(t.messages.at(-1).linear, 0.1);
assert.equal(t.messages.at(-1).deadman, true);
t.key('keyup', 'i', {target: {tagName: 'INPUT'}});
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
for (const key of ['j', 'l', ',', 'u', 'o', 'm', '.']) {
t.key('keydown', key); t.key('keyup', key);
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.messages.at(-1).angular, 0);
}
t.key('keydown', 'i'); t.handlers.blur();
assert.equal(t.messages.at(-1).linear, 0);
t.key('keydown', 'i'); t.context.document.hidden = true; t.handlers.visibilitychange();
assert.equal(t.messages.at(-1).linear, 0);
t = create();
t.key('keydown', 'i'); t.context.latestSlamStateReceivedAt = 0;
for (const tick of t.intervals.values()) tick();
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
t = create(); t.context.latestSlamState.teleop_deadman = false; t.key('keydown', 'i');
assert.equal(t.messages.length, 0);
t = create(true, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
t = create(false); t.key('keydown', 'i'); t.key('keyup', 'i');
assert.equal(t.messages.at(-1).linear, 0.22);
assert.equal(t.intervals.size, 1);
console.log('PASS: deadman release, focus/visibility/stale-state stops, legacy bridge and other real vehicles blocked, simulation unchanged');