init for release

This commit is contained in:
Huibean
2025-07-29 09:25:01 +08:00
parent de4ecf0d18
commit c3c4eb64f0
78 changed files with 63268 additions and 19 deletions
+4
View File
@@ -0,0 +1,4 @@
// filepath: .babelrc
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
+6 -18
View File
@@ -1,18 +1,6 @@
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
node_modules
dist
.DS_Store
mav.parm
mav.tlog
mav.tlog.raw
+16
View File
@@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080", // Adjust the URL and port as needed
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///src/*": "${webRoot}/*"
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"Mavlink"
]
}
+93 -1
View File
@@ -1 +1,93 @@
# dronecan-configurator
# DroneCAN-Webtools
![droencan-webtools](index_preview.jpg)
A web-based tool for DroneCAN configuration and monitoring. This application provides multiple panels for interacting with DroneCAN nodes including bus monitoring, ESC control, actuator control, and parameter management.
## Access
**Official Entry**: https://can.vimdrones.com
**Backup Entry**: https://can.vimdrones.com
## Features
- **Bus Monitor**: Monitor DroneCAN bus traffic and messages
- **ESC Panel**: Control and configure Electronic Speed Controllers
- **Actuator Panel**: Control and test actuators
- **Subscriber**: Subscribe to specific DroneCAN messages
- **Node Configuration**: Manage node parameters and settings
## Prerequisites
- Node.js (version 14 or higher)
- npm or yarn package manager
## Installation
1. Clone the repository:
```bash
git clone https://github.com/VimDrones/dronecan-webtools.git
cd dronecan-webtools
```
2. Install dependencies:
```bash
npm install
```
## Running the Application
### Development Mode
To start the development server with hot reload:
```bash
npm start
```
The application will be available at `http://localhost:8080`
### Production Build
To create a production build:
```bash
npm run build
```
The built files will be generated in the `dist` directory.
### Update dronecan.js
```bash
git clone https://github.com/dronecan/dronecan_dsdljs
cd dronecan_dsdljs
./dronecan_dsdljs.py ../DSDL/dronecan ../DSDL/ardupilot ../DSDL/uavcan ../DSDL/com --output ../dronecan-webtools/src/dronecan # adjust path in your case
```
## Application Structure
The application consists of multiple entry points:
- **Main Application** (`/`): Primary interface with navigation to all tools
- **Bus Monitor** (`/bus_monitor.html`): Real-time DroneCAN message monitoring
- **ESC Panel** (`/esc_panel.html`): ESC configuration and control
- **Actuator Panel** (`/actuator_panel.html`): Actuator testing and control
- **Subscriber** (`/subscriber.html`): Message subscription interface
## Usage
1. Start the development server using `npm start`
2. Open your web browser and navigate to `http://localhost:8080`
3. Connect to your DroneCAN bus using the connection settings
4. Use the various panels to monitor and control your DroneCAN devices
## Development
This project uses:
- React 18 for the UI framework
- Material-UI for components
- Webpack 5 for bundling
- Babel for JavaScript transpilation
The development server runs on port 8080 with hot module replacement enabled.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

+7765
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "dronecan-webtools",
"version": "1.0.1",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.26.7",
"@babel/preset-env": "^7.26.7",
"@babel/preset-react": "^7.26.3",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.4",
"@mui/material": "^6.4.2",
"babel-loader": "^9.2.1",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.1",
"html-webpack-plugin": "^5.6.3",
"long": "^5.2.4",
"process": "^0.11.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"stream-browserify": "^3.0.0",
"underscore": "^1.13.7",
"util": "^0.12.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.97.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.0"
},
"devDependencies": {
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"style-loader": "^4.0.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Actuator Panel</title>
</head>
<body>
<div id="sub-root"></div>
<script defer="" src="actuator_panel.bundle.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bus Monitor</title>
</head>
<body>
<div id="sub-root"></div>
<script defer="" src="bus_monitor.bundle.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESC Panel</title>
</head>
<body>
<div id="sub-root"></div>
<script defer="" src="esc_panel.bundle.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DroneCan Web Tools</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="DroneCAN Web Tools - A powerful web application for configuring, monitoring and managing DroneCAN devices" />
<meta name="keywords" content="DroneCAN, UAV, Ardupilot, AM32, AP_Periph, Pixhawk, drone, configuration, parameter editing, node discovery, firmware updates" />
<meta name="author" content="Huibean Luo" />
<meta name="sponsor" content="Vimdrones" />
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="root"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Subscriber</title>
</head>
<body>
<div id="sub-root"></div>
<script defer="" src="subscriber.bundle.js"></script>
</body>
</html>
+274
View File
@@ -0,0 +1,274 @@
import React from 'react';
import { Typography, Box, Paper, Divider, Link, Grid, Card, CardContent, List, ListItem, ListItemIcon, ListItemText, Stack, Chip, Button } from '@mui/material';
import packageInfo from '../package.json';
import vimdronesLogo from './image/vimdrones_logo.png';
import discordLogo from './image/discord_logo.png'; // Import Discord logo
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import UsbIcon from '@mui/icons-material/Usb';
import WifiIcon from '@mui/icons-material/Wifi';
import ComputerIcon from '@mui/icons-material/Computer';
// Remove the DiscordIcon import since we're now using a custom image
const About = () => {
return (
<Box sx={{ mx: 'auto', p: 2, width: '100%', flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1 }} component={Paper} elevation={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%', mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline' }}>
<Typography variant="h4" component="h1" sx={{ fontWeight: 600 }}>
DroneCAN Web Tools
</Typography>
<Chip
label={`v${packageInfo.version}`}
size="small"
color="primary"
variant="outlined"
sx={{ ml: 2 }}
/>
</Box>
</Box>
<Card variant="outlined">
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="h6" sx={{ mb: 1.5, fontWeight: 500, color: 'primary.main' }}>Key Features</Typography>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<List dense disablePadding>
{['Web Serial API connection', 'WebSocket remote access', 'Parameter editing', 'Node discovery', 'Frame Monitor'].map((item, index) => (
<ListItem key={index} disablePadding sx={{ py: 0.25 }}>
<ListItemIcon sx={{ minWidth: 30 }}>
<CheckCircleOutlineIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={item} />
</ListItem>
))}
</List>
</Grid>
<Grid item xs={12} sm={6}>
<List dense disablePadding>
{['Firmware updates', 'Data visualization', 'ESC Control', 'Actuator Control', 'Bus Monitor'].map((item, index) => (
<ListItem key={index} disablePadding sx={{ py: 0.25 }}>
<ListItemIcon sx={{ minWidth: 30 }}>
<CheckCircleOutlineIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={item} />
</ListItem>
))}
</List>
</Grid>
</Grid>
</CardContent>
</Card>
<Stack spacing={2} direction={{ xs: 'column', md: 'row' }}>
<Card variant="outlined" sx={{ flex: 1 }}>
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<WifiIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>WebSocket via MAVProxy</Typography>
</Box>
<Typography variant="body2" sx={{ bgcolor: 'action.hover', p: 1, borderRadius: 1, fontFamily: 'monospace' }}>
mavproxy.py --master /dev/tty.usbmodem111401,115200 --out wsserver:127.0.0.1:5555
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
Connect to <Box component="span" sx={{ fontWeight: 'bold' }}>ws://127.0.0.1:5555</Box> in the Adapter Settings
</Typography>
<Box sx={{ mt: 2, pt: 1, borderTop: '1px dashed', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<ComputerIcon color="info" sx={{ mr: 1, fontSize: '1rem' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Or try without hardware using Ardupilot SITL
</Typography>
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
./Tools/autotest/sim_vehicle.py -v ArduPlane --can-peripherals --out wsserver:127.0.0.1:5555
</Typography>
</Box>
</CardContent>
</Card>
<Card variant="outlined" sx={{ flex: 1 }}>
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<UsbIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>Direct Serial Connection</Typography>
</Box>
<Typography variant="body2">
Use the Web Serial API to connect directly to your device through the browser
</Typography>
<Typography variant="subtitle2" sx={{ mt: 1, mb: 0.5, fontWeight: 600 }}>
Supported Devices:
</Typography>
<List dense disablePadding>
<ListItem disablePadding sx={{ py: 0.25 }}>
<ListItemIcon sx={{ minWidth: 30 }}>
<CheckCircleOutlineIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Flight Controller with MAVCAN passthrough (ie Ardupilot)" />
</ListItem>
<ListItem disablePadding sx={{ py: 0.25 }}>
<ListItemIcon sx={{ minWidth: 30 }}>
<CheckCircleOutlineIcon color="success" fontSize="small" /> {/* Changed from info to success */}
</ListItemIcon>
<ListItemText
primary="Standalone MAVCAN USB Adaptor"
secondary={
<Box component="span">
<Link
href="https://github.com/VimDrones/MAVCAN_Bridge"
target="_blank"
rel="noopener"
sx={{ fontSize: '0.75rem' }}
>
MAVCAN Bridge
</Link>
<Typography
component="span"
variant="caption"
sx={{ ml: 0.5, fontSize: '0.75rem', color: 'text.secondary' }}
>
from Vimdrones
</Typography>
</Box>
}
/>
</ListItem>
</List>
</CardContent>
</Card>
</Stack>
{/* Footer */}
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end'}}>
<Divider sx={{ my: 1.5 }} />
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1,
justifyContent: 'space-between',
alignItems: { xs: 'flex-start', sm: 'center' }
}}>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1, mr: 5, alignItems: { xs: 'flex-start', sm: 'center' } }}>
<Typography variant="body2" color="text.secondary">
Author: <Link href="https://github.com/huibean" target="_blank" rel="noopener" sx={{ fontWeight: 500 }}>Huibean Luo</Link>
</Typography>
<Button
variant="outlined"
size="small"
component={Link}
href="https://discord.gg/xxCKsZXU4K"
target="_blank"
rel="noopener"
sx={{
borderRadius: 4,
textTransform: 'none',
fontSize: '0.75rem',
height: 28,
px: 1.5,
bgcolor: 'rgba(88, 101, 242, 0.08)',
borderColor: '#5865F2',
color: '#5865F2',
display: 'flex',
alignItems: 'center',
gap: 0.5,
'&:hover': {
bgcolor: 'rgba(88, 101, 242, 0.12)',
borderColor: '#5865F2',
}
}}
>
<Box
component="span"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: 20,
width: 20,
mr: 0.5,
'& img': {
height: '100%',
width: '100%',
}
}}
>
<img
style={{borderRadius: '50%'}}
src={discordLogo}
alt="Discord"
/>
</Box>
Vimdrones
</Button>
<Button
variant="outlined"
size="small"
component={Link}
href="https://discord.gg/vz7a499KXN"
target="_blank"
rel="noopener"
sx={{
borderRadius: 4,
textTransform: 'none',
fontSize: '0.75rem',
height: 28,
px: 1.5,
bgcolor: 'rgba(88, 101, 242, 0.08)',
borderColor: '#5865F2',
color: '#5865F2',
display: 'flex',
alignItems: 'center',
gap: 0.5,
'&:hover': {
bgcolor: 'rgba(88, 101, 242, 0.12)',
borderColor: '#5865F2',
}
}}
>
<Box
component="span"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: 20,
width: 20,
mr: 0.5,
'& img': {
height: '100%',
width: '100%',
}
}}
>
<img
style={{borderRadius: '50%'}}
src={discordLogo}
alt="Discord"
/>
</Box>
DroneCAN
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>
Sponsored by
</Typography>
<Link href="https://dev.vimdrones.com" target="_blank" rel="noopener">
<img
src={vimdronesLogo}
alt="Vimdrones"
style={{ height: '30px' }}
/>
</Link>
</Box>
</Box>
</Box>
</Box>
);
}
export default About;
+778
View File
@@ -0,0 +1,778 @@
import React, { useState, useEffect } from 'react';
import {
Paper, Box, Typography, Card, CardContent, Slider, TextField, AppBar,
Toolbar, Button, IconButton, FormGroup, FormControlLabel, Checkbox,
Dialog, DialogTitle, DialogContent, DialogActions, Select, MenuItem, FormControl,
InputLabel, Tab, Tabs, Divider, Grid
} from '@mui/material';
import PanToolIcon from '@mui/icons-material/PanTool';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import SettingsIcon from '@mui/icons-material/Settings';
const MAX_ACTUATOR_IDS = 256;
const COMMAND_TYPES = {
UNITLESS: 0, // [-1, 1]
POSITION: 1, // meter or radian
FORCE: 2, // Newton or Newton metre
SPEED: 3 // meter per second or radian per second
};
const COMMAND_TYPE_LABELS = {
0: 'Unitless [-1, 1]',
1: 'Position (m/rad)',
2: 'Force (N/Nm)',
3: 'Speed (m/s, rad/s)'
};
const DEFAULT_RANGES = {
0: { min: -1, max: 1 }, // Unitless is fixed -1 to 1
1: { min: -2, max: 2 }, // Position range (adjustable)
2: { min: -10, max: 10 }, // Force range (adjustable)
3: { min: -20, max: 20 } // Speed range (adjustable)
};
const ActuatorPanel = () => {
const [enabledActuatorIds, setEnabledActuatorIds] = useState(
Array(MAX_ACTUATOR_IDS).fill(false).map((_, i) => i < 4)
);
const [actuatorData, setActuatorData] = useState([]);
const [commandValues, setCommandValues] = useState({});
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [defaultRanges, setDefaultRanges] = useState({...DEFAULT_RANGES});
const [broadcastRate, setBroadcastRate] = useState(10);
const [isPaused, setIsPaused] = useState(false);
const [showIdSelector, setShowIdSelector] = useState(false);
const nodeId = 0;
const activeActuatorIds = enabledActuatorIds
.map((enabled, id) => enabled ? id : null)
.filter(id => id !== null);
const togglePause = () => {
setIsPaused(!isPaused);
};
const handleBroadcastRateChange = (e) => {
const newValue = parseInt(e.target.value) || 0;
const safeValue = Math.max(1, Math.min(100, newValue));
setBroadcastRate(safeValue);
};
const toggleActuatorId = (id) => {
const newEnabledIds = [...enabledActuatorIds];
newEnabledIds[id] = !newEnabledIds[id];
setEnabledActuatorIds(newEnabledIds);
};
const handleDefaultRangeChange = (type, field, value) => {
const parsedValue = parseFloat(value);
if (isNaN(parsedValue)) return;
if (field === 'min' && parsedValue >= defaultRanges[type].max) return;
if (field === 'max' && parsedValue <= defaultRanges[type].min) return;
setDefaultRanges(prev => ({
...prev,
[type]: {
...prev[type],
[field]: parsedValue
}
}));
};
const applyRangesToAllOfType = (type) => {
if (type === COMMAND_TYPES.UNITLESS) return; // Don't change unitless ranges
const newMin = defaultRanges[type].min;
const newMax = defaultRanges[type].max;
activeActuatorIds.forEach(id => {
if ((commandTypes[id] || COMMAND_TYPES.UNITLESS) === type) {
setSliderMins(prev => ({ ...prev, [id]: newMin }));
setSliderMaxs(prev => ({ ...prev, [id]: newMax }));
}
});
};
const applyAllRanges = () => {
activeActuatorIds.forEach(id => {
const type = commandTypes[id] || COMMAND_TYPES.UNITLESS;
if (type !== COMMAND_TYPES.UNITLESS) {
setSliderMins(prev => ({ ...prev, [id]: defaultRanges[type].min }));
setSliderMaxs(prev => ({ ...prev, [id]: defaultRanges[type].max }));
}
});
};
useEffect(() => {
const activeIds = activeActuatorIds;
const initialCommandValues = {};
activeIds.forEach(id => {
initialCommandValues[id] = commandValues[id] || 0;
});
setCommandValues(initialCommandValues);
const initialActuatorData = activeIds.map(id => ({
actuator_id: id,
position: null,
force: null,
speed: null,
power_rating_pct: null,
}));
setActuatorData(initialActuatorData);
}, [enabledActuatorIds]);
const handleCommandChange = (id, value) => {
setCommandValues(prev => ({
...prev,
[id]: value
}));
};
const handleCommandInputChange = (id, event) => {
let value = parseFloat(event.target.value);
if (isNaN(value)) value = 0;
value = Math.max(sliderMins[id] || -1, Math.min(sliderMaxs[id] || 1, value));
handleCommandChange(id, value);
};
const handleZeroAll = () => {
const newCommandValues = {};
activeActuatorIds.forEach(id => {
newCommandValues[id] = 0;
});
setCommandValues(newCommandValues);
};
const handleZeroOne = (id) => {
setCommandValues(prev => ({
...prev,
[id]: 0
}));
};
useEffect(() => {
const localNode = window.opener?.localNode;
if (!localNode) return;
const handleActuatorData = (transfer) => {
const msg = transfer.payload;
if (!msg) return;
try {
const msgObj = msg.toObj ? msg.toObj() : msg;
if (!msgObj || typeof msgObj.actuator_id !== 'number') return;
if (enabledActuatorIds[msgObj.actuator_id]) {
setActuatorData(prev => {
const existingIndex = prev.findIndex(a => a.actuator_id === msgObj.actuator_id);
const newData = [...prev];
const updatedActuator = {
actuator_id: msgObj.actuator_id,
position: msgObj.position,
force: msgObj.force,
speed: msgObj.speed,
power_rating_pct: msgObj.power_rating_pct || null,
status_flags: msgObj.status_flags
};
if (existingIndex !== -1) {
newData[existingIndex] = updatedActuator;
} else {
newData.push(updatedActuator);
}
return newData;
});
}
} catch (error) {
console.error('Error processing actuator status:', error);
}
};
localNode.on('uavcan.equipment.actuator.Status', handleActuatorData);
return () => {
localNode.off('uavcan.equipment.actuator.Status', handleActuatorData);
};
}, [enabledActuatorIds]);
useEffect(() => {
// Create or retrieve the worker
if (!window.ActuatorPanelWorker) {
window.ActuatorPanelWorker = new Worker(new URL('./workers/actuator-command-worker.js', import.meta.url));
console.log('Created actuator command worker');
}
window.ActuatorPanelWorker.onmessage = (event) => {
const localNode = window.opener?.localNode;
if (!localNode) return;
if (event.data.type === 'requestActuatorCommand') {
try {
const allCommands = [];
activeActuatorIds.forEach(id => {
const type = commandTypes[id] || COMMAND_TYPES.UNITLESS;
const value = commandValues[id] || 0;
if (value < (sliderMins[id] || -1) || value > (sliderMaxs[id] || 1)) {
// Value out of range, skipping
} else {
allCommands.push({ id, type, value });
}
});
const MAX_COMMANDS_PER_BATCH = 15;
if (allCommands.length > MAX_COMMANDS_PER_BATCH) {
const batchCount = Math.ceil(allCommands.length / MAX_COMMANDS_PER_BATCH);
for (let i = 0; i < batchCount; i++) {
const startIdx = i * MAX_COMMANDS_PER_BATCH;
const endIdx = Math.min(startIdx + MAX_COMMANDS_PER_BATCH, allCommands.length);
const batchCommands = allCommands.slice(startIdx, endIdx);
localNode.sendUavcanEquipmentActuatorArrayCommand(0, batchCommands);
}
} else if (allCommands.length > 0) {
localNode.sendUavcanEquipmentActuatorArrayCommand(0, allCommands);
}
} catch (error) {
console.error('Error sending actuator commands:', error);
}
}
}
// Clean up
return () => {
};
}, [activeActuatorIds, commandTypes, commandValues, sliderMins, sliderMaxs]);
useEffect(() => {
if (!window.ActuatorPanelWorker) return;
if (!isPaused) {
console.log(`Starting Actuator commands with rate: ${broadcastRate}Hz`);
window.ActuatorPanelWorker.postMessage({
type: 'actuator',
command: 'start',
rate: broadcastRate
});
} else {
console.log('Pausing Actuator commands');
window.ActuatorPanelWorker.postMessage({
type: 'actuator',
command: 'stop'
});
}
return () => {
window.ActuatorPanelWorker.postMessage({
type: 'actuator',
command: 'stop'
});
};
}, [isPaused, broadcastRate]);
const renderIdSelectorDialog = () => (
<Dialog open={showIdSelector} onClose={() => setShowIdSelector(false)}>
<DialogTitle>Select Actuator IDs</DialogTitle>
<DialogContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Select Actuator IDs:</Typography>
<FormGroup sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1 }}>
{Array(MAX_ACTUATOR_IDS).fill(0).map((_, id) => (
<FormControlLabel
key={id}
control={
<Checkbox
checked={enabledActuatorIds[id]}
onChange={() => toggleActuatorId(id)}
/>
}
label={id}
/>
))}
</FormGroup>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowIdSelector(false)} color="primary">
Done
</Button>
</DialogActions>
</Dialog>
);
const renderRangeSettingsDialog = () => (
<Dialog
open={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
Command Type Range Settings
</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2, fontStyle: 'italic' }}>
Configure default ranges for each command type. These settings can be applied to all actuators.
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" sx={{ mb: 1 }}>
{COMMAND_TYPE_LABELS[0]}
</Typography>
<Typography variant="body2" color="text.secondary">
Unitless command range is fixed at -1 to 1
</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" sx={{ mb: 1 }}>
{COMMAND_TYPE_LABELS[1]}
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={5}>
<TextField
label="Min"
type="number"
size="small"
fullWidth
value={defaultRanges[1].min}
onChange={(e) => handleDefaultRangeChange(1, 'min', e.target.value)}
InputProps={{ inputProps: { step: 0.1 } }}
/>
</Grid>
<Grid item xs={5}>
<TextField
label="Max"
type="number"
size="small"
fullWidth
value={defaultRanges[1].max}
onChange={(e) => handleDefaultRangeChange(1, 'max', e.target.value)}
InputProps={{ inputProps: { step: 0.1 } }}
/>
</Grid>
<Grid item xs={2}>
<Button
variant="outlined"
size="small"
onClick={() => applyRangesToAllOfType(1)}
fullWidth
>
Apply
</Button>
</Grid>
</Grid>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" sx={{ mb: 1 }}>
{COMMAND_TYPE_LABELS[2]}
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={5}>
<TextField
label="Min"
type="number"
size="small"
fullWidth
value={defaultRanges[2].min}
onChange={(e) => handleDefaultRangeChange(2, 'min', e.target.value)}
InputProps={{ inputProps: { step: 0.5 } }}
/>
</Grid>
<Grid item xs={5}>
<TextField
label="Max"
type="number"
size="small"
fullWidth
value={defaultRanges[2].max}
onChange={(e) => handleDefaultRangeChange(2, 'max', e.target.value)}
InputProps={{ inputProps: { step: 0.5 } }}
/>
</Grid>
<Grid item xs={2}>
<Button
variant="outlined"
size="small"
onClick={() => applyRangesToAllOfType(2)}
fullWidth
>
Apply
</Button>
</Grid>
</Grid>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" sx={{ mb: 1 }}>
{COMMAND_TYPE_LABELS[3]}
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={5}>
<TextField
label="Min"
type="number"
size="small"
fullWidth
value={defaultRanges[3].min}
onChange={(e) => handleDefaultRangeChange(3, 'min', e.target.value)}
InputProps={{ inputProps: { step: 1 } }}
/>
</Grid>
<Grid item xs={5}>
<TextField
label="Max"
type="number"
size="small"
fullWidth
value={defaultRanges[3].max}
onChange={(e) => handleDefaultRangeChange(3, 'max', e.target.value)}
InputProps={{ inputProps: { step: 1 } }}
/>
</Grid>
<Grid item xs={2}>
<Button
variant="outlined"
size="small"
onClick={() => applyRangesToAllOfType(3)}
fullWidth
>
Apply
</Button>
</Grid>
</Grid>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={applyAllRanges}
color="primary"
variant="contained"
>
Apply All Ranges
</Button>
<Button
onClick={() => setShowSettingsModal(false)}
color="primary"
>
Close
</Button>
</DialogActions>
</Dialog>
);
const [commandTypes, setCommandTypes] = useState({});
const [sliderMins, setSliderMins] = useState({});
const [sliderMaxs, setSliderMaxs] = useState({});
const handleActuatorCommandTypeChange = (id, newType) => {
setCommandTypes(prev => {
const updated = { ...prev, [id]: newType };
return updated;
});
if (newType === COMMAND_TYPES.UNITLESS) {
setSliderMins(prev => ({ ...prev, [id]: -1 }));
setSliderMaxs(prev => ({ ...prev, [id]: 1 }));
} else {
setSliderMins(prev => ({ ...prev, [id]: defaultRanges[newType].min }));
setSliderMaxs(prev => ({ ...prev, [id]: defaultRanges[newType].max }));
}
setCommandValues(prev => ({ ...prev, [id]: 0 }));
};
useEffect(() => {
const activeIds = activeActuatorIds;
const initialCommandValues = {};
const initialCommandTypes = {};
const initialSliderMins = {};
const initialSliderMaxs = {};
activeIds.forEach(id => {
initialCommandValues[id] = commandValues[id] || 0;
initialCommandTypes[id] = commandTypes[id] || COMMAND_TYPES.UNITLESS;
const type = commandTypes[id] || COMMAND_TYPES.UNITLESS;
if (type === COMMAND_TYPES.UNITLESS) {
initialSliderMins[id] = -1;
initialSliderMaxs[id] = 1;
} else {
initialSliderMins[id] = sliderMins[id] || defaultRanges[type].min;
initialSliderMaxs[id] = sliderMaxs[id] || defaultRanges[type].max;
}
});
setCommandValues(initialCommandValues);
setCommandTypes(initialCommandTypes);
setSliderMins(initialSliderMins);
setSliderMaxs(initialSliderMaxs);
const initialActuatorData = activeIds.map(id => ({
actuator_id: id,
position: null,
force: null,
speed: null,
power_rating_pct: null,
}));
setActuatorData(initialActuatorData);
}, [enabledActuatorIds]);
return (
<Box
sx={{
flexGrow: 1,
bgcolor: 'background.paper',
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column'
}}
component={Paper}
p={1}
>
<AppBar position="static" color="primary" sx={{ mb: 2 }}>
<Toolbar variant="dense" sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Button
variant="outlined"
size="small"
color="inherit"
startIcon={<CheckBoxIcon />}
onClick={() => setShowIdSelector(true)}
sx={{ textTransform: 'none' }}
>
Actuator IDs ({activeActuatorIds.length})
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
<Button
variant="outlined"
size="small"
color="inherit"
startIcon={<SettingsIcon />}
onClick={() => setShowSettingsModal(true)}
sx={{ textTransform: 'none' }}
>
Range Settings
</Button>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>Broadcast Rate:</Typography>
<TextField
type="number"
size="small"
value={broadcastRate}
sx={{ width: '60px' }}
InputProps={{
inputProps: {
min: 1,
max: 100,
style: {
textAlign: 'center',
padding: '2px 4px'
}
}
}}
onChange={handleBroadcastRateChange}
/>
<IconButton
size="small"
onClick={togglePause}
sx={{ ml: 1 }}
color={isPaused ? "default" : "primary"}
>
{isPaused ? <PlayArrowIcon /> : <PauseIcon />}
</IconButton>
</Box>
</Box>
</Toolbar>
</AppBar>
{renderIdSelectorDialog()}
{renderRangeSettingsDialog()}
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
justifyContent: 'center',
flexGrow: 1,
overflowY: 'auto',
minHeight: '150px',
maxHeight: 'calc(100% - 140px)'
}}>
{actuatorData.map((actuator) => (
<Box
key={actuator.actuator_id}
sx={{
width: '180px',
height: '250px',
}}
>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent
sx={{
height: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
p: 1.5,
}}
>
<Box sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
width: '50%'
}}>
<Box sx={{flexGrow: 1}}>
<Typography variant="body2" color="textSecondary">ID: {actuator.actuator_id}</Typography>
<Typography variant="body2" color="textSecondary">
Pos: {actuator.position !== null ? actuator.position.toFixed(3) : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
Force: {actuator.force !== null ? `${actuator.force.toFixed(2)} N` : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
Speed: {actuator.speed !== null ? `${actuator.speed.toFixed(2)} rad/s` : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
RAT: {actuator.power_rating_pct !== null
? actuator.power_rating_pct === 127
? "unknown"
: `${actuator.power_rating_pct.toFixed(1)} %`
: "NC"}
</Typography>
</Box>
<FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}>
<Select
value={commandTypes[actuator.actuator_id] || COMMAND_TYPES.UNITLESS}
onChange={(e) => handleActuatorCommandTypeChange(actuator.actuator_id, e.target.value)}
variant="outlined"
sx={{ height: '30px', fontSize: '0.8rem' }}
>
<MenuItem value={COMMAND_TYPES.UNITLESS}>Unitless</MenuItem>
<MenuItem value={COMMAND_TYPES.POSITION}>Position</MenuItem>
<MenuItem value={COMMAND_TYPES.FORCE}>Force</MenuItem>
<MenuItem value={COMMAND_TYPES.SPEED}>Speed</MenuItem>
</Select>
</FormControl>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%' }}>
<TextField
type="number"
size="small"
value={(commandValues[actuator.actuator_id] || 0).toFixed(3)}
fullWidth
InputProps={{
inputProps: {
min: sliderMins[actuator.actuator_id] || -1,
max: sliderMaxs[actuator.actuator_id] || 1,
step: 0.001,
style: {
padding: '2px 4px'
}
}
}}
onChange={(e) => handleCommandInputChange(actuator.actuator_id, e)}
/>
<Button
color="primary"
variant="contained"
onClick={() => handleZeroOne(actuator.actuator_id)}
fullWidth
size="small"
>
Zero
</Button>
</Box>
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'center',
height: '100%',
paddingTop: '5px',
width: '30%',
}}>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', height: '100%', width: '100%' }}>
<Slider
sx={{ height: '100%' }}
orientation="vertical"
value={commandValues[actuator.actuator_id] || 0}
valueLabelDisplay="auto"
step={0.01}
marks={[
{ value: sliderMaxs[actuator.actuator_id] || 1, label: '' },
{ value: 0, label: '' },
{ value: sliderMins[actuator.actuator_id] || -1, label: '' }
]}
min={sliderMins[actuator.actuator_id] || -1}
max={sliderMaxs[actuator.actuator_id] || 1}
onChange={(e, value) => handleCommandChange(actuator.actuator_id, value)}
/>
</Box>
</Box>
</CardContent>
</Card>
</Box>
))}
</Box>
<Box sx={{
mt: 1,
width: '100%',
p: 0.5,
gap: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}>
<Box sx={{ p: 1, border: '1px solid #ddd', borderRadius: 1}}>
<Typography variant="body2" color="textSecondary">
cmd: [{
activeActuatorIds
.map(id => {
const type = commandTypes[id] || COMMAND_TYPES.UNITLESS;
const typeLabel = Object.keys(COMMAND_TYPES).find(key => COMMAND_TYPES[key] === type).toLowerCase();
return `${id}:${(commandValues[id] || 0).toFixed(3)}[${typeLabel}]`;
})
.join(', ')
}]
</Typography>
</Box>
<Button
variant="contained"
color="primary"
fullWidth
startIcon={<PanToolIcon />}
onClick={handleZeroAll}
>
Zero All
</Button>
</Box>
</Box>
);
};
export default ActuatorPanel;
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import { ThemeProvider } from '@mui/material';
import ActuatorPanel from './ActuatorPanel';
import theme from './theme';
import './css/panel.css';
const ActuatorPanelWindow = () => {
return (
<ThemeProvider theme={theme}>
<ActuatorPanel />
</ThemeProvider>
);
};
export default ActuatorPanelWindow;
+307
View File
@@ -0,0 +1,307 @@
import React, { useEffect, useState } from 'react';
import { AppBar, Toolbar, Typography, Box, Button, ThemeProvider, IconButton, Snackbar, Alert, Tooltip, FormControl, Select, MenuItem, InputLabel, Chip } from '@mui/material';
import MavlinkSession from './mavlink_session';
import dronecan from './dronecan';
import theme from './theme';
import NodeList from './NodeList';
import NodeLogs from './NodeLogs';
import NodeProperties from './NodeProperties';
import NodeParam from './NodeParam';
import About from './About';
import ToolsMenu from './ToolsMenu';
import PanelsMenu from './PanelsMenu';
import ConnectionSettingsModal from './ConnectionSettingsModal';
import DronecanLogo from './image/dronecan_logo.png';
import FileServer from './FileServer';
import './css/index.css';
import ConnectionIndicators from './ConnectionIndicators';
import DnsIcon from '@mui/icons-material/Dns';
import LanIcon from '@mui/icons-material/Lan';
import CompactSidebar from './CompactSidebar';
import DNAServerModal from './DNAServerModal';
window.mavlinkSession = new MavlinkSession();
window.localNode = new dronecan.Node({name: "com.vimdrones.web_gui"});
localNode.on('sendFrame', (messageId, data, len) => {
const msg = new mavlink20.messages.can_frame(
mavlinkSession.targetSystem, // target_system
mavlinkSession.targetComponent, // target_component
localNode.bus,
len,
messageId,
data.toString('binary')
);
mavlinkSession.sendMavlinkMsg(msg);
});
localNode.on('uavcan.protocol.file.Read.Request', (transfer) => {
FileServer.handleReadRequest(transfer, localNode);
});
const App = () => {
const [nodes, setNodes] = useState({});
const [nodesUpdateTimestamp, setNodesUpdateTimestamp] = useState(0);
const [isConnected, setIsConnected] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState(null);
const [multiNodeEditorEnable, setMultiNodeEditorEnable] = useState(false);
const [subWindowRef, setSubWindowRef] = useState({});
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarSeverity, setSnackbarSeverity] = useState('info');
const [selectedBus, setSelectedBus] = useState(0);
const [dnaModalOpen, setDnaModalOpen] = useState(false);
const [dnaServerActive, setDnaServerActive] = useState(false);
const openWindow = (windowTitle, windowPath, windowSize) => {
if (subWindowRef[windowPath]) {
subWindowRef[windowPath].focus();
return;
}
const newWindow = window.open(windowPath, windowTitle, windowSize);
if (newWindow) {
subWindowRef[windowPath] = newWindow;
setSubWindowRef(subWindowRef);
newWindow.addEventListener('beforeunload', () => {
subWindowRef[windowPath] = null;
setSubWindowRef(subWindowRef);
});
} else {
console.error(`Main: Failed to open ${windowName}`);
}
};
const showMessage = (message, severity = 'info') => {
setSnackbarMessage(message);
setSnackbarSeverity(severity);
setSnackbarOpen(true);
};
const handleConnectionStatusChange = (isConnected) => {
setIsConnected(isConnected);
showMessage(
isConnected ? 'Successfully connected to device' : 'Disconnected from device',
isConnected ? 'success' : 'info'
);
};
const handleBusChange = (event) => {
const newBus = event.target.value;
if (newBus === selectedBus) return;
setSelectedBus(newBus);
if (window.localNode) {
window.localNode.changeBus(newBus);
showMessage(`Switched to CAN bus ${newBus}`, 'info');
}
};
const handleNodeList = (nodeList) => {
setNodes(nodeList);
setNodesUpdateTimestamp(Date.now());
};
useEffect(() => {
localNode.on('nodeList', handleNodeList);
return () => {
localNode.off('nodeList');
};
}, []);
const handleOpenModal = () => {
setModalOpen(true);
};
const handleCloseModal = (serverRunning) => {
setModalOpen(false);
setDnaServerActive(serverRunning);
};
const handleOpenDnaModal = () => {
setDnaModalOpen(true);
};
const handleCloseDnaModal = (serverRunning) => {
setDnaModalOpen(false);
setDnaServerActive(serverRunning);
};
return (
<ThemeProvider theme={theme}>
<AppBar position="static">
<Toolbar variant="dense" sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{width: '30%', flexGrow: 1, display: 'flex', flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center'}}>
<ToolsMenu openWindow={openWindow.bind(this)} />
<PanelsMenu openWindow={openWindow.bind(this)} />
</Box>
<Box sx={{flexGrow: 2, display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center'}}>
<Box sx={{display: 'flex', flexDirection: 'row', alignItems: 'center'}} ml={0.5} mr={0.5}>
<a href="https://dev.vimdrones.com" target="_blank" rel="noreferrer" style={{height: 30}}>
<img src={DronecanLogo} alt="DroneCAN" style={{ height: 30}} />
</a>
</Box>
<Typography variant="caption">
DroneCAN Web Tools
</Typography>
</Box>
<Box sx={{width: '30%', flexGrow: 1, display: 'flex', flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 1}}>
<ConnectionIndicators
isConnected={isConnected}
mavlinkSession={window.mavlinkSession}
localNode={window.localNode}
/>
<FormControl
size="small"
sx={{
minWidth: 80,
'& .MuiOutlinedInput-root': {
height: 30,
},
'& .MuiSelect-select': {
paddingTop: 0.5,
paddingBottom: 0.5,
fontSize: '0.8rem'
}
}}
>
<Select
value={selectedBus}
onChange={handleBusChange}
displayEmpty
variant="outlined"
sx={{
backgroundColor: 'background.paper',
}}
>
<MenuItem value={0}>Bus 1</MenuItem>
<MenuItem value={1}>Bus 2</MenuItem>
<MenuItem value={2}>Bus 3</MenuItem>
<MenuItem value={3}>Bus 4</MenuItem>
</Select>
</FormControl>
<Button
variant="outlined"
color={dnaServerActive ? "success" : "primary"}
startIcon={
dnaServerActive ?
<DnsIcon sx={{
animation: 'pulse 1.5s infinite',
'@keyframes pulse': {
'0%': { opacity: 0.6 },
'50%': { opacity: 1 },
'100%': { opacity: 0.6 },
}
}} /> :
<DnsIcon />
}
onClick={handleOpenDnaModal}
sx={dnaServerActive ? {
borderColor: 'success.main',
'&:hover': {
backgroundColor: 'rgba(76, 175, 80, 0.08)',
borderColor: 'success.dark'
}
} : {}}
>
DNA
</Button>
<Button
variant="contained"
color="primary"
startIcon={<LanIcon />}
onClick={handleOpenModal}
>
Adapter
</Button>
</Box>
</Toolbar>
</AppBar>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1, height: '95vh', gap: 0.5, p: 1 }}>
<CompactSidebar
nodes={nodes}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId}
nodesUpdateTimestamp={nodesUpdateTimestamp}
/>
<Box
sx={{
minWidth: 550,
display: { xs: 'none', md: 'flex' },
flexDirection: 'column',
gap: 0.5,
}}
>
<NodeList
nodes={nodes}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId.bind(this)}
nodesUpdateTimestamp={nodesUpdateTimestamp}
/>
<NodeLogs/>
</Box>
<Box
display="flex"
flexDirection="column"
sx={{
gap: 0.5,
ml: { xs: 1, md: 0 },
flexGrow: 1,
}}
>
{selectedNodeId && (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, gap: 0.5 }}>
<NodeProperties
nodeId={selectedNodeId}
nodes={nodes}
multiNodeEditorEnable={multiNodeEditorEnable}
setMultiNodeEditorEnable={setMultiNodeEditorEnable.bind(this)}
nodesUpdateTimestamp={nodesUpdateTimestamp}
/>
<NodeParam
nodeId={selectedNodeId}
nodes={nodes}
multiNodeEditorEnable={multiNodeEditorEnable}
/>
</Box>
)}
{selectedNodeId === null && (
<About />
)}
</Box>
</Box>
<ConnectionSettingsModal
open={modalOpen}
onClose={handleCloseModal}
onConnectionStatusChange={handleConnectionStatusChange}
showMessage={showMessage.bind(this)}
selectedBus={selectedBus}
onBusChange={handleBusChange}
/>
<DNAServerModal
open={dnaModalOpen}
onClose={handleCloseDnaModal}
showMessage={showMessage.bind(this)}
/>
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setSnackbarOpen(false)} severity={snackbarSeverity}>
{snackbarMessage}
</Alert>
</Snackbar>
</ThemeProvider>
);
};
export default App;
+358
View File
@@ -0,0 +1,358 @@
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;
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import { ThemeProvider } from '@mui/material';
import BusMonitor from './BusMonitor';
import theme from './theme';
import './css/panel.css';
const BusMonitorWindow = () => {
return (
<ThemeProvider theme={theme}>
<BusMonitor />
</ThemeProvider>
);
};
export default BusMonitorWindow;
+193
View File
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import { Box, Badge, Tooltip, CircularProgress, Typography } from '@mui/material';
import NotificationsIcon from '@mui/icons-material/Notifications';
const CompactSidebar = ({
nodes,
selectedNodeId,
setSelectedNodeId
}) => {
const [logCounts, setLogCounts] = useState({});
useEffect(() => {
// Function to get log counts from the window.localNode events
const updateLogCounts = () => {
const counts = {};
// Initialize counts for all nodes to 0
Object.keys(nodes).forEach(nodeId => {
counts[nodeId] = 0;
});
try {
// Access logs if they're stored somewhere else in the app
// Option 1: If logs are stored in a global state/context
if (window.dronecanLogs && Array.isArray(window.dronecanLogs)) {
window.dronecanLogs.forEach(log => {
if (log.id) {
counts[log.id] = (counts[log.id] || 0) + 1;
}
});
}
// Option 2: Listen for log events and maintain our own count
// This is already set up in the useEffect for event listening below
} catch (error) {
console.error("Error accessing logs:", error);
}
setLogCounts(counts);
};
// Create log listener and counter
let lastLogCounts = {};
const handleLog = (transfer) => {
const sourceNodeId = transfer.sourceNodeId;
if (sourceNodeId) {
// Update count for this node
lastLogCounts[sourceNodeId] = (lastLogCounts[sourceNodeId] || 0) + 1;
setLogCounts({...lastLogCounts});
}
};
// Listen for log messages
const localNode = window.localNode;
if (localNode) {
localNode.on('uavcan.protocol.debug.LogMessage', handleLog);
}
// Initial update
updateLogCounts();
return () => {
// Remove event listener on cleanup
if (localNode) {
localNode.off('uavcan.protocol.debug.LogMessage', handleLog);
}
};
}, [nodes]);
// Get color based on node mode (matching the same logic as NodeList)
const getModeColor = (mode) => {
switch (mode) {
case 'OPERATIONAL':
return '#f5f5f5'; // Light gray for operational (subtle)
case 'INITIALIZATION':
return '#ffb74d'; // Warning color
case 'MAINTENANCE':
return '#9c27b0'; // Secondary/purple
case 'SOFTWARE_UPDATE':
return '#4caf50'; // Success/green
case 'OFFLINE':
return '#f44336'; // Error/red
default:
return '#f44336'; // Default to error color
}
};
// Handle click on a node
const handleNodeClick = (nodeId) => {
if (nodeId === selectedNodeId) {
setSelectedNodeId(null);
} else {
setSelectedNodeId(Number(nodeId));
}
};
return (
<Box
sx={{
display: { xs: 'flex', md: 'none', alignItems: 'center' },
flexDirection: 'column',
width: '60px',
borderRight: '1px solid',
borderColor: 'divider',
p: 1,
gap: 1,
overflowY: 'auto',
height: '100%'
}}
>
<Typography
variant="caption"
sx={{
textAlign: 'center',
display: 'block',
mb: 0.5,
fontWeight: 'bold',
fontSize: '0.5rem'
}}
>
NODES
</Typography>
{Object.keys(nodes).length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<CircularProgress size={24} />
</Box>
) : (
Object.keys(nodes).map((nodeId) => {
const node = nodes[nodeId];
const mode = node.status.getConstant('mode');
const logCount = logCounts[nodeId] || 0;
return (
<Tooltip
key={nodeId}
title={`${node.name || 'Unknown'} (${mode})`}
placement="right"
>
<Box
sx={{
position: 'relative',
display: 'flex',
width: '30px',
height: '30px',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 1,
border: '1px solid',
borderColor: selectedNodeId === Number(nodeId) ? 'primary.main' : 'divider',
backgroundColor: 'background.paper',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'action.hover',
}
}}
onClick={() => handleNodeClick(Number(nodeId))}
>
{/* Make NID larger and more prominent */}
<Typography
variant='caption'
sx={{
color: getModeColor(mode),
}}
>
{nodeId}
</Typography>
{logCount > 0 && (
<Badge
badgeContent={logCount > 99 ? '99+' : logCount}
color="error"
size="small"
sx={{
position: 'absolute',
top: -3,
right: -3
}}
>
<NotificationsIcon sx={{ fontSize: 14 }} />
</Badge>
)}
</Box>
</Tooltip>
);
})
)}
</Box>
);
};
export default CompactSidebar;
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
const ConfirmRestartModal = ({ open, onClose, onConfirm }) => {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Confirm Restart</DialogTitle>
<DialogContent>
<Typography variant="body2">
Are you sure you want to restart the node?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button onClick={onConfirm} color="primary">
Confirm
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmRestartModal;
+74
View File
@@ -0,0 +1,74 @@
import React, { useEffect, useState, useRef } from 'react';
import { Box, Typography } from '@mui/material';
import CircleIcon from '@mui/icons-material/Circle';
const ConnectionIndicators = ({ isConnected, mavlinkSession, localNode }) => {
const [txActive, setTxActive] = useState(false);
const [rxActive, setRxActive] = useState(false);
const txTimeout = useRef(null);
const rxTimeout = useRef(null);
useEffect(() => {
const handleFrameSend = () => {
setTxActive(true);
clearTimeout(txTimeout.current);
txTimeout.current = setTimeout(() => {
setTxActive(false);
}, 100); // Blink for 200ms
};
const handleFrameReceive = () => {
setRxActive(true);
clearTimeout(rxTimeout.current);
rxTimeout.current = setTimeout(() => {
setRxActive(false);
}, 100); // Blink for 200ms
};
if (mavlinkSession) {
mavlinkSession.on('mav-tx', handleFrameSend);
mavlinkSession.on('mav-rx', handleFrameReceive);
}
return () => {
if (mavlinkSession) {
mavlinkSession.removeListener('mav-tx', handleFrameSend);
mavlinkSession.removeListener('mav-rx', handleFrameReceive);
}
clearTimeout(txTimeout.current);
clearTimeout(rxTimeout.current);
};
}, [localNode, mavlinkSession]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mr: 5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', opacity: isConnected ? 1 : 0.3 }}>
<CircleIcon
fontSize="small"
sx={{
color: txActive && isConnected ? '#4caf50' : '#7e7e7e',
width: '12px',
height: '12px',
transition: 'color 0.1s ease'
}}
/>
<Typography variant="caption" sx={{ ml: 0.5, color: 'text.secondary' }}>TX</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', opacity: isConnected ? 1 : 0.3 }}>
<CircleIcon
fontSize="small"
sx={{
color: rxActive && isConnected ? '#2196f3' : '#7e7e7e',
width: '12px',
height: '12px',
transition: 'color 0.1s ease'
}}
/>
<Typography variant="caption" sx={{ ml: 0.5, color: 'text.secondary' }}>RX</Typography>
</Box>
</Box>
);
};
export default ConnectionIndicators;
+719
View File
@@ -0,0 +1,719 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, FormControl, InputLabel, Select, MenuItem,
Typography, Box, Divider, RadioGroup, FormControlLabel, Radio, Chip,
TextField, Tabs, Tab, Paper, IconButton
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import UsbIcon from '@mui/icons-material/Usb';
import CloseIcon from '@mui/icons-material/Close';
import WebSerial from './web_serial';
// Add this constant at the top of your file, outside the component
const USB_DEVICE_NAMES = {
// Ardupilot/PX4/Flight Controllers
'1209:5738': 'MAVCAN USB',
'1209:5740': 'ArduPilot',
'1209:5741': 'ArduPilot',
'0483:5740': 'STM32 Virtual COMPort',
// FTDI Adapters
'0403:6001': 'FTDI FT232R USB-Serial',
'0403:6010': 'FTDI FT2232 Dual USB-Serial',
'0403:6011': 'FTDI FT4232 Quad USB-Serial',
'0403:6014': 'FTDI FT232H Single USB-Serial',
// Silicon Labs Chips
'10c4:ea60': 'Silicon Labs CP210x USB-Serial',
'10c4:ea63': 'Silicon Labs CP2103 USB-Serial',
// CH340 Chips
'1a86:7523': 'CH340 USB-Serial',
'1a86:5523': 'CH341 USB-Serial',
};
// Add these constants at the top of your file
const BAUD_RATES = [
9600,
19200,
38400,
57600,
74880,
115200,
230400,
460800,
500000,
921600,
1000000,
1500000,
2000000
];
const DEFAULT_BAUD_RATE = 115200;
// Add default WebSocket settings
const DEFAULT_WS_HOST = '127.0.0.1';
const DEFAULT_WS_PORT = '5555';
// Connection types enum
const CONNECTION_TYPES = {
SERIAL: 'serial',
WEBSOCKET: 'websocket'
};
// Add this constant inside the ConnectionSettingsModal.js file, outside the component
const INTERFACE_BUS_LIST = [0, 1]; //BUS 1, BUS 2
// Update the state management for connection tracking
const ConnectionSettingsModal = ({
open,
onClose,
onConnectionStatusChange,
showMessage, // Use this prop for error messages
selectedBus, // New prop
onBusChange // New prop
}) => {
// Port and connection management
const [ports, setPorts] = useState([]);
const [selectedPort, setSelectedPort] = useState(null);
const [baudRate, setBaudRate] = useState(DEFAULT_BAUD_RATE);
const [wsHost, setWsHost] = useState(DEFAULT_WS_HOST);
const [wsPort, setWsPort] = useState(DEFAULT_WS_PORT);
// Move bus state management into this component
// const [selectedBus, setSelectedBus] = useState(0);
// Track active connection
const [activeConnection, setActiveConnection] = useState(null); // null, 'serial', or 'websocket'
// Add these state variables after your other state declarations
const [ipError, setIpError] = useState('');
const [portError, setPortError] = useState('');
// Add nodeId state to use the prop value
const [nodeId, setNodeId] = useState(127);
// Add state for the forwarding interval
const [forwardingInterval, setForwardingInterval] = useState(null);
// Add this state variable to track connection attempts in progress
const [connectionInProgress, setConnectionInProgress] = useState(false);
// Create a function to identify and index duplicate devices
const getPortDisplayName = (port, allPorts) => {
if (!port) return "No port selected";
// Try to extract the most user-friendly name possible
if (port.info && port.info.product) {
return port.info.product;
}
if (port.info && port.info.manufacturer) {
return `${port.info.manufacturer} device`;
}
if (port.info && port.info.serialNumber) {
return `Device (S/N: ${port.info.serialNumber})`;
}
if (port.getInfo) {
try {
const info = port.getInfo();
// Format vendor/product IDs as hex with leading zeros
const vendorId = info.usbVendorId ? info.usbVendorId.toString(16).padStart(4, '0') : '';
const productId = info.usbProductId ? info.usbProductId.toString(16).padStart(4, '0') : '';
// Look up device by VID:PID
if (vendorId && productId) {
const deviceKey = `${vendorId}:${productId}`;
const deviceName = USB_DEVICE_NAMES[deviceKey] || `USB Device ${vendorId ? '0x'+vendorId : 'N/A'}:${productId ? '0x'+productId : 'N/A'}`;
// Check if there are multiple devices with the same VID:PID
if (allPorts) {
// Create a list of all ports with this VID:PID
const sameDevicePorts = allPorts.filter(p => {
try {
const pInfo = p.getInfo && p.getInfo();
if (pInfo && pInfo.usbVendorId && pInfo.usbProductId) {
const pVendorId = pInfo.usbVendorId.toString(16).padStart(4, '0');
const pProductId = pInfo.usbProductId.toString(16).padStart(4, '0');
return pVendorId === vendorId && pProductId === productId;
}
return false;
} catch (e) {
return false;
}
});
// If there are multiple devices with the same VID:PID, add an index
if (sameDevicePorts.length > 1) {
const index = sameDevicePorts.indexOf(port) + 1;
return `${deviceName} (#${index})`;
}
}
return deviceName;
}
return `USB Device ${vendorId ? '0x'+vendorId : 'N/A'}:${productId ? '0x'+productId : 'N/A'}`;
} catch (e) {
// getInfo() might fail
}
}
// Rest of the function remains the same...
// Last resort: try to get some kind of identifier from the port object
try {
if (typeof port === 'object') {
// Try to find any identifying property
const keys = Object.keys(port);
for (const key of ['id', 'deviceId', 'path', 'name']) {
if (port[key]) {
return `Port ${key}: ${port[key]}`;
}
}
return `Port ${keys.length > 0 ? keys[0] + ': ' + String(port[keys[0]]).substring(0, 30) : 'object'}`;
}
} catch (e) {
// Object inspection might fail
}
// Fallback for ports without specific info
return "Serial Port";
};
// Moved from App.js - Lists available ports
const listPorts = async () => {
try {
const availablePorts = await WebSerial.listPorts();
window.availablePorts = availablePorts;
if (availablePorts.length > 0) {
setPorts(availablePorts);
if (!selectedPort) {
setSelectedPort(availablePorts[0]);
}
}
} catch (error) {
console.error('Error listing ports:', error);
}
};
// Moved from App.js - Requests a port from the browser
const handleRequestPort = async () => {
try {
const port = await navigator.serial.requestPort();
if (!ports.some(p => p === port)) {
setSelectedPort(port);
setPorts(prevPorts => [...prevPorts, port]);
} else {
setSelectedPort(port);
}
return true;
} catch (err) {
if (err.name === 'NotFoundError') {
console.log('User canceled port selection');
} else {
console.error('Error requesting port:', err);
}
return false;
}
};
// Handle Serial connection - update to show error messages
const handleSerialConnect = async () => {
try {
if (activeConnection === 'serial') {
// Disconnect using close()
window.mavlinkSession.close();
// Update the UI regardless of connection state
setActiveConnection(null);
onConnectionStatusChange(false);
showMessage('Serial connection closed', 'info');
// Clear the forwarding interval
if (forwardingInterval) {
clearInterval(forwardingInterval);
setForwardingInterval(null);
}
} else {
// Connect via serial - set in progress state
setConnectionInProgress(true);
const port = ports.find(p => p === selectedPort);
if (port) {
try {
// Disconnect any existing connection first
if (activeConnection) {
window.mavlinkSession.close();
// Clear any existing interval
if (forwardingInterval) {
clearInterval(forwardingInterval);
}
}
window.mavlinkSession.initWebSerialConnection(port, baudRate);
window.mavlinkSession.addWebSerialOpenHandler(() => {
// Set Node ID and Bus for the local node
window.localNode.setNodeId(parseInt(nodeId, 10));
window.localNode.setBus(selectedBus);
// Start the mavlinkCanForward interval
const intervalId = setInterval(() => {
if (window.mavlinkSession) {
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
}
}, 1000);
setForwardingInterval(intervalId);
setActiveConnection('serial');
setConnectionInProgress(false);
onConnectionStatusChange(true);
showMessage('Serial connection established', 'success');
})
window.mavlinkSession.addWebSerialErrorHandler((error) => {
console.error('Serial connection error:', error);
showMessage(`Serial connection failed: ${error.message || 'Could not connect to port'}`, 'error');
// Reset in-progress state on error
setConnectionInProgress(false);
});
window.mavlinkSession.webSerialConnect();
} catch (error) {
console.error('Serial connection error:', error);
showMessage(`Serial connection failed: ${error.message || 'Could not connect to port'}`, 'error');
// Reset in-progress state on error
setConnectionInProgress(false);
}
} else {
// Reset in-progress state if no port selected
setConnectionInProgress(false);
}
}
} catch (error) {
console.error('Error with serial connection:', error);
showMessage(`Serial error: ${error.message || 'Unknown error'}`, 'error');
// Reset in-progress state on any error
setConnectionInProgress(false);
}
};
// Add this validation function
const validateIpAddress = (input) => {
// Check if empty
if (!input) {
return 'IP address is required';
}
// Allow "localhost"
if (input === 'localhost') {
return '';
}
// Check for IPv4 format
if (input.includes('.')) {
const octets = input.split('.');
// An IPv4 address must have exactly 4 octets
if (octets.length !== 4) {
return 'Invalid IPv4 format';
}
// Each octet must be a number between 0 and 255
for (const octet of octets) {
const num = parseInt(octet, 10);
if (isNaN(num) || num < 0 || num > 255 || octet !== num.toString()) {
return 'Each part must be a number between 0-255';
}
}
// Valid IPv4
return '';
}
// Check for hostname format
const hostnamePattern = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])(\.[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])*$/;
if (hostnamePattern.test(input)) {
return '';
}
return 'Invalid IP address or hostname';
};
const validatePort = (input) => {
if (!input) {
return 'Port is required';
}
const port = parseInt(input, 10);
if (isNaN(port) || port < 1 || port > 65535) {
return 'Port must be between 1-65535';
}
return '';
};
// Add validation for nodeId
const validateNodeId = (value) => {
const id = parseInt(value, 10);
return (isNaN(id) || id < 1 || id > 127) ? 'Node ID must be between 1-127' : '';
};
// Update handler to propagate changes to parent
const handleNodeIdChange = (e) => {
const value = e.target.value;
// Only allow numeric values
if (/^\d*$/.test(value)) {
setNodeId(value);
if (setNodeId) {
setNodeId(parseInt(value, 10));
}
}
};
// Update the ws host/port change handlers
const handleWsHostChange = (e) => {
const value = e.target.value;
setWsHost(value);
setIpError(validateIpAddress(value));
};
const handleWsPortChange = (e) => {
const value = e.target.value;
setWsPort(value);
setPortError(validatePort(value));
};
// Update the WebSocket connection handler
const handleWebSocketConnect = async () => {
try {
if (activeConnection === 'websocket') {
// Force close the connection
window.mavlinkSession.close();
// Always update the UI state regardless of actual connection state
setActiveConnection(null);
onConnectionStatusChange(false);
showMessage('WebSocket connection closed', 'info');
// Clear the forwarding interval
if (forwardingInterval) {
clearInterval(forwardingInterval);
setForwardingInterval(null);
}
} else {
// Set in-progress state before attempting to connect
setConnectionInProgress(true);
// Validate both fields before connecting
const hostError = validateIpAddress(wsHost);
const portError = validatePort(wsPort);
setIpError(hostError);
setPortError(portError);
// Only connect if both validations pass
if (!hostError && !portError) {
if (activeConnection) {
window.mavlinkSession.close();
// Clear any existing interval
if (forwardingInterval) {
clearInterval(forwardingInterval);
}
}
// Use window.mavlinkSession consistently
window.mavlinkSession.initWebSocketConnection(wsHost, parseInt(wsPort, 10));
window.mavlinkSession.addWebSocketOpenHandler(() => {
console.log('WebSocket connection open');
// Set Node ID and Bus for the local node
window.localNode.setNodeId(parseInt(nodeId, 10));
window.localNode.setBus(selectedBus);
// Start the mavlinkCanForward interval
const intervalId = setInterval(() => {
if (window.mavlinkSession) {
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
}
}, 1000);
setForwardingInterval(intervalId);
setActiveConnection('websocket');
onConnectionStatusChange(true);
setConnectionInProgress(false);
showMessage('WebSocket connection established', 'success');
});
window.mavlinkSession.addWebSocketErrorHandler((error) => {
console.error('WebSocket error:', error);
// Clear any existing interval
if (forwardingInterval) {
clearInterval(forwardingInterval);
setForwardingInterval(null);
}
setActiveConnection(null);
onConnectionStatusChange(false);
// Reset in-progress state on error
setConnectionInProgress(false);
// Show error message using App's showMessage function
let errorMsg = 'Connection failed';
if (error && error.message) {
errorMsg = `Connection failed: ${error.message}`;
} else if (typeof error === 'string') {
errorMsg = `Connection failed: ${error}`;
}
showMessage(errorMsg, 'error');
});
window.mavlinkSession.webSocketConnect();
} else {
// If validation fails, reset the in-progress state
setConnectionInProgress(false);
}
}
} catch (error) {
console.error('Error with WebSocket connection:', error);
showMessage(`WebSocket error: ${error.message || 'Unknown error'}`, 'error');
// Reset in-progress state on any error
setConnectionInProgress(false);
}
};
// Load ports on component mount
useEffect(() => {
if (open) {
listPorts();
}
}, [open]);
// Add cleanup effect to clear the interval when component unmounts
useEffect(() => {
return () => {
if (forwardingInterval) {
clearInterval(forwardingInterval);
}
};
}, [forwardingInterval]);
// Update the layout to column direction for connection options
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="h6">Adapter Settings</Typography>
<Box display="flex" alignItems="center" gap={1}>
{activeConnection && (
<Chip
label={`Connected via ${activeConnection}`}
color="success"
size="small"
variant="outlined"
/>
)}
<IconButton
aria-label="close"
onClick={onClose}
size="small"
sx={{
ml: 1,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
</Box>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Connection Options - Column layout */}
<Box display="flex" flexDirection="column" gap={3}>
{/* Serial Connection Panel */}
<Paper sx={{
p: 1.5, // Reduce padding
width: '100%',
bgcolor: activeConnection === 'serial' ? 'rgba(0, 200, 83, 0.1)' : 'inherit'
}}>
<Typography variant="subtitle1" gutterBottom>Serial Connection</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> {/* Reduce gap */}
{/* Port and Baud Selection in same row */}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{/* Port Selection - takes more space */}
<Box sx={{ flex: 3 }}>
<FormControl fullWidth size="small" disabled={activeConnection !== null}>
<InputLabel>Port</InputLabel>
<Select
value={selectedPort || ''}
onChange={(e) => setSelectedPort(e.target.value)}
label="Port"
>
{ports.length === 0 ? (
<MenuItem value="" disabled>No ports available</MenuItem>
) : (
ports.map((port, index) => (
<MenuItem key={index} value={port}>
{getPortDisplayName(port, ports)}
</MenuItem>
))
)}
</Select>
</FormControl>
</Box>
{/* Baud Rate Selection - takes less space */}
<Box sx={{ flex: 1 }}>
<FormControl fullWidth size="small" disabled={activeConnection !== null}>
<InputLabel>Baud Rate</InputLabel>
<Select
value={baudRate}
onChange={(e) => setBaudRate(e.target.value)}
label="Baud Rate"
>
{BAUD_RATES.map((rate) => (
<MenuItem key={rate} value={rate}>
{rate}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Box>
{/* Port action buttons */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={listPorts}
disabled={activeConnection !== null}
size="small"
>
Refresh
</Button>
<Button
variant="outlined"
startIcon={<UsbIcon />}
onClick={handleRequestPort}
disabled={activeConnection !== null}
size="small"
>
Request
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button
onClick={handleSerialConnect}
color={activeConnection === 'serial' ? "error" : "primary"}
variant="contained"
disabled={
connectionInProgress || // Disable when connection attempt is in progress
!selectedPort ||
(activeConnection !== null && activeConnection !== 'serial')
}
size="small"
>
{activeConnection === 'serial' ? 'Disconnect' :
connectionInProgress && !activeConnection ? 'Connecting...' : 'Connect'}
</Button>
</Box>
</Box>
</Paper>
{/* WebSocket Connection Panel */}
<Paper sx={{
p: 1.5, // Reduce padding
width: '100%',
bgcolor: activeConnection === 'websocket' ? 'rgba(0, 200, 83, 0.1)' : 'inherit'
}}>
<Typography variant="subtitle1" gutterBottom>WebSocket Connection</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> {/* Reduce gap */}
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Host/IP Address"
value={wsHost}
onChange={handleWsHostChange}
disabled={activeConnection !== null}
size="small"
sx={{ flex: 3 }}
error={!!ipError}
helperText={ipError}
/>
<TextField
label="Port"
value={wsPort}
onChange={handleWsPortChange}
type="number"
disabled={activeConnection !== null}
size="small"
sx={{ flex: 1 }}
error={!!portError}
helperText={portError}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={handleWebSocketConnect}
color={activeConnection === 'websocket' ? "error" : "primary"}
variant="contained"
disabled={
connectionInProgress || // Disable when connection attempt is in progress
!!ipError ||
!!portError ||
!wsHost ||
!wsPort ||
(activeConnection !== null && activeConnection !== 'websocket')
}
size="small"
>
{activeConnection === 'websocket' ? 'Disconnect' :
connectionInProgress && !activeConnection ? 'Connecting...' : 'Connect'}
</Button>
</Box>
</Box>
</Paper>
</Box>
<Divider />
{/* Bus Selection */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<TextField
label="Node ID"
value={nodeId}
onChange={handleNodeIdChange}
disabled={activeConnection !== null}
size="small"
type="number"
InputProps={{
inputProps: { min: 1, max: 127 }
}}
sx={{ width: 100 }}
error={validateNodeId(nodeId) !== ''}
helperText={validateNodeId(nodeId)}
/>
</Box>
</Box>
</Box>
</DialogContent>
</Dialog>
);
};
export default ConnectionSettingsModal;
+421
View File
@@ -0,0 +1,421 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, Button, Switch, FormControlLabel, TextField,
Paper,
IconButton, Divider, Tooltip, Chip,
Dialog, DialogTitle, DialogContent, DialogActions,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import RefreshIcon from '@mui/icons-material/Refresh';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StopIcon from '@mui/icons-material/Stop';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import CloseIcon from '@mui/icons-material/Close';
import DynamicNodeIdServer from './services/DynamicNodeIdServer';
const DNAServerModal = ({ open, onClose, showMessage }) => {
const [serverEnabled, setServerEnabled] = useState(false);
const [minNodeId, setMinNodeId] = useState(1);
const [maxNodeId, setMaxNodeId] = useState(125);
const [persistAllocations, setPersistAllocations] = useState(true);
const [allocatedNodes, setAllocatedNodes] = useState([]);
const [server, setServer] = useState(null);
const [operationInProgress, setOperationInProgress] = useState(false);
const [refreshInterval, setRefreshInterval] = useState(null);
const handleAllocationUpdate = () => {
console.log("Allocation update detected, refreshing list");
fetchCurrentAllocations();
};
useEffect(() => {
if (!window.dnaServer && window.localNode) {
window.dnaServer = new DynamicNodeIdServer(window.localNode);
}
setServer(window.dnaServer);
// Check if server is already running
if (window.dnaServer?.getStatus().isActive) {
setServerEnabled(true);
}
// Add event listener for allocation updates
if (window.dnaServer) {
window.dnaServer.addEventListener('allocationUpdated', handleAllocationUpdate);
}
return () => {
// Remove event listener when component unmounts
if (window.dnaServer) {
window.dnaServer.removeEventListener('allocationUpdated', handleAllocationUpdate);
}
};
}, []);
const fetchCurrentAllocations = () => {
if (!server) return;
const allocations = server.getAllocations();
setAllocatedNodes(allocations.map(allocation => ({
nodeId: allocation.nodeId,
uniqueId: allocation.uniqueId
})));
};
const handleToggleServer = () => {
if (!server || operationInProgress) return;
setOperationInProgress(true);
try {
if (!serverEnabled) {
// Start the server
const success = server.start(minNodeId, maxNodeId);
if (success) {
setServerEnabled(true);
fetchCurrentAllocations(); // Refresh the list
// Set up refresh interval when server is active
const interval = setInterval(() => {
if (server.getStatus().isActive) {
fetchCurrentAllocations();
}
}, 1000); // Check every 5 seconds
setRefreshInterval(interval);
showMessage("DNA server started successfully", "success");
} else {
showMessage("Failed to start DNA server", "error");
}
} else {
// Stop the server
server.stop();
setServerEnabled(false);
// Clear refresh interval when server is stopped
if (refreshInterval) {
clearInterval(refreshInterval);
setRefreshInterval(null);
}
showMessage("DNA server stopped", "info");
}
} catch (error) {
console.error("Error toggling DNA server:", error);
showMessage(`Error: ${error.message}`, "error");
} finally {
setOperationInProgress(false);
}
};
const handleMinNodeIdChange = (e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 125) {
setMinNodeId(value);
}
};
const handleMaxNodeIdChange = (e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 125) {
setMaxNodeId(value);
}
};
const validateNodeIdRange = () => {
return minNodeId >= maxNodeId ? "Min ID must be less than Max ID" : "";
};
const handleDeleteAllocation = (nodeId) => {
if (!server) return;
const success = server.deleteAllocation(nodeId);
if (success) {
fetchCurrentAllocations();
showMessage(`Node ID ${nodeId} allocation revoked`, "info");
} else {
showMessage(`Failed to revoke allocation for node ID ${nodeId}`, "error");
}
};
const handleRefreshAllocations = () => {
fetchCurrentAllocations();
showMessage("Allocations refreshed", "info");
};
// Modify the modal close handler to not stop the server
const handleClose = () => {
// Don't stop the server when modal is closed, just pass the status back
onClose(serverEnabled);
};
useEffect(() => {
if (open && server) {
fetchCurrentAllocations();
}
}, [open, server]);
// Add a useEffect hook to properly initialize the component when reopened
useEffect(() => {
if (open && server) {
// If opening the modal and server is already available
// Check if it's running and update the UI accordingly
const status = server.getStatus();
setServerEnabled(status.isActive);
// If server is active, refresh the allocation list
if (status.isActive) {
fetchCurrentAllocations();
// Set up refresh interval if not already set
if (!refreshInterval) {
const interval = setInterval(() => {
if (server.getStatus().isActive) {
fetchCurrentAllocations();
}
}, 1000);
setRefreshInterval(interval);
}
}
}
}, [open, server]);
// Add cleanup only for component unmount, not modal close
useEffect(() => {
return () => {
// Only clean up on component unmount, not on modal close
if (refreshInterval) {
clearInterval(refreshInterval);
}
};
}, []);
// Add this helper function just before the return statement in your component
const formatUniqueId = (uniqueId) => {
if (!uniqueId) return '';
// Add a space after every 4 characters (16 bits/2 bytes)
return uniqueId.match(/.{1,2}/g)?.join(' ') || uniqueId;
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm" // Changed from "md" to "sm" for smaller width
fullWidth
>
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="h6">Dynamic Node ID Allocation Server</Typography>
<Box display="flex" alignItems="center" gap={1}>
{serverEnabled && (
<Chip
label="Server Active"
color="success"
size="small"
variant="outlined"
/>
)}
<IconButton
aria-label="close"
onClick={handleClose}
size="small"
sx={{
ml: 1,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
</Box>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Server Control Panel */}
<Paper sx={{
p: 1.5,
width: '100%',
bgcolor: serverEnabled ? 'rgba(0, 200, 83, 0.1)' : 'inherit'
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle1">Server Control</Typography>
<Button
variant="contained"
startIcon={serverEnabled ? <StopIcon /> : <PlayArrowIcon />}
onClick={handleToggleServer}
color={serverEnabled ? "error" : "success"}
disabled={operationInProgress}
size="small"
>
{operationInProgress ? "Processing..." :
serverEnabled ? "Stop" : "Start"}
</Button>
</Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{/* Min Node ID */}
<Box sx={{ width: { xs: '100%', sm: '22%' }, minWidth: '100px' }}>
<TextField
label="Min Node ID"
type="number"
value={minNodeId}
onChange={handleMinNodeIdChange}
fullWidth
disabled={serverEnabled}
InputProps={{
inputProps: { min: 1, max: 125 }
}}
size="small"
error={minNodeId >= maxNodeId}
helperText={minNodeId >= maxNodeId ? "Must be < Max" : ""}
/>
</Box>
{/* Max Node ID */}
<Box sx={{ width: { xs: '100%', sm: '22%' }, minWidth: '100px' }}>
<TextField
label="Max Node ID"
type="number"
value={maxNodeId}
onChange={handleMaxNodeIdChange}
fullWidth
disabled={serverEnabled}
InputProps={{
inputProps: { min: 1, max: 125 }
}}
size="small"
error={minNodeId >= maxNodeId}
helperText={minNodeId >= maxNodeId ? "Must be > Min" : ""}
/>
</Box>
{/* Persist Allocations */}
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: { xs: '100%', sm: 'auto' },
flexGrow: 1
}}>
<FormControlLabel
control={
<Switch
checked={persistAllocations}
onChange={(e) => setPersistAllocations(e.target.checked)}
color="primary"
disabled={serverEnabled}
size="small"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 0.5 }}>Persist Allocations</Typography>
<Tooltip title="When enabled, node ID allocations are stored and restored when the server restarts">
<HelpOutlineIcon fontSize="small" />
</Tooltip>
</Box>
}
/>
</Box>
</Box>
</Paper>
{/* Allocated Nodes Section - without the title now */}
<Paper sx={{ p: 1.5, width: '100%' }}> {/* Removed mt: -1 as it's not needed anymore */}
{/* Add the title back inside the Paper */}
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1
}}>
<Typography
variant="body1" // Using body1 for smaller font size instead of subtitle1
sx={{ fontWeight: 500 }} // Adding some weight to make it still look like a title
>
Allocated Node IDs ({allocatedNodes.length})
</Typography>
<Tooltip title="Refresh allocation list">
<IconButton
onClick={handleRefreshAllocations}
size="small"
color="primary"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Rest of the Paper content remains the same */}
{allocatedNodes.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No node IDs allocated
</Typography>
</Box>
) : (
<TableContainer sx={{
maxHeight: '120px',
overflow: 'auto',
position: 'relative',
'& .MuiTableHead-root': {
position: 'sticky',
top: 0,
zIndex: 2,
backgroundColor: theme => theme.palette.background.paper
}
}}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>NID</TableCell>
<TableCell>UUID</TableCell>
<TableCell align="right" width="60px">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allocatedNodes.map((node) => (
<TableRow key={node.nodeId} hover>
<TableCell component="th" scope="row">
{node.nodeId}
</TableCell>
<TableCell>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
wordBreak: 'break-all'
}}
>
{formatUniqueId(node.uniqueId)}
</Typography>
</TableCell>
<TableCell align="right" padding="none" sx={{ pr: 1 }}>
<IconButton
onClick={() => handleDeleteAllocation(node.nodeId)}
size="small"
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
</Box>
</DialogContent>
</Dialog>
);
};
export default DNAServerModal;
+646
View File
@@ -0,0 +1,646 @@
import React, { useEffect, useState } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, TextField, Box, Checkbox, Typography,
Select, MenuItem, FormControl, InputLabel, Divider,
IconButton, Tooltip
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StopIcon from '@mui/icons-material/Stop'; // Add this import for the stop button
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import AM32_Rtttl from './am32_rtttl'; // Updated import to match class name
const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
// Add a new state for tracking whether a tune is currently playing
const [isPlaying, setIsPlaying] = useState(false);
// Add to your existing state variables
const [value, setValue] = useState(null);
const [previewTune, setPreviewTune] = useState(''); // For tune preview
const [selectedPreset, setSelectedPreset] = useState(""); // Move this here from renderRTTLEditor
const [errorMessage, setErrorMessage] = useState(''); // For validation error messages
const [isValid, setIsValid] = useState(true); // Add a new state variable to track validation status
const [paramName, setParamName] = useState(""); // Add paramName to the component state
useEffect(() => {
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return;
const param = localNode.nodeParams[nodeId][paramIndex];
const currentParamName = param.fields.name.toString();
// Set the param name in state so it's available to the whole component
setParamName(currentParamName);
const paramValueField = param.fields.value.msg.unionField;
// Different handling based on value type
let paramValue;
if (param.fields.value.msg.fields.string_value !== undefined) {
// For string values, use toString() directly
paramValue = paramValueField.toString();
} else {
// For other types (int, float, bool), use .value
paramValue = paramValueField.value;
}
// Special handling for STARTUP_TUNE parameter
if (currentParamName === "STARTUP_TUNE" && param.fields.value.msg.fields.string_value !== undefined) {
try {
// Get the binary string value
const binaryString = paramValue;
// Convert binary string to Uint8Array
const binaryData = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
binaryData[i] = binaryString.charCodeAt(i);
}
// Convert to RTTTL format
const rtttlString = AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune");
setValue(rtttlString);
} catch (err) {
console.error("Error converting binary data to RTTTL:", err);
setValue(""); // Set empty string on error
}
} else {
setValue(paramValue);
}
}, [nodeId, paramIndex]); // Add nodeId to dependencies
const handleSave = () => {
const localNode = window.localNode;
let valueToSave = value;
// If this is the STARTUP_TUNE parameter, convert RTTTL string to byte array
if (paramName === "STARTUP_TUNE") {
console.log("Converting RTTTL tune:", value);
try {
// Validate RTTTL string format
const rtttlValue = value || "";
const isValidFormat = rtttlValue && rtttlValue.includes(':') && rtttlValue.split(':').length === 3;
let result; // Declare result variable outside the if/else blocks
if (!isValidFormat) {
// Warn user but continue with a default tune
setErrorMessage('Warning: Invalid RTTTL format! Using a default empty tune instead.');
// Continue with a minimal valid RTTTL string
const tuneToParse = "Empty:d=4,o=5,b=120:";
result = AM32_Rtttl.to_am32_startup_melody(tuneToParse);
} else {
// Format is valid, proceed normally
setErrorMessage(''); // Clear any previous errors
result = AM32_Rtttl.to_am32_startup_melody(rtttlValue);
}
// Convert to binary string after we have the result
const binaryString = String.fromCharCode.apply(null, result.data);
valueToSave = binaryString;
// For debug purposes, show numeric values
console.log("Binary array values:", Array.from(result.data).slice(0, 30));
} catch (err) {
console.error("Error converting RTTTL to binary:", err);
setErrorMessage(`Error saving tune: ${err.message || 'Unknown error'}`);
// Provide an empty binary string (all zeros) as fallback
const emptyArray = new Uint8Array(128);
valueToSave = String.fromCharCode.apply(null, emptyArray);
}
}
localNode.setNodeParam(nodeId, paramIndex, valueToSave);
onClose();
};
// Modify handlePlayTune function to toggle play/stop
const handlePlayTune = () => {
// If already playing, stop the melody
if (isPlaying) {
AM32_Rtttl.stopMelody();
setIsPlaying(false);
return;
}
const tuneToPlay = value || '';
try {
// First validate that the tune has the basic RTTTL format (name:defaults:notes)
if (!tuneToPlay || !tuneToPlay.includes(':') || tuneToPlay.split(':').length !== 3) {
setErrorMessage('Invalid RTTTL format! Format should be: name:defaults:notes');
return;
}
// Clear any previous error when successful
setErrorMessage('');
// Stop any currently playing tune before starting a new one
AM32_Rtttl.stopMelody();
// Play the new tune
AM32_Rtttl.playMelody(tuneToPlay);
setPreviewTune(tuneToPlay);
setIsPlaying(true);
// Set up an event listener to detect when audio context is closed or ends
const estimatedDuration = estimateTuneDuration(tuneToPlay);
setTimeout(() => {
setIsPlaying(false);
}, estimatedDuration + 500); // Add a small buffer
} catch (err) {
console.error("Error playing tune:", err);
setErrorMessage(`Error playing tune: ${err.message || 'Unknown error'}`);
setIsPlaying(false);
}
};
// Add a helper function to estimate tune duration
const estimateTuneDuration = (rtttlString) => {
try {
if (!rtttlString) return 2000; // Default duration if no tune
const parts = rtttlString.split(':');
if (parts.length !== 3) return 5000; // Default if format is wrong
const defaults = parts[1];
// Extract BPM from defaults
const bpmMatch = /b=(\d+)/i.exec(defaults);
const bpm = bpmMatch ? parseInt(bpmMatch[1], 10) : 120;
// Count notes in the tune
const notes = parts[2].split(',');
const noteCount = notes.length;
// Rough calculation: (60000 / bpm) gives ms per beat, multiply by estimated beats
return Math.min((60000 / bpm) * noteCount * 1.5, 30000); // Cap at 30 seconds
} catch (e) {
console.warn('Error estimating tune duration:', e);
return 5000; // Default fallback
}
};
const handleSelectPreset = (presetValue) => {
setValue(presetValue);
};
// Handle apply preset - moved from renderRTTTLEditor
const handleApplyPreset = () => {
if (selectedPreset) {
setValue(selectedPreset);
setSelectedPreset(""); // Reset selection after applying
}
};
// Common RTTTL tunes
const rtttlPresets = {
"BlueJay": "bluejay:b=570,o=4,d=32:4b,p,4e5,p,4b,p,4f#5,2p,4e5,2b5,8b5",
"Nokia Tune": "Nokia:d=4,o=5,b=63:e6,d6,f#,g#,c#6,b,d,e,b,a,c#,e,a",
"Mario": "Mario:d=4,o=5,b=125:a,a,a,a,a#,c6,a,g,e,c,d,a#4,c",
"Star Wars": "StarWars:d=4,o=5,b=112:8f,8f,8f,2a#.,2f.6,8d#6,8d6,8c6,2a#.6,f.6,8d#6,8d6,8c6,2a#.6,f.6,8d#6,8d6,8d#6,2c6",
"Pacman": "Pacman:d=16,o=6,b=140:b5,b,f#,d#,8b,8d#,c,c7,g,f,8c7,8e,b5,b,f#,d#,8b,8d#,c,g,c7,g,8f,8c7",
"Indiana": "Indiana:d=4,o=5,b=250:e,8p,8f,8g,8p,1c6,8p.,d,8p,8e,1f,p.,g,8p,8a,8b,8p,1f6,p,a,8p,8b,2c6,2d6,2e6,e,8p,8f,8g,8p,1c6",
"Mission": "Mission:d=16,o=6,b=95:32d,32d#,32d,32d#,32d,32d#,32d,32d#,32d,32d,32d#,32e,32f,32f#,32g,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,a#,g,2d"
};
// Fix the validateRtttl function to handle non-string values
const validateRtttl = (rtttlString) => {
console.log("Validating RTTTL:", rtttlString);
// If we're not editing STARTUP_TUNE, don't validate with RTTTL
if (paramName !== "STARTUP_TUNE") {
return true;
}
// Convert to string if it's a number or other type
const stringValue = String(rtttlString || '');
// Empty strings are valid (to allow clearing the tune)
if (!stringValue) {
setErrorMessage('');
return true;
}
// Check for basic RTTTL format
const isValidFormat = stringValue.includes(':') && stringValue.split(':').length === 3;
if (!isValidFormat) {
setErrorMessage('Invalid RTTTL format! Format should be: name:defaults:notes');
return false;
}
// Additional validation could be added here
setErrorMessage(''); // Clear error if valid
return true;
};
// Similarly, update the handleValueChange function to ensure we're dealing with strings
const handleValueChange = (newValue) => {
// For RTTTL special handling - check if it's specifically "STARTUP_TUNE"
if (paramName === "STARTUP_TUNE") {
setValue(newValue);
// Convert to string before validation
setIsValid(validateRtttl(String(newValue)));
return;
}
// For all other parameters, don't try to validate with RTTTL
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param) {
setValue(newValue);
return;
}
// Rest of your existing numeric validation...
if (param && (
param.fields.value.msg.fields.integer_value !== undefined ||
param.fields.value.msg.fields.real_value !== undefined
)) {
// Skip validation for empty strings or non-numeric values during typing
if (newValue === '' || isNaN(parseFloat(newValue))) {
setValue(newValue);
return;
}
const numericValue = parseFloat(newValue);
const min = param.fields.min_value.msg &&
param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.min_value.msg.unionField.value : null;
const max = param.fields.max_value.msg &&
param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.max_value.msg.unionField.value : null;
// Validate against min/max if they exist
if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) {
setIsValid(false);
setErrorMessage(`Value must be between ${min !== null ? min : '-∞'} and ${max !== null ? max : '∞'}`);
} else {
setIsValid(true);
setErrorMessage('');
}
}
setValue(newValue);
};
// Add useEffect to validate parameter changes
useEffect(() => {
// Skip validation if value is null or undefined or paramName isn't set yet
if (value === null || value === undefined || !paramName) return;
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param) return;
// For RTTTL validation
if (paramName === "STARTUP_TUNE") {
setIsValid(validateRtttl(value));
return;
}
// For numeric validation
if (param.fields.value.msg.fields.integer_value !== undefined ||
param.fields.value.msg.fields.real_value !== undefined) {
// Skip validation for empty strings or non-numeric values
if (value === '' || isNaN(parseFloat(value))) {
setIsValid(false);
return;
}
const numericValue = parseFloat(value);
const min = param.fields.min_value.msg &&
param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.min_value.msg.unionField.value : null;
const max = param.fields.max_value.msg &&
param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.max_value.msg.unionField.value : null;
// Validate against min/max if they exist
if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) {
setIsValid(false);
setErrorMessage(`Value must be between ${min !== null ? min : '-∞'} and ${max !== null ? max : '∞'}`);
} else {
setIsValid(true);
setErrorMessage('');
}
} else {
// For boolean and string types, always valid
setIsValid(true);
setErrorMessage('');
}
}, [value, nodeId, paramIndex, paramName, localNode]);
// Update the RTTTL editor to remove the problematic helperText
const renderRTTTLEditor = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'flex-end' }}>
<FormControl fullWidth margin="dense">
<InputLabel id="rtttl-preset-label">Select Preset Tune</InputLabel>
<Select
labelId="rtttl-preset-label"
value={selectedPreset}
onChange={(e) => setSelectedPreset(e.target.value)}
>
<MenuItem value="" disabled>
<em>Choose a preset tune</em>
</MenuItem>
{Object.entries(rtttlPresets).map(([name, tune]) => (
<MenuItem key={name} value={tune}>
{name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
onClick={handleApplyPreset}
disabled={!selectedPreset}
sx={{ mb: 1 }}
>
Apply
</Button>
</Box>
<Box sx={{ position: 'relative' }}>
<TextField
label="RTTTL Tune"
value={value || ""}
onChange={(e) => handleValueChange(e.target.value)}
fullWidth
margin="dense"
multiline
rows={3}
placeholder="Format: name:d=duration,o=octave,b=bpm:notes"
error={!isValid && value !== ''}
// Remove helperText to avoid layout issues
/>
<Tooltip title={isPlaying ? "Stop tune" : "Play tune"}>
<IconButton
size="small"
color={isPlaying ? "secondary" : "primary"}
onClick={handlePlayTune}
disabled={!value}
sx={{
position: 'absolute',
bottom: '10px',
right: '10px',
width: '32px',
height: '32px'
}}
>
{isPlaying ? <StopIcon fontSize="small" /> : <PlayArrowIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Box>
{/* Add a simple instruction text below the field */}
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
Enter RTTTL format tune or select a preset
</Typography>
<Divider />
<Box sx={{ bgcolor: 'action.hover', p: 1, borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
RTTTL Format Guide
</Typography>
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" display="block"> d=duration (1=whole, 2=half, 4=quarter, 8=eighth, 16=16th note)</Typography>
<Typography variant="caption" display="block"> o=octave (4-7 where 5 is default)</Typography>
<Typography variant="caption" display="block"> b=tempo (beats per minute)</Typography>
<Typography variant="caption" display="block"> Notes are: c, c#, d, d#, e, f, f#, g, g#, a, a#, b or h</Typography>
<Typography variant="caption" display="block"> Example: Beep:d=4,o=5,b=120:c</Typography>
</Box>
</Box>
</Box>
);
};
// Update the renderValueEditField function to fix layout issues and improve validation
const renderValueEditField = (min, max) => {
const param = localNode.nodeParams[nodeId][paramIndex];
let step;
// For boolean type
if (param.fields.value.msg.fields.boolean_value) {
return (
<Box sx={{ display: 'flex', alignItems: 'center'}}>
<Typography variant="body2" sx={{ mr: 2 }}>Enable/Disable:</Typography>
<Checkbox
checked={value === 1 || value === true}
onChange={(e) => setValue(e.target.checked ? 1 : 0)}
/>
</Box>
);
}
// For string type - return null for normal strings, STARTUP_TUNE is handled separately
if (param.fields.value.msg.fields.string_value) {
return null;
}
// For numeric types
if (param.fields.value.msg.fields.integer_value) {
step = 1;
} else if (param.fields.value.msg.fields.real_value) {
step = 0.01;
} else {
console.error('Unknown value kind');
}
// Check if value is outside limits for the error state
const numericValue = parseFloat(value);
const isOutOfBounds =
(min !== "" && !isNaN(min) && numericValue < min) ||
(max !== "" && !isNaN(max) && numericValue > max);
return (
<TextField
label="New Value"
value={value}
type="number"
inputProps={{
step: step
}}
error={isOutOfBounds}
onChange={(e) => handleValueChange(e.target.value)}
fullWidth
margin="dense"
helperText={isOutOfBounds ?
`Value must be between ${min !== "" ? min : '-∞'} and ${max !== "" ? max : '∞'}` :
null
}
/>
);
};
if (value === null) return null;
const localNode = window.localNode;
let param = localNode.nodeParams[nodeId][paramIndex];
if (!param) return null;
if (!param.fields) return null;
let paramValueField = param.fields.value.msg.unionField;
let paramMinValue = "";
if (param.fields.min_value.msg && param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramMinValue = param.fields.min_value.msg.unionField.value;
}
let paramMaxValue = "";
if (param.fields.max_value.msg && param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramMaxValue = param.fields.max_value.msg.unionField.value;
}
let paramDefaultValue = "";
if (param.fields.default_value.msg && param.fields.default_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramDefaultValue = param.fields.default_value.msg.unionField.value;
}
const isBoolean = param.fields.value.msg.fields.boolean_value !== undefined;
const isString = param.fields.value.msg.fields.string_value !== undefined;
const isRTTTLEditor = paramName === "STARTUP_TUNE";
const renderParamNameField = (name) => (
<TextField
label="Parameter Name"
value={name || "Unknown"}
InputProps={{
readOnly: true,
}}
size="small"
fullWidth
variant="outlined"
margin="dense"
/>
);
// Replace the renderInfoField function with this enhanced version that handles RTTTL differently
const renderInfoField = (label, value, isRtttl = false) => (
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
{isRtttl ? (
<TextField
variant="outlined"
size="small"
fullWidth
multiline
rows={2}
value={value || ""}
InputProps={{
readOnly: true,
}}
sx={{
mt: 0.5,
'& .MuiOutlinedInput-root': {
backgroundColor: 'action.hover'
}
}}
/>
) : (
<Typography variant="body2">
{value !== undefined && value !== "" && value !== null ? value : "Unknown"}
</Typography>
)}
</Box>
);
return (
<Dialog
open={open}
onClose={onClose}
sx={{ '& .MuiDialog-paper': { minWidth: isRTTTLEditor ? '600px' : '400px' } }}
>
<DialogTitle>Edit Parameter</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'flex-end' }}>
{renderParamNameField(paramName)}
{!isString && !isRTTTLEditor && renderValueEditField(paramMinValue, paramMaxValue)}
</Box>
{isString && !isRTTTLEditor && (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<TextField
label="String Value"
value={value || ""}
onChange={(e) => setValue(e.target.value)}
fullWidth
margin="dense"
multiline={Boolean(value && typeof value === 'string' && value.length > 30)}
rows={(value && typeof value === 'string' && value.length > 30) ? 3 : 1}
/>
</Box>
)}
{isRTTTLEditor && renderRTTTLEditor()}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{paramName === "STARTUP_TUNE" && isString ? (
renderInfoField("Current RTTTL", (() => {
try {
// Get the binary string value
const binaryString = paramValueField.toString();
// Convert binary string to Uint8Array
const binaryData = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
binaryData[i] = binaryString.charCodeAt(i);
}
// Convert to RTTTL format
return AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune");
} catch (err) {
console.error("Error converting binary data to RTTTL:", err);
return "Error parsing melody data";
}
})(), true) // Pass true to indicate this is an RTTTL value
) : (
renderInfoField(
"Current Value",
isBoolean
? (paramValueField.value ? "True" : "False")
: isString
? paramValueField.toString()
: paramValueField.value
)
)}
{/* Only show default value when not STARTUP_TUNE */}
{paramName !== "STARTUP_TUNE" && renderInfoField("Default Value", isBoolean ? (paramDefaultValue ? "True" : "False") : paramDefaultValue)}
</Box>
{!isBoolean && !isString && !isRTTTLEditor && (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{renderInfoField("Min Value", paramMinValue)}
{renderInfoField("Max Value", paramMaxValue)}
</Box>
)}
{errorMessage && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="error">
{errorMessage}
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={!isValid}
>
Save
</Button>
</DialogActions>
</Dialog>
);
};
export default EditParamModal;
+514
View File
@@ -0,0 +1,514 @@
import React, { useState, useEffect } from 'react';
import { Paper, Box, Typography, Card, CardContent, Slider, TextField, AppBar, Toolbar, Button, IconButton, Checkbox, FormControlLabel } from '@mui/material';
import PanToolIcon from '@mui/icons-material/PanTool';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import dronecan from './dronecan';
const commandValueType = new dronecan.DSDL.uavcan_equipment_esc_RawCommand().fields.cmd.value_type;
const CMD_MAX = Number(commandValueType.value_range.max);
const CMD_MIN = Number(commandValueType.value_range.min);
const getScaledCommands = (thrustValues) => {
return thrustValues.map(val => {
if (val === 0) return 0;
if (val > 0) {
return Math.round((val / 100) * CMD_MAX);
}
return Math.round((val / 100) * Math.abs(CMD_MIN));
});
};
const EscPanel = () => {
const [escData, setEscData] = useState([]);
const [thrustValues, setThrustValues] = useState([]);
const [localInstances, setLocalInstances] = useState(4);
const [sendSafety, setSendSafety] = useState(false);
const [sendArming, setSendArming] = useState(false);
const [broadcastRate, setBroadcastRate] = useState(10); // Changed default to 10
const [isPaused, setIsPaused] = useState(false); // New state for pause toggle
// Add toggle pause function
const togglePause = () => {
setIsPaused(!isPaused);
console.log(`Broadcasting ${!isPaused ? 'paused' : 'resumed'}`);
};
// Add handler function for broadcast rate
const handleBroadcastRateChange = (e) => {
const newValue = parseInt(e.target.value) || 0;
// Reasonable limits for broadcast rate
const safeValue = Math.max(1, Math.min(1000, newValue));
setBroadcastRate(safeValue);
console.log('Broadcast rate changed to:', safeValue);
};
useEffect(() => {
const localNode = window.opener.localNode;
if (!localNode) return;
const handleEscData = (transfer) => {
const msg = transfer.payload;
const msgObj = msg.toObj();
// console.log('ESC data:', msgObj);
if (msgObj && typeof msgObj.esc_index === 'number' && msgObj.esc_index < localInstances) {
const newEscData = [...escData];
newEscData[msgObj.esc_index] = {
esc_index: msgObj.esc_index,
error_count: msgObj.error_count,
temperature: msgObj.temperature,
voltage: msgObj.voltage,
current: msgObj.current,
rpm: msgObj.rpm,
power_rating_pct: msgObj.power_rating_pct
};
setEscData(newEscData);
}
};
localNode.on('uavcan.equipment.esc.Status', handleEscData);
return () => {
localNode.off('uavcan.equipment.esc.Status', handleEscData);
};
}, [escData, localInstances]);
// Initialize thrust values array when instances change
useEffect(() => {
// Ensure instances is a valid positive number within reasonable limits
const safeInstances = Math.max(1, Math.min(32, parseInt(localInstances) || 1));
setThrustValues(Array(safeInstances).fill(0));
// Initialize ESC data array with empty data
const initialEscData = Array(safeInstances).fill(0).map((_, index) => ({
esc_index: index,
error_count: null,
temperature: null,
voltage: null,
current: null,
rpm: null,
power_rating_pct: null
}));
setEscData(initialEscData);
}, [localInstances]);
const handleThrustChange = (index, value) => {
const newThrustValues = [...thrustValues];
newThrustValues[index] = value;
setThrustValues(newThrustValues);
};
const handleThrustInputChange = (index, event) => {
let value = parseInt(event.target.value);
// Check bounds
if (isNaN(value)) value = 0;
value = Math.max(-100, Math.min(100, value));
handleThrustChange(index, value);
};
const handleInstanceChange = (e) => {
// Ensure we have a valid positive integer
const newValue = parseInt(e.target.value) || 8;
const newInstances = Math.max(1, Math.min(32, newValue));
setLocalInstances(newInstances);
console.log('ESC instances changed to:', newInstances);
};
// Define the stop all function
const handleStopAll = () => {
const newThrustValues = Array(thrustValues.length).fill(0);
setThrustValues(newThrustValues);
console.log('Stopping all ESCs');
};
// Define the stop one function
const handleStopOne = (index) => {
const newThrustValues = [...thrustValues];
newThrustValues[index] = 0;
setThrustValues(newThrustValues);
console.log(`Stopping ESC ${index + 1}`);
};
// First, create the worker once (outside of any specific effect)
useEffect(() => {
if (!window.EscPanelWorker) {
window.EscPanelWorker = new Worker(new URL('./workers/esc-command-worker.js', import.meta.url));
console.log('Created ESC command worker');
}
// Set up message handler for all command types
window.EscPanelWorker.onmessage = (event) => {
const localNode = window.opener?.localNode;
if (!localNode) return;
if (event.data.type === 'requestEscCommand') {
try {
if (thrustValues.length === 0) {
console.warn("Warning: thrustValues array is empty!");
return;
}
const scaledCommands = getScaledCommands(thrustValues);
localNode.sendUavcanEquipmentEscRawCommand(0, scaledCommands);
} catch (error) {
console.error('Error sending ESC commands:', error);
}
} else if (event.data.type === 'requestSafetyCommand') {
try {
localNode.sendArdupilotIndicationSafetyState(0, 255);
// console.log('Sent safety message via worker');
} catch (error) {
console.error('Error sending safety message:', error);
}
} else if (event.data.type === 'requestArmingCommand') {
try {
localNode.sendUavcanEquipmentSafetyArmingStatus(0, 255);
// console.log('Sent arming message via worker');
} catch (error) {
console.error('Error sending arming message:', error);
}
}
};
return () => {
// No need to terminate the worker here since it's shared
};
}, [thrustValues, sendArming, sendSafety]); // Update when thrust values change
// ESC commands - managed by pause state
useEffect(() => {
if (!window.EscPanelWorker) return;
if (!isPaused) {
console.log(`Starting ESC commands with rate: ${broadcastRate}Hz`);
window.EscPanelWorker.postMessage({
type: 'esc',
command: 'start',
rate: broadcastRate
});
} else {
console.log('Pausing ESC commands');
window.EscPanelWorker.postMessage({
type: 'esc',
command: 'stop'
});
}
return () => {
window.EscPanelWorker.postMessage({
type: 'esc',
command: 'stop'
});
};
}, [isPaused, broadcastRate]);
// Safety commands - independent of pause state
useEffect(() => {
if (!window.EscPanelWorker) return;
if (sendSafety) {
console.log('Starting safety commands via worker');
window.EscPanelWorker.postMessage({
type: 'safety',
command: 'start'
});
} else {
console.log('Stopping safety commands');
window.EscPanelWorker.postMessage({
type: 'safety',
command: 'stop'
});
}
return () => {
window.EscPanelWorker.postMessage({
type: 'safety',
command: 'stop'
});
};
}, [sendSafety]); // Only depends on sendSafety
// Arming commands - independent of pause state
useEffect(() => {
if (!window.EscPanelWorker) return;
if (sendArming) {
console.log('Starting arming commands via worker');
window.EscPanelWorker.postMessage({
type: 'arming',
command: 'start'
});
} else {
console.log('Stopping arming commands');
window.EscPanelWorker.postMessage({
type: 'arming',
command: 'stop'
});
}
return () => {
window.EscPanelWorker.postMessage({
type: 'arming',
command: 'stop'
});
};
}, [sendArming]); // Only depends on sendArming
return (
// Main container - add height and overflow handling
<Box
sx={{
flexGrow: 1,
bgcolor: 'background.paper',
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column' // Make sure it's a column layout
}}
component={Paper}
p={1}
>
<AppBar position="static" color="primary" sx={{ mb: 2 }}>
<Toolbar variant="dense" sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
<Typography variant="body2" sx={{ mr: 1 }}>Channels:</Typography>
<TextField
type="number"
size="small"
value={localInstances}
sx={{ width: '60px' }} // Add specific width
InputProps={{
inputProps: {
min: 1,
max: 20,
style: {
textAlign: 'center',
padding: '2px 4px' // Reduce internal padding
}
}
}}
onChange={handleInstanceChange}
/>
</Box>
{/* Add warning text */}
<Typography
variant="body2"
sx={{
color: 'error.main',
fontWeight: 'bold',
ml: 2,
mr: 2,
display: 'flex',
alignItems: 'center',
fontSize: '0.6rem'
}}
>
REMOVE PROPELLERS!
</Typography>
</Box>
{/* Controls on the right side */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FormControlLabel
control={
<Checkbox
checked={sendSafety}
onChange={(e) => setSendSafety(e.target.checked)}
size="small"
sx={{ p: 0.5 }}
/>
}
label={<Typography variant="body2">Send Safety</Typography>}
labelPlacement="start"
sx={{ ml: 0, mr: 1 }}
/>
<FormControlLabel
control={
<Checkbox
checked={sendArming}
onChange={(e) => setSendArming(e.target.checked)}
size="small"
sx={{ p: 0.5 }}
/>
}
label={<Typography variant="body2">Send Arming</Typography>}
labelPlacement="start"
sx={{ ml: 0, mr: 2 }}
/>
{/* Broadcast Rate moved to the right */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>Broadcast Rate:</Typography>
<TextField
type="number"
size="small"
value={broadcastRate}
sx={{ width: '60px' }}
InputProps={{
inputProps: {
min: 1,
max: 1000,
style: {
textAlign: 'center',
padding: '2px 4px'
}
}
}}
onChange={handleBroadcastRateChange}
/>
{/* Pause/Play toggle button */}
<IconButton
size="small"
onClick={togglePause}
sx={{ ml: 1 }}
color={isPaused ? "default" : "primary"}
>
{isPaused ? <PlayArrowIcon /> : <PauseIcon />}
</IconButton>
</Box>
</Box>
</Toolbar>
</AppBar>
{/* Make the ESC cards section scrollable */}
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
justifyContent: 'center',
flexGrow: 1,
overflowY: 'auto', // Add scrolling
minHeight: '150px',
maxHeight: 'calc(100% - 140px)' // Reserve space for header and footer
}}>
{escData.map((esc, index) => (
<Box
key={index}
sx={{
width: '180px',
height: '250px',
}}
>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent
sx={{
height: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<Box sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}}>
<Box>
<Typography variant="body2" color="textSecondary">Index: {esc.esc_index}</Typography>
<Typography variant="body2" color="textSecondary">Err: {esc.error_count !== null ? esc.error_count : "NC"}</Typography>
<Typography variant="body2" color="textSecondary">
Temp: {esc.temperature !== null ? `${(esc.temperature - 273.15).toFixed(1)} °C` : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
Volt: {esc.voltage !== null ? `${esc.voltage.toFixed(2)} V` : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
Curr: {esc.current !== null ? `${esc.current.toFixed(2)} A` : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
RPM: {esc.rpm !== null ? Math.round(esc.rpm) : "NC"}
</Typography>
<Typography variant="body2" color="textSecondary">
RAT: {esc.power_rating_pct !== null ? `${esc.power_rating_pct.toFixed(1)} %` : "NC"}
</Typography>
</Box>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start', width: '80%' }}>
<TextField
type="number"
size="small"
value={thrustValues[index] || 0}
fullWidth
InputProps={{
inputProps: {
min: -100,
max: 100,
style: {
padding: '2px 4px'
}
}
}}
onChange={(e) => handleThrustInputChange(index, e)}
/>
<Button
color="error"
variant="contained"
onClick={() => handleStopOne(index)}
fullWidth
size="small"
>
Stop
</Button>
</Box>
</Box>
<Box sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'space-between',
height: '100%',
paddingTop: '5px'
}}>
<Slider
sx={{ height: '100%' }}
orientation="vertical"
value={thrustValues[index] || 0}
valueLabelDisplay="auto"
step={1}
marks={[
{ value: 100, label: '' },
{ value: 0, label: '' },
{ value: -100, label: '' }
]}
min={-100}
max={100}
onChange={(e, value) => handleThrustChange(index, value)}
/>
</Box>
</CardContent>
</Card>
</Box>
))}
</Box>
{/* Bottom controls section - ensure it stays at the bottom */}
<Box sx={{
mt: 1,
width: '100%',
flexGrow: 1,
p: 0.5,
gap: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}>
<Box sx={{ p: 1, border: '1px solid #ddd', borderRadius: 1}}>
<Typography variant="body2" color="textSecondary">
cmd: [{getScaledCommands(thrustValues).join(', ')}]
</Typography>
</Box>
<Button
variant="contained"
color="error"
fullWidth
startIcon={<PanToolIcon />}
onClick={handleStopAll}
>
Stop All
</Button>
</Box>
</Box>
);
};
export default EscPanel;
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import { ThemeProvider } from '@mui/material';
import EscPanel from './EscPanel';
import theme from './theme';
import './css/panel.css';
const EscPanelWindow = () => {
return (
<ThemeProvider theme={theme}>
<EscPanel />
</ThemeProvider>
);
};
export default EscPanelWindow;
+256
View File
@@ -0,0 +1,256 @@
import { off } from 'process';
import dronecan from './dronecan';
/**
* DroneCAN FileServer class for handling firmware and file transfers
*/
class FileServer {
constructor() {
this.files = {}; // Map to store loaded files by path
this.chunks = {}; // Map to store chunked file data
this.reader = new FileReader();
this.maxChunkSize = 256; // Maximum UAVCAN file chunk size
// Add tracking for current transfers
this.activeTransfers = {}; // Map of active transfers by path
this.progressCallbacks = {}; // Map of progress callbacks by path
}
/**
* Generate a key used in file read requests for a path
* This is kept to 7 bytes to keep the read request in 2 frames
* @param {string} path - The file path
* @returns {string} - A 7-character key representing the path
*/
pathKey(path) {
// Create a CRC32 hash of the path converted to UTF-8
const pathBuffer = new TextEncoder().encode(path);
// Calculate CRC32
let crc = 0xFFFFFFFF;
for (let i = 0; i < pathBuffer.length; i++) {
crc ^= pathBuffer[i];
for (let j = 0; j < 8; j++) {
crc = (crc >>> 1) ^ ((crc & 1) ? 0xEDB88320 : 0);
}
}
crc = (~crc >>> 0); // Convert to unsigned 32-bit integer
// Pack as 4 bytes (equivalent to Python struct.pack("<I", crc))
const crcBuffer = new ArrayBuffer(4);
const dataView = new DataView(crcBuffer);
dataView.setUint32(0, crc, true); // true means little-endian
// Convert to Base64
const base64String = btoa(
String.fromCharCode.apply(null, new Uint8Array(crcBuffer))
);
// Take first 7 characters and replace path separators
let result = base64String.slice(0, 7);
result = result.replace(/\//g, '_').replace(/\\/g, '_');
return result;
}
/**
* Load a file into the file server
* @param {File} file - The file object to load
* @param {string} path - The virtual path to register the file under
* @return {Promise} - Resolves when file is loaded
*/
loadFile(file, path = null) {
return new Promise((resolve, reject) => {
// Use the file name as the path if no path is provided
const filePath = path || file.name;
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target.result;
this.files[filePath] = {
name: file.name,
size: buffer.byteLength,
data: buffer,
path: filePath,
key: this.pathKey(filePath)
};
console.log(`File loaded: ${filePath}, size: ${buffer.byteLength} bytes`);
resolve(this.files[filePath]);
};
reader.onerror = (error) => {
console.error('Error loading file:', error);
reject(error);
};
reader.readAsArrayBuffer(file);
});
}
/**
* Get a chunk of file data at a specific offset
* @param {string} path - The file path
* @param {number} offset - The offset into the file
* @param {number} maxSize - Maximum size of chunk to return
* @returns {Object} - { data: Uint8Array, eof: boolean }
*/
getFileChunk(path, offset, maxSize = this.maxChunkSize) {
const fileInfo = this.files[path];
if (!fileInfo) {
console.warn(`File not found: ${path}`);
return { data: new Uint8Array(0), eof: true };
}
const dataView = new DataView(fileInfo.data);
const fileSize = fileInfo.size;
// Check if we're at or past the end of file
if (offset >= fileSize) {
return { data: new Uint8Array(0), eof: true };
}
// Calculate chunk size (might be less than maxSize if near EOF)
const chunkSize = Math.min(maxSize, fileSize - offset);
const chunk = new Uint8Array(chunkSize);
// Copy data from the file buffer to our chunk
for (let i = 0; i < chunkSize; i++) {
chunk[i] = dataView.getUint8(offset + i);
}
// Return the chunk and EOF status
return {
data: chunk,
eof: (offset + chunkSize >= fileSize)
};
}
/**
* Register a progress callback for a specific file path
* @param {string} path - File path to track
* @param {function} callback - Callback function(progress, offset, total)
*/
registerProgressCallback(path, callback) {
this.progressCallbacks[path] = callback;
}
/**
* Unregister a progress callback for a path
* @param {string} path - File path to stop tracking
*/
unregisterProgressCallback(path) {
delete this.progressCallbacks[path];
}
/**
* Update progress tracking for a file
* @param {string} path - File path
* @param {number} offset - Current offset
* @param {boolean} eof - Whether end of file was reached
*/
updateTransferProgress(path, offset, eof) {
const fileInfo = this.files[path];
if (!fileInfo) return;
// Store in active transfers
this.activeTransfers[path] = {
offset,
total: fileInfo.size,
percentage: fileInfo.size > 0 ? (offset / fileInfo.size) * 100 : 0,
eof: eof,
lastUpdated: Date.now()
};
// Call progress callback if registered
if (this.progressCallbacks[path]) {
const progress = fileInfo.size > 0 ? (offset / fileInfo.size) : 0;
this.progressCallbacks[path](progress, offset, fileInfo.size, eof);
}
// If EOF, clean up after a short delay
if (eof) {
setTimeout(() => {
delete this.activeTransfers[path];
}, 5000);
}
}
/**
* Get current transfer progress for a path
* @param {string} path - File path to check
* @returns {Object|null} - Progress object or null if not active
*/
getTransferProgress(path) {
return this.activeTransfers[path] || null;
}
/**
* Handle a File.Read request from DroneCAN
* @param {Object} transfer - The DroneCAN transfer object
* @param {Object} localNode - The DroneCAN local node
*/
handleReadRequest(transfer, localNode) {
if (!transfer || !transfer.payload) {
console.error('Invalid file read request, missing transfer or payload');
return;
}
try {
if (transfer.destNodeId !== localNode.nodeId) {
console.error('File read request not for this node');
return;
}
const request = transfer.payload;
// Extract path with fallbacks
let filePath = '';
if (request.fields && request.fields.path) {
// console.log('Path field:', request.fields.path);
if (request.fields.path.msg && request.fields.path.msg.fields && request.fields.path.msg.fields.path) {
filePath = request.fields.path.msg.fields.path.toString();
} else if (typeof request.fields.path.toString === 'function') {
filePath = request.fields.path.toString();
} else if (request.fields.path.value) {
filePath = request.fields.path.value.toString();
} else {
filePath = String(request.fields.path);
}
}
if (!filePath && request.path) {
filePath = request.path.toString();
}
// console.log(`Requested file path: ${filePath}`);
// Extract offset
let offset = 0;
if (request.fields && request.fields.offset) {
if (typeof request.fields.offset.value !== 'undefined') {
offset = request.fields.offset.value;
}
}
// Check if file exists after possible path remapping
if (!this.files[filePath]) {
return;
}
// Get the requested chunk
const { data, eof } = this.getFileChunk(filePath, offset);
// Update progress tracking
this.updateTransferProgress(filePath, offset, eof);
let error = 0; //#OK
localNode.responseUavcanProtocolFileRead(transfer, error, request.fields.path, data);
// console.log(`File chunk sent: ${filePath}, offset: ${offset}, size: ${data.length}, eof: ${eof}`);
} catch (error) {
console.error('Error handling file read request:', error);
}
}
}
// Export as a global object and as a module
window.FileServer = new FileServer();
export default window.FileServer;
+224
View File
@@ -0,0 +1,224 @@
import React, { useState } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, LinearProgress, Box, Alert
} from '@mui/material';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FileServer from './FileServer';
const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
const [firmwareFile, setFirmwareFile] = useState(null);
const [fileContent, setFileContent] = useState(null);
const [updateProgress, setUpdateProgress] = useState(0);
const [updateStatus, setUpdateStatus] = useState(null); // 'idle', 'updating', 'success', 'error'
const [statusMessage, setStatusMessage] = useState('');
const handleFileChange = (event) => {
const file = event.target.files[0];
if (!file) return;
// Validate file extension is .bin
const fileExtension = file.name.split('.').pop().toLowerCase();
if (fileExtension !== 'bin') {
setUpdateStatus('error');
setStatusMessage('Invalid file type. Please select a .bin firmware file.');
return;
}
setFirmwareFile(file);
setUpdateStatus('idle');
setStatusMessage('');
// Generate a unique path for this firmware file
const firmwarePath = FileServer.pathKey(file.name);
console.log(`Generated firmware path key: ${firmwarePath}`);
// Load the file into the FileServer using the generated path
FileServer.loadFile(file, firmwarePath)
.then(fileInfo => {
console.log(`Firmware loaded: ${fileInfo.name}, size: ${fileInfo.size} bytes, path: ${fileInfo.path}`);
setFileContent(fileInfo);
})
.catch(error => {
console.error('Error loading firmware:', error);
setUpdateStatus('error');
setStatusMessage('Failed to load firmware file');
});
};
// Function to read data at a specific offset
const readDataAtOffset = (offset, length) => {
if (!fileContent || !fileContent.data) return null;
try {
const dataView = new DataView(fileContent.data);
if (offset + length > fileContent.size) {
console.error('Requested offset+length exceeds file size');
return null;
}
// Read bytes and return as hex string
let hexString = '';
for (let i = 0; i < length; i++) {
const byte = dataView.getUint8(offset + i);
hexString += byte.toString(16).padStart(2, '0');
}
return hexString;
} catch (error) {
console.error('Error reading data at offset:', error);
return null;
}
};
const handleUpdate = () => {
if (!firmwareFile || !fileContent) return;
try {
// Get the local node reference
const localNode = window.localNode;
if (!localNode) {
setUpdateStatus('error');
setStatusMessage('Local node not available');
return;
}
// Update UI state
setUpdateStatus('updating');
setStatusMessage('Starting firmware update...');
setUpdateProgress(0);
// Register a progress callback with the FileServer
const firmwarePath = fileContent.path;
FileServer.registerProgressCallback(firmwarePath, (progress, offset, total, eof) => {
// Update progress in state (0-100%)
setUpdateProgress(progress * 100);
// Update status message
setStatusMessage(`Updating firmware: ${Math.round(progress * 100)}% (${offset}/${total} bytes)`);
// When complete
if (eof) {
setUpdateStatus('success');
setStatusMessage('Firmware update completed successfully!');
// Clean up progress tracking
setTimeout(() => {
FileServer.unregisterProgressCallback(firmwarePath);
}, 2000);
}
});
// Begin the firmware update process
console.log(`Starting firmware update for node ${targetNodeId} with file ${firmwareFile.name}`);
// Call the beginFirmwareUpdate method on the local node
localNode.beginFirmwareUpdate(
targetNodeId,
firmwarePath,
(transfer) => {
console.log("Firmware update result:", transfer);
const msg = transfer.payload;
if (!msg || msg.fields.error.value > 0) {
// Handle update failure
setUpdateStatus('error');
setStatusMessage(`Update failed: code: ${msg.fields.error.value} ${msg.fields.optional_error_message.toString() || 'Unknown error'}`);
FileServer.unregisterProgressCallback(firmwarePath);
} else {
setUpdateStatus('updating');
}
}
);
} catch (error) {
console.error('Error initiating firmware update:', error);
setUpdateStatus('error');
setStatusMessage(`Failed to start update: ${error.message || 'Unknown error'}`);
}
};
return (
<Dialog open={open} onClose={updateStatus === 'updating' ? null : onClose} maxWidth="sm" fullWidth>
<DialogTitle>Firmware Update</DialogTitle>
<DialogContent>
<Typography variant="body2" gutterBottom>
Please select the firmware file (.bin) to upload to node {targetNodeId}.
</Typography>
<Button
variant="contained"
component="label"
startIcon={<FileUploadIcon />}
disabled={updateStatus === 'updating'}
>
Select Firmware File
<input
type="file"
hidden
accept=".bin"
onChange={handleFileChange}
/>
</Button>
{firmwareFile && (
<Typography variant="body2" sx={{ mt: 2 }}>
Selected File: {firmwareFile.name}
{fileContent && ` (${fileContent.size} bytes)`}
</Typography>
)}
{updateStatus === 'updating' && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" gutterBottom>
{statusMessage || 'Updating firmware...'}
</Typography>
<LinearProgress
variant="determinate"
value={updateProgress}
sx={{ mt: 1, mb: 2 }}
/>
</Box>
)}
{updateStatus === 'error' && (
<Alert severity="error" sx={{ mt: 2 }}>
{statusMessage || 'An error occurred during the update.'}
</Alert>
)}
{updateStatus === 'success' && (
<Alert severity="success" sx={{ mt: 2 }}>
{statusMessage || 'Firmware update completed successfully!'}
</Alert>
)}
</DialogContent>
<DialogActions>
{updateStatus !== 'updating' ? (
<>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button
onClick={handleUpdate}
color="primary"
disabled={!firmwareFile || updateStatus === 'updating'}
>
Update
</Button>
</>
) : (
<Button
color="secondary"
disabled={updateProgress < 100}
onClick={onClose}
>
{updateProgress < 100 ? 'Updating...' : 'Close'}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default FirmwareUpdateModal;
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Typography, Box } from '@mui/material';
import { secondsToTime } from './common';
const NodeList = ({ nodes, selectedNodeId, setSelectedNodeId }) => {
const handleRowClick = (key) => {
if (key === selectedNodeId) {
setSelectedNodeId(null);
return;
}
setSelectedNodeId(Number(key));
};
const getModeColor = (mode) => {
switch (mode) {
case 'OPERATIONAL':
return '';
case 'INITIALIZATION':
return 'warning.main';
case 'MAINTENANCE':
return 'secondary.main';
case 'SOFTWARE_UPDATE':
return 'success.main';
case 'OFFLINE':
return 'error.main';
default:
return 'error.main';
}
};
const renderNodeRow = (key) => {
let node = nodes[key];
let status = node.status;
let health = status.getConstant('health');
let mode = status.getConstant('mode');
return (
<TableRow key={key} onClick={() => handleRowClick((Number(key)))} style={{ cursor: 'pointer' }}>
<TableCell>{key}</TableCell>
<TableCell>{node.name}</TableCell>
<TableCell>{health}</TableCell>
<TableCell sx={{bgcolor: getModeColor(mode)}}>{mode}</TableCell>
<TableCell>{secondsToTime(status.uptime_sec)}</TableCell>
<TableCell>{node.status.vendor_specific_status_code}</TableCell>
</TableRow>
);
};
return (
<Box
component={Paper}
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, height: '50%'}}
>
<Box margin={1} sx={{height: 20}}>
<Typography variant="caption">Online Nodes</Typography>
</Box>
<TableContainer
sx={{ overflow: 'auto' }}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>NID</TableCell>
<TableCell
sx={{
width: 150,
}}
>
Name
</TableCell>
<TableCell>Health</TableCell>
<TableCell>Mode</TableCell>
<TableCell>Uptime</TableCell>
<TableCell>VSSC</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.keys(nodes).map((key) => (
renderNodeRow(key)
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default NodeList;
+121
View File
@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { TableContainer, Table, TableHead, TableBody, TableRow, TableCell, Paper, Box, Typography, IconButton } from '@mui/material';
import PauseIcon from '@mui/icons-material/Pause';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
const NodeLogs = () => {
const [logs, setLogs] = useState([]);
const [paused, setPaused] = useState(false);
useEffect(() => {
const localNode = window.localNode;
const handleLog = (transfer) => {
if (paused) {
return;
}
// console.log(transfer);
const msg = transfer.payload;
const msgObj = msg.toObj();
setLogs((logs) => [...logs, {
id: transfer.sourceNodeId,
localTime: new Date().toLocaleTimeString(),
level: msgObj.level.getConstant('value'),
source: '',
text: msgObj.text
}]);
};
localNode.on('uavcan.protocol.debug.LogMessage', handleLog);
return () => {
localNode.off('uavcan.protocol.debug.LogMessage', handleLog);
};
});
const getLevelColor = (level) => {
switch (level) {
case 'DEBUG':
return 'secondary.main';
case 'INFO':
return '';
case 'WARNING':
return 'warning.main';
case 'ERROR':
return 'error.main';
default:
return 'primary.main';
}
}
return (
<Box
component={Paper}
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, height: '50%'}}
>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
height: 20
}} margin={1}>
<Typography variant="caption" flexGrow={1}>Logs</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
sx={{
width: 20,
height: 20,
padding: 0
}}
size='small'
onClick={() => setPaused(!paused)}
>
{paused ? <PlayArrowIcon sx={{ fontSize: 16 }} /> : <PauseIcon sx={{ fontSize: 16 }} />}
</IconButton>
<IconButton
sx={{
width: 20,
height: 20,
padding: 0
}}
size='small'
color="warning"
onClick={() => setLogs([])}
>
<CleaningServicesIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
<TableContainer
sx={{ overflow: 'auto' }}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: '5%' }}>NID</TableCell>
<TableCell sx={{ width: '15%' }}>Time</TableCell>
<TableCell sx={{ width: '10%' }}>Level</TableCell>
<TableCell sx={{ width: '10%' }}>Source</TableCell>
<TableCell sx={{ width: '60%' }}>Text</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map((log, index) => (
<TableRow key={index}>
<TableCell>{log.id}</TableCell>
<TableCell>{log.localTime}</TableCell>
<TableCell sx={{bgcolor: getLevelColor(log.level)}}>
{log.level}
</TableCell>
<TableCell>{log.source}</TableCell>
<TableCell sx={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{log.text}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default NodeLogs;
+399
View File
@@ -0,0 +1,399 @@
import React, { useState } from 'react';
import {
Box, Button, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Typography, Paper, Tooltip, Chip
} from '@mui/material';
import SyncIcon from '@mui/icons-material/Sync';
import SaveIcon from '@mui/icons-material/Save';
import AutoFixNormalIcon from '@mui/icons-material/AutoFixNormal';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import EditIcon from '@mui/icons-material/Edit';
import ParamEditorSelector from './ParamEditors/ParamEditorSelector';
import AM32_Rtttl from './am32_rtttl';
const OPCODE_SAVE = 0;
const OPCODE_ERASE = 1;
const NodeParam = ({ nodeId, nodes }) => {
const [modalOpen, setModalOpen] = useState(false);
const [editParamIndex, setEditParamIndex] = useState(null);
if (!nodeId) return null;
const node = nodes[nodeId];
if (!node) return null;
const handleFetchParams = () => {
const localNode = window.localNode;
let currentParamIndex = 0;
const callback = (transfer) => {
const msg = transfer.payload;
if (msg && msg.fields.name.items.length > 0) {
if (msg && transfer.destNodeId === localNode.nodeId) {
localNode.updateNodeParamsFromResponse(transfer, currentParamIndex);
currentParamIndex += 1;
localNode.fetchNodeParam(nodeId, currentParamIndex, '', callback);
}
}
};
localNode.fetchNodeParam(nodeId, 0, '', callback);
};
const handleEraseParams = () => {
const localNode = window.localNode;
localNode.requestUavcanProtocolParamExecuteOpcode(nodeId, OPCODE_ERASE, 0, (transfer) => {
console.log('Erase response:', transfer);
});
}
const handleSaveParams = () => {
const localNode = window.localNode;
localNode.requestUavcanProtocolParamExecuteOpcode(nodeId, OPCODE_SAVE, 0, (transfer) => {
console.log('Save response:', transfer);
});
}
const handleEditClick = (index) => {
setEditParamIndex(Number(index))
setModalOpen(true);
};
// Function to get color for parameter type chip
const getTypeChipColor = (paramType) => {
switch (paramType) {
case 'integer': return 'primary';
case 'real': return 'secondary';
case 'boolean': return 'success';
case 'string': return 'info';
default: return 'default';
}
};
// Function to format boolean values visually
const formatBooleanValue = (value) => {
if (value === 'True') {
return <Chip size="small" label="True" color="success" />;
} else if (value === 'False') {
return <Chip size="small" label="False" color="error" />;
}
return value;
};
const handleDownloadParams = () => {
const localNode = window.localNode;
const params = localNode.nodeParams[nodeId];
if (!params) {
console.error('No parameters to download');
return;
}
// Create simple format content (NAME VALUE)
let content = '';
Object.keys(params).forEach(key => {
try {
const param = params[key];
if (!param || !param.fields) return;
const paramName = param.fields.name.toString();
let paramValue;
// Determine parameter value based on type
if (param.fields.value.msg.fields.integer_value !== undefined) {
paramValue = param.fields.value.msg.fields.integer_value.value;
} else if (param.fields.value.msg.fields.real_value !== undefined) {
paramValue = param.fields.value.msg.fields.real_value.value;
} else if (param.fields.value.msg.fields.boolean_value !== undefined) {
paramValue = param.fields.value.msg.fields.boolean_value.value === 1 ? 'True' : 'False';
} else if (param.fields.value.msg.fields.string_value !== undefined) {
// Don't quote string values in this format
paramValue = param.fields.value.msg.fields.string_value.value.toString();
} else {
paramValue = '';
}
// Append to content in NAME VALUE format
content += `${paramName} ${paramValue}\n`;
} catch (err) {
console.error(`Error formatting parameter at index ${key}:`, err);
}
});
// Create a downloadable file
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Generate a filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
link.download = `dronecan-params-node${nodeId}-${timestamp}.txt`;
// Trigger download and cleanup
document.body.appendChild(link);
link.click();
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 100);
};
const renderNodeParamItems = () => {
const localNode = window.localNode;
if (!localNode.nodeParams[nodeId]) return null;
return (
Object.keys(localNode.nodeParams[nodeId]).map((key) => {
let param = localNode.nodeParams[nodeId][key];
if (!param) return null;
if (!param.fields) return null;
let paramName = param.fields.name.toString();
let paramValueDisplay;
let paramTypeDisplay;
let paramMinValue = "";
let paramMinValueDisplay = "";
if (param.fields.min_value.msg && param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramMinValue = typeof param.fields.min_value.msg.unionField.value === 'object' ?
JSON.stringify(param.fields.min_value.msg.unionField.value) :
param.fields.min_value.msg.unionField.value;
}
let paramMaxValue = "";
let paramMaxValueDisplay = "";
if (param.fields.max_value.msg && param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramMaxValue = typeof param.fields.max_value.msg.unionField.value === 'object' ?
JSON.stringify(param.fields.max_value.msg.unionField.value) :
param.fields.max_value.msg.unionField.value;
}
let paramDefaultValue = "";
let paramDefaultValueDisplay = "";
if (param.fields.default_value.msg && param.fields.default_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramDefaultValue = typeof param.fields.default_value.msg.unionField.value === 'object' ?
JSON.stringify(param.fields.default_value.msg.unionField.value) :
param.fields.default_value.msg.unionField.value;
}
// Determine parameter type by checking which field exists
if (param.fields.value.msg.fields.integer_value !== undefined) {
paramTypeDisplay = 'integer';
paramValueDisplay = param.fields.value.msg.fields.integer_value.value;
paramMinValueDisplay = paramMinValue;
paramMaxValueDisplay = paramMaxValue;
paramDefaultValueDisplay = paramDefaultValue;
} else if (param.fields.value.msg.fields.real_value !== undefined) {
paramTypeDisplay = 'real';
paramValueDisplay = param.fields.value.msg.fields.real_value.value;
paramMinValueDisplay = paramMinValue;
paramMaxValueDisplay = paramMaxValue;
paramDefaultValueDisplay = paramDefaultValue;
} else if (param.fields.value.msg.fields.boolean_value !== undefined) {
paramTypeDisplay = 'boolean';
if (param.fields.value.msg.fields.boolean_value.value === 0) {
paramValueDisplay = 'Disabled';
} else if (param.fields.value.msg.fields.boolean_value.value === 1) {
paramValueDisplay = 'Enabled';
}
if (paramDefaultValue === 0) {
paramDefaultValueDisplay = 'Disabled';
} else {
paramDefaultValueDisplay = 'Enabled';
}
paramMinValueDisplay = "";
paramMaxValueDisplay = "";
} else if (param.fields.value.msg.fields.string_value !== undefined) {
paramTypeDisplay = 'string';
let stringValue = param.fields.value.msg.fields.string_value.toString();
// Special handling for STARTUP_TUNE parameter
if (paramName === "STARTUP_TUNE") {
try {
// Convert binary string to Uint8Array
const binaryData = new Uint8Array(stringValue.length);
for (let i = 0; i < stringValue.length; i++) {
binaryData[i] = stringValue.charCodeAt(i);
}
// Convert to RTTTL format
const rtttlString = AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune");
paramValueDisplay = rtttlString;
} catch (err) {
console.error("Error converting STARTUP_TUNE to RTTTL:", err);
paramValueDisplay = stringValue;
}
} else {
paramValueDisplay = stringValue;
}
} else {
paramTypeDisplay = 'empty';
paramValueDisplay = '';
}
return (
<TableRow
key={key}
sx={{
'&:hover': { backgroundColor: 'action.hover' },
cursor: 'pointer'
}}
onClick={() => handleEditClick(key)}
>
<TableCell>{key}</TableCell>
<TableCell>
<Tooltip title={paramName} placement="top-start">
<Typography noWrap sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{paramName}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Chip
size="small"
label={paramTypeDisplay}
color={getTypeChipColor(paramTypeDisplay)}
sx={{ minWidth: 60 }}
/>
</TableCell>
<TableCell>
<Tooltip title={paramValueDisplay} placement="top">
<Box sx={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{paramValueDisplay}
</Box>
</Tooltip>
</TableCell>
<TableCell>
<Tooltip title={String(paramDefaultValue)} placement="top">
<Typography noWrap sx={{ maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{paramDefaultValueDisplay === "" ? "-" : String(paramDefaultValueDisplay)}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Typography noWrap sx={{ maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{paramMinValueDisplay === "" ? "-" : String(paramMinValueDisplay)}
</Typography>
</TableCell>
<TableCell>
<Typography noWrap sx={{ maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{paramMaxValueDisplay === "" ? "-" : String(paramMaxValueDisplay)}
</Typography>
</TableCell>
<TableCell padding="none" align="center">
<Tooltip title="Edit Parameter">
<EditIcon fontSize="small" color="primary" />
</Tooltip>
</TableCell>
</TableRow>
);
})
);
};
const renderNodeParams = () => {
return (
<TableContainer sx={{ maxHeight: 'calc(100vh - 450px)', overflowY: 'auto', flexGrow: 1 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell sx={{width: '5%'}}>Idx</TableCell>
<TableCell sx={{width: '28%'}}>Name</TableCell>
<TableCell sx={{width: '10%'}}>Type</TableCell>
<TableCell sx={{width: '17%'}}>Value</TableCell>
<TableCell sx={{width: '10%'}}>Default</TableCell>
<TableCell sx={{width: '10%'}}>Min</TableCell>
<TableCell sx={{width: '10%'}}>Max</TableCell>
<TableCell sx={{width: '10%'}}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{renderNodeParamItems()}
</TableBody>
</Table>
</TableContainer>
);
};
return (
<Box
sx={{
flexGrow: 1,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
component={Paper}
p={0.5}
>
<Box sx={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', mb: 1}}>
<Typography
sx={{ width: 80, mr: 2, ml: 0.5 }}
variant="caption"
>
Parameters
</Typography>
<Box sx={{ display: 'flex', alignItems: 'space-between', flexDirection: 'row', border: 1, borderColor: 'grey.500', borderRadius: 1, p: 0.5, mr: 2 }}>
<Button
onClick={handleFetchParams}
variant="contained"
sx={{ mr: 1 }}
startIcon={<SyncIcon />}
>
Fetch All
</Button>
<Button
onClick={handleSaveParams}
variant="contained"
color="primary"
sx={{ mr: 1 }}
startIcon={<SaveIcon />}
disabled={!localNode.nodeParams[nodeId] || Object.keys(localNode.nodeParams[nodeId]).length === 0}
>
Store All
</Button>
<Button
onClick={handleEraseParams}
variant="contained"
color="warning"
startIcon={<AutoFixNormalIcon />}
>
Erase All
</Button>
</Box>
<Box sx={{ flexGrow: 1 }}></Box>
<Box sx={{ display: 'flex', flexDirection: 'row', border: 1, borderColor: 'grey.500', borderRadius: 1, p: 0.5 }}>
<Button
variant="outlined"
color="primary"
sx={{ mr: 1 }}
startIcon={<FileDownloadIcon />}
onClick={handleDownloadParams}
disabled={!localNode.nodeParams[nodeId] || Object.keys(localNode.nodeParams[nodeId]).length === 0}
>
Download
</Button>
<Button
variant="outlined"
color="primary"
startIcon={<FileUploadIcon />}
disabled={!nodeId} // Disable Load button if no node is selected
>
Load
</Button>
</Box>
</Box>
{renderNodeParams()}
<ParamEditorSelector
open={modalOpen}
onClose={() => setModalOpen(false)}
nodeId={nodeId}
paramIndex={editParamIndex}
/>
</Box>
);
};
export default NodeParam;
+241
View File
@@ -0,0 +1,241 @@
import React, { useEffect, useState } from 'react';
import dronecan from './dronecan';
import { Paper, Box, Typography, TextField, Button, Stack, Switch, TableContainer, Table, TableHead, TableBody, TableRow, TableCell } from '@mui/material';
import { secondsToTime } from './common';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import CableIcon from '@mui/icons-material/Cable';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import FirmwareUpdateModal from './FirmwareUpdateModal';
import ConfirmRestartModal from './ConfirmRestartModal';
const VendorSpecificCodeDisplay = (code) => {
code = Math.max(0, Math.floor(code) & 0xFFFF);
let decimal = code.toString();
let hex = `0x${code.toString(16).padStart(4, '0')}`;
let binary = `0b${(code >>> 8).toString(2).padStart(8, '0')}_${(code & 255).toString(2).padStart(8, '0')}`;
return `${decimal} | ${hex} | ${binary}`;
};
const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEditorEnable }) => {
const [firmwareModalOpen, setFirmwareModalOpen] = useState(false);
const [restartModalOpen, setRestartModalOpen] = useState(false);
useEffect(() => {
const localNode = window.localNode;
const handleNodeParam = (transfer) => {
if (transfer.sourceNodeId !== nodeId) return;
};
localNode.on('uavcan.protocol.param.GetSet.Response', handleNodeParam);
return () => {
localNode.off('uavcan.protocol.param.GetSet.Response', handleNodeParam);
};
}, [nodeId]);
if (!nodeId) return null;
const node = nodes[nodeId];
if (!node) return null;
const handleNodeRestart = (nodeId) => {
const localNode = window.localNode;
localNode.restartNode(nodeId, (transfer) => {
console.log('Restart response:', transfer);
});
};
const handleConfirmRestart = () => {
handleNodeRestart(nodeId);
setRestartModalOpen(false);
};
const status = nodes[nodeId]?.status;
const name = node.name ? node.name : '';
const health = status ? `${status.getConstant('health')} (${status.health})` : '';
const mode = status ? `${status.getConstant('mode')} (${status.mode})` : '';
const uptime = status ? secondsToTime(status.uptime_sec) : 0;
const vendor_specific_status_code = status ? VendorSpecificCodeDisplay(status.vendor_specific_status_code) : 0;
const softwareVersion = node.software_version ? `${node.software_version.major}.${node.software_version.minor}` : '';
const softwareCrc64 = node.software_version ? `0x${node.software_version.image_crc.toString(16).padStart(8, '0')}` : '';
const softwareVcsCommit = node.software_version ? `0x${node.software_version.vcs_commit.toString(16).padStart(4, '0')}` : '';
const hardwareVersion = node.hardware_version ? `${node.hardware_version.major}.${node.hardware_version.minor}` : '';
const hardwareUID = node.hardware_version ? node.hardware_version.unique_id.map((item) => { return item.toString(16).padStart(2, '0') }).join(' ') : '';
const certificateOfAuthenticity = node.certificate_of_authenticity ? node.certificate_of_authenticity : ' ';
return (
<Box
sx={{ flexGrow: 1, bgcolor: 'background.paper', height: 340}}
component={Paper}
p={1}
>
<Box sx={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption">
Node Properties
</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="caption">Multi Node Editor</Typography>
<Switch checked={multiNodeEditorEnable} onChange={(e) => { setMultiNodeEditorEnable(e.target.checked) }} />
</Stack>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Node ID"
value={nodeId}
InputProps={{
readOnly: true,
}}
sx={{ mr: 0.5 }}
/>
<TextField
label="Name"
value={name}
fullWidth
InputProps={{
readOnly: true,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Mode"
value={mode}
fullWidth
InputProps={{
readOnly: true,
}}
sx={{ mr: 0.5 }}
/>
<TextField
label="Health"
value={health}
fullWidth
InputProps={{
readOnly: true,
}}
sx={{ mr: 0.5 }}
/>
<TextField
label="Uptime"
value={uptime}
fullWidth
InputProps={{
readOnly: true,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Vendor Specific Status Code"
fullWidth
value={vendor_specific_status_code}
InputProps={{
readOnly: true,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Software Version"
fullWidth
value={softwareVersion}
InputProps={{
readOnly: true,
}}
sx={{ mr: 0.5 }}
/>
<TextField
label="CRC64"
fullWidth
value={softwareCrc64}
InputProps={{
readOnly: true,
}}
sx={{ mr: 0.5 }}
/>
<TextField
label="VCS Commit"
fullWidth
value={softwareVcsCommit}
InputProps={{
readOnly: true,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Hardware Version"
value={hardwareVersion}
InputProps={{
readOnly: true,
}}
sx={{ mr: 0.5 }}
/>
<TextField
label="UID"
fullWidth
value={hardwareUID}
InputProps={{
readOnly: true,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Cert. of authenticity"
fullWidth
value={certificateOfAuthenticity}
InputProps={{
readOnly: true,
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography
sx={{ width: 80, mr: 2 }}
variant="caption"
>
Node Controls
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1, border: 1, borderColor: 'grey.500', borderRadius: 1, p: 0.5 }}>
<Button
sx={{ mr: 1 }}
color="error"
variant="contained"
startIcon={<PowerSettingsNewIcon />}
onClick={() => setRestartModalOpen(true)}
>
Restart
</Button>
<Button
sx={{ mr: 1 }}
variant="outlined"
startIcon={<CableIcon />}
>
Get Transport Stats
</Button>
<Box sx={{ flexGrow: 1 }}></Box>
<Button
variant="outlined"
startIcon={<SystemUpdateAltIcon />}
onClick={() => setFirmwareModalOpen(true)}
>
Update Firmware
</Button>
</Box>
</Box>
<FirmwareUpdateModal
open={firmwareModalOpen}
onClose={() => setFirmwareModalOpen(false)}
targetNodeId={nodeId}
/>
<ConfirmRestartModal
open={restartModalOpen}
onClose={() => setRestartModalOpen(false)}
onConfirm={handleConfirmRestart}
/>
</Box>
);
};
export default NodeProperties;
+71
View File
@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { Box, Button, Menu, MenuItem, Divider } from '@mui/material';
import VideogameAssetIcon from '@mui/icons-material/VideogameAsset';
const PanelsMenu = ({openWindow}) => {
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleEscPanelClick = () => {
openWindow(
"ESC Panel",
"esc_panel.html",
"width=800,height=430",
);
handleClose();
};
const handleActuatorPanelClick = () => {
openWindow(
"Actuator Panel",
"actuator_panel.html",
"width=800,height=430",
);
handleClose();
};
return (
<Box mr={1}>
<Button
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
disableElevation
onClick={handleClick}
color="default"
startIcon={<VideogameAssetIcon />}
>
Panels
</Button>
<Menu
elevation={0}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleEscPanelClick} disableRipple>
ESC
</MenuItem>
<MenuItem onClick={handleActuatorPanelClick} disableRipple>
Actuator
</MenuItem>
</Menu>
</Box>
);
}
export default PanelsMenu;
+91
View File
@@ -0,0 +1,91 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Box, Typography, Switch, FormControlLabel
} from '@mui/material';
import { renderParamNameField, renderInfoField, getParamValues } from './ParamEditorUtils';
const BooleanParamEditor = ({ open, onClose, nodeId, paramIndex, paramName }) => {
const [value, setValue] = useState(null);
// Load initial value
useEffect(() => {
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return;
const param = localNode.nodeParams[nodeId][paramIndex];
const paramValueField = param.fields.value.msg.unionField;
setValue(Boolean(paramValueField.value));
}, [nodeId, paramIndex, paramName]);
const handleValueChange = (event) => {
setValue(event.target.checked);
};
const handleSave = () => {
const localNode = window.localNode;
localNode.setNodeParam(nodeId, paramIndex, value ? 1 : 0);
onClose();
};
if (value === null) return null;
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param || !param.fields) return null;
const { paramValueField, paramDefaultValue } = getParamValues(param);
return (
<Dialog
open={open}
onClose={onClose}
sx={{ '& .MuiDialog-paper': { minWidth: '400px' } }}
>
<DialogTitle>Edit Boolean Parameter</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Box sx={{display: 'flex', flexDirection: 'row', gap: 2}}>
{renderParamNameField(paramName)}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center', my: 2 }}>
<FormControlLabel
control={
<Switch
checked={value}
onChange={handleValueChange}
color="primary"
/>
}
label={value ? "Enabled" : "Disabled"}
/>
</Box>
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
bgcolor: 'action.hover',
p: 1.5,
borderRadius: 1
}}>
{renderInfoField("Current Value", paramValueField.value ? "Enabled" : "Disabled")}
{paramDefaultValue !== "" && renderInfoField("Default Value", Boolean(paramDefaultValue) ? "Enabled" : "Disabled")}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button onClick={handleSave} color="primary">
Save
</Button>
</DialogActions>
</Dialog>
);
};
export default BooleanParamEditor;
+139
View File
@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, TextField, Box, Typography
} from '@mui/material';
import { renderParamNameField, renderInfoField, getParamValues } from './ParamEditorUtils';
const NumberParamEditor = ({ open, onClose, nodeId, paramIndex, paramName }) => {
const [value, setValue] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const [isValid, setIsValid] = useState(true);
// Load initial value
useEffect(() => {
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return;
const param = localNode.nodeParams[nodeId][paramIndex];
const paramValueField = param.fields.value.msg.unionField;
setValue(paramValueField.value);
}, [nodeId, paramIndex, paramName]);
const handleValueChange = (newValue) => {
setValue(newValue);
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param) {
setIsValid(true);
setErrorMessage('');
return;
}
// Skip validation for empty strings or non-numeric values during typing
if (newValue === '' || isNaN(parseFloat(newValue))) {
setIsValid(false);
setErrorMessage('Value must be a number');
return;
}
const numericValue = parseFloat(newValue);
const min = param.fields.min_value.msg &&
param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.min_value.msg.unionField.value : null;
const max = param.fields.max_value.msg &&
param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.max_value.msg.unionField.value : null;
// Validate against min/max if they exist
if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) {
setIsValid(false);
setErrorMessage(`Value must be between ${min !== null ? min : '-∞'} and ${max !== null ? max : '∞'}`);
} else {
setIsValid(true);
setErrorMessage('');
}
};
const handleSave = () => {
const localNode = window.localNode;
localNode.setNodeParam(nodeId, paramIndex, parseFloat(value));
onClose();
};
if (value === null) return null;
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param || !param.fields) return null;
const { paramValueField, paramMinValue, paramMaxValue, paramDefaultValue } = getParamValues(param);
// Determine step value based on parameter type
let step;
if (param.fields.value.msg.fields.integer_value !== undefined) {
step = 1;
} else if (param.fields.value.msg.fields.real_value !== undefined) {
step = 0.01;
} else {
step = 1;
}
return (
<Dialog
open={open}
onClose={onClose}
sx={{ '& .MuiDialog-paper': { minWidth: '400px' } }}
>
<DialogTitle>Edit Number Parameter</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{renderParamNameField(paramName)}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<TextField
label="New Value"
value={value}
type="number"
inputProps={{ step: step }}
onChange={(e) => handleValueChange(e.target.value)}
fullWidth
margin="dense"
error={!isValid}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{renderInfoField("Current Value", paramValueField.value)}
{paramMinValue !== "" && renderInfoField("Min Value", paramMinValue)}
{paramMaxValue !== "" && renderInfoField("Max Value", paramMaxValue)}
{paramDefaultValue !== "" && renderInfoField("Default Value", paramDefaultValue)}
</Box>
{errorMessage && (
<Typography color="error" variant="caption">
{errorMessage}
</Typography>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={!isValid}
>
Save
</Button>
</DialogActions>
</Dialog>
);
};
export default NumberParamEditor;
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, TextField, Box, Typography, Slider, InputAdornment
} from '@mui/material';
import { renderParamNameField, renderInfoField, getParamValues } from './ParamEditorUtils';
const NumericParamEditor = ({ open, onClose, nodeId, paramIndex, paramName }) => {
const [value, setValue] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const [isValid, setIsValid] = useState(true);
// Load initial value
useEffect(() => {
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return;
const param = localNode.nodeParams[nodeId][paramIndex];
const paramValueField = param.fields.value.msg.unionField;
setValue(paramValueField.value);
}, [nodeId, paramIndex]);
// Handle value change with validation
const handleValueChange = (newValue) => {
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param) {
setValue(newValue);
return;
}
// Skip validation for empty strings or non-numeric values during typing
if (newValue === '' || isNaN(parseFloat(newValue))) {
setValue(newValue);
return;
}
const numericValue = parseFloat(newValue);
const min = param.fields.min_value.msg &&
param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.min_value.msg.unionField.value : null;
const max = param.fields.max_value.msg &&
param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.max_value.msg.unionField.value : null;
// Validate against min/max if they exist
if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) {
setIsValid(false);
setErrorMessage(`Value must be between ${min !== null ? min : '-∞'} and ${max !== null ? max : '∞'}`);
} else {
setIsValid(true);
setErrorMessage('');
}
setValue(newValue);
};
// Handle slider change
const handleSliderChange = (event, newValue) => {
handleValueChange(newValue);
};
// Validation effect
useEffect(() => {
if (value === null || value === undefined) return;
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param) return;
// Skip validation for empty strings or non-numeric values
if (value === '' || isNaN(parseFloat(value))) {
setIsValid(false);
return;
}
const numericValue = parseFloat(value);
const min = param.fields.min_value.msg &&
param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.min_value.msg.unionField.value : null;
const max = param.fields.max_value.msg &&
param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty' ?
param.fields.max_value.msg.unionField.value : null;
// Validate against min/max if they exist
if ((min !== null && numericValue < min) || (max !== null && numericValue > max)) {
setIsValid(false);
setErrorMessage(`Value must be between ${min !== null ? min : '-∞'} and ${max !== null ? max : '∞'}`);
} else {
setIsValid(true);
setErrorMessage('');
}
}, [value, nodeId, paramIndex]);
// Handle save
const handleSave = () => {
const localNode = window.localNode;
// Ensure value is properly parsed as a number before saving
const numericValue = parseFloat(value);
// For integer parameters, ensure we save an integer
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
const isInteger = param?.fields.value.msg.fields.integer_value !== undefined;
const valueToSave = isInteger ? Math.round(numericValue) : numericValue;
localNode.setNodeParam(nodeId, paramIndex, valueToSave);
onClose();
};
// Render the edit field with validation
const renderValueEditField = (min, max) => {
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId]?.[paramIndex]) return null;
const param = localNode.nodeParams[nodeId][paramIndex];
const isInteger = param.fields.value.msg.fields.integer_value !== undefined;
const step = isInteger ? 1 : 0.01;
// Check if value is outside limits for the error state
const numericValue = parseFloat(value);
const isOutOfBounds =
(min !== "" && !isNaN(min) && numericValue < min) ||
(max !== "" && !isNaN(max) && numericValue > max);
// Determine if we should show a slider (only for integers with defined min and max)
const showSlider = isInteger &&
min !== "" && !isNaN(parseFloat(min)) &&
max !== "" && !isNaN(parseFloat(max))
return (
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField
label="New Value"
value={value}
type="number"
inputProps={{ step }}
error={isOutOfBounds}
onChange={(e) => handleValueChange(e.target.value)}
fullWidth
margin="dense"
helperText={isOutOfBounds ?
`Value must be between ${min !== "" ? min : '-∞'} and ${max !== "" ? max : '∞'}` :
null
}
/>
{showSlider && (
<Box sx={{ px: 1, mt: 1 }}>
<Slider
value={parseFloat(value) || parseFloat(min) || 0}
onChange={handleSliderChange}
aria-labelledby="input-slider"
min={parseFloat(min)}
max={parseFloat(max)}
step={1}
marks={generateSliderMarks(min, max)}
valueLabelDisplay="auto"
sx={{
color: isOutOfBounds ? 'error.main' : 'primary.main',
'& .MuiSlider-thumb': {
height: 24,
width: 24,
},
'& .MuiSlider-valueLabel': {
fontSize: '0.75rem',
}
}}
/>
</Box>
)}
</Box>
);
};
// Generate marks for the slider
const generateSliderMarks = (min, max) => {
const minVal = parseFloat(min);
const maxVal = parseFloat(max);
// If the range is too large, just show min, middle, and max
if (maxVal - minVal > 10) {
return [
{ value: minVal, label: minVal.toString() },
{ value: Math.round((minVal + maxVal) / 2), label: Math.round((minVal + maxVal) / 2).toString() },
{ value: maxVal, label: maxVal.toString() }
];
}
// Otherwise show all integer values
const marks = [];
for (let i = minVal; i <= maxVal; i++) {
marks.push({ value: i, label: i.toString() });
}
return marks;
};
if (value === null) return null;
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param || !param.fields) return null;
const { paramValueField, paramMinValue, paramMaxValue, paramDefaultValue } = getParamValues(param);
return (
<Dialog open={open} onClose={onClose} sx={{ '& .MuiDialog-paper': { minWidth: '450px' } }}>
<DialogTitle>Edit Numeric Parameter</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{/* Parameter name in its own row */}
{renderParamNameField(paramName)}
{/* Value editor with potential slider in its own row */}
{renderValueEditField(paramMinValue, paramMaxValue)}
{/* Current & Default values in their own row */}
<Box sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
bgcolor: 'action.hover',
p: 1.5,
borderRadius: 1
}}>
{renderInfoField("Current Value", paramValueField.value)}
{renderInfoField("Default Value", paramDefaultValue)}
</Box>
{/* Min & Max values in their own row */}
<Box sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
bgcolor: 'action.hover',
p: 1.5,
borderRadius: 1
}}>
{renderInfoField("Min Value", paramMinValue)}
{renderInfoField("Max Value", paramMaxValue)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={!isValid}
>
Save
</Button>
</DialogActions>
</Dialog>
);
};
export default NumericParamEditor;
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import NumberParamEditor from './NumericParamEditor';
import BooleanParamEditor from './BooleanParamEditor';
import StringParamEditor from './StringParamEditor';
const ParamEditorSelector = ({ open, onClose, nodeId, paramIndex }) => {
if (!open || paramIndex === null) return null;
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return null;
const param = localNode.nodeParams[nodeId][paramIndex];
const paramName = param.fields.name.toString();
// Determine parameter type
const isBoolean = param.fields.value.msg.fields.boolean_value !== undefined;
const isString = param.fields.value.msg.fields.string_value !== undefined;
const isNumeric = !isBoolean && !isString;
if (isBoolean) {
return <BooleanParamEditor
open={open}
onClose={onClose}
nodeId={nodeId}
paramIndex={paramIndex}
paramName={paramName}
/>;
}
if (isString) {
return <StringParamEditor
open={open}
onClose={onClose}
nodeId={nodeId}
paramIndex={paramIndex}
paramName={paramName}
/>;
}
return <NumberParamEditor
open={open}
onClose={onClose}
nodeId={nodeId}
paramIndex={paramIndex}
paramName={paramName}
/>;
};
export default ParamEditorSelector;
+96
View File
@@ -0,0 +1,96 @@
import React from 'react';
import { TextField, Box, Typography, IconButton, Tooltip } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StopIcon from '@mui/icons-material/Stop';
// Export properly with named exports
export const renderParamNameField = (name) => (
<TextField
label="Parameter Name"
value={name || "Unknown"}
InputProps={{
readOnly: true,
}}
size="small"
fullWidth
variant="outlined"
margin="dense"
/>
);
// Export properly with named exports
export const renderInfoField = (label, value, isRtttl = false, handlePlayCallback = null, isPlaying = false) => (
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
{isRtttl ? (
<Box sx={{ position: 'relative' }}>
<TextField
variant="outlined"
size="small"
fullWidth
multiline
rows={3}
value={value || ""}
InputProps={{
readOnly: true,
}}
sx={{
mt: 0.5,
'& .MuiOutlinedInput-root': {
backgroundColor: 'action.hover'
}
}}
/>
{handlePlayCallback && value && value !== "Error parsing melody data" && (
<Tooltip title={isPlaying ? "Stop tune" : "Play tune"}>
<IconButton
size="small"
color={isPlaying ? "secondary" : "primary"}
onClick={() => handlePlayCallback(value)}
sx={{
position: 'absolute',
bottom: '10px',
right: '10px',
width: '32px',
height: '32px'
}}
>
{isPlaying ? <StopIcon fontSize="small" /> : <PlayArrowIcon fontSize="small" />}
</IconButton>
</Tooltip>
)}
</Box>
) : (
<Typography variant="body2">
{value !== undefined && value !== "" && value !== null ? value : "Unknown"}
</Typography>
)}
</Box>
);
// Export properly with named exports
export const getParamValues = (param) => {
const paramValueField = param.fields.value.msg.unionField;
let paramMinValue = "";
if (param.fields.min_value.msg && param.fields.min_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramMinValue = param.fields.min_value.msg.unionField.value;
}
let paramMaxValue = "";
if (param.fields.max_value.msg && param.fields.max_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramMaxValue = param.fields.max_value.msg.unionField.value;
}
let paramDefaultValue = "";
if (param.fields.default_value.msg && param.fields.default_value.msg.unionField.name !== 'uavcan.protocol.param.Empty') {
paramDefaultValue = param.fields.default_value.msg.unionField.value;
}
return {
paramValueField,
paramMinValue,
paramMaxValue,
paramDefaultValue
};
};
+455
View File
@@ -0,0 +1,455 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, TextField, Box, Typography, Select, MenuItem,
FormControl, InputLabel, Divider, IconButton, Tooltip
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StopIcon from '@mui/icons-material/Stop';
import { renderParamNameField, renderInfoField, getParamValues } from './ParamEditorUtils';
import AM32_Rtttl from '../am32_rtttl';
// Common RTTTL tunes
const rtttlPresets = {
"BlueJay": "bluejay:b=570,o=4,d=32:4b,p,4e5,p,4b,p,4f#5,2p,4e5,2b5,8b5",
"Nokia Tune": "Nokia:d=4,o=5,b=63:e6,d6,f#,g#,c#6,b,d,e,b,a,c#,e,a",
"Mario": "Mario:d=4,o=5,b=125:a,a,a,a,a#,c6,a,g,e,c,d,a#4,c",
"Star Wars": "StarWars:d=4,o=5,b=112:8f,8f,8f,2a#.,2f.6,8d#6,8d6,8c6,2a#.6,f.6,8d#6,8d6,8c6,2a#.6,f.6,8d#6,8d6,8d#6,2c6",
"Pacman": "Pacman:d=16,o=6,b=140:b5,b,f#,d#,8b,8d#,c,c7,g,f,8c7,8e,b5,b,f#,d#,8b,8d#,c,g,c7,g,8f,8c7",
"Indiana": "Indiana:d=4,o=5,b=250:e,8p,8f,8g,8p,1c6,8p.,d,8p,8e,1f,p.,g,8p,8a,8b,8p,1f6,p,a,8p,8b,2c6,2d6,2e6,e,8p,8f,8g,8p,1c6",
"Mission": "Mission:d=16,o=6,b=95:32d,32d#,32d,32d#,32d,32d#,32d,32d#,32d,32d,32d#,32e,32f,32f#,32g,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,a#,g,2d"
};
const StringParamEditor = ({ open, onClose, nodeId, paramIndex, paramName }) => {
const [value, setValue] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isCurrentTunePlaying, setIsCurrentTunePlaying] = useState(false);
const [selectedPreset, setSelectedPreset] = useState("");
const [errorMessage, setErrorMessage] = useState('');
const [isValid, setIsValid] = useState(true);
const [previewTune, setPreviewTune] = useState('');
const isRTTLEditor = paramName === "STARTUP_TUNE";
// Load initial value
useEffect(() => {
const localNode = window.localNode;
if (!localNode?.nodeParams?.[nodeId] || !localNode.nodeParams[nodeId][paramIndex]) return;
const param = localNode.nodeParams[nodeId][paramIndex];
const paramValueField = param.fields.value.msg.unionField;
// Special handling for STARTUP_TUNE parameter
if (paramName === "STARTUP_TUNE") {
try {
// Get the binary string value
const binaryString = paramValueField.toString();
// Convert binary string to Uint8Array
const binaryData = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
binaryData[i] = binaryString.charCodeAt(i);
}
// Convert to RTTTL format
const rtttlString = AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune");
setValue(rtttlString);
} catch (err) {
console.error("Error converting binary data to RTTTL:", err);
setValue(""); // Set empty string on error
}
} else {
setValue(paramValueField.toString());
}
}, [nodeId, paramIndex, paramName]);
// Clean up audio on unmount
useEffect(() => {
return () => {
if (isPlaying) {
AM32_Rtttl.stopMelody();
}
};
}, [isPlaying]);
// Use effect to register melody end listener
useEffect(() => {
const melodyEndListener = () => {
setIsPlaying(false);
setIsCurrentTunePlaying(false);
};
AM32_Rtttl.addMelodyEndListener(melodyEndListener);
// Clean up on unmount
return () => {
AM32_Rtttl.removeMelodyEndListener(melodyEndListener);
AM32_Rtttl.stopMelody();
};
}, []);
const validateRtttl = (rtttlString) => {
console.log("Validating RTTTL:", rtttlString);
// Convert to string if it's a number or other type
const stringValue = String(rtttlString || '');
// Empty strings are valid (to allow clearing the tune)
if (!stringValue) {
setErrorMessage('');
return true;
}
// Check for basic RTTTL format
const isValidFormat = stringValue.includes(':') && stringValue.split(':').length === 3;
if (!isValidFormat) {
setErrorMessage('Invalid RTTTL format! Format should be: name:defaults:notes');
return false;
}
setErrorMessage(''); // Clear error if valid
return true;
};
const handleValueChange = (newValue) => {
setValue(newValue);
if (isRTTLEditor) {
setIsValid(validateRtttl(String(newValue)));
} else {
setIsValid(true);
}
};
const handlePlayTune = () => {
// If already playing, stop the melody
if (isPlaying) {
AM32_Rtttl.stopMelody();
return; // The melody end listener will reset the state
}
const tuneToPlay = value || '';
try {
// First validate that the tune has the basic RTTTL format
if (!tuneToPlay || !tuneToPlay.includes(':') || tuneToPlay.split(':').length !== 3) {
setErrorMessage('Invalid RTTTL format! Format should be: name:defaults:notes');
return;
}
// Clear any previous error when successful
setErrorMessage('');
// Stop any currently playing tune before starting a new one
AM32_Rtttl.stopMelody();
// Play the new tune
AM32_Rtttl.playMelody(tuneToPlay);
setPreviewTune(tuneToPlay);
setIsPlaying(true);
// Set up a timeout to detect when tune ends
const estimatedDuration = estimateTuneDuration(tuneToPlay);
setTimeout(() => {
setIsPlaying(false);
}, estimatedDuration + 500); // Add a small buffer
} catch (err) {
console.error("Error playing tune:", err);
setErrorMessage(`Error playing tune: ${err.message || 'Unknown error'}`);
setIsPlaying(false);
}
};
// Handle playing the current tune from the "Current RTTTL" field
const handlePlayCurrentTune = (tune) => {
if (isCurrentTunePlaying) {
AM32_Rtttl.stopMelody();
setIsCurrentTunePlaying(false);
return;
}
try {
// Validate that the tune has the basic RTTTL format
if (!tune || !tune.includes(':') || tune.split(':').length !== 3) {
setErrorMessage('Invalid RTTTL format in current tune');
return;
}
// Stop any playing tune
AM32_Rtttl.stopMelody();
// Reset other playing state
setIsPlaying(false);
// Play the current tune
AM32_Rtttl.playMelody(tune);
setIsCurrentTunePlaying(true);
} catch (err) {
console.error("Error playing current tune:", err);
setErrorMessage(`Error playing current tune: ${err.message || 'Unknown error'}`);
}
};
const estimateTuneDuration = (rtttlString) => {
try {
if (!rtttlString) return 2000; // Default duration if no tune
const parts = rtttlString.split(':');
if (parts.length !== 3) return 5000; // Default if format is wrong
const defaults = parts[1];
// Extract BPM from defaults
const bpmMatch = /b=(\d+)/i.exec(defaults);
const bpm = bpmMatch ? parseInt(bpmMatch[1], 10) : 120;
// Count notes in the tune
const notes = parts[2].split(',');
const noteCount = notes.length;
// Rough calculation: (60000 / bpm) gives ms per beat, multiply by estimated beats
return Math.min((60000 / bpm) * noteCount * 1.5, 30000); // Cap at 30 seconds
} catch (e) {
console.warn('Error estimating tune duration:', e);
return 5000; // Default fallback
}
};
const handleApplyPreset = () => {
if (selectedPreset) {
setValue(selectedPreset);
setSelectedPreset(""); // Reset selection after applying
setIsValid(true);
setErrorMessage('');
}
};
const renderRTTLEditor = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'flex-end' }}>
<FormControl fullWidth margin="dense">
<InputLabel id="rtttl-preset-label">Select Preset Tune</InputLabel>
<Select
labelId="rtttl-preset-label"
value={selectedPreset}
onChange={(e) => setSelectedPreset(e.target.value)}
>
<MenuItem value="" disabled>
<em>Choose a preset tune</em>
</MenuItem>
{Object.entries(rtttlPresets).map(([name, tune]) => (
<MenuItem key={name} value={tune}>
{name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
onClick={handleApplyPreset}
disabled={!selectedPreset}
sx={{ mb: 1 }}
>
Apply
</Button>
</Box>
<Box sx={{ position: 'relative' }}>
<TextField
label="RTTTL Tune"
value={value || ""}
onChange={(e) => handleValueChange(e.target.value)}
fullWidth
margin="dense"
multiline
rows={3}
placeholder="Format: name:d=duration,o=octave,b=bpm:notes"
error={!isValid && value !== ''}
helperText={!isValid && errorMessage ? errorMessage : "Enter RTTTL format tune or select a preset"}
InputProps={{
// Add some right padding to ensure text doesn't go under the button
sx: { pr: 5 }
}}
/>
{value && (
<Tooltip title={isPlaying ? "Stop tune" : "Play tune"}>
<IconButton
size="small"
color={isPlaying ? "secondary" : "primary"}
onClick={handlePlayTune}
sx={{
position: 'absolute',
bottom: '50%', // Center vertically in the input area
transform: 'translateY(50%)', // Adjust for perfect centering
right: '12px', // Position from right edge
width: '32px',
height: '32px',
zIndex: 1 // Ensure button is above text
}}
>
{isPlaying ? <StopIcon fontSize="small" /> : <PlayArrowIcon fontSize="small" />}
</IconButton>
</Tooltip>
)}
</Box>
<Divider />
<Box sx={{ bgcolor: 'action.hover', p: 1, borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
RTTTL Format Guide
</Typography>
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" display="block"> d=duration (1=whole, 2=half, 4=quarter, 8=eighth, 16=16th note)</Typography>
<Typography variant="caption" display="block"> o=octave (4-7 where 5 is default)</Typography>
<Typography variant="caption" display="block"> b=tempo (beats per minute)</Typography>
<Typography variant="caption" display="block"> Notes are: c, c#, d, d#, e, f, f#, g, g#, a, a#, b or h</Typography>
<Typography variant="caption" display="block"> Example: Beep:d=4,o=5,b=120:c</Typography>
</Box>
</Box>
</Box>
);
};
const handleSave = () => {
const localNode = window.localNode;
let valueToSave = value;
let conversionSuccessful = true;
// If this is the STARTUP_TUNE parameter, convert RTTTL string to byte array
if (isRTTLEditor) {
try {
// Validate RTTTL string format
const rtttlValue = value || "";
const isValidFormat = rtttlValue && rtttlValue.includes(':') && rtttlValue.split(':').length === 3;
let result; // Declare result variable outside the if/else blocks
if (!isValidFormat) {
// Warn user but continue with a default tune
setErrorMessage('Warning: Invalid RTTTL format! Using a default empty tune instead.');
setIsValid(false);
conversionSuccessful = false;
// Continue with a minimal valid RTTTL string
const tuneToParse = "Empty:d=4,o=5,b=120:";
result = AM32_Rtttl.to_am32_startup_melody(tuneToParse);
} else {
// Format is valid, proceed normally
setErrorMessage(''); // Clear any previous errors
result = AM32_Rtttl.to_am32_startup_melody(rtttlValue);
}
// Convert to binary string after we have the result
const binaryString = String.fromCharCode.apply(null, result.data);
valueToSave = binaryString;
// For debug purposes, show numeric values
console.log("Binary array values:", Array.from(result.data).slice(0, 30));
} catch (err) {
console.error("Error converting RTTTL to binary:", err);
setErrorMessage(`Error saving tune: ${err.message || 'Unknown error'}`);
setIsValid(false);
conversionSuccessful = false;
// Provide an empty binary string (all zeros) as fallback
const emptyArray = new Uint8Array(128);
valueToSave = String.fromCharCode.apply(null, emptyArray);
}
}
// Only close and save if conversion was successful
if (conversionSuccessful) {
localNode.setNodeParam(nodeId, paramIndex, valueToSave);
onClose();
}
};
if (value === null) return null;
const localNode = window.localNode;
const param = localNode?.nodeParams?.[nodeId]?.[paramIndex];
if (!param || !param.fields) return null;
const { paramValueField } = getParamValues(param);
return (
<Dialog
open={open}
onClose={onClose}
sx={{ '& .MuiDialog-paper': { minWidth: isRTTLEditor ? '600px' : '400px' } }}
>
<DialogTitle>{isRTTLEditor ? "Edit Tune Parameter" : "Edit String Parameter"}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{renderParamNameField(paramName)}
{!isRTTLEditor && (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<TextField
label="String Value"
value={value || ""}
onChange={(e) => handleValueChange(e.target.value)}
fullWidth
margin="dense"
multiline={Boolean(value && typeof value === 'string' && value.length > 30)}
rows={(value && typeof value === 'string' && value.length > 30) ? 3 : 1}
error={!isValid}
helperText={!isValid && errorMessage ? errorMessage : null}
/>
</Box>
)}
{isRTTLEditor && renderRTTLEditor()}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{isRTTLEditor ? (
renderInfoField(
"Current RTTTL",
(() => {
try {
// Get the binary string value
const binaryString = paramValueField.toString();
// Convert binary string to Uint8Array
const binaryData = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
binaryData[i] = binaryString.charCodeAt(i);
}
// Convert to RTTTL format
return AM32_Rtttl.from_am32_startup_melody(binaryData, "Tune");
} catch (err) {
console.error("Error converting binary data to RTTTL:", err);
return "Error parsing melody data";
}
})(),
true,
handlePlayCurrentTune,
isCurrentTunePlaying
)
) : (
renderInfoField("Current Value", paramValueField.toString())
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={!isValid}
>
Save
</Button>
</DialogActions>
</Dialog>
);
};
export default StringParamEditor;
+171
View File
@@ -0,0 +1,171 @@
import React, { useState, useEffect } from "react";
import { ThemeProvider, Box, Select, MenuItem, IconButton, TextField, Typography } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import DronecanLogo from './image/dronecan_logo.png';
import { toYaml } from './dronecan/message_format_utils';
import theme from './theme';
import './css/subscriber.css';
const SubscriberWindow = () => {
const [messages, setMessages] = useState([]);
const [messageRate, setMessageRate] = useState(0);
const [totalRX, setTotalRX] = useState(0);
const [maxMessageCount, setMaxMessageCount] = useState(5);
const [rxTimestamp, setRxTimestamp] = useState(0);
const [selectedMessageName, setSelectedMessageName] = useState("");
const [recordingSet, setRecordingSet] = useState([]);
const [displayRecordText, setDisplayRecordText] = useState("");
const [recording, setRecording] = useState(true);
if (window.opener === null) {
return "Not Allowed To Open Directly";
}
useEffect(() => {
const localNode = window.opener.localNode;
const handleMessageUpdate = (transfer) => {
if (transfer.payload.name === selectedMessageName && recording) {
setTotalRX(totalRX + 1);
}
if (messages.indexOf(transfer.payload.name) === -1) {
messages.push(transfer.payload.name);
setMessages(messages);
setRxTimestamp(Date.now());
}
if (recording && transfer.payload.name === selectedMessageName) {
recordingSet.push(transfer);
setRecordingSet(recordingSet.slice(-maxMessageCount));
}
if (transfer.payload.name === selectedMessageName) {
setRxTimestamp(Date.now());
}
let recordingSetText = "";
let rate = 0;
let tsDiffs = 0;
let lastTransferTs;
recordingSet.map((transfer, index) => {
if (index > 0) {
tsDiffs += transfer.tsMonotonic - lastTransferTs
}
lastTransferTs = transfer.tsMonotonic;
if (index > 0 && index === recordingSet.length - 1) {
rate = 1 / (tsDiffs / (recordingSet.length - 1))
setMessageRate(rate);
}
const msg = transfer.payload;
const msgObj = msg.toObj();
let destNodeText = "All";
if (transfer.destNodeId && transfer.destNodeId !== 0) {
destNodeText = `${transfer.destNodeId}`;
}
recordingSetText += `### Message from ${transfer.sourceNodeId} to ${destNodeText} ts_mono=${transfer.tsMonotonic.toFixed(15)} ts_real=${transfer.tsReal.toFixed(15)} \n`;
recordingSetText += toYaml(msgObj);
recordingSetText += "\n";
})
setDisplayRecordText(recordingSetText);
};
localNode.on('message', handleMessageUpdate);
return () => {
localNode.off('message', handleMessageUpdate);
};
});
const updateMaxMessageCount = (event) => {
if (event.target.value < 1) {
setMaxMessageCount(1);
} else if (event.target.value > 100) {
setMaxMessageCount(100);
} else {
setMaxMessageCount(event.target.value);
}
};
const handleSelectChange = (event) => {
if (event.target.value !== selectedMessageName) {
handleClean();
}
setSelectedMessageName(event.target.value);
};
const handleClean = () => {
setRecordingSet([]);
setTotalRX(0);
setMessageRate(0);
};
useEffect(() => {
const availableMessages = messages;
if (!selectedMessageName && availableMessages.length > 0) {
setSelectedMessageName(availableMessages[0]);
}
}, [rxTimestamp]);
const availableMessages = messages;
return (
<ThemeProvider theme={theme}>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1 }}>
<Box sx={{display: 'flex', flexDirection: 'row', flexGrow: 1}} >
<Box sx={{display: 'flex', flexDirection: 'row', alignItems: 'center'}} ml={0.5} mr={0.5}>
<IconButton
component="a"
href="https://dronecan.github.io/Specification/7._List_of_standard_data_types"
target="_blank"
rel="noreferrer"
size="small"
sx={{
p: 0.5,
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)'
}
}}
>
<img src={DronecanLogo} alt="DroneCAN" style={{height: 30}} />
</IconButton>
</Box>
<Select
sx={{ minWidth: 250 }}
size="small"
value={selectedMessageName}
onChange={handleSelectChange}
>
{availableMessages.map((messageName, index) => (
<MenuItem key={index} value={messageName}>{messageName}</MenuItem>
))}
</Select>
<IconButton ml={5} size="small" onClick={() => setRecording(!recording)}>
{recording ? <PauseIcon /> : <PlayArrowIcon />}
</IconButton>
</Box>
<Box sx={{display: 'flex', flexDirection: 'row', alignItems: 'center', mr: 0.5}}>
<Box sx={{minWidth: 200, display: 'flex', flexDirection: 'row'}}>
<Box sx={{flexGrow: 1}}></Box>
<Typography variant="caption" mr={0.5}>RX:</Typography>
<Typography variant="caption" mr={1} sx={{minWidth: 30}}>{totalRX}</Typography>
<Typography variant="caption" mr={0.5}>Rates(Hz):</Typography>
<Typography variant="caption" mr={1} sx={{minWidth: 30}}>{messageRate.toFixed(0)}</Typography>
</Box>
<Typography variant="caption" mr={1}> Max:</Typography>
<TextField size="small" sx={{width: 80}} type="number" min={1} max={100} value={maxMessageCount} onChange={updateMaxMessageCount} />
<IconButton size="small" onClick={handleClean}>
<CleaningServicesIcon />
</IconButton>
</Box>
</Box>
<Box sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, bgcolor: 'background.paper', height: '100%', width: '100%'}}>
<textarea className="subscriber-textarea" readOnly value={displayRecordText} />
</Box>
</Box>
</ThemeProvider>
);
}
export default SubscriberWindow;
+74
View File
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { Box, Button, Menu, MenuItem, Divider } from '@mui/material';
import BuildIcon from '@mui/icons-material/Build';
import MessageIcon from '@mui/icons-material/Message';
import SettingsInputCompositeIcon from '@mui/icons-material/SettingsInputComposite';
const ToolsMenu =({openWindow}) => {
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSubscriberClick = () => {
openWindow(
"Subscriber",
"subscriber.html",
"width=800,height=400",
);
handleClose();
};
const handleBusMonitorClick = () => {
openWindow(
"Bus Monitor",
"bus_monitor.html",
"width=1000,height=400",
);
handleClose();
};
return (
<Box mr={1}>
<Button
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
disableElevation
onClick={handleClick}
color="default"
startIcon={<BuildIcon />}
>
Tools
</Button>
<Menu
elevation={0}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleSubscriberClick} disableRipple>
Subscriber
</MenuItem>
<MenuItem onClick={handleBusMonitorClick} disableRipple>
Bus Monitor
</MenuItem>
</Menu>
</Box>
);
}
export default ToolsMenu;
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import ActuatorPanelWindow from './ActuatorPanelWindow';
const root = createRoot(document.getElementById('sub-root'));
root.render(<ActuatorPanelWindow />);
+475
View File
@@ -0,0 +1,475 @@
class AM32_Rtttl {
static parse(rtttl) {
// Ensure rtttl is a string
if (typeof rtttl !== 'string') {
rtttl = String(rtttl || '');
}
const REQUIRED_SECTIONS_NUM = 3;
const SECTIONS = rtttl.split(':');
// Validate format
if (SECTIONS.length !== REQUIRED_SECTIONS_NUM) {
// Instead of throwing, provide a default minimal valid structure
console.warn('Invalid RTTTL string, using default values');
return {
name: 'Empty',
defaults: {
duration: '4',
octave: '5',
bpm: '120'
},
melody: []
};
}
const NAME = AM32_Rtttl.get_name(SECTIONS[0]);
const DEFAULTS = AM32_Rtttl.get_defaults(SECTIONS[1]);
const MELODY = AM32_Rtttl.get_data(SECTIONS[2], DEFAULTS);
return {
name: NAME,
defaults: DEFAULTS,
melody: MELODY
};
}
static to_am32_startup_melody(rtttl, startup_melody_length = 128) {
if (rtttl === '') {
return {
data: new Uint8Array(128),
errorCodes: null
};
}
const parsed_data = AM32_Rtttl.parse(rtttl);
if (startup_melody_length < 4) {
throw new Error('startupMelodyLength is too small to fit a am32 Startup Melody');
}
const MAX_ITEM_VALUE = 2**8;
const melody = parsed_data.melody;
const result = new Uint8Array(startup_melody_length);
const error_codes = Array(melody.length).fill(0);
const bpm = parseInt(parsed_data.defaults.bpm) % (2**16);
result[0] = (bpm >> 8) & (2**8 - 1);
result[1] = bpm & (2**8 - 1);
result[2] = parseInt(parsed_data.defaults.octave) % MAX_ITEM_VALUE;
result[3] = parseInt(parsed_data.defaults.duration) % MAX_ITEM_VALUE;
let current_result_index = 4;
let current_melody_index = 0;
while (current_melody_index < melody.length && current_result_index < result.length) {
const item = melody[current_melody_index];
if (item.frequency !== 0) {
const temp3 = AM32_Rtttl._calculate_am32_temp3_from_frequency(item.frequency);
if (0 < temp3 && temp3 < MAX_ITEM_VALUE) {
const duration_per_pulse_ms = 1000 / item.frequency;
let pulses_needed = Math.round(item.duration / duration_per_pulse_ms);
while (pulses_needed > 0 && current_result_index < result.length) {
result[current_result_index] = Math.min(pulses_needed, MAX_ITEM_VALUE - 1);
result[current_result_index + 1] = temp3;
current_result_index += 2;
pulses_needed -= result[current_result_index - 2];
}
if (pulses_needed > 0) {
error_codes[current_melody_index] = 2;
} else {
error_codes[current_melody_index] = 0;
}
} else {
error_codes[current_melody_index] = 1;
}
} else {
let duration = Math.round(item.duration);
while (duration > 0 && current_result_index < result.length) {
result[current_result_index] = Math.min(duration, MAX_ITEM_VALUE - 1);
result[current_result_index + 1] = 0;
current_result_index += 2;
duration -= result[current_result_index - 2];
}
if (duration > 0) {
error_codes[current_melody_index] = 2;
} else {
error_codes[current_melody_index] = 0;
}
}
current_melody_index += 1;
}
while (current_melody_index < melody.length) {
error_codes[current_melody_index] = 2;
current_melody_index += 1;
}
return {
data: result,
errorCodes: error_codes
};
}
static is_am32_melody_param(param_struct) {
// Note: Adapt to your specific DroneCAN implementation
return param_struct.name === "STARTUP_TUNE" &&
(param_struct.getActiveUnionField?.() === 'string_value' ||
param_struct.value.hasOwnProperty('string_value'));
}
static is_am32_melody_param_from_file(name) {
return name === "STARTUP_TUNE";
}
static from_am32_startup_melody(startup_melody_data, melody_name = 'Melody') {
if (startup_melody_data instanceof Uint8Array &&
startup_melody_data.every(byte => byte === 255 || byte === 0)) {
return `${melody_name}:d=1,o=4,bpm=100:`;
}
if (startup_melody_data.length < 4) {
return `${melody_name}:d=1,o=4,bpm=100:`;
}
const defaults = {
bpm: (startup_melody_data[0] << 8) + startup_melody_data[1],
octave: startup_melody_data[2],
duration: startup_melody_data[3]
};
const melody_notes = [];
for (let i = 4; i < startup_melody_data.length - 1; i += 2) {
const freq = AM32_Rtttl._calculate_frequency_from_am32_temp3(startup_melody_data[i + 1]);
const note = AM32_Rtttl._calculate_note_name_from_frequency(freq);
const octave = AM32_Rtttl._calculate_note_octave_from_frequency(freq);
const dur = freq === 0 ?
startup_melody_data[i] :
(1000 / AM32_Rtttl._calculate_frequency(note, octave)) * startup_melody_data[i];
if (dur > 0) {
if (melody_notes.length > 0 &&
Math.abs(melody_notes[melody_notes.length - 1].frequency - freq) < 0.01 &&
startup_melody_data[i - 2] === 255) {
melody_notes[melody_notes.length - 1].duration += dur;
} else {
melody_notes.push({
duration: dur,
frequency: freq,
musicalNote: note,
musicalOctave: octave
});
}
} else {
break;
}
}
const full_note_duration = 4 * 60000 / defaults.bpm;
const smallest_musical_duration = full_note_duration / 64;
const quantized_duration = (duration) => {
return Math.round(duration / smallest_musical_duration) * smallest_musical_duration;
};
let melody_string = '';
for (const item of melody_notes) {
let musical_duration = quantized_duration(item.duration) / full_note_duration;
while (musical_duration > 1 / 64) {
const current_duration = Math.min(1.5, musical_duration);
const rtttl_duration = 2 ** -Math.floor(Math.log2(current_duration));
const is_dotted_note = current_duration * rtttl_duration > 1;
melody_string += (rtttl_duration === defaults.duration ? '' : rtttl_duration.toString()) +
item.musicalNote +
(item.musicalOctave === defaults.octave || item.musicalOctave === 0 ? '' : item.musicalOctave.toString()) +
(is_dotted_note ? '.' : '') + ',';
musical_duration -= current_duration;
}
}
return `${melody_name}:b=${defaults.bpm},o=${defaults.octave},d=${defaults.duration}:${melody_string.replace(/,$/,'')}`;
}
static get_melody_string_from_dronecan_param_value(value) {
if (value.every(item => item === 255)) {
return 'MelodyMelody:d=1,o=4,bpm=100:';
}
const melody_array = new Uint8Array(128);
for (let i = 0; i < value.length; i++) {
melody_array[i] = value[i];
}
const melody_string = AM32_Rtttl.from_am32_startup_melody(melody_array, "Melody");
return melody_string;
}
static get_name(name) {
const MAX_LENGTH = 10;
if (name.length > MAX_LENGTH) {
console.warn('Warning: Tune name should not exceed 10 characters.');
}
return name || 'Unknown';
}
static get_defaults(defaults) {
const VALUES = defaults.split(',');
const ALLOWED_DURATION = ['1', '2', '4', '8', '16', '32'];
const ALLOWED_OCTAVE = ['4', '5', '6', '7'];
const ALLOWED_BPM = [
'25', '28', '31', '35', '40', '45', '50', '56', '63', '70', '80', '90', '100',
'112', '125', '140', '160', '180', '200', '225', '250', '285', '320', '355',
'400', '450', '500', '565', '570', '635', '715', '800', '900'
];
const DEFAULT_VALUES = {
duration: '4',
octave: '6',
bpm: '63'
};
for (const value of VALUES) {
if (value) {
const [KEY, VAL] = value.split('=');
if (KEY === 'd' && ALLOWED_DURATION.includes(VAL)) {
DEFAULT_VALUES.duration = VAL;
} else if (KEY === 'o' && ALLOWED_OCTAVE.includes(VAL)) {
DEFAULT_VALUES.octave = VAL;
} else if (KEY === 'b' && ALLOWED_BPM.includes(VAL)) {
DEFAULT_VALUES.bpm = VAL;
}
}
}
return { ...DEFAULT_VALUES };
}
static _calculate_semitones_from_c4(note, octave) {
const NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
const MIDDLE_OCTAVE = 4;
const SEMITONES_IN_OCTAVE = 12;
const OCTAVE_JUMP = (parseInt(octave) - MIDDLE_OCTAVE) * SEMITONES_IN_OCTAVE;
return NOTE_ORDER.indexOf(note) + OCTAVE_JUMP;
}
static get_data(melody, defaults) {
const NOTES = melody.split(',');
const BEAT_EVERY = 60000 / parseInt(defaults.bpm);
const calculate_duration = (beat_every, note_duration, dots) => {
const DURATION = (beat_every * 4) / note_duration;
return DURATION * (dots === 4 ? 1.9375 : dots === 3 ? 1.875 : dots === 2 ? 1.75 : dots === 1 ? 1.5 : 1);
};
const calculate_frequency = (note, octave) => {
if (note === 'p') {
return 0;
}
const C4 = 261.63;
const TWELFTH_ROOT = Math.pow(2, 1/12);
const N = AM32_Rtttl._calculate_semitones_from_c4(note, octave);
return Math.round(C4 * Math.pow(TWELFTH_ROOT, N) * 10) / 10;
};
const NOTE_REGEX = /^(1|2|4|8|16|32|64)?((?:[a-g]|h|p)#?)(\.*)(1|2|3|4|5|6|7|8)?(\.*)/;
const parsed_notes = [];
for (const note of NOTES) {
if (!note) continue;
const match = NOTE_REGEX.exec(note);
if (match) {
const NOTE_DURATION = match[1] || defaults.duration;
const NOTE = match[2] === 'h' ? 'b' : match[2];
const NOTE_OCTAVE = match[4] || defaults.octave;
const NOTE_DOTS = (match[3] ? match[3].length : 0) + (match[5] ? match[5].length : 0);
parsed_notes.push({
note: NOTE,
duration: calculate_duration(BEAT_EVERY, parseFloat(NOTE_DURATION), NOTE_DOTS),
frequency: calculate_frequency(NOTE, NOTE_OCTAVE)
});
}
}
return parsed_notes;
}
static _calculate_am32_temp3_from_frequency(freq) {
return freq === 0 ? 0 : Math.round(1000000 / (freq * 24.72) - 399.3 / 24.72);
}
static _calculate_frequency_from_am32_temp3(temp3) {
return temp3 === 0 ? 0 : 1000000 / (24.72 * temp3 + 399.3);
}
static _calculate_note_name_from_frequency(freq) {
if (freq === 0) {
return 'p';
}
const C4 = 261.63;
const NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
const SEMITONES_IN_OCTAVE = 12;
const note_semitones = Math.round(SEMITONES_IN_OCTAVE * Math.log2(freq / C4));
const note_index = note_semitones >= 0
? note_semitones % SEMITONES_IN_OCTAVE
: 12 + (note_semitones % SEMITONES_IN_OCTAVE);
return NOTE_ORDER[note_index];
}
static _calculate_frequency(note, octave) {
if (note === 'p') {
return 0;
}
const C4 = 261.63;
const NOTE_ORDER = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
const SEMITONES_IN_OCTAVE = 12;
const MIDDLE_OCTAVE = 4;
const note_index = NOTE_ORDER.indexOf(note);
const octave_diff = parseInt(octave) - MIDDLE_OCTAVE;
const semitone_diff = note_index + (octave_diff * SEMITONES_IN_OCTAVE);
return C4 * Math.pow(2, semitone_diff / SEMITONES_IN_OCTAVE);
}
static _calculate_note_octave_from_frequency(freq) {
if (freq === 0) {
return 0;
}
const C4 = 261.63;
const MIDDLE_OCTAVE = 4;
const SEMITONES_IN_OCTAVE = 12;
const note_semitones = Math.round(SEMITONES_IN_OCTAVE * Math.log2(freq / C4));
return MIDDLE_OCTAVE + Math.floor(note_semitones / SEMITONES_IN_OCTAVE);
}
static _audioContext = null; // Class property to store the current audio context
static _onMelodyEndListeners = [];
static playMelody(rtttl) {
try {
// Stop any currently playing melody first
this.stopMelody();
// Basic validation
if (!rtttl || typeof rtttl !== 'string' || !rtttl.includes(':')) {
console.warn("Invalid RTTTL format, cannot play");
return false;
}
// Create new audio context
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioContext = this._audioContext;
const parsedData = this.parse(rtttl);
let startTime = audioContext.currentTime;
// Only proceed if we have melody data
if (!parsedData.melody || parsedData.melody.length === 0) {
console.warn("No melody data to play");
return false;
}
// Store references to oscillators and gain nodes for potential cleanup
this._audioNodes = [];
// Calculate total melody duration for the end callback
let totalDuration = 0;
parsedData.melody.forEach(note => {
// Create audio nodes for each note
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.frequency.value = note.frequency || 0;
oscillator.type = 'sine';
gainNode.gain.value = 0.3; // Adjust volume
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
if (note.frequency > 0) {
oscillator.start(startTime);
oscillator.stop(startTime + note.duration / 1000);
}
// Store nodes for potential cleanup
this._audioNodes.push({ oscillator, gainNode });
// Update total duration
totalDuration = Math.max(totalDuration, startTime + note.duration / 1000 - audioContext.currentTime);
startTime += note.duration / 1000;
});
// Set up a timer to notify when melody ends
setTimeout(() => {
this._notifyMelodyEnd();
}, (totalDuration * 1000) + 100); // Convert to ms and add a small buffer
return true;
} catch (err) {
console.error("Error in playMelody:", err);
// Clean up in case of error
this.stopMelody();
return false;
}
}
static stopMelody() {
try {
// If there's an active AudioContext, close it
if (this._audioContext) {
// In most browsers, close() is asynchronous and returns a promise
this._audioContext.close().catch(err => {
console.warn("Error closing AudioContext:", err);
});
// Clear the reference
this._audioContext = null;
this._audioNodes = [];
// Notify melody end listeners
this._notifyMelodyEnd();
}
} catch (err) {
console.warn("Error in stopMelody:", err);
}
}
static _notifyMelodyEnd() {
// Call all registered listeners
this._onMelodyEndListeners.forEach(callback => {
try {
callback();
} catch (err) {
console.warn("Error in melody end listener:", err);
}
});
}
static addMelodyEndListener(callback) {
this._onMelodyEndListeners.push(callback);
}
static removeMelodyEndListener(callback) {
this._onMelodyEndListeners = this._onMelodyEndListeners.filter(cb => cb !== callback);
}
}
export default AM32_Rtttl;
// Example of how to use the class
// const rtttl_string = "bluejay:b=570,o=4,d=32:4b,p,4e5,p,4b,p,4f#5,2p,4e5,2b5,8b5";
// const am32_melody = AM32_Rtttl.to_am32_startup_melody(rtttl_string, 128);
// console.log("AM32 EEPROM Struct:", Array.from(am32_melody.data));
// const melody_string = AM32_Rtttl.from_am32_startup_melody(am32_melody.data, "bluejay_converted");
// console.log("Converted Melody String:", ody_string);
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import BusMonitorWindow from './BusMonitorWindow';
const root = createRoot(document.getElementById('sub-root'));
root.render(<BusMonitorWindow />);
+8
View File
@@ -0,0 +1,8 @@
function secondsToTime(seconds) {
seconds = Math.max(0, Math.floor(seconds));
const date = new Date(seconds * 1000);
const timeStr = date.toISOString().substr(11, 8);
return seconds < 3600 ? timeStr.slice(3) : timeStr;
}
export { secondsToTime };
+32
View File
@@ -0,0 +1,32 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: rgb(22, 13, 13);
overflow: hidden;
}
#root {
height: 100vh;
width: 100vw;
}
.menu-icon {
display: flex;
justify-content: center;
align-items: center;
margin-right: 1.5px;
}
.compact-sidebar {
transition: width 0.3s ease;
}
@media (max-width: 1200px) {
.compact-sidebar {
width: 60px;
}
}
+11
View File
@@ -0,0 +1,11 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#sub-root {
height: 100vh;
width: 100vw;
overflow: hidden;
}
+19
View File
@@ -0,0 +1,19 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#sub-root {
height: 100vh;
width: 100vw;
overflow: hidden;
}
.subscriber-textarea {
width: 100vw;
height: 100vh;
padding: 5px;
line-height: 1.5;
font-size: 12px;
}
+116
View File
@@ -0,0 +1,116 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Type = require('./type');
const PrimitiveType = require('./primitive_type');
class ArrayType extends Type {
static MODE_STATIC = 0;
static MODE_DYNAMIC = 1;
constructor(value_type, mode, max_size) {
let normalizedDefinition;
if (mode === ArrayType.MODE_DYNAMIC) {
if (value_type.category === Type.CATEGORY_PRIMITIVE) {
normalizedDefinition = `${value_type.getNormalizedDefinition()}[<=${max_size}]`;
} else {
//TODO
}
} else {
if (value_type.category === Type.CATEGORY_PRIMITIVE) {
normalizedDefinition = `${value_type.getNormalizedDefinition()}[${max_size}]`;
} else {
//TODO
}
}
// Call parent constructor with the normalized definition and the CATEGORY_ARRAY constant.
super(normalizedDefinition, Type.CATEGORY_ARRAY);
this.value_type = value_type;
this.mode = mode;
this.max_size = max_size;
this.items = [];
}
get length() {
return this.items.length;
}
toString() {
const buf = this.items.map((item) => { return item.value });
return String.fromCharCode(...buf);
}
fromString(str) {
this.items = str.split('').map((char) => {
return new PrimitiveType(char.charCodeAt(0), PrimitiveType.KIND_UNSIGNED_INT, 8);
});
}
toObj(forceArray = false) {
if (!forceArray && this.isStringLike) {
return this.toString();
} else {
let obj = [];
for (let item of this.items) {
if (item.category === Type.CATEGORY_PRIMITIVE) {
obj.push(Number(item.value));
} else if (item.category === Type.CATEGORY_COMPOUND) {
obj.push(item.toObj());
} else {
new Error('Unknown type category in ArrayType item');
}
}
return obj;
}
}
getNormalizedDefinition() {
const typedef = this.value_type.getNormalizedDefinition();
if (this.mode === ArrayType.MODE_DYNAMIC) {
return `${typedef}[<=${this.max_size}]`;
} else {
return `${typedef}[${this.max_size}]`;
}
}
static bitLength(n) {
return n > 0 ? Math.floor(Math.log2(n)) + 1 : 0;
}
push(item) {
if (this.items.length >= this.max_size) {
throw new Error("Array is full");
}
this.items.push(item);
}
getMaxBitlen() {
const payload_max_bitlen = this.max_size * this.value_type.getMaxBitlen();
if (this.mode === ArrayType.MODE_DYNAMIC) {
return payload_max_bitlen + ArrayType.bitLength(this.max_size);
} else {
return payload_max_bitlen;
}
}
getMinBitlen() {
if (this.mode === ArrayType.MODE_STATIC) {
return this.value_type.getMinBitlen() * this.max_size;
} else {
return 0;
}
}
getDataTypeSignature() {
return this.value_type.getDataTypeSignature();
}
get isStringLike() {
return this.mode === ArrayType.MODE_DYNAMIC &&
this.value_type.category === Type.CATEGORY_PRIMITIVE &&
this.value_type.bitlen === 8;
}
}
module.exports = ArrayType;
+31
View File
@@ -0,0 +1,31 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const jspack = require("jspack").jspack;
function packBigInt64LE(value) {
const low = Number(value & 0xFFFFFFFFn);
const high = Number((value >> 32n) & 0xFFFFFFFFn);
return jspack.Pack("<II", [low, high]);
}
function crc16FromBytes(buffer, initial = 0xFFFF) {
let crc = Number(initial);
for (let byte of buffer) {
crc ^= byte << 8; // Move byte to MSB of 16-bit CRC
for (let i = 0; i < 8; i++) {
if (crc & 0x8000) { // Check MSB
crc = ((crc << 1) ^ 0x1021) & 0xFFFF;
} else {
crc = (crc << 1) & 0xFFFF;
}
}
}
return crc & 0xFFFF;
}
module.exports = {
crc16FromBytes, packBigInt64LE
};
+131
View File
@@ -0,0 +1,131 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Type = require('./type');
function computeSignature(text) {
return text.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0).toString();
}
function bytesFromCRC64(val) {
return Buffer.from(val);
}
class Signature {
constructor(value) {
this._value = value;
}
getValue() {
return this._value;
}
add(bytesBuffer) {
this._value += bytesBuffer.toString('hex');
}
}
class CompoundType extends Type {
static KIND_SERVICE = 0;
static KIND_MESSAGE = 1;
constructor(msg, version=null) {
super(msg.name, Type.CATEGORY_COMPOUND);
this.default_dtid = msg.id;
this.version = version;
this.msg = msg;
this._data_type_signature = null;
this.override_signature = null; // Optional override
}
toObj() {
return this.msg.toObj();
}
get fieldNames() {
return this.msg.fieldNames;
}
get fields() {
return this.msg.fields;
}
get unionFieldIndex() {
return this.msg.unionFieldIndex;
}
pack(tao=true) {
return this.msg.pack(tao);
}
_instantiate(...args) {
return null;
}
call(...args) {
return this._instantiate(...args);
}
getDsdlSignatureSourceDefinition() {
let lines = [];
lines.push(this.full_name);
const adjoin = (attrs) => {
if (attrs.length > 0) {
lines.push(attrs.map(x => x.getNormalizedDefinition()).join('\n'));
}
};
if (this.kind === CompoundType.KIND_SERVICE) {
if (this.request_union) {
lines.push('@union');
}
adjoin(this.request_fields);
lines.push('---');
if (this.response_union) {
lines.push('@union');
}
adjoin(this.response_fields);
} else if (this.kind === CompoundType.KIND_MESSAGE) {
if (this.union) {
lines.push('@union');
}
adjoin(this.fields);
} else {
throw new Error(`Compound type of unknown kind [${this.kind}]`);
}
return lines.join('\n').replace(/\n{3,}/g, '\n').trim();
}
getDsdlSignature() {
if (this.override_signature !== null) {
return this.override_signature;
}
return computeSignature(this.getDsdlSignatureSourceDefinition());
}
getNormalizedDefinition() {
return this.full_name;
}
getDataTypeSignature() {
if (this._data_type_signature === null) {
let sig = new Signature(this.getDsdlSignature());
let fields = (this.kind === CompoundType.KIND_SERVICE)
? (this.request_fields.concat(this.response_fields))
: this.fields;
for (const field of fields) {
const fieldSig = field.type.getDataTypeSignature();
if (fieldSig !== null) {
const sigValue = sig.getValue();
sig.add(bytesFromCRC64(fieldSig));
sig.add(bytesFromCRC64(sigValue));
}
}
this._data_type_signature = sig.getValue();
}
return this._data_type_signature;
}
}
module.exports = CompoundType;
+22903
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
class Frame {
constructor(can_id, data, extended, canfd) {
this.id = can_id;
this.data = data instanceof Array ? new Uint8Array(data) : data;
this.extended = extended;
this.tsMonotonic = performance.now() / 1000; // Use performance.now() for monotonic time
this.tsReal = Date.now() / 1000; // Use Date.now() for real time
this.canfd = canfd;
this.MAX_DATA_LENGTH = canfd ? 64 : 8;
};
get transferKey() {
// The transfer is uniquely identified by the message ID and the 5-bit Transfer ID contained in the last byte of the frame payload.
return [this.id, this.data.length > 0 ? (this.data[this.data.length - 1] & 0x1F) : null];
}
get startOfTransfer() {
return this.data.length > 0 ? Boolean(this.data[this.data.length - 1] & 0x80) : false;
}
get endOfTransfer() {
return this.data.length > 0 ? Boolean(this.data[this.data.length - 1] & 0x40) : false;
}
get dataTypeId() {
return (this.id >> 8) & 0xFFFF;
}
get sourceNodeId() {
return this.id & 0x7F;
};
get destNodeId() {
return (this.id >> 8) & 0x7F;
}
get priority() {
return (this.id >> 24) & 0x1F;
}
get serviceTypeId() {
return (this.id >> 16) & 0xFF;
}
isService() {
return (this.id >> 7) & 0x1;
}
isExtended() {
return this.extended;
};
isFD() {
return this.canfd;
};
}
module.exports = Frame;
+34
View File
@@ -0,0 +1,34 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Node = require('./node');
const TransferManager = require('./transfer_manager');
const Frame = require('./frame');
const ArrayType = require('./array_type');
const CompoundType = require('./compound_type');
const PrimitiveType = require('./primitive_type');
const DSDL = require('./dsdl');
function parseFrameMessage(messageId, buffer) {
const MessageClass = DSDL.messages[messageId];
if (!MessageClass) {
console.error(`Unknown message id: ${messageId}`);
return null;
}
return MessageClass.unpack(buffer);
}
function toYaml(msg) {
}
module.exports = {
Node,
TransferManager,
Frame,
ArrayType,
CompoundType,
PrimitiveType,
DSDL,
parseFrameMessage
};
+140
View File
@@ -0,0 +1,140 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const isDict = (obj) => {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
};
export function toYaml(msgObj) {
if (!msgObj) return '';
let yamlText = '';
// Process all properties that are not functions
Object.keys(msgObj).forEach(key => {
if (typeof msgObj[key] !== 'function') {
yamlText += getMsgObjText(msgObj, key, " ");
}
});
return yamlText;
function getMsgObjText(msgObj, key, tab) {
let localText = '';
if (typeof msgObj[key] !== 'function') {
if (isDict(msgObj[key])) {
localText += `${key}:\n`;
Object.keys(msgObj[key]).forEach(subKey => {
if (typeof msgObj[key][subKey] !== 'function') {
localText += tab;
localText += getFieldItems(msgObj[key], subKey, tab + " ");
}
});
} else {
localText += getFieldItems(msgObj, key, tab);
}
}
return localText;
}
function getFieldItems(msgObj, key, tab = "") {
let constant = "";
let text = '';
// Get constant value if available
if (msgObj.getConstant && typeof msgObj.getConstant === 'function') {
try {
const constValue = msgObj.getConstant(key);
if (constValue) {
constant = ` # ${constValue}`;
}
} catch (e) {}
}
// Handle arrays
if (Array.isArray(msgObj[key])) {
if (msgObj[key].length > 0 && isDict(msgObj[key][0])) {
text += `${key}:${constant}\n`;
msgObj[key].forEach((item) => {
text += `${tab} - `;
if (item && typeof item === 'object') {
text += '\n';
let objToProcess = item;
if (typeof item.toObj === 'function') {
objToProcess = item.toObj();
}
for (const prop in objToProcess) {
if (typeof objToProcess[prop] !== 'function') {
let propConstant = "";
let value = objToProcess[prop];
if (item.getConstant && typeof item.getConstant === 'function') {
try {
const constValue = item.getConstant(prop);
if (constValue) {
propConstant = ` # ${constValue}`;
}
} catch (e) {}
}
if (!propConstant && item._parent && item._parent.getConstant) {
try {
const parentConstant = item._parent.getConstant(prop);
if (parentConstant) {
propConstant = ` # ${parentConstant}`;
}
} catch (e) {}
}
if (typeof value === 'string') {
value = `"${value}"`;
}
text += `${tab} ${prop}: ${value}${propConstant}\n`;
}
}
} else {
text += `${item}\n`;
}
});
} else {
text += `${key}: [${msgObj[key].join(', ')}]${constant}\n`;
}
} else {
// Format simple values
let value = msgObj[key];
if (typeof value === 'string') {
value = `"${value}"`;
}
text += `${key}: ${value}${constant}\n`;
}
return text;
}
}
export function toJson(msgObj, indent = 2) {
if (!msgObj) return '';
try {
// First try to use toObj if available
const objToConvert = typeof msgObj.toObj === 'function'
? msgObj.toObj()
: msgObj;
// Filter out functions since they can't be serialized
const filtered = JSON.parse(JSON.stringify(objToConvert, (key, value) => {
return typeof value === 'function' ? undefined : value;
}));
return JSON.stringify(filtered, null, indent);
} catch (e) {
console.error('Error converting message to JSON:', e);
return `{"error": "Failed to convert to JSON: ${e.message}"}`;
}
}
+456
View File
@@ -0,0 +1,456 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const EventEmitter = require('events');
const TransferManager = require('./transfer_manager');
const CompoundType = require('./compound_type');
const PrimitiveType = require('./primitive_type');
const Frame = require('./frame');
const Transfer = require('./transfer');
const DSDL = require('./dsdl');
var Buffer = require('buffer').Buffer;
const REQUEST_PRIORITY = 30;
class Node extends EventEmitter {
constructor({
nodeId = null,
name = 'dronecan.js',
nodeStatusInterval = 1.0,
mode = 0,
nodeInfo = null,
catchHandlerExceptions = true,
sendCanfd = false
} = {}) {
super();
this.nodeId = nodeId;
this.name = name;
this.nodeStatusInterval = nodeStatusInterval;
this.mode = mode;
this.nodeInfo = nodeInfo;
this.catchHandlerExceptions = catchHandlerExceptions;
this.sendCanfd = sendCanfd;
this.bus = 0;
this.nodeMonitors = {};
this.changeBusTimestamp = 0;
this.nodeParams = {};
this.nodeParamsRequestingIndex = {};
this.nodeParamsRequestingNodeId = null;
this.subscribers = {};
this.subscribersMaxItems = 10;
this.transferManager = new TransferManager();
this.priority = 20; // Set default priority
this.callbacks = new Map();
this.uptimeBegin = new Date();
this.status = new DSDL.uavcan_protocol_NodeStatus();
this.status.fields.uptime_sec.value = 0;
this.status.fields.health.value = 0;
this.status.fields.mode.value = mode;
this.status.fields.sub_mode.value = 0;
this.status.fields.vendor_specific_status_code.value = 0;
this.status.fields.uptime_sec.value = parseInt((new Date() - this.uptimeBegin) / 1000);
this.hardware_version = new DSDL.uavcan_protocol_HardwareVersion();
this.hardware_version.fields.major.value = 1;
this.hardware_version.fields.minor.value = 0;
this.hardware_version.fields.unique_id.items = new Array(16).fill(new PrimitiveType(0, PrimitiveType.KIND_UNSIGNED_INT, 8));;
this.hardware_version.fields.certificate_of_authenticity.items = [];
this.software_version = new DSDL.uavcan_protocol_SoftwareVersion();
this.software_version.fields.major.value = 1;
this.software_version.fields.minor.value = 0;
this.software_version.fields.vcs_commit.value = 0;
this.software_version.fields.optional_field_flags.value = 1;
this.software_version.fields.image_crc.value = 0;
const nodeStatusIntervalID = setInterval(() => {
if (this.nodeId) {
this.status.fields.uptime_sec.value = parseInt((new Date() - this.uptimeBegin) / 1000);
this.request({req: this.status, destNodeId: 0, serviceNotMessage: false, priority: REQUEST_PRIORITY});
}
}, 1000 * nodeStatusInterval);
this.nodeStatusIntervalID = nodeStatusIntervalID;
this.on('can-frame', (id, data, len) => {
let buf = Buffer.from(data, 'binary');
buf = buf.slice(0, len);
let canId = id & 0x1FFFFFFF;
const canFrame = new Frame(
canId,
buf,
true,
false
);
this.handleFrame(canFrame);
});
this.on('uavcan.protocol.GetNodeInfo.Request', (transfer) => {
this.responseNodeInfo();
});
this.on('uavcan.protocol.NodeStatus', (transfer) => {
this.nodeMonitorsUpdate(transfer);
});
}
setNodeId(nodeId) {
this.nodeId = nodeId;
}
refreshNodeMonitor() {
this.nodeMonitors = {};
}
setBus(bus) {
this.bus = bus;
}
changeBus(bus) {
this.bus = bus;
this.changeBusTimestamp = new Date();
setTimeout(() => {
this.refreshNodeMonitor();
});
}
updateSubscribers(transfer) {
if (this.subscribers[transfer.dataTypeId] === undefined) {
this.subscribers[transfer.dataTypeId] = [];
}
const msg = transfer.payload;
this.subscribers[transfer.dataTypeId].push([msg.name, msg.toObj()]);
}
setSubscriberMaxItems(maxItems) {
if (maxItems < 1) {
throw new Error('Max items must be greater than 0');
}
if (maxItems > 50) {
throw new Error('Max items must be less than 100');
}
this.subscribersMaxItems = maxItems;
}
responseNodeInfo() {
if (this.nodeId) {
const msg = new DSDL.uavcan_protocol_GetNodeInfo_Response();
this.status.fields.uptime_sec.value = parseInt((new Date() - this.uptimeBegin) / 1000);
msg.fields.status.msg = this.status;
msg.fields.name.fromString(this.name);
msg.fields.hardware_version.msg = this.hardware_version;
msg.fields.software_version.msg = this.software_version;
this.request({req: msg, requestNotResponse: false, serviceNotMessage: true, destNodeId: 0, priority: REQUEST_PRIORITY});
}
}
nodeMonitorsUpdate(transfer) {
if (this.nodeId) {
this.getNodeInfo(transfer.sourceNodeId);
}
if ((new Date() - this.changeBusTimestamp) > 3000) {
this.updateNodeMonitorStatus(transfer);
} else {
this.refreshNodeMonitor();
}
if (this.listenerCount('nodeList') > 0) {
this.emit('nodeList', this.nodeMonitors);
}
}
handleFrame(frame) {
const transfer = this.transferManager.addFrame(frame);
if (transfer) {
const msg = transfer.payload;
let topicName;
if (transfer.serviceNotMessage) {
if (transfer.requestNotResponse) {
if (msg) {
topicName = `${msg.name}.Request`;
}
} else {
if (msg) {
topicName = `${msg.name}.Response`;
}
const callback = this.callbacks.get(transfer.transferId);
if (callback) {
callback(transfer);
this.callbacks.delete(transfer.transferId); // Remove the callback after invoking it
}
}
// console.log(`recv transfer dtid: ${transfer.dataTypeId}`);
if (topicName && this.listenerCount(topicName) > 0) {
this.emit(topicName, transfer);
}
} else {
if (!transfer.dataTypeId || !transfer.payload) {
// console.error('#TODO dataTypeId or payload is null');
return;
}
if (transfer.payload) {
const msg = transfer.payload;
if (this.listenerCount(msg.name) > 0) {
this.emit(msg.name, transfer);
}
}
if (this.listenerCount('message') > 0) {
this.emit('message', transfer);
}
// console.log('Message frame');
}
if (this.listenerCount('transfer-rx') > 0) {
this.emit('transfer-rx', transfer);
}
}
}
request({req, destNodeId, requestNotResponse=true, serviceNotMessage=true, priority=REQUEST_PRIORITY, canfd=false, callback=null}) {
// console.log('Request:', req, targetNodeId);
const transfer = new Transfer({
transferId: this.transferManager.getNextTransferId(),
sourceNodeId: this.nodeId,
destNodeId: destNodeId,
payload: req,
requestNotResponse: requestNotResponse,
serviceNotMessage: serviceNotMessage,
transferPriority: priority,
canfd: canfd,
});
this.callbacks.set(transfer.transferId, callback);
const frames = transfer.toFrames(req);
// console.log('Request Transfer ID:', transfer.transferId);
frames.forEach(frame => {
let messageId = frame.id;
if (frame.extended) {
messageId = (messageId | (1 << 31)) >>> 0;
}
if (this.listenerCount('sendFrame') > 0) {
this.emit('sendFrame', messageId, frame.data, frame.data.length);
}
});
if (this.listenerCount('transfer-tx') > 0) {
this.emit('transfer-tx', transfer);
}
}
response({res, transfer, canfd=false}) {
const responseTransfer = new Transfer({
transferId: transfer.transferId,
sourceNodeId: this.nodeId,
destNodeId: transfer.sourceNodeId,
payload: res,
requestNotResponse: false,
serviceNotMessage: transfer.serviceNotMessage,
transferPriority: transfer.transferPriority,
canfd: canfd,
});
const frames = responseTransfer.toFrames(res);
frames.forEach(frame => {
let messageId = frame.id;
if (frame.extended) {
messageId = (messageId | (1 << 31)) >>> 0;
}
if (this.listenerCount('sendFrame') > 0) {
this.emit('sendFrame', messageId, frame.data, frame.data.length);
}
});
}
updateNodeMonitorFromResponse(transfer) {
if (transfer.destNodeId === this.nodeId) {
const msg = transfer.payload;
if (msg instanceof DSDL.uavcan_protocol_GetNodeInfo_Response) {
this.nodeMonitors[transfer.sourceNodeId] = msg.toObj();
} else {
console.error('msg is not instance of uavcan_protocol_GetNodeInfo_Response');
}
}
}
updateNodeMonitorStatus(transfer) {
const msg = transfer.payload;
if (msg instanceof DSDL.uavcan_protocol_NodeStatus) {
if (this.nodeMonitors[transfer.sourceNodeId] === undefined) {
this.nodeMonitors[transfer.sourceNodeId] = {};
}
this.nodeMonitors[transfer.sourceNodeId].status = msg.toObj();
} else {
console.error('msg is not instance of uavcan_protocol_NodeStatus');
}
}
getNodeInfo(nodeId) {
// console.log('Fetching node info:', nodeId);
const msg = new DSDL.uavcan_protocol_GetNodeInfo_Request();
this.request({req: msg, destNodeId: nodeId, priority: REQUEST_PRIORITY, callback: (transfer) => {
this.updateNodeMonitorFromResponse(transfer);
}});
}
restartNode(nodeId, callback=null) {
const msg = new DSDL.uavcan_protocol_RestartNode_Request();
msg.fields.magic_number.value = 0xACCE551B1E; //magic number;
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: true, priority: REQUEST_PRIORITY, callback: callback});
}
requestUavcanProtocolParamExecuteOpcode(nodeId, opcode, argument, callback=null) {
const msg = new DSDL.uavcan_protocol_param_ExecuteOpcode_Request();
msg.fields.opcode.value = opcode;
msg.fields.argument.value = argument;
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: true, priority: REQUEST_PRIORITY, callback: callback});
}
sendUavcanEquipmentEscRawCommand(nodeId, cmd, priority=REQUEST_PRIORITY) {
const msg = new DSDL.uavcan_equipment_esc_RawCommand();
msg.fields.cmd.items = cmd.map((value) => new PrimitiveType(value, PrimitiveType.KIND_SIGNED_INT, 14));
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: false, priority: priority});
}
sendUavcanEquipmentActuatorArrayCommand(nodeId, commands, priority=REQUEST_PRIORITY) {
const msg = new DSDL.uavcan_equipment_actuator_ArrayCommand();
msg.fields.commands.items = commands.map((command) => {
if (command.id < 0 || command.id > 32) {
throw new Error('Actuator ID must be between 0 and 255');
}
if (command.type < 0 || command.type > 3) {
throw new Error('Command type must be between 0 and 3');
}
switch (command.type) {
case 0:
if (command.value < -1 || command.value > 1) {
throw new Error('Command value must be between -1 and 1');
}
break;
default:
if (command.value < -65504 || command.value > 65504) {
throw new Error('Command value must be between -65504 and 65504 for float16');
}
break;
}
const cmd = new DSDL.uavcan_equipment_actuator_Command();
cmd.fields.actuator_id.value = command.id;
cmd.fields.command_type.value = command.type;
cmd.fields.command_value.value = command.value;
return cmd;
});
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: false, priority: priority});
}
sendUavcanProtocolDynamicNodeIdAllocation(first_part_of_unique_id, nodeId, unique_id, priority=REQUEST_PRIORITY) {
const msg = new DSDL.uavcan_protocol_dynamic_node_id_Allocation();
msg.fields.first_part_of_unique_id.value = first_part_of_unique_id;
msg.fields.node_id.value = nodeId;
msg.fields.unique_id.items = unique_id.map((value) => new PrimitiveType(value, PrimitiveType.KIND_UNSIGNED_INT, 8));
this.request({req: msg, destNodeId: 0, requestNotResponse: false, serviceNotMessage: false, priority: priority});
}
sendUavcanEquipmentSafetyArmingStatus(nodeId, status, priority=REQUEST_PRIORITY) {
const msg = new DSDL.uavcan_equipment_safety_ArmingStatus();
msg.fields.status.value = Number(status);
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: false, priority: priority});
}
sendArdupilotIndicationSafetyState(nodeId, status, priority=REQUEST_PRIORITY) {
const msg = new DSDL.ardupilot_indication_SafetyState();
msg.fields.status.value = Number(status);
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: false, priority: priority});
}
beginFirmwareUpdate(nodeId, filePath, callback=null) {
const msg = new DSDL.uavcan_protocol_file_BeginFirmwareUpdate_Request();
msg.fields.source_node_id.value = this.nodeId;
msg.fields.image_file_remote_path.fields.path.fromString(filePath)
this.request({req: msg, destNodeId: nodeId, requestNotResponse: true, serviceNotMessage: true, priority: REQUEST_PRIORITY, callback:callback});
}
responseUavcanProtocolFileRead(transfer, error, path, data) {
const msg = new DSDL.uavcan_protocol_file_Read_Response();
msg.fields.error.msg.fields.value.value = error;
msg.fields.path = path;
for (let i = 0; i < data.length; i++) {
msg.fields.data.items.push(new PrimitiveType(data[i], PrimitiveType.KIND_UNSIGNED_INT, 8));
}
this.response({res: msg, transfer: transfer});
}
setNodeParamsRequestingIndex(sourceNodeId, index) {
this.nodeParamsRequestingIndex[sourceNodeId] = index;
}
getNodeParamsRequestingIndex(sourceNodeId) {
return this.nodeParamsRequestingIndex[sourceNodeId];
}
updateNodeParamsFromResponse(transfer, paramIndex) {
const msg = transfer.payload;
if (this.nodeParams[transfer.sourceNodeId] === undefined) {
this.nodeParams[transfer.sourceNodeId] = {};
}
this.nodeParams[transfer.sourceNodeId][paramIndex] = msg;
}
setNodeParam(sourceNodeId, index, value) {
const currentRequestIndex = index;
const responseMsg = this.nodeParams[sourceNodeId][index];
if (responseMsg === undefined) {
console.error('Response message is undefined');
return;
}
const msg = new DSDL.uavcan_protocol_param_GetSet_Request();
msg.fields.index.value = index;
msg.fields.name = responseMsg.fields.name;
msg.fields.value = responseMsg.fields.value;
if (responseMsg.fields.value.fields.string_value) {
msg.fields.value.msg.unionField.fromString(value);
} else {
msg.fields.value.msg.unionField.value = value;
}
this.request({req: msg, destNodeId: sourceNodeId, serviceNotMessage: true, requestNotResponse: true, priority: REQUEST_PRIORITY, callback: (transfer) => {
if (transfer.destNodeId === this.nodeId) {
if (transfer.payload.fields.name.toString() === responseMsg.fields.name.toString()) {
this.updateNodeParamsFromResponse(transfer, currentRequestIndex);
console.log('Set param response:', transfer.payload.fields.name.toString(), transfer.payload);
} else {
console.error('Set param response name mismatch');
}
}
}});
}
fetchNodeParam(sourceNodeId, index, name, callback=null) {
// console.log('Fetching node param:', sourceNodeId, index, name);
// const currentRequestIndex = index;
this.nodeParamsRequestingNodeId = sourceNodeId;
this.setNodeParamsRequestingIndex(sourceNodeId, index);
const msg = new DSDL.uavcan_protocol_param_GetSet_Request();
msg.fields.index.value = index;
const valueMsg = new DSDL.uavcan_protocol_param_Value();
valueMsg.unionFieldIndex.value = 0;
valueMsg.fields.empty = new CompoundType(new DSDL.uavcan_protocol_param_Empty());
msg.fields.value.msg = valueMsg;
msg.fields.name.fromString(name);
this.request({req: msg, destNodeId: sourceNodeId, serviceNotMessage: true, requestNotResponse: true, priority: REQUEST_PRIORITY, callback: callback});
}
bitsToBuffer(bits) {
const byteLength = Math.ceil(bits.length / 8);
const buffer = Buffer.alloc(byteLength);
for (let i = 0; i < bits.length; i++) {
const byteIndex = Math.floor(i / 8);
const bitIndex = i % 8;
if (bits[i]) {
buffer[byteIndex] |= (1 << (7 - bitIndex));
}
}
return buffer;
}
}
module.exports = Node;
+450
View File
@@ -0,0 +1,450 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Type = require('./type');
var Buffer = require('buffer').Buffer;
Buffer.prototype.setBit = function(bitOffset, value) {
const byteIndex = Math.floor(bitOffset / 8);
const bitPosition = bitOffset % 8;
if (byteIndex >= this.length) {
throw new RangeError("setBit: bitOffset out of range");
}
if (value === 1) {
this[byteIndex] |= (1 << (7 - bitPosition));
} else if (value === 0) {
this[byteIndex] &= ~(1 << (7 - bitPosition));
} else {
throw new Error("setBit: value must be 0 or 1");
}
};
// Helper functions for value ranges.
function getUnsignedIntegerRange(bitlen) {
return { min: 0n, max: (1n << BigInt(bitlen)) - 1n };
}
function getSignedIntegerRange(bitlen) {
return { min: -(1n << BigInt(bitlen - 1)), max: (1n << BigInt(bitlen - 1)) - 1n };
}
function getFloatRange(bitlen) {
if (bitlen === 16) {
// Approximate range for float16.
return { min: -65504, max: 65504 };
} else if (bitlen === 32) {
return { min: -3.4e38, max: 3.4e38 };
} else if (bitlen === 64) {
return { min: -1.8e308, max: 1.8e308 };
} else {
throw new Error("Unsupported float bitlen: " + bitlen);
}
}
// New helper to resolve the value range.
function getValueRange(kind, bitlen) {
switch (kind) {
case PrimitiveType.KIND_BOOLEAN:
return { min: 0, max: 1 };
case PrimitiveType.KIND_UNSIGNED_INT:
return getUnsignedIntegerRange(bitlen);
case PrimitiveType.KIND_SIGNED_INT:
return getSignedIntegerRange(bitlen);
case PrimitiveType.KIND_FLOAT:
if (bitlen === 1) {
throw new Error("Unsupported float bitlen: " + bitlen);
}
return getFloatRange(bitlen);
default:
throw new Error("Unknown kind: " + kind);
}
}
// Helper functions for float16 conversion.
function floatToHalf(val) {
const floatView = new Float32Array(1);
const int32View = new Int32Array(floatView.buffer);
floatView[0] = val;
const x = int32View[0];
const sign = (x >> 16) & 0x8000;
let exp = ((x >> 23) & 0xff) - 127 + 15;
let mantissa = (x >> 13) & 0x03ff;
if (exp <= 0) {
// Underflow - convert to subnormal value.
if (exp < -10) {
return sign;
}
mantissa = (mantissa | 0x0400) >> (1 - exp);
return sign | mantissa;
} else if (exp === 0xff - 127 + 15) {
if (mantissa) {
// NaN
return sign | 0x7e00;
}
// Inf
return sign | 0x7c00;
} else if (exp > 30) {
// Overflow - return Inf.
return sign | 0x7c00;
}
return sign | (exp << 10) | mantissa;
}
function halfToFloat(h) {
const sign = (h & 0x8000) << 16;
let exp = (h & 0x7c00) >> 10;
let mantissa = h & 0x03ff;
if (exp === 0) {
if (mantissa === 0) {
// Zero.
return sign ? -0 : 0;
}
// Subnormal number
exp = 1;
while ((mantissa & 0x0400) === 0) {
mantissa <<= 1;
exp--;
}
mantissa &= 0x03ff;
} else if (exp === 31) {
// Inf or NaN.
return mantissa === 0 ? (sign ? -Infinity : Infinity) : NaN;
}
exp = exp + (127 - 15);
const int32 = sign | (exp << 23) | (mantissa << 13);
const floatView = new Float32Array(new Uint32Array([int32]).buffer);
return floatView[0];
}
class PrimitiveType extends Type {
// Type kind constants.
static KIND_BOOLEAN = 0;
static KIND_UNSIGNED_INT = 1;
static KIND_SIGNED_INT = 2;
static KIND_FLOAT = 3;
// Cast mode constants.
static CAST_MODE_SATURATED = 0;
static CAST_MODE_TRUNCATED = 1;
/**
* Constructs a new PrimitiveType.
* @param {*} value - The value to store.
* @param {number} kind - The kind of the primitive. Use one of the PrimitiveType kind constants.
* @param {number} bitlen - The bit length to use for encoding. For booleans, use 1.
* @param {number} [cast_mode=PrimitiveType.CAST_MODE_SATURATED] - The cast mode.
*/
constructor(value, kind, bitlen, cast_mode = PrimitiveType.CAST_MODE_SATURATED) {
// Generate the normalized definition string.
const cast_mode_str = (cast_mode === PrimitiveType.CAST_MODE_SATURATED) ? "saturated" : "truncated";
let primary_type;
switch (kind) {
case PrimitiveType.KIND_BOOLEAN:
primary_type = "bool";
break;
case PrimitiveType.KIND_UNSIGNED_INT:
primary_type = "uint" + bitlen;
break;
case PrimitiveType.KIND_SIGNED_INT:
primary_type = "int" + bitlen;
break;
case PrimitiveType.KIND_FLOAT:
primary_type = "float" + bitlen;
break;
default:
primary_type = "unknown";
}
const normalizedDefinition = `${cast_mode_str} ${primary_type}`;
super(normalizedDefinition, Type.CATEGORY_PRIMITIVE);
this.value = value;
this.kind = kind;
this.bitlen = bitlen;
this.cast_mode = cast_mode;
this.value_range = getValueRange(this.kind, this.bitlen);
}
getNormalizedDefinition() {
const cast_mode_str = (this.cast_mode === PrimitiveType.CAST_MODE_SATURATED) ? "saturated" : "truncated";
let primary_type;
switch (this.kind) {
case PrimitiveType.KIND_BOOLEAN:
primary_type = "bool";
break;
case PrimitiveType.KIND_UNSIGNED_INT:
primary_type = "uint" + this.bitlen;
break;
case PrimitiveType.KIND_SIGNED_INT:
primary_type = "int" + this.bitlen;
break;
case PrimitiveType.KIND_FLOAT:
primary_type = "float" + this.bitlen;
break;
default:
primary_type = "unknown";
}
return `${cast_mode_str} ${primary_type}`;
}
validateValueRange(value) {
const low = this.value_range.min;
const high = this.value_range.max;
let v = (typeof low === 'bigint') ? BigInt(value) : value;
if (v < low || v > high) {
throw new Error(`Value [${value}] is out of range [${low}, ${high}]`);
}
}
getMaxBitlen() {
return this.bitlen;
}
getMinBitlen() {
return this.bitlen;
}
pack() {
let buffer;
if (this.bitlen === 0) {
throw new Error("Bit length must be greater than zero");
};
switch (this.kind) {
case PrimitiveType.KIND_FLOAT: {
if (this.bitlen === 16) {
buffer = Buffer.alloc(2);
if (Number.isNaN(this.value)) {
buffer.writeUInt16LE(0x7FFF, 0);
} else {
buffer.writeUInt16LE(floatToHalf(this.value), 0);
}
} else if (this.bitlen === 32) {
buffer = Buffer.alloc(4);
if (Number.isNaN(this.value)) {
buffer.writeUInt32LE(0x7FC00000, 0);
} else {
buffer.writeFloatLE(this.value, 0);
}
} else if (this.bitlen === 64) {
buffer = Buffer.alloc(8);
if (Number.isNaN(this.value)) {
buffer.writeUInt32LE(0x7FF80000, 0);
} else {
buffer.writeDoubleLE(this.value, 0);
}
} else {
throw new Error(`Unsupported bitlen for float: ${this.bitlen}. Use 16, 32, or 64.`);
}
break;
}
case PrimitiveType.KIND_BOOLEAN: {
const byteCount = Math.ceil(this.bitlen / 8);
buffer = Buffer.alloc(byteCount);
buffer[0] = this.value ? 1 : 0;
break;
}
case PrimitiveType.KIND_UNSIGNED_INT: {
if (this.bitlen <= 8) {
buffer = Buffer.alloc(1);
buffer.writeUInt8(this.value, 0);
} else if (this.bitlen <= 16) {
buffer = Buffer.alloc(2);
buffer.writeUInt16LE(this.value, 0);
} else if (this.bitlen <= 32) {
buffer = Buffer.alloc(4);
buffer.writeUInt32LE(this.value, 0);
} else if (this.bitlen <= 64) {
buffer = Buffer.alloc(8);
buffer.writeBigUInt64LE(BigInt(this.value), 0);
} else {
throw new Error(`Unsupported bitlen for unsigned int: ${this.bitlen}`);
}
break;
}
case PrimitiveType.KIND_SIGNED_INT: {
if (this.bitlen <= 8) {
if (this.value < 0) {
buffer = Buffer.alloc(1, 0xFF);
} else {
buffer = Buffer.alloc(1);
}
if (this.value !== null) {
buffer.writeInt8(0, this.value);
}
} else if (this.bitlen <= 16) {
if (this.value < 0) {
buffer = Buffer.alloc(2, 0xFF);
} else {
buffer = Buffer.alloc(2);
}
buffer.writeInt16LE(this.value, 0);
} else if (this.bitlen <= 32) {
if (this.value < 0) {
buffer = Buffer.alloc(4, 0xFF);
} else {
buffer = Buffer.alloc(4);
}
buffer.writeInt32LE(this.value, 0);
} else if (this.bitlen <= 64) {
if (this.value < 0) {
buffer = Buffer.alloc(8, 0xFF);
} else {
buffer = Buffer.alloc(8);
}
if (this.value !== null) {
buffer.writeBigInt64LE(BigInt(this.value), 0);
}
} else {
throw new Error(`Unsupported bitlen for signed int: ${this.bitlen}`);
}
break;
}
default:
throw new Error("Unsupported kind for encoding");
}
const bits = [];
for (let i = 0; i < this.bitlen; i++) {
if (this.bitlen < 8 || (this.bitlen % 8 !== 0 && i >= Math.floor(this.bitlen / 8) * 8)) {
let bitOffset = 8 - this.bitlen % 8;
bits.push(buffer.getBit(i + bitOffset));
} else {
bits.push(buffer.getBit(i));
}
}
if (bits.length !== this.bitlen) {
throw new Error("Bit length mismatch");
}
return bits;
}
static unpack(kind, bits, bitlen, castMode = null) {
if (!(bits instanceof Array) && !(bits instanceof Uint8Array)) {
throw new Error("Unsupported input type for decoding; expected Uint8Array or Buffer");
}
let value;
switch (kind) {
case PrimitiveType.KIND_FLOAT: {
if (bitlen === 16) {
let buffer = Buffer.alloc(2);
for (let i = 0; i < 16; i++) {
buffer.setBit(i, bits[i]);
}
if (buffer.length < 2) throw new Error("Buffer too small for float16");
const half = buffer.readUInt16LE(0);
value = halfToFloat(half);
} else if (bitlen === 32) {
let buffer = Buffer.alloc(4);
for (let i = 0; i < 32; i++) {
buffer.setBit(i, bits[i]);
}
if (buffer.length < 4) throw new Error("Buffer too small for float32");
value = buffer.readFloatLE(0);
} else if (bitlen === 64) {
let buffer = Buffer.alloc(8);
for (let i = 0; i < 64; i++) {
buffer.setBit(i, bits[i]);
}
if (buffer.length < 8) throw new Error("Buffer too small for float64");
value = buffer.readDoubleLE(0);
} else {
throw new Error(`Unsupported bitlen for float: ${bitlen}. Use 16, 32, or 64.`);
}
break;
}
case PrimitiveType.KIND_BOOLEAN: {
if (bits.length === 1)
value = Boolean(bits[0] === 1);
else {
console.error("unexpected bits length for boolean: ", bits.length);
}
break;
}
case PrimitiveType.KIND_UNSIGNED_INT: {
value = 0;
let buffer = Buffer.alloc(8, 0);
if (castMode == PrimitiveType.CAST_MODE_TRUNCATED) {
for (let i = 0; i < bitlen; i++) {
buffer.setBit(i, bits[i]);
}
} else if (castMode == PrimitiveType.CAST_MODE_SATURATED ) {
let bitOffset = Math.ceil(bitlen % 8);
let bitPosition = 0;
if (bitOffset !== 0) {
bitPosition = 8 - bitOffset;
}
for (let i = 0; i < bitlen; i++) {
if (bitlen < 8 || i > Math.floor(bitlen/8) *8) {
buffer.setBit(i + bitPosition, bits[i]); //handle last byte
} else {
buffer.setBit(i, bits[i]);
}
}
}
if (bitlen <= 8) {
value = buffer.readUInt8(0);
} else if (bitlen <= 16) {
value = buffer.readUInt16LE(0);
} else if (bitlen <= 32) {
value = buffer.readUInt32LE(0);
} else if (bitlen <= 64) {
value = Number(buffer.readBigUInt64LE(0));
} else {
throw new Error(`Unsupported bitlen for unsigned int: ${bitlen}`);
}
break;
}
case PrimitiveType.KIND_SIGNED_INT: {
let buffer;
let MSB_index = Math.floor(bitlen/8) * 8;
let MSB = bits[MSB_index];
if (MSB === 1) {
buffer = Buffer.alloc(8, 0xFF); // Negative number
} else {
buffer = Buffer.alloc(8, 0); // Positive number
}
if (castMode == PrimitiveType.CAST_MODE_TRUNCATED) {
for (let i = 0; i < bitlen; i++) {
buffer.setBit(i, bits[i]);
}
} else if (castMode == PrimitiveType.CAST_MODE_SATURATED ) {
let bitOffset = 8 - Math.ceil(bitlen%8)
for (let i = 0; i < bitlen; i++) {
if ( i > Math.floor(bitlen/8) *8) {
buffer.setBit(i + bitOffset, bits[i]); //handle last byte
} else {
buffer.setBit(i, bits[i]);
}
}
}
if (bitlen <= 8) {
value = buffer.readUInt8(0);
} else if (bitlen <= 16) {
value = buffer.readInt16LE(0);
} else if (bitlen <= 32) {
value = buffer.readInt32LE(0);
} else if (bitlen <= 64) {
value = Number(buffer.readBigInt64LE(0));
} else {
throw new Error(`Unsupported bitlen for unsigned int: ${bitlen}`);
}
break;
}
default:
throw new Error("Unsupported kind for decoding");
}
return new PrimitiveType(value, kind, bitlen);
}
toString() {
return `PrimitiveType(${this.kind}): ${this.value}`;
}
}
module.exports = PrimitiveType;
+216
View File
@@ -0,0 +1,216 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Frame = require('./frame');
const DSDL = require('./dsdl');
const { crc16FromBytes, packBigInt64LE } = require('./common');
class Transfer {
static DEFAULT_TRANSFER_PRIORITY = 31;
constructor({
transferId = 0,
sourceNodeId = 0,
destNodeId = null,
payload = null,
transferPriority = 20,
requestNotResponse = false,
serviceNotMessage = false,
discriminator = null,
canfd = false,
} = {}) {
this.transferId = transferId;
this.sourceNodeId = sourceNodeId;
this.destNodeId = destNodeId;
this.payload = payload;
this.transferPriority = transferPriority;
this.requestNotResponse = requestNotResponse;
this.serviceNotMessage = serviceNotMessage;
this.discriminator = discriminator;
this.canfd = canfd;
if (this.payload) {
this.dataTypeId = this.payload.dataTypeId;
} else {
this.dataTypeId = null;
}
}
get messageId() {
let id;
id |= (this.transferPriority & 0x1F) << 24;
id |= Number(this.serviceNotMessage) << 7;
id |= this.sourceNodeId;
if (this.serviceNotMessage) {
id |= this.dataTypeId << 16;
id |= Number(this.requestNotResponse) << 15;
id |= this.destNodeId << 8;
} else if (this.sourceNodeId === 0) {
id |= this.discriminator << 10;
id |= (this.dataTypeId & 0x3) << 8;
} else {
id |= this.dataTypeId << 8;
}
return id;
}
set messageId(value) {
this.transferPriority = (value >> 24) & 0x1F;
this.serviceNotMessage = Boolean((value >> 7) & 0x1);
this.sourceNodeId = value & 0x7F;
if (this.serviceNotMessage) {
this.dataTypeId = (value >> 16) & 0xFF;
this.requestNotResponse = Boolean(value & 0x8000);
this.destNodeId = (value >> 8) & 0x7F;
} else if (this.sourceNodeId === 0) {
this.discriminator = (value >> 10) & 0x3FFF;
this.dataTypeId = (value >> 8) & 0x3;
} else {
this.dataTypeId = (value >> 8) & 0xFFFF;
}
}
toFrames(req, tao=true) {
const outFrames = [];
let payloadBits = this.payload.pack(tao);
let payload = this.bitsToBuffer(payloadBits);
const frameMax = this.canfd ? 64 : 8;
if (this.canfd && payload.length > 7) {
let totalLen = payload.length + 1;
if (totalLen > frameMax) {
totalLen += 2;
}
const modLen = totalLen % (frameMax - 1);
const roundedLength = Math.ceil(modLen / 8) * 8;
const padlen = roundedLength - modLen;
payload = Buffer.concat([payload, Buffer.alloc(padlen)]);
}
let remainingPayload = payload;
if (remainingPayload.length > frameMax - 1) {
const baseCrc = crc16FromBytes(packBigInt64LE(req.getDataTypeSignature()));
const crc = crc16FromBytes(payload, baseCrc);
remainingPayload = Buffer.concat([Buffer.from([crc & 0xFF, crc >> 8]), remainingPayload]);
}
let tail = 0x20;
while (true) {
tail = ((outFrames.length === 0 ? 0x80 : 0) | // Start of transfer
(remainingPayload.length <= (frameMax - 1) ? 0x40 : 0) | // End of transfer
((tail ^ 0x20) & 0x20) | // Toggle bit
(this.transferId & 0x1F));
let remainingPayloadLength = remainingPayload.length;
let frameBuffer = remainingPayload.slice(0, frameMax - 1);
frameBuffer = Buffer.concat([frameBuffer, Buffer.from([tail])]);
outFrames.push(new Frame(this.messageId, frameBuffer, true, this.canfd));
remainingPayload = remainingPayload.slice(frameMax - 1, remainingPayloadLength);
if (remainingPayload.length === 0) {
break;
}
}
return outFrames;
}
padToByteBoundary(bits) {
const padLength = (8 - (bits.length % 8)) % 8;
return bits + '0'.repeat(padLength);
}
fromFrames(frames) {
this.tsMonotonic = frames[0].tsMonotonic;
this.tsReal = frames[0].tsReal;
let expectedToggle = 0;
const expectedTransferId = frames[0].data[frames[0].data.length - 1] & 0x1F;
for (let idx = 0; idx < frames.length; idx++) {
const tail = frames[idx].data[frames[idx].data.length - 1];
if ((tail & 0x1F) !== expectedTransferId) {
throw new Error(`Transfer ID ${tail & 0x1F} incorrect, expected ${expectedTransferId}`);
} else if (idx === 0 && !(tail & 0x80)) {
throw new Error("Start of transmission not set on frame 0");
} else if (idx > 0 && (tail & 0x80)) {
throw new Error(`Start of transmission set unexpectedly on frame ${idx}`);
} else if (idx === frames.length - 1 && !(tail & 0x40)) {
throw new Error("End of transmission not set on last frame");
} else if (idx < frames.length - 1 && (tail & 0x40)) {
throw new Error(`End of transmission set unexpectedly on frame ${idx}`);
} else if ((tail & 0x20) !== expectedToggle) {
throw new Error(`Toggle bit value ${tail & 0x20} incorrect on frame ${idx}`);
}
expectedToggle ^= 0x20;
}
this.transferId = expectedTransferId;
this.messageId = frames[0].id;
let payloadBytes = Buffer.concat(frames.map(f => f.data.slice(0, -1)));
let payloadClass;
if (this.serviceNotMessage) {
let serviceGroup = DSDL.services[this.dataTypeId]
if (!serviceGroup) {
throw new Error(`Unknown service type ID: ${this.dataTypeId}`);
}
if (this.requestNotResponse) {
payloadClass = serviceGroup['request'];
} else {
payloadClass = serviceGroup['response'];
}
} else {
payloadClass = DSDL.messages[this.dataTypeId];
}
if (frames.length > 1) {
const transferCrc = payloadBytes[0] + (payloadBytes[1] << 8);
payloadBytes = payloadBytes.slice(2, payloadBytes.length);
const baseCrc = crc16FromBytes(packBigInt64LE(payloadClass.getDataTypeSignature()));
const crc = crc16FromBytes(payloadBytes, baseCrc);
if (crc !== transferCrc) {
throw new Error(`CRC mismatch: expected ${crc}, got ${transferCrc}`);
}
}
let tao = !this.canfd;
if (!payloadClass) {
throw new Error(`Unknown data type ID: ${this.dataTypeId}`);
}
this.payload = payloadClass.unpack(payloadBytes, tao);
this._payloadBytes = payloadBytes;
}
get key() {
return [this.messageId, this.transferId];
}
isResponseTo(transfer) {
return (transfer.serviceNotMessage &&
this.serviceNotMessage &&
transfer.requestNotResponse &&
!this.requestNotResponse &&
transfer.destNodeId === this.sourceNodeId &&
transfer.sourceNodeId === this.destNodeId &&
transfer.dataTypeId === this.dataTypeId &&
transfer.transferId === this.transferId);
}
bitsToBuffer(bits) {
const byteLength = Math.ceil(bits.length / 8);
const buffer = Buffer.alloc(byteLength);
for (let i = 0; i < bits.length; i++) {
const byteIndex = Math.floor(i / 8);
const bitIndex = i % 8;
if (bits[i]) {
buffer[byteIndex] |= (1 << (7 - bitIndex));
}
}
return buffer;
}
}
module.exports = Transfer;
+59
View File
@@ -0,0 +1,59 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Transfer = require('./transfer');
class TransferManager {
static MaskStdID = 0x000007FF;
static MaskExtID = 0x1FFFFFFF;
static FlagEFF = 1 << 31; // Extended frame format
static FlagRTR = 1 << 30; // Remote transmission request
static FlagERR = 1 << 29; // Error frame
constructor() {
this.activeTransfers = {};
this.activeTransferTimestamps = {};
this.rxQueue = [];
this.txQueue = [];
this.transferId = 0;
}
getNextTransferId() {
const transferId = this.transferId;
this.transferId = (this.transferId + 1) % 32; // Transfer ID is a 5-bit value (0-31)
return transferId;
}
addFrame(frame) {
const transferKey = frame.transferKey;
if (transferKey in this.activeTransfers || frame.startOfTransfer) {
// If the first frame was received, restart this transfer from scratch
if (frame.startOfTransfer) {
// console.log("Start of transfer received");
this.activeTransfers[transferKey] = [];
}
this.activeTransfers[transferKey].push(frame);
this.activeTransferTimestamps[transferKey] = performance.now();
// If the last frame of a transfer was received, return its frames
if (frame.endOfTransfer) {
// console.log("End of transfer received");
let transfer = null;
// transfer = new Transfer();
// transfer.fromFrames(this.activeTransfers[transferKey]);
try {
transfer = new Transfer();
transfer.fromFrames(this.activeTransfers[transferKey]);
} catch (error) {
// console.error(error);
}
delete this.activeTransfers[transferKey];
delete this.activeTransferTimestamps[transferKey];
return transfer
}
}
return null;
}
}
module.exports = TransferManager;
+37
View File
@@ -0,0 +1,37 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
class Type {
static CATEGORY_PRIMITIVE = 0;
static CATEGORY_ARRAY = 1;
static CATEGORY_COMPOUND = 2;
static CATEGORY_VOID = 3;
constructor(full_name, category) {
this.full_name = String(full_name);
this.category = category;
}
getNormalizedDefinition() {
throw new Error('Pure virtual method: getNormalizedDefinition() must be implemented by subclass.');
}
getDataTypeSignature() {
return null;
}
getMaxBitlen() {
throw new Error('Pure virtual method: getMaxBitlen() must be implemented by subclass.');
}
getMinBitlen() {
throw new Error('Pure virtual method: getMinBitlen() must be implemented by subclass.');
}
toString() {
return this.getNormalizedDefinition();
}
}
module.exports = Type;
+28
View File
@@ -0,0 +1,28 @@
/***
Author: Huibean Luo <huibean.luo@vimdrones.com>
***/
const Type = require('./type');
class VoidType extends Type {
constructor(bitlen) {
const normalizedDefinition = `void${bitlen}`;
super(normalizedDefinition, Type.CATEGORY_VOID);
this.bitlen = bitlen;
}
getNormalizedDefinition() {
return `void${this.bitlen}`;
}
getMaxBitlen() {
return this.bitlen;
}
getMinBitlen() {
return this.bitlen;
}
}
module.exports = VoidType;
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import EscPanelWindow from './EscPanelWindow';
const root = createRoot(document.getElementById('sub-root'));
root.render(<EscPanelWindow />);
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
+21258
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
import { EventEmitter } from 'events';
import WebSocketClient from './ws_client';
import WebSerial from './web_serial';
import { mavlink20, MAVLink20Processor } from './mavlink';
import dronecan from './dronecan';
class MavlinkSession extends EventEmitter {
constructor() {
super();
this.targetSystem = 255;
this.targetComponent = 0;
this.mavlinkProcessor = new MAVLink20Processor(null, this.targetSystem, this.targetComponent);
this.wsClient = null;
this.serial = null;
this.parseBuffer = this.parseBuffer.bind(this);
}
addWebSocketErrorHandler(handler) {
if (this.wsClient) {
this.wsClient.addErrorHandler(handler);
}
}
addWebSocketOpenHandler(handler) {
if (this.wsClient) {
this.wsClient.addOpenHandler(handler);
}
}
initWebSocketConnection(ip, port) {
this.wsClient = new WebSocketClient(`ws://${ip}:${port}`);
this.mavlinkProcessor.file = this.wsClient;
this.wsClient.addMessageHandler((buffer) => {
this.parseBuffer(buffer);
});
}
webSocketConnect() {
this.wsClient.connect();
}
initWebSerialConnection(port, baudRate) {
this.serial = new WebSerial(port, baudRate);
this.mavlinkProcessor.file = this.serial;
this.serial.addMessageHandler((buffer) => {
// console.log('Received buffer:', buffer);
this.parseBuffer(buffer);
});
}
addWebSerialErrorHandler(handler) {
if (this.serial) {
this.serial.addErrorHandler(handler);
}
}
addWebSerialOpenHandler(handler) {
if (this.serial) {
this.serial.addOpenHandler(handler);
}
}
webSerialConnect() {
this.serial.connect();
}
close() {
if (this.wsClient) {
this.wsClient.close();
}
if (this.serial) {
this.serial.close();
}
}
parseBuffer(buffer) {
// console.log('Parsing buffer:', buffer);
const messages = this.mavlinkProcessor.parseBuffer(buffer);
if (messages && messages.length > 0) {
messages.forEach(this.handleMavlinkMsg.bind(this));
} else {
// console.error('No messages parsed from buffer or messages is null.');
}
}
handleMavlinkMsg(message) {
switch (message._id) {
case mavlink20.MAVLINK_MSG_ID_HEARTBEAT:
{
// console.log('Heartbeat message received:', message);
this.targetSystem = message._header.srcSystem;
}
break;
case mavlink20.MAVLINK_MSG_ID_CAN_FRAME:
{
// console.log('CAN frame message received:', message);
let isExtended = message.id & dronecan.TransferManager.FlagEFF;
if (isExtended) {
// this.localNode.emit('can-frame', message.id, message.data, message.len);
if (localNode) {
localNode.emit('can-frame', message.id, message.data, message.len);
}
} else {
console.log('Standard frame');
}
}
break;
default:
}
if (this.getMaxListeners('mav-rx') > 0) {
this.emit('mav-rx', message);
}
}
sendMavlinkMsg(msg) {
if ((this.wsClient && this.wsClient.connected) || (this.serial && this.serial.connected)) {
this.mavlinkProcessor.send(msg);
if (this.getMaxListeners('mav-tx') > 0) {
this.emit('mav-tx', msg);
}
}
}
enableMavlinkCanForward(bus) {
// console.log('Enabling CAN forward on bus:', bus);
const msg = new mavlink20.messages.command_long(
this.targetSystem, // target_system
this.targetComponent, // target_component
mavlink20.MAV_CMD_CAN_FORWARD, // command
0, // confirmation
bus + 1, // param1
0, // param2
0, // param3
0, // param4
0, // param5
0, // param6
0 // param7
);
this.sendMavlinkMsg(msg);
}
isConnected() {
return (this.wsClient && this.wsClient.isConnected()) ||
(this.serial && this.serial.connected);
}
}
export default MavlinkSession;
+431
View File
@@ -0,0 +1,431 @@
/**
* DynamicNodeIdServer - A JavaScript implementation of the UAVCAN/DroneCAN dynamic node ID allocation server
*/
class DynamicNodeIdServer {
// Constants
static QUERY_TIMEOUT_MS = 1000; // Timeout for query follow-ups
static DEFAULT_NODE_ID_RANGE = [1, 125];
static STORAGE_KEY = 'dna_server_allocations';
constructor(localNode, options = {}) {
this.localNode = localNode;
this.nodeIdRange = options.nodeIdRange || DynamicNodeIdServer.DEFAULT_NODE_ID_RANGE;
this.persistAllocations = options.persistAllocations !== false;
// Server state
this.isActive = false;
this.currentQuery = null; // Current in-progress allocation query
this.lastQueryTimestamp = 0;
// Load saved allocations if persistence is enabled
this.allocationTable = {};
if (this.persistAllocations) {
this.loadAllocations();
}
// Bound methods to use as event handlers
this.handleAllocationMessage = this.handleAllocationMessage.bind(this);
// Add event handling support
this.eventListeners = {
'allocationUpdated': []
};
}
/**
* Start the allocation server
*/
start(minNodeId, maxNodeId) {
if (this.isActive) {
console.warn('[DynamicNodeIdServer] Server already active');
return;
}
if (typeof minNodeId === 'number' && typeof maxNodeId === 'number') {
if (minNodeId >= 1 && maxNodeId <= 125 && minNodeId < maxNodeId) {
this.nodeIdRange = [minNodeId, maxNodeId];
console.log(`[DynamicNodeIdServer] Using node ID range: [${minNodeId}, ${maxNodeId}]`);
} else {
console.warn('[DynamicNodeIdServer] Invalid node ID range, using defaults');
}
}
// Reset state
this.currentQuery = null;
this.lastQueryTimestamp = 0;
// Register event handlers
if (this.localNode) {
this.localNode.on('uavcan.protocol.dynamic_node_id.Allocation', this.handleAllocationMessage);
// Register our own node ID in the allocation table
if (this.localNode.nodeInfo && this.localNode.nodeInfo.hardwareVersion) {
const uniqueId = this.localNode.nodeInfo.hardwareVersion.uniqueId;
if (uniqueId && this.localNode.nodeId) {
this.allocationTable[this.arrayToHexString(uniqueId)] = {
nodeId: this.localNode.nodeId,
lastSeen: new Date().toISOString()
};
this.saveAllocations();
}
}
this.isActive = true;
console.log('[DynamicNodeIdServer] Started');
return true;
} else {
console.error('[DynamicNodeIdServer] Cannot start server - no local node available');
return false;
}
}
/**
* Stop the allocation server
*/
stop() {
if (!this.isActive) {
return;
}
if (this.localNode) {
this.localNode.off('uavcan.protocol.dynamic_node_id.Allocation', this.handleAllocationMessage);
}
this.isActive = false;
console.log('[DynamicNodeIdServer] Stopped');
}
/**
* Handle incoming allocation messages
*/
handleAllocationMessage(transfer) {
if (!this.isActive) return;
// Centralized allocator cannot co-exist with other allocators
if (transfer.sourceNodeId !== 0) {
console.warn('[DynamicNodeIdServer] Message from another allocator ignored:', transfer);
return;
}
const now = Date.now();
const msg = transfer.payload;
const msgObj = msg.toObj ? msg.toObj() : msg;
// Reset query if timeout expired
if (this.currentQuery && (now - this.lastQueryTimestamp > DynamicNodeIdServer.QUERY_TIMEOUT_MS)) {
console.log('[DynamicNodeIdServer] Query timeout, resetting');
this.currentQuery = null;
}
// First stage of allocation protocol
if (msgObj.first_part_of_unique_id === 1) {
this.handleFirstStageRequest(msgObj, transfer);
}
// Second stage (we check unique_id length)
else if (msgObj.unique_id && msgObj.unique_id.length === 6 && this.currentQuery && this.currentQuery.length === 6) {
this.handleSecondStageRequest(msgObj, transfer);
}
// Third stage
else if (msgObj.unique_id && msgObj.unique_id.length === 4 && this.currentQuery && this.currentQuery.length === 12) {
this.handleThirdStageRequest(msgObj, transfer);
} else {
console.warn('[DynamicNodeIdServer] Received invalid allocation message stage');
}
}
/**
* Handle first stage request (first 6 bytes of unique ID)
*/
handleFirstStageRequest(msgObj, transfer) {
// Store the first part of the unique ID
this.currentQuery = Array.from(msgObj.unique_id);
this.lastQueryTimestamp = Date.now();
console.log(`[DynamicNodeIdServer] Got first-stage request: ${this.arrayToHexString(this.currentQuery)}`);
// Send response to request second part
this.sendAllocationResponse(this.currentQuery, 0);
}
/**
* Handle second stage request (middle 6 bytes of unique ID)
*/
handleSecondStageRequest(msgObj, transfer) {
// Append second part to the unique ID
this.currentQuery = this.currentQuery.concat(Array.from(msgObj.unique_id));
this.lastQueryTimestamp = Date.now();
console.log(`[DynamicNodeIdServer] Got second-stage request: ${this.arrayToHexString(this.currentQuery)}`);
// Send response to request third part
this.sendAllocationResponse(this.currentQuery, 0);
}
/**
* Handle third stage request (last 4 bytes of unique ID plus optional requested ID)
*/
handleThirdStageRequest(msgObj, transfer) {
// Complete the unique ID
this.currentQuery = this.currentQuery.concat(Array.from(msgObj.unique_id));
this.lastQueryTimestamp = Date.now();
const uniqueIdStr = this.arrayToHexString(this.currentQuery);
console.log(`[DynamicNodeIdServer] Got third-stage request: ${uniqueIdStr}`);
// Check if this unique ID already has an allocation
let allocatedNodeId = this.getAllocatedNodeId(uniqueIdStr);
const nodeRequestedId = msgObj.node_id || 0;
// If an ID was requested but not allocated yet, try to allocate that or higher
if (nodeRequestedId > 0 && !allocatedNodeId) {
for (let nodeId = nodeRequestedId; nodeId <= this.nodeIdRange[1]; nodeId++) {
if (!this.isNodeIdTaken(nodeId)) {
allocatedNodeId = nodeId;
break;
}
}
}
// If no ID was allocated above, allocate the highest available ID
if (!allocatedNodeId) {
for (let nodeId = this.nodeIdRange[1]; nodeId >= this.nodeIdRange[0]; nodeId--) {
if (!this.isNodeIdTaken(nodeId)) {
allocatedNodeId = nodeId;
break;
}
}
}
if (allocatedNodeId) {
// Save the allocation (simplified without name and lastSeen)
this.allocationTable[uniqueIdStr] = {
nodeId: allocatedNodeId
};
if (this.persistAllocations) {
this.saveAllocations();
}
console.log(`[DynamicNodeIdServer] Allocated node ID ${allocatedNodeId} to node with unique ID ${uniqueIdStr}`);
// Send the allocation response
this.sendAllocationResponse(this.currentQuery, allocatedNodeId);
// Notify listeners about the allocation
this.triggerEvent('allocationUpdated', {
uniqueId: uniqueIdStr,
nodeId: allocatedNodeId
});
// Reset query state
this.currentQuery = null;
} else {
console.error('[DynamicNodeIdServer] Could not allocate a node ID - all IDs taken!');
}
}
/**
* Send allocation response message
*/
sendAllocationResponse(uniqueId, nodeId) {
if (!this.localNode) return;
try {
this.localNode.sendUavcanProtocolDynamicNodeIdAllocation(0, nodeId, uniqueId);
} catch (error) {
console.error('[DynamicNodeIdServer] Error sending allocation response:', error);
}
}
/**
* Check if a node ID is already allocated
*/
isNodeIdTaken(nodeId) {
for (const uniqueId in this.allocationTable) {
if (this.allocationTable[uniqueId].nodeId === nodeId) {
return true;
}
}
return false;
}
/**
* Get the allocated node ID for a unique ID
*/
getAllocatedNodeId(uniqueIdStr) {
return this.allocationTable[uniqueIdStr]?.nodeId || null;
}
/**
* Get all current allocations
*/
getAllocations() {
const allocations = [];
for (const uniqueId in this.allocationTable) {
allocations.push({
uniqueId: uniqueId,
nodeId: this.allocationTable[uniqueId].nodeId
});
}
return allocations;
}
/**
* Delete an allocation
*/
deleteAllocation(nodeId) {
let deletedKey = null;
for (const uniqueId in this.allocationTable) {
if (this.allocationTable[uniqueId].nodeId === nodeId) {
deletedKey = uniqueId;
break;
}
}
if (deletedKey) {
delete this.allocationTable[deletedKey];
if (this.persistAllocations) {
this.saveAllocations();
}
return true;
}
return false;
}
/**
* Update node name
*/
updateNodeName(nodeId, name) {
for (const uniqueId in this.allocationTable) {
if (this.allocationTable[uniqueId].nodeId === nodeId) {
this.allocationTable[uniqueId].name = name;
if (this.persistAllocations) {
this.saveAllocations();
}
return true;
}
}
return false;
}
/**
* Update last seen timestamp
*/
updateLastSeen(nodeId) {
for (const uniqueId in this.allocationTable) {
if (this.allocationTable[uniqueId].nodeId === nodeId) {
this.allocationTable[uniqueId].lastSeen = new Date().toISOString();
if (this.persistAllocations) {
this.saveAllocations();
}
return true;
}
}
return false;
}
/**
* Save allocations to localStorage
*/
saveAllocations() {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(DynamicNodeIdServer.STORAGE_KEY, JSON.stringify(this.allocationTable));
} catch (error) {
console.error('[DynamicNodeIdServer] Error saving allocations to localStorage:', error);
}
}
}
/**
* Load allocations from localStorage
*/
loadAllocations() {
if (typeof localStorage !== 'undefined') {
try {
const saved = localStorage.getItem(DynamicNodeIdServer.STORAGE_KEY);
if (saved) {
this.allocationTable = JSON.parse(saved);
console.log(`[DynamicNodeIdServer] Loaded ${Object.keys(this.allocationTable).length} allocations from storage`);
}
} catch (error) {
console.error('[DynamicNodeIdServer] Error loading allocations from localStorage:', error);
}
}
}
/**
* Convert byte array to hex string representation
*/
arrayToHexString(arr) {
if (!arr) return '';
return Array.from(arr).map(byte => byte.toString(16).padStart(2, '0')).join('');
}
/**
* Get server status information
*/
getStatus() {
return {
isActive: this.isActive,
nodeIdRange: this.nodeIdRange,
persistAllocations: this.persistAllocations,
allocationCount: Object.keys(this.allocationTable).length,
hasActiveQuery: this.currentQuery !== null
};
}
// Add event listener methods
addEventListener(event, callback) {
if (this.eventListeners[event]) {
this.eventListeners[event].push(callback);
return true;
}
return false;
}
removeEventListener(event, callback) {
if (this.eventListeners[event]) {
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback);
return true;
}
return false;
}
// Method to trigger events
triggerEvent(event, data) {
if (this.eventListeners[event]) {
this.eventListeners[event].forEach(callback => {
try {
callback(data);
} catch (e) {
console.error(`Error in ${event} event handler:`, e);
}
});
}
}
/**
* Get pending allocation requests
*/
getPendingRequests() {
// In a real implementation, this would track requests that haven't been allocated yet
const pendingRequests = [];
if (this.currentQuery) {
const now = Date.now();
// Only include if within timeout window
if (now - this.lastQueryTimestamp < DynamicNodeIdServer.QUERY_TIMEOUT_MS) {
pendingRequests.push({
uniqueId: this.arrayToHexString(this.currentQuery)
});
}
}
return pendingRequests;
}
}
export default DynamicNodeIdServer;
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import SubscriberWindow from './SubscriberWindow';
const root = createRoot(document.getElementById('sub-root'));
root.render(<SubscriberWindow />);
+164
View File
@@ -0,0 +1,164 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
text: {
primary: '#ffffff', // Set the primary text color to white
},
},
// Add typography settings to make all fonts smaller
typography: {
fontSize: 12, // Default font size in px (reduced from the default 14px)
htmlFontSize: 14, // Base HTML font-size (was 16px by default)
// Customize specific variants
h1: {
fontSize: '2rem', // 24px
},
h2: {
fontSize: '1.75rem', // 21px
},
h3: {
fontSize: '1.5rem', // 18px
},
h4: {
fontSize: '1.25rem', // 15px
},
h5: {
fontSize: '1.1rem', // 13.2px
},
h6: {
fontSize: '1rem', // 12px
},
body1: {
fontSize: '0.875rem', // 10.5px
},
body2: {
fontSize: '0.825rem', // 9.9px
},
button: {
fontSize: '0.825rem', // 9.9px
},
caption: {
fontSize: '0.75rem', // 9px
},
},
components: {
MuiButton: {
defaultProps: {
size: 'small',
},
styleOverrides: {
root: {
textTransform: 'none',
},
},
},
// Add table cell specific overrides
MuiTableCell: {
styleOverrides: {
root: {
fontSize: '0.8rem',
padding: '4px 8px',
},
head: {
fontWeight: 'bold',
fontSize: '0.8rem',
},
},
},
MuiTextField: {
defaultProps: {
size: 'small',
},
styleOverrides: {
root: {
'& .MuiInputBase-root': {
fontSize: '0.8rem',
},
'& .MuiInputLabel-root': {
fontSize: '0.8rem',
transform: 'translate(14px, 9px) scale(1)',
},
'& .MuiInputLabel-shrink': {
transform: 'translate(14px, -6px) scale(0.75)',
},
'& .MuiOutlinedInput-root': {
padding: '4px 8px',
},
'& .MuiOutlinedInput-input': {
padding: '4px',
},
},
},
},
MuiTable: {
defaultProps: {
size: 'small',
},
},
MuiSelect: {
defaultProps: {
size: 'small',
},
},
MuiFormControl: {
defaultProps: {
size: 'small',
},
},
MuiInputLabel: {
defaultProps: {
size: 'small',
},
},
MuiIconButton: {
defaultProps: {
size: 'small',
},
},
MuiFab: {
defaultProps: {
size: 'small',
},
},
MuiCheckbox: {
defaultProps: {
size: 'small',
},
},
MuiRadio: {
defaultProps: {
size: 'small',
},
},
MuiSwitch: {
defaultProps: {
size: 'small',
},
},
MuiDialogTitle: {
styleOverrides: {
root: {
fontSize: '1.25rem', // Set your desired font size here
},
},
},
MuiTypography: {
styleOverrides: {
root: {
color: '#ffffff', // Set the default text color to white
},
},
},
AppBar: {
styleOverrides: {
defaultProps: {
size: 'small',
},
}
}
},
});
export default theme;
+305
View File
@@ -0,0 +1,305 @@
class WebSerial {
constructor(port, baudRate) {
this.port = port;
this.baudRate = baudRate;
this.reader = null;
this.writer = null;
this.messageHandlers = [];
this.openHandlers = [];
this.errorHandlers = [];
this.closeHandlers = [];
this.isClosed = false;
this.readLoopActive = false; // Track if read loop is running
this.connected = false; // Track if the connection is established
this.connectionStatusHandlers = []; // Handlers for connection status changes
}
// Add getter for connection status
isConnected() {
return this.connected;
}
// Add method to register connection status change handlers
addConnectionStatusHandler(handler) {
this.connectionStatusHandlers.push(handler);
}
// Update connection status and notify handlers
updateConnectionStatus(status) {
const previousStatus = this.connected;
this.connected = status;
// Only notify if there was a change
if (previousStatus !== status) {
// console.log(`Connection status changed: ${status ? 'Connected' : 'Disconnected'}`);
this.connectionStatusHandlers.forEach(handler => handler(status));
}
}
static async requestPort() {
try {
const port = await navigator.serial.requestPort();
return port;
} catch (error) {
console.error('Error selecting port:', error);
throw error;
}
}
static async listPorts() {
const ports = await navigator.serial.getPorts();
// console.log('Serial ports:', ports);
return ports;
}
async connect() {
try {
if (!this.port) {
console.error('No port available to connect.');
return;
}
// console.log('Port selected:', this.port);
const baudRate = this.baudRate;
await this.port.open({ baudRate });
// console.log('Port opened:', this.port);
this.handleOpen();
this.isClosed = false;
if (this.port.writable) {
this.writer = this.port.writable.getWriter();
}
if (this.port.readable) {
// Store reader as instance property so we can access it during close
this.reader = this.port.readable.getReader();
const messageHandlers = this.messageHandlers;
// Set flag to indicate read loop is active
this.readLoopActive = true;
// Update connection status
this.updateConnectionStatus(true);
const readLoop = async () => {
try {
while (this.readLoopActive && this.reader) {
const { value, done } = await this.reader.read();
if (done) {
break;
}
messageHandlers.forEach(handler => handler(value));
}
} catch (error) {
if (error.name === 'BreakError') {
// console.log('BreakError received - this is expected during certain operations');
if (this.readLoopActive && !this.isClosed) {
// Release the current reader
try {
if (this.reader) {
this.reader.releaseLock();
}
} catch (releaseError) {
console.warn('Error releasing reader after BreakError:', releaseError);
}
// Wait a moment before attempting to restart
await new Promise(resolve => setTimeout(resolve, 150));
// Only try to restart if we're still supposed to be reading
if (this.readLoopActive && !this.isClosed && this.port && this.port.readable) {
// console.log('Restarting read loop after BreakError...');
try {
// Get a new reader
this.reader = this.port.readable.getReader();
// Restart the read loop with the new reader
readLoop(); // Recursively call readLoop to continue reading
} catch (restartError) {
console.error('Failed to restart reader after BreakError:', restartError);
this.handleError(restartError);
// If we can't restart, mark as disconnected
if (!this.isClosed) {
this.updateConnectionStatus(false);
}
}
}
}
} else {
console.error('Error reading from serial port:', error);
this.handleError(error);
// Update connection status if there's a critical error
if (!['BreakError', 'NetworkError'].includes(error.name)) {
this.updateConnectionStatus(false);
}
}
}
};
readLoop();
} else {
console.error('Port is not readable or writable.');
this.updateConnectionStatus(false);
}
} catch (error) {
this.handleError(error);
this.updateConnectionStatus(false);
}
}
handleOpen() {
this.openHandlers.forEach(handler => handler());
}
addOpenHandler(handler) {
this.openHandlers.push(handler);
}
handleMessage(data) {
if (this.messageHandlers && this.messageHandlers.length > 0) {
this.messageHandlers.forEach(handler => handler(data));
} else {
console.error('Message handlers are not initialized or empty.');
}
}
addMessageHandler(handler) {
this.messageHandlers.push(handler);
}
handleError(error) {
if (error.name === 'BreakError') {
// console.error('BreakError: Break received');
} else {
console.error('Serial port error observed:', error);
this.errorHandlers.forEach(handler => handler(error));
}
}
addErrorHandler(handler) {
this.errorHandlers.push(handler);
}
handleClose() {
if (!this.isClosed) { // Check if the port is already closed
console.log('Serial port is closed now.');
this.isClosed = true; // Set the flag to true
this.updateConnectionStatus(false); // Update connection status
this.closeHandlers.forEach(handler => handler());
}
}
addCloseHandler(handler) {
this.closeHandlers.push(handler);
}
async write(data) {
try {
if (!this.connected) {
console.error('Cannot write: not connected to serial port');
return;
}
if (!(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
// Convert data to ArrayBuffer if it's not already an ArrayBuffer or ArrayBufferView
if (typeof data === 'string') {
data = new TextEncoder().encode(data);
} else if (data instanceof Blob) {
data = await data.arrayBuffer();
} else if (Array.isArray(data)) {
data = new Uint8Array(data);
} else {
throw new TypeError('The provided value is not of type (ArrayBuffer or ArrayBufferView)');
}
}
await this.writer.write(data);
} catch (error) {
this.handleError(error);
// Update connection status if write fails
if (error.message && error.message.includes('locked') === false) {
this.updateConnectionStatus(false);
}
}
}
async close() {
try {
// First, set the flag to prevent multiple close attempts
if (this.isClosed) {
console.log('Port already closed');
return;
}
this.isClosed = true;
// Signal read loop to stop
this.readLoopActive = false;
// Update connection status
this.updateConnectionStatus(false);
// Cancel and release reader if it exists
if (this.reader) {
try {
await this.reader.cancel();
this.reader.releaseLock();
this.reader = null;
} catch (readerError) {
console.warn('Error while closing reader:', readerError);
// Even if cancel failed, try to release the lock
try {
this.reader.releaseLock();
} catch (e) {
console.warn('Failed to release reader lock:', e);
}
this.reader = null;
}
}
// Close and release writer if it exists
if (this.writer) {
try {
await this.writer.close();
this.writer.releaseLock();
this.writer = null;
} catch (writerError) {
console.warn('Error while closing writer:', writerError);
// Even if close failed, try to release the lock
try {
this.writer.releaseLock();
} catch (e) {
console.warn('Failed to release writer lock:', e);
}
this.writer = null;
}
}
// Wait a bit for locks to be fully released
await new Promise(resolve => setTimeout(resolve, 50));
// Close the port last
if (this.port) {
try {
console.log('Closing port...');
await this.port.close();
console.log('Port closed successfully');
} catch (portError) {
console.error('Error while closing port:', portError);
}
this.port = null;
}
this.handleClose();
} catch (error) {
console.error('Error in close method:', error);
this.handleError(error);
}
}
}
export default WebSerial;
+134
View File
@@ -0,0 +1,134 @@
// Actuator Command Worker with fixed rate calculation
console.log('Actuator Command Worker initialized - Rate-corrected version');
// High-resolution timing helpers
const originalSetTimeout = setTimeout;
const hrSetTimeout = (callback, delay) => {
const start = performance.now();
return originalSetTimeout(() => {
const drift = performance.now() - start - delay;
callback(drift);
}, delay);
};
// Busy-wait loop for ultra-precise timing
function preciseSleep(ms) {
const start = performance.now();
while (performance.now() - start < ms) {
// Busy wait for ultra-precise timing (use carefully - consumes CPU)
}
}
// Command variables
let timerId = null;
let running = false;
let rate = 10; // Default rate in Hz
let commandCounter = 0;
let lastPerformanceReport = 0;
const PERFORMANCE_REPORT_INTERVAL = 5000; // ms
// Performance monitoring
let cycleCount = 0;
let cycleStartTime = 0;
self.onmessage = function(e) {
const command = e.data.command;
console.log(`Worker received ${command} command`);
switch (command) {
case 'start':
const newStartRate = e.data.rate || 10;
// Stop any existing timer
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
// Start new timer with requested rate
console.log(`Starting actuator commands with rate: ${newStartRate}Hz`);
rate = newStartRate;
running = true;
commandCounter = 0;
lastPerformanceReport = performance.now();
cycleCount = 0;
cycleStartTime = performance.now();
startPreciseLoop();
break;
case 'stop':
console.log('Stopping actuator commands');
running = false;
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
// Report final stats
const elapsedSec = (performance.now() - cycleStartTime) / 1000;
if (elapsedSec > 0.5 && cycleCount > 0) {
const actualRate = cycleCount / elapsedSec;
console.log(`Final actuator command rate: ${actualRate.toFixed(2)}Hz (target: ${rate}Hz)`);
}
break;
}
};
function startPreciseLoop() {
// FIX: Calculate the target interval correctly - ensure we're getting exactly the requested rate
const targetInterval = 1000 / rate;
let nextTime = performance.now();
console.log(`Starting actuator command loop with interval: ${targetInterval.toFixed(2)}ms (${rate}Hz)`);
function sendCommand() {
if (!running) return;
const now = performance.now();
cycleCount++;
// Request actuator command from main thread
self.postMessage({
type: 'requestActuatorCommand',
timestamp: now
});
// Performance monitoring
if (now - lastPerformanceReport > PERFORMANCE_REPORT_INTERVAL) {
const elapsedSec = (now - cycleStartTime) / 1000;
const actualRate = cycleCount / elapsedSec;
console.log(`Actuator command rate: ${actualRate.toFixed(2)}Hz (target: ${rate}Hz)`);
cycleCount = 0;
cycleStartTime = now;
lastPerformanceReport = now;
}
// Calculate next execution time with drift correction
nextTime += targetInterval;
// If we're behind schedule, catch up with improved handling
if (nextTime < now) {
const driftMS = now - nextTime;
const driftCycles = driftMS / targetInterval;
if (driftCycles > 1) {
console.warn(`Actuator timing drift: ${driftMS.toFixed(1)}ms (${driftCycles.toFixed(1)} cycles behind)`);
// Reset more completely to avoid compounding issues
nextTime = now;
}
}
// Schedule next execution with precise timing
const totalWaitTime = Math.max(1, nextTime - now);
// FIX: Use a more direct approach for timers instead of the hybrid approach
// that might be causing doubled commands
timerId = setTimeout(sendCommand, totalWaitTime);
}
// Start the loop
sendCommand();
}
+287
View File
@@ -0,0 +1,287 @@
// Enhanced Command Worker - Multi-command support
console.log('Enhanced Command Worker initialized - Multi-command support');
// High-resolution timing helpers
const originalSetTimeout = setTimeout;
const hrSetTimeout = (callback, delay) => {
const start = performance.now();
return originalSetTimeout(() => {
const drift = performance.now() - start - delay;
callback(drift);
}, delay);
};
// Busy-wait loop for ultra-precise timing
function preciseSleep(ms) {
const start = performance.now();
while (performance.now() - start < ms) {
// Busy wait for ultra-precise timing (use carefully - consumes CPU)
}
}
// ESC command variables
let escTimerId = null;
let escRunning = false;
let escRate = 10; // Default rate in Hz
// Safety command variables
let safetyTimerId = null;
let safetyRunning = false;
// Arming command variables
let armingTimerId = null;
let armingRunning = false;
self.onmessage = function(e) {
const type = e.data.type || 'esc'; // Default to ESC commands
const command = e.data.command;
console.log(`Worker received ${type} ${command} command`);
switch (type) {
case 'esc':
handleEscCommand(command, e.data);
break;
case 'safety':
handleSafetyCommand(command);
break;
case 'arming':
handleArmingCommand(command);
break;
}
};
// Handle ESC commands with high precision
function handleEscCommand(command, data) {
switch (command) {
case 'start':
const newStartRate = data.rate || 10;
// Stop any existing timer
if (escTimerId !== null) {
clearTimeout(escTimerId);
escTimerId = null;
}
// Start new timer with requested rate
console.log(`Starting ESC commands with rate: ${newStartRate}Hz`);
escRate = newStartRate;
escRunning = true;
startEscPreciseLoop();
break;
case 'stop':
console.log('Stopping ESC commands');
escRunning = false;
if (escTimerId !== null) {
clearTimeout(escTimerId);
escTimerId = null;
}
break;
}
}
// Handle safety commands with fixed 2Hz
function handleSafetyCommand(command) {
switch (command) {
case 'start':
if (safetyTimerId !== null) {
clearInterval(safetyTimerId);
safetyTimerId = null;
}
console.log('Starting safety commands at 2Hz');
safetyRunning = true;
// Use setInterval for fixed-rate safety commands
safetyTimerId = setInterval(() => {
if (safetyRunning) {
self.postMessage({
type: 'requestSafetyCommand',
timestamp: performance.now()
});
}
}, 500); // Fixed 2Hz rate
break;
case 'stop':
console.log('Stopping safety commands');
safetyRunning = false;
if (safetyTimerId !== null) {
clearInterval(safetyTimerId);
safetyTimerId = null;
}
break;
}
}
// Handle arming commands with fixed 2Hz
function handleArmingCommand(command) {
switch (command) {
case 'start':
if (armingTimerId !== null) {
clearInterval(armingTimerId);
armingTimerId = null;
}
console.log('Starting arming commands at 2Hz');
armingRunning = true;
// Use setInterval for fixed-rate arming commands
armingTimerId = setInterval(() => {
if (armingRunning) {
self.postMessage({
type: 'requestArmingCommand',
timestamp: performance.now()
});
}
}, 500); // Fixed 2Hz rate
break;
case 'stop':
console.log('Stopping arming commands');
armingRunning = false;
if (armingTimerId !== null) {
clearInterval(armingTimerId);
armingTimerId = null;
}
break;
}
}
// Improve the startEscPreciseLoop function for more stable high-frequency operation
function startEscPreciseLoop() {
// Special optimization for ultra-high frequencies
const isUltraHighRate = escRate >= 300; // Special handling for rates >= 300Hz
const isHighRate = escRate > 200;
// Optimized batch sizing for different rate ranges
let batchSize = 1;
if (isUltraHighRate) {
batchSize = Math.ceil(escRate / 180); // More aggressive batching for ultra-high rates
} else if (isHighRate) {
batchSize = Math.ceil(escRate / 200);
}
const targetInterval = 1000 / escRate;
const adjustedInterval = isHighRate ? targetInterval * batchSize : targetInterval;
// console.log(`Starting ultra-precise loop with ${isUltraHighRate ? 'ULTRA HIGH' : isHighRate ? 'HIGH' : 'standard'} mode`);
// console.log(`Target rate: ${escRate}Hz, using batch size: ${batchSize}, adjusted interval: ${adjustedInterval.toFixed(2)}ms`);
// Performance tracking
let nextTime = performance.now();
let cycleCount = 0;
let lastPerformanceReport = performance.now();
const PERFORMANCE_REPORT_INTERVAL = 5000; // Report performance every 5 seconds
// Pre-allocate the message object to reduce GC pressure
const messageTemplate = {
type: 'requestEscCommand',
timestamp: 0
};
function sendCommand() {
if (!escRunning) return;
const now = performance.now();
cycleCount++;
// Send multiple commands in a batch for high frequencies
for (let i = 0; i < batchSize; i++) {
if (!escRunning) break;
// Update timestamp rather than creating new objects each time
messageTemplate.timestamp = now + (i * (targetInterval / batchSize));
self.postMessage(messageTemplate);
}
// Performance monitoring
if (now - lastPerformanceReport > PERFORMANCE_REPORT_INTERVAL) {
const actualRate = (cycleCount * batchSize) / ((now - lastPerformanceReport) / 1000);
// console.log(`Performance report: Achieved rate ${actualRate.toFixed(2)}Hz (target: ${escRate}Hz)`);
cycleCount = 0;
lastPerformanceReport = now;
}
// Calculate next execution time with improved drift correction
nextTime += adjustedInterval;
// If we're behind schedule, use a more sophisticated catch-up strategy
if (nextTime < now) {
const driftMS = now - nextTime;
const driftCycles = driftMS / adjustedInterval;
if (driftCycles > 1) {
// More nuanced drift correction for stability
if (driftCycles > 10) {
// We're way behind, reset completely to avoid CPU overload
console.warn(`Severe timing drift: ${driftMS.toFixed(1)}ms (${driftCycles.toFixed(1)} cycles behind) - resetting`);
nextTime = now;
} else {
// We're moderately behind, gradual catch-up
console.warn(`Moderate timing drift: ${driftMS.toFixed(1)}ms (${driftCycles.toFixed(1)} cycles behind) - gradual catchup`);
// The larger the drift, the more aggressive the correction
const catchupFactor = Math.min(0.8, driftCycles / 20);
nextTime = now - (adjustedInterval * catchupFactor);
}
}
}
// Optimized scheduling strategy for high-frequency stability
const totalWaitTime = Math.max(0.5, nextTime - now); // Minimum 0.5ms wait to avoid tight loops
// For ultra-high rates, use more precise timing
if (isUltraHighRate && totalWaitTime < 4) {
// For very short waits, just use optimized sleep
optimizedSleep(totalWaitTime, () => {
sendCommand();
});
} else {
// For longer waits or standard rates, use the hybrid approach
const timeoutPortion = Math.max(0, totalWaitTime - (isUltraHighRate ? 1.5 : 2));
escTimerId = originalSetTimeout(() => {
const remainingTime = nextTime - performance.now();
if (remainingTime > 0) {
preciseSleep(remainingTime);
}
sendCommand();
}, timeoutPortion);
}
}
// Start the loop
sendCommand();
}
// Optimized sleep function that avoids excessive CPU usage
// while still providing precise timing
function optimizedSleep(ms, callback) {
const targetTime = performance.now() + ms;
// For very short sleeps (<1ms), use busy waiting
if (ms < 1) {
preciseSleep(ms);
callback();
return;
}
// For moderate sleeps, use a combination approach
// First sleep most of the time using setTimeout
const timeoutMs = ms - 1; // Leave 1ms for precise waiting
escTimerId = originalSetTimeout(() => {
// Then precisely wait the remaining time
const remaining = Math.max(0, targetTime - performance.now());
if (remaining > 0) {
preciseSleep(remaining);
}
callback();
}, timeoutMs);
}
+112
View File
@@ -0,0 +1,112 @@
class WebSocketClient {
constructor(url) {
this.url = url;
this.socket = null;
this.messageHandlers = [];
this.openHandlers = [];
this.errorHandlers = [];
this.closeHandlers = [];
this.connected = false; // Add connected property
}
connect() {
// Reset connected status before attempting a new connection
this.connected = false;
this.socket = new WebSocket(this.url);
this.socket.addEventListener('open', (event) => {
this.handleOpen(event);
});
this.socket.addEventListener('message', (event) => {
this.handleMessage(event);
});
this.socket.addEventListener('error', (event) => {
this.handleError(event);
});
this.socket.addEventListener('close', (event) => {
this.handleClose(event);
});
}
// Updated to set connected status
handleOpen(event) {
console.log('WebSocket is open now.');
this.connected = true;
this.openHandlers.forEach(handler => handler());
}
addOpenHandler(handler) {
this.openHandlers.push(handler);
}
handleMessage(event) {
if (event.data instanceof Blob) {
this.handleBlobMessage(event);
} else {
console.log('Message from server:', event.data);
}
}
handleBlobMessage = (event) => {
const reader = new FileReader();
reader.onload = () => {
const arrayBuffer = reader.result;
const buffer = new Uint8Array(arrayBuffer);
this.messageHandlers.forEach(handler => handler(buffer));
};
reader.readAsArrayBuffer(event.data);
}
addMessageHandler(handler) {
this.messageHandlers.push(handler);
}
handleError(event) {
console.error('WebSocket error observed:', event);
this.connected = false; // Update connected status on error
this.errorHandlers.forEach(handler => handler(event));
}
addErrorHandler(handler) {
this.errorHandlers.push(handler);
}
// Updated to set connected status
handleClose(event) {
console.log('WebSocket is closed now.');
this.connected = false;
this.closeHandlers.forEach(handler => handler());
}
addCloseHandler(handler) {
this.closeHandlers.push(handler);
}
write(buffer) {
if (this.isConnected() && this.socket) {
const binaryData = new Blob([new Uint8Array(buffer)]);
this.socket.send(binaryData);
return true;
} else {
console.error('WebSocket is not open. Ready state:', this.socket?.readyState);
return false;
}
}
// Add a method to check if connected
isConnected() {
return this.connected && this.socket && this.socket.readyState === WebSocket.OPEN;
}
close() {
if (this.socket) {
this.socket.close();
this.connected = false;
}
}
}
export default WebSocketClient;
+100
View File
@@ -0,0 +1,100 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
devtool: 'source-map',
// entry: './src/index.js',
entry: {
main: './src/index.js',
subscriber: './src/subscriber.js',
bus_monitor: './src/bus_monitor.js',
esc_panel: './src/esc_panel.js',
actuator_panel: './src/actuator_panel.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]',
},
},
],
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
fallback: {
"util": require.resolve("util/"),
"buffer": require.resolve("buffer/"),
"underscore": require.resolve("underscore/modules/index.js"),
"crypto": require.resolve("crypto-browserify"),
"long": require.resolve("long"),
"vm": require.resolve("vm-browserify"),
"stream": require.resolve("stream-browserify"),
"process": require.resolve("process/browser")
},
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
filename: 'index.html',
chunks: ['main']
}),
new HtmlWebpackPlugin({
template: './public/subscriber.html',
filename: 'subscriber.html',
chunks: ['subscriber']
}),
new HtmlWebpackPlugin({
template: './public/bus_monitor.html',
filename: 'bus_monitor.html',
chunks: ['bus_monitor']
}),
new HtmlWebpackPlugin({
template: './public/esc_panel.html',
filename: 'esc_panel.html',
chunks: ['esc_panel']
}),
new HtmlWebpackPlugin({
template: './public/actuator_panel.html',
filename: 'actuator_panel.html',
chunks: ['actuator_panel']
}),
new webpack.ProvidePlugin({
process: 'process/browser',
util: 'util',
Buffer: ['buffer', 'Buffer'],
}),
],
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
compress: true,
port: 8080,
},
};