Files
DroneCan_WebTools/src/BusMonitor.js
2025-07-29 09:25:01 +08:00

358 lines
15 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import {
Box, Paper, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, IconButton, Toolbar, AppBar, Button,
FormControlLabel, Switch, Dialog, DialogTitle, DialogContent,
DialogActions
} from '@mui/material';
import ClearIcon from '@mui/icons-material/Clear';
import PauseIcon from '@mui/icons-material/Pause';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import SaveIcon from '@mui/icons-material/Save';
import { toYaml } from './dronecan/message_format_utils';
const BusMonitor = () => {
const [transfers, setTransfers] = useState([]);
const [isPaused, setIsPaused] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [selectedTransfer, setSelectedTransfer] = useState(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [messageYaml, setMessageYaml] = useState('');
const tableContainerRef = useRef(null);
const maxTransfers = 1000; // Maximum number of transfers to store
// Handle row click to show details
const handleRowClick = (transfer) => {
setSelectedTransfer(transfer);
// Convert the message to YAML format if it has payload
let yamlText = '';
if (transfer.data && transfer.data.toObj) {
const msgObj = transfer.data.toObj();
yamlText = `### Message details\n`;
yamlText += `Direction: ${transfer.direction}\n`;
yamlText += `Time: ${transfer.timestamp}\n`;
yamlText += `CAN ID: ${transfer.frameId}\n`;
yamlText += `Source Node: ${transfer.sourceNodeId}\n`;
yamlText += `Destination Node: ${transfer.destNodeId || 'Broadcast'}\n`;
yamlText += `Data Type: ${transfer.dataType}\n\n`;
yamlText += `### Message Payload\n`;
yamlText += toYaml(msgObj);
} else {
yamlText = `No detailed payload data available for this transfer.\n\n`;
yamlText += `Direction: ${transfer.direction}\n`;
yamlText += `Time: ${transfer.timestamp}\n`;
yamlText += `CAN ID: ${transfer.frameId}\n`;
yamlText += `Hex Data: ${transfer.hexData}\n`;
yamlText += `Source Node: ${transfer.sourceNodeId}\n`;
yamlText += `Destination Node: ${transfer.destNodeId || 'Broadcast'}\n`;
}
setMessageYaml(yamlText);
setDetailsOpen(true);
};
// Close the details dialog
const handleCloseDetails = () => {
setDetailsOpen(false);
};
useEffect(() => {
const localNode = window.opener.localNode;
if (!localNode) return;
// Function to handle incoming transfers
const handleTransfer = (transfer, direction) => {
if (isPaused) return;
const now = new Date();
const timeString = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
// Format payload bytes as hex
let hexData = '';
if (transfer._payloadBytes && transfer._payloadBytes.length > 0) {
hexData = Array.from(transfer._payloadBytes)
.map(byte => byte.toString(16).padStart(2, '0').toUpperCase())
.join(' ');
}
setTransfers(prevTransfers => {
const newTransfers = [...prevTransfers, {
timestamp: timeString,
timestampMs: now.getTime(),
transfer: transfer,
dataType: transfer.payload ? transfer.payload.name : 'Unknown',
sourceNodeId: transfer.sourceNodeId,
destNodeId: transfer.destNodeId,
frameId: `0x${transfer.messageId.toString(16).toUpperCase()}`,
data: transfer.payload,
_payloadBytes: transfer._payloadBytes,
hexData: hexData,
direction: direction,
rawData: transfer.payload ? JSON.stringify(transfer.payload.toObj()) : '{}',
id: now.getTime() + Math.random().toString(36).substring(2, 9)
}];
// Keep only the last maxTransfers items
if (newTransfers.length > maxTransfers) {
return newTransfers.slice(newTransfers.length - maxTransfers);
}
return newTransfers;
});
};
// Subscribe to both TX and RX transfers
const handleTransferRx = (transfer) => handleTransfer(transfer, 'RX');
const handleTransferTx = (transfer) => handleTransfer(transfer, 'TX');
localNode.on('transfer-rx', handleTransferRx);
localNode.on('transfer-tx', handleTransferTx);
return () => {
// Clean up subscriptions
localNode.off('transfer-rx', handleTransferRx);
localNode.off('transfer-tx', handleTransferTx);
};
}, [isPaused, maxTransfers]);
useEffect(() => {
if (autoScroll && tableContainerRef.current && !isPaused) {
tableContainerRef.current.scrollTop = tableContainerRef.current.scrollHeight;
}
}, [transfers, autoScroll, isPaused]);
const handleClearTransfers = () => {
setTransfers([]);
};
const togglePause = () => {
setIsPaused(!isPaused);
};
const toggleAutoScroll = () => {
setAutoScroll(!autoScroll);
};
const exportToCSV = () => {
const headers = ['Direction', 'Timestamp', 'CAN ID (Hex)', 'Hex Data', 'Src Node ID', 'Dst Node ID', 'Data Type', 'Raw Data'];
const csvRows = [
headers.join(','),
...transfers.map(transfer => {
// Format payload bytes for CSV export
let hexData = '';
if (transfer._payloadBytes && transfer._payloadBytes.length > 0) {
hexData = Array.from(transfer._payloadBytes)
.map(byte => byte.toString(16).padStart(2, '0').toUpperCase())
.join(' ');
}
return [
transfer.direction,
transfer.timestamp,
transfer.frameId,
`"${hexData}"`,
transfer.sourceNodeId,
transfer.destNodeId,
transfer.dataType,
`"${transfer.rawData.replace(/"/g, '""')}"`
].join(',');
})
];
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `bus-monitor-export-${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.csv`);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<AppBar position="static" color="primary">
<Toolbar variant="dense">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Bus Monitor
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FormControlLabel
control={
<Switch
checked={autoScroll}
onChange={toggleAutoScroll}
size="small"
/>
}
label={<Typography variant="body2">Auto Scroll</Typography>}
labelPlacement="start"
/>
<IconButton
color={isPaused ? "default" : "inherit"}
onClick={togglePause}
size="small"
>
{isPaused ? <PlayArrowIcon /> : <PauseIcon />}
</IconButton>
<IconButton
color="inherit"
onClick={handleClearTransfers}
size="small"
>
<ClearIcon />
</IconButton>
<Button
startIcon={<SaveIcon />}
onClick={exportToCSV}
color="inherit"
size="small"
variant="outlined"
sx={{ ml: 1 }}
>
Export
</Button>
</Box>
</Toolbar>
</AppBar>
<TableContainer
component={Paper}
sx={{ flexGrow: 1, overflow: 'auto' }}
ref={tableContainerRef}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Dir</TableCell>
<TableCell>Time</TableCell>
<TableCell>CAN ID</TableCell>
<TableCell>Hex Data</TableCell>
<TableCell>Src</TableCell>
<TableCell>Dst</TableCell>
<TableCell>Data Type</TableCell>
</TableRow>
</TableHead>
<TableBody>
{transfers.map((item) => (
<TableRow
key={item.id}
onClick={() => handleRowClick(item)}
sx={{
backgroundColor: item.direction === 'TX' ? 'rgba(200, 250, 200, 0.05)' : 'rgba(200, 200, 255, 0.05)',
cursor: 'pointer',
'&:hover': {
backgroundColor: item.direction === 'TX' ? 'rgba(200, 250, 200, 0.2)' : 'rgba(200, 200, 255, 0.2)',
}
}}
>
<TableCell
sx={{
color: item.direction === 'TX' ? '#2e7d32' : '#1565c0',
fontWeight: 'bold',
width: '40px',
padding: '2px 8px'
}}
>
{item.direction}
</TableCell>
<TableCell>{item.timestamp}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{item.frameId}</TableCell>
<TableCell
sx={{
maxWidth: '200px',
overflowX: 'auto',
whiteSpace: 'nowrap',
fontFamily: 'monospace',
fontSize: '0.75rem'
}}
>
{item.hexData}
</TableCell>
<TableCell>{item.sourceNodeId}</TableCell>
<TableCell>{item.destNodeId || ''}</TableCell>
<TableCell>{item.dataType}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box
sx={{
p: 1,
borderTop: '1px solid #e0e0e0',
backgroundColor: '#0b0202',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Typography variant="body2" color="textSecondary">
Showing {transfers.length} of max {maxTransfers} transfers
</Typography>
{isPaused && (
<Typography
variant="body2"
sx={{
color: '#d32f2f',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<PauseIcon fontSize="small" /> PAUSED
</Typography>
)}
</Box>
<Dialog
open={detailsOpen}
onClose={handleCloseDetails}
maxWidth="md"
fullWidth
>
<DialogTitle>
Message Details
{selectedTransfer && (
<Typography variant="subtitle2" color="textSecondary">
{selectedTransfer.dataType} - {selectedTransfer.frameId}
</Typography>
)}
</DialogTitle>
<DialogContent dividers>
<textarea
readOnly
value={messageYaml}
style={{
width: '100%',
height: '60vh',
fontFamily: 'monospace',
fontSize: '14px',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '4px',
resize: 'none'
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDetails} color="primary">
Close
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BusMonitor;