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
24 changes: 24 additions & 0 deletions src/components/channels/ChannelEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { EncryptionKey } from '../../models/EncryptionKey';
import type { QuickContact } from '../../models/QuickContact';
import { CTCSS_FREQUENCIES, DCS_CODES, formatCTCSSFrequency, formatDCSCode } from '../../utils/ctcssConstants';
import { isNoTxFrequency, isRxInNoTxBand } from '../../services/validation/frequencyValidator';
import { validateChannel, type ValidationError } from '../../services/validation/channelValidator';
import type { RadioBandLimits } from '../../types/radioCapabilities';

// Frequency input component that only updates parent on blur
interface FrequencyInputProps {
Expand Down Expand Up @@ -50,6 +52,8 @@ interface ChannelEditModalProps {
onClose: () => void;
channel: Channel;
onSave: (channel: Channel) => void;
/** Band limits from radio capabilities (getCapabilitiesForModel(radioInfo?.model)?.bandLimits). */
bandLimits?: RadioBandLimits | null;
rxGroups?: RXGroup[];
encryptionKeys?: EncryptionKey[];
talkGroups?: QuickContact[];
Expand All @@ -60,11 +64,13 @@ export const ChannelEditModal: React.FC<ChannelEditModalProps> = ({
onClose,
channel,
onSave,
bandLimits = null,
rxGroups = [],
encryptionKeys = [],
talkGroups = [],
}) => {
const [editedChannel, setEditedChannel] = React.useState<Channel>(channel);
const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]);

React.useEffect(() => {
const updatedChannel = { ...channel };
Expand All @@ -75,13 +81,21 @@ export const ChannelEditModal: React.FC<ChannelEditModalProps> = ({
updatedChannel.name = 'VFO B';
}
setEditedChannel(updatedChannel);
setValidationErrors([]);
}, [channel]);

const handleChange = (field: keyof Channel, value: any) => {
setEditedChannel(prev => ({ ...prev, [field]: value }));
if (validationErrors.length > 0) setValidationErrors([]);
};

const handleSave = () => {
const errors = validateChannel(editedChannel, bandLimits);
if (errors.length > 0) {
setValidationErrors(errors);
return;
}
setValidationErrors([]);
onSave(editedChannel);
onClose();
};
Expand Down Expand Up @@ -110,6 +124,16 @@ export const ChannelEditModal: React.FC<ChannelEditModalProps> = ({
>
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto pr-2">
{validationErrors.length > 0 && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-500/50 rounded text-red-300 text-sm">
<p className="font-semibold mb-1">Please fix the following:</p>
<ul className="list-disc list-inside space-y-0.5">
{validationErrors.map((e, i) => (
<li key={i}>{e.field}: {e.message}</li>
))}
</ul>
</div>
)}
<div className="space-y-4">
{/* Basic Information */}
<section>
Expand Down
5 changes: 5 additions & 0 deletions src/components/channels/ChannelsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import { useChannelsStore } from '../../store/channelsStore';
import { useRadioStore } from '../../store/radioStore';
import { useRadioSettingsStore } from '../../store/radioSettingsStore';
import { useScanListsStore } from '../../store/scanListsStore';
import { getCapabilitiesForModel } from '../../radios/capabilities';
import { useRXGroupsStore } from '../../store/rxGroupsStore';
import { useEncryptionKeysStore } from '../../store/encryptionKeysStore';
import { useQuickContactsStore } from '../../store/quickContactsStore';
Expand Down Expand Up @@ -68,7 +70,9 @@ export const ChannelsTable: React.FC<ChannelsTableProps> = ({
onSelectionChange,
}) => {
const { channels: channelsFromStore, updateChannel, deleteChannel, addChannel } = useChannelsStore();
const { radioInfo } = useRadioStore();
const { settings: radioSettings, updateSettings } = useRadioSettingsStore();
const bandLimits = getCapabilitiesForModel(radioInfo?.model)?.bandLimits ?? null;
const { scanLists } = useScanListsStore();
const { groups: rxGroups } = useRXGroupsStore();
const { keys: encryptionKeys } = useEncryptionKeysStore();
Expand Down Expand Up @@ -1080,6 +1084,7 @@ export const ChannelsTable: React.FC<ChannelsTableProps> = ({
updateChannel(updatedChannel.number, updatedChannel);
setEditingChannel(null);
}}
bandLimits={bandLimits}
rxGroups={rxGroups}
encryptionKeys={encryptionKeys}
talkGroups={talkGroups}
Expand Down
27 changes: 16 additions & 11 deletions src/components/digital/DigitalTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useQuickContactsStore } from '../../store/quickContactsStore';
import { useRXGroupsStore } from '../../store/rxGroupsStore';
import { useQuickMessagesStore } from '../../store/quickMessagesStore';
import { getCapabilitiesForModel } from '../../radios/capabilities';
import { isValidDMRId } from '../../services/validation/dmrValidator';
import { RXGroupsList } from '../rxgroups/RXGroupsList';
import { Card } from '../ui/Card';
import { SectionTitle } from '../ui/SectionTitle';
Expand Down Expand Up @@ -266,22 +267,26 @@ export const DigitalTab: React.FC = () => {
value={radioId.dmrId}
onChange={(e) => {
const dmrIdValue = parseInt(e.target.value, 10);
if (!isNaN(dmrIdValue) && dmrIdValue >= 0 && dmrIdValue <= 0xFFFFFF) {
updateRadioId(radioId.index, {
dmrId: e.target.value,
dmrIdValue: dmrIdValue,
dmrIdBytes: new Uint8Array([
dmrIdValue & 0xFF,
(dmrIdValue >> 8) & 0xFF,
(dmrIdValue >> 16) & 0xFF,
]),
});
if (isNaN(dmrIdValue) || dmrIdValue < 0 || dmrIdValue > 0xFFFFFF) return;
if (dmrIdValue > 0 && !isValidDMRId(dmrIdValue)) {
setAlertMessage('DMR ID must be between 1 and 9,999,999 (0 = none).');
setAlertOpen(true);
return;
}
updateRadioId(radioId.index, {
dmrId: e.target.value,
dmrIdValue: dmrIdValue,
dmrIdBytes: new Uint8Array([
dmrIdValue & 0xFF,
(dmrIdValue >> 8) & 0xFF,
(dmrIdValue >> 16) & 0xFF,
]),
});
}}
min="0"
max="16777215"
className="bg-transparent border border-neon-cyan border-opacity-30 rounded px-2 py-1 focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan w-full text-xs text-white font-mono"
placeholder="DMR ID (0-16777215)"
placeholder="DMR ID (1-9999999, 0=none)"
/>
</td>
<td className="px-2 py-2">
Expand Down
2 changes: 1 addition & 1 deletion src/radios/dm32uv/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,4 @@ export function storeRawData<T extends { data: Uint8Array; [key: string]: unknow
data: new Uint8Array(data),
offset: offset,
} as T);
}
}
57 changes: 26 additions & 31 deletions src/services/validation/channelValidator.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import type { Channel } from '../../models/Channel';
import type { RadioSettings } from '../../models/RadioSettings';
import type { RadioBandLimits } from '../../types/radioCapabilities';
import { isNoTxFrequency, isRxInNoTxBand } from './frequencyValidator';

interface SettingsWithBandLimits {
bandLimits: {
vhfMin: number;
vhfMax: number;
uhfMin: number;
uhfMax: number;
};
}
import { isValidColorCode, isValidTimeSlot } from './dmrValidator';

export interface ValidationError {
field: string;
message: string;
}

/**
* Validate a channel. Band limits come from radio capabilities (getCapabilitiesForModel(radioInfo?.model)?.bandLimits).
*/
export function validateChannel(
channel: Channel,
settings?: RadioSettings | SettingsWithBandLimits
bandLimits?: RadioBandLimits | null
): ValidationError[] {
const errors: ValidationError[] = [];

Expand All @@ -39,17 +34,14 @@ export function validateChannel(
errors.push({ field: 'txFrequency', message: 'TX frequency must be greater than 0' });
}

// Band limits validation (if settings available with bandLimits)
if (settings && 'bandLimits' in settings && settings.bandLimits) {
const isVHF = channel.rxFrequency >= settings.bandLimits.vhfMin &&
channel.rxFrequency <= settings.bandLimits.vhfMax;
const isUHF = channel.rxFrequency >= settings.bandLimits.uhfMin &&
channel.rxFrequency <= settings.bandLimits.uhfMax;

// Band limits validation (from radio capabilities)
if (bandLimits) {
const isVHF = channel.rxFrequency >= bandLimits.vhfMin && channel.rxFrequency <= bandLimits.vhfMax;
const isUHF = channel.rxFrequency >= bandLimits.uhfMin && channel.rxFrequency <= bandLimits.uhfMax;
if (!isVHF && !isUHF) {
errors.push({
field: 'rxFrequency',
message: `RX frequency must be within radio band limits (VHF: ${settings.bandLimits.vhfMin}-${settings.bandLimits.vhfMax} MHz, UHF: ${settings.bandLimits.uhfMin}-${settings.bandLimits.uhfMax} MHz)`
errors.push({
field: 'rxFrequency',
message: `RX frequency must be within radio band limits (VHF: ${bandLimits.vhfMin}-${bandLimits.vhfMax} MHz, UHF: ${bandLimits.uhfMin}-${bandLimits.uhfMax} MHz)`,
});
}
}
Expand All @@ -59,15 +51,20 @@ export function validateChannel(
errors.push({ field: 'number', message: 'Channel number must be between 1 and 4000' });
}

// DMR-specific validation
if (channel.mode === 'Digital' || channel.mode === 'Fixed Digital') {
if (channel.colorCode < 0 || channel.colorCode > 15) {
// DMR-specific validation (digital only)
const isDigital = channel.mode === 'Digital' || channel.mode === 'Fixed Digital';
if (isDigital) {
if (!isValidColorCode(channel.colorCode)) {
errors.push({ field: 'colorCode', message: 'Color code must be between 0 and 15' });
}
const slotForValidation = (channel.slotOperation ?? 0) === 0 ? 1 : 2;
if (!isValidTimeSlot(slotForValidation)) {
errors.push({ field: 'slotOperation', message: 'Slot must be 1 (TS1) or 2 (TS2)' });
}
}

// Contact ID validation
if (channel.contactId < 0 || channel.contactId > 250) {
// Contact ID validation (digital only; analog does not use talk group)
if (isDigital && (channel.contactId < 0 || channel.contactId > 250)) {
errors.push({ field: 'contactId', message: 'Contact ID must be between 0 and 250' });
}

Expand All @@ -76,17 +73,15 @@ export function validateChannel(

export function validateChannels(
channels: Channel[],
settings?: RadioSettings | SettingsWithBandLimits
bandLimits?: RadioBandLimits | null
): Map<number, ValidationError[]> {
const errors = new Map<number, ValidationError[]>();

channels.forEach(channel => {
const channelErrors = validateChannel(channel, settings);
channels.forEach((channel) => {
const channelErrors = validateChannel(channel, bandLimits);
if (channelErrors.length > 0) {
errors.set(channel.number, channelErrors);
}
});

return errors;
}

2 changes: 1 addition & 1 deletion src/services/validation/codeplugValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function validateCodeplugForWrite(
const warnings: CodeplugWriteWarning[] = [];

// Always check: zones must not reference non-existent channels (prevents radio issues)
if (zones.length > 0 && channels.length >= 0) {
if (zones.length > 0) {
const zoneRefs = getZonesWithInvalidChannelRefs(zones, channels);
if (zoneRefs.length > 0) {
const totalInvalid = zoneRefs.reduce((sum, z) => sum + z.invalidChannelNumbers.length, 0);
Expand Down
51 changes: 9 additions & 42 deletions src/services/validation/frequencyValidator.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import type { RadioSettings } from '../../models/RadioSettings';
import type { Channel } from '../../models/Channel';
import type { RadioBandLimits } from '../../types/radioCapabilities';
import { DEFAULT_BAND_LIMITS } from '../../types/radioCapabilities';

interface SettingsWithBandLimits {
bandLimits: {
vhfMin: number;
vhfMax: number;
uhfMin: number;
uhfMax: number;
};
}

/** RX range where TX is not used (aviation/FM receive-only). TX bytes stored as 0xFF on radio. */
export const NO_TX_BAND_RX_MIN_MHZ = 87;
export const NO_TX_BAND_RX_MAX_MHZ = 136;
Expand Down Expand Up @@ -59,41 +49,18 @@ export function isValidChannelFrequency(channel: Channel, limits?: RadioBandLimi
isValidFrequencyRange(channel.txFrequency, limits);
}

export function isValidFrequency(
frequency: number,
settings?: RadioSettings | SettingsWithBandLimits
): boolean {
/** Band limits from radio capabilities (e.g. getCapabilitiesForModel(radioInfo?.model)?.bandLimits). */
export function isValidFrequency(frequency: number, bandLimits?: RadioBandLimits | null): boolean {
if (frequency <= 0) return false;

if (!settings || !('bandLimits' in settings) || !settings.bandLimits) {
return true; // Skip validation if bandLimits not available
}
const isVHF = frequency >= settings.bandLimits.vhfMin &&
frequency <= settings.bandLimits.vhfMax;
const isUHF = frequency >= settings.bandLimits.uhfMin &&
frequency <= settings.bandLimits.uhfMax;

return isVHF || isUHF;
if (!bandLimits) return true;
return isValidFrequencyRange(frequency, bandLimits);
}

export function getFrequencyBand(
frequency: number,
settings?: RadioSettings | SettingsWithBandLimits
): 'VHF' | 'UHF' | 'Unknown' {
if (!settings || !('bandLimits' in settings) || !settings.bandLimits) {
return 'Unknown';
}

if (frequency >= settings.bandLimits.vhfMin &&
frequency <= settings.bandLimits.vhfMax) {
return 'VHF';
}

if (frequency >= settings.bandLimits.uhfMin &&
frequency <= settings.bandLimits.uhfMax) {
return 'UHF';
}

/** Band limits from radio capabilities. */
export function getFrequencyBand(frequency: number, bandLimits?: RadioBandLimits | null): 'VHF' | 'UHF' | 'Unknown' {
if (!bandLimits) return 'Unknown';
if (frequency >= bandLimits.vhfMin && frequency <= bandLimits.vhfMax) return 'VHF';
if (frequency >= bandLimits.uhfMin && frequency <= bandLimits.uhfMax) return 'UHF';
return 'Unknown';
}