Skip to content
Merged
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ Program your DMR radio directly from your browser—no software installation req
### 📡 Channel Configuration
- **Smart Import** - Location-based channel wizard using repeater databases
- **Bulk Editing** - Powerful table interface for editing multiple channels at once
- **CSV Support** - Import/export channels from CHIRP and custom CSV formats
- **Codeplug backup** - Save and load a full codeplug as a `.neonplug` file (zipped JSON)
- **Chirp CSV** - Import and export channels in CHIRP CSV format; custom CSV import also supported
- **Auto-Configuration** - Automatic offset, CTCSS, and color code detection

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
Expand Down
41 changes: 1 addition & 40 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,40 +1 @@
{
"name": "neonplug",
"version": "0.1.0",
"type": "module",
"description": "Cyberpunk-themed Radio CPS for Baofeng DM-32UV",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:prod": "tsc && vite build --mode production",
"build:single": "tsc && vite build --mode singlefile",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest"
},
"dependencies": {
"exceljs": "^4.4.0",
"jszip": "^3.10.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.2.2",
"vite": "^7.3.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^4.0.16"
}
}
{"name":"neonplug","version":"0.1.0","type":"module","description":"Cyberpunk-themed Radio CPS for Baofeng DM-32UV","scripts":{"dev":"vite","build":"tsc && vite build","build:prod":"tsc && vite build --mode production","build:single":"tsc && vite build --mode singlefile","preview":"vite preview","lint":"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0","test":"vitest"},"dependencies":{"jszip":"^3.10.1","react":"^18.2.0","react-dom":"^18.2.0","react-easy-crop":"^5.5.6","zustand":"^4.4.7"},"devDependencies":{"@types/react":"^18.2.43","@types/react-dom":"^18.2.17","@typescript-eslint/eslint-plugin":"^6.14.0","@typescript-eslint/parser":"^6.14.0","@vitejs/plugin-react":"^4.2.1","autoprefixer":"^10.4.16","eslint":"^8.55.0","eslint-plugin-react-hooks":"^4.6.0","eslint-plugin-react-refresh":"^0.4.5","postcss":"^8.4.32","tailwindcss":"^3.4.0","typescript":"^5.2.2","vite":"^7.3.0","vite-plugin-singlefile":"^2.3.0","vitest":"^4.0.16"}}
49 changes: 37 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { useScanListsStore } from './store/scanListsStore';
import { useRadioSettingsStore } from './store/radioSettingsStore';
import { useDigitalEmergencyStore } from './store/digitalEmergencyStore';
import { useAnalogEmergencyStore } from './store/analogEmergencyStore';
import { useQuickMessagesStore } from './store/quickMessagesStore';
import { useDMRRadioIDsStore } from './store/dmrRadioIdsStore';
import { useQuickContactsStore } from './store/quickContactsStore';
import { useRXGroupsStore } from './store/rxGroupsStore';
import { useEncryptionKeysStore } from './store/encryptionKeysStore';
import { useRadioStore } from './store/radioStore';
import { useRadioConnection } from './hooks/useRadioConnection';
import { importChannelsFromCSV, importContactsFromCSV } from './services/csv';
import { sampleChannels, sampleContacts, sampleZones } from './utils/sampleData';
Expand All @@ -38,6 +44,12 @@ function App() {
const { setSettings: setRadioSettings } = useRadioSettingsStore();
const { setSystems: setDigitalEmergencies, setConfig: setDigitalEmergencyConfig } = useDigitalEmergencyStore();
const { setSystems: setAnalogEmergencies } = useAnalogEmergencyStore();
const { setMessages } = useQuickMessagesStore();
const { setRadioIds } = useDMRRadioIDsStore();
const { setContacts: setQuickContacts } = useQuickContactsStore();
const { setGroups: setRXGroups } = useRXGroupsStore();
const { setKeys: setEncryptionKeys } = useEncryptionKeysStore();
const { setRadioInfo } = useRadioStore();
const { isConnecting, error: radioError } = useRadioConnection();
const fileInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -119,10 +131,9 @@ function App() {
const fileName = file.name.toLowerCase();
const fileExtension = fileName.split('.').pop()?.toLowerCase();

// Check if it's a codeplug XLSX file (ExcelJS supports .xlsx only; .xls is not supported)
if (fileExtension === 'xlsx') {
// Check if it's a codeplug file (.neonplug = zipped JSON)
if (fileExtension === 'neonplug') {
try {
// Lazy load Excel library only when needed
const { importCodeplug } = await import('./services/codeplugExport');
const codeplugData = await importCodeplug(file);

Expand All @@ -139,15 +150,29 @@ function App() {
if (codeplugData.radioSettings) {
setRadioSettings(codeplugData.radioSettings);
}
setRadioInfo(codeplugData.radioInfo ?? null);
setMessages(codeplugData.messages ?? []);
setRadioIds(codeplugData.radioIds ?? []);
setQuickContacts(codeplugData.quickContacts ?? []);
setRXGroups(codeplugData.rxGroups ?? []);
setEncryptionKeys(codeplugData.encryptionKeys ?? []);

setShowStartupModal(false);
setAlertMessage(
`Successfully imported codeplug!\n\n` +
`• ${codeplugData.channels.length} channels\n` +
`• ${codeplugData.zones.length} zones\n` +
`• ${codeplugData.scanLists.length} scan lists\n` +
`• ${codeplugData.contacts.length} contacts`
);
const lines = [
`• ${codeplugData.channels.length} channels`,
`• ${codeplugData.zones.length} zones`,
`• ${codeplugData.scanLists.length} scan lists`,
`• ${codeplugData.contacts.length} contacts`,
`• ${codeplugData.digitalEmergencies?.length ?? 0} digital emergency system(s)`,
`• ${codeplugData.analogEmergencies?.length ?? 0} analog emergency system(s)`,
codeplugData.radioSettings ? '• Radio settings' : null,
`• ${codeplugData.messages?.length ?? 0} quick message(s)`,
`• ${codeplugData.radioIds?.length ?? 0} DMR radio ID(s)`,
`• ${codeplugData.quickContacts?.length ?? 0} talk group(s)`,
`• ${codeplugData.rxGroups?.length ?? 0} RX group(s)`,
`• ${codeplugData.encryptionKeys?.length ?? 0} encryption key(s)`,
].filter(Boolean);
setAlertMessage(`Successfully imported codeplug!\n\n${lines.join('\n')}`);
setAlertOpen(true);
} catch (error) {
setAlertMessage(`Failed to import codeplug: ${error instanceof Error ? error.message : 'Unknown error'}`);
Expand Down Expand Up @@ -180,7 +205,7 @@ function App() {
setAlertOpen(true);
}
} else {
setAlertMessage('File must be a codeplug (.xlsx) or CSV file containing "channel" or "contact" in the filename');
setAlertMessage('File must be a codeplug (.neonplug) or CSV file containing "channel" or "contact" in the filename');
setAlertOpen(true);
}
}
Expand Down Expand Up @@ -247,7 +272,7 @@ function App() {
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx"
accept=".csv,.neonplug"
onChange={handleFileSelect}
className="hidden"
/>
Expand Down
8 changes: 8 additions & 0 deletions src/components/about/AboutTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ npm run build:single</code>
</div>
</Card>

{/* Codeplug format */}
<Card>
<SectionTitle>Codeplug format (.neonplug)</SectionTitle>
<p className="text-cool-gray text-sm">
The codeplug file is a zipped JSON archive. You can unzip it to inspect the contents in a semi-human-readable way (e.g. <code className="text-neon-cyan">codeplug.json</code> 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.
</p>
</Card>

{/* Links */}
<Card>
<SectionTitle>Links</SectionTitle>
Expand Down
15 changes: 11 additions & 4 deletions src/components/diagnostics/DiagnosticsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useRXGroupsStore } from '../../store/rxGroupsStore';
import { useQuickMessagesStore } from '../../store/quickMessagesStore';
import { useQuickContactsStore } from '../../store/quickContactsStore';
import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore';
import { useEncryptionKeysStore } from '../../store/encryptionKeysStore';
import { useLogStore } from '../../store/logStore';
import { getCapabilitiesForModel } from '../../radios/capabilities';
import {
Expand Down Expand Up @@ -46,6 +47,7 @@ export const DiagnosticsTab: React.FC = () => {
const { messages: quickMessages } = useQuickMessagesStore();
const { contacts: quickContacts } = useQuickContactsStore();
const { radioIds: dmrRadioIds } = useDMRRadioIDsStore();
const { keys: encryptionKeys } = useEncryptionKeysStore();
const caps = useMemo(() => getCapabilitiesForModel(radioInfo?.model), [radioInfo?.model]);
const [showMetadataBlock, setShowMetadataBlock] = useState(false);
const [showMetadataBlock41, setShowMetadataBlock41] = useState(false);
Expand Down Expand Up @@ -530,7 +532,7 @@ export const DiagnosticsTab: React.FC = () => {
writeFolder.file('expected-write-data.json', JSON.stringify(expectedWrite, null, 2));
}

// Add codeplug XLSX
// Add codeplug (.neonplug = zipped JSON)
const codeplugData = {
channels,
zones,
Expand All @@ -541,13 +543,18 @@ export const DiagnosticsTab: React.FC = () => {
analogEmergencies,
radioSettings,
radioInfo,
messages: quickMessages,
radioIds: dmrRadioIds,
quickContacts,
rxGroups,
encryptionKeys,
exportDate: new Date().toISOString(),
version: '1.0.0',
};

const xlsxBlob = await exportCodeplug(codeplugData, true);
if (xlsxBlob instanceof Blob) {
zip.file('codeplug.xlsx', xlsxBlob);
const codeplugBlob = await exportCodeplug(codeplugData, true);
if (codeplugBlob instanceof Blob) {
zip.file('codeplug.neonplug', codeplugBlob);
}

// Generate and download zip
Expand Down
8 changes: 4 additions & 4 deletions src/components/digital/DigitalTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,9 @@ export const DigitalTab: React.FC = () => {
)}
</div>

{!block10Data ? (
{!block10Data && digitalEmergencies.length === 0 ? (
<Card variant="subdued">
<EmptyState message="Block 0x10 not found. Read from radio to view digital emergency systems." />
<EmptyState message="Block 0x10 not found. Read from radio or load a codeplug to view digital emergency systems." />
</Card>
) : digitalEmergencies.length === 0 ? (
<Card variant="subdued">
Expand Down Expand Up @@ -526,9 +526,9 @@ export const DigitalTab: React.FC = () => {
)}
</div>

{!block10Data ? (
{!block10Data && keys.length === 0 ? (
<Card variant="subdued">
<EmptyState message="Block 0x10 not found. Read from radio to view encryption keys." />
<EmptyState message="Block 0x10 not found. Read from radio or load a codeplug to view encryption keys." />
</Card>
) : (
<Card className="max-h-[calc(100vh-400px)] flex flex-col" padding="none">
Expand Down
Loading