diff --git a/electron/services/DTM_transport.cjs b/electron/services/DTM_transport.cjs index ee99978..e14fbc7 100644 --- a/electron/services/DTM_transport.cjs +++ b/electron/services/DTM_transport.cjs @@ -68,19 +68,32 @@ const DTM_SETUP_CONTROL = { 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 DTM_PAYLOAD_LABEL = { + 0: 'PRBS9', + 1: '11110000', + 2: '10101010', + 3: 'Constant carrier', +}; + +const dtmTransmitterTestNote = (b0, b1) => { + const channel = b0 & 0x3f; + const mhz = 2402 + 2 * channel; + const lengthField = (b1 >> 2) & 0x3f; + const pattern = DTM_PAYLOAD_LABEL[b1 & 0x03] ?? `type ${b1 & 0x03}`; + return `Transmitter test - transmit on ch ${channel}(${mhz}MHz), len ${lengthField}, ${pattern}`; +}; + +const dtmPacketReportNote = count => { + const noun = count === 1 ? 'packet' : 'packets'; + return `Receiver report - ${count} ${noun} received on active RX channel (LE DTM event)`; +}; + const dtmTxNote = buffer => { const b0 = buffer[0]; const b1 = buffer[1]; @@ -96,15 +109,11 @@ const dtmTxNote = buffer => { } 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}`; + return `Receiver test - listen on ${dtmChannelLabel(b0)}`; } 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}`; + return dtmTransmitterTestNote(b0, b1); } if (cmdType === 3) { @@ -125,11 +134,6 @@ 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; @@ -198,15 +202,21 @@ class DTMTransport { 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); + this.#eventEmitter.emit('traffic', { + direction: 'rx', + data: Array.from(response), + note: dtmPacketReportNote(packetCount), + kind: 'packetReport', + }); + } else { + this.#eventEmitter.emit('traffic', { + direction: 'rx', + data: Array.from(response), + note: dtmRxNote(response), + }); } if (this.#callback) { diff --git a/electron/services/dtmController.cjs b/electron/services/dtmController.cjs index 445c124..3715d32 100644 --- a/electron/services/dtmController.cjs +++ b/electron/services/dtmController.cjs @@ -189,9 +189,9 @@ const attachEvents = (sessionId, instance) => { emitForSession(sessionId, { type: 'ended', channel: event.channel, - packets: event.packets ?? 0, mode: event.type, data: [...status.lastReceived], + ...(typeof event.packets === 'number' ? { packets: event.packets } : {}), }); }); @@ -210,6 +210,9 @@ const attachEvents = (sessionId, instance) => { }); instance.onTraffic(event => { + if (sessionId === 'transmitter' && event.kind === 'packetReport') { + return; + } emitForSession(sessionId, { type: 'traffic', direction: event.direction, diff --git a/src/App.tsx b/src/App.tsx index 91f3eef..a5d4eca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import packageJson from '../package.json'; @@ -72,14 +72,22 @@ type TrafficEntry = { const parseTrafficBytes = (hex: string) => hex === '--' ? [] : hex.split(' ').map(part => parseInt(part, 16)).filter(n => !Number.isNaN(n)); -/** RF-related DTM traffic: RX/TX test commands and LE Packet Report events (not setup/status). */ +/** RTT direction: host→board (TX) vs board→host (RX), not over-the-air RF. */ +const trafficDirLabel = (direction: Direction) => { + if (direction === 'tx') return 'TX'; + if (direction === 'rx') return 'RX'; + if (direction === 'error') return 'ERR'; + return 'INFO'; +}; + +/** RF-related DTM traffic: test commands and packet-report events (not setup/status). */ const isRfTrafficEntry = (entry: TrafficEntry) => { if (entry.direction === 'info' || entry.direction === 'error') return false; if ( - entry.note.startsWith('Packet report') || - entry.note.startsWith('Receiver test') || - entry.note.startsWith('Transmitter test') + entry.note.startsWith('Receiver test -') || + entry.note.startsWith('Transmitter test -') || + entry.note.startsWith('Receiver report -') ) { return true; } @@ -95,6 +103,108 @@ const isRfTrafficEntry = (entry: TrafficEntry) => { return cmdType === 0b01 || cmdType === 0b10; }; +const SCROLL_BOTTOM_THRESHOLD_PX = 12; + +/** Append-newest-at-bottom lists: auto-scroll only while the user is already at the bottom. */ +function useStickToBottomOnAppend(items: unknown[]) { + const scrollRef = useRef(null); + const stickToBottomRef = useRef(true); + + const onScroll = () => { + const el = scrollRef.current; + if (!el) { + return; + } + stickToBottomRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_BOTTOM_THRESHOLD_PX; + }; + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) { + return; + } + + if (items.length === 0) { + el.scrollTop = 0; + stickToBottomRef.current = true; + return; + } + + if (stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [items]); + + return { scrollRef, onScroll }; +} + +function TrafficLogList({ + sessionId, + trafficLog, + trafficRfOnly, +}: { + sessionId: SessionId; + trafficLog: TrafficEntry[]; + trafficRfOnly: boolean; +}) { + const visibleTraffic = useMemo( + () => (trafficRfOnly ? trafficLog.filter(isRfTrafficEntry) : trafficLog), + [trafficLog, trafficRfOnly] + ); + const { scrollRef, onScroll } = useStickToBottomOnAppend(visibleTraffic); + + return ( +
+ {visibleTraffic.length === 0 ? ( +
+ {trafficLog.length === 0 ? 'No traffic yet.' : 'No RF traffic (setup/status hidden).'} +
+ ) : ( + visibleTraffic.map((entry, index) => ( +
+ {entry.time} + {trafficDirLabel(entry.direction)} + {entry.hex} + {entry.note} +
+ )) + )} +
+ ); +} + +function SessionEventLogList({ + sessionId, + eventLog, +}: { + sessionId: SessionId; + eventLog: LogEntry[]; +}) { + const { scrollRef, onScroll } = useStickToBottomOnAppend(eventLog); + + return ( +
+ {eventLog.length === 0 ? ( +
No events yet.
+ ) : ( + eventLog.map((entry, index) => ( +
+ {entry.time} + {entry.message} +
+ )) + )} +
+ ); +} + type JlinkPorts = { gdb: number; swo: number; @@ -169,8 +279,8 @@ const formatLogTime = (date = new Date()) => { return `${hours}:${minutes}:${seconds}.${millis}`; }; -const prependEvent = (message: string, eventLog: LogEntry[]) => - [{ time: formatLogTime(), message }, ...eventLog].slice(0, PANEL_LIMIT); +const appendEvent = (message: string, eventLog: LogEntry[]) => + [...eventLog, { time: formatLogTime(), message }].slice(-PANEL_LIMIT); const formatSessionMessage = (message: string) => message @@ -288,7 +398,7 @@ function App() { const nextState: SessionState = { ...current, - trafficLog: [entry, ...current.trafficLog].slice(0, TRAFFIC_LIMIT), + trafficLog: [...current.trafficLog, entry].slice(-TRAFFIC_LIMIT), lastTxHex: entry.direction === 'tx' ? entry.hex : current.lastTxHex, lastRxHex: entry.direction === 'rx' ? entry.hex : current.lastRxHex, }; @@ -316,7 +426,7 @@ function App() { received: new Array(40).fill(0), txVisited: new Array(40).fill(false), currentChannel: null, - eventLog: prependEvent('Reset test state', current.eventLog), + eventLog: appendEvent('Reset test state', current.eventLog), }; } @@ -331,7 +441,7 @@ function App() { ...current, currentChannel: channel, txVisited, - eventLog: prependEvent(`Started ${String(event.mode)} on ch ${channel}`, current.eventLog), + eventLog: appendEvent(`Started ${String(event.mode)} on ch ${channel}`, current.eventLog), }; } @@ -345,14 +455,17 @@ function App() { if (nextType === 'ended') { const data = Array.isArray(event.data) ? (event.data as number[]) : current.received; + const channel = String(event.channel); + const endedMessage = + event.mode === 'receiver' && typeof event.packets === 'number' + ? `Ended receiver on ch ${channel} · ${event.packets} packets received` + : `Ended ${String(event.mode)} on ch ${channel}`; + return { ...current, received: data, currentChannel: null, - eventLog: prependEvent( - `Ended ${String(event.mode)} on ch ${String(event.channel)} packets=${String(event.packets ?? 0)}`, - current.eventLog - ), + eventLog: appendEvent(endedMessage, current.eventLog), }; } @@ -368,7 +481,7 @@ function App() { txPowerFeedback: typeof event.txPower === 'number' ? Number(event.txPower) : current.txPowerFeedback, message: 'Test finished', - eventLog: prependEvent('Test finished', current.eventLog), + eventLog: appendEvent('Test finished', current.eventLog), }; } @@ -399,7 +512,7 @@ function App() { starting: false, currentChannel: null, message: String(event.message ?? 'Unknown error'), - eventLog: prependEvent( + eventLog: appendEvent( `Error: ${String(event.message ?? 'Unknown error')}`, current.eventLog ), @@ -498,7 +611,7 @@ function App() { running: false, starting: false, message: canStart.reason || 'Cannot start', - eventLog: prependEvent(canStart.reason || 'Cannot start', current.eventLog), + eventLog: appendEvent(canStart.reason || 'Cannot start', current.eventLog), })); return; } @@ -514,7 +627,7 @@ function App() { trafficLog: [], lastTxHex: '--', lastRxHex: '--', - eventLog: prependEvent('Requested start', current.eventLog), + eventLog: appendEvent('Requested start', current.eventLog), })); try { @@ -537,7 +650,7 @@ function App() { starting: false, currentChannel: null, message: 'Stopped', - eventLog: prependEvent('Requested stop', current.eventLog), + eventLog: appendEvent('Requested stop', current.eventLog), })); }; @@ -638,28 +751,6 @@ function App() {
- {isReceiver && ( -
- -
- -
- Start 시 Transmitter 설정과 자동 맞춤 -
- )} - {!isReceiver && ( <>
@@ -1036,70 +1127,61 @@ function App() {
DTM Traffic - -
-
- {(() => { - const visibleTraffic = session.trafficRfOnly - ? session.trafficLog.filter(isRfTrafficEntry) - : session.trafficLog; - - if (visibleTraffic.length === 0) { - return ( -
- {session.trafficLog.length === 0 - ? 'No traffic yet.' - : 'No RF traffic (setup/status hidden).'} -
- ); - } - - return visibleTraffic.map((entry, index) => ( -
- {entry.time} - {entry.direction.toUpperCase()} - {entry.hex} - {entry.note} -
- )); - })()} +
+ + +
+
-
Session Events
-
- {session.eventLog.length === 0 && ( -
No events yet.
- )} - {session.eventLog.map((entry, index) => ( -
- {entry.time} - {entry.message} -
- ))} +
+ Session Events +
+
diff --git a/src/styles.css b/src/styles.css index afb987b..6301258 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1017,6 +1017,36 @@ input:focus { align-content: start; } +.panel-title--traffic { + flex-wrap: wrap; + gap: 6px 10px; + align-items: flex-start; +} + +.panel-title--traffic .panel-title-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.traffic-legend { + font-size: 9px; + font-weight: 500; + color: var(--text-dim); + line-height: 1.35; + letter-spacing: 0; + text-transform: none; +} + +.panel-title-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .traffic-filter-btn { padding: 2px 8px; border-radius: 4px;