Channels: on-radio add/delete from the Channel quick menu

Add two codeplug primitives - codeplugAllChannelsIndexSetUnused() (frees a
channel slot: clears the in-use bitmap bit, persists the header, fixes counts)
and codeplugZoneRemoveChannelFromZoneAndSave() (removes a channel from a zone's
list) - and wire two new Channel quick-menu items:

- 'new channel': allocates the first free slot, writes a blank 145.500 FM
  channel, adds it to the current zone, reloads the zone and opens Channel
  Details to edit it.
- 'Delete Channel': confirmation, then scrubs the channel from every real zone
  before freeing the slot. Guards against deleting the last channel and honours
  the read-only/APRS lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcus Kida 2026-07-10 11:11:48 +02:00
parent 7c10a92475
commit e145a1188d
5 changed files with 305 additions and 0 deletions

View file

@ -7,6 +7,14 @@ single running list until the first versioned release.
## Unreleased
- **On-radio channel add/delete.** The Channel quick menu (top button) has two new
entries:
- **new channel** — creates a blank 145.500 MHz FM channel in the first free
slot, adds it to the current zone, and opens Channel Details to edit it. The
zone is reloaded so the channel is immediately navigable.
- **Delete Channel** — deletes the selected channel after a confirmation, and
scrubs it out of every zone that referenced it. Refuses to delete the last
remaining channel, and respects the read-only/APRS channel lock.
- **Release script** now flags every release before v1.0.0 as a **pre-release**
(and corrects the flag on re-runs).

View file

@ -415,6 +415,7 @@ void codeplugGetVFO_ChannelData(CodeplugChannel_t *vfoBuf, Channel_t VFONumber);
void codeplugSetVFO_ChannelData(CodeplugChannel_t *vfoBuf, Channel_t VFONumber);
bool codeplugAllChannelsIndexIsInUse(int index);
void codeplugAllChannelsIndexSetUsed(int index);
void codeplugAllChannelsIndexSetUnused(int index);
bool codeplugChannelSaveDataForIndex(int index, CodeplugChannel_t *channelBuf);
CodeplugCSSTypes_t codeplugGetCSSType(uint16_t tone);
void codeplugConvertChannelInternalToCodeplug(CodeplugChannel_t *codeplugChannel, CodeplugChannel_t *internalChannel);
@ -442,6 +443,7 @@ bool codeplugGetGeneralSettings(CodeplugGeneralSettings_t *generalSettingsBuffer
bool codeplugGetSignallingDTMF(CodeplugSignallingDTMF_t *signallingDTMFBuffer);
bool codeplugGetSignallingDTMFDurations(CodeplugSignallingDTMFDurations_t *signallingDTMFDurationsBuffer);
bool codeplugZoneAddChannelToZoneAndSave(int channelIndex, CodeplugZone_t *zoneBuf);
bool codeplugZoneRemoveChannelFromZoneAndSave(int channelIndex, CodeplugZone_t *zoneBuf);
bool codeplugGetDeviceInfo(CodeplugDeviceInfo_t *deviceInfoBuffer);
uint16_t codeplugGetQuickkeyFunctionID(char key);

View file

@ -356,6 +356,8 @@ enum CHANNEL_SCREEN_QUICK_MENU_ITEMS
CH_SCREEN_QUICK_MENU_TALKAROUND,
CH_SCREEN_QUICK_MENU_ROAMING,
CH_SCREEN_QUICK_MENU_AUDIO_MUTE,
CH_SCREEN_QUICK_MENU_NEW_CHANNEL,
CH_SCREEN_QUICK_MENU_DELETE_CHANNEL,
NUM_CH_SCREEN_QUICK_MENU_ITEMS
};

View file

@ -383,6 +383,46 @@ bool codeplugZoneAddChannelToZoneAndSave(int channelIndex, CodeplugZone_t *zoneB
return false;
}
bool codeplugZoneRemoveChannelFromZoneAndSave(int channelIndex, CodeplugZone_t *zoneBuf)
{
// Not valid for the virtual "All Channels" zone (indexNumber -1): its membership
// is the channel in-use bitmap, not a stored channels[] list.
if (zoneBuf->NOT_IN_CODEPLUGDATA_indexNumber < 0)
{
return false;
}
// Find the channel in the zone's list.
int found = -1;
for (int i = 0; i < zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone; i++)
{
if (zoneBuf->channels[i] == channelIndex)
{
found = i;
break;
}
}
if (found == -1)
{
return false; // channel is not a member of this zone
}
// Shift the remaining channels down to close the gap, then zero the freed tail slot
// (a zero also terminates the list for codeplugZoneGetDataForNumber()).
for (int i = found; i < (zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone - 1); i++)
{
zoneBuf->channels[i] = zoneBuf->channels[i + 1];
}
zoneBuf->channels[zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone - 1] = 0;
zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone--;
zoneBuf->NOT_IN_CODEPLUGDATA_highestIndex = zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone;
// IMPORTANT. Write size is different from the size of the data (see the add function).
return EEPROM_Write(CODEPLUG_ADDR_EX_ZONE_LIST + (zoneBuf->NOT_IN_CODEPLUGDATA_indexNumber * (16 + (sizeof(uint16_t) * codeplugChannelsPerZone))),
(uint8_t *)zoneBuf, ((codeplugChannelsPerZone == 16) ? CODEPLUG_ZONE_DATA_ORIGINAL_STRUCT_SIZE : CODEPLUG_ZONE_DATA_OPENGD77_STRUCT_SIZE));
}
static uint16_t codeplugAllChannelsGetCount(void)
{
uint16_t c = 0;
@ -447,6 +487,48 @@ void codeplugAllChannelsIndexSetUsed(int index)
}
}
void codeplugAllChannelsIndexSetUnused(int index)
{
if ((index >= CODEPLUG_CHANNELS_MIN) && (index <= CODEPLUG_CHANNELS_MAX))
{
index--;
int channelBank = (index / CODEPLUG_CHANNELS_PER_BANK);
int byteno = (index % CODEPLUG_CHANNELS_PER_BANK) / 8;
int cacheOffset = index / 8;
codeplugAllChannelsCache[cacheOffset] &= ~(1 << (index % 8));
if(channelBank == 0)
{
EEPROM_Write(CODEPLUG_ADDR_CHANNEL_HEADER_EEPROM + byteno, &codeplugAllChannelsCache[cacheOffset], 1);
}
else
{
SPI_Flash_write(FLASH_ADDRESS_OFFSET + (CODEPLUG_ADDR_CHANNEL_HEADER_FLASH + ((channelBank - 1) *
(CODEPLUG_CHANNELS_PER_BANK * CODEPLUG_CHANNEL_DATA_STRUCT_SIZE + 16))) + byteno, &codeplugAllChannelsCache[cacheOffset], 1);
}
if (allChannelsTotalNumOfChannels > 0)
{
allChannelsTotalNumOfChannels--;
}
// If we just freed the highest-numbered channel, rescan for the new highest.
if ((index + 1) >= allChannelsHighestChannelIndex)
{
allChannelsHighestChannelIndex = 0;
for (int i = CODEPLUG_CHANNELS_MAX; i >= CODEPLUG_CHANNELS_MIN; i--)
{
if (codeplugAllChannelsIndexIsInUse(i))
{
allChannelsHighestChannelIndex = i;
break;
}
}
}
}
}
void codeplugAllChannelsInitCache(void)
{
// There are 8 banks

View file

@ -2164,6 +2164,152 @@ static bool validateOverwriteChannel(void)
return true;
}
// Shown when channels are locked (read-only / APRS) and an edit is attempted.
static void channelEditShowLockedMessageBox(void)
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s\n%s", currentLanguage->aprs_channel, currentLanguage->locked);
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
uiDataGlobal.MessageBox.buttons =
#if defined(PLATFORM_MD9600)
MESSAGEBOX_BUTTONS_ENT;
#else
MESSAGEBOX_BUTTONS_OK;
#endif
uiDataGlobal.MessageBox.validatorCallback = NULL;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
voicePromptsInit();
voicePromptsAppendLanguageString(currentLanguage->aprs_channel);
voicePromptsAppendLanguageString(currentLanguage->locked);
voicePromptsPlay();
}
// Create a blank/default 145.500 MHz FM simplex channel in the first free slot,
// add it to the current zone, select it and open Channel Details to edit it.
static void channelEditCreateNewChannel(void)
{
int16_t newChannelIndex;
for (newChannelIndex = CODEPLUG_CHANNELS_MIN; newChannelIndex <= CODEPLUG_CHANNELS_MAX; newChannelIndex++)
{
if (codeplugAllChannelsIndexIsInUse(newChannelIndex) == false)
{
break;
}
}
if (newChannelIndex > CODEPLUG_CHANNELS_MAX)
{
nextKeyBeepMelody = (int16_t *)MELODY_NACK_BEEP; // codeplug full, no free slot
return;
}
// Start from the currently loaded channel so all structural fields are valid,
// then override the essentials to a blank FM simplex default.
char nameBuf[SCREEN_LINE_BUFFER_SIZE];
CodeplugChannel_t tempChannel = channelScreenChannelData;
snprintf(nameBuf, SCREEN_LINE_BUFFER_SIZE, "%s %d", currentLanguage->new_channel, newChannelIndex);
memset(&tempChannel.name, 0xFF, sizeof(tempChannel.name)); // codeplug strings are 0xFF-padded
memcpy(&tempChannel.name, nameBuf, strlen(nameBuf));
tempChannel.chMode = RADIO_MODE_ANALOG;
tempChannel.rxFreq = tempChannel.txFreq = 14550000; // 145.500 MHz
tempChannel.txTone = tempChannel.rxTone = CODEPLUG_CSS_TONE_NONE;
tempChannel.sql = 0U; // use the global squelch level
tempChannel.rxGroupList = 0;
tempChannel.contact = 0;
tempChannel.NOT_IN_CODEPLUG_flag = 0x00;
tempChannel.NOT_IN_CODEPLUG_CALCULATED_DISTANCE_X10 = -1;
if (codeplugChannelSaveDataForIndex(newChannelIndex, &tempChannel) == false)
{
nextKeyBeepMelody = (int16_t *)MELODY_NACK_BEEP;
return;
}
codeplugAllChannelsIndexSetUsed(newChannelIndex); // mark the slot used AFTER the data is written
if (currentZone.NOT_IN_CODEPLUGDATA_indexNumber == 0xDEADBEEF)
{
uiChannelInitializeCurrentZone();
}
// Add it to the current zone ("All Channels" already includes every used slot).
if (CODEPLUG_ZONE_IS_ALLCHANNELS(currentZone))
{
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, newChannelIndex);
}
else
{
if (codeplugZoneAddChannelToZoneAndSave(newChannelIndex, &currentZone))
{
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, (currentZone.NOT_IN_CODEPLUGDATA_numChannelsInZone - 1));
}
}
// Reload the zone so the new channel is immediately navigable without having to
// re-enter the zone (refreshes the channel count / highest index for real zones,
// and re-derives them from the channel in-use bitmap for "All Channels").
uiChannelInitializeCurrentZone();
// Select the new channel (kept in memory, so Channel Details edits valid data).
memcpy(&channelScreenChannelData, &tempChannel, sizeof(tempChannel));
uiDataGlobal.currentSelectedChannelNumber = newChannelIndex;
uiDataGlobal.VoicePrompts.inhibitInitial = true;
menuSystemPopAllAndDisplaySpecificRootMenu(UI_CHANNEL_MODE, true);
menuSystemPushNewMenu(MENU_CHANNEL_DETAILS); // jump straight into the editor
nextKeyBeepMelody = (int16_t *)MELODY_ACK_BEEP;
}
// Confirmation callback for deleting the currently-selected channel.
static bool validateDeleteChannel(void)
{
if (uiDataGlobal.MessageBox.keyPressed == KEY_GREEN)
{
int deletedChannelIndex = uiDataGlobal.currentSelectedChannelNumber;
// Remove the channel from every real zone that references it, so no zone is
// left pointing at a freed slot.
int numZones = codeplugZonesGetCount();
for (int z = 0; z < numZones; z++)
{
CodeplugZone_t zoneBuf;
if (codeplugZoneGetDataForNumber(z, &zoneBuf) && (CODEPLUG_ZONE_IS_ALLCHANNELS(zoneBuf) == false))
{
codeplugZoneRemoveChannelFromZoneAndSave(deletedChannelIndex, &zoneBuf);
}
}
// Free the slot (clears the in-use bitmap bit).
codeplugAllChannelsIndexSetUnused(deletedChannelIndex);
// Point the current zone at a valid remaining channel and force a reload.
if (CODEPLUG_ZONE_IS_ALLCHANNELS(currentZone))
{
int firstCh = CODEPLUG_CHANNELS_MIN;
while ((firstCh <= CODEPLUG_CHANNELS_MAX) && (codeplugAllChannelsIndexIsInUse(firstCh) == false))
{
firstCh++;
}
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, ((firstCh <= CODEPLUG_CHANNELS_MAX) ? firstCh : CODEPLUG_CHANNELS_MIN));
}
else
{
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, 0); // first channel in the zone
}
uiChannelInitializeCurrentZone(); // its channel list/count changed
channelScreenChannelData.rxFreq = 0; // force the channel screen to reload
menuSystemPopAllAndDisplaySpecificRootMenu(UI_CHANNEL_MODE, true);
nextKeyBeepMelody = (int16_t *)MELODY_ACK_BEEP;
}
return true;
}
static void updateQuickMenuScreen(bool isFirstRun)
{
int mNum = 0;
@ -2266,6 +2412,12 @@ static void updateQuickMenuScreen(bool isFirstRun)
leftSide = currentLanguage->mute;
rightSideConst = (uiDataGlobal.QuickMenu.tmpAudioMute ? currentLanguage->yes : currentLanguage->no);
break;
case CH_SCREEN_QUICK_MENU_NEW_CHANNEL:
rightSideConst = currentLanguage->new_channel;
break;
case CH_SCREEN_QUICK_MENU_DELETE_CHANNEL:
rightSideConst = "Delete Channel";
break;
default:
buf[0] = 0;
}
@ -2496,6 +2648,65 @@ static void handleQuickMenuEvent(uiEvent_t *ev)
}
break;
case CH_SCREEN_QUICK_MENU_NEW_CHANNEL:
if (settingsIsOptionBitSet(BIT_CHANNELS_ARE_READ_ONLY))
{
channelEditShowLockedMessageBox();
}
else
{
channelEditCreateNewChannel();
}
return;
case CH_SCREEN_QUICK_MENU_DELETE_CHANNEL:
{
if (settingsIsOptionBitSet(BIT_CHANNELS_ARE_READ_ONLY))
{
channelEditShowLockedMessageBox();
return;
}
// Don't allow deleting the last remaining channel (would leave the
// codeplug with no channels).
int inUseCount = 0;
for (int i = CODEPLUG_CHANNELS_MIN; (i <= CODEPLUG_CHANNELS_MAX) && (inUseCount < 2); i++)
{
if (codeplugAllChannelsIndexIsInUse(i))
{
inUseCount++;
}
}
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
if (inUseCount < 2)
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "Cannot delete\nlast channel");
uiDataGlobal.MessageBox.buttons =
#if defined(PLATFORM_MD9600)
MESSAGEBOX_BUTTONS_ENT;
#else
MESSAGEBOX_BUTTONS_OK;
#endif
uiDataGlobal.MessageBox.validatorCallback = NULL;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
return;
}
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "Delete channel?\n%s", currentLanguage->please_confirm);
uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_YESNO;
uiDataGlobal.MessageBox.validatorCallback = validateDeleteChannel;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
voicePromptsInit();
voicePromptsAppendLanguageString(currentLanguage->please_confirm);
voicePromptsPlay();
return;
}
break;
default:
// CH_SCREEN_QUICK_MENU_FILTER_FM
if (nonVolatileSettings.analogFilterLevel != uiDataGlobal.QuickMenu.tmpAnalogFilterLevel)