1bf60d01f3
- Receiver에 expected packet type 필드 제거 - 로그 아래로 쌓기 - Clear 버튼 추가 - Trasmitter 세션에서는 packet report 숨김
528 lines
17 KiB
JavaScript
528 lines
17 KiB
JavaScript
const { DTM } = require('./DTM.cjs');
|
|
const { DEFAULT_DEVICE, DEFAULT_SWD_SPEED } = require('./DTM_transport.cjs');
|
|
const {
|
|
ChannelMode,
|
|
DtmModulationMode,
|
|
DtmPacketType,
|
|
DtmPhysicalLayer,
|
|
} = require('./dtm-types.cjs');
|
|
const { listJLinkProbes, getJLinkSetupHint } = require('./jlink/jlinkProbe.cjs');
|
|
const { hasSeggerRttStack } = require('./jlink/jlinkPaths.cjs');
|
|
const { allocateSessionPorts, JLINK_PORTS_BY_SESSION } = require('./jlink/jlinkSessionPorts.cjs');
|
|
|
|
const BLE_CHANNELS = Array.from({ length: 40 }, (_, index) => index);
|
|
const SESSION_IDS = ['receiver', 'transmitter'];
|
|
|
|
const createInitialStatus = mode => ({
|
|
running: false,
|
|
mode,
|
|
currentChannel: null,
|
|
lastReceived: new Array(40).fill(0),
|
|
txPower: 0,
|
|
message: 'Idle',
|
|
jlinkPorts: null,
|
|
});
|
|
|
|
const allocatedPortsBySession = {
|
|
receiver: null,
|
|
transmitter: null,
|
|
};
|
|
|
|
const getGloballyReservedPorts = () => {
|
|
const reserved = new Set();
|
|
for (const sessionId of SESSION_IDS) {
|
|
const ports = allocatedPortsBySession[sessionId];
|
|
if (!ports) continue;
|
|
reserved.add(ports.gdb);
|
|
reserved.add(ports.swo);
|
|
reserved.add(ports.rtt);
|
|
}
|
|
return reserved;
|
|
};
|
|
|
|
const dtmBySession = {
|
|
receiver: null,
|
|
transmitter: null,
|
|
};
|
|
|
|
const configKeyBySession = {
|
|
receiver: '',
|
|
transmitter: '',
|
|
};
|
|
|
|
const lastConfigBySession = {
|
|
receiver: null,
|
|
transmitter: null,
|
|
};
|
|
|
|
const runningBySession = {
|
|
receiver: false,
|
|
transmitter: false,
|
|
};
|
|
|
|
const startingBySession = {
|
|
receiver: false,
|
|
transmitter: false,
|
|
};
|
|
|
|
const statusBySession = {
|
|
receiver: createInitialStatus('receiver'),
|
|
transmitter: createInitialStatus('transmitter'),
|
|
};
|
|
|
|
let dtmEventSink = () => undefined;
|
|
|
|
const configKey = config =>
|
|
`${config.port}|${config.device || DEFAULT_DEVICE}|${Number(config.speed || DEFAULT_SWD_SPEED)}`;
|
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
const probeSerialForSession = sessionId => {
|
|
const fromConfig = lastConfigBySession[sessionId]?.port;
|
|
if (fromConfig) return String(fromConfig);
|
|
const key = configKeyBySession[sessionId];
|
|
return key ? key.split('|')[0] : '';
|
|
};
|
|
|
|
const emit = event => {
|
|
dtmEventSink(event);
|
|
};
|
|
|
|
const emitForSession = (sessionId, event) => {
|
|
emit({ sessionId, ...event });
|
|
};
|
|
|
|
const getSessionStatus = sessionId => statusBySession[sessionId];
|
|
|
|
const releaseDtm = async sessionId => {
|
|
if (!dtmBySession[sessionId]) {
|
|
allocatedPortsBySession[sessionId] = null;
|
|
getSessionStatus(sessionId).jlinkPorts = null;
|
|
return;
|
|
}
|
|
|
|
const instance = dtmBySession[sessionId];
|
|
dtmBySession[sessionId] = null;
|
|
configKeyBySession[sessionId] = '';
|
|
allocatedPortsBySession[sessionId] = null;
|
|
getSessionStatus(sessionId).jlinkPorts = null;
|
|
await instance.dispose().catch(() => {});
|
|
await wait(800);
|
|
};
|
|
|
|
const isSessionBusy = sessionId => {
|
|
const status = getSessionStatus(sessionId);
|
|
return runningBySession[sessionId] || startingBySession[sessionId] || status.running;
|
|
};
|
|
|
|
const sharesProbeSerial = (leftPort, rightPort) =>
|
|
Boolean(leftPort) && Boolean(rightPort) && String(leftPort) === String(rightPort);
|
|
|
|
const findProbeConflict = (sessionId, config) => {
|
|
if (!config.port) return null;
|
|
|
|
for (const otherSessionId of SESSION_IDS) {
|
|
if (otherSessionId === sessionId) continue;
|
|
|
|
const sameProbe = sharesProbeSerial(probeSerialForSession(otherSessionId), config.port);
|
|
|
|
if (!sameProbe) continue;
|
|
|
|
if (startingBySession[otherSessionId] || runningBySession[otherSessionId] || dtmBySession[otherSessionId]) {
|
|
return otherSessionId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const assertProbeAvailable = (sessionId, config) => {
|
|
const conflict = findProbeConflict(sessionId, config);
|
|
if (!conflict) return;
|
|
|
|
throw new Error(
|
|
`동일 J-Link(SN ${config.port})는 GDB Server를 하나만 쓸 수 있습니다. ${conflict} 세션을 Stop한 뒤 ${sessionId}를 시작하세요. 서로 다른 SN이면 동시 실행이 가능합니다.`
|
|
);
|
|
};
|
|
|
|
const releaseInactiveSessionUsingProbe = async (sessionId, config) => {
|
|
const nextKey = configKey(config);
|
|
const otherSessionId = SESSION_IDS.find(id => id !== sessionId && configKeyBySession[id] === nextKey);
|
|
|
|
if (!otherSessionId) return;
|
|
|
|
if (isSessionBusy(otherSessionId) || dtmBySession[otherSessionId]) {
|
|
throw new Error(
|
|
`동일 J-Link(${config.port})는 RTT 세션을 하나만 열 수 있습니다. ${otherSessionId} 세션을 Stop한 뒤 다시 시도하세요.`
|
|
);
|
|
}
|
|
|
|
await releaseDtm(otherSessionId);
|
|
};
|
|
|
|
const attachEvents = (sessionId, instance) => {
|
|
instance.onReset(() => {
|
|
const status = getSessionStatus(sessionId);
|
|
status.currentChannel = null;
|
|
status.lastReceived = new Array(40).fill(0);
|
|
emitForSession(sessionId, { type: 'reset' });
|
|
});
|
|
|
|
instance.onStarted(event => {
|
|
const status = getSessionStatus(sessionId);
|
|
status.currentChannel = event.channel;
|
|
emitForSession(sessionId, { type: 'started', channel: event.channel, mode: event.type });
|
|
});
|
|
|
|
instance.onEnded(event => {
|
|
const status = getSessionStatus(sessionId);
|
|
status.currentChannel = null;
|
|
if (typeof event.packets === 'number') {
|
|
if (event.accumulate) {
|
|
status.lastReceived[event.channel] += event.packets;
|
|
} else {
|
|
status.lastReceived[event.channel] = Math.max(
|
|
status.lastReceived[event.channel] || 0,
|
|
event.packets
|
|
);
|
|
}
|
|
}
|
|
emitForSession(sessionId, {
|
|
type: 'ended',
|
|
channel: event.channel,
|
|
mode: event.type,
|
|
data: [...status.lastReceived],
|
|
...(typeof event.packets === 'number' ? { packets: event.packets } : {}),
|
|
});
|
|
});
|
|
|
|
instance.onPackets(event => {
|
|
const status = getSessionStatus(sessionId);
|
|
if (typeof event.packets !== 'number' || typeof event.channel !== 'number') {
|
|
return;
|
|
}
|
|
status.lastReceived[event.channel] = event.packets;
|
|
emitForSession(sessionId, {
|
|
type: 'packets',
|
|
channel: event.channel,
|
|
packets: event.packets,
|
|
data: [...status.lastReceived],
|
|
});
|
|
});
|
|
|
|
instance.onTraffic(event => {
|
|
if (sessionId === 'transmitter' && event.kind === 'packetReport') {
|
|
return;
|
|
}
|
|
emitForSession(sessionId, {
|
|
type: 'traffic',
|
|
direction: event.direction,
|
|
data: event.data,
|
|
note: event.note,
|
|
});
|
|
});
|
|
};
|
|
|
|
const ensureDtm = async (sessionId, config) => {
|
|
const nextKey = configKey(config);
|
|
await releaseInactiveSessionUsingProbe(sessionId, config);
|
|
|
|
if (!dtmBySession[sessionId] || configKeyBySession[sessionId] !== nextKey) {
|
|
await releaseDtm(sessionId);
|
|
const ports = await allocateSessionPorts(sessionId, getGloballyReservedPorts());
|
|
allocatedPortsBySession[sessionId] = ports;
|
|
getSessionStatus(sessionId).jlinkPorts = ports;
|
|
|
|
const instance = new DTM(config.port, {
|
|
device: config.device || DEFAULT_DEVICE,
|
|
speed: Number(config.speed || DEFAULT_SWD_SPEED),
|
|
gdbPort: ports.gdb,
|
|
swoPort: ports.swo,
|
|
rttPort: ports.rtt,
|
|
});
|
|
attachEvents(sessionId, instance);
|
|
await instance.connect();
|
|
dtmBySession[sessionId] = instance;
|
|
configKeyBySession[sessionId] = nextKey;
|
|
|
|
emitForSession(sessionId, {
|
|
type: 'status',
|
|
message: `RTT ports GDB ${ports.gdb} / SWO ${ports.swo} / RTT ${ports.rtt}`,
|
|
running: false,
|
|
mode: getSessionStatus(sessionId).mode,
|
|
jlinkPorts: ports,
|
|
});
|
|
}
|
|
return dtmBySession[sessionId];
|
|
};
|
|
|
|
const runSetupStep = async (sessionId, label, action) => {
|
|
const status = getSessionStatus(sessionId);
|
|
status.message = label;
|
|
emitForSession(sessionId, { type: 'status', message: label, running: true, mode: status.mode });
|
|
|
|
try {
|
|
return await action();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (message === 'Timeout') {
|
|
throw new Error(
|
|
`${label} timed out. Check J-Link SN, SWD, target device (NRF52840_xxAA), DTM_TRANSPORT_RTT firmware flashed and running, and traffic log for TX without RX. Close Ozone or a second GDB Server on the same probe.`
|
|
);
|
|
}
|
|
throw new Error(`${label} failed: ${message}`);
|
|
}
|
|
};
|
|
|
|
const setupTest = async (sessionId, instance, config) => {
|
|
const status = getSessionStatus(sessionId);
|
|
|
|
await runSetupStep(sessionId, 'Resetting device', () => instance.setupReset());
|
|
|
|
if (config.testMode === 'transmitter') {
|
|
status.txPower = await runSetupStep(sessionId, 'Applying TX power', () =>
|
|
instance.setTxPower(Number(config.txPower))
|
|
);
|
|
await runSetupStep(sessionId, 'Applying packet length', () =>
|
|
instance.setupLength(Number(config.length))
|
|
);
|
|
await runSetupStep(sessionId, 'Applying modulation mode', () =>
|
|
instance.setupModulation(Number(config.modulationMode))
|
|
);
|
|
} else {
|
|
await runSetupStep(sessionId, 'Applying packet length', () =>
|
|
instance.setupLength(Number(config.length))
|
|
);
|
|
}
|
|
|
|
await runSetupStep(sessionId, 'Applying PHY mode', () => instance.setupPhy(Number(config.phy)));
|
|
};
|
|
|
|
const normalizeChannelIndexes = config => {
|
|
const single = BLE_CHANNELS.indexOf(Number(config.singleChannel));
|
|
const rangeValues = [Number(config.rangeStart), Number(config.rangeEnd)];
|
|
const indexedRange = rangeValues.map(value => BLE_CHANNELS.indexOf(value)).sort((a, b) => a - b);
|
|
return {
|
|
singleChannelIndexed: single,
|
|
rangeStart: indexedRange[0],
|
|
rangeEnd: indexedRange[1],
|
|
};
|
|
};
|
|
|
|
const startTest = async (sessionId, config, nextEmitter) => {
|
|
if (typeof nextEmitter === 'function') {
|
|
dtmEventSink = nextEmitter;
|
|
}
|
|
|
|
if (!SESSION_IDS.includes(sessionId)) {
|
|
throw new Error(`Unknown session: ${sessionId}`);
|
|
}
|
|
|
|
if (!config.port) {
|
|
throw new Error('J-Link serial number is required.');
|
|
}
|
|
|
|
if (!hasSeggerRttStack()) {
|
|
throw new Error(getJLinkSetupHint() || 'SEGGER J-Link GDB Server is required for RTT.');
|
|
}
|
|
|
|
lastConfigBySession[sessionId] = config;
|
|
startingBySession[sessionId] = true;
|
|
|
|
const status = getSessionStatus(sessionId);
|
|
status.mode = config.testMode;
|
|
status.message = 'Connecting';
|
|
status.lastReceived = new Array(40).fill(0);
|
|
emitForSession(sessionId, { type: 'status', message: status.message, running: false, mode: status.mode });
|
|
|
|
let instance;
|
|
try {
|
|
assertProbeAvailable(sessionId, config);
|
|
instance = await ensureDtm(sessionId, config);
|
|
runningBySession[sessionId] = true;
|
|
status.running = true;
|
|
emitForSession(sessionId, { type: 'status', message: status.message, running: true, mode: status.mode });
|
|
|
|
status.message = 'Setting up device';
|
|
emitForSession(sessionId, { type: 'status', message: status.message, running: true, mode: status.mode });
|
|
await setupTest(sessionId, instance, config);
|
|
startingBySession[sessionId] = false;
|
|
} catch (error) {
|
|
startingBySession[sessionId] = false;
|
|
runningBySession[sessionId] = false;
|
|
status.running = false;
|
|
await releaseDtm(sessionId).catch(() => {});
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
status.message = message;
|
|
emitForSession(sessionId, { type: 'error', message });
|
|
throw error;
|
|
}
|
|
|
|
const { singleChannelIndexed, rangeStart, rangeEnd } = normalizeChannelIndexes(config);
|
|
|
|
status.message = 'Running test';
|
|
emitForSession(sessionId, {
|
|
type: 'status',
|
|
message: status.message,
|
|
running: true,
|
|
mode: status.mode,
|
|
txPower: status.txPower,
|
|
});
|
|
|
|
let testPromise;
|
|
|
|
if (config.testMode === 'transmitter' && config.channelMode === ChannelMode.single) {
|
|
testPromise = instance.singleChannelTransmitterTest(
|
|
Number(config.bitpattern),
|
|
Number(config.length),
|
|
singleChannelIndexed,
|
|
Number(config.timeoutMs)
|
|
);
|
|
} else if (config.testMode === 'transmitter') {
|
|
testPromise = instance.sweepTransmitterTest(
|
|
Number(config.bitpattern),
|
|
Number(config.length),
|
|
rangeStart,
|
|
rangeEnd,
|
|
Number(config.sweepTimeMs),
|
|
Number(config.timeoutMs)
|
|
);
|
|
} else if (config.channelMode === ChannelMode.single) {
|
|
testPromise = instance.singleChannelReceiverTest(
|
|
Number(config.bitpattern),
|
|
Number(config.length),
|
|
singleChannelIndexed,
|
|
Number(config.timeoutMs)
|
|
);
|
|
} else {
|
|
testPromise = instance.sweepReceiverTest(
|
|
Number(config.bitpattern),
|
|
Number(config.length),
|
|
rangeStart,
|
|
rangeEnd,
|
|
Number(config.sweepTimeMs),
|
|
Number(config.timeoutMs)
|
|
);
|
|
}
|
|
|
|
testPromise
|
|
.then(result => {
|
|
runningBySession[sessionId] = false;
|
|
status.running = false;
|
|
releaseDtm(sessionId).catch(() => {});
|
|
if (result.type === 'error') {
|
|
status.message = result.message;
|
|
emitForSession(sessionId, { type: 'error', message: result.message });
|
|
return;
|
|
}
|
|
|
|
if (result.type === 'receiver') {
|
|
status.lastReceived = result.receivedPerChannel;
|
|
} else {
|
|
status.lastReceived = new Array(40).fill(0);
|
|
}
|
|
|
|
status.message = 'Test finished';
|
|
emitForSession(sessionId, {
|
|
type: 'complete',
|
|
mode: result.type,
|
|
data: [...status.lastReceived],
|
|
txPower: status.txPower,
|
|
});
|
|
})
|
|
.catch(error => {
|
|
runningBySession[sessionId] = false;
|
|
status.running = false;
|
|
releaseDtm(sessionId).catch(() => {});
|
|
status.message = error.message;
|
|
emitForSession(sessionId, { type: 'error', message: error.message });
|
|
});
|
|
|
|
return { ok: true };
|
|
};
|
|
|
|
const stopTest = async sessionId => {
|
|
if (!SESSION_IDS.includes(sessionId)) {
|
|
throw new Error(`Unknown session: ${sessionId}`);
|
|
}
|
|
|
|
const instance = dtmBySession[sessionId];
|
|
const status = getSessionStatus(sessionId);
|
|
|
|
if (instance) {
|
|
await instance.endTest().catch(() => {});
|
|
}
|
|
await releaseDtm(sessionId);
|
|
|
|
startingBySession[sessionId] = false;
|
|
runningBySession[sessionId] = false;
|
|
status.running = false;
|
|
status.message = 'Stopped';
|
|
emitForSession(sessionId, { type: 'status', message: status.message, running: false, mode: status.mode });
|
|
return { ok: true };
|
|
};
|
|
|
|
const listJLinks = async () => listJLinkProbes();
|
|
|
|
const syncSessionConfig = (sessionId, config) => {
|
|
if (!SESSION_IDS.includes(sessionId)) {
|
|
throw new Error(`Unknown session: ${sessionId}`);
|
|
}
|
|
lastConfigBySession[sessionId] = config;
|
|
return { ok: true };
|
|
};
|
|
|
|
const canStartSession = async (sessionId, config) => {
|
|
try {
|
|
assertProbeAvailable(sessionId, config);
|
|
|
|
if (!dtmBySession[sessionId]) {
|
|
await allocateSessionPorts(sessionId, getGloballyReservedPorts());
|
|
}
|
|
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
};
|
|
|
|
const getStatus = async sessionId => {
|
|
if (!SESSION_IDS.includes(sessionId)) {
|
|
throw new Error(`Unknown session: ${sessionId}`);
|
|
}
|
|
return {
|
|
...getSessionStatus(sessionId),
|
|
running: runningBySession[sessionId],
|
|
starting: startingBySession[sessionId],
|
|
jlinkPorts:
|
|
allocatedPortsBySession[sessionId] ||
|
|
getSessionStatus(sessionId).jlinkPorts ||
|
|
JLINK_PORTS_BY_SESSION[sessionId],
|
|
};
|
|
};
|
|
|
|
const stopAllSessions = async () => {
|
|
await Promise.all(SESSION_IDS.map(sessionId => releaseDtm(sessionId)));
|
|
};
|
|
|
|
module.exports = {
|
|
listJLinks,
|
|
listSerialPorts: listJLinks,
|
|
startTest,
|
|
stopTest,
|
|
getStatus,
|
|
syncSessionConfig,
|
|
canStartSession,
|
|
stopAllSessions,
|
|
constants: {
|
|
ChannelMode,
|
|
DtmPhysicalLayer,
|
|
DtmModulationMode,
|
|
DtmPacketType,
|
|
DEFAULT_DEVICE,
|
|
DEFAULT_SWD_SPEED,
|
|
},
|
|
};
|