initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
@@ -0,0 +1,70 @@
|
||||
# DTM RTT Tester
|
||||
|
||||
- Electron 기반의 독립형 DTM 테스트 도구
|
||||
- nRF5 SDK DTM 예제의 **RTT transport**(`DTM_TRANSPORT_RTT`)와 동일한 2바이트 DTM 프로토콜을 **SEGGER J-Link RTT**로 주고받음
|
||||
|
||||
## 펌웨어 (대상 보드)
|
||||
|
||||
- nRF5 SDK 17.1.0 `examples/dtm/direct_test_mode/main.c`를 **RTT 빌드**로 플래시
|
||||
|
||||
- `SEGGER_RTT_Read(0, …)` / `SEGGER_RTT_Write(0, …)` — **Up/Down channel 0**
|
||||
- Keil 등에서 전처리기 `DTM_TRANSPORT_RTT` 정의
|
||||
- pca10056 기본 타겟: **NRF52840_xxAA**
|
||||
|
||||
## 호스트 요구 사항
|
||||
|
||||
- **플래시**: `nrfjprog` (nRF Command Line Tools / `C:\nrfutil\bin` 등) — 프로브 목록에도 사용
|
||||
- **RTT 테스트**: [SEGGER J-Link Software and Documentation Pack](https://www.segger.com/downloads/jlink/) — `JLinkGDBServerCL.exe` 필요 (`nrfjprog`만으로는 RTT 불가)
|
||||
- SEGGER V7.50+ 기본 설치 경로: `C:\Program Files\SEGGER\JLink_V942\` (버전마다 `JLink_Vxxx` — `JLink\` 아님)
|
||||
- PATH에 없으면 `SEGGER_JLINK_PATH`에 위 폴더 지정 (예: `C:\Program Files\SEGGER\JLink_V942`)
|
||||
- **동시 실행**: **J-Link 2대 + 보드 2개**, 세션마다 **서로 다른 프로브 SN**. GDB Server는 인스턴스마다 GDB/SWO/RTT 포트가 겹치면 안 됨 (기본 2331/2332는 한 프로세스만 가능)
|
||||
- Receiver: GDB/SWO/RTT 선호 **29021/29022/19021** (점유 시 자동으로 빈 포트 탐색)
|
||||
- Transmitter: **29031/29032/19022**
|
||||
- **J-Link 1대만** 있으면 한 세션만 Start 가능 (다른 쪽 Start 시 충돌·RTT 타임아웃). 앱이 같은 SN이면 Start를 막고 안내 메시지를 표시함
|
||||
|
||||
- 프로브가 보이지 않는 경우 PowerShell에서 `nrfjprog --ids`로 SN이 나오는지 먼저 확인(앱은 `nrfjprog --ids`로 목록 채움)
|
||||
|
||||
## 현재 구조
|
||||
|
||||
- `electron/main.cjs`: Electron 메인 프로세스
|
||||
- `electron/preload.cjs`: renderer API
|
||||
- `electron/services/DTM.cjs`: DTM 테스트 로직
|
||||
- `electron/services/DTM_transport.cjs`: J-Link RTT 기반 DTM transport
|
||||
- `electron/services/jlink/`: J-Link 프로브 목록, GDB Server, RTT Telnet 클라이언트
|
||||
- `electron/services/dtmController.cjs`: 세션·IPC
|
||||
- `src/App.tsx`: UI
|
||||
|
||||
## RTT 연결 방식
|
||||
|
||||
1. 세션별로 `JLinkGDBServerCL`을 실행해 SWD로 타겟에 연결
|
||||
2. 세션별 TCP (선호값, busy면 자동 대체): Receiver **29021/29022/19021**, Transmitter **29031/29032/19022**
|
||||
3. 연결 직후 `RTTCh;0` 설정 (펌웨어 channel 0과 일치)
|
||||
4. DTM 명령/응답은 UART와 동일하게 **2바이트** 프레임
|
||||
|
||||
- RTT Viewer 등이 이미 같은 J-Link/포트를 점유 중이면 연결이 실패할 수 있음
|
||||
|
||||
## 시작
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
로컬 실행 (UI 빌드 후 Electron):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Windows 설치/실행 파일 (`release/` 폴더):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
- `release/DTM RTT Tester Setup x.x.x.exe` — NSIS 설치 프로그램
|
||||
- `release/DTM RTT Tester x.x.x.exe` — portable (설치 없이 실행)
|
||||
|
||||
macOS (Apple Silicon): `npm run build:mac`
|
||||
@@ -0,0 +1,22 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const electronBinary = require('electron');
|
||||
const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(electronBinary, args, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
child.on('close', code => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
child.on('error', error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
let electronMain;
|
||||
try {
|
||||
electronMain = require('electron/main');
|
||||
} catch {
|
||||
electronMain = require('electron');
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, ipcMain, Menu } = electronMain;
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
listJLinks,
|
||||
startTest,
|
||||
stopTest,
|
||||
getStatus,
|
||||
syncSessionConfig,
|
||||
canStartSession,
|
||||
stopAllSessions,
|
||||
} = require('./services/dtmController.cjs');
|
||||
|
||||
let mainWindow;
|
||||
|
||||
const createWindow = async () => {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1500,
|
||||
height: 980,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
backgroundColor: '#f3f0e8',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
if (devServerUrl) {
|
||||
await mainWindow.loadURL(devServerUrl);
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
} else {
|
||||
await mainWindow.loadFile(path.join(app.getAppPath(), 'dist', 'index.html'));
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle('jlink:list', async () => listJLinks());
|
||||
ipcMain.handle('serial:list', async () => listJLinks());
|
||||
ipcMain.handle('dtm:status', async (_, sessionId) => getStatus(sessionId));
|
||||
ipcMain.handle('dtm:stop', async (_, sessionId) => stopTest(sessionId));
|
||||
ipcMain.handle('dtm:sync-config', async (_, sessionId, config) => syncSessionConfig(sessionId, config));
|
||||
ipcMain.handle('dtm:can-start', async (_, sessionId, config) => canStartSession(sessionId, config));
|
||||
ipcMain.handle('dtm:start', async (_, sessionId, config) =>
|
||||
startTest(sessionId, config, event => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('dtm:event', event);
|
||||
})
|
||||
);
|
||||
|
||||
app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null);
|
||||
return createWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopAllSessions().catch(() => {});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow().catch(console.error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
let electronRenderer;
|
||||
try {
|
||||
electronRenderer = require('electron/renderer');
|
||||
} catch {
|
||||
electronRenderer = require('electron');
|
||||
}
|
||||
|
||||
const { contextBridge, ipcRenderer } = electronRenderer;
|
||||
|
||||
contextBridge.exposeInMainWorld('dtmApi', {
|
||||
listJLinks: () => ipcRenderer.invoke('jlink:list'),
|
||||
listSerialPorts: () => ipcRenderer.invoke('jlink:list'),
|
||||
startTest: (sessionId, config) => ipcRenderer.invoke('dtm:start', sessionId, config),
|
||||
stopTest: sessionId => ipcRenderer.invoke('dtm:stop', sessionId),
|
||||
getStatus: sessionId => ipcRenderer.invoke('dtm:status', sessionId),
|
||||
syncSessionConfig: (sessionId, config) => ipcRenderer.invoke('dtm:sync-config', sessionId, config),
|
||||
canStartSession: (sessionId, config) => ipcRenderer.invoke('dtm:can-start', sessionId, config),
|
||||
onEvent: callback => {
|
||||
const handler = (_, event) => callback(event);
|
||||
ipcRenderer.on('dtm:event', handler);
|
||||
return () => ipcRenderer.removeListener('dtm:event', handler);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const {
|
||||
DTMTransport,
|
||||
DTM_CONTROL,
|
||||
DTM_DC,
|
||||
DTM_EVENT,
|
||||
DTM_PARAMETER,
|
||||
} = require('./DTM_transport.cjs');
|
||||
|
||||
const validate0x09Command = res => {
|
||||
if (!Array.isArray(res) || res.length !== 2 || (res[1] & 0x01) !== 0) {
|
||||
throw new Error(`Invalid result: ${JSON.stringify(res)}`);
|
||||
}
|
||||
const rawDbmValue = (res[1] & 0xfe) >> 1;
|
||||
const modifier = res[0] & 0x01 ? -128 : 0;
|
||||
return modifier + rawDbmValue;
|
||||
};
|
||||
|
||||
const validateResult = res => {
|
||||
if (!Array.isArray(res) || res.length !== 2 || (res[0] !== 0 && res[1] !== 0)) {
|
||||
throw new Error('Invalid result');
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 3000;
|
||||
const RESET_COMMAND_TIMEOUT_MS = 8000;
|
||||
|
||||
const channelToFrequency = channel => 2402 + 2 * channel;
|
||||
const reportSuccess = report => (report && report[0] & 0x01) === 0;
|
||||
const RECEIVER_LIVE_POLL_MS = 400;
|
||||
|
||||
const packetCountFromResponse = response => {
|
||||
if (!Array.isArray(response) || response.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
if (((response[0] & 0x80) >> 7) !== DTM_EVENT.LE_PACKET_REPORT_EVENT) {
|
||||
return 0;
|
||||
}
|
||||
return ((response[0] & 0x3f) << 8) | response[1];
|
||||
};
|
||||
|
||||
class DTM {
|
||||
#dtmTransport;
|
||||
#lengthPayload = 1;
|
||||
#modulationPayload = 0;
|
||||
#phyPayload = 0x01;
|
||||
#dbmPayload = 0;
|
||||
#isTransmitting = false;
|
||||
#isReceiving = false;
|
||||
#timedOut = false;
|
||||
#sweepTimedOut = false;
|
||||
#timeoutEvent;
|
||||
#onEndEvent;
|
||||
#activeReceiverChannel = null;
|
||||
#receiverHopAccumulate = false;
|
||||
#receiverLiveTotal = 0;
|
||||
#receiverPollTimer = null;
|
||||
#receiverPollInFlight = false;
|
||||
#receiverPollParams = null;
|
||||
#eventEmitter = new EventEmitter();
|
||||
|
||||
constructor(jlinkSerial, transportOptions = {}) {
|
||||
this.#dtmTransport = new DTMTransport(jlinkSerial, transportOptions);
|
||||
this.#dtmTransport.onPacketReport(count => {
|
||||
if (
|
||||
!this.#isReceiving ||
|
||||
this.#activeReceiverChannel === null ||
|
||||
this.#receiverPollInFlight ||
|
||||
this.#receiverPollTimer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.#eventEmitter.emit('packets', {
|
||||
type: 'receiver',
|
||||
channel: this.#activeReceiverChannel,
|
||||
packets: count,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.#dtmTransport.open();
|
||||
}
|
||||
|
||||
onReset(listener) {
|
||||
this.#eventEmitter.on('reset', listener);
|
||||
return () => this.#eventEmitter.removeListener('reset', listener);
|
||||
}
|
||||
|
||||
onStarted(listener) {
|
||||
this.#eventEmitter.on('started', listener);
|
||||
return () => this.#eventEmitter.removeListener('started', listener);
|
||||
}
|
||||
|
||||
onEnded(listener) {
|
||||
this.#eventEmitter.on('ended', listener);
|
||||
return () => this.#eventEmitter.removeListener('ended', listener);
|
||||
}
|
||||
|
||||
onTraffic(listener) {
|
||||
return this.#dtmTransport.onTraffic(listener);
|
||||
}
|
||||
|
||||
onPackets(listener) {
|
||||
this.#eventEmitter.on('packets', listener);
|
||||
return () => this.#eventEmitter.removeListener('packets', listener);
|
||||
}
|
||||
|
||||
#resetEvent() {
|
||||
this.#eventEmitter.emit('reset');
|
||||
}
|
||||
|
||||
#startedTransmitterEvent(channel) {
|
||||
this.#eventEmitter.emit('started', { type: 'transmitter', channel });
|
||||
return () => this.#eventEmitter.emit('ended', { type: 'transmitter', channel });
|
||||
}
|
||||
|
||||
#startedReceiverEvent(channel, accumulate = false) {
|
||||
this.#activeReceiverChannel = channel;
|
||||
this.#receiverHopAccumulate = accumulate;
|
||||
this.#eventEmitter.emit('started', { type: 'receiver', channel });
|
||||
return packets => {
|
||||
this.#activeReceiverChannel = null;
|
||||
this.#eventEmitter.emit('ended', {
|
||||
type: 'receiver',
|
||||
channel,
|
||||
packets,
|
||||
accumulate,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
startTimeoutEvent(rxtxFlag, timeout) {
|
||||
let timeoutEvent;
|
||||
this.#timedOut = false;
|
||||
if (timeout > 0) {
|
||||
timeoutEvent = setTimeout(() => {
|
||||
this.#timedOut = true;
|
||||
if (rxtxFlag() && !this.#sweepTimedOut) {
|
||||
this.endCurrentTest().catch(() => undefined);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
return timeoutEvent;
|
||||
}
|
||||
|
||||
startSweepTimeoutEvent(rxtxFlag, timeout) {
|
||||
let timeoutEvent;
|
||||
this.#sweepTimedOut = false;
|
||||
if (timeout > 0) {
|
||||
timeoutEvent = setTimeout(() => {
|
||||
this.#sweepTimedOut = true;
|
||||
if (rxtxFlag() && !this.#timedOut) {
|
||||
this.endCurrentTest().catch(() => undefined);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
return timeoutEvent;
|
||||
}
|
||||
|
||||
endEventDataReceived() {
|
||||
return new Promise(done => {
|
||||
this.#onEndEvent = received => {
|
||||
this.#onEndEvent = undefined;
|
||||
done({ received });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async endCurrentTest() {
|
||||
const response = await this.#dtmTransport.sendCMD(DTMTransport.createEndCMD());
|
||||
const receivedPackets = packetCountFromResponse(response);
|
||||
|
||||
if (this.#onEndEvent) {
|
||||
this.#onEndEvent(receivedPackets);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
#emitReceiverPacketTotal(channel, total) {
|
||||
this.#eventEmitter.emit('packets', {
|
||||
type: 'receiver',
|
||||
channel,
|
||||
packets: total,
|
||||
});
|
||||
}
|
||||
|
||||
startReceiverLivePoll(channel, bitpattern, length) {
|
||||
this.stopReceiverLivePoll();
|
||||
this.#receiverLiveTotal = 0;
|
||||
this.#receiverPollParams = { channel, bitpattern, length };
|
||||
this.#receiverPollTimer = setInterval(() => {
|
||||
this.#pollReceiverLiveCount().catch(() => undefined);
|
||||
}, RECEIVER_LIVE_POLL_MS);
|
||||
}
|
||||
|
||||
stopReceiverLivePoll() {
|
||||
if (this.#receiverPollTimer) {
|
||||
clearInterval(this.#receiverPollTimer);
|
||||
this.#receiverPollTimer = null;
|
||||
}
|
||||
this.#receiverPollParams = null;
|
||||
}
|
||||
|
||||
async #pollReceiverLiveCount() {
|
||||
if (
|
||||
this.#receiverPollInFlight ||
|
||||
!this.#isReceiving ||
|
||||
this.#timedOut ||
|
||||
!this.#receiverPollParams
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { channel, bitpattern, length } = this.#receiverPollParams;
|
||||
this.#receiverPollInFlight = true;
|
||||
|
||||
try {
|
||||
const endResponse = await this.#dtmTransport.sendCMD(DTMTransport.createEndCMD());
|
||||
const hopCount = packetCountFromResponse(endResponse);
|
||||
this.#receiverLiveTotal += hopCount;
|
||||
this.#emitReceiverPacketTotal(channel, this.#receiverLiveTotal);
|
||||
|
||||
if (!this.#isReceiving || this.#timedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rxResponse = await this.#dtmTransport.sendCMD(
|
||||
DTMTransport.createReceiverCMD(channelToFrequency(channel), length, bitpattern)
|
||||
);
|
||||
|
||||
if (!reportSuccess(rxResponse)) {
|
||||
this.#isReceiving = false;
|
||||
}
|
||||
} finally {
|
||||
this.#receiverPollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
static carrierTestCMD(frequency, length, bitpattern) {
|
||||
let lengthParam = length & 0x3f;
|
||||
if (bitpattern === 0x03) {
|
||||
lengthParam = 0;
|
||||
}
|
||||
return DTMTransport.createTransmitterCMD(frequency, lengthParam, bitpattern);
|
||||
}
|
||||
|
||||
async setTxPower(dbm = this.#dbmPayload) {
|
||||
this.#dbmPayload = dbm;
|
||||
const cmd = DTMTransport.createTxPowerCMD(dbm);
|
||||
return validate0x09Command(await this.#dtmTransport.sendCMD(cmd));
|
||||
}
|
||||
|
||||
async setupReset() {
|
||||
const cmd = DTMTransport.createSetupCMD(
|
||||
DTM_CONTROL.RESET,
|
||||
DTM_PARAMETER.DEFAULT,
|
||||
DTM_DC.DEFAULT
|
||||
);
|
||||
return validateResult(await this.#dtmTransport.sendCMD(cmd, RESET_COMMAND_TIMEOUT_MS));
|
||||
}
|
||||
|
||||
async setupLength(length = this.#lengthPayload) {
|
||||
this.#lengthPayload = length;
|
||||
const lengthBits = length >> 6;
|
||||
const cmd = DTMTransport.createSetupCMD(
|
||||
DTM_CONTROL.ENABLE_LENGTH,
|
||||
lengthBits,
|
||||
DTM_DC.DEFAULT
|
||||
);
|
||||
return validateResult(await this.#dtmTransport.sendCMD(cmd, DEFAULT_COMMAND_TIMEOUT_MS));
|
||||
}
|
||||
|
||||
async setupPhy(phy = this.#phyPayload) {
|
||||
this.#phyPayload = phy;
|
||||
const cmd = DTMTransport.createSetupCMD(DTM_CONTROL.PHY, phy, DTM_DC.DEFAULT);
|
||||
return validateResult(await this.#dtmTransport.sendCMD(cmd));
|
||||
}
|
||||
|
||||
async setupModulation(modulation = this.#modulationPayload) {
|
||||
this.#modulationPayload = modulation;
|
||||
const cmd = DTMTransport.createSetupCMD(
|
||||
DTM_CONTROL.MODULATION,
|
||||
modulation,
|
||||
DTM_DC.DEFAULT
|
||||
);
|
||||
return validateResult(await this.#dtmTransport.sendCMD(cmd));
|
||||
}
|
||||
|
||||
async singleChannelTransmitterTest(bitpattern, length, channel, timeout = 0) {
|
||||
this.#resetEvent();
|
||||
this.#isTransmitting = true;
|
||||
this.#timeoutEvent = this.startTimeoutEvent(() => this.#isTransmitting, timeout);
|
||||
this.#sweepTimedOut = false;
|
||||
this.#timedOut = false;
|
||||
|
||||
const response = await this.#dtmTransport.sendCMD(
|
||||
DTM.carrierTestCMD(channelToFrequency(channel), length, bitpattern)
|
||||
);
|
||||
|
||||
if (!reportSuccess(response)) {
|
||||
this.#isTransmitting = false;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'error', message: 'Could not start transmission.' };
|
||||
}
|
||||
|
||||
const endEvent = this.#startedTransmitterEvent(channel);
|
||||
await this.endEventDataReceived();
|
||||
this.#isTransmitting = false;
|
||||
endEvent();
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'transmitter' };
|
||||
}
|
||||
|
||||
async sweepTransmitterTest(
|
||||
bitpattern,
|
||||
length,
|
||||
channelLow,
|
||||
channelHigh,
|
||||
sweepTime = 1000,
|
||||
timeout = 0
|
||||
) {
|
||||
this.#resetEvent();
|
||||
this.#isTransmitting = true;
|
||||
this.#timeoutEvent = this.startTimeoutEvent(() => this.#isTransmitting, timeout);
|
||||
let currentChannelIdx = 0;
|
||||
|
||||
do {
|
||||
const channel = channelLow + currentChannelIdx;
|
||||
this.#sweepTimedOut = false;
|
||||
this.#isTransmitting = false;
|
||||
|
||||
if (this.#timedOut) continue;
|
||||
|
||||
const endEventDataReceivedEvt = this.endEventDataReceived();
|
||||
const sendCMDPromise = this.#dtmTransport
|
||||
.sendCMD(DTM.carrierTestCMD(channelToFrequency(channel), length, bitpattern))
|
||||
.catch(() => undefined);
|
||||
|
||||
if (this.#timedOut) continue;
|
||||
|
||||
this.#isTransmitting = true;
|
||||
const response = await sendCMDPromise;
|
||||
|
||||
if (!reportSuccess(response)) {
|
||||
this.#isTransmitting = false;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'error', message: 'Could not start transmission.' };
|
||||
}
|
||||
|
||||
const endEvent = this.#startedTransmitterEvent(channel);
|
||||
const sweepTimeoutEvent = this.startSweepTimeoutEvent(
|
||||
() => this.#isTransmitting,
|
||||
sweepTime
|
||||
);
|
||||
|
||||
if (this.#timedOut) {
|
||||
this.endCurrentTest().catch(() => undefined);
|
||||
}
|
||||
|
||||
await endEventDataReceivedEvt;
|
||||
clearTimeout(sweepTimeoutEvent);
|
||||
endEvent();
|
||||
|
||||
currentChannelIdx = (currentChannelIdx + 1) % (channelHigh - channelLow + 1);
|
||||
} while (this.#isTransmitting && !this.#timedOut);
|
||||
|
||||
this.#isTransmitting = false;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'transmitter' };
|
||||
}
|
||||
|
||||
async singleChannelReceiverTest(bitpattern, length, channel, timeout = 0) {
|
||||
this.#resetEvent();
|
||||
this.#isReceiving = true;
|
||||
this.#timeoutEvent = this.startTimeoutEvent(() => this.#isReceiving, timeout);
|
||||
this.#timedOut = false;
|
||||
this.#sweepTimedOut = false;
|
||||
|
||||
const endEventDataReceivedEvt = this.endEventDataReceived();
|
||||
const response = await this.#dtmTransport
|
||||
.sendCMD(DTMTransport.createReceiverCMD(channelToFrequency(channel), length, bitpattern))
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!reportSuccess(response)) {
|
||||
this.#isReceiving = false;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'error', message: 'Could not start receiver.' };
|
||||
}
|
||||
|
||||
const endEvent = this.#startedReceiverEvent(channel, false);
|
||||
this.startReceiverLivePoll(channel, bitpattern, length);
|
||||
|
||||
let status;
|
||||
try {
|
||||
status = await endEventDataReceivedEvt;
|
||||
} finally {
|
||||
this.stopReceiverLivePoll();
|
||||
}
|
||||
|
||||
this.#isReceiving = false;
|
||||
this.#activeReceiverChannel = null;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
|
||||
const totalReceived = this.#receiverLiveTotal + status.received;
|
||||
this.#receiverLiveTotal = 0;
|
||||
endEvent(totalReceived);
|
||||
|
||||
const receivedPerChannel = new Array(40).fill(0);
|
||||
receivedPerChannel[channel] = totalReceived;
|
||||
return { type: 'receiver', receivedPerChannel };
|
||||
}
|
||||
|
||||
async sweepReceiverTest(
|
||||
bitpattern,
|
||||
length,
|
||||
channelLow,
|
||||
channelHigh,
|
||||
sweepTime = 1000,
|
||||
timeout = 0
|
||||
) {
|
||||
this.#resetEvent();
|
||||
this.#isReceiving = true;
|
||||
const packetsReceivedForChannel = new Array(40).fill(0);
|
||||
this.#timeoutEvent = this.startTimeoutEvent(() => this.#isReceiving, timeout);
|
||||
let currentChannelIdx = 0;
|
||||
|
||||
do {
|
||||
const channel = channelLow + currentChannelIdx;
|
||||
this.#sweepTimedOut = false;
|
||||
this.#isReceiving = false;
|
||||
if (this.#timedOut) continue;
|
||||
|
||||
const endEventDataReceivedEvt = this.endEventDataReceived();
|
||||
const responseEvent = this.#dtmTransport
|
||||
.sendCMD(DTMTransport.createReceiverCMD(channelToFrequency(channel), length, bitpattern))
|
||||
.catch(() => undefined);
|
||||
|
||||
if (this.#timedOut) continue;
|
||||
|
||||
this.#isReceiving = true;
|
||||
const response = await responseEvent;
|
||||
|
||||
if (!reportSuccess(response)) {
|
||||
this.#isReceiving = false;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'error', message: 'Could not start receiver.' };
|
||||
}
|
||||
|
||||
const endEvent = this.#startedReceiverEvent(channel, true);
|
||||
const sweepTimeoutEvent = this.startSweepTimeoutEvent(
|
||||
() => this.#isReceiving,
|
||||
sweepTime
|
||||
);
|
||||
|
||||
const status = await endEventDataReceivedEvt;
|
||||
clearTimeout(sweepTimeoutEvent);
|
||||
|
||||
packetsReceivedForChannel[channel] += status.received;
|
||||
endEvent(status.received);
|
||||
|
||||
currentChannelIdx = (currentChannelIdx + 1) % (channelHigh - channelLow + 1);
|
||||
} while (this.#isReceiving && !this.#timedOut);
|
||||
|
||||
this.#isReceiving = false;
|
||||
this.#activeReceiverChannel = null;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
return { type: 'receiver', receivedPerChannel: packetsReceivedForChannel };
|
||||
}
|
||||
|
||||
async endTest() {
|
||||
if (this.#timedOut) return;
|
||||
this.#timedOut = true;
|
||||
clearTimeout(this.#timeoutEvent);
|
||||
this.stopReceiverLivePoll();
|
||||
|
||||
if (!this.#sweepTimedOut && (this.#isTransmitting || this.#isReceiving)) {
|
||||
await this.endCurrentTest();
|
||||
}
|
||||
|
||||
this.#isTransmitting = false;
|
||||
this.#isReceiving = false;
|
||||
this.#activeReceiverChannel = null;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.#eventEmitter.removeAllListeners();
|
||||
await this.endTest().catch(() => {});
|
||||
await this.#dtmTransport.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DTM };
|
||||
@@ -0,0 +1,389 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const { JlinkRttSession, RTT_CHANNEL_DTM } = require('./jlink/jlinkRttSession.cjs');
|
||||
|
||||
const DtmPacketType = {
|
||||
PRBS9: 0x00,
|
||||
_11110000: 0x01,
|
||||
_10101010: 0x02,
|
||||
'Constant carrier': 0x03,
|
||||
};
|
||||
|
||||
const DTM_CMD = {
|
||||
TEST_SETUP: '00',
|
||||
RECEIVER_TEST: '01',
|
||||
TRANSMITTER_TEST: '10',
|
||||
TEST_END: '11',
|
||||
};
|
||||
|
||||
const DTM_CONTROL = {
|
||||
RESET: 0x00,
|
||||
ENABLE_LENGTH: 0x01,
|
||||
PHY: 0x02,
|
||||
MODULATION: 0x03,
|
||||
FEATURES: 0x04,
|
||||
TXRX: 0x05,
|
||||
END: 0x00,
|
||||
};
|
||||
|
||||
const DTM_PARAMETER = {
|
||||
DEFAULT: 0x00,
|
||||
};
|
||||
|
||||
const DTM_DC = {
|
||||
DEFAULT: '00',
|
||||
};
|
||||
|
||||
const DTM_EVENT = {
|
||||
LE_TEST_STATUS_EVENT: 0,
|
||||
LE_PACKET_REPORT_EVENT: 1,
|
||||
};
|
||||
|
||||
const DTM_FREQUENCY = frequency =>
|
||||
((frequency - 2402) / 2).toString(2).padStart(6, '0');
|
||||
|
||||
const toTwosComplementBitString = data => {
|
||||
const absTwosComplementValue = (data < 0 ? 128 : 0) + data;
|
||||
const negativeBit = data < 0 ? 128 : 0;
|
||||
return (negativeBit + absTwosComplementValue).toString(2).padStart(8, '0');
|
||||
};
|
||||
|
||||
const toBitString = (data, length = 6) => data.toString(2).padStart(length, '0');
|
||||
|
||||
const DTM_CMD_FORMAT = cmd => {
|
||||
const firstByte = parseInt(cmd.substring(0, 8), 2);
|
||||
const secondByte = parseInt(cmd.substring(8, 16), 2);
|
||||
return Buffer.from([firstByte, secondByte]);
|
||||
};
|
||||
|
||||
const DEFAULT_DEVICE = 'NRF52840_xxAA';
|
||||
const DEFAULT_SWD_SPEED = 4000;
|
||||
|
||||
const DTM_SETUP_CONTROL = {
|
||||
0: 'reset',
|
||||
1: 'packet length',
|
||||
2: 'PHY',
|
||||
3: 'modulation',
|
||||
4: 'features',
|
||||
5: 'TX/RX select',
|
||||
};
|
||||
|
||||
const DTM_PKT_TYPE = {
|
||||
0: 'PRBS9',
|
||||
1: '11110000',
|
||||
2: '10101010',
|
||||
3: 'constant carrier',
|
||||
};
|
||||
|
||||
const dtmChannelLabel = byte0 => {
|
||||
const channel = byte0 & 0x3f;
|
||||
const mhz = 2402 + 2 * channel;
|
||||
return `ch ${channel} (${mhz} MHz)`;
|
||||
};
|
||||
|
||||
const dtmTxNote = buffer => {
|
||||
const b0 = buffer[0];
|
||||
const b1 = buffer[1];
|
||||
const cmdType = (b0 >> 6) & 0x03;
|
||||
|
||||
if (cmdType === 0) {
|
||||
const control = b0 & 0x3f;
|
||||
if (control === 9) {
|
||||
return `DTM setup — set TX power (param 0x${b1.toString(16).toUpperCase().padStart(2, '0')})`;
|
||||
}
|
||||
const label = DTM_SETUP_CONTROL[control] ?? `control ${control}`;
|
||||
return `DTM setup — ${label} (param 0x${b1.toString(16).toUpperCase().padStart(2, '0')})`;
|
||||
}
|
||||
|
||||
if (cmdType === 1) {
|
||||
const length = (b1 >> 2) & 0x3f;
|
||||
const pkt = DTM_PKT_TYPE[b1 & 0x03] ?? `type ${b1 & 0x03}`;
|
||||
return `Receiver test — listen on ${dtmChannelLabel(b0)}, len ${length}, ${pkt}`;
|
||||
}
|
||||
|
||||
if (cmdType === 2) {
|
||||
const length = (b1 >> 2) & 0x3f;
|
||||
const pkt = DTM_PKT_TYPE[b1 & 0x03] ?? `type ${b1 & 0x03}`;
|
||||
return `Transmitter test — transmit on ${dtmChannelLabel(b0)}, len ${length}, ${pkt}`;
|
||||
}
|
||||
|
||||
if (cmdType === 3) {
|
||||
return 'Test end — stop current receiver/transmitter test';
|
||||
}
|
||||
|
||||
return 'DTM command';
|
||||
};
|
||||
|
||||
const parsePacketReportCount = buffer => {
|
||||
if (!buffer || buffer.length < 2 || (buffer[0] & 0x80) === 0) {
|
||||
return null;
|
||||
}
|
||||
return ((buffer[0] & 0x3f) << 8) | buffer[1];
|
||||
};
|
||||
|
||||
const dtmRxNote = buffer => {
|
||||
const b0 = buffer[0];
|
||||
const b1 = buffer[1];
|
||||
|
||||
const packetCount = parsePacketReportCount(buffer);
|
||||
if (packetCount !== null) {
|
||||
return `Packet report — ${packetCount} packet(s) received on active RX channel (LE DTM event)`;
|
||||
}
|
||||
|
||||
if ((b1 & 0x01) !== 0) {
|
||||
const rawDbm = (b1 & 0xfe) >> 1;
|
||||
const modifier = b0 & 0x01 ? -128 : 0;
|
||||
return `DTM status — applied TX power ${modifier + rawDbm} dBm (firmware feedback)`;
|
||||
}
|
||||
|
||||
if (b0 === 0 && b1 === 0) {
|
||||
return 'DTM status — OK (command accepted)';
|
||||
}
|
||||
|
||||
if ((b0 & 0x01) !== 0) {
|
||||
return 'DTM status — command failed (status bit set)';
|
||||
}
|
||||
|
||||
return 'DTM status — response from firmware';
|
||||
};
|
||||
|
||||
class DTMTransport {
|
||||
#session;
|
||||
#dataBuffer;
|
||||
#callback;
|
||||
#sendQueue = [];
|
||||
#isProcessing = false;
|
||||
#isOpen = false;
|
||||
#eventEmitter = new EventEmitter();
|
||||
#detachDataListener;
|
||||
|
||||
constructor(jlinkSerial, options = {}) {
|
||||
const {
|
||||
device = DEFAULT_DEVICE,
|
||||
speed = DEFAULT_SWD_SPEED,
|
||||
gdbPort = 29021,
|
||||
swoPort = 29022,
|
||||
rttPort = 19021,
|
||||
rttChannel = RTT_CHANNEL_DTM,
|
||||
} = options;
|
||||
|
||||
this.#session = new JlinkRttSession({
|
||||
serialNumber: jlinkSerial,
|
||||
device,
|
||||
speed,
|
||||
gdbPort,
|
||||
swoPort,
|
||||
rttPort,
|
||||
rttChannel,
|
||||
});
|
||||
}
|
||||
|
||||
addListeners() {
|
||||
this.#detachDataListener = this.#session.onData((data, error) => {
|
||||
if (error) {
|
||||
this.#eventEmitter.emit('traffic', {
|
||||
direction: 'error',
|
||||
data: [],
|
||||
note: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#dataBuffer = this.#dataBuffer
|
||||
? Buffer.concat([this.#dataBuffer, data])
|
||||
: Buffer.from(data);
|
||||
|
||||
while (this.#dataBuffer && this.#dataBuffer.length >= 2) {
|
||||
const response = this.#dataBuffer.subarray(0, 2);
|
||||
this.#dataBuffer =
|
||||
this.#dataBuffer.length > 2 ? this.#dataBuffer.subarray(2) : undefined;
|
||||
|
||||
this.#eventEmitter.emit('traffic', {
|
||||
direction: 'rx',
|
||||
data: Array.from(response),
|
||||
note: dtmRxNote(response),
|
||||
});
|
||||
|
||||
const packetCount = parsePacketReportCount(response);
|
||||
if (packetCount !== null) {
|
||||
this.#eventEmitter.emit('packetReport', packetCount);
|
||||
}
|
||||
|
||||
if (this.#callback) {
|
||||
this.#callback(response);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onTraffic(listener) {
|
||||
this.#eventEmitter.on('traffic', listener);
|
||||
return () => this.#eventEmitter.removeListener('traffic', listener);
|
||||
}
|
||||
|
||||
onPacketReport(listener) {
|
||||
this.#eventEmitter.on('packetReport', listener);
|
||||
return () => this.#eventEmitter.removeListener('packetReport', listener);
|
||||
}
|
||||
|
||||
open() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (!this.#detachDataListener) {
|
||||
this.addListeners();
|
||||
}
|
||||
await this.#session.start();
|
||||
this.#isOpen = true;
|
||||
this.#dataBuffer = undefined;
|
||||
this.#eventEmitter.emit('traffic', {
|
||||
direction: 'info',
|
||||
data: [],
|
||||
note: `RTT connected on localhost:${this.#session.rttPort} (channel ${RTT_CHANNEL_DTM}, target running)`,
|
||||
});
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
await this.#session.close();
|
||||
this.#isOpen = false;
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static #createCMD(cmdType, arg2, arg3, arg4 = '') {
|
||||
return DTM_CMD_FORMAT(cmdType + arg2 + arg3 + arg4);
|
||||
}
|
||||
|
||||
static createSetupCMD(
|
||||
control = DTM_CONTROL.RESET,
|
||||
parameter = DTM_PARAMETER.DEFAULT,
|
||||
dc = DTM_DC.DEFAULT
|
||||
) {
|
||||
return DTMTransport.#createCMD(
|
||||
DTM_CMD.TEST_SETUP,
|
||||
toBitString(control),
|
||||
toBitString(parameter),
|
||||
dc
|
||||
);
|
||||
}
|
||||
|
||||
static createEndCMD() {
|
||||
return DTMTransport.#createCMD(
|
||||
DTM_CMD.TEST_END,
|
||||
toBitString(DTM_CONTROL.END),
|
||||
toBitString(DTM_PARAMETER.DEFAULT),
|
||||
DTM_DC.DEFAULT
|
||||
);
|
||||
}
|
||||
|
||||
static createTransmitterCMD(frequency = 2402, length = 0, pkt = DtmPacketType.PRBS9) {
|
||||
return DTMTransport.#createCMD(
|
||||
DTM_CMD.TRANSMITTER_TEST,
|
||||
DTM_FREQUENCY(frequency),
|
||||
toBitString(length),
|
||||
toBitString(pkt, 2)
|
||||
);
|
||||
}
|
||||
|
||||
static createReceiverCMD(frequency = 2402, length = 0, pkt = DtmPacketType.PRBS9) {
|
||||
return DTMTransport.#createCMD(
|
||||
DTM_CMD.RECEIVER_TEST,
|
||||
DTM_FREQUENCY(frequency),
|
||||
toBitString(length),
|
||||
toBitString(pkt, 2)
|
||||
);
|
||||
}
|
||||
|
||||
static createTxPowerCMD(dbm) {
|
||||
return DTMTransport.#createCMD(
|
||||
DTM_CMD.TEST_SETUP,
|
||||
toBitString(9, 6),
|
||||
toTwosComplementBitString(dbm)
|
||||
);
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
if (this.#isProcessing || this.#sendQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isProcessing = true;
|
||||
const item = this.#sendQueue.shift();
|
||||
if (!item) return;
|
||||
|
||||
const { cmd, resolve, reject, timeoutMs } = item;
|
||||
|
||||
const responseTimeout = setTimeout(() => {
|
||||
this.#callback = undefined;
|
||||
this.#dataBuffer = undefined;
|
||||
this.#isProcessing = false;
|
||||
reject(new Error('Timeout'));
|
||||
this.#processQueue();
|
||||
}, timeoutMs);
|
||||
|
||||
this.#callback = data => {
|
||||
this.#callback = undefined;
|
||||
clearTimeout(responseTimeout);
|
||||
this.#isProcessing = false;
|
||||
resolve(Array.from(data));
|
||||
this.#processQueue();
|
||||
};
|
||||
|
||||
this.#eventEmitter.emit('traffic', {
|
||||
direction: 'tx',
|
||||
data: Array.from(cmd),
|
||||
note: dtmTxNote(cmd),
|
||||
});
|
||||
|
||||
this.#session.write(cmd).catch(error => {
|
||||
clearTimeout(responseTimeout);
|
||||
this.#callback = undefined;
|
||||
this.#isProcessing = false;
|
||||
reject(error);
|
||||
this.#processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async sendCMD(cmd, timeoutMs = 3000) {
|
||||
if (!this.#isOpen) {
|
||||
await this.open();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#sendQueue.push({ cmd, resolve, reject, timeoutMs });
|
||||
this.#processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.#callback = undefined;
|
||||
this.#sendQueue = [];
|
||||
this.#isProcessing = false;
|
||||
if (this.#detachDataListener) {
|
||||
this.#detachDataListener();
|
||||
this.#detachDataListener = undefined;
|
||||
}
|
||||
if (this.#isOpen) {
|
||||
await this.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DTMTransport,
|
||||
DTM_CONTROL,
|
||||
DTM_DC,
|
||||
DTM_PARAMETER,
|
||||
DTM_EVENT,
|
||||
DEFAULT_DEVICE,
|
||||
DEFAULT_SWD_SPEED,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const ChannelMode = {
|
||||
single: 'single',
|
||||
sweep: 'sweep',
|
||||
};
|
||||
|
||||
const DtmPhysicalLayer = {
|
||||
'LE 1Mbps': 0x01,
|
||||
'LE 2Mbps': 0x02,
|
||||
'LE Coded S8': 0x03,
|
||||
'LE Coded S2': 0x04,
|
||||
};
|
||||
|
||||
const DtmModulationMode = {
|
||||
Standard: 0x00,
|
||||
Stable: 0x01,
|
||||
};
|
||||
|
||||
const DtmPacketType = {
|
||||
PRBS9: 0x00,
|
||||
_11110000: 0x01,
|
||||
_10101010: 0x02,
|
||||
'Constant carrier': 0x03,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ChannelMode,
|
||||
DtmPhysicalLayer,
|
||||
DtmModulationMode,
|
||||
DtmPacketType,
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
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,
|
||||
packets: event.packets ?? 0,
|
||||
mode: event.type,
|
||||
data: [...status.lastReceived],
|
||||
});
|
||||
});
|
||||
|
||||
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 => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/** Serialize J-Link GDB Server starts so two probes on USB do not race. */
|
||||
let spawnChain = Promise.resolve();
|
||||
|
||||
const withGdbSpawn = async fn => {
|
||||
const run = spawnChain.then(() => fn());
|
||||
spawnChain = run
|
||||
.then(() => wait(450))
|
||||
.catch(() => wait(450));
|
||||
return run;
|
||||
};
|
||||
|
||||
module.exports = { withGdbSpawn };
|
||||
@@ -0,0 +1,208 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const STATIC_INSTALL_DIRS = [
|
||||
process.env.SEGGER_JLINK_PATH,
|
||||
process.env.JLINK_PATH,
|
||||
'C:\\Program Files\\SEGGER\\JLink',
|
||||
'C:\\Program Files\\SEGGER\\JLink_V942',
|
||||
'C:\\Program Files (x86)\\SEGGER\\JLink',
|
||||
'C:\\Program Files (x86)\\SEGGER\\JLink_V942',
|
||||
path.join(process.env.ProgramFiles || '', 'SEGGER', 'JLink'),
|
||||
path.join(process.env.ProgramFiles || '', 'SEGGER', 'JLink_V942'),
|
||||
path.join(process.env['ProgramFiles(x86)'] || '', 'SEGGER', 'JLink'),
|
||||
path.join(process.env['ProgramFiles(x86)'] || '', 'SEGGER', 'JLink_V942'),
|
||||
'C:\\nrfutil\\bin',
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'SEGGER', 'JLink'),
|
||||
].filter(Boolean);
|
||||
|
||||
const SEGGER_ROOTS = [
|
||||
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'SEGGER'),
|
||||
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'SEGGER'),
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'SEGGER'),
|
||||
];
|
||||
|
||||
/** SEGGER installer default: Program Files\SEGGER\JLink_Vxxx */
|
||||
const discoverSeggerJLinkDirs = () => {
|
||||
const discovered = [];
|
||||
|
||||
for (const root of SEGGER_ROOTS) {
|
||||
if (!root || !fs.existsSync(root)) continue;
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!/^JLink/i.test(entry.name)) continue;
|
||||
discovered.push(path.join(root, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return discovered;
|
||||
};
|
||||
|
||||
const parseJLinkFolderVersion = directory => {
|
||||
const name = path.basename(directory);
|
||||
const match = name.match(/^JLink(?:_V)?(.+)$/i);
|
||||
if (!match) {
|
||||
return { sortKey: 0, label: name };
|
||||
}
|
||||
|
||||
const token = match[1];
|
||||
const numeric = Number.parseInt(token, 10) || 0;
|
||||
const suffix = token.slice(String(numeric).length);
|
||||
|
||||
return {
|
||||
sortKey: numeric * 1000 + (suffix ? suffix.charCodeAt(0) : 0),
|
||||
label: name,
|
||||
};
|
||||
};
|
||||
|
||||
/** Prefer newest SEGGER pack (e.g. JLink_V942 over JLink_V898). */
|
||||
const sortInstallDirsNewestFirst = directories =>
|
||||
[...directories].sort((left, right) => {
|
||||
const leftVersion = parseJLinkFolderVersion(left);
|
||||
const rightVersion = parseJLinkFolderVersion(right);
|
||||
if (rightVersion.sortKey !== leftVersion.sortKey) {
|
||||
return rightVersion.sortKey - leftVersion.sortKey;
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const getInstallDirectories = () => {
|
||||
const envDirs = [process.env.SEGGER_JLINK_PATH, process.env.JLINK_PATH].filter(Boolean);
|
||||
const discovered = sortInstallDirsNewestFirst(discoverSeggerJLinkDirs());
|
||||
const staticDirs = STATIC_INSTALL_DIRS.filter(
|
||||
directory => directory && !envDirs.includes(directory)
|
||||
);
|
||||
|
||||
return [...new Set([...envDirs, ...discovered, ...staticDirs])];
|
||||
};
|
||||
|
||||
const findExecutable = (name, directories = getInstallDirectories()) => {
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(directory, name);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findOnPath = name => {
|
||||
const pathEntries = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
const candidate = path.join(entry, name);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNrfjprogExe = () => findExecutable('nrfjprog.exe') || findOnPath('nrfjprog.exe');
|
||||
|
||||
const getJLinkExe = () => {
|
||||
const direct = findExecutable('JLink.exe') || findOnPath('JLink.exe');
|
||||
if (direct) return direct;
|
||||
|
||||
const nrfjprogExe = getNrfjprogExe();
|
||||
if (nrfjprogExe) {
|
||||
const sibling = path.join(path.dirname(nrfjprogExe), 'JLink.exe');
|
||||
if (fs.existsSync(sibling)) return sibling;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getJLinkGdbServerExe = () => {
|
||||
const candidates = ['JLinkGDBServerCL.exe', 'JLinkGDBServer.exe'];
|
||||
for (const name of candidates) {
|
||||
const direct = findExecutable(name) || findOnPath(name);
|
||||
if (direct) return direct;
|
||||
}
|
||||
|
||||
const nrfjprogExe = getNrfjprogExe();
|
||||
if (nrfjprogExe) {
|
||||
const directory = path.dirname(nrfjprogExe);
|
||||
for (const name of candidates) {
|
||||
const sibling = path.join(directory, name);
|
||||
if (fs.existsSync(sibling)) return sibling;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasSeggerRttStack = () => Boolean(getJLinkGdbServerExe());
|
||||
|
||||
let cachedDiagnostics;
|
||||
|
||||
const clearJLinkDiagnosticsCache = () => {
|
||||
cachedDiagnostics = null;
|
||||
};
|
||||
|
||||
const getJLinkDiagnostics = () => {
|
||||
if (cachedDiagnostics) return cachedDiagnostics;
|
||||
|
||||
let nrfjprogIds = [];
|
||||
const nrfjprogExe = getNrfjprogExe();
|
||||
const jlinkExe = getJLinkExe();
|
||||
const gdbServerExe = getJLinkGdbServerExe();
|
||||
|
||||
if (nrfjprogExe) {
|
||||
try {
|
||||
const output = execFileSync(nrfjprogExe, ['--ids'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
});
|
||||
nrfjprogIds = output
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => /^\d+$/.test(line));
|
||||
} catch {
|
||||
nrfjprogIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
cachedDiagnostics = {
|
||||
nrfjprogExe,
|
||||
jlinkExe,
|
||||
gdbServerExe,
|
||||
gdbServerDir: gdbServerExe ? path.dirname(gdbServerExe) : '',
|
||||
nrfjprogIds,
|
||||
hasRttStack: Boolean(gdbServerExe),
|
||||
installDirectories: getInstallDirectories(),
|
||||
};
|
||||
|
||||
return cachedDiagnostics;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
STATIC_INSTALL_DIRS,
|
||||
getInstallDirectories,
|
||||
discoverSeggerJLinkDirs,
|
||||
findExecutable,
|
||||
findOnPath,
|
||||
getNrfjprogExe,
|
||||
getJLinkExe,
|
||||
getJLinkGdbServerExe,
|
||||
hasSeggerRttStack,
|
||||
getJLinkDiagnostics,
|
||||
clearJLinkDiagnosticsCache,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
const net = require('net');
|
||||
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const probeTcpPort = (host, port, timeoutMs = 500) =>
|
||||
new Promise(resolve => {
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.on('error', () => resolve(false));
|
||||
socket.setTimeout(timeoutMs, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** Something is accepting connections on this port. */
|
||||
const isTcpPortBusy = (host, port) => probeTcpPort(host, port);
|
||||
|
||||
/** True if we can bind — more reliable than connect probe before GDB Server starts. */
|
||||
const canBindTcpPort = (host, port) =>
|
||||
new Promise(resolve => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.listen({ host, port }, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
|
||||
const waitUntilTcpPortFree = async (host, port, timeoutMs = 10000) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if ((await canBindTcpPort(host, port)) && !(await isTcpPortBusy(host, port))) {
|
||||
return;
|
||||
}
|
||||
await wait(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`TCP 포트 ${port}(${host})이 아직 사용 중입니다. JLinkGDBServerCL을 종료한 뒤 몇 초 기다려 주세요.`
|
||||
);
|
||||
};
|
||||
|
||||
const assertTcpPortFree = async (host, port, label = 'RTT') => {
|
||||
if (await canBindTcpPort(host, port)) return;
|
||||
|
||||
throw new Error(
|
||||
`${label} 포트 ${port}(${host})이 이미 사용 중입니다. Receiver/Transmitter를 Stop한 뒤 몇 초 기다리거나, 작업 관리자에서 JLinkGDBServerCL 프로세스를 종료하세요.`
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isTcpPortBusy,
|
||||
canBindTcpPort,
|
||||
waitUntilTcpPortFree,
|
||||
assertTcpPortFree,
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const {
|
||||
getJLinkExe,
|
||||
getNrfjprogExe,
|
||||
getJLinkDiagnostics,
|
||||
clearJLinkDiagnosticsCache,
|
||||
} = require('./jlinkPaths.cjs');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const parseShowEmuList = output => {
|
||||
const probes = [];
|
||||
const patterns = [
|
||||
/Serial number:\s*(\d+).*?(?:ProductName|Product name):\s*([^,\r\n]+).*?(?:Connection):\s*([^,\r\n]+)/i,
|
||||
/Serial number:\s*(\d+).*?(?:ProductName|Product name):\s*([^,\r\n]+)/i,
|
||||
/Serial number:\s*(\d+)/i,
|
||||
];
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (!/serial number/i.test(line)) continue;
|
||||
|
||||
let serialNumber;
|
||||
let productName = 'J-Link';
|
||||
let connection = 'USB';
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (!match) continue;
|
||||
serialNumber = match[1];
|
||||
if (match[2]) productName = match[2].trim();
|
||||
if (match[3]) connection = match[3].trim();
|
||||
break;
|
||||
}
|
||||
|
||||
if (!serialNumber) continue;
|
||||
|
||||
probes.push({
|
||||
path: serialNumber,
|
||||
serialNumber,
|
||||
friendlyName: productName,
|
||||
manufacturer: 'SEGGER',
|
||||
connection,
|
||||
source: 'jlink',
|
||||
});
|
||||
}
|
||||
|
||||
return probes;
|
||||
};
|
||||
|
||||
const parseNrfjprogIds = output =>
|
||||
output
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => /^\d+$/.test(line))
|
||||
.map(serialNumber => ({
|
||||
path: serialNumber,
|
||||
serialNumber,
|
||||
friendlyName: `J-Link (${serialNumber})`,
|
||||
manufacturer: 'SEGGER',
|
||||
connection: 'USB',
|
||||
source: 'nrfjprog',
|
||||
}));
|
||||
|
||||
const listViaJLinkExe = async jlinkExe => {
|
||||
const scriptPath = path.join(os.tmpdir(), `dtm-jlink-emu-${process.pid}.jlink`);
|
||||
await fs.promises.writeFile(scriptPath, 'ShowEmuList\nexit\n', 'utf8');
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
jlinkExe,
|
||||
['-CommandFile', scriptPath, '-nogui', '1'],
|
||||
{
|
||||
timeout: 15000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
);
|
||||
|
||||
return parseShowEmuList(`${stdout}\n${stderr}`);
|
||||
} finally {
|
||||
await fs.promises.unlink(scriptPath).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const listViaNrfjprog = async nrfjprogExe => {
|
||||
const { stdout, stderr } = await execFileAsync(nrfjprogExe, ['--ids'], {
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
return parseNrfjprogIds(`${stdout}\n${stderr}`);
|
||||
};
|
||||
|
||||
const listJLinkProbes = async () => {
|
||||
clearJLinkDiagnosticsCache();
|
||||
const jlinkExe = getJLinkExe();
|
||||
const nrfjprogExe = getNrfjprogExe();
|
||||
const errors = [];
|
||||
|
||||
if (jlinkExe) {
|
||||
try {
|
||||
const probes = await listViaJLinkExe(jlinkExe);
|
||||
if (probes.length > 0) {
|
||||
return probes;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (nrfjprogExe) {
|
||||
try {
|
||||
const probes = await listViaNrfjprog(nrfjprogExe);
|
||||
if (probes.length > 0) {
|
||||
return probes;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const diagnostics = getJLinkDiagnostics();
|
||||
|
||||
if (!jlinkExe && !nrfjprogExe) {
|
||||
throw new Error(
|
||||
'nrfjprog and JLink.exe were not found. Install nRF Command Line Tools or SEGGER J-Link software, or set SEGGER_JLINK_PATH.'
|
||||
);
|
||||
}
|
||||
|
||||
if (diagnostics.nrfjprogIds.length > 0) {
|
||||
return parseNrfjprogIds(diagnostics.nrfjprogIds.join('\n'));
|
||||
}
|
||||
|
||||
const detail = errors.length > 0 ? ` (${errors.join('; ')})` : '';
|
||||
|
||||
throw new Error(
|
||||
`No J-Link responded on USB. Plug in the probe, close Ozone or RTT Viewer, then refresh.${detail}`
|
||||
);
|
||||
};
|
||||
|
||||
const getJLinkSetupHint = () => {
|
||||
const diagnostics = getJLinkDiagnostics();
|
||||
|
||||
if (!diagnostics.gdbServerExe) {
|
||||
const ids =
|
||||
diagnostics.nrfjprogIds.length > 0
|
||||
? ` Detected probe SN: ${diagnostics.nrfjprogIds.join(', ')}.`
|
||||
: '';
|
||||
const searched =
|
||||
diagnostics.installDirectories?.length > 0
|
||||
? ` Searched: ${diagnostics.installDirectories.slice(0, 4).join('; ')}…`
|
||||
: '';
|
||||
|
||||
return (
|
||||
'RTT needs JLinkGDBServerCL.exe (e.g. C:\\Program Files\\SEGGER\\JLink_V942). Install or set SEGGER_JLINK_PATH.' +
|
||||
ids +
|
||||
searched +
|
||||
' nrfjprog alone is not enough for RTT.'
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listJLinkProbes,
|
||||
parseShowEmuList,
|
||||
parseNrfjprogIds,
|
||||
getJLinkSetupHint,
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
const net = require('net');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const { getJLinkGdbServerExe } = require('./jlinkPaths.cjs');
|
||||
const { withGdbSpawn } = require('./jlinkGdbSpawnLock.cjs');
|
||||
const { waitUntilTcpPortFree } = require('./jlinkPortCheck.cjs');
|
||||
|
||||
const RTT_CONFIG_PREFIX = '$$SEGGER_TELNET_ConfigStr=';
|
||||
const RTT_CONFIG_SUFFIX = '$$';
|
||||
|
||||
const buildRttConfigString = channel =>
|
||||
`${RTT_CONFIG_PREFIX}RTTCh;${channel}${RTT_CONFIG_SUFFIX}`;
|
||||
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const waitForGdbTargetReady = async (readLog, timeoutMs = 12000) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const log = readLog();
|
||||
if (/connected to target|cpu is running|resetting target|listening on tcp/i.test(log)) {
|
||||
await wait(250);
|
||||
return;
|
||||
}
|
||||
await wait(100);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForTcpPort = async (host, port, timeoutMs = 25000, context = {}) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await wait(200);
|
||||
}
|
||||
}
|
||||
|
||||
const bootTail = (context.getBootLog?.() || '').trim().slice(-700);
|
||||
const serial = context.serialNumber ? `J-Link SN ${context.serialNumber}, ` : '';
|
||||
const gdbPortHint = /Failed to open listener port/i.test(bootTail)
|
||||
? 'A GDB/SWO port is still in use — Stop both sessions, end all JLinkGDBServerCL in Task Manager, wait a few seconds, then Start again. '
|
||||
: '';
|
||||
throw new Error(
|
||||
`Timed out waiting for RTT server on ${host}:${port} (${serial}port ${port}). ` +
|
||||
(bootTail ? `GDB log: ${bootTail} ` : '') +
|
||||
gdbPortHint +
|
||||
'Check SWD, DTM RTT firmware, and that no stale JLinkGDBServerCL holds this port. ' +
|
||||
'With two probes, start the other session only after the first shows Running.'
|
||||
);
|
||||
};
|
||||
|
||||
class JlinkRttSession {
|
||||
#serialNumber;
|
||||
#device;
|
||||
#speed;
|
||||
#gdbPort;
|
||||
#swoPort;
|
||||
#rttPort;
|
||||
#rttChannel;
|
||||
#gdbServer;
|
||||
#socket;
|
||||
#dataListeners = new Set();
|
||||
#started = false;
|
||||
#bootLog = '';
|
||||
|
||||
constructor({ serialNumber, device, speed, gdbPort, swoPort, rttPort, rttChannel = 0 }) {
|
||||
this.#serialNumber = serialNumber;
|
||||
this.#device = device;
|
||||
this.#speed = speed;
|
||||
this.#gdbPort = gdbPort;
|
||||
this.#swoPort = swoPort;
|
||||
this.#rttPort = rttPort;
|
||||
this.#rttChannel = rttChannel;
|
||||
}
|
||||
|
||||
get gdbPort() {
|
||||
return this.#gdbPort;
|
||||
}
|
||||
|
||||
get rttPort() {
|
||||
return this.#rttPort;
|
||||
}
|
||||
|
||||
get bootLog() {
|
||||
return this.#bootLog;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#started) return;
|
||||
|
||||
return withGdbSpawn(async () => {
|
||||
if (this.#started) return;
|
||||
|
||||
await this.#startGdbServer();
|
||||
});
|
||||
}
|
||||
|
||||
async #startGdbServer() {
|
||||
const gdbServerExe = getJLinkGdbServerExe();
|
||||
if (!gdbServerExe) {
|
||||
throw new Error(
|
||||
'J-Link GDB Server was not found. Install SEGGER J-Link software or set SEGGER_JLINK_PATH.'
|
||||
);
|
||||
}
|
||||
|
||||
await waitUntilTcpPortFree('127.0.0.1', this.#gdbPort, 12000);
|
||||
await waitUntilTcpPortFree('127.0.0.1', this.#swoPort, 12000);
|
||||
await waitUntilTcpPortFree('127.0.0.1', this.#rttPort, 12000);
|
||||
|
||||
const args = [
|
||||
'-select',
|
||||
`USB=${this.#serialNumber}`,
|
||||
'-device',
|
||||
this.#device,
|
||||
'-if',
|
||||
'SWD',
|
||||
'-speed',
|
||||
String(this.#speed),
|
||||
'-port',
|
||||
String(this.#gdbPort),
|
||||
'-swoport',
|
||||
String(this.#swoPort),
|
||||
'-RTTTelnetPort',
|
||||
String(this.#rttPort),
|
||||
'-nogui',
|
||||
'1',
|
||||
// DTM RTT firmware must keep running; default GDB Server halts on connect.
|
||||
'-nohalt',
|
||||
'-noir',
|
||||
];
|
||||
|
||||
try {
|
||||
this.#bootLog = '';
|
||||
this.#gdbServer = spawn(gdbServerExe, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const appendLog = chunk => {
|
||||
this.#bootLog += chunk.toString();
|
||||
if (this.#bootLog.length > 16000) {
|
||||
this.#bootLog = this.#bootLog.slice(-16000);
|
||||
}
|
||||
};
|
||||
|
||||
this.#gdbServer.stdout.on('data', appendLog);
|
||||
this.#gdbServer.stderr.on('data', appendLog);
|
||||
|
||||
this.#gdbServer.on('exit', code => {
|
||||
if (!this.#started) return;
|
||||
const detail = this.#bootLog.trim() ? `: ${this.#bootLog.trim().slice(-400)}` : '';
|
||||
for (const listener of this.#dataListeners) {
|
||||
listener(
|
||||
Buffer.from([]),
|
||||
new Error(`J-Link GDB Server exited (${code ?? 'unknown'})${detail}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForTcpPort('127.0.0.1', this.#rttPort, 25000, {
|
||||
getBootLog: () => this.#bootLog,
|
||||
serialNumber: this.#serialNumber,
|
||||
});
|
||||
await waitForGdbTargetReady(() => this.#bootLog);
|
||||
await this.#openRttSocket();
|
||||
// Allow DTM main (SEGGER_RTT_Init + dtm_init) to finish after target runs.
|
||||
await wait(600);
|
||||
this.#started = true;
|
||||
} catch (error) {
|
||||
await this.close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #openRttSocket() {
|
||||
this.#socket = await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(
|
||||
{ host: '127.0.0.1', port: this.#rttPort },
|
||||
() => resolve(socket)
|
||||
);
|
||||
socket.on('error', reject);
|
||||
});
|
||||
|
||||
this.#socket.setNoDelay(true);
|
||||
this.#socket.on('data', chunk => {
|
||||
for (const listener of this.#dataListeners) {
|
||||
listener(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
this.#socket.on('error', error => {
|
||||
for (const listener of this.#dataListeners) {
|
||||
listener(Buffer.alloc(0), error);
|
||||
}
|
||||
});
|
||||
|
||||
const configString = buildRttConfigString(this.#rttChannel);
|
||||
await new Promise((resolve, reject) => {
|
||||
this.#socket.write(configString, error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await wait(50);
|
||||
}
|
||||
|
||||
onData(listener) {
|
||||
this.#dataListeners.add(listener);
|
||||
return () => this.#dataListeners.delete(listener);
|
||||
}
|
||||
|
||||
async write(buffer) {
|
||||
if (!this.#socket) {
|
||||
throw new Error('RTT socket is not connected.');
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this.#socket.write(buffer, error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#started = false;
|
||||
this.#dataListeners.clear();
|
||||
|
||||
if (this.#socket) {
|
||||
await new Promise(resolve => {
|
||||
this.#socket.end(() => resolve());
|
||||
this.#socket.destroy();
|
||||
});
|
||||
this.#socket = null;
|
||||
}
|
||||
|
||||
const gdbPort = this.#gdbPort;
|
||||
const swoPort = this.#swoPort;
|
||||
const rttPort = this.#rttPort;
|
||||
|
||||
if (this.#gdbServer && !this.#gdbServer.killed) {
|
||||
const child = this.#gdbServer;
|
||||
child.kill();
|
||||
await new Promise(resolve => {
|
||||
child.once('exit', () => resolve());
|
||||
setTimeout(resolve, 2500);
|
||||
});
|
||||
this.#gdbServer = null;
|
||||
}
|
||||
|
||||
for (const port of [gdbPort, swoPort, rttPort]) {
|
||||
await waitUntilTcpPortFree('127.0.0.1', port, 8000).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
JlinkRttSession,
|
||||
buildRttConfigString,
|
||||
RTT_CHANNEL_DTM: 0,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
const { canBindTcpPort } = require('./jlinkPortCheck.cjs');
|
||||
|
||||
/** Preferred ports per session (dynamic fallback if busy). */
|
||||
const JLINK_PORTS_BY_SESSION = {
|
||||
receiver: {
|
||||
gdb: 29021,
|
||||
swo: 29022,
|
||||
rtt: 19021,
|
||||
},
|
||||
transmitter: {
|
||||
gdb: 29031,
|
||||
swo: 29032,
|
||||
rtt: 19022,
|
||||
},
|
||||
};
|
||||
|
||||
const GDB_PORT_RANGE = { min: 29000, max: 29199 };
|
||||
const SWO_PORT_RANGE = { min: 29000, max: 29199 };
|
||||
const RTT_PORT_RANGE = { min: 19020, max: 19199 };
|
||||
|
||||
const portCandidates = (preferred, min, max) => {
|
||||
const ordered = [preferred];
|
||||
for (let port = min; port <= max; port++) {
|
||||
if (port !== preferred) ordered.push(port);
|
||||
}
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const pickTcpPort = async (preferred, range, reserved) => {
|
||||
for (const port of portCandidates(preferred, range.min, range.max)) {
|
||||
if (reserved.has(port)) continue;
|
||||
if (await canBindTcpPort('127.0.0.1', port)) {
|
||||
reserved.add(port);
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`사용 가능한 TCP 포트가 없습니다 (선호 ${preferred}, 범위 ${range.min}-${range.max}). JLinkGDBServerCL을 모두 종료한 뒤 다시 시도하세요.`
|
||||
);
|
||||
};
|
||||
|
||||
const allocateSessionPorts = async (sessionId, reserved = new Set()) => {
|
||||
const preferred = JLINK_PORTS_BY_SESSION[sessionId];
|
||||
if (!preferred) {
|
||||
throw new Error(`Unknown session for port allocation: ${sessionId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
gdb: await pickTcpPort(preferred.gdb, GDB_PORT_RANGE, reserved),
|
||||
swo: await pickTcpPort(preferred.swo, SWO_PORT_RANGE, reserved),
|
||||
rtt: await pickTcpPort(preferred.rtt, RTT_PORT_RANGE, reserved),
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
JLINK_PORTS_BY_SESSION,
|
||||
allocateSessionPorts,
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>DTM RTT Tester</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7221
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "dtm-rtt-tester",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "DTM RTT Tester — Electron DTM tester over SEGGER J-Link RTT",
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"author": "DTM RTT Tester",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 node electron/launch.cjs .\"",
|
||||
"build:ui": "vite build",
|
||||
"build": "npm run build:ui",
|
||||
"build:win": "npm run build:ui && electron-builder --win --x64",
|
||||
"build:mac": "npm run build:ui && electron-builder --mac --arm64",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs ."
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.3",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^31.1.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.4",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.dtm.rtt-tester",
|
||||
"productName": "DTM RTT Tester",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"asar": true,
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+1135
File diff suppressed because it is too large
Load Diff
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dtmApi: {
|
||||
listJLinks: () => Promise<
|
||||
{
|
||||
path: string;
|
||||
serialNumber: string;
|
||||
friendlyName: string;
|
||||
manufacturer: string;
|
||||
connection: string;
|
||||
}[]
|
||||
>;
|
||||
listSerialPorts: () => Promise<
|
||||
{
|
||||
path: string;
|
||||
serialNumber: string;
|
||||
friendlyName: string;
|
||||
manufacturer: string;
|
||||
connection: string;
|
||||
}[]
|
||||
>;
|
||||
startTest: (sessionId: 'receiver' | 'transmitter', config: Record<string, unknown>) => Promise<{ ok: boolean }>;
|
||||
stopTest: (sessionId: 'receiver' | 'transmitter') => Promise<{ ok: boolean }>;
|
||||
syncSessionConfig: (sessionId: 'receiver' | 'transmitter', config: Record<string, unknown>) => Promise<{ ok: boolean }>;
|
||||
canStartSession: (
|
||||
sessionId: 'receiver' | 'transmitter',
|
||||
config: Record<string, unknown>
|
||||
) => Promise<{ ok: boolean; reason?: string }>;
|
||||
getStatus: (sessionId: 'receiver' | 'transmitter') => Promise<{
|
||||
running: boolean;
|
||||
starting: boolean;
|
||||
mode: 'transmitter' | 'receiver';
|
||||
currentChannel: number | null;
|
||||
lastReceived: number[];
|
||||
txPower: number;
|
||||
message: string;
|
||||
jlinkPorts: { gdb: number; swo: number; rtt: number };
|
||||
}>;
|
||||
onEvent: (callback: (event: Record<string, unknown> & { sessionId?: 'receiver' | 'transmitter' }) => void) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
+1214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user