feat: support simultaneous Tailscale and LAN communication for Yahboom cameras

This commit is contained in:
2026-09-30 15:37:17 +09:00
parent 9387ade537
commit 08f74575c2
38 changed files with 917 additions and 142 deletions
+48
View File
@@ -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
View File
@@ -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())