fix: enable added week deletion and preserve learning concepts when cloning courses
This commit is contained in:
+1
-1
@@ -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,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,13 +825,27 @@ 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)
|
||||||
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:
|
def set_simulator_steering(self, angle: float) -> None:
|
||||||
if self.read_only:
|
if self.read_only:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
+91
-32
@@ -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=""',
|
||||||
' IMAGE="future-nav2:humble"',
|
' for CANDIDATE in future-nav2:humble-yahboom future-nav2:humble-yahboom-agent future-nav2:humble-jetson_orin future-nav2:humble; do',
|
||||||
' else',
|
' if docker image inspect "$CANDIDATE" >/dev/null 2>&1; then IMAGE="$CANDIDATE"; break; fi',
|
||||||
' IMAGE=""',
|
' done',
|
||||||
' fi',
|
|
||||||
' 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())
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
})
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 |
Binary file not shown.
@@ -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' }, '*');
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -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'],
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user