From 8afa0b42a8b9d7094164d80be868b6112030f60e Mon Sep 17 00:00:00 2001
From: Alex Harvey
Date: Sun, 8 Feb 2026 21:56:34 -0800
Subject: [PATCH 1/2] MMDVM wizard
---
src/components/import/SmartImportTab.tsx | 246 +++++++++++++++++++++++
src/services/mmdvmChannels.ts | 95 +++++++++
2 files changed, 341 insertions(+)
create mode 100644 src/services/mmdvmChannels.ts
diff --git a/src/components/import/SmartImportTab.tsx b/src/components/import/SmartImportTab.tsx
index 34c7840..2cc3323 100644
--- a/src/components/import/SmartImportTab.tsx
+++ b/src/components/import/SmartImportTab.tsx
@@ -12,15 +12,26 @@ import { findNearbyTaflEntries, groupTaflEntriesByName, type TaflData } from '..
import { generateRptrsChannels } from '../../services/rptrsChannels';
import { findNearbyRptrs, convertRptrFrequency, type RptrData } from '../../data/rptrsData';
import { importChannelsFromChirpCSV, exportChannelsToChirpCSV, downloadCSV } from '../../services/csv';
+import {
+ generateMMDVMChannels,
+ isValidMMDVMFrequency,
+ MMDVM_FREQ_MIN_MHZ,
+ MMDVM_FREQ_MAX_MHZ,
+ type MMDVMChannelEntry,
+} from '../../services/mmdvmChannels';
import type { Channel } from '../../models';
import type { Zone } from '../../models';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { SectionTitle } from '../ui/SectionTitle';
+import { useContactsStore } from '../../store/contactsStore';
+import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore';
export const SmartImportTab: React.FC = () => {
const { channels, setChannels } = useChannelsStore();
const { zones, setZones } = useZonesStore();
+ const { contacts, setContacts } = useContactsStore();
+ const { radioIds } = useDMRRadioIDsStore();
const [locationType, setLocationType] = useState<'coordinates' | 'city' | 'current'>('current');
const [latitude, setLatitude] = useState('');
@@ -82,6 +93,15 @@ export const SmartImportTab: React.FC = () => {
} | null>(null);
const fileInputRef = useRef(null);
+ // MMDVM simplex state
+ const [mmdvmFrequency, setMmdvmFrequency] = useState('431.150');
+ const [mmdvmEntries, setMmdvmEntries] = useState([
+ { channelName: '', talkGroupName: 'Local', talkGroupId: 9 },
+ ]);
+ const [mmdvmZoneName, setMmdvmZoneName] = useState('');
+ const [mmdvmDmrRadioIdIndex, setMmdvmDmrRadioIdIndex] = useState(''); // '' = None, or String(index)
+ const [isAddingMmdvm, setIsAddingMmdvm] = useState(false);
+
// Unified search handler that searches all selected types
const handleSearchAll = async () => {
@@ -677,6 +697,68 @@ export const SmartImportTab: React.FC = () => {
}
};
+ const handleAddMmdvmChannels = () => {
+ const freq = parseFloat(mmdvmFrequency);
+ if (!isValidMMDVMFrequency(freq)) {
+ setError(`Frequency must be between ${MMDVM_FREQ_MIN_MHZ} and ${MMDVM_FREQ_MAX_MHZ} MHz`);
+ return;
+ }
+ const validEntries = mmdvmEntries.filter(
+ (e) => (e.talkGroupName?.trim() || e.channelName?.trim()) && !isNaN(e.talkGroupId) && e.talkGroupId >= 0
+ );
+ if (validEntries.length === 0) {
+ setError('Add at least one channel with a Talk Group name and Talk Group ID.');
+ return;
+ }
+
+ setIsAddingMmdvm(true);
+ setError(null);
+
+ try {
+ const existingChannelNumbers = new Set(channels.map((ch) => ch.number));
+ let nextChannelNumber = 1;
+ while (existingChannelNumbers.has(nextChannelNumber)) {
+ nextChannelNumber++;
+ }
+
+ const maxContactId = contacts.length > 0 ? Math.max(...contacts.map((c) => c.id)) : 0;
+ const firstContactId = maxContactId + 1;
+
+ const firstDmrRadioIdIndex =
+ mmdvmDmrRadioIdIndex === '' || mmdvmDmrRadioIdIndex === 'none'
+ ? undefined
+ : parseInt(mmdvmDmrRadioIdIndex, 10);
+ const validDmrIndex =
+ firstDmrRadioIdIndex !== undefined &&
+ !isNaN(firstDmrRadioIdIndex) &&
+ radioIds.some((r) => r.index === firstDmrRadioIdIndex)
+ ? firstDmrRadioIdIndex
+ : undefined;
+
+ const result = generateMMDVMChannels({
+ frequencyMhz: freq,
+ entries: validEntries,
+ firstChannelNumber: nextChannelNumber,
+ firstContactId,
+ dmrRadioIdIndex: validDmrIndex,
+ zoneName: mmdvmZoneName.trim() || undefined,
+ });
+
+ setContacts([...contacts, ...result.contacts]);
+ setChannels([...channels, ...result.channels]);
+ setZones([...zones, result.zone]);
+
+ setGenerationResult({
+ channels: result.channels.length,
+ zones: 1,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to add MMDVM channels');
+ } finally {
+ setIsAddingMmdvm(false);
+ }
+ };
+
const handleChirpCSVExport = () => {
try {
// Filter out digital channels - Chirp doesn't support them
@@ -1504,6 +1586,170 @@ export const SmartImportTab: React.FC = () => {
)}
+ {/* MMDVM Simplex Section */}
+
+ MMDVM
+
+ Add simplex MMDVM hotspot channels (one frequency, Slot 2, Color Code 1). You can create multiple channels on the same frequency with different talk groups—for example, one for local (TG 9) and one for a brandmeister talk group.
+
+
+
+
+
Frequency (MHz)
+
setMmdvmFrequency(e.target.value)}
+ min={MMDVM_FREQ_MIN_MHZ}
+ max={MMDVM_FREQ_MAX_MHZ}
+ step="0.001"
+ placeholder="431.150"
+ className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
+ />
+
+ {MMDVM_FREQ_MIN_MHZ}–{MMDVM_FREQ_MAX_MHZ} MHz (simplex: same RX/TX)
+
+
+
+
+
DMR Radio ID
+
setMmdvmDmrRadioIdIndex(e.target.value)}
+ className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
+ >
+ None
+ {radioIds.map((radioId) => (
+
+ {radioId.name} (ID: {radioId.dmrId})
+
+ ))}
+
+
+ Used for TX on all MMDVM channels. Set in the Digital tab if the list is empty.
+
+
+
+
+
Channels (same frequency, different talk groups)
+
+ Each row is one channel. Set the talk group name and ID (e.g. Local = 9, Brandmeister Canada = 3100).
+
+
+ {mmdvmEntries.map((entry, index) => (
+
+
+ Channel name
+ {
+ const next = [...mmdvmEntries];
+ next[index] = { ...next[index], channelName: e.target.value };
+ setMmdvmEntries(next);
+ }}
+ placeholder="Optional"
+ maxLength={16}
+ className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm"
+ />
+
+
+ Talk group name
+ {
+ const next = [...mmdvmEntries];
+ next[index] = { ...next[index], talkGroupName: e.target.value };
+ setMmdvmEntries(next);
+ }}
+ placeholder="e.g. Local"
+ maxLength={16}
+ className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm"
+ />
+
+
+ TG ID
+ {
+ const v = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
+ const next = [...mmdvmEntries];
+ next[index] = { ...next[index], talkGroupId: isNaN(v) ? 0 : v };
+ setMmdvmEntries(next);
+ }}
+ min={0}
+ max={16776415}
+ placeholder="9"
+ className="w-full bg-black border border-neon-cyan rounded px-2 py-1.5 text-white text-sm"
+ />
+
+
+ {mmdvmEntries.length > 1 ? (
+ setMmdvmEntries(mmdvmEntries.filter((_, i) => i !== index))}
+ className="text-sm text-red-400 hover:text-red-300"
+ >
+ Remove
+
+ ) : null}
+ {index === mmdvmEntries.length - 1 ? (
+
+ setMmdvmEntries([
+ ...mmdvmEntries,
+ { channelName: '', talkGroupName: '', talkGroupId: 9 },
+ ])
+ }
+ className="text-sm text-neon-cyan hover:text-neon-cyan-bright"
+ >
+ + Add channel
+
+ ) : null}
+
+
+ ))}
+
+
+
+
+ Zone name (optional)
+ setMmdvmZoneName(e.target.value)}
+ placeholder="Default: MMDVM"
+ maxLength={16}
+ className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
+ />
+
+
+ {radioIds.length === 0 && (
+
+ No DMR Radio ID set. Add one in the Digital tab so your radio can transmit on these channels.
+
+ )}
+
+
+ Settings: Digital, Slot 2, Color Code 1. Selected DMR Radio ID is used for TX on all channels.
+
+
+
+
+ {isAddingMmdvm ? 'Adding MMDVM channels...' : 'Add MMDVM channels'}
+
+
+
{/* Fixed Channels Section */}
Fixed Channels
diff --git a/src/services/mmdvmChannels.ts b/src/services/mmdvmChannels.ts
new file mode 100644
index 0000000..f568aa2
--- /dev/null
+++ b/src/services/mmdvmChannels.ts
@@ -0,0 +1,95 @@
+/**
+ * MMDVM Simplex Channel Generator
+ * Creates digital channels and talk groups for a simplex MMDVM hotspot
+ * (single frequency, Slot 2, Color Code 1, user-defined talk groups).
+ */
+
+import type { Channel, Contact, Zone } from '../models';
+import { createDefaultChannel } from '../utils/channelHelpers';
+import { generateZoneId } from '../utils/zoneHelpers';
+
+export const MMDVM_FREQ_MIN_MHZ = 431;
+export const MMDVM_FREQ_MAX_MHZ = 435;
+
+export interface MMDVMChannelEntry {
+ channelName: string;
+ talkGroupName: string;
+ talkGroupId: number; // DMR talk group number (e.g. 9 for local, 3100 for BM Canada)
+}
+
+export interface MMDVMGenerateOptions {
+ frequencyMhz: number;
+ entries: MMDVMChannelEntry[];
+ firstChannelNumber: number;
+ firstContactId: number; // Next available contact id (e.g. max(existing contact ids) + 1)
+ dmrRadioIdIndex: number | undefined; // 0-based index into DMR Radio IDs; undefined = None
+ zoneName?: string;
+}
+
+export interface MMDVMGenerateResult {
+ channels: Channel[];
+ contacts: Contact[];
+ zone: Zone;
+}
+
+/**
+ * Validate frequency is in the 431–435 MHz range for MMDVM simplex.
+ */
+export function isValidMMDVMFrequency(mhz: number): boolean {
+ return mhz >= MMDVM_FREQ_MIN_MHZ && mhz <= MMDVM_FREQ_MAX_MHZ && !isNaN(mhz);
+}
+
+/**
+ * Generate MMDVM simplex channels and talk group contacts.
+ * Same frequency for all channels; Slot 2, Color Code 1; each channel gets its own talk group.
+ */
+export function generateMMDVMChannels(options: MMDVMGenerateOptions): MMDVMGenerateResult {
+ const { frequencyMhz, entries, firstChannelNumber, firstContactId, dmrRadioIdIndex, zoneName } = options;
+
+ if (!isValidMMDVMFrequency(frequencyMhz)) {
+ throw new Error(`Frequency must be between ${MMDVM_FREQ_MIN_MHZ} and ${MMDVM_FREQ_MAX_MHZ} MHz`);
+ }
+ if (!entries.length) {
+ throw new Error('At least one channel/talk group entry is required');
+ }
+
+ const contacts: Contact[] = [];
+ const channels: Channel[] = [];
+ let nextContactId = firstContactId;
+
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ const contactId = nextContactId++;
+ const contact: Contact = {
+ id: contactId,
+ name: (entry.talkGroupName || `TG ${entry.talkGroupId}`).substring(0, 16),
+ dmrId: entry.talkGroupId,
+ };
+ contacts.push(contact);
+
+ const channelName = (entry.channelName || entry.talkGroupName || `MMDVM ${i + 1}`).substring(0, 16);
+ const ch = createDefaultChannel({
+ number: firstChannelNumber + i,
+ name: channelName,
+ rxFrequency: frequencyMhz,
+ txFrequency: frequencyMhz, // Simplex: same as RX
+ mode: 'Digital',
+ bandwidth: '12.5kHz',
+ power: 'Low',
+ scanAdd: true,
+ colorCode: 1,
+ contactId,
+ slotOperation: 1, // Slot 2 (TS2)
+ dmrRadioIdIndex,
+ });
+ channels.push(ch);
+ }
+
+ const zone: Zone = {
+ id: generateZoneId(),
+ name: (zoneName || 'MMDVM').substring(0, 16),
+ channels: channels.map((c) => c.number),
+ };
+
+ return { channels, contacts, zone };
+}
From b1f07239f64e5e7d254411503571371a703b0fca Mon Sep 17 00:00:00 2001
From: Alex Harvey
Date: Sun, 8 Feb 2026 22:14:22 -0800
Subject: [PATCH 2/2] Clean up the UI
---
src/components/import/SmartImportTab.tsx | 105 ++++++++++++-----------
1 file changed, 57 insertions(+), 48 deletions(-)
diff --git a/src/components/import/SmartImportTab.tsx b/src/components/import/SmartImportTab.tsx
index 2cc3323..471394f 100644
--- a/src/components/import/SmartImportTab.tsx
+++ b/src/components/import/SmartImportTab.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import { useChannelsStore } from '../../store/channelsStore';
import { useZonesStore } from '../../store/zonesStore';
import { getCurrentLocation, geocodeLocation } from '../../services/repeaterFinder';
@@ -98,9 +98,18 @@ export const SmartImportTab: React.FC = () => {
const [mmdvmEntries, setMmdvmEntries] = useState([
{ channelName: '', talkGroupName: 'Local', talkGroupId: 9 },
]);
- const [mmdvmZoneName, setMmdvmZoneName] = useState('');
+ const [mmdvmZoneName, setMmdvmZoneName] = useState('MMDVM');
const [mmdvmDmrRadioIdIndex, setMmdvmDmrRadioIdIndex] = useState(''); // '' = None, or String(index)
const [isAddingMmdvm, setIsAddingMmdvm] = useState(false);
+ const mmdvmDmrIdDefaultSetRef = useRef(false);
+
+ // Preset MMDVM DMR Radio ID to first ID (slot 1) when list becomes available, once
+ useEffect(() => {
+ if (radioIds.length > 0 && !mmdvmDmrIdDefaultSetRef.current) {
+ setMmdvmDmrRadioIdIndex(String(radioIds[0].index));
+ mmdvmDmrIdDefaultSetRef.current = true;
+ }
+ }, [radioIds]);
// Unified search handler that searches all selected types
@@ -1594,40 +1603,52 @@ export const SmartImportTab: React.FC = () => {
-
-
Frequency (MHz)
-
setMmdvmFrequency(e.target.value)}
- min={MMDVM_FREQ_MIN_MHZ}
- max={MMDVM_FREQ_MAX_MHZ}
- step="0.001"
- placeholder="431.150"
- className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
- />
-
- {MMDVM_FREQ_MIN_MHZ}–{MMDVM_FREQ_MAX_MHZ} MHz (simplex: same RX/TX)
-
-
-
-
-
DMR Radio ID
-
setMmdvmDmrRadioIdIndex(e.target.value)}
- className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
- >
- None
- {radioIds.map((radioId) => (
-
- {radioId.name} (ID: {radioId.dmrId})
-
- ))}
-
-
- Used for TX on all MMDVM channels. Set in the Digital tab if the list is empty.
-
+
+
+ Zone name
+ setMmdvmZoneName(e.target.value)}
+ placeholder="Default: MMDVM"
+ maxLength={16}
+ className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
+ />
+
+
+
Frequency (MHz)
+
setMmdvmFrequency(e.target.value)}
+ min={MMDVM_FREQ_MIN_MHZ}
+ max={MMDVM_FREQ_MAX_MHZ}
+ step="0.001"
+ placeholder="431.150"
+ className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
+ />
+
+ {MMDVM_FREQ_MIN_MHZ}–{MMDVM_FREQ_MAX_MHZ} MHz
+
+
+
+
DMR Radio ID
+
setMmdvmDmrRadioIdIndex(e.target.value)}
+ className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
+ >
+ None
+ {radioIds.map((radioId) => (
+
+ {radioId.name} (ID: {radioId.dmrId})
+
+ ))}
+
+
+ For TX on all channels
+
+
@@ -1718,18 +1739,6 @@ export const SmartImportTab: React.FC = () => {
-
- Zone name (optional)
- setMmdvmZoneName(e.target.value)}
- placeholder="Default: MMDVM"
- maxLength={16}
- className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
- />
-
-
{radioIds.length === 0 && (
No DMR Radio ID set. Add one in the Digital tab so your radio can transmit on these channels.