init for release
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
// filepath: .babelrc
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"]
|
||||
}
|
||||
+6
-18
@@ -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
|
||||
Vendored
+16
@@ -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}/*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Mavlink"
|
||||
]
|
||||
}
|
||||
@@ -1 +1,93 @@
|
||||
# dronecan-configurator
|
||||
# DroneCAN-Webtools
|
||||
|
||||

|
||||
|
||||
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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 495 KiB |
Generated
+7765
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 />);
|
||||
@@ -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);
|
||||
@@ -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 />);
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#sub-root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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}"}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user