Compare commits

..

14 Commits

Author SHA1 Message Date
wuxu 4d9f5687dd 1 2026-05-24 19:52:13 +08:00
wuxu 43d8563b79 update visual design system 2026-05-24 19:47:05 +08:00
wuxu 1850c58197 add Dockerfile and docker-compose.yml 2026-05-23 09:47:38 +08:00
wuxu a02925dfd0 add Chinese translate 2026-05-23 09:31:44 +08:00
wuxu e612c852e5 update dna and panel controls 2026-05-23 09:02:26 +08:00
wuxu 39b5edb585 improve parameter fetch feedback 2026-05-23 09:02:26 +08:00
wuxu 71345419d5 route can frames through session 2026-05-23 09:02:26 +08:00
wuxu fe841fb0ba fix dronecan zero data type handling 2026-05-23 09:02:26 +08:00
wuxu 1cd3badb2d add slcan serial support 2026-05-23 09:02:26 +08:00
Huibean Luo b50cb503a8 Update official entry URL in README 2025-09-02 17:20:11 +01:00
Huibean Luo 8e06ca76ca Merge pull request #2 from Huibean/pr-add-firmware-upgrade-hex-support
firmware upgrade add .hex support
2025-08-14 10:08:57 +08:00
Huibean 783b901fe7 firmware upgrade add .hex support 2025-08-14 10:07:33 +08:00
Huibean Luo ae31462434 Merge pull request #1 from Huibean/pr-add-mavlink-signing
add mavlink signing and wss support
2025-08-14 09:59:18 +08:00
Huibean 7bffe34f54 add mavlink sigining and wss support 2025-08-14 09:56:58 +08:00
39 changed files with 4376 additions and 9024 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.claude
npm-debug.log*
Dockerfile*
+1
View File
@@ -4,3 +4,4 @@ dist
mav.parm
mav.tlog
mav.tlog.raw
deploy.sh
+55
View File
@@ -0,0 +1,55 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
- `npm install` — install dependencies.
- `npm start` — start webpack dev server on `http://localhost:8080` with hot reload.
- `npm run build` — create a production build in `dist/`.
There are currently no lint, typecheck, or test scripts configured in `package.json`, and no Jest/Vitest/ESLint config files are present. Use `npm run build` as the available validation command after changes. There is no single-test command until a test runner is added.
## Application structure
This is a React 18 + Material UI browser application bundled by Webpack 5/Babel. `webpack.config.js` defines multiple entry points and HTML outputs:
- `src/index.js` -> `public/index.html` for the main application.
- `src/subscriber.js` -> `public/subscriber.html`.
- `src/bus_monitor.js` -> `public/bus_monitor.html`.
- `src/esc_panel.js` -> `public/esc_panel.html`.
- `src/actuator_panel.js` -> `public/actuator_panel.html`.
The main app is `src/App.js`. It creates global browser objects `window.mavlinkSession` and `window.localNode`, wires `localNode.sendFrame` into MAVLink CAN frame forwarding, and renders the primary node list, node logs, node properties, parameter editor, adapter settings, DNA server modal, and panel launch menus.
Standalone panel pages are opened by the main app with `window.open(...)`. Panels such as subscriber, bus monitor, ESC, and actuator use their own entry files/window wrapper components and access shared state through `window.opener.localNode` when they are child windows. Avoid changing these panels as if they were independent apps unless you also account for the opener relationship.
## Transport and protocol layers
`src/mavlink_session.js` is the bridge between browser transports and DroneCAN:
- WebSocket transport is implemented in `src/ws_client.js`.
- Web Serial transport is implemented in `src/web_serial.js`.
- MAVLink parsing/sending comes from `src/mavlink.js` globals and wraps CAN frames in MAVLink `CAN_FRAME` messages.
- Incoming extended CAN frames are emitted into `window.localNode` as `can-frame` events.
`src/ConnectionSettingsModal.js` owns connection setup UI for serial and WebSocket, local node ID selection, CAN bus selection, MAVLink signing, and the recurring `MAV_CMD_CAN_FORWARD` command that enables CAN forwarding on the selected bus.
`src/dronecan/` contains the generated/ported DroneCAN protocol implementation: DSDL message classes, type packing/unpacking, frame and transfer logic, and `node.js`. `src/dronecan/node.js` is the event-oriented high-level API for node monitoring, node info requests, parameter get/set, restarts, ESC/actuator commands, dynamic node ID allocation messages, and firmware file read responses.
To regenerate the DroneCAN DSDL JavaScript, the README documents using `dronecan_dsdljs.py` from `https://github.com/dronecan/dronecan_dsdljs` and writing output to `src/dronecan`.
## Feature-specific modules
- `src/FileServer.js` is a singleton (`window.FileServer`) used for firmware/file transfer. It loads `.bin` files directly, converts Intel HEX `.hex` files to binary, registers files by virtual path/key, and answers `uavcan.protocol.file.Read.Request` through `localNode.responseUavcanProtocolFileRead`.
- `src/services/DynamicNodeIdServer.js` implements a browser-side DroneCAN dynamic node ID allocation server and persists allocations in `localStorage` under `dna_server_allocations`.
- `src/NodeParam.js`, `src/EditParamModal.js`, and `src/ParamEditors/` implement parameter display/editing using DroneCAN `GetSet` responses stored on `localNode.nodeParams`.
- `src/BusMonitor.js`, `src/SubscriberWindow.js`, `src/EscPanel.js`, and `src/ActuatorPanel.js` consume `localNode` events to monitor transfers or send command messages.
## Styling and assets
Shared Material UI theme settings live in `src/theme.js`. Page-specific CSS is in `src/css/`. Webpack handles CSS and image assets via `style-loader`, `css-loader`, and `file-loader`.
## README details to preserve
The README states the official entry is `https://can.ardupilot.org` and the backup entry is `https://can.vimdrones.com`. The browser app provides bus monitoring, ESC control/configuration, actuator control/testing, message subscription, node configuration, dynamic node ID allocation, and firmware/file transfer workflows.
+589
View File
@@ -0,0 +1,589 @@
---
version: alpha
name: Claude-design-analysis
description: A warm-canvas editorial interface for Anthropic's Claude product. The system anchors on a tinted cream canvas with serif display headlines, warm coral CTAs, and dark navy product surfaces (code editor mockups, model showcase cards). Brand voltage comes from the cream/coral pairing — deliberately warm and humanist where most AI brands use cool blue + slate. Type voice runs a slab-serif display ("Copernicus" / Tiempos Headline) for h1/h2 and a humanist sans for body. The signature Anthropic black-radial-spike mark anchors the wordmark.
colors:
primary: "#cc785c"
primary-active: "#a9583e"
primary-disabled: "#e6dfd8"
ink: "#141413"
body: "#3d3d3a"
body-strong: "#252523"
muted: "#6c6a64"
muted-soft: "#8e8b82"
hairline: "#e6dfd8"
hairline-soft: "#ebe6df"
canvas: "#faf9f5"
surface-soft: "#f5f0e8"
surface-card: "#efe9de"
surface-cream-strong: "#e8e0d2"
surface-dark: "#181715"
surface-dark-elevated: "#252320"
surface-dark-soft: "#1f1e1b"
on-primary: "#ffffff"
on-dark: "#faf9f5"
on-dark-soft: "#a09d96"
accent-teal: "#5db8a6"
accent-amber: "#e8a55a"
success: "#5db872"
warning: "#d4a017"
error: "#c64545"
typography:
display-xl:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 64px
fontWeight: 400
lineHeight: 1.05
letterSpacing: -1.5px
display-lg:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 48px
fontWeight: 400
lineHeight: 1.1
letterSpacing: -1px
display-md:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 36px
fontWeight: 400
lineHeight: 1.15
letterSpacing: -0.5px
display-sm:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 28px
fontWeight: 400
lineHeight: 1.2
letterSpacing: -0.3px
title-lg:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 22px
fontWeight: 500
lineHeight: 1.3
letterSpacing: 0
title-md:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 18px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
title-sm:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 16px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
body-md:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 16px
fontWeight: 400
lineHeight: 1.55
letterSpacing: 0
body-sm:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.55
letterSpacing: 0
caption:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 13px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
caption-uppercase:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 12px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 1.5px
code:
fontFamily: "JetBrains Mono, ui-monospace, monospace"
fontSize: 14px
fontWeight: 400
lineHeight: 1.6
letterSpacing: 0
button:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 14px
fontWeight: 500
lineHeight: 1
letterSpacing: 0
nav-link:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 14px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
rounded:
xs: 4px
sm: 6px
md: 8px
lg: 12px
xl: 16px
pill: 9999px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 16px
lg: 24px
xl: 32px
xxl: 48px
section: 96px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 12px 20px
height: 40px
button-primary-active:
backgroundColor: "{colors.primary-active}"
textColor: "{colors.on-primary}"
rounded: "{rounded.md}"
button-primary-disabled:
backgroundColor: "{colors.primary-disabled}"
textColor: "{colors.muted}"
rounded: "{rounded.md}"
button-secondary:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 12px 20px
height: 40px
button-secondary-on-dark:
backgroundColor: "{colors.surface-dark-elevated}"
textColor: "{colors.on-dark}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 12px 20px
button-text-link:
backgroundColor: transparent
textColor: "{colors.ink}"
typography: "{typography.button}"
button-icon-circular:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
size: 36px
text-link:
backgroundColor: transparent
textColor: "{colors.primary}"
typography: "{typography.body-md}"
top-nav:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.nav-link}"
height: 64px
hero-band:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.display-xl}"
padding: 96px
hero-illustration-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
feature-card:
backgroundColor: "{colors.surface-card}"
textColor: "{colors.ink}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
product-mockup-card-dark:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
code-window-card:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.code}"
rounded: "{rounded.lg}"
padding: 24px
model-comparison-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
pricing-tier-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.title-lg}"
rounded: "{rounded.lg}"
padding: 32px
pricing-tier-card-featured:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.title-lg}"
rounded: "{rounded.lg}"
padding: 32px
callout-card-coral:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
connector-tile:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.title-sm}"
rounded: "{rounded.lg}"
padding: 20px
text-input:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-md}"
rounded: "{rounded.md}"
padding: 10px 14px
height: 40px
text-input-focused:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
cookie-consent-card:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.body-sm}"
rounded: "{rounded.lg}"
padding: 24px
category-tab:
backgroundColor: transparent
textColor: "{colors.muted}"
typography: "{typography.nav-link}"
padding: 8px 14px
rounded: "{rounded.md}"
category-tab-active:
backgroundColor: "{colors.surface-card}"
textColor: "{colors.ink}"
typography: "{typography.nav-link}"
rounded: "{rounded.md}"
badge-pill:
backgroundColor: "{colors.surface-card}"
textColor: "{colors.ink}"
typography: "{typography.caption}"
rounded: "{rounded.pill}"
padding: 4px 12px
badge-coral:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.caption-uppercase}"
rounded: "{rounded.pill}"
padding: 4px 12px
cta-band-coral:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.display-sm}"
rounded: "{rounded.lg}"
padding: 64px
cta-band-dark:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.display-sm}"
rounded: "{rounded.lg}"
padding: 64px
footer:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark-soft}"
typography: "{typography.body-sm}"
padding: 64px
---
## Overview
Claude.com is the warmest, most editorial interface in the AI-product category. The base atmosphere is a **tinted cream canvas** (`{colors.canvas}`#faf9f5) — distinctly warm, deliberately not the cool gray-white that every other AI brand uses. Headlines run a **slab-serif display** ("Copernicus" / Tiempos Headline) at weight 400 with negative letter-spacing, paired with **StyreneB / Inter** body sans. The combination feels like a literary publication, not a SaaS marketing page.
Brand voltage comes from the **cream + coral pairing** — coral (`{colors.primary}`#cc785c) is the signature Anthropic accent, used on every primary CTA, on the brand wordmark, and on full-bleed callout cards. The coral is warm, slightly muted, never cyan/blue — a deliberate counter-positioning against OpenAI's cool slate, Google's saturated blue, and Microsoft's corporate cyan.
The system has three surface modes that alternate page-by-page:
1. **Cream canvas** (`{colors.canvas}`) — default body floor
2. **Light cream cards** (`{colors.surface-card}`) — feature card backgrounds
3. **Dark navy product surfaces** (`{colors.surface-dark}`) — code editor mockups, model showcase cards, pre-footer CTAs, footer itself
The dark surfaces are where Claude shows its product chrome — code blocks, terminal output, model comparison tables, agentic-flow diagrams. The cream-to-dark contrast is the page's pacing rhythm.
**Key Characteristics:**
- Warm cream canvas (`{colors.canvas}`#faf9f5) with dark warm-ink text (`{colors.ink}`#141413). The brand's defining color choice.
- Coral primary CTA (`{colors.primary}`#cc785c). Used scarcely on individual buttons, generously on full-bleed coral callout cards.
- Slab-serif display headlines via Copernicus / Tiempos Headline at weight 400 with negative letter-spacing. Pairs with humanist sans body for a literary editorial voice.
- Dark navy product mockup cards (`{colors.surface-dark}`#181715) carrying code blocks, terminal panels, model comparison data — the brand shows the product chrome at scale rather than abstract marketing illustrations.
- Light cream feature cards (`{colors.surface-card}`#efe9de) — slightly darker than canvas, used for content-driven feature explanations.
- Anthropic radial-spike mark — a small black asterisk-like glyph (4-spoke radial) — appears as the brand wordmark prefix and as a content marker.
- Border radius is hierarchical: `{rounded.md}` (8px) for buttons + inputs, `{rounded.lg}` (12px) for content + product cards, `{rounded.xl}` (16px) for the hero illustration container, `{rounded.pill}` for badges.
- Section rhythm `{spacing.section}` (96px) — modern-SaaS standard. Internal card padding stays generous at `{spacing.xl}` (32px).
## Colors
### Brand & Accent
- **Coral / Primary** (`{colors.primary}`#cc785c): The signature Anthropic warm coral. Used on every primary CTA background, on full-bleed coral callout cards, on the brand wordmark accent. The most-recognized Anthropic color outside of the spike-mark logo.
- **Coral Active** (`{colors.primary-active}`#a9583e): The press / hover-darker variant.
- **Coral Disabled** (`{colors.primary-disabled}`#e6dfd8): A desaturated cream-tinted disabled state.
- **Accent Teal** (`{colors.accent-teal}`#5db8a6): Used sparingly on secondary product surfaces (terminal status indicators, "active connection" dots in connectors page).
- **Accent Amber** (`{colors.accent-amber}`#e8a55a): A small companion warm-tone used on category badges and inline highlights.
### Surface
- **Canvas** (`{colors.canvas}`#faf9f5): The default page floor. Tinted cream — warm, deliberately not pure white.
- **Surface Soft** (`{colors.surface-soft}`#f5f0e8): Section dividers, very-soft band backgrounds.
- **Surface Card** (`{colors.surface-card}`#efe9de): Feature cards, content cards. One step darker than canvas.
- **Surface Cream Strong** (`{colors.surface-cream-strong}`#e8e0d2): A strongest-cream variant used on selected category tabs and emphasized section bands.
- **Surface Dark** (`{colors.surface-dark}`#181715): Code editor mockups, model showcase cards, footer. The dominant dark surface.
- **Surface Dark Elevated** (`{colors.surface-dark-elevated}`#252320): Elevated cards inside dark bands (settings panels in mockups).
- **Surface Dark Soft** (`{colors.surface-dark-soft}`#1f1e1b): Slightly lighter dark, used for code block backgrounds inside larger dark cards.
- **Hairline** (`{colors.hairline}`#e6dfd8): The 1px border tone on cream surfaces. Same hex as `{colors.primary-disabled}` — borders feel like one elevation step rather than ink lines.
- **Hairline Soft** (`{colors.hairline-soft}`#ebe6df): Barely-visible divider used inside the same band.
### Text
- **Ink** (`{colors.ink}`#141413): All headlines and primary text. Warm dark, slightly off-pure-black.
- **Body Strong** (`{colors.body-strong}`#252523): Emphasized paragraphs, lead text.
- **Body** (`{colors.body}`#3d3d3a): Default running-text color.
- **Muted** (`{colors.muted}`#6c6a64): Sub-headings, breadcrumbs, footer-adjacent secondary text.
- **Muted Soft** (`{colors.muted-soft}`#8e8b82): Captions, fine-print, copyright lines.
- **On Primary** (`{colors.on-primary}`#ffffff): Text on coral buttons.
- **On Dark** (`{colors.on-dark}`#faf9f5): Cream-tinted white used on dark surfaces (echoes the canvas tone).
- **On Dark Soft** (`{colors.on-dark-soft}`#a09d96): Footer body text, secondary labels in dark mockups.
### Semantic
- **Success** (`{colors.success}`#5db872): Green status dots, "available" indicators.
- **Warning** (`{colors.warning}`#d4a017): Warning callouts (rare on marketing surfaces).
- **Error** (`{colors.error}`#c64545): Validation errors.
## Typography
### Font Family
The system runs **Copernicus** (or **Tiempos Headline** as substitute) as the slab-serif display face for headlines, and **StyreneB** (or **Inter** as substitute) as the humanist sans for body, navigation, and UI labels. **JetBrains Mono** handles code blocks. The fallback stack walks `Tiempos Headline, Garamond, "Times New Roman", serif` for display and `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` for body.
The display/body split is editorial:
- Copernicus serif (weight 400, negative tracking) → h1, h2, h3, hero display
- StyreneB sans (weight 400-500) → body, navigation, buttons, captions, labels
- JetBrains Mono → all code blocks and terminal text
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.display-xl}` | 64px | 400 | 1.05 | -1.5px | Homepage h1 ("Meet your thinking partner") — Copernicus serif |
| `{typography.display-lg}` | 48px | 400 | 1.1 | -1px | Section heads — Copernicus |
| `{typography.display-md}` | 36px | 400 | 1.15 | -0.5px | Sub-section heads, model names — Copernicus |
| `{typography.display-sm}` | 28px | 400 | 1.2 | -0.3px | Pricing tier names, callout headlines — Copernicus |
| `{typography.title-lg}` | 22px | 500 | 1.3 | 0 | Pricing plan size labels — StyreneB |
| `{typography.title-md}` | 18px | 500 | 1.4 | 0 | Feature card titles, intro paragraphs |
| `{typography.title-sm}` | 16px | 500 | 1.4 | 0 | Connector tile titles, list labels |
| `{typography.body-md}` | 16px | 400 | 1.55 | 0 | Default running-text — StyreneB |
| `{typography.body-sm}` | 14px | 400 | 1.55 | 0 | Footer body, fine-print |
| `{typography.caption}` | 13px | 500 | 1.4 | 0 | Badge labels, captions |
| `{typography.caption-uppercase}` | 12px | 500 | 1.4 | 1.5px | Category tags, "NEW" badges |
| `{typography.code}` | 14px | 400 | 1.6 | 0 | Code blocks — JetBrains Mono |
| `{typography.button}` | 14px | 500 | 1.0 | 0 | Standard button labels |
| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu items |
### Principles
Display sizes use weight 400 (regular), never bold. Negative letter-spacing (-0.3 to -1.5px) is essential — Copernicus without it reads as off-brand. The serif character is what gives Anthropic its literary, considered voice; switching to a sans-serif display would make Claude feel like every other AI tool.
Body type stays at weight 400 for paragraphs, weight 500 for labels and emphasized phrases. The sans body is humanist (StyreneB) — never geometric. Inter is an acceptable substitute because of its similar humanist proportions; Helvetica or Arial would be too neutral and break the warm-editorial feel.
### Note on Font Substitutes
If Copernicus / Tiempos Headline is unavailable, **Cormorant Garamond** at weight 500 with -0.02em letter-spacing is the closest open-source approximation. **EB Garamond** is a fallback. For StyreneB, **Inter** is the closest match — both are humanist sans designed for screen reading. **Söhne** is another close alternative if licensed.
## Layout
### Spacing System
- **Base unit:** 4px.
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px.
- **Section padding:** `{spacing.section}` (96px) — modern-SaaS rhythm.
- **Card internal padding:** `{spacing.xl}` (32px) for feature cards, pricing tier cards, model comparison cards; `{spacing.lg}` (24px) for code-window cards and connector tiles.
- **Callout / CTA bands:** `{spacing.xxl}` (48px) inside coral callout cards; 64px inside the larger dark CTA band.
### Grid & Container
- **Max content width:** ~1200px centered.
- **Editorial body:** Single 12-column grid; hero often uses 6/6 split (h1 left, illustration right).
- **Feature card grids:** 3-up at desktop, 2-up at tablet, 1-up at mobile.
- **Connector tile grids:** 4-up or 6-up at desktop, 2-up at tablet, 1-up at mobile.
- **Pricing grid:** 3-up at desktop (Free / Pro / Team / Enterprise often), 1-up at mobile.
### Whitespace Philosophy
The cream canvas + serif display + generous internal padding create an editorial pacing — Claude reads like a long-form magazine column rather than a marketing template. Whitespace between bands stays uniform at 96px; whitespace inside cards is generous (32px), letting type breathe.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| Flat | No shadow, no border | Body sections, top nav, hero bands |
| Soft hairline | 1px `{colors.hairline}` border | Inputs, sub-nav, occasionally on cards |
| Cream card | `{colors.surface-card}` background — no shadow | Feature cards, content cards |
| Dark surface card | `{colors.surface-dark}` background — no shadow | Code editor mockups, model showcase cards |
| Subtle drop shadow | Faint shadow at low alpha | Hover-elevated states (the system uses `0 1px 3px rgba(20,20,19,0.08)` rarely) |
The elevation philosophy is **color-block first, shadow rare**. Most depth comes from the cream-vs-dark surface contrast. Shadows are minimal. The dark surface mockups have their own internal product chrome (code editor scrollbars, line numbers, syntax highlighting) which adds detail without needing external shadows.
### Decorative Depth
- The Anthropic spike-mark glyph (4-spoke radial asterisk) appears as a small black mark in the brand wordmark and inline as a content marker.
- Code editor mockups carry their own internal depth: syntax-highlighted text in muted blues / oranges / grays, line numbers in `{colors.muted-soft}`, status bars at the bottom in `{colors.surface-dark-elevated}`.
- Some hero illustrations use simple line-art with coral and dark-navy strokes on cream — minimal, hand-drawn-feeling, never photorealistic.
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.xs}` | 4px | Reserved for badge accents and tiny dropdowns |
| `{rounded.sm}` | 6px | Small inline buttons, dropdown items |
| `{rounded.md}` | 8px | Standard CTA buttons, text inputs, category tabs |
| `{rounded.lg}` | 12px | Content cards (feature, pricing, code-window, model-comparison) |
| `{rounded.xl}` | 16px | Hero illustration container, the larger marquee components |
| `{rounded.pill}` | 9999px | Badge pills, "NEW" tags |
| `{rounded.full}` | 9999px / 50% | Avatar substitutes, icon buttons |
### Photography & Illustrations
Claude's hero rarely uses photography. Instead it uses:
- Simple line-art illustrations with coral + dark-navy strokes on the cream canvas
- Code editor mockups (the dominant "hero" treatment on developer-focused pages)
- Terminal output mockups with monospace text on dark
- Model comparison cards (Opus / Sonnet / Haiku) with abstract geometric thumbnails
When photography is used (rare — mostly testimonials), avatars crop to perfect circles at 40px diameter.
## Components
### Top Navigation
**`top-nav`** — Cream nav bar pinned to the top of every page. 64px tall, `{colors.canvas}` background. Carries the Anthropic spike-mark + "Claude" wordmark at left, primary horizontal menu (Product, Solutions, Use Cases, Pricing, Research, Company) center-left, right-side cluster with "Sign in" text-link, "Try Claude" `{component.button-primary}` (coral). Menu items in `{typography.nav-link}` (StyreneB 14px / 500).
### Buttons
**`button-primary`** — The signature coral CTA. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), type `{typography.button}` (StyreneB 14px / 500), padding 12px × 20px, height 40px, rounded `{rounded.md}` (8px). Active state `button-primary-active` darkens to `{colors.primary-active}` (#a9583e).
**`button-secondary`** — Cream button with hairline outline. Background `{colors.canvas}`, text `{colors.ink}`, 1px hairline border, same padding + height + radius as primary.
**`button-secondary-on-dark`** — Used over `{colors.surface-dark}` cards. Background `{colors.surface-dark-elevated}` (#252320), text `{colors.on-dark}`. Stays dark — the system never inverts to a light secondary on dark surfaces.
**`button-text-link`** — Inline text button, no background. Used for "Sign in" in the top nav and inline CTA links.
**`button-icon-circular`** — 36px circular icon button. Background `{colors.canvas}`, hairline border, ink-color icon. Used for carousel arrows, share, "view more".
**`text-link`** — Inline body links in `{colors.primary}` (the coral). Underlined on press; the coral inline link is one of the system's most distinctive small details.
### Cards & Containers
**`hero-band`** — Cream-canvas hero with a 6-6 grid: h1 + sub-headline + button row on the left, hero illustration card or product mockup card on the right. Vertical padding `{spacing.section}` (96px).
**`hero-illustration-card`** — A larger card holding the hero's right-side artifact — sometimes a coral-stroke line illustration on cream background, sometimes a dark code editor mockup. Background `{colors.canvas}` or `{colors.surface-dark}` depending on context, rounded `{rounded.xl}` (16px).
**`feature-card`** — Used in 3-up feature grids. Background `{colors.surface-card}` (#efe9de — slightly darker cream), rounded `{rounded.lg}` (12px), internal padding `{spacing.xl}` (32px). Carries a small icon at top, an `{typography.title-md}` headline, and a body description in `{typography.body-md}`.
**`product-mockup-card-dark`** — Dark navy card showing actual Claude product chrome (chat interface, code editor, agent controls). Background `{colors.surface-dark}`, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries text labels in `{colors.on-dark}` and product UI fragments below.
**`code-window-card`** — A specialized dark card showing a code editor with line numbers, syntax-highlighted code in `{typography.code}` (JetBrains Mono), and sometimes a "Run" button or terminal output panel below. Background `{colors.surface-dark}` with `{colors.surface-dark-soft}` for the inner code block, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). The signature visual element of Claude Code product pages.
**`model-comparison-card`** — Used on the homepage's "Which problem are you up against?" section comparing Opus / Sonnet / Haiku. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries the model name, a short capability blurb, and a `{component.text-link}` to learn more.
**`pricing-tier-card`** — Standard tier card. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding `{spacing.xl}` (32px). Carries the plan name in `{typography.title-lg}` (StyreneB), price in `{typography.display-sm}` (Copernicus serif!), feature checklist in `{typography.body-md}`, and a `{component.button-primary}` at the bottom.
**`pricing-tier-card-featured`** — The featured tier (typically "Pro" or "Team"). Background flips to `{colors.surface-dark}`, text inverts to `{colors.on-dark}`. The dark surface IS the featured-tier signal.
**`callout-card-coral`** — A full-bleed coral card carrying a major call-to-action. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), rounded `{rounded.lg}`, padding `{spacing.xxl}` (48px). The coral surface IS the voltage; the CTA inside uses an inverted button style (cream/canvas button on coral).
**`connector-tile`** — Used on the connectors page's integration grid. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding 20px. Each tile carries a logo at top, a `{typography.title-sm}` connector name, and a short description.
### Inputs & Forms
**`text-input`** — Standard text input. Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, rounded `{rounded.md}` (8px), padding 10px × 14px, height 40px. 1px hairline border in `{colors.hairline}`.
**`text-input-focused`** — Focus state. Border thickens or shifts to `{colors.primary}` (coral) for emphasis. Carries a 3px coral-at-15%-alpha outer ring.
**`cookie-consent-card`** — Bottom-right floating dark cookie banner. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). One of the few places dark surface appears at small scale on cream pages.
### Tags / Badges
**`badge-pill`** — Small pill label used for category tags. Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.caption}` (13px / 500), rounded `{rounded.pill}`, padding 4px × 12px.
**`badge-coral`** — Coral-fill badge for "NEW", "BETA", featured highlights. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.caption-uppercase}` (12px / 500 / 1.5px tracking), rounded `{rounded.pill}`, padding 4px × 12px.
### Tab / Filter
**`category-tab`** + **`category-tab-active`** — Used in sub-nav rows on solutions / connectors pages. Inactive: transparent background, `{colors.muted}` text. Active: `{colors.surface-card}` background, `{colors.ink}` text. Padding 8px × 14px, rounded `{rounded.md}`.
### CTA / Footer
**`cta-band-coral`** — A pre-footer "Try Claude" CTA card. Full-width coral fill, white type, rounded `{rounded.lg}`, padding 64px. Carries an h2 in `{typography.display-sm}` (still serif!), a sub-line, and a cream-button CTA.
**`cta-band-dark`** — Alternative pre-footer band on developer-focused pages. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding 64px. Often pairs with a code-window card.
**`footer`** — Dark navy footer that closes every page. Background `{colors.surface-dark}` (#181715), text `{colors.on-dark-soft}`. 4-column link list at desktop covering Product / Company / Resources / Legal. Vertical padding 64px. The Anthropic spike-mark + "Anthropic" wordmark sits at the top in `{colors.on-dark}`. The footer never inverts.
## Do's and Don'ts
### Do
- Anchor every page on the cream canvas. Pure white reads as "any other AI tool"; the warm tint is the brand differentiator.
- Use Copernicus serif for every display headline. Pair with StyreneB sans body. Negative letter-spacing on display sizes is non-negotiable.
- Reserve `{colors.primary}` (coral) for primary CTAs and full-bleed `{component.callout-card-coral}` moments. Don't paint accent moments coral elsewhere.
- Use `{component.product-mockup-card-dark}` and `{component.code-window-card}` to show actual Claude product chrome. Don't paint marketing illustrations of code when you can show real code.
- Pair `{component.feature-card}` (cream) with `{component.product-mockup-card-dark}` (navy) in alternating bands. The cream-to-dark rhythm is the brand's pacing mechanism.
- Use the Anthropic spike-mark glyph as the brand wordmark prefix. Never invert the mark to white-on-dark within the wordmark itself.
- Apply `{spacing.section}` (96px) between major bands.
### Don't
- Don't use cool grays or pure white for canvas. Cream is the brand.
- Don't bold serif display weight. Copernicus at 700 reads as bombastic; the system stays at 400.
- Don't use cool blue or saturated cyan as a brand accent. The coral is the brand voltage.
- Don't put coral everywhere. The coral is scarce on individual elements and generous only on full-bleed coral callout cards.
- Don't use Inter for display headlines. The serif character is the brand voice.
- Don't repeat the same surface mode in two consecutive bands. The pacing alternates: cream → cream-card → dark-mockup → cream → coral-callout → dark-footer.
- Don't add hover state styling beyond what the system already encodes — primary darkens on press; nothing else changes.
## Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile | < 768px | Hamburger nav; hero h1 64→32px; hero-illustration-card stacks below content; feature grids 1-up; connector tiles 2-up; pricing 1-up; footer 4 cols → 1 |
| Tablet | 7681024px | Top nav stays horizontal but tightens; feature cards 2-up; connector tiles 3-up; pricing 2-up |
| Desktop | 10241440px | Full top-nav with all menu items; 3-up feature cards; 4-up or 6-up connector tiles; 3-up pricing tiers |
| Wide | > 1440px | Same as desktop with more outer breathing room; max content width caps at 1200px |
### Touch Targets
- `{component.button-primary}` at minimum 40 × 40px.
- `{component.button-icon-circular}` at exactly 36 × 36 — slightly under WCAG 44 but visually centered.
- `{component.text-input}` height is 40px.
- Connector tile entire card area is tappable; effective tap area >> 44px.
### Collapsing Strategy
- Top nav collapses to hamburger at < 768px; menu opens as a full-screen cream sheet.
- Hero band's 6-6 grid collapses to single-column on mobile — h1 + sub-head + buttons first, then the illustration / mockup card below.
- Feature grids reduce columns rather than scaling cards down.
- Pricing tier cards collapse 4 → 2 → 1; featured-tier dark surface stays visually distinct at every breakpoint.
- Code-window cards retain code legibility at every breakpoint by allowing horizontal scroll within the card rather than wrapping code lines.
### Image Behavior
- Code blocks inside dark mockups stay at fixed font-size; horizontal scroll on mobile rather than wrapping.
- Hero illustrations scale proportionally; line-art strokes thin slightly on mobile.
- Avatar photos in testimonials crop to circles at every breakpoint.
## Iteration Guide
1. Focus on ONE component at a time. Reference its YAML key (`{component.feature-card}`, `{component.code-window-card}`).
2. Variants of an existing component (`-active`, `-disabled`, `-focused`) live as separate entries in `components:`.
3. Use `{token.refs}` everywhere — never inline hex.
4. Never document hover. Default and Active/Pressed states only.
5. Display headlines stay Copernicus serif 400 with negative tracking. Body stays StyreneB / Inter 400. The split is unbreakable.
6. Cream + coral + dark navy is the trinity. Don't introduce a fourth surface tone (no purple cards, no green sections).
7. When in doubt about emphasis: bigger Copernicus serif before bolder weight.
## Known Gaps
- Copernicus and StyreneB are licensed Anthropic typefaces and not available as public web fonts. Substitutes (Tiempos Headline / Cormorant Garamond / EB Garamond for serif; Inter / Söhne for sans) are documented in the typography section.
- The Anthropic radial-spike-mark is a brand glyph rendered as inline SVG; it's not formalized as a system token here. Treat it as a logo asset.
- Animation and transition timings (chat message reveal, code block typewriter effect on the homepage, agentic-flow diagram animations) are not in scope.
- Form validation states beyond `{component.text-input-focused}` are not extracted — error / success states would need a sign-up or feedback flow to confirm.
- The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document.
- The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser — the static screenshot doesn't fully capture the animation chrome.
+16
View File
@@ -0,0 +1,16 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -6,7 +6,7 @@ A web-based tool for DroneCAN configuration and monitoring. This application pro
## Access
**Official Entry**: https://can.vimdrones.com
**Official Entry**: https://can.ardupilot.org
**Backup Entry**: https://can.vimdrones.com
+10
View File
@@ -0,0 +1,10 @@
services:
dronecan-webtools:
build:
context: .
dockerfile: Dockerfile
image: dronecan-webtools:latest
container_name: dronecan-webtools
ports:
- "8080:80"
restart: unless-stopped
+151 -75
View File
@@ -1,12 +1,12 @@
{
"name": "dronecan-configurator",
"version": "1.0.0",
"name": "dronecan-webtools",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dronecan-configurator",
"version": "1.0.0",
"name": "dronecan-webtools",
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.26.7",
@@ -20,6 +20,7 @@
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.1",
"html-webpack-plugin": "^5.6.3",
"jspack": "^0.0.4",
"long": "^5.2.4",
"process": "^0.11.10",
"react": "^18.3.1",
@@ -52,14 +53,14 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.9",
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
@@ -320,18 +321,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -361,25 +362,25 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz",
"integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==",
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz",
"integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.25.9",
"@babel/types": "^7.26.7"
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz",
"integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.7"
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1510,26 +1511,23 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==",
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
"integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.25.9",
"@babel/parser": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1554,13 +1552,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.26.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz",
"integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==",
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -2192,9 +2190,9 @@
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.15",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz",
"integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==",
"version": "1.17.16",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -3288,16 +3286,16 @@
}
},
"node_modules/compression": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz",
"integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.0.2",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
@@ -4342,9 +4340,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -4789,9 +4787,9 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
@@ -5301,6 +5299,11 @@
"node": ">=6"
}
},
"node_modules/jspack": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/jspack/-/jspack-0.0.4.tgz",
"integrity": "sha512-DC/lSTXYDDdYWzyY/9A1kMzp6Ov9mCRhZQ1cGg4te2w3y4/aKZTSspvbYN4LUsvSzMCb/H8z4TV9mYYW/bs3PQ=="
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -5701,9 +5704,9 @@
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -5899,21 +5902,53 @@
}
},
"node_modules/pbkdf2": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
"integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz",
"integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==",
"license": "MIT",
"dependencies": {
"create-hash": "^1.1.2",
"create-hmac": "^1.1.4",
"ripemd160": "^2.0.1",
"safe-buffer": "^5.0.1",
"sha.js": "^2.4.8"
"create-hash": "~1.1.3",
"create-hmac": "^1.1.7",
"ripemd160": "=2.0.1",
"safe-buffer": "^5.2.1",
"sha.js": "^2.4.11",
"to-buffer": "^1.2.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/pbkdf2/node_modules/create-hash": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz",
"integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==",
"license": "MIT",
"dependencies": {
"cipher-base": "^1.0.1",
"inherits": "^2.0.1",
"ripemd160": "^2.0.0",
"sha.js": "^2.4.0"
}
},
"node_modules/pbkdf2/node_modules/hash-base": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz",
"integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.1"
}
},
"node_modules/pbkdf2/node_modules/ripemd160": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz",
"integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==",
"license": "MIT",
"dependencies": {
"hash-base": "^2.0.0",
"inherits": "^2.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6323,12 +6358,6 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
@@ -7159,6 +7188,26 @@
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"license": "MIT"
},
"node_modules/to-buffer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz",
"integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==",
"license": "MIT",
"dependencies": {
"isarray": "^2.0.5",
"safe-buffer": "^5.2.1",
"typed-array-buffer": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/to-buffer/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7215,6 +7264,20 @@
"node": ">= 0.6"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"es-errors": "^1.3.0",
"is-typed-array": "^1.1.14"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/underscore": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
@@ -7522,14 +7585,15 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz",
"integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz",
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.21",
"@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5",
"@types/sockjs": "^0.3.36",
@@ -7542,7 +7606,7 @@
"connect-history-api-fallback": "^2.0.0",
"express": "^4.21.2",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.7",
"http-proxy-middleware": "^2.0.9",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",
@@ -7577,6 +7641,18 @@
}
}
},
"node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/webpack-merge": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dronecan-webtools",
"version": "1.0.1",
"version": "1.0.2",
"description": "",
"main": "index.js",
"scripts": {
@@ -22,6 +22,7 @@
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.1",
"html-webpack-plugin": "^5.6.3",
"jspack": "^0.0.4",
"long": "^5.2.4",
"process": "^0.11.10",
"react": "^18.3.1",
+32 -17
View File
@@ -2,20 +2,34 @@ 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 discordLogo from './image/discord_logo.png';
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 }}>
<Box
component={Paper}
elevation={0}
sx={{
mx: 'auto',
p: 3,
width: '100%',
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: 2,
backgroundColor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%', mb: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline', gap: 1 }}>
<Typography variant="h4" component="h1" sx={{ fontWeight: 400, letterSpacing: '-0.03em' }}>
DroneCAN Web Tools
</Typography>
<Chip
@@ -23,14 +37,16 @@ const About = () => {
size="small"
color="primary"
variant="outlined"
sx={{ ml: 2 }}
/>
</Box>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary', textAlign: 'center', maxWidth: 720 }}>
A browser-based console for discovery, parameters, firmware, monitoring, and panel workflows.
</Typography>
</Box>
<Card variant="outlined">
<Card variant="outlined" sx={{ backgroundColor: 'background.default' }}>
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="h6" sx={{ mb: 1.5, fontWeight: 500, color: 'primary.main' }}>Key Features</Typography>
<Typography variant="h6" sx={{ mb: 1.5, fontWeight: 600, color: 'primary.main' }}>Key Features</Typography>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<List dense disablePadding>
@@ -61,20 +77,20 @@ const About = () => {
</Card>
<Stack spacing={2} direction={{ xs: 'column', md: 'row' }}>
<Card variant="outlined" sx={{ flex: 1 }}>
<Card variant="outlined" sx={{ flex: 1, backgroundColor: 'background.paper' }}>
<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' }}>
<Typography variant="body2" sx={{ bgcolor: 'background.default', p: 1.25, borderRadius: 1, fontFamily: 'monospace', border: '1px solid', borderColor: 'divider' }}>
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={{ mt: 2, pt: 1, borderTop: '1px solid', 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 }}>
@@ -88,7 +104,7 @@ const About = () => {
</CardContent>
</Card>
<Card variant="outlined" sx={{ flex: 1 }}>
<Card variant="outlined" sx={{ flex: 1, backgroundColor: 'background.paper' }}>
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<UsbIcon color="primary" sx={{ mr: 1 }} />
@@ -109,7 +125,7 @@ const About = () => {
</ListItem>
<ListItem disablePadding sx={{ py: 0.25 }}>
<ListItemIcon sx={{ minWidth: 30 }}>
<CheckCircleOutlineIcon color="success" fontSize="small" /> {/* Changed from info to success */}
<CheckCircleOutlineIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Standalone MAVCAN USB Adaptor"
@@ -139,8 +155,7 @@ const About = () => {
</Card>
</Stack>
{/* Footer */}
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end'}}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
<Divider sx={{ my: 1.5 }} />
<Box sx={{
display: 'flex',
+36 -34
View File
@@ -10,6 +10,7 @@ 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';
import { useTranslation } from './i18n/LanguageContext';
const MAX_ACTUATOR_IDS = 256;
@@ -50,6 +51,7 @@ const ActuatorPanel = () => {
const [showIdSelector, setShowIdSelector] = useState(false);
const nodeId = 0;
const { t } = useTranslation();
const activeActuatorIds = enabledActuatorIds
.map((enabled, id) => enabled ? id : null)
@@ -282,9 +284,9 @@ const ActuatorPanel = () => {
const renderIdSelectorDialog = () => (
<Dialog open={showIdSelector} onClose={() => setShowIdSelector(false)}>
<DialogTitle>Select Actuator IDs</DialogTitle>
<DialogTitle>{t('act.select_ids_title')}</DialogTitle>
<DialogContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Select Actuator IDs:</Typography>
<Typography variant="subtitle2" sx={{ mb: 1 }}>{t('act.select_ids_label')}</Typography>
<FormGroup sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1 }}>
{Array(MAX_ACTUATOR_IDS).fill(0).map((_, id) => (
<FormControlLabel
@@ -302,7 +304,7 @@ const ActuatorPanel = () => {
</DialogContent>
<DialogActions>
<Button onClick={() => setShowIdSelector(false)} color="primary">
Done
{t('act.done')}
</Button>
</DialogActions>
</Dialog>
@@ -316,11 +318,11 @@ const ActuatorPanel = () => {
fullWidth
>
<DialogTitle>
Command Type Range Settings
{t('act.range_title')}
</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.
{t('act.range_instruction')}
</Typography>
<Box sx={{ mb: 3 }}>
@@ -328,7 +330,7 @@ const ActuatorPanel = () => {
{COMMAND_TYPE_LABELS[0]}
</Typography>
<Typography variant="body2" color="text.secondary">
Unitless command range is fixed at -1 to 1
{t('act.unitless_fixed')}
</Typography>
</Box>
@@ -339,7 +341,7 @@ const ActuatorPanel = () => {
<Grid container spacing={2} alignItems="center">
<Grid item xs={5}>
<TextField
label="Min"
label={t('act.min')}
type="number"
size="small"
fullWidth
@@ -350,7 +352,7 @@ const ActuatorPanel = () => {
</Grid>
<Grid item xs={5}>
<TextField
label="Max"
label={t('act.max')}
type="number"
size="small"
fullWidth
@@ -366,7 +368,7 @@ const ActuatorPanel = () => {
onClick={() => applyRangesToAllOfType(1)}
fullWidth
>
Apply
{t('act.apply')}
</Button>
</Grid>
</Grid>
@@ -379,7 +381,7 @@ const ActuatorPanel = () => {
<Grid container spacing={2} alignItems="center">
<Grid item xs={5}>
<TextField
label="Min"
label={t('act.min')}
type="number"
size="small"
fullWidth
@@ -390,7 +392,7 @@ const ActuatorPanel = () => {
</Grid>
<Grid item xs={5}>
<TextField
label="Max"
label={t('act.max')}
type="number"
size="small"
fullWidth
@@ -406,7 +408,7 @@ const ActuatorPanel = () => {
onClick={() => applyRangesToAllOfType(2)}
fullWidth
>
Apply
{t('act.apply')}
</Button>
</Grid>
</Grid>
@@ -419,7 +421,7 @@ const ActuatorPanel = () => {
<Grid container spacing={2} alignItems="center">
<Grid item xs={5}>
<TextField
label="Min"
label={t('act.min')}
type="number"
size="small"
fullWidth
@@ -430,7 +432,7 @@ const ActuatorPanel = () => {
</Grid>
<Grid item xs={5}>
<TextField
label="Max"
label={t('act.max')}
type="number"
size="small"
fullWidth
@@ -446,7 +448,7 @@ const ActuatorPanel = () => {
onClick={() => applyRangesToAllOfType(3)}
fullWidth
>
Apply
{t('act.apply')}
</Button>
</Grid>
</Grid>
@@ -458,13 +460,13 @@ const ActuatorPanel = () => {
color="primary"
variant="contained"
>
Apply All Ranges
{t('act.apply_all')}
</Button>
<Button
onClick={() => setShowSettingsModal(false)}
color="primary"
>
Close
{t('act.close')}
</Button>
</DialogActions>
</Dialog>
@@ -553,7 +555,7 @@ const ActuatorPanel = () => {
onClick={() => setShowIdSelector(true)}
sx={{ textTransform: 'none' }}
>
Actuator IDs ({activeActuatorIds.length})
{t('act.ids', { count: activeActuatorIds.length })}
</Button>
</Box>
@@ -566,14 +568,14 @@ const ActuatorPanel = () => {
onClick={() => setShowSettingsModal(true)}
sx={{ textTransform: 'none' }}
>
Range Settings
{t('act.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>
<Typography variant="body2" sx={{ mr: 1 }}>{t('act.broadcast_rate')}</Typography>
<TextField
type="number"
size="small"
@@ -642,22 +644,22 @@ const ActuatorPanel = () => {
width: '50%'
}}>
<Box sx={{flexGrow: 1}}>
<Typography variant="body2" color="textSecondary">ID: {actuator.actuator_id}</Typography>
<Typography variant="body2" color="textSecondary">{t('act.id')} {actuator.actuator_id}</Typography>
<Typography variant="body2" color="textSecondary">
Pos: {actuator.position !== null ? actuator.position.toFixed(3) : "NC"}
{t('act.pos')} {actuator.position !== null ? actuator.position.toFixed(3) : t('act.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
Force: {actuator.force !== null ? `${actuator.force.toFixed(2)} N` : "NC"}
{t('act.force')} {actuator.force !== null ? `${actuator.force.toFixed(2)} N` : t('act.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
Speed: {actuator.speed !== null ? `${actuator.speed.toFixed(2)} rad/s` : "NC"}
{t('act.speed')} {actuator.speed !== null ? `${actuator.speed.toFixed(2)} rad/s` : t('act.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
RAT: {actuator.power_rating_pct !== null
{t('act.rat')} {actuator.power_rating_pct !== null
? actuator.power_rating_pct === 127
? "unknown"
? t('act.unknown')
: `${actuator.power_rating_pct.toFixed(1)} %`
: "NC"}
: t('act.nc')}
</Typography>
</Box>
@@ -668,10 +670,10 @@ const ActuatorPanel = () => {
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>
<MenuItem value={COMMAND_TYPES.UNITLESS}>{t('act.type_unitless')}</MenuItem>
<MenuItem value={COMMAND_TYPES.POSITION}>{t('act.type_position')}</MenuItem>
<MenuItem value={COMMAND_TYPES.FORCE}>{t('act.type_force')}</MenuItem>
<MenuItem value={COMMAND_TYPES.SPEED}>{t('act.type_speed')}</MenuItem>
</Select>
</FormControl>
@@ -700,7 +702,7 @@ const ActuatorPanel = () => {
fullWidth
size="small"
>
Zero
{t('act.zero')}
</Button>
</Box>
</Box>
@@ -749,7 +751,7 @@ const ActuatorPanel = () => {
}}>
<Box sx={{ p: 1, border: '1px solid #ddd', borderRadius: 1}}>
<Typography variant="body2" color="textSecondary">
cmd: [{
{t('act.cmd')} [{
activeActuatorIds
.map(id => {
const type = commandTypes[id] || COMMAND_TYPES.UNITLESS;
@@ -768,7 +770,7 @@ const ActuatorPanel = () => {
startIcon={<PanToolIcon />}
onClick={handleZeroAll}
>
Zero All
{t('act.zero_all')}
</Button>
</Box>
</Box>
+87 -66
View File
@@ -1,5 +1,5 @@
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 { AppBar, Toolbar, Typography, Box, Button, ThemeProvider, IconButton, Snackbar, Alert, Tooltip, FormControl, Select, MenuItem } from '@mui/material';
import MavlinkSession from './mavlink_session';
import dronecan from './dronecan';
import theme from './theme';
@@ -17,21 +17,15 @@ import './css/index.css';
import ConnectionIndicators from './ConnectionIndicators';
import DnsIcon from '@mui/icons-material/Dns';
import LanIcon from '@mui/icons-material/Lan';
import LanguageIcon from '@mui/icons-material/Language';
import CompactSidebar from './CompactSidebar';
import DNAServerModal from './DNAServerModal';
import DynamicNodeIdServer from './services/DynamicNodeIdServer';
import { LanguageProvider, useTranslation } from './i18n/LanguageContext';
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);
mavlinkSession.sendCanFrame(localNode.bus, messageId, data, len);
});
@@ -40,6 +34,17 @@ localNode.on('uavcan.protocol.file.Read.Request', (transfer) => {
});
const App = () => {
return (
<ThemeProvider theme={theme}>
<LanguageProvider>
<AppContent />
</LanguageProvider>
</ThemeProvider>
);
};
const AppContent = () => {
const { t, language, setLanguage } = useTranslation();
const [nodes, setNodes] = useState({});
const [nodesUpdateTimestamp, setNodesUpdateTimestamp] = useState(0);
const [isConnected, setIsConnected] = useState(false);
@@ -51,25 +56,26 @@ const App = () => {
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]) {
if (subWindowRef[windowPath] && !subWindowRef[windowPath].closed) {
subWindowRef[windowPath].focus();
return;
}
const newWindow = window.open(windowPath, windowTitle, windowSize);
if (newWindow) {
subWindowRef[windowPath] = newWindow;
setSubWindowRef(subWindowRef);
setSubWindowRef((prev) => ({ ...prev, [windowPath]: newWindow }));
newWindow.addEventListener('beforeunload', () => {
subWindowRef[windowPath] = null;
setSubWindowRef(subWindowRef);
setSubWindowRef((prev) => {
const next = { ...prev };
delete next[windowPath];
return next;
});
});
} else {
console.error(`Main: Failed to open ${windowName}`);
console.error(`Main: Failed to open ${windowTitle}`);
}
};
@@ -82,7 +88,7 @@ const App = () => {
const handleConnectionStatusChange = (isConnected) => {
setIsConnected(isConnected);
showMessage(
isConnected ? 'Successfully connected to device' : 'Disconnected from device',
isConnected ? t('app.connected') : t('app.disconnected'),
isConnected ? 'success' : 'info'
);
};
@@ -94,7 +100,7 @@ const App = () => {
if (window.localNode) {
window.localNode.changeBus(newBus);
showMessage(`Switched to CAN bus ${newBus}`, 'info');
showMessage(t('app.bus_switched', { bus: newBus }), 'info');
}
};
@@ -114,39 +120,47 @@ const App = () => {
setModalOpen(true);
};
const handleCloseModal = (serverRunning) => {
const handleCloseModal = () => {
setModalOpen(false);
setDnaServerActive(serverRunning);
};
const handleOpenDnaModal = () => {
setDnaModalOpen(true);
const handleToggleDnaServer = () => {
if (!window.dnaServer) {
window.dnaServer = new DynamicNodeIdServer(window.localNode);
}
if (window.dnaServer.getStatus().isActive) {
window.dnaServer.stop();
setDnaServerActive(false);
showMessage(t('app.dna_stopped'), 'info');
} else {
const success = window.dnaServer.start(1, 125);
setDnaServerActive(success);
showMessage(success ? t('app.dna_started') : t('app.dna_failed'), success ? 'success' : 'error');
}
};
const handleCloseDnaModal = (serverRunning) => {
setDnaModalOpen(false);
setDnaServerActive(serverRunning);
};
const surfaceBorder = '1px solid rgba(230, 223, 216, 0.95)';
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)} />
<>
<AppBar position="static" color="transparent" elevation={0} sx={{ borderBottom: surfaceBorder }}>
<Toolbar variant="dense" sx={{ display: 'flex', justifyContent: 'space-between', minHeight: 64, px: 1.5, gap: 1 }}>
<Box sx={{ width: '30%', flexGrow: 1, display: 'flex', flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center' }}>
<ToolsMenu openWindow={openWindow} />
<PanelsMenu openWindow={openWindow} />
</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}} />
<Box sx={{ flexGrow: 2, display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<a href="https://dev.vimdrones.com" target="_blank" rel="noreferrer" style={{ height: 30, display: 'flex', alignItems: 'center' }}>
<img src={DronecanLogo} alt="DroneCAN" style={{ height: 30 }} />
</a>
</Box>
<Typography variant="caption">
DroneCAN Web Tools
<Typography variant="body2" sx={{ fontWeight: 600, letterSpacing: 0.2 }}>
{t('app.title')}
</Typography>
</Box>
<Box sx={{width: '30%', flexGrow: 1, display: 'flex', flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 1}}>
<Box sx={{ width: '30%', flexGrow: 1, display: 'flex', flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 1 }}>
<ConnectionIndicators
isConnected={isConnected}
mavlinkSession={window.mavlinkSession}
@@ -156,9 +170,10 @@ const App = () => {
<FormControl
size="small"
sx={{
minWidth: 80,
minWidth: 92,
'& .MuiOutlinedInput-root': {
height: 30,
height: 32,
backgroundColor: '#faf9f5',
},
'& .MuiSelect-select': {
paddingTop: 0.5,
@@ -176,16 +191,16 @@ const App = () => {
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>
<MenuItem value={0}>{t('app.bus', { n: 1 })}</MenuItem>
<MenuItem value={1}>{t('app.bus', { n: 2 })}</MenuItem>
<MenuItem value={2}>{t('app.bus', { n: 3 })}</MenuItem>
<MenuItem value={3}>{t('app.bus', { n: 4 })}</MenuItem>
</Select>
</FormControl>
<Button
variant="outlined"
color={dnaServerActive ? "success" : "primary"}
color={dnaServerActive ? 'success' : 'primary'}
startIcon={
dnaServerActive ?
<DnsIcon sx={{
@@ -198,30 +213,41 @@ const App = () => {
}} /> :
<DnsIcon />
}
onClick={handleOpenDnaModal}
onClick={handleToggleDnaServer}
sx={dnaServerActive ? {
borderColor: 'success.main',
'&:hover': {
backgroundColor: 'rgba(76, 175, 80, 0.08)',
backgroundColor: 'rgba(93, 184, 114, 0.08)',
borderColor: 'success.dark'
}
} : {}}
>
DNA
{t('app.dna')}
</Button>
<Tooltip title={language === 'en' ? '中文' : 'English'}>
<IconButton
size="small"
color="inherit"
onClick={() => setLanguage(language === 'en' ? 'zh' : 'en')}
sx={{ mr: 0.5, border: '1px solid rgba(230, 223, 216, 0.95)', backgroundColor: '#faf9f5' }}
>
<LanguageIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
color="primary"
startIcon={<LanIcon />}
onClick={handleOpenModal}
>
Adapter
{t('app.adapter')}
</Button>
</Box>
</Toolbar>
</AppBar>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1, height: '95vh', gap: 0.5, p: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1, minHeight: 'calc(100vh - 64px)', gap: 0.75, p: 1, backgroundColor: '#faf9f5' }}>
<CompactSidebar
nodes={nodes}
selectedNodeId={selectedNodeId}
@@ -234,13 +260,13 @@ const App = () => {
minWidth: 550,
display: { xs: 'none', md: 'flex' },
flexDirection: 'column',
gap: 0.5,
gap: 0.75,
}}
>
<NodeList
nodes={nodes}
selectedNodeId={selectedNodeId}
setSelectedNodeId={setSelectedNodeId.bind(this)}
setSelectedNodeId={setSelectedNodeId}
nodesUpdateTimestamp={nodesUpdateTimestamp}
/>
<NodeLogs/>
@@ -250,18 +276,18 @@ const App = () => {
display="flex"
flexDirection="column"
sx={{
gap: 0.5,
gap: 0.75,
ml: { xs: 1, md: 0 },
flexGrow: 1,
}}
>
{selectedNodeId && (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, gap: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, gap: 0.75 }}>
<NodeProperties
nodeId={selectedNodeId}
nodes={nodes}
multiNodeEditorEnable={multiNodeEditorEnable}
setMultiNodeEditorEnable={setMultiNodeEditorEnable.bind(this)}
setMultiNodeEditorEnable={setMultiNodeEditorEnable}
nodesUpdateTimestamp={nodesUpdateTimestamp}
/>
<NodeParam
@@ -281,26 +307,21 @@ const App = () => {
open={modalOpen}
onClose={handleCloseModal}
onConnectionStatusChange={handleConnectionStatusChange}
showMessage={showMessage.bind(this)}
showMessage={showMessage}
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}>
<Alert onClose={() => setSnackbarOpen(false)} severity={snackbarSeverity} variant="filled">
{snackbarMessage}
</Alert>
</Snackbar>
</ThemeProvider>
</>
);
};
+32 -30
View File
@@ -10,6 +10,7 @@ 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';
import { useTranslation } from './i18n/LanguageContext';
const BusMonitor = () => {
const [transfers, setTransfers] = useState([]);
@@ -18,6 +19,7 @@ const BusMonitor = () => {
const [selectedTransfer, setSelectedTransfer] = useState(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [messageYaml, setMessageYaml] = useState('');
const { t } = useTranslation();
const tableContainerRef = useRef(null);
const maxTransfers = 1000; // Maximum number of transfers to store
@@ -29,23 +31,23 @@ const BusMonitor = () => {
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 = `${t('bus.details_heading')}\n`;
yamlText += `${t('bus.detail_direction')} ${transfer.direction}\n`;
yamlText += `${t('bus.detail_time')} ${transfer.timestamp}\n`;
yamlText += `${t('bus.detail_can_id')} ${transfer.frameId}\n`;
yamlText += `${t('bus.detail_source')} ${transfer.sourceNodeId}\n`;
yamlText += `${t('bus.detail_dest')} ${transfer.destNodeId || t('bus.broadcast')}\n`;
yamlText += `${t('bus.detail_data_type')} ${transfer.dataType}\n\n`;
yamlText += `${t('bus.payload_heading')}\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`;
yamlText = `${t('bus.no_payload')}\n\n`;
yamlText += `${t('bus.detail_direction')} ${transfer.direction}\n`;
yamlText += `${t('bus.detail_time')} ${transfer.timestamp}\n`;
yamlText += `${t('bus.detail_can_id')} ${transfer.frameId}\n`;
yamlText += `${t('bus.detail_hex_data')} ${transfer.hexData}\n`;
yamlText += `${t('bus.detail_source')} ${transfer.sourceNodeId}\n`;
yamlText += `${t('bus.detail_dest')} ${transfer.destNodeId || t('bus.broadcast')}\n`;
}
setMessageYaml(yamlText);
@@ -139,7 +141,7 @@ const BusMonitor = () => {
};
const exportToCSV = () => {
const headers = ['Direction', 'Timestamp', 'CAN ID (Hex)', 'Hex Data', 'Src Node ID', 'Dst Node ID', 'Data Type', 'Raw Data'];
const headers = [t('bus.csv_direction'), t('bus.csv_timestamp'), t('bus.csv_can_id'), t('bus.csv_hex_data'), t('bus.csv_src'), t('bus.csv_dst'), t('bus.csv_data_type'), t('bus.csv_raw')];
const csvRows = [
headers.join(','),
...transfers.map(transfer => {
@@ -182,7 +184,7 @@ const BusMonitor = () => {
<AppBar position="static" color="primary">
<Toolbar variant="dense">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Bus Monitor
{t('bus.title')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<FormControlLabel
@@ -193,7 +195,7 @@ const BusMonitor = () => {
size="small"
/>
}
label={<Typography variant="body2">Auto Scroll</Typography>}
label={<Typography variant="body2">{t('bus.auto_scroll')}</Typography>}
labelPlacement="start"
/>
<IconButton
@@ -218,7 +220,7 @@ const BusMonitor = () => {
variant="outlined"
sx={{ ml: 1 }}
>
Export
{t('bus.export')}
</Button>
</Box>
</Toolbar>
@@ -232,13 +234,13 @@ const BusMonitor = () => {
<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>
<TableCell>{t('bus.col_dir')}</TableCell>
<TableCell>{t('bus.col_time')}</TableCell>
<TableCell>{t('bus.col_can_id')}</TableCell>
<TableCell>{t('bus.col_hex_data')}</TableCell>
<TableCell>{t('bus.col_src')}</TableCell>
<TableCell>{t('bus.col_dst')}</TableCell>
<TableCell>{t('bus.col_data_type')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -297,7 +299,7 @@ const BusMonitor = () => {
}}
>
<Typography variant="body2" color="textSecondary">
Showing {transfers.length} of max {maxTransfers} transfers
{t('bus.showing', { count: transfers.length, max: maxTransfers })}
</Typography>
{isPaused && (
<Typography
@@ -310,7 +312,7 @@ const BusMonitor = () => {
gap: '4px'
}}
>
<PauseIcon fontSize="small" /> PAUSED
<PauseIcon fontSize="small" /> {t('bus.paused')}
</Typography>
)}
</Box>
@@ -322,7 +324,7 @@ const BusMonitor = () => {
fullWidth
>
<DialogTitle>
Message Details
{t('bus.message_details')}
{selectedTransfer && (
<Typography variant="subtitle2" color="textSecondary">
{selectedTransfer.dataType} - {selectedTransfer.frameId}
@@ -347,7 +349,7 @@ const BusMonitor = () => {
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDetails} color="primary">
Close
{t('bus.close')}
</Button>
</DialogActions>
</Dialog>
+16 -26
View File
@@ -10,18 +10,14 @@ const CompactSidebar = ({
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) {
@@ -29,8 +25,6 @@ const CompactSidebar = ({
}
});
}
// 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);
}
@@ -38,54 +32,47 @@ const CompactSidebar = ({
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)
return '#5db872';
case 'INITIALIZATION':
return '#ffb74d'; // Warning color
return '#d4a017';
case 'MAINTENANCE':
return '#9c27b0'; // Secondary/purple
return '#5db8a6';
case 'SOFTWARE_UPDATE':
return '#4caf50'; // Success/green
return '#5db872';
case 'OFFLINE':
return '#f44336'; // Error/red
return '#c64545';
default:
return '#f44336'; // Default to error color
return '#c64545';
}
};
// Handle click on a node
const handleNodeClick = (nodeId) => {
if (nodeId === selectedNodeId) {
setSelectedNodeId(null);
@@ -105,7 +92,8 @@ const CompactSidebar = ({
p: 1,
gap: 1,
overflowY: 'auto',
height: '100%'
height: '100%',
backgroundColor: 'background.paper',
}}
>
<Typography
@@ -114,8 +102,9 @@ const CompactSidebar = ({
textAlign: 'center',
display: 'block',
mb: 0.5,
fontWeight: 'bold',
fontSize: '0.5rem'
fontWeight: 700,
fontSize: '0.5rem',
color: 'text.secondary',
}}
>
NODES
@@ -130,6 +119,7 @@ const CompactSidebar = ({
const node = nodes[nodeId];
const mode = node.status.getConstant('mode');
const logCount = logCounts[nodeId] || 0;
const selected = selectedNodeId === Number(nodeId);
return (
<Tooltip
@@ -148,20 +138,20 @@ const CompactSidebar = ({
justifyContent: 'center',
borderRadius: 1,
border: '1px solid',
borderColor: selectedNodeId === Number(nodeId) ? 'primary.main' : 'divider',
backgroundColor: 'background.paper',
borderColor: selected ? 'primary.main' : 'divider',
backgroundColor: selected ? 'rgba(204, 120, 92, 0.08)' : 'background.default',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'action.hover',
backgroundColor: 'rgba(245, 240, 232, 0.9)',
}
}}
onClick={() => handleNodeClick(Number(nodeId))}
>
{/* Make NID larger and more prominent */}
<Typography
variant='caption'
sx={{
color: getModeColor(mode),
fontWeight: 600,
}}
>
{nodeId}
+6 -4
View File
@@ -1,21 +1,23 @@
import React from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
import { useTranslation } from './i18n/LanguageContext';
const ConfirmRestartModal = ({ open, onClose, onConfirm }) => {
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Confirm Restart</DialogTitle>
<DialogTitle>{t('confirm.title')}</DialogTitle>
<DialogContent>
<Typography variant="body2">
Are you sure you want to restart the node?
{t('confirm.message')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
{t('confirm.cancel')}
</Button>
<Button onClick={onConfirm} color="primary">
Confirm
{t('confirm.confirm')}
</Button>
</DialogActions>
</Dialog>
+5 -5
View File
@@ -14,7 +14,7 @@ const ConnectionIndicators = ({ isConnected, mavlinkSession, localNode }) => {
clearTimeout(txTimeout.current);
txTimeout.current = setTimeout(() => {
setTxActive(false);
}, 100); // Blink for 200ms
}, 100);
};
const handleFrameReceive = () => {
@@ -22,7 +22,7 @@ const ConnectionIndicators = ({ isConnected, mavlinkSession, localNode }) => {
clearTimeout(rxTimeout.current);
rxTimeout.current = setTimeout(() => {
setRxActive(false);
}, 100); // Blink for 200ms
}, 100);
};
if (mavlinkSession) {
@@ -41,12 +41,12 @@ const ConnectionIndicators = ({ isConnected, mavlinkSession, localNode }) => {
}, [localNode, mavlinkSession]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mr: 5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mr: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', opacity: isConnected ? 1 : 0.3 }}>
<CircleIcon
fontSize="small"
sx={{
color: txActive && isConnected ? '#4caf50' : '#7e7e7e',
color: txActive && isConnected ? '#5db872' : '#8e8b82',
width: '12px',
height: '12px',
transition: 'color 0.1s ease'
@@ -59,7 +59,7 @@ const ConnectionIndicators = ({ isConnected, mavlinkSession, localNode }) => {
<CircleIcon
fontSize="small"
sx={{
color: rxActive && isConnected ? '#2196f3' : '#7e7e7e',
color: rxActive && isConnected ? '#5db8a6' : '#8e8b82',
width: '12px',
height: '12px',
transition: 'color 0.1s ease'
+136 -94
View File
@@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Dialog, DialogTitle, DialogContent,
Button, FormControl, InputLabel, Select, MenuItem,
Typography, Box, Divider, RadioGroup, FormControlLabel, Radio, Chip,
TextField, Tabs, Tab, Paper, IconButton
Typography, Box, Divider, Chip,
TextField, 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 Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import WebSerial from './web_serial';
import { useTranslation } from './i18n/LanguageContext';
// Add this constant at the top of your file, outside the component
const USB_DEVICE_NAMES = {
@@ -62,6 +65,11 @@ const CONNECTION_TYPES = {
WEBSOCKET: 'websocket'
};
const SERIAL_PROTOCOLS = {
MAVLINK: 'mavlink',
SLCAN: 'slcan'
};
// Add this constant inside the ConnectionSettingsModal.js file, outside the component
const INTERFACE_BUS_LIST = [0, 1]; //BUS 1, BUS 2
@@ -74,10 +82,16 @@ const ConnectionSettingsModal = ({
selectedBus, // New prop
onBusChange // New prop
}) => {
// State for showing/hiding mavlink signing
const [showMavlinkSigning, setShowMavlinkSigning] = useState(false);
const handleToggleMavlinkSigning = () => {
setShowMavlinkSigning((show) => !show);
};
// Port and connection management
const [ports, setPorts] = useState([]);
const [selectedPort, setSelectedPort] = useState(null);
const [baudRate, setBaudRate] = useState(DEFAULT_BAUD_RATE);
const [serialProtocol, setSerialProtocol] = useState(SERIAL_PROTOCOLS.MAVLINK);
const [wsHost, setWsHost] = useState(DEFAULT_WS_HOST);
const [wsPort, setWsPort] = useState(DEFAULT_WS_PORT);
@@ -86,23 +100,27 @@ const ConnectionSettingsModal = ({
// Track active connection
const [activeConnection, setActiveConnection] = useState(null); // null, 'serial', or 'websocket'
const [activeSerialProtocol, setActiveSerialProtocol] = useState(null);
// Add these state variables after your other state declarations
const [ipError, setIpError] = useState('');
const [hostError, setHostError] = useState('');
const [portError, setPortError] = useState('');
// Add nodeId state to use the prop value
const [nodeId, setNodeId] = useState(127);
// Add mavlink signing state
const [mavlinkSigning, setMavlinkSigning] = useState('');
// 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);
const { t } = useTranslation();
// Create a function to identify and index duplicate devices
const getPortDisplayName = (port, allPorts) => {
if (!port) return "No port selected";
if (!port) return t('conn.no_port_selected');
// Try to extract the most user-friendly name possible
if (port.info && port.info.product) {
@@ -180,7 +198,7 @@ const ConnectionSettingsModal = ({
}
// Fallback for ports without specific info
return "Serial Port";
return t('conn.serial_port');
};
// Moved from App.js - Lists available ports
@@ -231,8 +249,9 @@ const ConnectionSettingsModal = ({
// Update the UI regardless of connection state
setActiveConnection(null);
setActiveSerialProtocol(null);
onConnectionStatusChange(false);
showMessage('Serial connection closed', 'info');
showMessage(t('conn.serial_closed'), 'info');
// Clear the forwarding interval
if (forwardingInterval) {
@@ -255,30 +274,33 @@ const ConnectionSettingsModal = ({
}
}
window.mavlinkSession.initWebSerialConnection(port, baudRate);
window.mavlinkSession.initWebSerialConnection(port, baudRate, { protocol: serialProtocol });
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
if (serialProtocol === SERIAL_PROTOCOLS.MAVLINK) {
const intervalId = setInterval(() => {
if (window.mavlinkSession) {
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
}
}, 1000);
setForwardingInterval(intervalId);
} else {
setForwardingInterval(null);
}
setActiveConnection('serial');
setActiveSerialProtocol(serialProtocol);
setConnectionInProgress(false);
onConnectionStatusChange(true);
showMessage('Serial connection established', 'success');
showMessage(serialProtocol === SERIAL_PROTOCOLS.SLCAN ? t('conn.serial_slcan_ok') : t('conn.serial_mavlink_ok'), 'success');
})
window.mavlinkSession.addWebSerialErrorHandler((error) => {
console.error('Serial connection error:', error);
showMessage(`Serial connection failed: ${error.message || 'Could not connect to port'}`, 'error');
showMessage(t('conn.serial_failed', { error: error.message || t('conn.could_not_connect') }), 'error');
// Reset in-progress state on error
setConnectionInProgress(false);
});
@@ -286,7 +308,7 @@ const ConnectionSettingsModal = ({
window.mavlinkSession.webSerialConnect();
} catch (error) {
console.error('Serial connection error:', error);
showMessage(`Serial connection failed: ${error.message || 'Could not connect to port'}`, 'error');
showMessage(t('conn.serial_failed', { error: error.message || t('conn.could_not_connect') }), 'error');
// Reset in-progress state on error
setConnectionInProgress(false);
}
@@ -297,7 +319,7 @@ const ConnectionSettingsModal = ({
}
} catch (error) {
console.error('Error with serial connection:', error);
showMessage(`Serial error: ${error.message || 'Unknown error'}`, 'error');
showMessage(t('conn.serial_error', { error: error.message || t('conn.unknown_error') }), 'error');
// Reset in-progress state on any error
setConnectionInProgress(false);
}
@@ -307,7 +329,7 @@ const ConnectionSettingsModal = ({
const validateIpAddress = (input) => {
// Check if empty
if (!input) {
return 'IP address is required';
return t('conn.ip_required');
}
// Allow "localhost"
@@ -315,45 +337,38 @@ const ConnectionSettingsModal = ({
return '';
}
// Check for IPv4 format
if (input.includes('.')) {
// If input matches IPv4 pattern, validate as IPv4, else treat as hostname/domain
const ipv4Pattern = /^\d{1,3}(\.\d{1,3}){3}$/;
if (ipv4Pattern.test(input)) {
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';
return t('conn.ip_invalid_parts');
}
}
// 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])*$/;
// Check for hostname or domain (including subdomains)
// Allow domains like 'support.ardupilot.org', 'foo.local', etc.
const hostnamePattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (hostnamePattern.test(input)) {
return '';
}
return 'Invalid IP address or hostname';
return t('conn.ip_invalid');
};
const validatePort = (input) => {
if (!input) {
return 'Port is required';
return t('conn.port_required');
}
const port = parseInt(input, 10);
if (isNaN(port) || port < 1 || port > 65535) {
return 'Port must be between 1-65535';
return t('conn.port_range');
}
return '';
@@ -362,7 +377,7 @@ const ConnectionSettingsModal = ({
// 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' : '';
return (isNaN(id) || id < 1 || id > 127) ? t('conn.node_id_range') : '';
};
// Update handler to propagate changes to parent
@@ -377,11 +392,16 @@ const ConnectionSettingsModal = ({
}
};
// Handler for Mavlink Signing input
const handleMavlinkSigningChange = (e) => {
setMavlinkSigning(e.target.value);
};
// Update the ws host/port change handlers
const handleWsHostChange = (e) => {
const value = e.target.value;
setWsHost(value);
setIpError(validateIpAddress(value));
setHostError(validateIpAddress(value));
};
const handleWsPortChange = (e) => {
@@ -396,96 +416,83 @@ const ConnectionSettingsModal = ({
if (activeConnection === 'websocket') {
// Force close the connection
window.mavlinkSession.close();
// Always update the UI state regardless of actual connection state
setActiveConnection(null);
setActiveSerialProtocol(null);
onConnectionStatusChange(false);
showMessage('WebSocket connection closed', 'info');
// Clear the forwarding interval
showMessage(t('conn.ws_closed'), 'info');
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) {
const hostErr = validateIpAddress(wsHost);
const portErr = validatePort(wsPort);
setHostError(hostErr);
setPortError(portErr);
if (!hostErr && !portErr) {
if (activeConnection) {
window.mavlinkSession.close();
// Clear any existing interval
if (forwardingInterval) {
clearInterval(forwardingInterval);
}
}
// Use window.mavlinkSession consistently
window.mavlinkSession.initWebSocketConnection(wsHost, parseInt(wsPort, 10));
// Use wss if signing is present, else ws
const wsProtocol = mavlinkSigning ? 'wss' : 'ws';
// Compose the full URL for the websocket
const wsUrl = `${wsProtocol}://${wsHost}:${wsPort}`;
if (window.mavlinkSession.initWebSocketConnection.length === 1) {
// If the function expects a URL
window.mavlinkSession.initWebSocketConnection(wsUrl);
} else {
// Fallback to old signature (host, port)
window.mavlinkSession.initWebSocketConnection(wsHost, parseInt(wsPort, 10), mavlinkSigning);
}
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');
setActiveSerialProtocol(null);
onConnectionStatusChange(true);
setConnectionInProgress(false);
showMessage('WebSocket connection established', 'success');
showMessage(t('conn.ws_connected'), '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';
let errorMsg = t('conn.ws_failed');
if (error && error.message) {
errorMsg = `Connection failed: ${error.message}`;
errorMsg = t('conn.ws_failed_detail', { error: error.message });
} else if (typeof error === 'string') {
errorMsg = `Connection failed: ${error}`;
errorMsg = t('conn.ws_failed_detail', { error: 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
showMessage(t('conn.ws_error', { error: error.message || t('conn.unknown_error') }), 'error');
setConnectionInProgress(false);
}
};
@@ -516,11 +523,11 @@ const ConnectionSettingsModal = ({
>
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="h6">Adapter Settings</Typography>
<Typography variant="h6">{t('conn.title')}</Typography>
<Box display="flex" alignItems="center" gap={1}>
{activeConnection && (
<Chip
label={`Connected via ${activeConnection}`}
label={activeConnection === 'serial' && activeSerialProtocol ? (activeSerialProtocol === SERIAL_PROTOCOLS.SLCAN ? t('conn.connected_serial_slcan') : t('conn.connected_serial_mavlink')) : t('conn.connected_ws', { type: activeConnection })}
color="success"
size="small"
variant="outlined"
@@ -550,21 +557,21 @@ const ConnectionSettingsModal = ({
width: '100%',
bgcolor: activeConnection === 'serial' ? 'rgba(0, 200, 83, 0.1)' : 'inherit'
}}>
<Typography variant="subtitle1" gutterBottom>Serial Connection</Typography>
<Typography variant="subtitle1" gutterBottom>{t('conn.serial_section')}</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>
<InputLabel>{t('conn.port')}</InputLabel>
<Select
value={selectedPort || ''}
onChange={(e) => setSelectedPort(e.target.value)}
label="Port"
label={t('conn.port')}
>
{ports.length === 0 ? (
<MenuItem value="" disabled>No ports available</MenuItem>
<MenuItem value="" disabled>{t('conn.no_ports')}</MenuItem>
) : (
ports.map((port, index) => (
<MenuItem key={index} value={port}>
@@ -579,11 +586,11 @@ const ConnectionSettingsModal = ({
{/* Baud Rate Selection - takes less space */}
<Box sx={{ flex: 1 }}>
<FormControl fullWidth size="small" disabled={activeConnection !== null}>
<InputLabel>Baud Rate</InputLabel>
<InputLabel>{t('conn.baud_rate')}</InputLabel>
<Select
value={baudRate}
onChange={(e) => setBaudRate(e.target.value)}
label="Baud Rate"
label={t('conn.baud_rate')}
>
{BAUD_RATES.map((rate) => (
<MenuItem key={rate} value={rate}>
@@ -595,6 +602,18 @@ const ConnectionSettingsModal = ({
</Box>
</Box>
<FormControl fullWidth size="small" disabled={activeConnection !== null}>
<InputLabel>{t('conn.serial_protocol')}</InputLabel>
<Select
value={serialProtocol}
onChange={(e) => setSerialProtocol(e.target.value)}
label={t('conn.serial_protocol')}
>
<MenuItem value={SERIAL_PROTOCOLS.MAVLINK}>{t('conn.protocol_mavlink')}</MenuItem>
<MenuItem value={SERIAL_PROTOCOLS.SLCAN}>{t('conn.protocol_slcan')}</MenuItem>
</Select>
</FormControl>
{/* Port action buttons */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
@@ -604,7 +623,7 @@ const ConnectionSettingsModal = ({
disabled={activeConnection !== null}
size="small"
>
Refresh
{t('conn.refresh')}
</Button>
<Button
variant="outlined"
@@ -613,7 +632,7 @@ const ConnectionSettingsModal = ({
disabled={activeConnection !== null}
size="small"
>
Request
{t('conn.request')}
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button
@@ -627,8 +646,8 @@ const ConnectionSettingsModal = ({
}
size="small"
>
{activeConnection === 'serial' ? 'Disconnect' :
connectionInProgress && !activeConnection ? 'Connecting...' : 'Connect'}
{activeConnection === 'serial' ? t('conn.disconnect') :
connectionInProgress && !activeConnection ? t('conn.connecting') : t('conn.connect')}
</Button>
</Box>
</Box>
@@ -640,21 +659,21 @@ const ConnectionSettingsModal = ({
width: '100%',
bgcolor: activeConnection === 'websocket' ? 'rgba(0, 200, 83, 0.1)' : 'inherit'
}}>
<Typography variant="subtitle1" gutterBottom>WebSocket Connection</Typography>
<Typography variant="subtitle1" gutterBottom>{t('conn.ws_section')}</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> {/* Reduce gap */}
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Host/IP Address"
label={t('conn.host')}
value={wsHost}
onChange={handleWsHostChange}
disabled={activeConnection !== null}
size="small"
sx={{ flex: 3 }}
error={!!ipError}
helperText={ipError}
error={!!hostError}
helperText={hostError}
/>
<TextField
label="Port"
label={t('conn.ws_port')}
value={wsPort}
onChange={handleWsPortChange}
type="number"
@@ -673,7 +692,7 @@ const ConnectionSettingsModal = ({
variant="contained"
disabled={
connectionInProgress || // Disable when connection attempt is in progress
!!ipError ||
!!hostError ||
!!portError ||
!wsHost ||
!wsPort ||
@@ -681,8 +700,8 @@ const ConnectionSettingsModal = ({
}
size="small"
>
{activeConnection === 'websocket' ? 'Disconnect' :
connectionInProgress && !activeConnection ? 'Connecting...' : 'Connect'}
{activeConnection === 'websocket' ? t('conn.disconnect') :
connectionInProgress && !activeConnection ? t('conn.connecting') : t('conn.connect')}
</Button>
</Box>
</Box>
@@ -691,11 +710,11 @@ const ConnectionSettingsModal = ({
<Divider />
{/* Bus Selection */}
{/* Bus Selection and Mavlink Signing */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<TextField
label="Node ID"
label={t('conn.node_id')}
value={nodeId}
onChange={handleNodeIdChange}
disabled={activeConnection !== null}
@@ -708,6 +727,29 @@ const ConnectionSettingsModal = ({
error={validateNodeId(nodeId) !== ''}
helperText={validateNodeId(nodeId)}
/>
<TextField
label={t('conn.signing')}
value={mavlinkSigning}
onChange={handleMavlinkSigningChange}
disabled={activeConnection !== null}
size="small"
placeholder={t('conn.secret_key')}
type={showMavlinkSigning ? 'text' : 'password'}
sx={{ flex: 1 }}
InputProps={{
endAdornment: (
<IconButton
aria-label={showMavlinkSigning ? t('conn.hide_secret') : t('conn.show_secret')}
onClick={handleToggleMavlinkSigning}
edge="end"
size="small"
tabIndex={-1}
>
{showMavlinkSigning ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
),
}}
/>
</Box>
</Box>
</Box>
+27 -25
View File
@@ -13,6 +13,7 @@ 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';
import { useTranslation } from './i18n/LanguageContext';
const DNAServerModal = ({ open, onClose, showMessage }) => {
const [serverEnabled, setServerEnabled] = useState(false);
@@ -23,6 +24,7 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
const [server, setServer] = useState(null);
const [operationInProgress, setOperationInProgress] = useState(false);
const [refreshInterval, setRefreshInterval] = useState(null);
const { t } = useTranslation();
const handleAllocationUpdate = () => {
console.log("Allocation update detected, refreshing list");
@@ -84,9 +86,9 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
}, 1000); // Check every 5 seconds
setRefreshInterval(interval);
showMessage("DNA server started successfully", "success");
showMessage(t('dna.started'), "success");
} else {
showMessage("Failed to start DNA server", "error");
showMessage(t('dna.failed_start'), "error");
}
} else {
// Stop the server
@@ -99,11 +101,11 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
setRefreshInterval(null);
}
showMessage("DNA server stopped", "info");
showMessage(t('dna.stopped'), "info");
}
} catch (error) {
console.error("Error toggling DNA server:", error);
showMessage(`Error: ${error.message}`, "error");
showMessage(t('dna.error', { error: error.message }), "error");
} finally {
setOperationInProgress(false);
}
@@ -124,7 +126,7 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
};
const validateNodeIdRange = () => {
return minNodeId >= maxNodeId ? "Min ID must be less than Max ID" : "";
return minNodeId >= maxNodeId ? t('dna.invalid_range') : "";
};
const handleDeleteAllocation = (nodeId) => {
@@ -133,15 +135,15 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
const success = server.deleteAllocation(nodeId);
if (success) {
fetchCurrentAllocations();
showMessage(`Node ID ${nodeId} allocation revoked`, "info");
showMessage(t('dna.revoked', { id: nodeId }), "info");
} else {
showMessage(`Failed to revoke allocation for node ID ${nodeId}`, "error");
showMessage(t('dna.revoke_failed', { id: nodeId }), "error");
}
};
const handleRefreshAllocations = () => {
fetchCurrentAllocations();
showMessage("Allocations refreshed", "info");
showMessage(t('dna.refreshed'), "info");
};
// Modify the modal close handler to not stop the server
@@ -208,11 +210,11 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
>
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="h6">Dynamic Node ID Allocation Server</Typography>
<Typography variant="h6">{t('dna.title')}</Typography>
<Box display="flex" alignItems="center" gap={1}>
{serverEnabled && (
<Chip
label="Server Active"
label={t('dna.active')}
color="success"
size="small"
variant="outlined"
@@ -242,7 +244,7 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
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>
<Typography variant="subtitle1">{t('dna.control')}</Typography>
<Button
variant="contained"
startIcon={serverEnabled ? <StopIcon /> : <PlayArrowIcon />}
@@ -251,8 +253,8 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
disabled={operationInProgress}
size="small"
>
{operationInProgress ? "Processing..." :
serverEnabled ? "Stop" : "Start"}
{operationInProgress ? t('dna.processing') :
serverEnabled ? t('dna.stop') : t('dna.start')}
</Button>
</Box>
@@ -262,7 +264,7 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
{/* Min Node ID */}
<Box sx={{ width: { xs: '100%', sm: '22%' }, minWidth: '100px' }}>
<TextField
label="Min Node ID"
label={t('dna.min_node_id')}
type="number"
value={minNodeId}
onChange={handleMinNodeIdChange}
@@ -273,14 +275,14 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
}}
size="small"
error={minNodeId >= maxNodeId}
helperText={minNodeId >= maxNodeId ? "Must be < Max" : ""}
helperText={minNodeId >= maxNodeId ? t('dna.must_lt_max') : ""}
/>
</Box>
{/* Max Node ID */}
<Box sx={{ width: { xs: '100%', sm: '22%' }, minWidth: '100px' }}>
<TextField
label="Max Node ID"
label={t('dna.max_node_id')}
type="number"
value={maxNodeId}
onChange={handleMaxNodeIdChange}
@@ -291,7 +293,7 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
}}
size="small"
error={minNodeId >= maxNodeId}
helperText={minNodeId >= maxNodeId ? "Must be > Min" : ""}
helperText={minNodeId >= maxNodeId ? t('dna.must_gt_min') : ""}
/>
</Box>
@@ -315,8 +317,8 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
}
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">
<Typography variant="body2" sx={{ mr: 0.5 }}>{t('dna.persist')}</Typography>
<Tooltip title={t('dna.persist_tooltip')}>
<HelpOutlineIcon fontSize="small" />
</Tooltip>
</Box>
@@ -339,9 +341,9 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
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})
{t('dna.allocated', { count: allocatedNodes.length })}
</Typography>
<Tooltip title="Refresh allocation list">
<Tooltip title={t('dna.refresh_tooltip')}>
<IconButton
onClick={handleRefreshAllocations}
size="small"
@@ -356,7 +358,7 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
{allocatedNodes.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No node IDs allocated
{t('dna.no_allocations')}
</Typography>
</Box>
) : (
@@ -374,9 +376,9 @@ const DNAServerModal = ({ open, onClose, showMessage }) => {
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>NID</TableCell>
<TableCell>UUID</TableCell>
<TableCell align="right" width="60px">Action</TableCell>
<TableCell>{t('dna.col_nid')}</TableCell>
<TableCell>{t('dna.col_uuid')}</TableCell>
<TableCell align="right" width="60px">{t('dna.col_action')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
+39 -37
View File
@@ -9,6 +9,7 @@ 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
import { useTranslation } from './i18n/LanguageContext';
const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
// Add a new state for tracking whether a tune is currently playing
@@ -21,6 +22,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
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
const { t } = useTranslation();
useEffect(() => {
const localNode = window.localNode;
@@ -84,7 +86,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
if (!isValidFormat) {
// Warn user but continue with a default tune
setErrorMessage('Warning: Invalid RTTTL format! Using a default empty tune instead.');
setErrorMessage(t('edit.rtttl_warning'));
// Continue with a minimal valid RTTTL string
const tuneToParse = "Empty:d=4,o=5,b=120:";
result = AM32_Rtttl.to_am32_startup_melody(tuneToParse);
@@ -102,7 +104,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
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'}`);
setErrorMessage(t('edit.error_saving', { error: err.message || t('edit.unknown') }));
// Provide an empty binary string (all zeros) as fallback
const emptyArray = new Uint8Array(128);
valueToSave = String.fromCharCode.apply(null, emptyArray);
@@ -126,7 +128,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
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');
setErrorMessage(t('edit.rtttl_invalid'));
return;
}
@@ -149,7 +151,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
} catch (err) {
console.error("Error playing tune:", err);
setErrorMessage(`Error playing tune: ${err.message || 'Unknown error'}`);
setErrorMessage(t('edit.error_playing', { error: err.message || t('edit.unknown') }));
setIsPlaying(false);
}
};
@@ -224,7 +226,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
const isValidFormat = stringValue.includes(':') && stringValue.split(':').length === 3;
if (!isValidFormat) {
setErrorMessage('Invalid RTTTL format! Format should be: name:defaults:notes');
setErrorMessage(t('edit.rtttl_invalid'));
return false;
}
@@ -275,7 +277,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
// 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 : '∞'}`);
setErrorMessage(t('edit.value_range', { min: min !== null ? min : '-∞', max: max !== null ? max : '∞' }));
} else {
setIsValid(true);
setErrorMessage('');
@@ -322,7 +324,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
// 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 : '∞'}`);
setErrorMessage(t('edit.value_range', { min: min !== null ? min : '-∞', max: max !== null ? max : '∞' }));
} else {
setIsValid(true);
setErrorMessage('');
@@ -340,14 +342,14 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
<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>
<InputLabel id="rtttl-preset-label">{t('edit.select_preset')}</InputLabel>
<Select
labelId="rtttl-preset-label"
value={selectedPreset}
onChange={(e) => setSelectedPreset(e.target.value)}
>
<MenuItem value="" disabled>
<em>Choose a preset tune</em>
<em>{t('edit.choose_preset')}</em>
</MenuItem>
{Object.entries(rtttlPresets).map(([name, tune]) => (
<MenuItem key={name} value={tune}>
@@ -363,24 +365,24 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
disabled={!selectedPreset}
sx={{ mb: 1 }}
>
Apply
{t('edit.apply')}
</Button>
</Box>
<Box sx={{ position: 'relative' }}>
<TextField
label="RTTTL Tune"
label={t('edit.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"
placeholder={t('edit.rtttl_placeholder')}
error={!isValid && value !== ''}
// Remove helperText to avoid layout issues
/>
<Tooltip title={isPlaying ? "Stop tune" : "Play tune"}>
<Tooltip title={isPlaying ? t('edit.stop_tune') : t('edit.play_tune')}>
<IconButton
size="small"
color={isPlaying ? "secondary" : "primary"}
@@ -401,21 +403,21 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
{/* 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
{t('edit.rtttl_instruction')}
</Typography>
<Divider />
<Box sx={{ bgcolor: 'action.hover', p: 1, borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
RTTTL Format Guide
{t('edit.rtttl_guide_title')}
</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>
<Typography variant="caption" display="block"> {t('edit.rtttl_guide_duration')}</Typography>
<Typography variant="caption" display="block"> {t('edit.rtttl_guide_octave')}</Typography>
<Typography variant="caption" display="block"> {t('edit.rtttl_guide_tempo')}</Typography>
<Typography variant="caption" display="block"> {t('edit.rtttl_guide_notes')}</Typography>
<Typography variant="caption" display="block"> {t('edit.rtttl_guide_example')}</Typography>
</Box>
</Box>
</Box>
@@ -431,7 +433,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
if (param.fields.value.msg.fields.boolean_value) {
return (
<Box sx={{ display: 'flex', alignItems: 'center'}}>
<Typography variant="body2" sx={{ mr: 2 }}>Enable/Disable:</Typography>
<Typography variant="body2" sx={{ mr: 2 }}>{t('edit.enable_disable')}</Typography>
<Checkbox
checked={value === 1 || value === true}
onChange={(e) => setValue(e.target.checked ? 1 : 0)}
@@ -462,7 +464,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
return (
<TextField
label="New Value"
label={t('edit.new_value')}
value={value}
type="number"
inputProps={{
@@ -473,7 +475,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
fullWidth
margin="dense"
helperText={isOutOfBounds ?
`Value must be between ${min !== "" ? min : '-∞'} and ${max !== "" ? max : '∞'}` :
t('edit.value_range', { min: min !== "" ? min : '-∞', max: max !== "" ? max : '∞' }) :
null
}
/>
@@ -506,8 +508,8 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
const renderParamNameField = (name) => (
<TextField
label="Parameter Name"
value={name || "Unknown"}
label={t('edit.param_name')}
value={name || t('edit.unknown')}
InputProps={{
readOnly: true,
}}
@@ -542,7 +544,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
/>
) : (
<Typography variant="body2">
{value !== undefined && value !== "" && value !== null ? value : "Unknown"}
{value !== undefined && value !== "" && value !== null ? value : t('edit.unknown')}
</Typography>
)}
</Box>
@@ -554,7 +556,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
onClose={onClose}
sx={{ '& .MuiDialog-paper': { minWidth: isRTTTLEditor ? '600px' : '400px' } }}
>
<DialogTitle>Edit Parameter</DialogTitle>
<DialogTitle>{t('edit.title')}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'flex-end' }}>
@@ -565,7 +567,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
{isString && !isRTTTLEditor && (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
<TextField
label="String Value"
label={t('edit.string_value')}
value={value || ""}
onChange={(e) => setValue(e.target.value)}
fullWidth
@@ -580,7 +582,7 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{paramName === "STARTUP_TUNE" && isString ? (
renderInfoField("Current RTTTL", (() => {
renderInfoField(t('edit.current_rtttl'), (() => {
try {
// Get the binary string value
const binaryString = paramValueField.toString();
@@ -595,27 +597,27 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
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";
return t('edit.error_parsing_melody');
}
})(), true) // Pass true to indicate this is an RTTTL value
) : (
renderInfoField(
"Current Value",
t('edit.current_value'),
isBoolean
? (paramValueField.value ? "True" : "False")
? (paramValueField.value ? t('edit.true') : t('edit.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)}
{paramName !== "STARTUP_TUNE" && renderInfoField(t('edit.default_value'), isBoolean ? (paramDefaultValue ? t('param.true') : t('param.false')) : paramDefaultValue)}
</Box>
{!isBoolean && !isString && !isRTTTLEditor && (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
{renderInfoField("Min Value", paramMinValue)}
{renderInfoField("Max Value", paramMaxValue)}
{renderInfoField(t('edit.min_value'), paramMinValue)}
{renderInfoField(t('edit.max_value'), paramMaxValue)}
</Box>
)}
{errorMessage && (
@@ -629,14 +631,14 @@ const EditParamModal = ({ open, onClose, nodeId, paramIndex }) => {
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="secondary">
Cancel
{t('edit.cancel')}
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={!isValid}
>
Save
{t('edit.save')}
</Button>
</DialogActions>
</Dialog>
+17 -15
View File
@@ -4,6 +4,7 @@ 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';
import { useTranslation } from './i18n/LanguageContext';
const commandValueType = new dronecan.DSDL.uavcan_equipment_esc_RawCommand().fields.cmd.value_type;
const CMD_MAX = Number(commandValueType.value_range.max);
@@ -27,6 +28,7 @@ const EscPanel = () => {
const [sendArming, setSendArming] = useState(false);
const [broadcastRate, setBroadcastRate] = useState(10); // Changed default to 10
const [isPaused, setIsPaused] = useState(false); // New state for pause toggle
const { t } = useTranslation();
// Add toggle pause function
const togglePause = () => {
@@ -269,7 +271,7 @@ const EscPanel = () => {
<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>
<Typography variant="body2" sx={{ mr: 1 }}>{t('esc.channels')}</Typography>
<TextField
type="number"
size="small"
@@ -302,7 +304,7 @@ const EscPanel = () => {
fontSize: '0.6rem'
}}
>
REMOVE PROPELLERS!
{t('esc.remove_propellers')}
</Typography>
</Box>
@@ -317,7 +319,7 @@ const EscPanel = () => {
sx={{ p: 0.5 }}
/>
}
label={<Typography variant="body2">Send Safety</Typography>}
label={<Typography variant="body2">{t('esc.send_safety')}</Typography>}
labelPlacement="start"
sx={{ ml: 0, mr: 1 }}
/>
@@ -330,14 +332,14 @@ const EscPanel = () => {
sx={{ p: 0.5 }}
/>
}
label={<Typography variant="body2">Send Arming</Typography>}
label={<Typography variant="body2">{t('esc.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>
<Typography variant="body2" sx={{ mr: 1 }}>{t('esc.broadcast_rate')}</Typography>
<TextField
type="number"
size="small"
@@ -403,22 +405,22 @@ const EscPanel = () => {
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">{t('esc.index')} {esc.esc_index}</Typography>
<Typography variant="body2" color="textSecondary">{t('esc.error')} {esc.error_count !== null ? esc.error_count : t('esc.nc')}</Typography>
<Typography variant="body2" color="textSecondary">
Temp: {esc.temperature !== null ? `${(esc.temperature - 273.15).toFixed(1)} °C` : "NC"}
{t('esc.temp')} {esc.temperature !== null ? `${(esc.temperature - 273.15).toFixed(1)} °C` : t('esc.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
Volt: {esc.voltage !== null ? `${esc.voltage.toFixed(2)} V` : "NC"}
{t('esc.volt')} {esc.voltage !== null ? `${esc.voltage.toFixed(2)} V` : t('esc.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
Curr: {esc.current !== null ? `${esc.current.toFixed(2)} A` : "NC"}
{t('esc.curr')} {esc.current !== null ? `${esc.current.toFixed(2)} A` : t('esc.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
RPM: {esc.rpm !== null ? Math.round(esc.rpm) : "NC"}
{t('esc.rpm')} {esc.rpm !== null ? Math.round(esc.rpm) : t('esc.nc')}
</Typography>
<Typography variant="body2" color="textSecondary">
RAT: {esc.power_rating_pct !== null ? `${esc.power_rating_pct.toFixed(1)} %` : "NC"}
{t('esc.rat')} {esc.power_rating_pct !== null ? `${esc.power_rating_pct.toFixed(1)} %` : t('esc.nc')}
</Typography>
</Box>
@@ -446,7 +448,7 @@ const EscPanel = () => {
fullWidth
size="small"
>
Stop
{t('esc.stop')}
</Button>
</Box>
</Box>
@@ -493,7 +495,7 @@ const EscPanel = () => {
}}>
<Box sx={{ p: 1, border: '1px solid #ddd', borderRadius: 1}}>
<Typography variant="body2" color="textSecondary">
cmd: [{getScaledCommands(thrustValues).join(', ')}]
{t('esc.cmd')} [{getScaledCommands(thrustValues).join(', ')}]
</Typography>
</Box>
@@ -504,7 +506,7 @@ const EscPanel = () => {
startIcon={<PanToolIcon />}
onClick={handleStopAll}
>
Stop All
{t('esc.stop_all')}
</Button>
</Box>
</Box>
+111 -1
View File
@@ -58,14 +58,119 @@ class FileServer {
* @param {string} path - The virtual path to register the file under
* @return {Promise} - Resolves when file is loaded
*/
/**
* Parse Intel HEX format and convert to binary ArrayBuffer
* @param {string} hexContent - The Intel HEX file content as string
* @returns {ArrayBuffer} - Binary data
*/
parseIntelHex(hexContent) {
const lines = hexContent.split(/\r?\n/);
let binaryData = [];
let minAddress = Infinity;
let maxAddress = 0;
let addressMap = {};
let baseAddress = 0; // For extended address records
for (let line of lines) {
line = line.trim();
if (!line || !line.startsWith(':')) continue;
// Parse Intel HEX record
const byteCount = parseInt(line.substring(1, 3), 16);
const address = parseInt(line.substring(3, 7), 16);
const recordType = parseInt(line.substring(7, 9), 16);
const data = line.substring(9, 9 + (byteCount * 2));
const checksum = parseInt(line.substring(9 + (byteCount * 2), 9 + (byteCount * 2) + 2), 16);
// Verify checksum
let sum = byteCount + ((address >> 8) & 0xFF) + (address & 0xFF) + recordType;
for (let i = 0; i < data.length; i += 2) {
sum += parseInt(data.substring(i, i + 2), 16);
}
sum = (~sum + 1) & 0xFF;
if (sum !== checksum) {
throw new Error(`Checksum mismatch in line: ${line}`);
}
if (recordType === 0x00) { // Data record
const bytes = [];
for (let i = 0; i < data.length; i += 2) {
bytes.push(parseInt(data.substring(i, i + 2), 16));
}
// Store data at address (with base address offset)
for (let i = 0; i < bytes.length; i++) {
const addr = baseAddress + address + i;
addressMap[addr] = bytes[i];
minAddress = Math.min(minAddress, addr);
maxAddress = Math.max(maxAddress, addr);
}
} else if (recordType === 0x04) { // Extended Linear Address
// Upper 16 bits of address
const upperAddress = parseInt(data, 16);
baseAddress = upperAddress << 16;
console.log(`Extended Linear Address: base = 0x${baseAddress.toString(16)}`);
} else if (recordType === 0x02) { // Extended Segment Address
// Segment address (multiply by 16)
const segmentAddress = parseInt(data, 16);
baseAddress = segmentAddress << 4;
console.log(`Extended Segment Address: base = 0x${baseAddress.toString(16)}`);
} else if (recordType === 0x01) { // End of file
console.log('End of Intel HEX file');
break;
}
}
if (minAddress === Infinity) {
throw new Error('No data found in Intel HEX file');
}
// Convert to contiguous binary array
const size = maxAddress - minAddress + 1;
const buffer = new ArrayBuffer(size);
const view = new Uint8Array(buffer);
// Fill with 0xFF (typical for flash memory)
view.fill(0xFF);
// Copy data
for (let addr = minAddress; addr <= maxAddress; addr++) {
if (addressMap[addr] !== undefined) {
view[addr - minAddress] = addressMap[addr];
}
}
console.log(`Intel HEX parsed: ${lines.length} lines, address range 0x${minAddress.toString(16)}-0x${maxAddress.toString(16)}, binary size: ${size} bytes`);
return buffer;
}
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;
// Check if this is a .hex file
const isHexFile = file.name.toLowerCase().endsWith('.hex');
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target.result;
let buffer;
if (isHexFile) {
// Parse Intel HEX format
const hexContent = e.target.result;
try {
buffer = this.parseIntelHex(hexContent);
} catch (error) {
console.error('Error parsing Intel HEX file:', error);
reject(new Error('Invalid Intel HEX format'));
return;
}
} else {
// Use raw binary data for .bin files
buffer = e.target.result;
}
this.files[filePath] = {
name: file.name,
size: buffer.byteLength,
@@ -82,7 +187,12 @@ class FileServer {
reject(error);
};
// Read as text for .hex files, as binary for others
if (isHexFile) {
reader.readAsText(file);
} else {
reader.readAsArrayBuffer(file);
}
});
}
+22 -20
View File
@@ -5,6 +5,7 @@ import {
} from '@mui/material';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FileServer from './FileServer';
import { useTranslation } from './i18n/LanguageContext';
const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
const [firmwareFile, setFirmwareFile] = useState(null);
@@ -12,16 +13,17 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
const [updateProgress, setUpdateProgress] = useState(0);
const [updateStatus, setUpdateStatus] = useState(null); // 'idle', 'updating', 'success', 'error'
const [statusMessage, setStatusMessage] = useState('');
const { t } = useTranslation();
const handleFileChange = (event) => {
const file = event.target.files[0];
if (!file) return;
// Validate file extension is .bin
// Validate file extension is .bin or .hex
const fileExtension = file.name.split('.').pop().toLowerCase();
if (fileExtension !== 'bin') {
if (fileExtension !== 'bin' && fileExtension !== 'hex') {
setUpdateStatus('error');
setStatusMessage('Invalid file type. Please select a .bin firmware file.');
setStatusMessage(t('fw.invalid_file'));
return;
}
@@ -42,7 +44,7 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
.catch(error => {
console.error('Error loading firmware:', error);
setUpdateStatus('error');
setStatusMessage('Failed to load firmware file');
setStatusMessage(t('fw.load_failed'));
});
};
@@ -79,13 +81,13 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
if (!localNode) {
setUpdateStatus('error');
setStatusMessage('Local node not available');
setStatusMessage(t('fw.node_unavailable'));
return;
}
// Update UI state
setUpdateStatus('updating');
setStatusMessage('Starting firmware update...');
setStatusMessage(t('fw.starting'));
setUpdateProgress(0);
// Register a progress callback with the FileServer
@@ -95,12 +97,12 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
setUpdateProgress(progress * 100);
// Update status message
setStatusMessage(`Updating firmware: ${Math.round(progress * 100)}% (${offset}/${total} bytes)`);
setStatusMessage(t('fw.updating', { progress: Math.round(progress * 100), offset: offset, total: total }));
// When complete
if (eof) {
setUpdateStatus('success');
setStatusMessage('Firmware update completed successfully!');
setStatusMessage(t('fw.success'));
// Clean up progress tracking
setTimeout(() => {
@@ -123,7 +125,7 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
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'}`);
setStatusMessage(t('fw.update_failed', { code: msg.fields.error.value, message: msg.fields.optional_error_message.toString() || t('edit.unknown') }));
FileServer.unregisterProgressCallback(firmwarePath);
} else {
setUpdateStatus('updating');
@@ -134,16 +136,16 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
} catch (error) {
console.error('Error initiating firmware update:', error);
setUpdateStatus('error');
setStatusMessage(`Failed to start update: ${error.message || 'Unknown error'}`);
setStatusMessage(t('fw.start_failed', { error: error.message || t('edit.unknown') }));
}
};
return (
<Dialog open={open} onClose={updateStatus === 'updating' ? null : onClose} maxWidth="sm" fullWidth>
<DialogTitle>Firmware Update</DialogTitle>
<DialogTitle>{t('fw.title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" gutterBottom>
Please select the firmware file (.bin) to upload to node {targetNodeId}.
{t('fw.select_hint', { id: targetNodeId })}
</Typography>
<Button
@@ -152,11 +154,11 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
startIcon={<FileUploadIcon />}
disabled={updateStatus === 'updating'}
>
Select Firmware File
{t('fw.select_file')}
<input
type="file"
hidden
accept=".bin"
accept=".bin,.hex"
onChange={handleFileChange}
/>
</Button>
@@ -164,7 +166,7 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
{firmwareFile && (
<Typography variant="body2" sx={{ mt: 2 }}>
Selected File: {firmwareFile.name}
{fileContent && ` (${fileContent.size} bytes)`}
{fileContent && ` (${fileContent.size} ${t('fw.bytes', { defaultValue: 'bytes' })})`}
</Typography>
)}
@@ -183,13 +185,13 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
{updateStatus === 'error' && (
<Alert severity="error" sx={{ mt: 2 }}>
{statusMessage || 'An error occurred during the update.'}
{statusMessage || t('fw.error_occurred')}
</Alert>
)}
{updateStatus === 'success' && (
<Alert severity="success" sx={{ mt: 2 }}>
{statusMessage || 'Firmware update completed successfully!'}
{statusMessage || t('fw.success')}
</Alert>
)}
</DialogContent>
@@ -197,14 +199,14 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
{updateStatus !== 'updating' ? (
<>
<Button onClick={onClose} color="secondary">
Cancel
{t('fw.cancel')}
</Button>
<Button
onClick={handleUpdate}
color="primary"
disabled={!firmwareFile || updateStatus === 'updating'}
>
Update
{t('fw.update')}
</Button>
</>
) : (
@@ -213,7 +215,7 @@ const FirmwareUpdateModal = ({ open, onClose, targetNodeId }) => {
disabled={updateProgress < 100}
onClick={onClose}
>
{updateProgress < 100 ? 'Updating...' : 'Close'}
{updateProgress < 100 ? t('fw.updating_ellipsis') : t('fw.close')}
</Button>
)}
</DialogActions>
+24 -23
View File
@@ -29,16 +29,27 @@ const NodeList = ({ nodes, selectedNodeId, setSelectedNodeId }) => {
};
const renderNodeRow = (key) => {
let node = nodes[key];
let status = node.status;
let health = status.getConstant('health');
let mode = status.getConstant('mode');
const node = nodes[key];
const status = node.status;
const health = status.getConstant('health');
const mode = status.getConstant('mode');
const isSelected = Number(key) === Number(selectedNodeId);
return (
<TableRow key={key} onClick={() => handleRowClick((Number(key)))} style={{ cursor: 'pointer' }}>
<TableRow
key={key}
onClick={() => handleRowClick(Number(key))}
sx={{
cursor: 'pointer',
backgroundColor: isSelected ? 'rgba(204, 120, 92, 0.08)' : 'transparent',
'&:hover': {
backgroundColor: 'rgba(245, 240, 232, 0.9)',
},
}}
>
<TableCell>{key}</TableCell>
<TableCell>{node.name}</TableCell>
<TableCell sx={{ width: 150 }}>{node.name}</TableCell>
<TableCell>{health}</TableCell>
<TableCell sx={{bgcolor: getModeColor(mode)}}>{mode}</TableCell>
<TableCell sx={{ bgcolor: getModeColor(mode), color: getModeColor(mode) ? 'common.white' : 'inherit' }}>{mode}</TableCell>
<TableCell>{secondsToTime(status.uptime_sec)}</TableCell>
<TableCell>{node.status.vendor_specific_status_code}</TableCell>
</TableRow>
@@ -48,25 +59,17 @@ const NodeList = ({ nodes, selectedNodeId, setSelectedNodeId }) => {
return (
<Box
component={Paper}
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, height: '50%'}}
sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, height: '50%', backgroundColor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
>
<Box margin={1} sx={{height: 20}}>
<Typography variant="caption">Online Nodes</Typography>
<Box margin={1} sx={{ height: 20 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, letterSpacing: 0.2 }}>Online Nodes</Typography>
</Box>
<TableContainer
sx={{ overflow: 'auto' }}
>
<TableContainer sx={{ overflow: 'auto' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>NID</TableCell>
<TableCell
sx={{
width: 150,
}}
>
Name
</TableCell>
<TableCell>Name</TableCell>
<TableCell>Health</TableCell>
<TableCell>Mode</TableCell>
<TableCell>Uptime</TableCell>
@@ -74,9 +77,7 @@ const NodeList = ({ nodes, selectedNodeId, setSelectedNodeId }) => {
</TableRow>
</TableHead>
<TableBody>
{Object.keys(nodes).map((key) => (
renderNodeRow(key)
))}
{Object.keys(nodes).map((key) => renderNodeRow(key))}
</TableBody>
</Table>
</TableContainer>
+16 -16
View File
@@ -3,10 +3,12 @@ import { TableContainer, Table, TableHead, TableBody, TableRow, TableCell, Paper
import PauseIcon from '@mui/icons-material/Pause';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { useTranslation } from './i18n/LanguageContext';
const NodeLogs = () => {
const [logs, setLogs] = useState([]);
const [paused, setPaused] = useState(false);
const { t } = useTranslation();
useEffect(() => {
const localNode = window.localNode;
@@ -14,7 +16,6 @@ const NodeLogs = () => {
if (paused) {
return;
}
// console.log(transfer);
const msg = transfer.payload;
const msgObj = msg.toObj();
setLogs((logs) => [...logs, {
@@ -49,7 +50,7 @@ const NodeLogs = () => {
return (
<Box
component={Paper}
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, height: '50%'}}
sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, height: '50%', backgroundColor: 'background.paper', border: '1px solid', borderColor: 'divider' }}
>
<Box sx={{
display: 'flex',
@@ -57,13 +58,14 @@ const NodeLogs = () => {
alignItems: 'center',
height: 20
}} margin={1}>
<Typography variant="caption" flexGrow={1}>Logs</Typography>
<Typography variant="caption" flexGrow={1} sx={{ color: 'text.secondary', fontWeight: 600, letterSpacing: 0.2 }}>{t('logs.title')}</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
sx={{
width: 20,
height: 20,
padding: 0
padding: 0,
color: 'text.secondary',
}}
size='small'
onClick={() => setPaused(!paused)}
@@ -74,35 +76,33 @@ const NodeLogs = () => {
sx={{
width: 20,
height: 20,
padding: 0
padding: 0,
color: 'warning.main',
}}
size='small'
color="warning"
onClick={() => setLogs([])}
>
<CleaningServicesIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
<TableContainer
sx={{ overflow: 'auto' }}
>
<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>
<TableCell sx={{ width: '5%' }}>{t('logs.col_nid')}</TableCell>
<TableCell sx={{ width: '15%' }}>{t('logs.col_time')}</TableCell>
<TableCell sx={{ width: '10%' }}>{t('logs.col_level')}</TableCell>
<TableCell sx={{ width: '10%' }}>{t('logs.col_source')}</TableCell>
<TableCell sx={{ width: '60%' }}>{t('logs.col_text')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map((log, index) => (
<TableRow key={index}>
<TableRow key={index} sx={{ '&:hover': { backgroundColor: 'rgba(245, 240, 232, 0.9)' } }}>
<TableCell>{log.id}</TableCell>
<TableCell>{log.localTime}</TableCell>
<TableCell sx={{bgcolor: getLevelColor(log.level)}}>
<TableCell sx={{ bgcolor: getLevelColor(log.level), color: getLevelColor(log.level) ? 'common.white' : 'inherit' }}>
{log.level}
</TableCell>
<TableCell>{log.source}</TableCell>
+49 -21
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import {
Box, Button, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Typography, Paper, Tooltip, Chip
@@ -11,6 +11,7 @@ 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';
import { useTranslation } from './i18n/LanguageContext';
const OPCODE_SAVE = 0;
const OPCODE_ERASE = 1;
@@ -18,6 +19,10 @@ const OPCODE_ERASE = 1;
const NodeParam = ({ nodeId, nodes }) => {
const [modalOpen, setModalOpen] = useState(false);
const [editParamIndex, setEditParamIndex] = useState(null);
const [paramsUpdateTimestamp, setParamsUpdateTimestamp] = useState(0);
const [fetchingParams, setFetchingParams] = useState(false);
const fetchTimeoutRef = useRef(null);
const { t } = useTranslation();
if (!nodeId) return null;
const node = nodes[nodeId];
@@ -26,17 +31,38 @@ const NodeParam = ({ nodeId, nodes }) => {
const handleFetchParams = () => {
const localNode = window.localNode;
let currentParamIndex = 0;
setFetchingParams(true);
setParamsUpdateTimestamp(Date.now());
if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current);
const resetFetchTimeout = () => {
if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = setTimeout(() => {
setFetchingParams(false);
console.warn(`Fetch params timed out for node ${nodeId} at index ${currentParamIndex}`);
}, 3000);
};
const callback = (transfer) => {
const msg = transfer.payload;
console.log('Param response:', { nodeId: transfer.sourceNodeId, index: currentParamIndex, name: msg?.fields?.name?.toString?.() });
if (msg && msg.fields.name.items.length > 0) {
if (msg && transfer.destNodeId === localNode.nodeId) {
localNode.updateNodeParamsFromResponse(transfer, currentParamIndex);
setParamsUpdateTimestamp(Date.now());
currentParamIndex += 1;
resetFetchTimeout();
localNode.fetchNodeParam(nodeId, currentParamIndex, '', callback);
}
} else {
setFetchingParams(false);
setParamsUpdateTimestamp(Date.now());
if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current);
console.log(`Finished fetching params for node ${nodeId}`);
}
};
resetFetchTimeout();
localNode.fetchNodeParam(nodeId, 0, '', callback);
};
@@ -73,9 +99,9 @@ const NodeParam = ({ nodeId, nodes }) => {
// Function to format boolean values visually
const formatBooleanValue = (value) => {
if (value === 'True') {
return <Chip size="small" label="True" color="success" />;
return <Chip size="small" label={t('param.true')} color="success" />;
} else if (value === 'False') {
return <Chip size="small" label="False" color="error" />;
return <Chip size="small" label={t('param.false')} color="error" />;
}
return value;
};
@@ -190,15 +216,15 @@ const NodeParam = ({ nodeId, nodes }) => {
} else if (param.fields.value.msg.fields.boolean_value !== undefined) {
paramTypeDisplay = 'boolean';
if (param.fields.value.msg.fields.boolean_value.value === 0) {
paramValueDisplay = 'Disabled';
paramValueDisplay = t('param.disabled');
} else if (param.fields.value.msg.fields.boolean_value.value === 1) {
paramValueDisplay = 'Enabled';
paramValueDisplay = t('param.enabled');
}
if (paramDefaultValue === 0) {
paramDefaultValueDisplay = 'Disabled';
paramDefaultValueDisplay = t('param.disabled');
} else {
paramDefaultValueDisplay = 'Enabled';
paramDefaultValueDisplay = t('param.enabled');
}
paramMinValueDisplay = "";
paramMaxValueDisplay = "";
@@ -282,7 +308,7 @@ const NodeParam = ({ nodeId, nodes }) => {
</Typography>
</TableCell>
<TableCell padding="none" align="center">
<Tooltip title="Edit Parameter">
<Tooltip title={t('param.edit_param')}>
<EditIcon fontSize="small" color="primary" />
</Tooltip>
</TableCell>
@@ -298,13 +324,13 @@ const NodeParam = ({ nodeId, nodes }) => {
<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: '5%'}}>{t('param.col_idx')}</TableCell>
<TableCell sx={{width: '28%'}}>{t('param.col_name')}</TableCell>
<TableCell sx={{width: '10%'}}>{t('param.col_type')}</TableCell>
<TableCell sx={{width: '17%'}}>{t('param.col_value')}</TableCell>
<TableCell sx={{width: '10%'}}>{t('param.col_default')}</TableCell>
<TableCell sx={{width: '10%'}}>{t('param.col_min')}</TableCell>
<TableCell sx={{width: '10%'}}>{t('param.col_max')}</TableCell>
<TableCell sx={{width: '10%'}}></TableCell>
</TableRow>
</TableHead>
@@ -333,7 +359,7 @@ const NodeParam = ({ nodeId, nodes }) => {
sx={{ width: 80, mr: 2, ml: 0.5 }}
variant="caption"
>
Parameters
{t('param.title')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'space-between', flexDirection: 'row', border: 1, borderColor: 'grey.500', borderRadius: 1, p: 0.5, mr: 2 }}>
<Button
@@ -341,8 +367,9 @@ const NodeParam = ({ nodeId, nodes }) => {
variant="contained"
sx={{ mr: 1 }}
startIcon={<SyncIcon />}
disabled={fetchingParams}
>
Fetch All
{fetchingParams ? t('param.fetching') : t('param.fetch_all')}
</Button>
<Button
onClick={handleSaveParams}
@@ -352,7 +379,7 @@ const NodeParam = ({ nodeId, nodes }) => {
startIcon={<SaveIcon />}
disabled={!localNode.nodeParams[nodeId] || Object.keys(localNode.nodeParams[nodeId]).length === 0}
>
Store All
{t('param.store_all')}
</Button>
<Button
onClick={handleEraseParams}
@@ -360,7 +387,7 @@ const NodeParam = ({ nodeId, nodes }) => {
color="warning"
startIcon={<AutoFixNormalIcon />}
>
Erase All
{t('param.erase_all')}
</Button>
</Box>
<Box sx={{ flexGrow: 1 }}></Box>
@@ -373,7 +400,7 @@ const NodeParam = ({ nodeId, nodes }) => {
onClick={handleDownloadParams}
disabled={!localNode.nodeParams[nodeId] || Object.keys(localNode.nodeParams[nodeId]).length === 0}
>
Download
{t('param.download')}
</Button>
<Button
variant="outlined"
@@ -381,11 +408,12 @@ const NodeParam = ({ nodeId, nodes }) => {
startIcon={<FileUploadIcon />}
disabled={!nodeId} // Disable Load button if no node is selected
>
Load
{t('param.load')}
</Button>
</Box>
</Box>
{renderNodeParams()}
<span style={{ display: 'none' }}>{paramsUpdateTimestamp}</span>
<ParamEditorSelector
open={modalOpen}
onClose={() => setModalOpen(false)}
+23 -21
View File
@@ -7,6 +7,7 @@ import CableIcon from '@mui/icons-material/Cable';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import FirmwareUpdateModal from './FirmwareUpdateModal';
import ConfirmRestartModal from './ConfirmRestartModal';
import { useTranslation } from './i18n/LanguageContext';
const VendorSpecificCodeDisplay = (code) => {
code = Math.max(0, Math.floor(code) & 0xFFFF);
@@ -19,6 +20,7 @@ const VendorSpecificCodeDisplay = (code) => {
const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEditorEnable }) => {
const [firmwareModalOpen, setFirmwareModalOpen] = useState(false);
const [restartModalOpen, setRestartModalOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => {
const localNode = window.localNode;
@@ -65,22 +67,22 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
return (
<Box
sx={{ flexGrow: 1, bgcolor: 'background.paper', height: 340}}
sx={{ flexGrow: 1, bgcolor: 'background.paper', height: 340, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
component={Paper}
p={1}
>
<Box sx={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption">
Node Properties
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, letterSpacing: 0.2 }}>
{t('props.title')}
</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="caption">Multi Node Editor</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{t('props.multi_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"
label={t('props.node_id')}
value={nodeId}
InputProps={{
readOnly: true,
@@ -88,7 +90,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ mr: 0.5 }}
/>
<TextField
label="Name"
label={t('props.name')}
value={name}
fullWidth
InputProps={{
@@ -98,7 +100,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Mode"
label={t('props.mode')}
value={mode}
fullWidth
InputProps={{
@@ -107,7 +109,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ mr: 0.5 }}
/>
<TextField
label="Health"
label={t('props.health')}
value={health}
fullWidth
InputProps={{
@@ -116,7 +118,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ mr: 0.5 }}
/>
<TextField
label="Uptime"
label={t('props.uptime')}
value={uptime}
fullWidth
InputProps={{
@@ -126,7 +128,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Vendor Specific Status Code"
label={t('props.vendor_code')}
fullWidth
value={vendor_specific_status_code}
InputProps={{
@@ -136,7 +138,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Software Version"
label={t('props.sw_version')}
fullWidth
value={softwareVersion}
InputProps={{
@@ -145,7 +147,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ mr: 0.5 }}
/>
<TextField
label="CRC64"
label={t('props.crc64')}
fullWidth
value={softwareCrc64}
InputProps={{
@@ -154,7 +156,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ mr: 0.5 }}
/>
<TextField
label="VCS Commit"
label={t('props.vcs_commit')}
fullWidth
value={softwareVcsCommit}
InputProps={{
@@ -164,7 +166,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Hardware Version"
label={t('props.hw_version')}
value={hardwareVersion}
InputProps={{
readOnly: true,
@@ -172,7 +174,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ mr: 0.5 }}
/>
<TextField
label="UID"
label={t('props.uid')}
fullWidth
value={hardwareUID}
InputProps={{
@@ -182,7 +184,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', mt: 1 }}>
<TextField
label="Cert. of authenticity"
label={t('props.certificate')}
fullWidth
value={certificateOfAuthenticity}
InputProps={{
@@ -195,9 +197,9 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
sx={{ width: 80, mr: 2 }}
variant="caption"
>
Node Controls
{t('props.controls')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1, border: 1, borderColor: 'grey.500', borderRadius: 1, p: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', flexGrow: 1, border: 1, borderColor: 'divider', borderRadius: 1, p: 0.5, backgroundColor: 'background.default' }}>
<Button
sx={{ mr: 1 }}
color="error"
@@ -205,14 +207,14 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
startIcon={<PowerSettingsNewIcon />}
onClick={() => setRestartModalOpen(true)}
>
Restart
{t('props.restart')}
</Button>
<Button
sx={{ mr: 1 }}
variant="outlined"
startIcon={<CableIcon />}
>
Get Transport Stats
{t('props.transport_stats')}
</Button>
<Box sx={{ flexGrow: 1 }}></Box>
<Button
@@ -220,7 +222,7 @@ const NodeProperties = ({ nodeId, nodes, multiNodeEditorEnable, setMultiNodeEdit
startIcon={<SystemUpdateAltIcon />}
onClick={() => setFirmwareModalOpen(true)}
>
Update Firmware
{t('props.update_firmware')}
</Button>
</Box>
</Box>
+11 -5
View File
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { Box, Button, Menu, MenuItem, Divider } from '@mui/material';
import { Box, Button, Menu, MenuItem } from '@mui/material';
import VideogameAssetIcon from '@mui/icons-material/VideogameAsset';
import { useTranslation } from './i18n/LanguageContext';
const PanelsMenu = ({openWindow}) => {
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const { t } = useTranslation();
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
@@ -39,10 +41,14 @@ const PanelsMenu = ({openWindow}) => {
aria-expanded={open ? 'true' : undefined}
disableElevation
onClick={handleClick}
color="default"
color="inherit"
startIcon={<VideogameAssetIcon />}
sx={{
border: '1px solid rgba(230, 223, 216, 0.95)',
backgroundColor: 'background.default',
}}
>
Panels
{t('panels.title')}
</Button>
<Menu
elevation={0}
@@ -59,10 +65,10 @@ const PanelsMenu = ({openWindow}) => {
onClose={handleClose}
>
<MenuItem onClick={handleEscPanelClick} disableRipple>
ESC
{t('panels.esc')}
</MenuItem>
<MenuItem onClick={handleActuatorPanelClick} disableRipple>
Actuator
{t('panels.actuator')}
</MenuItem>
</Menu>
</Box>
+7 -5
View File
@@ -7,6 +7,7 @@ import DronecanLogo from './image/dronecan_logo.png';
import { toYaml } from './dronecan/message_format_utils';
import theme from './theme';
import { useTranslation } from './i18n/LanguageContext';
import './css/subscriber.css';
const SubscriberWindow = () => {
@@ -19,9 +20,10 @@ const SubscriberWindow = () => {
const [recordingSet, setRecordingSet] = useState([]);
const [displayRecordText, setDisplayRecordText] = useState("");
const [recording, setRecording] = useState(true);
const { t } = useTranslation();
if (window.opener === null) {
return "Not Allowed To Open Directly";
return t('sub.not_allowed');
}
useEffect(() => {
@@ -61,7 +63,7 @@ const SubscriberWindow = () => {
}
const msg = transfer.payload;
const msgObj = msg.toObj();
let destNodeText = "All";
let destNodeText = t('sub.all');
if (transfer.destNodeId && transfer.destNodeId !== 0) {
destNodeText = `${transfer.destNodeId}`;
}
@@ -148,12 +150,12 @@ const SubscriberWindow = () => {
<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={0.5}>{t('sub.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={0.5}>{t('sub.rates')}</Typography>
<Typography variant="caption" mr={1} sx={{minWidth: 30}}>{messageRate.toFixed(0)}</Typography>
</Box>
<Typography variant="caption" mr={1}> Max:</Typography>
<Typography variant="caption" mr={1}>{t('sub.max')}</Typography>
<TextField size="small" sx={{width: 80}} type="number" min={1} max={100} value={maxMessageCount} onChange={updateMaxMessageCount} />
<IconButton size="small" onClick={handleClean}>
<CleaningServicesIcon />
+11 -7
View File
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Box, Button, Menu, MenuItem, Divider } from '@mui/material';
import { Box, Button, Menu, MenuItem } from '@mui/material';
import BuildIcon from '@mui/icons-material/Build';
import MessageIcon from '@mui/icons-material/Message';
import SettingsInputCompositeIcon from '@mui/icons-material/SettingsInputComposite';
import { useTranslation } from './i18n/LanguageContext';
const ToolsMenu =({openWindow}) => {
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const { t } = useTranslation();
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
@@ -41,10 +41,14 @@ const ToolsMenu =({openWindow}) => {
aria-expanded={open ? 'true' : undefined}
disableElevation
onClick={handleClick}
color="default"
color="inherit"
startIcon={<BuildIcon />}
sx={{
border: '1px solid rgba(230, 223, 216, 0.95)',
backgroundColor: 'background.default',
}}
>
Tools
{t('tools.title')}
</Button>
<Menu
elevation={0}
@@ -61,10 +65,10 @@ const ToolsMenu =({openWindow}) => {
onClose={handleClose}
>
<MenuItem onClick={handleSubscriberClick} disableRipple>
Subscriber
{t('tools.subscriber')}
</MenuItem>
<MenuItem onClick={handleBusMonitorClick} disableRipple>
Bus Monitor
{t('tools.bus_monitor')}
</MenuItem>
</Menu>
</Box>
+2 -1
View File
@@ -5,8 +5,9 @@
}
body {
background-color: rgb(22, 13, 13);
background-color: #faf9f5;
overflow: hidden;
color: #141413;
}
#root {
+2 -2
View File
@@ -187,7 +187,7 @@ class Node extends EventEmitter {
this.emit(topicName, transfer);
}
} else {
if (!transfer.dataTypeId || !transfer.payload) {
if (transfer.dataTypeId === null || transfer.dataTypeId === undefined || !transfer.payload) {
// console.error('#TODO dataTypeId or payload is null');
return;
}
@@ -425,7 +425,7 @@ class Node extends EventEmitter {
}
fetchNodeParam(sourceNodeId, index, name, callback=null) {
// console.log('Fetching node param:', sourceNodeId, index, name);
console.log('Fetching node param:', sourceNodeId, index, name);
// const currentRequestIndex = index;
this.nodeParamsRequestingNodeId = sourceNodeId;
this.setNodeParamsRequestingIndex(sourceNodeId, index);
+67
View File
@@ -0,0 +1,67 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
import { en, zh } from "./translations";
const allTranslations = { en, zh };
const LanguageContext = createContext();
const STORAGE_KEY = "language";
function getInitialLanguage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "zh" || stored === "en") return stored;
} catch (e) {}
return "en";
}
export function LanguageProvider({ children }) {
const [language, setLanguage] = useState(getInitialLanguage);
const strings = allTranslations[language];
const handleSetLanguage = useCallback((lang) => {
setLanguage(lang);
try {
localStorage.setItem(STORAGE_KEY, lang);
} catch (e) {}
}, []);
const t = useCallback(
(key, params) => {
let str = strings[key] || en[key] || key;
if (params) {
Object.entries(params).forEach(([k, v]) => {
str = str.replace(new RegExp(`\\{${k}\\}`, "g"), v);
});
}
return str;
},
[strings]
);
const value = useMemo(
() => ({ language, setLanguage: handleSetLanguage, t }),
[language, handleSetLanguage, t]
);
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
}
export function useTranslation() {
const ctx = useContext(LanguageContext);
if (!ctx) {
const t = (key, params) => {
let str = en[key] || key;
if (params) {
Object.entries(params).forEach(([k, v]) => {
str = str.replace(new RegExp(`\\{${k}\\}`, "g"), v);
});
}
return str;
};
return { language: "en", setLanguage: () => {}, t };
}
return ctx;
}
+605
View File
@@ -0,0 +1,605 @@
export const en = {
// App.js
"app.title": "DroneCAN Web Tools",
"app.bus": "Bus {n}",
"app.dna": "DNA",
"app.adapter": "Adapter",
"app.connected": "Successfully connected to device",
"app.disconnected": "Disconnected from device",
"app.bus_switched": "Switched to CAN bus {bus}",
"app.dna_stopped": "DNA server stopped",
"app.dna_started": "DNA server started",
"app.dna_failed": "Failed to start DNA server",
// ConnectionSettingsModal.js
"conn.title": "Adapter Settings",
"conn.connected_serial_slcan": "Connected via serial (SLCAN)",
"conn.connected_serial_mavlink": "Connected via serial (MAVLink)",
"conn.connected_ws": "Connected via {type}",
"conn.serial_section": "Serial Connection",
"conn.port": "Port",
"conn.no_ports": "No ports available",
"conn.baud_rate": "Baud Rate",
"conn.serial_protocol": "Serial Protocol",
"conn.protocol_mavlink": "MAVLink tunnel",
"conn.protocol_slcan": "SLCAN / LAWICEL",
"conn.refresh": "Refresh",
"conn.request": "Request",
"conn.disconnect": "Disconnect",
"conn.connecting": "Connecting...",
"conn.connect": "Connect",
"conn.ws_section": "WebSocket Connection",
"conn.host": "Host/IP Address",
"conn.ws_port": "Port",
"conn.node_id": "Node ID",
"conn.signing": "Mavlink Signing",
"conn.secret_key": "Secret Key",
"conn.show_secret": "Show secret",
"conn.hide_secret": "Hide secret",
"conn.serial_closed": "Serial connection closed",
"conn.serial_slcan_ok": "Serial SLCAN connection established",
"conn.serial_mavlink_ok": "Serial MAVLink connection established",
"conn.serial_failed": "Serial connection failed: {error}",
"conn.could_not_connect": "Could not connect to port",
"conn.serial_error": "Serial error: {error}",
"conn.unknown_error": "Unknown error",
"conn.ws_closed": "WebSocket connection closed",
"conn.ws_connected": "WebSocket connection established",
"conn.ws_failed": "Connection failed",
"conn.ws_failed_detail": "Connection failed: {error}",
"conn.ws_error": "WebSocket error: {error}",
"conn.ip_required": "IP address is required",
"conn.ip_invalid_parts": "Each part must be a number between 0-255",
"conn.ip_invalid": "Invalid IP address or hostname",
"conn.port_required": "Port is required",
"conn.port_range": "Port must be between 1-65535",
"conn.node_id_range": "Node ID must be between 1-127",
"conn.no_port_selected": "No port selected",
"conn.serial_port": "Serial Port",
// NodeParam.js
"param.title": "Parameters",
"param.fetching": "Fetching...",
"param.fetch_all": "Fetch All",
"param.store_all": "Store All",
"param.erase_all": "Erase All",
"param.download": "Download",
"param.load": "Load",
"param.col_idx": "Idx",
"param.col_name": "Name",
"param.col_type": "Type",
"param.col_value": "Value",
"param.col_default": "Default",
"param.col_min": "Min",
"param.col_max": "Max",
"param.edit_param": "Edit Parameter",
"param.true": "True",
"param.false": "False",
"param.disabled": "Disabled",
"param.enabled": "Enabled",
// EditParamModal.js
"edit.title": "Edit Parameter",
"edit.param_name": "Parameter Name",
"edit.unknown": "Unknown",
"edit.string_value": "String Value",
"edit.enable_disable": "Enable/Disable:",
"edit.new_value": "New Value",
"edit.value_range": "Value must be between {min} and {max}",
"edit.current_value": "Current Value",
"edit.current_rtttl": "Current RTTTL",
"edit.default_value": "Default Value",
"edit.min_value": "Min Value",
"edit.max_value": "Max Value",
"edit.error_parsing_melody": "Error parsing melody data",
"edit.select_preset": "Select Preset Tune",
"edit.choose_preset": "Choose a preset tune",
"edit.apply": "Apply",
"edit.rtttl_tune": "RTTTL Tune",
"edit.rtttl_placeholder": "Format: name:d=duration,o=octave,b=bpm:notes",
"edit.stop_tune": "Stop tune",
"edit.play_tune": "Play tune",
"edit.rtttl_instruction": "Enter RTTTL format tune or select a preset",
"edit.rtttl_guide_title": "RTTTL Format Guide",
"edit.rtttl_guide_duration": "d=duration (1=whole, 2=half, 4=quarter, 8=eighth, 16=16th note)",
"edit.rtttl_guide_octave": "o=octave (4-7 where 5 is default)",
"edit.rtttl_guide_tempo": "b=tempo (beats per minute)",
"edit.rtttl_guide_notes": "Notes are: c, c#, d, d#, e, f, f#, g, g#, a, a#, b or h",
"edit.rtttl_guide_example": "Example: Beep:d=4,o=5,b=120:c",
"edit.rtttl_warning": "Warning: Invalid RTTTL format! Using a default empty tune instead.",
"edit.rtttl_invalid": "Invalid RTTTL format! Format should be: name:defaults:notes",
"edit.error_saving": "Error saving tune: {error}",
"edit.error_playing": "Error playing tune: {error}",
"edit.cancel": "Cancel",
"edit.save": "Save",
"edit.true": "True",
"edit.false": "False",
// BusMonitor.js
"bus.title": "Bus Monitor",
"bus.auto_scroll": "Auto Scroll",
"bus.export": "Export",
"bus.col_dir": "Dir",
"bus.col_time": "Time",
"bus.col_can_id": "CAN ID",
"bus.col_hex_data": "Hex Data",
"bus.col_src": "Src",
"bus.col_dst": "Dst",
"bus.col_data_type": "Data Type",
"bus.showing": "Showing {count} of max {max} transfers",
"bus.paused": "PAUSED",
"bus.message_details": "Message Details",
"bus.close": "Close",
"bus.broadcast": "Broadcast",
"bus.no_payload": "No detailed payload data available for this transfer.",
"bus.details_heading": "### Message details",
"bus.payload_heading": "### Message Payload",
"bus.detail_direction": "Direction:",
"bus.detail_time": "Time:",
"bus.detail_can_id": "CAN ID:",
"bus.detail_source": "Source Node:",
"bus.detail_dest": "Destination Node:",
"bus.detail_data_type": "Data Type:",
"bus.detail_hex_data": "Hex Data:",
"bus.csv_direction": "Direction",
"bus.csv_timestamp": "Timestamp",
"bus.csv_can_id": "CAN ID (Hex)",
"bus.csv_hex_data": "Hex Data",
"bus.csv_src": "Src Node ID",
"bus.csv_dst": "Dst Node ID",
"bus.csv_data_type": "Data Type",
"bus.csv_raw": "Raw Data",
// SubscriberWindow.js
"sub.not_allowed": "Not Allowed To Open Directly",
"sub.rx": "RX:",
"sub.rates": "Rates(Hz):",
"sub.max": "Max:",
"sub.all": "All",
// EscPanel.js
"esc.channels": "Channels:",
"esc.remove_propellers": "REMOVE PROPELLERS!",
"esc.send_safety": "Send Safety",
"esc.send_arming": "Send Arming",
"esc.broadcast_rate": "Broadcast Rate:",
"esc.index": "Index:",
"esc.error": "Err:",
"esc.temp": "Temp:",
"esc.volt": "Volt:",
"esc.curr": "Curr:",
"esc.rpm": "RPM:",
"esc.rat": "RAT:",
"esc.nc": "NC",
"esc.stop": "Stop",
"esc.cmd": "cmd:",
"esc.stop_all": "Stop All",
// ActuatorPanel.js
"act.ids": "Actuator IDs ({count})",
"act.range_settings": "Range Settings",
"act.broadcast_rate": "Broadcast Rate:",
"act.select_ids_title": "Select Actuator IDs",
"act.select_ids_label": "Select Actuator IDs:",
"act.done": "Done",
"act.range_title": "Command Type Range Settings",
"act.range_instruction": "Configure default ranges for each command type. These settings can be applied to all actuators.",
"act.unitless_fixed": "Unitless command range is fixed at -1 to 1",
"act.min": "Min",
"act.max": "Max",
"act.apply": "Apply",
"act.apply_all": "Apply All Ranges",
"act.close": "Close",
"act.id": "ID:",
"act.pos": "Pos:",
"act.force": "Force:",
"act.speed": "Speed:",
"act.rat": "RAT:",
"act.nc": "NC",
"act.unknown": "unknown",
"act.type_unitless": "Unitless",
"act.type_position": "Position",
"act.type_force": "Force",
"act.type_speed": "Speed",
"act.type_unitless_label": "Unitless [-1, 1]",
"act.type_position_label": "Position (m/rad)",
"act.type_force_label": "Force (N/Nm)",
"act.type_speed_label": "Speed (m/s, rad/s)",
"act.zero": "Zero",
"act.cmd": "cmd:",
"act.zero_all": "Zero All",
// DnaServerModal.js
"dna.title": "Dynamic Node ID Allocation Server",
"dna.active": "Server Active",
"dna.control": "Server Control",
"dna.processing": "Processing...",
"dna.stop": "Stop",
"dna.start": "Start",
"dna.min_node_id": "Min Node ID",
"dna.must_lt_max": "Must be < Max",
"dna.max_node_id": "Max Node ID",
"dna.must_gt_min": "Must be > Min",
"dna.persist": "Persist Allocations",
"dna.persist_tooltip": "When enabled, node ID allocations are stored and restored when the server restarts",
"dna.allocated": "Allocated Node IDs ({count})",
"dna.no_allocations": "No node IDs allocated",
"dna.col_nid": "NID",
"dna.col_uuid": "UUID",
"dna.col_action": "Action",
"dna.refresh_tooltip": "Refresh allocation list",
"dna.started": "DNA server started successfully",
"dna.failed_start": "Failed to start DNA server",
"dna.stopped": "DNA server stopped",
"dna.revoked": "Node ID {id} allocation revoked",
"dna.revoke_failed": "Failed to revoke allocation for node ID {id}",
"dna.refreshed": "Allocations refreshed",
"dna.error": "Error: {error}",
"dna.invalid_range": "Min ID must be less than Max ID",
// NodeProperties.js
"props.title": "Node Properties",
"props.multi_editor": "Multi Node Editor",
"props.node_id": "Node ID",
"props.name": "Name",
"props.mode": "Mode",
"props.health": "Health",
"props.uptime": "Uptime",
"props.vendor_code": "Vendor Specific Status Code",
"props.sw_version": "Software Version",
"props.crc64": "CRC64",
"props.vcs_commit": "VCS Commit",
"props.hw_version": "Hardware Version",
"props.uid": "UID",
"props.certificate": "Cert. of authenticity",
"props.controls": "Node Controls",
"props.restart": "Restart",
"props.transport_stats": "Get Transport Stats",
"props.update_firmware": "Update Firmware",
// NodeLogs.js
"logs.title": "Logs",
"logs.col_nid": "NID",
"logs.col_time": "Time",
"logs.col_level": "Level",
"logs.col_source": "Source",
"logs.col_text": "Text",
// ToolsMenu.js
"tools.title": "Tools",
"tools.subscriber": "Subscriber",
"tools.bus_monitor": "Bus Monitor",
// PanelsMenu.js
"panels.title": "Panels",
"panels.esc": "ESC",
"panels.actuator": "Actuator",
// ConfirmRestartModal.js
"confirm.title": "Confirm Restart",
"confirm.message": "Are you sure you want to restart the node?",
"confirm.cancel": "Cancel",
"confirm.confirm": "Confirm",
// FirmwareUpdateModal.js
"fw.title": "Firmware Update",
"fw.select_hint": "Please select the firmware file (.bin|.hex) to upload to node {id}.",
"fw.select_file": "Select Firmware File",
"fw.selected_file": "Selected File: {name} ({size} bytes)",
"fw.invalid_file": "Invalid file type. Please select a .bin or .hex firmware file.",
"fw.load_failed": "Failed to load firmware file",
"fw.starting": "Starting firmware update...",
"fw.updating": "Updating firmware: {progress}% ({offset}/{total} bytes)",
"fw.success": "Firmware update completed successfully!",
"fw.update_failed": "Update failed: code: {code} {message}",
"fw.start_failed": "Failed to start update: {error}",
"fw.node_unavailable": "Local node not available",
"fw.error_occurred": "An error occurred during the update.",
"fw.cancel": "Cancel",
"fw.update": "Update",
"fw.updating_ellipsis": "Updating...",
"fw.close": "Close",
};
export const zh = {
// App.js
"app.title": "DroneCAN Web Tools",
"app.bus": "总线 {n}",
"app.dna": "DNA",
"app.adapter": "适配器",
"app.connected": "设备连接成功",
"app.disconnected": "设备已断开",
"app.bus_switched": "已切换到 CAN 总线 {bus}",
"app.dna_stopped": "DNA 服务器已停止",
"app.dna_started": "DNA 服务器已启动",
"app.dna_failed": "DNA 服务器启动失败",
// ConnectionSettingsModal.js
"conn.title": "适配器设置",
"conn.connected_serial_slcan": "已通过串口连接 (SLCAN)",
"conn.connected_serial_mavlink": "已通过串口连接 (MAVLink)",
"conn.connected_ws": "已通过 {type} 连接",
"conn.serial_section": "串口连接",
"conn.port": "端口",
"conn.no_ports": "无可用端口",
"conn.baud_rate": "波特率",
"conn.serial_protocol": "串口协议",
"conn.protocol_mavlink": "MAVLink 隧道",
"conn.protocol_slcan": "SLCAN / LAWICEL",
"conn.refresh": "刷新",
"conn.request": "请求",
"conn.disconnect": "断开",
"conn.connecting": "连接中...",
"conn.connect": "连接",
"conn.ws_section": "WebSocket 连接",
"conn.host": "主机/IP 地址",
"conn.ws_port": "端口",
"conn.node_id": "节点 ID",
"conn.signing": "MAVLink 签名",
"conn.secret_key": "密钥",
"conn.show_secret": "显示密钥",
"conn.hide_secret": "隐藏密钥",
"conn.serial_closed": "串口连接已关闭",
"conn.serial_slcan_ok": "SLCAN 串口连接已建立",
"conn.serial_mavlink_ok": "MAVLink 串口连接已建立",
"conn.serial_failed": "串口连接失败: {error}",
"conn.could_not_connect": "无法连接到端口",
"conn.serial_error": "串口错误: {error}",
"conn.unknown_error": "未知错误",
"conn.ws_closed": "WebSocket 连接已关闭",
"conn.ws_connected": "WebSocket 连接已建立",
"conn.ws_failed": "连接失败",
"conn.ws_failed_detail": "连接失败: {error}",
"conn.ws_error": "WebSocket 错误: {error}",
"conn.ip_required": "IP 地址为必填项",
"conn.ip_invalid_parts": "每段必须是 0-255 之间的数字",
"conn.ip_invalid": "无效的 IP 地址或主机名",
"conn.port_required": "端口为必填项",
"conn.port_range": "端口必须在 1-65535 之间",
"conn.node_id_range": "节点 ID 必须在 1-127 之间",
"conn.no_port_selected": "未选择端口",
"conn.serial_port": "串口",
// NodeParam.js
"param.title": "参数",
"param.fetching": "获取中...",
"param.fetch_all": "获取全部",
"param.store_all": "保存全部",
"param.erase_all": "擦除全部",
"param.download": "下载",
"param.load": "加载",
"param.col_idx": "索引",
"param.col_name": "名称",
"param.col_type": "类型",
"param.col_value": "值",
"param.col_default": "默认值",
"param.col_min": "最小值",
"param.col_max": "最大值",
"param.edit_param": "编辑参数",
"param.true": "是",
"param.false": "否",
"param.disabled": "禁用",
"param.enabled": "启用",
// EditParamModal.js
"edit.title": "编辑参数",
"edit.param_name": "参数名称",
"edit.unknown": "未知",
"edit.string_value": "字符串值",
"edit.enable_disable": "启用/禁用:",
"edit.new_value": "新值",
"edit.value_range": "值必须在 {min} 和 {max} 之间",
"edit.current_value": "当前值",
"edit.current_rtttl": "当前 RTTTL",
"edit.default_value": "默认值",
"edit.min_value": "最小值",
"edit.max_value": "最大值",
"edit.error_parsing_melody": "解析旋律数据出错",
"edit.select_preset": "选择预设铃声",
"edit.choose_preset": "选择一个预设铃声",
"edit.apply": "应用",
"edit.rtttl_tune": "RTTTL 铃声",
"edit.rtttl_placeholder": "格式: name:d=duration,o=octave,b=bpm:notes",
"edit.stop_tune": "停止播放",
"edit.play_tune": "播放铃声",
"edit.rtttl_instruction": "输入 RTTTL 格式铃声或选择预设",
"edit.rtttl_guide_title": "RTTTL 格式指南",
"edit.rtttl_guide_duration": "d=时值 (1=全音符, 2=二分, 4=四分, 8=八分, 16=十六分音符)",
"edit.rtttl_guide_octave": "o=八度 (4-7, 默认为 5)",
"edit.rtttl_guide_tempo": "b=速度 (每分钟节拍数)",
"edit.rtttl_guide_notes": "音符: c, c#, d, d#, e, f, f#, g, g#, a, a#, b 或 h",
"edit.rtttl_guide_example": "示例: Beep:d=4,o=5,b=120:c",
"edit.rtttl_warning": "警告: 无效的 RTTTL 格式! 使用默认空铃声代替。",
"edit.rtttl_invalid": "无效的 RTTTL 格式! 格式应为: name:defaults:notes",
"edit.error_saving": "保存铃声出错: {error}",
"edit.error_playing": "播放铃声出错: {error}",
"edit.cancel": "取消",
"edit.save": "保存",
"edit.true": "是",
"edit.false": "否",
// BusMonitor.js
"bus.title": "总线监视器",
"bus.auto_scroll": "自动滚动",
"bus.export": "导出",
"bus.col_dir": "方向",
"bus.col_time": "时间",
"bus.col_can_id": "CAN ID",
"bus.col_hex_data": "十六进制数据",
"bus.col_src": "源",
"bus.col_dst": "目标",
"bus.col_data_type": "数据类型",
"bus.showing": "显示 {count} 条,最大 {max} 条",
"bus.paused": "已暂停",
"bus.message_details": "消息详情",
"bus.close": "关闭",
"bus.broadcast": "广播",
"bus.no_payload": "该传输无详细负载数据。",
"bus.details_heading": "### 消息详情",
"bus.payload_heading": "### 消息负载",
"bus.detail_direction": "方向:",
"bus.detail_time": "时间:",
"bus.detail_can_id": "CAN ID:",
"bus.detail_source": "源节点:",
"bus.detail_dest": "目标节点:",
"bus.detail_data_type": "数据类型:",
"bus.detail_hex_data": "十六进制数据:",
"bus.csv_direction": "方向",
"bus.csv_timestamp": "时间戳",
"bus.csv_can_id": "CAN ID (十六进制)",
"bus.csv_hex_data": "十六进制数据",
"bus.csv_src": "源节点 ID",
"bus.csv_dst": "目标节点 ID",
"bus.csv_data_type": "数据类型",
"bus.csv_raw": "原始数据",
// SubscriberWindow.js
"sub.not_allowed": "不允许直接打开",
"sub.rx": "接收:",
"sub.rates": "频率(Hz):",
"sub.max": "最大:",
"sub.all": "全部",
// EscPanel.js
"esc.channels": "通道数:",
"esc.remove_propellers": "请拆除螺旋桨!",
"esc.send_safety": "发送安全指令",
"esc.send_arming": "发送解锁指令",
"esc.broadcast_rate": "广播速率:",
"esc.index": "索引:",
"esc.error": "错误:",
"esc.temp": "温度:",
"esc.volt": "电压:",
"esc.curr": "电流:",
"esc.rpm": "转速:",
"esc.rat": "功率:",
"esc.nc": "无连接",
"esc.stop": "停止",
"esc.cmd": "指令:",
"esc.stop_all": "全部停止",
// ActuatorPanel.js
"act.ids": "执行器 ID ({count})",
"act.range_settings": "范围设置",
"act.broadcast_rate": "广播速率:",
"act.select_ids_title": "选择执行器 ID",
"act.select_ids_label": "选择执行器 ID:",
"act.done": "完成",
"act.range_title": "指令类型范围设置",
"act.range_instruction": "配置每种指令类型的默认范围。这些设置可以应用到所有执行器。",
"act.unitless_fixed": "无量纲指令范围固定为 -1 到 1",
"act.min": "最小值",
"act.max": "最大值",
"act.apply": "应用",
"act.apply_all": "应用所有范围",
"act.close": "关闭",
"act.id": "ID:",
"act.pos": "位置:",
"act.force": "力:",
"act.speed": "速度:",
"act.rat": "功率:",
"act.nc": "无连接",
"act.unknown": "未知",
"act.type_unitless": "无量纲",
"act.type_position": "位置",
"act.type_force": "力",
"act.type_speed": "速度",
"act.type_unitless_label": "无量纲 [-1, 1]",
"act.type_position_label": "位置 (m/rad)",
"act.type_force_label": "力 (N/Nm)",
"act.type_speed_label": "速度 (m/s, rad/s)",
"act.zero": "归零",
"act.cmd": "指令:",
"act.zero_all": "全部归零",
// DnaServerModal.js
"dna.title": "动态节点 ID 分配服务器",
"dna.active": "服务器运行中",
"dna.control": "服务器控制",
"dna.processing": "处理中...",
"dna.stop": "停止",
"dna.start": "启动",
"dna.min_node_id": "最小节点 ID",
"dna.must_lt_max": "必须小于最大值",
"dna.max_node_id": "最大节点 ID",
"dna.must_gt_min": "必须大于最小值",
"dna.persist": "持久化分配",
"dna.persist_tooltip": "启用后,节点 ID 分配将被保存,服务器重启时自动恢复",
"dna.allocated": "已分配节点 ID ({count})",
"dna.no_allocations": "暂无已分配的节点 ID",
"dna.col_nid": "节点ID",
"dna.col_uuid": "UUID",
"dna.col_action": "操作",
"dna.refresh_tooltip": "刷新分配列表",
"dna.started": "DNA 服务器启动成功",
"dna.failed_start": "DNA 服务器启动失败",
"dna.stopped": "DNA 服务器已停止",
"dna.revoked": "节点 ID {id} 分配已撤销",
"dna.revoke_failed": "撤销节点 ID {id} 分配失败",
"dna.refreshed": "分配列表已刷新",
"dna.error": "错误: {error}",
"dna.invalid_range": "最小 ID 必须小于最大 ID",
// NodeProperties.js
"props.title": "节点属性",
"props.multi_editor": "多节点编辑器",
"props.node_id": "节点 ID",
"props.name": "名称",
"props.mode": "模式",
"props.health": "健康状态",
"props.uptime": "运行时间",
"props.vendor_code": "供应商状态码",
"props.sw_version": "软件版本",
"props.crc64": "CRC64",
"props.vcs_commit": "VCS 提交",
"props.hw_version": "硬件版本",
"props.uid": "UID",
"props.certificate": "认证证书",
"props.controls": "节点控制",
"props.restart": "重启",
"props.transport_stats": "获取传输统计",
"props.update_firmware": "更新固件",
// NodeLogs.js
"logs.title": "日志",
"logs.col_nid": "节点ID",
"logs.col_time": "时间",
"logs.col_level": "级别",
"logs.col_source": "来源",
"logs.col_text": "内容",
// ToolsMenu.js
"tools.title": "工具",
"tools.subscriber": "订阅器",
"tools.bus_monitor": "总线监视器",
// PanelsMenu.js
"panels.title": "面板",
"panels.esc": "ESC",
"panels.actuator": "执行器",
// ConfirmRestartModal.js
"confirm.title": "确认重启",
"confirm.message": "确定要重启该节点吗?",
"confirm.cancel": "取消",
"confirm.confirm": "确认",
// FirmwareUpdateModal.js
"fw.title": "固件更新",
"fw.select_hint": "请选择要上传到节点 {id} 的固件文件 (.bin|.hex)。",
"fw.select_file": "选择固件文件",
"fw.selected_file": "已选文件: {name} ({size} 字节)",
"fw.invalid_file": "无效的文件类型。请选择 .bin 或 .hex 固件文件。",
"fw.load_failed": "加载固件文件失败",
"fw.starting": "正在开始固件更新...",
"fw.updating": "正在更新固件: {progress}% ({offset}/{total} 字节)",
"fw.success": "固件更新成功完成!",
"fw.update_failed": "更新失败: 代码: {code} {message}",
"fw.start_failed": "启动更新失败: {error}",
"fw.node_unavailable": "本地节点不可用",
"fw.error_occurred": "更新过程中发生错误。",
"fw.cancel": "取消",
"fw.update": "更新",
"fw.updating_ellipsis": "更新中...",
"fw.close": "关闭",
};
+270
View File
@@ -0,0 +1,270 @@
/*!
* Copyright © 2008 Fair Oaks Labs, Inc.
* All rights reserved.
*/
// Utility object: Encode/Decode C-style binary primitives to/from octet arrays
function JSPack()
{
// Module-level (private) variables
var el, bBE = false, m = this;
// Raw byte arrays
m._DeArray = function (a, p, l)
{
return [a.slice(p,p+l)];
};
m._EnArray = function (a, p, l, v)
{
for (var i = 0; i < l; a[p+i] = v[i]?v[i]:0, i++);
};
// ASCII characters
m._DeChar = function (a, p)
{
return String.fromCharCode(a[p]);
};
m._EnChar = function (a, p, v)
{
a[p] = v.charCodeAt(0);
};
// Little-endian (un)signed N-byte integers
m._DeInt = function (a, p)
{
var lsb = bBE?(el.len-1):0, nsb = bBE?-1:1, stop = lsb+nsb*el.len, rv, i, f;
for (rv = 0, i = lsb, f = 1; i != stop; rv+=(a[p+i]*f), i+=nsb, f*=256);
if (el.bSigned && (rv & Math.pow(2, el.len*8-1))) { rv -= Math.pow(2, el.len*8); }
return rv;
};
m._EnInt = function (a, p, v)
{
var lsb = bBE?(el.len-1):0, nsb = bBE?-1:1, stop = lsb+nsb*el.len, i;
v = (v<el.min)?el.min:(v>el.max)?el.max:v;
for (i = lsb; i != stop; a[p+i]=v&0xff, i+=nsb, v>>=8);
};
// ASCII character strings
m._DeString = function (a, p, l)
{
for (var rv = new Array(l), i = 0; i < l; rv[i] = String.fromCharCode(a[p+i]), i++);
return rv.join('');
};
m._EnString = function (a, p, l, v)
{
for (var t, i = 0; i < l; a[p+i] = (t=v.charCodeAt(i))?t:0, i++);
};
// Little-endian N-bit IEEE 754 floating point
m._De754 = function (a, p)
{
var s, e, m, i, d, nBits, mLen, eLen, eBias, eMax;
mLen = el.mLen, eLen = el.len*8-el.mLen-1, eMax = (1<<eLen)-1, eBias = eMax>>1;
i = bBE?0:(el.len-1); d = bBE?1:-1; s = a[p+i]; i+=d; nBits = -7;
for (e = s&((1<<(-nBits))-1), s>>=(-nBits), nBits += eLen; nBits > 0; e=e*256+a[p+i], i+=d, nBits-=8);
for (m = e&((1<<(-nBits))-1), e>>=(-nBits), nBits += mLen; nBits > 0; m=m*256+a[p+i], i+=d, nBits-=8);
switch (e)
{
case 0:
// Zero, or denormalized number
e = 1-eBias;
break;
case eMax:
// NaN, or +/-Infinity
return m?NaN:((s?-1:1)*Infinity);
default:
// Normalized number
m = m + Math.pow(2, mLen);
e = e - eBias;
break;
}
return (s?-1:1) * m * Math.pow(2, e-mLen);
};
m._En754 = function (a, p, v)
{
var s, e, m, i, d, c, mLen, eLen, eBias, eMax;
mLen = el.mLen, eLen = el.len*8-el.mLen-1, eMax = (1<<eLen)-1, eBias = eMax>>1;
s = v<0?1:0;
v = Math.abs(v);
if (isNaN(v) || (v == Infinity))
{
m = isNaN(v)?1:0;
e = eMax;
}
else
{
e = Math.floor(Math.log(v)/Math.LN2); // Calculate log2 of the value
if (v*(c = Math.pow(2, -e)) < 1) { e--; c*=2; } // Math.log() isn't 100% reliable
// Round by adding 1/2 the significand's LSD
if (e+eBias >= 1) { v += el.rt/c; } // Normalized: mLen significand digits
else { v += el.rt*Math.pow(2, 1-eBias); } // Denormalized: <= mLen significand digits
if (v*c >= 2) { e++; c/=2; } // Rounding can increment the exponent
if (e+eBias >= eMax)
{
// Overflow
m = 0;
e = eMax;
}
else if (e+eBias >= 1)
{
// Normalized - term order matters, as Math.pow(2, 52-e) and v*Math.pow(2, 52) can overflow
m = (v*c-1)*Math.pow(2, mLen);
e = e + eBias;
}
else
{
// Denormalized - also catches the '0' case, somewhat by chance
m = v*Math.pow(2, eBias-1)*Math.pow(2, mLen);
e = 0;
}
}
for (i = bBE?(el.len-1):0, d=bBE?-1:1; mLen >= 8; a[p+i]=m&0xff, i+=d, m/=256, mLen-=8);
for (e=(e<<mLen)|m, eLen+=mLen; eLen > 0; a[p+i]=e&0xff, i+=d, e/=256, eLen-=8);
a[p+i-d] |= s*128;
};
// Convert int64 to array with 3 elements: [lowBits, highBits, unsignedFlag]
// '>>>' trick to convert signed 32bit int to unsigned int (because << always results in a signed 32bit int)
m._DeInt64 = function (a, p) {
var start = bBE ? 0 : 7, nsb = bBE ? 1 : -1, stop = start + nsb * 8, rv = [0,0, !el.bSigned], i, f, rvi;
for (i = start, rvi = 1, f = 0;
i != stop;
rv[rvi] = (((rv[rvi]<<8)>>>0) + a[p + i]), i += nsb, f++, rvi = (f < 4 ? 1 : 0));
return rv;
};
m._EnInt64 = function (a, p, v) {
var start = bBE ? 0 : 7, nsb = bBE ? 1 : -1, stop = start + nsb * 8, i, f, rvi, s;
for (i = start, rvi = 1, f = 0, s = 24;
i != stop;
a[p + i] = v[rvi]>>s & 0xff, i += nsb, f++, rvi = (f < 4 ? 1 : 0), s = 24 - (8 * (f % 4)));
};
// Class data
m._sPattern = '(\\d+)?([AxcbBhHsfdiIlLqQ])';
m._lenLut = {'A':1, 'x':1, 'c':1, 'b':1, 'B':1, 'h':2, 'H':2, 's':1, 'f':4, 'd':8, 'i':4, 'I':4, 'l':4, 'L':4, 'q':8, 'Q':8};
m._elLut = { 'A': {en:m._EnArray, de:m._DeArray},
's': {en:m._EnString, de:m._DeString},
'c': {en:m._EnChar, de:m._DeChar},
'b': {en:m._EnInt, de:m._DeInt, len:1, bSigned:true, min:-Math.pow(2, 7), max:Math.pow(2, 7)-1},
'B': {en:m._EnInt, de:m._DeInt, len:1, bSigned:false, min:0, max:Math.pow(2, 8)-1},
'h': {en:m._EnInt, de:m._DeInt, len:2, bSigned:true, min:-Math.pow(2, 15), max:Math.pow(2, 15)-1},
'H': {en:m._EnInt, de:m._DeInt, len:2, bSigned:false, min:0, max:Math.pow(2, 16)-1},
'i': {en:m._EnInt, de:m._DeInt, len:4, bSigned:true, min:-Math.pow(2, 31), max:Math.pow(2, 31)-1},
'I': {en:m._EnInt, de:m._DeInt, len:4, bSigned:false, min:0, max:Math.pow(2, 32)-1},
'l': {en:m._EnInt, de:m._DeInt, len:4, bSigned:true, min:-Math.pow(2, 31), max:Math.pow(2, 31)-1},
'L': {en:m._EnInt, de:m._DeInt, len:4, bSigned:false, min:0, max:Math.pow(2, 32)-1},
'f': {en:m._En754, de:m._De754, len:4, mLen:23, rt:Math.pow(2, -24)-Math.pow(2, -77)},
'd': {en:m._En754, de:m._De754, len:8, mLen:52, rt:0},
'q': {en:m._EnInt64, de:m._DeInt64, bSigned:true},
'Q': {en:m._EnInt64, de:m._DeInt64, bSigned:false}};
// Unpack a series of n elements of size s from array a at offset p with fxn
m._UnpackSeries = function (n, s, a, p)
{
for (var fxn = el.de, rv = [], i = 0; i < n; rv.push(fxn(a, p+i*s)), i++);
return rv;
};
// Pack a series of n elements of size s from array v at offset i to array a at offset p with fxn
m._PackSeries = function (n, s, a, p, v, i)
{
for (var fxn = el.en, o = 0; o < n; fxn(a, p+o*s, v[i+o]), o++);
};
// Unpack the octet array a, beginning at offset p, according to the fmt string
m.Unpack = function (fmt, a, p)
{
// Set the private bBE flag based on the format string - assume big-endianness
bBE = (fmt.charAt(0) != '<');
p = p?p:0;
var re = new RegExp(this._sPattern, 'g'), m, n, s, rv = [];
while (m = re.exec(fmt))
{
n = ((m[1]==undefined)||(m[1]==''))?1:parseInt(m[1]);
s = this._lenLut[m[2]];
if ((p + n*s) > a.length)
{
return undefined;
}
switch (m[2])
{
case 'A': case 's':
rv.push(this._elLut[m[2]].de(a, p, n));
break;
case 'c': case 'b': case 'B': case 'h': case 'H':
case 'i': case 'I': case 'l': case 'L': case 'f': case 'd': case 'q': case 'Q':
el = this._elLut[m[2]];
rv.push(this._UnpackSeries(n, s, a, p));
break;
}
p += n*s;
}
return Array.prototype.concat.apply([], rv);
};
// Pack the supplied values into the octet array a, beginning at offset p, according to the fmt string
m.PackTo = function (fmt, a, p, values)
{
// Set the private bBE flag based on the format string - assume big-endianness
bBE = (fmt.charAt(0) != '<');
var re = new RegExp(this._sPattern, 'g'), m, n, s, i = 0, j;
while (m = re.exec(fmt))
{
n = ((m[1]==undefined)||(m[1]==''))?1:parseInt(m[1]);
s = this._lenLut[m[2]];
if ((p + n*s) > a.length)
{
return false;
}
switch (m[2])
{
case 'A': case 's':
if ((i + 1) > values.length) { return false; }
this._elLut[m[2]].en(a, p, n, values[i]);
i += 1;
break;
case 'c': case 'b': case 'B': case 'h': case 'H':
case 'i': case 'I': case 'l': case 'L': case 'f': case 'd': case 'q': case 'Q':
el = this._elLut[m[2]];
if ((i + n) > values.length) { return false; }
this._PackSeries(n, s, a, p, values, i);
i += n;
break;
case 'x':
for (j = 0; j < n; j++) { a[p+j] = 0; }
break;
}
p += n*s;
}
return a;
};
// Pack the supplied values into a new octet array, according to the fmt string
m.Pack = function (fmt, values)
{
return this.PackTo(fmt, new Array(this.CalcLength(fmt)), 0, values);
};
// Determine the number of bytes represented by the format string
m.CalcLength = function (fmt)
{
var re = new RegExp(this._sPattern, 'g'), m, sum = 0;
while (m = re.exec(fmt))
{
sum += (((m[1]==undefined)||(m[1]==''))?1:parseInt(m[1])) * this._lenLut[m[2]];
}
return sum;
};
};
exports.jspack = new JSPack();
+1156 -8088
View File
File diff suppressed because it is too large Load Diff
+88 -7
View File
@@ -1,8 +1,9 @@
import { EventEmitter } from 'events';
import WebSocketClient from './ws_client';
import WebSerial from './web_serial';
import { mavlink20, MAVLink20Processor } from './mavlink';
import SlcanCodec from './slcan';
import dronecan from './dronecan';
import './mavlink';
class MavlinkSession extends EventEmitter {
constructor() {
@@ -12,6 +13,8 @@ class MavlinkSession extends EventEmitter {
this.mavlinkProcessor = new MAVLink20Processor(null, this.targetSystem, this.targetComponent);
this.wsClient = null;
this.serial = null;
this.serialProtocol = 'mavlink';
this.slcanCodec = null;
this.parseBuffer = this.parseBuffer.bind(this);
}
@@ -27,8 +30,20 @@ class MavlinkSession extends EventEmitter {
}
}
initWebSocketConnection(ip, port) {
initWebSocketConnection(ip, port, mavlinkSigning='') {
this.serialProtocol = 'mavlink';
this.slcanCodec = null;
if (mavlinkSigning) {
const enc = new TextEncoder();
const data = enc.encode(mavlinkSigning);
const hash = mavlink20.sha256(data);
this.mavlinkProcessor.signing.secret_key = new Uint8Array(hash);
this.mavlinkProcessor.signing.sign_outgoing = true;
this.wsClient = new WebSocketClient(`wss://${ip}:${port}`);
} else {
this.wsClient = new WebSocketClient(`ws://${ip}:${port}`);
}
this.mavlinkProcessor.file = this.wsClient;
this.wsClient.addMessageHandler((buffer) => {
@@ -40,13 +55,21 @@ class MavlinkSession extends EventEmitter {
this.wsClient.connect();
}
initWebSerialConnection(port, baudRate) {
initWebSerialConnection(port, baudRate, options = {}) {
this.serial = new WebSerial(port, baudRate);
this.serialProtocol = options.protocol || 'mavlink';
this.slcanCodec = this.serialProtocol === 'slcan' ? new SlcanCodec() : null;
if (this.serialProtocol === 'mavlink') {
this.mavlinkProcessor.file = this.serial;
}
this.serial.addMessageHandler((buffer) => {
// console.log('Received buffer:', buffer);
if (this.serialProtocol === 'slcan') {
this.parseSlcanBuffer(buffer);
} else {
this.parseBuffer(buffer);
}
});
}
@@ -63,7 +86,11 @@ class MavlinkSession extends EventEmitter {
}
webSerialConnect() {
this.serial.connect();
this.serial.connect().then(() => {
if (this.serialProtocol === 'slcan') {
this.openSlcanChannel();
}
});
}
close() {
@@ -75,6 +102,17 @@ class MavlinkSession extends EventEmitter {
}
}
openSlcanChannel() {
if (!this.serial || this.serialProtocol !== 'slcan') return;
console.log('SLCAN: opening CAN channel');
setTimeout(() => {
if (this.serial && this.serial.connected) {
this.serial.write('O\r');
}
}, 0);
}
parseBuffer(buffer) {
// console.log('Parsing buffer:', buffer);
const messages = this.mavlinkProcessor.parseBuffer(buffer);
@@ -85,6 +123,21 @@ class MavlinkSession extends EventEmitter {
}
}
parseSlcanBuffer(buffer) {
this.slcanCodec.feed(
buffer,
({ id, data, len }) => {
console.log('SLCAN RX frame:', { id: id.toString(16), len, data });
if (typeof localNode !== 'undefined' && localNode) {
localNode.emit('can-frame', (id | dronecan.TransferManager.FlagEFF) >>> 0, data, len);
}
if (this.listenerCount('mav-rx') > 0) {
this.emit('mav-rx', { protocol: 'slcan', id, data, len });
}
}
);
}
handleMavlinkMsg(message) {
switch (message._id) {
case mavlink20.MAVLINK_MSG_ID_HEARTBEAT:
@@ -110,21 +163,49 @@ class MavlinkSession extends EventEmitter {
default:
}
if (this.getMaxListeners('mav-rx') > 0) {
if (this.listenerCount('mav-rx') > 0) {
this.emit('mav-rx', message);
}
}
sendMavlinkMsg(msg) {
if (this.serialProtocol === 'slcan') return;
if ((this.wsClient && this.wsClient.connected) || (this.serial && this.serial.connected)) {
this.mavlinkProcessor.send(msg);
if (this.getMaxListeners('mav-tx') > 0) {
if (this.listenerCount('mav-tx') > 0) {
this.emit('mav-tx', msg);
}
}
}
sendCanFrame(bus, messageId, data, len) {
if (this.serialProtocol === 'slcan') {
if (this.serial && this.serial.connected && this.slcanCodec) {
const line = this.slcanCodec.encodeExtendedFrame(messageId, data, len);
console.log('SLCAN TX frame:', line.trim());
this.serial.write(line);
if (this.listenerCount('mav-tx') > 0) {
this.emit('mav-tx', { protocol: 'slcan', id: messageId, data, len });
}
}
return;
}
const msg = new mavlink20.messages.can_frame(
this.targetSystem,
this.targetComponent,
bus,
len,
messageId,
data.toString('binary')
);
this.sendMavlinkMsg(msg);
}
enableMavlinkCanForward(bus) {
if (this.serialProtocol === 'slcan') return;
// console.log('Enabling CAN forward on bus:', bus);
const msg = new mavlink20.messages.command_long(
this.targetSystem, // target_system
+132
View File
@@ -0,0 +1,132 @@
class SlcanCodec {
constructor() {
this.buffer = '';
this.decoder = new TextDecoder('ascii');
}
feed(chunk, onFrame, onError) {
const text = this.decoder.decode(chunk, { stream: true });
if (text.includes('\x07')) {
console.warn('SLCAN adapter returned NACK');
}
this.buffer += text.replace(/\x07/g, '');
this.parseBuffer(onFrame);
}
parseBuffer(onFrame) {
while (this.buffer.length > 0) {
this.buffer = this.buffer.replace(/^[\r\n]+/, '');
if (!this.buffer) return;
const frameStart = this.buffer.search(/[TtRr]/);
if (frameStart === -1) {
this.buffer = '';
return;
}
if (frameStart > 0) {
this.buffer = this.buffer.slice(frameStart);
}
const type = this.buffer[0];
if (type === 'T') {
if (this.buffer.length < 10) return;
const dlcText = this.buffer[9];
if (!/^[0-8]$/.test(dlcText)) {
console.warn(`Malformed SLCAN frame header: ${this.buffer.slice(0, 10)}`);
this.buffer = this.buffer.slice(1);
continue;
}
const dlc = parseInt(dlcText, 16);
const frameLength = 10 + dlc * 2;
if (this.buffer.length < frameLength) return;
const line = this.buffer.slice(0, frameLength);
this.buffer = this.buffer.slice(frameLength).replace(/^[\r\n]+/, '');
this.parseLine(line, onFrame);
continue;
}
if (type === 't') {
if (this.buffer.length < 5) return;
const dlcText = this.buffer[4];
if (!/^[0-8]$/.test(dlcText)) {
this.buffer = this.buffer.slice(1);
continue;
}
const frameLength = 5 + parseInt(dlcText, 16) * 2;
if (this.buffer.length < frameLength) return;
this.buffer = this.buffer.slice(frameLength).replace(/^[\r\n]+/, '');
continue;
}
const lineEndIndex = this.findLineEnd();
if (lineEndIndex === -1) return;
this.buffer = this.buffer.slice(lineEndIndex + 1);
}
}
findLineEnd() {
const crIndex = this.buffer.indexOf('\r');
const lfIndex = this.buffer.indexOf('\n');
if (crIndex === -1) return lfIndex;
if (lfIndex === -1) return crIndex;
return Math.min(crIndex, lfIndex);
}
parseLine(line, onFrame) {
if (!line) return;
const type = line[0];
if (type === 't' || type === 'r' || type === 'R') return;
if (type !== 'T') return;
if (line.length < 10) {
console.warn(`Malformed SLCAN frame: ${line}`);
return;
}
const idText = line.slice(1, 9);
const dlcText = line[9];
if (!/^[0-9a-fA-F]{8}$/.test(idText) || !/^[0-8]$/.test(dlcText)) {
console.warn(`Malformed SLCAN frame: ${line}`);
return;
}
const dlc = parseInt(dlcText, 16);
const dataText = line.slice(10, 10 + dlc * 2);
if (dataText.length !== dlc * 2 || !/^[0-9a-fA-F]*$/.test(dataText)) {
console.warn(`Malformed SLCAN payload: ${line}`);
return;
}
const data = new Uint8Array(dlc);
for (let i = 0; i < dlc; i++) {
data[i] = parseInt(dataText.slice(i * 2, i * 2 + 2), 16);
}
onFrame?.({
id: parseInt(idText, 16) & 0x1FFFFFFF,
data,
len: dlc,
});
}
encodeExtendedFrame(messageId, data, len) {
const canId = messageId & 0x1FFFFFFF;
const frameLength = Math.min(len, data.length ?? len);
if (frameLength > 8) {
throw new Error('SLCAN classic CAN frames support a maximum DLC of 8');
}
let dataHex = '';
for (let i = 0; i < frameLength; i++) {
dataHex += data[i].toString(16).toUpperCase().padStart(2, '0');
}
return `T${canId.toString(16).toUpperCase().padStart(8, '0')}${frameLength.toString(16).toUpperCase()}${dataHex}\r`;
}
}
export default SlcanCodec;
+199 -61
View File
@@ -1,49 +1,164 @@
import { createTheme } from '@mui/material/styles';
const canvas = '#faf9f5';
const surfaceSoft = '#f5f0e8';
const surfaceCard = '#efe9de';
const surfaceCreamStrong = '#e8e0d2';
const surfaceDark = '#181715';
const surfaceDarkElevated = '#252320';
const surfaceDarkSoft = '#1f1e1b';
const hairline = '#e6dfd8';
const hairlineSoft = '#ebe6df';
const ink = '#141413';
const body = '#3d3d3a';
const muted = '#6c6a64';
const mutedSoft = '#8e8b82';
const primary = '#cc785c';
const primaryActive = '#a9583e';
const primaryDisabled = '#e6dfd8';
const onPrimary = '#ffffff';
const success = '#5db872';
const warning = '#d4a017';
const error = '#c64545';
const accentTeal = '#5db8a6';
const theme = createTheme({
palette: {
mode: 'dark',
mode: 'light',
primary: {
main: primary,
dark: primaryActive,
light: '#d89278',
contrastText: onPrimary,
},
secondary: {
main: accentTeal,
},
success: {
main: success,
},
warning: {
main: warning,
},
error: {
main: error,
},
background: {
default: canvas,
paper: surfaceCard,
},
text: {
primary: '#ffffff', // Set the primary text color to white
primary: ink,
secondary: body,
disabled: mutedSoft,
},
divider: hairline,
},
shape: {
borderRadius: 12,
},
// 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
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: 13,
htmlFontSize: 14,
h1: {
fontSize: '2rem', // 24px
fontFamily: 'Cormorant Garamond, Georgia, serif',
fontSize: '2rem',
fontWeight: 400,
letterSpacing: '-0.04em',
},
h2: {
fontSize: '1.75rem', // 21px
fontFamily: 'Cormorant Garamond, Georgia, serif',
fontSize: '1.75rem',
fontWeight: 400,
letterSpacing: '-0.03em',
},
h3: {
fontSize: '1.5rem', // 18px
fontFamily: 'Cormorant Garamond, Georgia, serif',
fontSize: '1.5rem',
fontWeight: 400,
letterSpacing: '-0.02em',
},
h4: {
fontSize: '1.25rem', // 15px
fontFamily: 'Cormorant Garamond, Georgia, serif',
fontSize: '1.25rem',
fontWeight: 400,
letterSpacing: '-0.02em',
},
h5: {
fontSize: '1.1rem', // 13.2px
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: '1.05rem',
fontWeight: 500,
},
h6: {
fontSize: '1rem', // 12px
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: '0.95rem',
fontWeight: 500,
},
body1: {
fontSize: '0.875rem', // 10.5px
fontSize: '0.9rem',
},
body2: {
fontSize: '0.825rem', // 9.9px
fontSize: '0.82rem',
},
button: {
fontSize: '0.825rem', // 9.9px
fontSize: '0.82rem',
fontWeight: 500,
textTransform: 'none',
},
caption: {
fontSize: '0.75rem', // 9px
fontSize: '0.75rem',
},
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundColor: canvas,
color: ink,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: canvas,
color: ink,
backgroundImage: 'none',
borderBottom: `1px solid ${hairline}`,
boxShadow: 'none',
},
},
},
MuiToolbar: {
styleOverrides: {
root: {
minHeight: 64,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
backgroundColor: surfaceCard,
color: ink,
border: `1px solid ${hairline}`,
},
elevation1: {
boxShadow: '0 1px 3px rgba(20, 20, 19, 0.08)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundImage: 'none',
backgroundColor: surfaceCard,
border: `1px solid ${hairline}`,
},
},
},
MuiButton: {
defaultProps: {
size: 'small',
@@ -51,19 +166,55 @@ const theme = createTheme({
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: 8,
boxShadow: 'none',
},
containedPrimary: {
backgroundColor: primary,
color: onPrimary,
'&:hover': {
backgroundColor: primaryActive,
boxShadow: 'none',
},
},
outlined: {
borderColor: hairline,
color: ink,
backgroundColor: canvas,
'&:hover': {
backgroundColor: surfaceSoft,
borderColor: hairline,
},
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
color: ink,
},
},
},
// Add table cell specific overrides
MuiTableCell: {
styleOverrides: {
root: {
fontSize: '0.8rem',
padding: '4px 8px',
padding: '6px 10px',
borderBottom: `1px solid ${hairlineSoft}`,
},
head: {
fontWeight: 'bold',
fontSize: '0.8rem',
fontWeight: 600,
color: muted,
backgroundColor: surfaceSoft,
},
},
},
MuiTableRow: {
styleOverrides: {
root: {
'&:hover': {
backgroundColor: surfaceSoft,
},
},
},
},
@@ -74,25 +225,19 @@ const theme = createTheme({
styleOverrides: {
root: {
'& .MuiInputBase-root': {
fontSize: '0.8rem',
fontSize: '0.82rem',
backgroundColor: canvas,
},
'& .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',
borderRadius: 8,
},
},
},
},
MuiTable: {
MuiFormControl: {
defaultProps: {
size: 'small',
},
@@ -102,62 +247,55 @@ const theme = createTheme({
size: 'small',
},
},
MuiFormControl: {
defaultProps: {
size: 'small',
},
},
MuiInputLabel: {
defaultProps: {
size: 'small',
},
},
MuiIconButton: {
defaultProps: {
size: 'small',
MuiMenuItem: {
styleOverrides: {
root: {
fontSize: '0.85rem',
},
},
MuiFab: {
defaultProps: {
size: 'small',
},
MuiDivider: {
styleOverrides: {
root: {
borderColor: hairline,
},
},
MuiCheckbox: {
defaultProps: {
size: 'small',
},
MuiDialog: {
styleOverrides: {
paper: {
backgroundColor: surfaceCard,
border: `1px solid ${hairline}`,
boxShadow: '0 12px 32px rgba(20, 20, 19, 0.12)',
},
MuiRadio: {
defaultProps: {
size: 'small',
},
},
MuiSwitch: {
defaultProps: {
size: 'small',
},
},
MuiDialogTitle: {
styleOverrides: {
root: {
fontSize: '1.25rem', // Set your desired font size here
color: ink,
},
},
},
MuiDialogContent: {
styleOverrides: {
root: {
color: body,
},
},
},
MuiTypography: {
styleOverrides: {
root: {
color: '#ffffff', // Set the default text color to white
color: ink,
},
},
},
AppBar: {
styleOverrides: {
defaultProps: {
size: 'small',
},
}
}
},
});
+1
View File
@@ -12,6 +12,7 @@ class WebSocketClient {
connect() {
// Reset connected status before attempting a new connection
this.connected = false;
console.log(`Connecting to WebSocket at ${this.url}`);
this.socket = new WebSocket(this.url);
this.socket.addEventListener('open', (event) => {