feat: support simultaneous Tailscale and LAN communication for Yahboom cameras
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { readdir, readFile, realpath } from 'fs/promises'
|
||||
import { resolve, sep } from 'path'
|
||||
import type { LocalGitModule } from '../shared/gitModuleImport'
|
||||
|
||||
export async function localGitModulePath(root: string, folder: string): Promise<string> {
|
||||
if (!/^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/\\]+_ws$/.test(folder)) throw new Error('Invalid custom module folder')
|
||||
const base = await realpath(root)
|
||||
const target = await realpath(resolve(base, folder))
|
||||
if (!target.startsWith(base + sep)) throw new Error('Module is outside the local workspace')
|
||||
return target
|
||||
}
|
||||
|
||||
export async function listLocalGitModules(root: string): Promise<LocalGitModule[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return []
|
||||
throw error
|
||||
})
|
||||
const modules: LocalGitModule[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('[lms-nav2-custom]')) continue
|
||||
try {
|
||||
const target = await localGitModulePath(root, entry.name)
|
||||
const data = JSON.parse(await readFile(resolve(target, '.autonomy-studio-module.json'), 'utf8'))
|
||||
if (data.sourceType !== 'CUSTOM' || !['name', 'category', 'packageName', 'launchFile'].every(key => typeof data[key] === 'string' && data[key])) continue
|
||||
modules.push({ name: data.name, category: data.category, packageName: data.packageName, launchFile: data.launchFile, folderName: entry.name })
|
||||
} catch { /* Ignore incomplete module folders. */ }
|
||||
}
|
||||
return modules.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function repositoryAddress(value: string): string {
|
||||
// Do not allow local paths, Git remote helpers, or credentials in the UI.
|
||||
const normalized = /^git@[^:]+:.+/.test(value) ? value.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2') : value
|
||||
const url = new URL(normalized)
|
||||
if (!['https:', 'http:', 'ssh:'].includes(url.protocol)) throw new Error('Unsupported Git repository protocol')
|
||||
url.password = ''
|
||||
if (url.protocol !== 'ssh:') url.username = ''
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function remoteBranches(output: string): Array<{ name: string; commit: string }> {
|
||||
return output.split('\n').flatMap(line => {
|
||||
const match = line.trim().match(/^([a-f0-9]{40,64})\s+refs\/heads\/(.+)$/)
|
||||
return match ? [{ name: match[2], commit: match[1] }] : []
|
||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
+136
-13
@@ -5,6 +5,8 @@ import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||
import { resolveContainerTerminal } from './containerTerminal'
|
||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||
import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport'
|
||||
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
||||
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
||||
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
|
||||
@@ -1273,6 +1275,82 @@ async function createRemoteCustomWorkspaceModule(
|
||||
return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) }
|
||||
}
|
||||
|
||||
async function inspectLocalGitModule(workspaceRoot: string, folder: string): Promise<GitModuleSource> {
|
||||
const workspace = expandUserPath(workspaceRoot)
|
||||
if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
const root = await localGitModulePath(workspace, folder)
|
||||
const git = (args: string[]) => runCommandOutput('git', args, root, { ...process.env, GIT_TERMINAL_PROMPT: '0' }, 15_000, 'stdout')
|
||||
const top = (await git(['rev-parse', '--show-toplevel'])).trim()
|
||||
if (resolve(top) !== root) throw new Error('모듈 자체의 Git 저장소가 필요합니다.')
|
||||
const remote = await git(['remote', 'get-url', 'student']).catch(() => git(['remote', 'get-url', 'origin']))
|
||||
const repository = repositoryAddress(remote.trim())
|
||||
const currentBranch = (await git(['branch', '--show-current'])).trim()
|
||||
const dirty = Boolean((await git(['status', '--porcelain'])).trim())
|
||||
const head = (await git(['rev-parse', 'HEAD'])).trim()
|
||||
let branches: GitModuleSource['branches']
|
||||
try {
|
||||
branches = remoteBranches(await git(['ls-remote', '--heads', withGitCredential(repository)]))
|
||||
} catch { throw new Error('Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.') }
|
||||
return { repository, currentBranch, branches, dirty, pushed: branches.some(branch => branch.name === currentBranch && branch.commit === head) }
|
||||
}
|
||||
|
||||
async function importRemoteGitModule(targetInput: RemoteWorkspaceTarget, workspaceRoot: string, input: GitModuleImportInput & { folderName: string }): Promise<{ ok: boolean; workspacePath: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const module = validateCustomModuleScaffold(input)
|
||||
const source = await inspectLocalGitModule(workspaceRoot, input.sourceFolder)
|
||||
if (source.dirty || !source.pushed) throw new Error('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')
|
||||
const branch = source.branches.find(branch => branch.name === input.branch && branch.commit === input.commit)
|
||||
if (!branch) throw new Error('브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.')
|
||||
const repository = remoteRepositoryUrl(source.repository)
|
||||
const authenticated = withGitCredential(repository)
|
||||
const url = new URL(authenticated)
|
||||
const authorization = ['http:', 'https:'].includes(url.protocol) && url.username
|
||||
? `Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : ''
|
||||
const config = encodeBase64(customModuleConfigContent(module))
|
||||
// Stage the clone and publish only after validation. Never overwrite an existing workspace.
|
||||
const script = [
|
||||
'set -e',
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
...expandRemotePathVar('ROOT'),
|
||||
'mkdir -p "$ROOT"',
|
||||
`TARGET="$ROOT/"${quoteShellSingle(module.folderName)}`,
|
||||
'if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then echo "Module folder already exists" >&2; exit 1; fi',
|
||||
'STAGING=$(mktemp -d "$ROOT/.git-import-XXXXXXXX")',
|
||||
'trap \'rm -rf -- "$STAGING"\' EXIT',
|
||||
`AUTH=$(printf %s ${quoteShellSingle(encodeBase64(authorization))} | base64 -d)`,
|
||||
'export GIT_TERMINAL_PROMPT=0',
|
||||
`if ! GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="$AUTH" git clone --single-branch --branch ${quoteShellSingle(branch.name)} -- ${quoteShellSingle(repository)} "$STAGING/module" 2>"$STAGING/clone-error"; then echo "Git clone failed: check repository access from the platform" >&2; exit 1; fi`,
|
||||
'unset AUTH',
|
||||
`test "$(git -C "$STAGING/module" rev-parse HEAD)" = ${quoteShellSingle(branch.commit)} || { echo "Branch changed; refresh and retry" >&2; exit 1; }`,
|
||||
`python3 - "$STAGING/module" ${quoteShellSingle(config)} <<'PY'`,
|
||||
'import base64, json, os, subprocess, sys',
|
||||
'root = sys.argv[1]',
|
||||
'config = json.loads(base64.b64decode(sys.argv[2]))',
|
||||
'paths = subprocess.check_output(["git", "-C", root, "ls-tree", "-r", "--name-only", "HEAD"]).decode().splitlines()',
|
||||
'if any(set(path.split("/")) & {"build", "install", "log", "__pycache__"} for path in paths): raise SystemExit("Repository contains build artifacts; remove them before importing")',
|
||||
'for path in paths:',
|
||||
' if os.path.islink(os.path.join(root, path)): raise SystemExit("Repository symlinks are not supported for module import")',
|
||||
'package = config["packageName"]',
|
||||
'candidates = [root, os.path.join(root, "src", package)]',
|
||||
'if not any(os.path.isfile(os.path.join(p, "package.xml")) and os.path.isfile(os.path.join(p, "launch", config["launchFile"])) for p in candidates): raise SystemExit("Package or launch file is missing in the selected branch")',
|
||||
'for script in ("build.sh", "run.sh"):',
|
||||
' if not os.path.isfile(os.path.join(root, script)): raise SystemExit("Module build.sh/run.sh is missing")',
|
||||
' os.chmod(os.path.join(root, script), 0o755)',
|
||||
'with open(os.path.join(root, ".autonomy-studio-module.json"), "w") as fp: json.dump(config, fp, indent=2)',
|
||||
'PY',
|
||||
'mv -T -n "$STAGING/module" "$TARGET"',
|
||||
'test ! -d "$STAGING/module" || { echo "Module folder already exists" >&2; exit 1; }',
|
||||
'printf "%s" "$TARGET"',
|
||||
].join('\n')
|
||||
try {
|
||||
const workspacePath = (await runSshOutput(target, script, 120_000)).trim()
|
||||
return { ok: true, workspacePath }
|
||||
} catch {
|
||||
// SSH failures can include the command (and its temporary authorization header).
|
||||
throw new Error('Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.')
|
||||
}
|
||||
}
|
||||
|
||||
async function transferLocalWorkspaceModuleToRemote(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
sourceWorkspaceRoot: string | null | undefined,
|
||||
@@ -1774,9 +1852,11 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N
|
||||
await runCommandOutput(command, args, cwd, env, timeoutMs)
|
||||
}
|
||||
|
||||
async function listLocalDockerImages(): Promise<{ images: string[]; message?: string }> {
|
||||
async function listWorkspaceDockerImages(target?: RemoteWorkspaceTarget): Promise<{ images: string[]; message?: string }> {
|
||||
try {
|
||||
const output = await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000)
|
||||
const output = target
|
||||
? await runSshOutput(target, "docker image ls --format '{{json .}}'", 10_000)
|
||||
: await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000)
|
||||
const images = Array.from(new Set(output.split('\n').flatMap((line) => {
|
||||
if (!line.trim()) return []
|
||||
try {
|
||||
@@ -1794,7 +1874,7 @@ async function listLocalDockerImages(): Promise<{ images: string[]; message?: st
|
||||
} catch (error) {
|
||||
return {
|
||||
images: [],
|
||||
message: error instanceof Error ? error.message : '로컬 Docker 이미지를 불러오지 못했습니다.'
|
||||
message: error instanceof Error ? error.message : 'Docker 이미지를 불러오지 못했습니다.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4337,7 +4417,7 @@ function parseRosDomainId(input?: string | null): number {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 232 ? value : 0
|
||||
}
|
||||
|
||||
function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null): string {
|
||||
function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null, lanAddresses: string[] = []): string {
|
||||
const domainId = parseRosDomainId(rosDomainId)
|
||||
const discoveryBasePort = 7400 + (250 * domainId)
|
||||
// Fast DDS can bind the default discovery port itself (d0), while other
|
||||
@@ -4347,7 +4427,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
||||
discoveryBasePort,
|
||||
...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId))
|
||||
]
|
||||
const localAddresses = Array.from(new Set(['127.0.0.1', localAddress]))
|
||||
const localAddresses = Array.from(new Set(['127.0.0.1', localAddress, ...lanAddresses]))
|
||||
const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress]))
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>',
|
||||
@@ -4371,6 +4451,11 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
||||
' <transport_id>autonomy_studio_udp</transport_id>',
|
||||
' </userTransports>',
|
||||
' <builtin>',
|
||||
...(lanAddresses.length ? [
|
||||
' <metatrafficMulticastLocatorList>',
|
||||
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
||||
' </metatrafficMulticastLocatorList>',
|
||||
] : []),
|
||||
' <metatrafficUnicastLocatorList>',
|
||||
...localAddresses.flatMap((address) => [
|
||||
' <locator>',
|
||||
@@ -4382,6 +4467,9 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
||||
]),
|
||||
' </metatrafficUnicastLocatorList>',
|
||||
' <initialPeersList>',
|
||||
...(lanAddresses.length ? [
|
||||
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
||||
] : []),
|
||||
...peerAddresses.flatMap((address) => peers.flatMap((port) => [
|
||||
' <locator>',
|
||||
' <udpv4>',
|
||||
@@ -4467,25 +4555,51 @@ async function prepareLocalRosNetworkConfig(
|
||||
return configPath
|
||||
}
|
||||
|
||||
function parseCameraLanAddresses(output: string): string[] {
|
||||
const interfaces = output.split('\n').filter((line) => line.trim()).flatMap((line) => JSON.parse(line))
|
||||
return Array.from(new Set<string>(interfaces.flatMap((item) =>
|
||||
(item.addr_info ?? [])
|
||||
.filter((address: { family?: string; scope?: string; local?: string }) =>
|
||||
address.family === 'inet' && address.scope === 'global'
|
||||
&& typeof address.local === 'string'
|
||||
&& /^(?:\d{1,3}\.){3}\d{1,3}$/.test(address.local)
|
||||
&& address.local.split('.').every((part) => Number(part) <= 255))
|
||||
.map((address: { local: string }) => address.local),
|
||||
)))
|
||||
}
|
||||
|
||||
function cameraLanEnabled(folderName: string): boolean {
|
||||
return /^\[lms-nav2-yahboom-camera\]yahboom_camera_v[^/]+_ws$/.test(folderName)
|
||||
}
|
||||
|
||||
async function prepareRemoteRosNetworkConfig(
|
||||
target: RemoteWorkspaceTarget,
|
||||
runtimeRoot: string,
|
||||
network?: RosNetworkConfig | null,
|
||||
rosDomainId?: string | null,
|
||||
allowCameraLan = false,
|
||||
): Promise<string | null> {
|
||||
if (!rosNetworkEnabled(network)) return null
|
||||
if (!rosNetworkEnabled(network) && !allowCameraLan) return null
|
||||
|
||||
const [peerAddress, localAddress] = await Promise.all([
|
||||
readLocalTailscaleIpv4(),
|
||||
readRemoteTailscaleIpv4(target),
|
||||
rosNetworkEnabled(network) ? readLocalTailscaleIpv4() : Promise.resolve('127.0.0.1'),
|
||||
rosNetworkEnabled(network) ? readRemoteTailscaleIpv4(target) : Promise.resolve('127.0.0.1'),
|
||||
])
|
||||
const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml`
|
||||
const lanAddresses = allowCameraLan ? parseCameraLanAddresses(await runSshOutput(target, [
|
||||
'set -e',
|
||||
'for interface in /sys/class/net/*; do',
|
||||
' [ -e "$interface/device" ] || continue',
|
||||
' ip -j -4 address show dev "${interface##*/}" up scope global',
|
||||
'done',
|
||||
].join('\n'), 15_000)) : []
|
||||
if (allowCameraLan && !lanAddresses.length) throw new Error('카메라 내부망 통신에 사용할 활성 Ethernet/Wi-Fi IPv4 주소가 없습니다.')
|
||||
const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}${allowCameraLan ? '-camera-lan' : ''}.xml`
|
||||
await runSsh(target, [
|
||||
'set -e',
|
||||
`CONFIG_PATH=${quoteShellSingle(configPath)}`,
|
||||
...expandRemotePathVar('CONFIG_PATH'),
|
||||
'mkdir -p "$(dirname \"$CONFIG_PATH\")"',
|
||||
`printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId)))} | base64 -d > "$CONFIG_PATH"`,
|
||||
`printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId, lanAddresses)))} | base64 -d > "$CONFIG_PATH"`,
|
||||
].join('\n'), 15_000)
|
||||
return configPath
|
||||
}
|
||||
@@ -5337,7 +5451,8 @@ async function startRemoteWorkspaceModule(
|
||||
const runtimeRoot = nav2Module
|
||||
? await ensureRemoteNav2RuntimeAssets(target)
|
||||
: await ensureRemoteStudioRuntimeAssets(target)
|
||||
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
|
||||
const allowCameraLan = cameraLanEnabled(targetFolderName)
|
||||
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId, allowCameraLan)
|
||||
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
|
||||
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
|
||||
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
|
||||
@@ -5390,7 +5505,7 @@ async function startRemoteWorkspaceModule(
|
||||
...expandRemotePathVar('ROOT'),
|
||||
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
|
||||
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
|
||||
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
|
||||
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) || allowCameraLan ? '0' : '1'}`,
|
||||
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
|
||||
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
|
||||
'export FASTDDS_CONFIG_PATH',
|
||||
@@ -6342,7 +6457,15 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
closeSplashAndShowMain()
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
||||
ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages())
|
||||
ipcMain.handle('local-workspace:modules:docker-images', () => listWorkspaceDockerImages())
|
||||
ipcMain.handle('local-workspace:modules:git-sources', (_event, root: string) => {
|
||||
const workspace = expandUserPath(root)
|
||||
if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
return listLocalGitModules(workspace)
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:git-source', (_event, root: string, folder: string) => inspectLocalGitModule(root, folder))
|
||||
ipcMain.handle('remote-workspace:modules:import-git', (_event, target: RemoteWorkspaceTarget, root: string, input: GitModuleImportInput & { folderName: string }) => importRemoteGitModule(target, root, input))
|
||||
ipcMain.handle('remote-workspace:modules:docker-images', (_event, target: RemoteWorkspaceTarget) => listWorkspaceDockerImages(target))
|
||||
ipcMain.handle('local-workspace:docker:status', () => checkDockerStatus())
|
||||
ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/'))
|
||||
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
||||
|
||||
Reference in New Issue
Block a user