Compare commits
14 Commits
c3c4eb64f0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d9f5687dd | |||
| 43d8563b79 | |||
| 1850c58197 | |||
| a02925dfd0 | |||
| e612c852e5 | |||
| 39b5edb585 | |||
| 71345419d5 | |||
| fe841fb0ba | |||
| 1cd3badb2d | |||
| b50cb503a8 | |||
| 8e06ca76ca | |||
| 783b901fe7 | |||
| ae31462434 | |||
| 7bffe34f54 |
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.claude
|
||||
npm-debug.log*
|
||||
Dockerfile*
|
||||
@@ -4,3 +4,4 @@ dist
|
||||
mav.parm
|
||||
mav.tlog
|
||||
mav.tlog.raw
|
||||
deploy.sh
|
||||
@@ -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.
|
||||
@@ -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 | 768–1024px | Top nav stays horizontal but tightens; feature cards 2-up; connector tiles 3-up; pricing 2-up |
|
||||
| Desktop | 1024–1440px | 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
@@ -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;"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Generated
+151
-75
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
+155
-113
@@ -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);
|
||||
|
||||
if (serialProtocol === SERIAL_PROTOCOLS.MAVLINK) {
|
||||
const intervalId = setInterval(() => {
|
||||
if (window.mavlinkSession) {
|
||||
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
|
||||
}
|
||||
}, 1000);
|
||||
setForwardingInterval(intervalId);
|
||||
} else {
|
||||
setForwardingInterval(null);
|
||||
}
|
||||
|
||||
// Start the mavlinkCanForward interval
|
||||
const intervalId = setInterval(() => {
|
||||
if (window.mavlinkSession) {
|
||||
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setForwardingInterval(intervalId);
|
||||
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"
|
||||
@@ -667,23 +686,23 @@ const ConnectionSettingsModal = ({
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={handleWebSocketConnect}
|
||||
color={activeConnection === 'websocket' ? "error" : "primary"}
|
||||
variant="contained"
|
||||
disabled={
|
||||
connectionInProgress || // Disable when connection attempt is in progress
|
||||
!!ipError ||
|
||||
!!portError ||
|
||||
!wsHost ||
|
||||
!wsPort ||
|
||||
(activeConnection !== null && activeConnection !== 'websocket')
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
{activeConnection === 'websocket' ? 'Disconnect' :
|
||||
connectionInProgress && !activeConnection ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleWebSocketConnect}
|
||||
color={activeConnection === 'websocket' ? "error" : "primary"}
|
||||
variant="contained"
|
||||
disabled={
|
||||
connectionInProgress || // Disable when connection attempt is in progress
|
||||
!!hostError ||
|
||||
!!portError ||
|
||||
!wsHost ||
|
||||
!wsPort ||
|
||||
(activeConnection !== null && activeConnection !== 'websocket')
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
{activeConnection === 'websocket' ? t('conn.disconnect') :
|
||||
connectionInProgress && !activeConnection ? t('conn.connecting') : t('conn.connect')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+112
-2
@@ -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);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
// Read as text for .hex files, as binary for others
|
||||
if (isHexFile) {
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+22
-20
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -5,8 +5,9 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: rgb(22, 13, 13);
|
||||
background-color: #faf9f5;
|
||||
overflow: hidden;
|
||||
color: #141413;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "关闭",
|
||||
};
|
||||
@@ -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();
|
||||
+1187
-8119
File diff suppressed because it is too large
Load Diff
+91
-10
@@ -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) {
|
||||
this.wsClient = new WebSocketClient(`ws://${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.mavlinkProcessor.file = this.serial;
|
||||
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);
|
||||
this.parseBuffer(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
@@ -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;
|
||||
+202
-64
@@ -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',
|
||||
text: {
|
||||
primary: '#ffffff', // Set the primary text color to white
|
||||
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: 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',
|
||||
},
|
||||
},
|
||||
MuiRadio: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: surfaceCard,
|
||||
border: `1px solid ${hairline}`,
|
||||
boxShadow: '0 12px 32px rgba(20, 20, 19, 0.12)',
|
||||
},
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user