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
|
node_modules
|
||||||
bin-debug/
|
dist
|
||||||
bin-release/
|
.DS_Store
|
||||||
[Oo]bj/
|
mav.parm
|
||||||
[Bb]in/
|
mav.tlog
|
||||||
|
mav.tlog.raw
|
||||||
# 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.
|
|
||||||
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