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. +

+ +
+
+ + 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) +

+
+ +
+ + +

+ Used for TX on all MMDVM channels. Set in the Digital tab if the list is empty. +

+
+ +
+ +

+ Each row is one channel. Set the talk group name and ID (e.g. Local = 9, Brandmeister Canada = 3100). +

+
+ {mmdvmEntries.map((entry, index) => ( +
+
+ + { + 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" + /> +
+
+ + { + 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" + /> +
+
+ + { + 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 ? ( + + ) : null} + {index === mmdvmEntries.length - 1 ? ( + + ) : null} +
+
+ ))} +
+
+ +
+ + 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. +

+
+ + +
+ {/* 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 }; +}