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
246 changes: 246 additions & 0 deletions src/components/import/SmartImportTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -82,6 +93,15 @@ export const SmartImportTab: React.FC = () => {
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

// MMDVM simplex state
const [mmdvmFrequency, setMmdvmFrequency] = useState('431.150');
const [mmdvmEntries, setMmdvmEntries] = useState<MMDVMChannelEntry[]>([
{ channelName: '', talkGroupName: 'Local', talkGroupId: 9 },
]);
const [mmdvmZoneName, setMmdvmZoneName] = useState('');
const [mmdvmDmrRadioIdIndex, setMmdvmDmrRadioIdIndex] = useState<string>(''); // '' = None, or String(index)
const [isAddingMmdvm, setIsAddingMmdvm] = useState(false);


// Unified search handler that searches all selected types
const handleSearchAll = async () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1504,6 +1586,170 @@ export const SmartImportTab: React.FC = () => {
</Card>
)}

{/* MMDVM Simplex Section */}
<Card padding="tight" className="mb-4">
<SectionTitle as="h3" size="lg" className="mb-2">MMDVM</SectionTitle>
<p className="text-sm text-cool-gray mb-4">
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.
</p>

<div className="grid grid-cols-1 gap-4 mb-4">
<div>
<label className="block text-sm text-cool-gray mb-2">Frequency (MHz)</label>
<input
type="number"
value={mmdvmFrequency}
onChange={(e) => 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"
/>
<p className="text-xs text-cool-gray mt-1">
{MMDVM_FREQ_MIN_MHZ}–{MMDVM_FREQ_MAX_MHZ} MHz (simplex: same RX/TX)
</p>
</div>

<div>
<label className="block text-sm text-cool-gray mb-2">DMR Radio ID</label>
<select
value={mmdvmDmrRadioIdIndex}
onChange={(e) => setMmdvmDmrRadioIdIndex(e.target.value)}
className="w-full bg-black border border-neon-cyan rounded px-3 py-2 text-white"
>
<option value="">None</option>
{radioIds.map((radioId) => (
<option key={radioId.index} value={String(radioId.index)}>
{radioId.name} (ID: {radioId.dmrId})
</option>
))}
</select>
<p className="text-xs text-cool-gray mt-1">
Used for TX on all MMDVM channels. Set in the Digital tab if the list is empty.
</p>
</div>

<div>
<label className="block text-sm text-cool-gray mb-2">Channels (same frequency, different talk groups)</label>
<p className="text-xs text-cool-gray mb-2">
Each row is one channel. Set the talk group name and ID (e.g. Local = 9, Brandmeister Canada = 3100).
</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
{mmdvmEntries.map((entry, index) => (
<div
key={index}
className="grid grid-cols-12 gap-2 items-end p-2 rounded border border-neon-cyan border-opacity-30 bg-black bg-opacity-30"
>
<div className="col-span-3">
<label className="block text-xs text-cool-gray mb-1">Channel name</label>
<input
type="text"
value={entry.channelName}
onChange={(e) => {
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"
/>
</div>
<div className="col-span-4">
<label className="block text-xs text-cool-gray mb-1">Talk group name</label>
<input
type="text"
value={entry.talkGroupName}
onChange={(e) => {
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"
/>
</div>
<div className="col-span-2">
<label className="block text-xs text-cool-gray mb-1">TG ID</label>
<input
type="number"
value={entry.talkGroupId || ''}
onChange={(e) => {
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"
/>
</div>
<div className="col-span-3 flex items-end gap-1">
{mmdvmEntries.length > 1 ? (
<button
type="button"
onClick={() => setMmdvmEntries(mmdvmEntries.filter((_, i) => i !== index))}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
) : null}
{index === mmdvmEntries.length - 1 ? (
<button
type="button"
onClick={() =>
setMmdvmEntries([
...mmdvmEntries,
{ channelName: '', talkGroupName: '', talkGroupId: 9 },
])
}
className="text-sm text-neon-cyan hover:text-neon-cyan-bright"
>
+ Add channel
</button>
) : null}
</div>
</div>
))}
</div>
</div>

<div>
<label className="block text-sm text-cool-gray mb-2">Zone name (optional)</label>
<input
type="text"
value={mmdvmZoneName}
onChange={(e) => 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"
/>
</div>

{radioIds.length === 0 && (
<div className="rounded p-2 bg-yellow-900 border border-yellow-600 text-yellow-200 text-sm">
No DMR Radio ID set. Add one in the Digital tab so your radio can transmit on these channels.
</div>
)}

<p className="text-xs text-cool-gray">
Settings: Digital, Slot 2, Color Code 1. Selected DMR Radio ID is used for TX on all channels.
</p>
</div>

<Button
onClick={handleAddMmdvmChannels}
disabled={isAddingMmdvm}
className="bg-neon-magenta text-white hover:bg-neon-magenta-bright w-full"
>
{isAddingMmdvm ? 'Adding MMDVM channels...' : 'Add MMDVM channels'}
</Button>
</Card>

{/* Fixed Channels Section */}
<Card padding="tight" className="mb-4">
<SectionTitle as="h3" size="lg" className="mb-2">Fixed Channels</SectionTitle>
Expand Down
95 changes: 95 additions & 0 deletions src/services/mmdvmChannels.ts
Original file line number Diff line number Diff line change
@@ -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 };
}