Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@

**A next-generation, web-based Channel Programming Software (CPS) for supported radios.**

NeonPlug supports the Baofeng DM-32UV / DP570UV and UV5R-Mini, with more radios on the way. Program your radio directly from your browser—no software installation required. Connect via Web Serial (USB) or, where supported, Bluetooth Low Energy (BLE). A sleek, cyberpunk neon-themed UI puts channels, zones, scan lists, contacts, and settings at your fingertips.
NeonPlug lets you program your radio directly from your browser—no software installation required. Connect via Web Serial (USB) or, where supported, Bluetooth Low Energy (BLE). A sleek, cyberpunk neon-themed UI puts channels, zones, scan lists, contacts, and settings at your fingertips.

**Supported radios:**
| Radio | Manufacturer | Bands | Connection |
|---|---|---|---|
| DM-32UV / DP570UV | Baofeng | VHF + UHF (DMR/Analog) | USB |
| UV5R-Mini | Baofeng | VHF + UHF (Analog) | USB or BLE |
| FT-65 / FT-65R / FT-65E | Yaesu | VHF + UHF (Analog) | USB (SCU-35) |
| FT-4 / FT-4XR / FT-4XE / FT-4VR | Yaesu | VHF + UHF (Analog) | USB (SCU-35) |
| FT-25R | Yaesu | VHF (Analog) | USB (SCU-35) |

**🚀 Try it live:** [https://neonplug.app](https://neonplug.app) · **📥 [Download offline version](https://neonplug.app)** (single-file, no install)

Expand Down Expand Up @@ -41,9 +50,9 @@ NeonPlug supports the Baofeng DM-32UV / DP570UV and UV5R-Mini, with more radios
The `.neonplug` file is a zipped JSON archive. You can unzip it to inspect the contents in a semi-human-readable way (e.g. `codeplug.json` inside the zip). Editing the JSON directly is not recommended—use NeonPlug’s import/export and in-app editing instead to avoid invalid data or corruption.

### 👥 Contact & Group Management
- **Digital Contacts** - Manage DMR contacts with full talk group support
- **RX Groups** - Create and organize receive groups
- **Scan Lists** - Configure scan lists across zones
- **Digital Contacts** - Manage DMR contacts with full talk group support (DM-32UV)
- **RX Groups** - Create and organize receive groups (DM-32UV)
- **Scan Lists** - Configure scan lists across zones (DM-32UV)

### 🎨 Modern Interface
- **Cyberpunk Theme** - Eye-catching neon UI that's both beautiful and functional
Expand All @@ -58,7 +67,7 @@ Just visit **[neonplug.app](https://neonplug.app)** in a Chrome-based browser (C

**Requirements:**
- Chrome, Edge, Opera, or Brave browser (for Web Serial API support)
- A supported radio (e.g. DM-32UV / DP570UV or UV5R-Mini) with USB cable—or BLE for radios that support it
- A supported radio (see table above) with the appropriate USB cable—or BLE for the UV5R-Mini

### 📥 Offline mode

Expand Down
Binary file modified neonplug_banner.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 36 additions & 5 deletions src/components/about/AboutTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useDebugStore } from '../../store/debugStore';
import { RADIO_DESCRIPTORS } from '../../radios';
import { Card } from '../ui/Card';
import { SectionTitle } from '../ui/SectionTitle';
import { Button } from '../ui/Button';
Expand All @@ -23,7 +24,7 @@ export const AboutTab: React.FC = () => {
<div className="mb-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-2">About NeonPlug</h2>
<p className="text-cool-gray">
Channel programming software. Supports: DM-32UV, DP570UV.
Online Digital CPS — program your radio directly from your browser.
</p>
</div>

Expand Down Expand Up @@ -101,14 +102,43 @@ npm run build:single</code>
<SectionTitle>Project Information</SectionTitle>
<div className="space-y-3 text-cool-gray">
<p>
<span className="text-neon-cyan font-semibold">NeonPlug</span> is a next-generation, web-based Channel Programming Software (CPS) for supported radios, including DM-32UV / DP570UV and UV5R-Mini. Built with a modern cyberpunk neon-themed UI, it provides an intuitive interface for managing channels, zones, scan lists, contacts, and radio settings.
<span className="text-neon-cyan font-semibold">NeonPlug</span> is a next-generation, web-based Channel Programming Software (CPS). Built with a cyberpunk neon-themed UI, it provides an intuitive interface for managing channels, zones, scan lists, contacts, and radio settings — all from your browser, with no drivers or software to install.
</p>
<p>
This software implements protocol support for each radio, enabling full read and write operations directly from your web browser via the Web Serial API andwhere supportedBluetooth Low Energy (BLE).
Each radio's full protocol is implemented natively, enabling read and write operations via the Web Serial API andwhere supportedBluetooth Low Energy (BLE).
</p>
</div>
</Card>

{/* Supported Radios */}
<Card>
<SectionTitle>Supported Radios</SectionTitle>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neon-cyan border-opacity-30">
<th className="text-left py-2 pr-4 text-neon-cyan font-semibold">Radio</th>
<th className="text-left py-2 pr-4 text-neon-cyan font-semibold">Manufacturer</th>
<th className="text-left py-2 text-neon-cyan font-semibold">Connection</th>
</tr>
</thead>
<tbody>
{RADIO_DESCRIPTORS.map((d) => (
<tr key={d.modelIds[0]} className="border-b border-neon-cyan border-opacity-10">
<td className="py-2 pr-4 text-white font-medium">
{d.icon} {d.modelIds.join(' / ')}
</td>
<td className="py-2 pr-4 text-cool-gray">{d.group ?? '—'}</td>
<td className="py-2 text-cool-gray">
{d.supportsBle ? 'USB or BLE' : 'USB'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>

{/* Codeplug format */}
<Card>
<SectionTitle>Codeplug format (.neonplug)</SectionTitle>
Expand Down Expand Up @@ -162,8 +192,9 @@ npm run build:single</code>
</a>
</p>
<p>
This project implements the DM-32UV protocol specification, which was reverse-engineered
through analysis of serial port captures and the official CPS software.
Radio protocols were implemented through reverse engineering — serial port captures,
analysis of official CPS software, and reference to open-source projects including
CHIRP. The DM-32UV protocol specification is documented separately.
</p>
<div className="mt-4 space-y-2">
<p className="text-sm text-cool-gray">
Expand Down
20 changes: 12 additions & 8 deletions src/components/layout/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,23 +261,23 @@ export const Toolbar: React.FC = () => {
await exportCodeplug(buildCodeplugData());
};

const handleRead = async () => {
const handleRead = async (forcePortSelection = true) => {
window.focus();
try {
setConnectionError(null);
setLastOperationMode('read');
setProgress(0);
setProgressMessage('Selecting port...');
setCurrentStep('Selecting port');

await readFromRadio((progress, message, step) => {
setProgress(progress);
setProgressMessage(message);
if (step) {
setCurrentStep(step);
}
});
}, { forcePortSelection });

setConnectionError(null);
setLastOperationMode(null);
const modelLabel = useRadioStore.getState().radioInfo?.model ?? effectiveModel ?? undefined;
Expand All @@ -289,8 +289,7 @@ export const Toolbar: React.FC = () => {
}, 2000);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const displayError = errorMessage;
setConnectionError(displayError);
setConnectionError(errorMessage);
setProgress(0);
setProgressMessage('Connection failed');
}
Expand All @@ -300,10 +299,14 @@ export const Toolbar: React.FC = () => {
if (lastOperationMode === 'write') {
handleWrite();
} else {
window.location.reload();
handleRead(false);
}
};

const handleChangePort = () => {
handleRead(true);
};

const handleCloseModal = () => {
setConnectionError(null);
setLastOperationMode(null);
Expand Down Expand Up @@ -494,7 +497,7 @@ export const Toolbar: React.FC = () => {
<Button
variant="primary"
data-action="read-from-radio"
onClick={handleRead}
onClick={() => handleRead()}
disabled={isConnecting || !webSerialSupported}
className={`rounded-r-none border-r border-white border-opacity-20 ${!webSerialSupported ? 'opacity-50 cursor-not-allowed' : ''}`}
title={!webSerialSupported ? 'Web Serial API not supported. Please use Chrome, Edge, Opera, or Brave.' : 'Read codeplug from current radio type'}
Expand Down Expand Up @@ -550,6 +553,7 @@ export const Toolbar: React.FC = () => {
steps={isWriting ? writeChannelsSteps : readSteps}
error={connectionError}
onRetry={handleRetry}
onChangePort={!isWriting ? handleChangePort : undefined}
onClose={handleCloseModal}
mode={isWriting ? 'write' : 'read'}
/>
Expand Down
17 changes: 8 additions & 9 deletions src/components/settings/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const SettingsTab: React.FC = () => {
const [showFirmwareWarning, setShowFirmwareWarning] = useState(false);

const { caps, model: effectiveModel } = useRadioCapabilities();
const settingsProfile = getSettingsProfileForModel(effectiveModel);

const EXPECTED_FIRMWARE = 'DM32.01.L01.048';
const hasRealFirmware = !!(radioInfo?.firmware && radioInfo.firmware !== '-' && radioInfo.firmware.trim() !== '');
const isNewerFirmware = !!(hasRealFirmware && caps?.isFirmware049OrNewer?.(radioInfo!.firmware));
Expand Down Expand Up @@ -504,10 +506,7 @@ export const SettingsTab: React.FC = () => {
</Card>

{/* Boot / Startup Image Section - only when profile declares bootImage feature */}
{(() => {
const profile = getSettingsProfileForModel(effectiveModel);
return profile?.features?.includes('bootImage');
})() && (
{settingsProfile?.features?.includes('bootImage') && (
<Card>
<SectionTitle size="lg" underline>Boot / Startup Image</SectionTitle>
<p className="text-cool-gray text-sm mb-6">
Expand Down Expand Up @@ -632,7 +631,7 @@ export const SettingsTab: React.FC = () => {

{/* Radio Configuration - profile-driven */}
{(() => {
const profile = getSettingsProfileForModel(effectiveModel);
const profile = settingsProfile;
if (!profile) {
return radioSettings ? (
<Card>
Expand Down Expand Up @@ -665,8 +664,8 @@ export const SettingsTab: React.FC = () => {
);
})()}

{/* One Key Operation (DM-32 only; UV5R-Mini uses uv5rMiniSettings) */}
{radioSettings && (!radioSettings.uv5rMiniSettings || radioSettings.analogCall) && (
{/* One Key Operation - only when profile declares the feature */}
{radioSettings && settingsProfile?.features?.includes('oneKeyOperation') && (
<Card className="mt-6">
<SectionTitle underline>One Key Operation</SectionTitle>

Expand Down Expand Up @@ -939,7 +938,7 @@ export const SettingsTab: React.FC = () => {
)}

{/* GPS & APRS Settings */}
{radioSettings && !radioSettings.uv5rMiniSettings && (
{radioSettings && settingsProfile?.features?.includes('gpsAprs') && (
<Card className="mt-6">
<SectionTitle underline>GPS & APRS</SectionTitle>

Expand Down Expand Up @@ -1204,7 +1203,7 @@ export const SettingsTab: React.FC = () => {
)}
</Card>
)}
<AnalogEmergencyList />
{caps?.supportsAnalogEmergency && <AnalogEmergencyList />}

<Modal
isOpen={showFirmwareWarning}
Expand Down
12 changes: 11 additions & 1 deletion src/components/ui/ReadProgressModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface ReadProgressModalProps {
steps: string[];
error?: string | null;
onRetry?: () => void;
onChangePort?: () => void;
onClose?: () => void;
mode?: 'read' | 'write';
}
Expand All @@ -40,6 +41,7 @@ export const ReadProgressModal: React.FC<ReadProgressModalProps> = ({
steps,
error,
onRetry,
onChangePort,
onClose,
mode = 'read',
}) => {
Expand Down Expand Up @@ -222,12 +224,20 @@ export const ReadProgressModal: React.FC<ReadProgressModalProps> = ({
Close
</button>
)}
{isError && onChangePort && (
<button
onClick={onChangePort}
className="px-4 py-2 bg-deep-gray text-cool-gray font-semibold rounded hover:bg-neon-cyan hover:text-dark-charcoal transition-all border border-neon-cyan border-opacity-30"
>
Change Port
</button>
)}
{isError && onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 bg-neon-cyan text-deep-gray font-semibold rounded hover:bg-neon-cyan hover:bg-opacity-80 transition-all shadow-lg hover:shadow-glow-cyan border border-neon-cyan border-opacity-50"
>
Retry Connection
Retry
</button>
)}
</div>
Expand Down
50 changes: 36 additions & 14 deletions src/components/ui/StartupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ export const StartupModal: React.FC<StartupModalProps> = ({
}, [isOpen]);
const options = useMemo(() => getRadioPickerOptions(), []);

// Group options by manufacturer; ungrouped radios go under a blank key
const groupedOptions = useMemo(() => {
const groups = new Map<string, typeof options>();
for (const opt of options) {
const key = opt.group ?? '';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(opt);
}
return groups;
}, [options]);

// Default to first radio if none selected
const effectiveSelected = selectedRadioModel ?? options[0]?.modelId ?? null;
const selectedOption = options.find(o => o.modelId === effectiveSelected);
Expand Down Expand Up @@ -108,20 +119,31 @@ export const StartupModal: React.FC<StartupModalProps> = ({
</div>

<p className="text-white text-center mb-4">Pick a radio</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{options.map((opt) => (
<button
key={opt.modelId}
type="button"
onClick={() => setSelectedRadioModel(opt.modelId)}
className={`flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-all ${
effectiveSelected === opt.modelId
? 'border-neon-cyan bg-neon-cyan bg-opacity-10 shadow-glow-cyan'
: 'border-cool-gray hover:border-neon-cyan hover:bg-opacity-5'
}`}
>
<span className="text-white font-medium text-lg">{opt.label}</span>
</button>
<div className="mb-6 space-y-3 max-h-64 overflow-y-auto pr-1">
{Array.from(groupedOptions.entries()).map(([group, opts]) => (
<div key={group || '__ungrouped'}>
{group && (
<p className="text-cool-gray text-xs font-semibold uppercase tracking-wider mb-1 px-1">
{group}
</p>
)}
<div className="grid grid-cols-2 gap-2">
{opts.map((opt) => (
<button
key={opt.modelId}
type="button"
onClick={() => setSelectedRadioModel(opt.modelId)}
className={`flex items-center justify-center px-3 py-2 rounded border-2 transition-all text-sm font-medium ${
effectiveSelected === opt.modelId
? 'border-neon-cyan bg-neon-cyan bg-opacity-10 shadow-glow-cyan text-white'
: 'border-cool-gray hover:border-neon-cyan text-cool-gray hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
))}
</div>

Expand Down
Loading
Loading