Compare commits

...

3 Commits

49 changed files with 5030 additions and 3210 deletions

View File

@@ -1,137 +0,0 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2017 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package io.github.dsheirer.channel.details;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.sample.Listener;
import net.miginfocom.swing.MigLayout;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.text.DefaultCaret;
import java.awt.EventQueue;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class ChannelDetailPanel extends JPanel implements Listener<ProcessingChain>
{
private static final String EMPTY_DETAILS = "Please select a channel to view details";
private JLabel mSystemLabel;
private JLabel mSiteLabel;
private JLabel mNameLabel;
private JTextArea mDetailTextPane;
private ChannelProcessingManager mChannelProcessingManager;
private ProcessingChain mProcessingChain;
public ChannelDetailPanel(ChannelProcessingManager channelProcessingManager)
{
mChannelProcessingManager = channelProcessingManager;
init();
}
private void init()
{
setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[]0[grow,fill]"));
JPanel buttonPanel = new JPanel();
buttonPanel.setLayout(new MigLayout("insets 1 1 1 1", "[][grow,fill][][grow,fill][][grow,fill][]", ""));
buttonPanel.add(new JLabel("System:"));
mSystemLabel = new JLabel(" ");
buttonPanel.add(mSystemLabel);
buttonPanel.add(new JLabel("Site:"));
mSiteLabel = new JLabel(" ");
buttonPanel.add(mSiteLabel);
buttonPanel.add(new JLabel("Channel Name:"));
mNameLabel = new JLabel(" ");
buttonPanel.add(mNameLabel);
JButton refreshButton = new JButton("Refresh");
refreshButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
receive(mProcessingChain);
}
});
buttonPanel.add(refreshButton);
add(buttonPanel, "wrap");
mDetailTextPane = new JTextArea(EMPTY_DETAILS);
DefaultCaret caret = (DefaultCaret)mDetailTextPane.getCaret();
caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
add(new JScrollPane(mDetailTextPane));
}
@Override
public void receive(ProcessingChain processingChain)
{
mProcessingChain = processingChain;
Channel channel = mChannelProcessingManager.getChannel(processingChain);
final String system = channel != null ? channel.getSystem() : null;
final String site = channel != null ? channel.getSite() : null;
final String name = channel != null ?
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null;
final String details;
if(processingChain != null)
{
StringBuilder sb = new StringBuilder();
for(DecoderState decoderState : processingChain.getDecoderStates())
{
sb.append(decoderState.getActivitySummary());
}
details = sb.toString();
}
else
{
details = EMPTY_DETAILS;
}
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
mSystemLabel.setText(system);
mSiteLabel.setText(site);
mNameLabel.setText(name);
mDetailTextPane.setText(details);
}
});
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -18,67 +18,73 @@
*/
package io.github.dsheirer.channel.metadata;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.sample.Listener;
import java.awt.EventQueue;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CopyOnWriteArrayList;
import javax.swing.table.AbstractTableModel;
public class ChannelMetadataModel extends AbstractTableModel implements IChannelMetadataUpdateListener
/**
* Model of the channel metadata for the currently processing channels, with a map from each channel metadata to the
* channel that it belongs to. The model is thread-safe and isn't tied to a user interface toolkit: listeners are
* notified on the thread that changes the model and pass the changes to their user interface thread.
*/
public class ChannelMetadataModel implements IChannelMetadataUpdateListener
{
private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataModel.class);
private final List<ChannelMetadata> mChannelMetadata = new ArrayList<>();
private final Map<ChannelMetadata,Channel> mMetadataChannelMap = new HashMap<>();
private final List<Listener> mListeners = new CopyOnWriteArrayList<>();
public static final int COLUMN_DECODER_STATE = 0;
public static final int COLUMN_DECODER_TYPE = 1;
public static final int COLUMN_USER_FROM = 2;
public static final int COLUMN_USER_FROM_ALIAS = 3;
public static final int COLUMN_USER_TO = 4;
public static final int COLUMN_USER_TO_ALIAS = 5;
public static final int COLUMN_DECODER_LOGICAL_CHANNEL_NAME = 6;
public static final int COLUMN_CONFIGURATION_FREQUENCY = 7;
public static final int COLUMN_CONFIGURATION_CHANNEL = 8;
private static final String[] COLUMNS = {"Status", "Decoder", "From", "Alias", "To", "Alias", "Channel", "Frequency", "Channel Name"};
private List<ChannelMetadata> mChannelMetadata = new ArrayList();
private Map<ChannelMetadata,Channel> mMetadataChannelMap = new HashMap();
private Listener<ChannelAndMetadata> mChannelAddListener;
public ChannelMetadataModel()
/**
* Listener for changes to the channel metadata model. Notifications arrive on the thread that changed the
* model, sometimes while the model is locked, so implementations must not block and must not call back into
* the model from the notification thread.
*/
public interface Listener
{
MyEventBus.getGlobalEventBus().register(this);
/**
* Channel metadata(s) for a channel were added to the model.
*/
void added(ChannelAndMetadata channelAndMetadata);
/**
* Channel metadata was removed from the model.
*/
void removed(ChannelMetadata channelMetadata);
/**
* A field of a channel metadata in the model was updated.
*/
void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField);
}
/**
* Receives preference update notifications via the event bus
* @param preferenceType that was updated
* Constructs an instance
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
public ChannelMetadataModel()
{
if(preferenceType == PreferenceType.TALKGROUP_FORMAT)
{
EventQueue.invokeLater(() -> {
for(int row = 0; row < mChannelMetadata.size(); row++)
{
fireTableCellUpdated(row, COLUMN_USER_FROM);
fireTableCellUpdated(row, COLUMN_USER_TO);
}
});
}
}
/**
* Registers the listener to receive model change notifications.
* @param listener to register
* @return the channel metadata in the model when the listener is registered, so that the listener starts from
* the current state and receives the changes that follow.
*/
public synchronized List<ChannelMetadata> addListener(Listener listener)
{
mListeners.add(listener);
return new ArrayList<>(mChannelMetadata);
}
/**
* Removes the listener from receiving model change notifications.
*/
public void removeListener(Listener listener)
{
mListeners.remove(listener);
}
/**
@@ -87,10 +93,16 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
*/
public String getDiagnosticInformation()
{
StringBuilder sb = new StringBuilder();
Map<ChannelMetadata,Channel> mapCopy = new HashMap<>(mMetadataChannelMap);
List<ChannelMetadata> metadataCopy = new ArrayList<>(mChannelMetadata);
Map<ChannelMetadata,Channel> mapCopy;
List<ChannelMetadata> metadataCopy;
synchronized(this)
{
mapCopy = new HashMap<>(mMetadataChannelMap);
metadataCopy = new ArrayList<>(mChannelMetadata);
}
StringBuilder sb = new StringBuilder();
sb.append("Channel Metadata Model Diagnostics\n");
for(ChannelMetadata channelMetadata: metadataCopy)
@@ -122,51 +134,34 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
return sb.toString();
}
/**
* Prepares the model for disposal.
*/
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);
mListeners.clear();
}
/**
* Lookup model row for the specified channel metadata
* Adds the channel metadata(s) for a channel to the model.
*/
public int getRow(ChannelMetadata channelMetadata)
public synchronized void add(ChannelAndMetadata channelAndMetadata)
{
if(mChannelMetadata.contains(channelMetadata))
for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
{
return mChannelMetadata.indexOf(channelMetadata);
}
return -1;
}
/**
* Registers the listener to be notified when channel metadata(s) are added to the model.
* @param listener to receive channel associated with the channel metadata(s) that were added
*/
public void setChannelAddListener(Listener<ChannelAndMetadata> listener)
{
mChannelAddListener = listener;
}
public void add(ChannelAndMetadata channelAndMetadata)
{
//Execute on the swing thread to avoid threading issues
EventQueue.invokeLater(() -> {
for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
if(!mChannelMetadata.contains(channelMetadata))
{
mChannelMetadata.add(channelMetadata);
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
int index = mChannelMetadata.indexOf(channelMetadata);
fireTableRowsInserted(index, index);
channelMetadata.setUpdateEventListener(ChannelMetadataModel.this);
}
if(mChannelAddListener != null)
{
mChannelAddListener.receive(channelAndMetadata);
}
});
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
channelMetadata.setUpdateEventListener(this);
}
for(Listener listener: mListeners)
{
listener.added(channelAndMetadata);
}
}
/**
@@ -174,7 +169,7 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
* @param channelMetadatas
* @param channel
*/
public void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
public synchronized void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
{
for(ChannelMetadata channelMetadata: channelMetadatas)
{
@@ -182,178 +177,46 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
}
}
public void remove(ChannelMetadata channelMetadata)
/**
* Removes the channel metadata from the model.
*/
public synchronized void remove(ChannelMetadata channelMetadata)
{
//Execute on the swing thread to avoid threading issues
EventQueue.invokeLater(() -> {
channelMetadata.removeUpdateEventListener();
int index = mChannelMetadata.indexOf(channelMetadata);
mChannelMetadata.remove(channelMetadata);
mMetadataChannelMap.remove(channelMetadata);
channelMetadata.removeUpdateEventListener();
mMetadataChannelMap.remove(channelMetadata);
if(index >= 0)
if(mChannelMetadata.remove(channelMetadata))
{
for(Listener listener: mListeners)
{
fireTableRowsDeleted(index, index);
listener.removed(channelMetadata);
}
});
}
}
/**
* Get the channel metadata at the specified model row index
* Current channel metadata in the model.
* @return copy of the channel metadata list.
*/
public ChannelMetadata getChannelMetadata(int row)
public synchronized List<ChannelMetadata> getChannelMetadata()
{
if(row < mChannelMetadata.size())
{
return mChannelMetadata.get(row);
}
return null;
return new ArrayList<>(mChannelMetadata);
}
/**
* Returns the channel that matches the metadata or null
*/
public Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
public synchronized Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
{
return mMetadataChannelMap.get(channelMetadata);
}
@Override
public int getRowCount()
{
return mChannelMetadata.size();
}
@Override
public int getColumnCount()
{
return COLUMNS.length;
}
@Override
public String getColumnName(int column)
{
return COLUMNS[column];
}
@Override
public Class<?> getColumnClass(int columnIndex)
{
switch(columnIndex)
{
case COLUMN_USER_FROM:
case COLUMN_USER_TO:
return ChannelMetadata.class;
case COLUMN_USER_FROM_ALIAS:
case COLUMN_USER_TO_ALIAS:
return Alias.class;
case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
return String.class;
default:
return Identifier.class;
}
}
@Override
public Object getValueAt(int rowIndex, int columnIndex)
{
if(rowIndex <= mChannelMetadata.size())
{
ChannelMetadata channelMetadata = mChannelMetadata.get(rowIndex);
switch(columnIndex)
{
case COLUMN_DECODER_STATE:
return channelMetadata.getChannelStateIdentifier();
case COLUMN_DECODER_TYPE:
return channelMetadata.getDecoderTypeConfigurationIdentifier();
case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
if(channelMetadata.hasTimeslot())
{
if(id == null)
{
return "TS:" + channelMetadata.getTimeslot();
}
else
{
String value = id.getValue().replace(" TS1", "")
.replace(" TS2", "");
return value + " TS:" + channelMetadata.getTimeslot();
}
}
else
{
if(id == null)
{
return null;
}
else
{
return id.getValue();
}
}
case COLUMN_CONFIGURATION_FREQUENCY:
return channelMetadata.getFrequencyConfigurationIdentifier();
case COLUMN_CONFIGURATION_CHANNEL:
return channelMetadata.getChannelNameConfigurationIdentifier();
case COLUMN_USER_TO:
return channelMetadata;
case COLUMN_USER_FROM:
return channelMetadata;
case COLUMN_USER_FROM_ALIAS:
return channelMetadata.getFromIdentifierAliases();
case COLUMN_USER_TO_ALIAS:
return channelMetadata.getToIdentifierAliases();
}
}
return null;
}
@Override
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
{
final int rowIndex = mChannelMetadata.indexOf(channelMetadata);
if(rowIndex >= 0)
for(Listener listener: mListeners)
{
//Execute on the swing thread to avoid threading issues
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
switch(channelMetadataField)
{
case CONFIGURATION_CHANNEL:
fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_CHANNEL);
break;
case CONFIGURATION_FREQUENCY:
fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_FREQUENCY);
break;
case DECODER_CHANNEL_NAME:
fireTableCellUpdated(rowIndex, COLUMN_DECODER_LOGICAL_CHANNEL_NAME);
break;
case DECODER_TYPE:
fireTableCellUpdated(rowIndex, COLUMN_DECODER_TYPE);
break;
case DECODER_STATE:
fireTableCellUpdated(rowIndex, COLUMN_DECODER_STATE);
break;
case USER_FROM:
fireTableCellUpdated(rowIndex, COLUMN_USER_FROM);
fireTableCellUpdated(rowIndex, COLUMN_USER_FROM_ALIAS);
break;
case USER_TO:
fireTableCellUpdated(rowIndex, COLUMN_USER_TO);
fireTableCellUpdated(rowIndex, COLUMN_USER_TO_ALIAS);
break;
}
}
});
listener.updated(channelMetadata, channelMetadataField);
}
}
}

View File

@@ -1,494 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.channel.metadata;
import com.google.common.base.Joiner;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier;
import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
import io.github.dsheirer.sample.Broadcaster;
import io.github.dsheirer.sample.Listener;
import java.awt.Color;
import java.awt.Component;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.text.DecimalFormat;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.SwingConstants;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.DefaultTableCellRenderer;
public class ChannelMetadataPanel extends JPanel implements ListSelectionListener
{
private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataPanel.class);
private static final String TABLE_PREFERENCE_KEY = "channel.metadata.panel";
private ChannelModel mChannelModel;
private ChannelProcessingManager mChannelProcessingManager;
private IconModel mIconModel;
private UserPreferences mUserPreferences;
private JTable mTable;
private Broadcaster<ProcessingChain> mSelectedProcessingChainBroadcaster = new Broadcaster<>();
private Map<State,Color> mBackgroundColors = new EnumMap<>(State.class);
private Map<State,Color> mForegroundColors = new EnumMap<>(State.class);
private JTableColumnWidthMonitor mTableColumnMonitor;
private Channel mUserSelectedChannel;
/**
* Table view for currently decoding channel metadata
*/
public ChannelMetadataPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences)
{
mChannelModel = playlistManager.getChannelModel();
mChannelProcessingManager = playlistManager.getChannelProcessingManager();
mIconModel = iconModel;
mUserPreferences = userPreferences;
init();
}
/**
* Initializes the panel
*/
private void init()
{
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
mTable = new JTable(mChannelProcessingManager.getChannelMetadataModel());
mChannelProcessingManager.getChannelMetadataModel().setChannelAddListener(new ChannelAddListener());
DefaultTableCellRenderer renderer = (DefaultTableCellRenderer)mTable.getDefaultRenderer(String.class);
renderer.setHorizontalAlignment(SwingConstants.CENTER);
mTable.getSelectionModel().addListSelectionListener(this);
mTable.addMouseListener(new MouseSupport());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_DECODER_STATE)
.setCellRenderer(new ColoredStateCellRenderer());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM)
.setCellRenderer(new FromCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO)
.setCellRenderer(new ToCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM_ALIAS)
.setCellRenderer(new AliasCellRenderer());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO_ALIAS)
.setCellRenderer(new AliasCellRenderer());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_CONFIGURATION_FREQUENCY)
.setCellRenderer(new FrequencyCellRenderer());
//Add a table column width monitor to store/restore column widths
mTableColumnMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
JScrollPane scrollPane = new JScrollPane(mTable, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
add(scrollPane);
setColors();
}
/**
* Setup the background and foreground color palette for the various channel states.
*/
private void setColors()
{
mBackgroundColors.put(State.ACTIVE, Color.CYAN);
mForegroundColors.put(State.ACTIVE, Color.BLUE);
mBackgroundColors.put(State.CALL, Color.BLUE);
mForegroundColors.put(State.CALL, Color.YELLOW);
mBackgroundColors.put(State.CONTROL, Color.ORANGE);
mForegroundColors.put(State.CONTROL, Color.BLUE);
mBackgroundColors.put(State.DATA, Color.GREEN);
mForegroundColors.put(State.DATA, Color.BLUE);
mBackgroundColors.put(State.ENCRYPTED, Color.MAGENTA);
mForegroundColors.put(State.ENCRYPTED, Color.WHITE);
mBackgroundColors.put(State.FADE, Color.LIGHT_GRAY);
mForegroundColors.put(State.FADE, Color.DARK_GRAY);
mBackgroundColors.put(State.IDLE, Color.WHITE);
mForegroundColors.put(State.IDLE, Color.DARK_GRAY);
mBackgroundColors.put(State.RESET, Color.PINK);
mForegroundColors.put(State.RESET, Color.YELLOW);
mBackgroundColors.put(State.TEARDOWN, Color.DARK_GRAY);
mForegroundColors.put(State.TEARDOWN, Color.WHITE);
}
@Override
public void valueChanged(ListSelectionEvent e)
{
if(!mTable.getSelectionModel().getValueIsAdjusting())
{
ProcessingChain processingChain = null;
int selectedViewRow = mTable.getSelectedRow();
if(selectedViewRow >= 0)
{
int selectedModelRow = mTable.convertRowIndexToModel(selectedViewRow);
ChannelMetadata selectedMetadata = mChannelProcessingManager.getChannelMetadataModel()
.getChannelMetadata(selectedModelRow);
if(selectedMetadata != null)
{
mUserSelectedChannel = mChannelProcessingManager.getChannelMetadataModel()
.getChannelFromMetadata(selectedMetadata);
processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
}
}
mSelectedProcessingChainBroadcaster.broadcast(processingChain);
}
}
/**
* Adds the listener to receive the processing chain associated with the metadata selected in the
* metadata table.
*/
public void addProcessingChainSelectionListener(Listener<ProcessingChain> listener)
{
mSelectedProcessingChainBroadcaster.addListener(listener);
}
/**
* Cell renderer for frequency values
*/
public class FrequencyCellRenderer extends DefaultTableCellRenderer
{
private final DecimalFormat FREQUENCY_FORMATTER = new DecimalFormat( "#.00000" );
public FrequencyCellRenderer()
{
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof FrequencyConfigurationIdentifier)
{
long frequency = ((FrequencyConfigurationIdentifier)value).getValue();
label.setText(FREQUENCY_FORMATTER.format(frequency / 1e6d));
}
else
{
label.setText(null);
}
return label;
}
}
/**
* Alias cell renderer
*/
public class AliasCellRenderer extends DefaultTableCellRenderer
{
public AliasCellRenderer()
{
setHorizontalAlignment(JLabel.LEFT);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof List<?>)
{
List<Alias> aliases = (List<Alias>)value;
if(!aliases.isEmpty())
{
label.setText(Joiner.on(", ").skipNulls().join(aliases));
label.setIcon(mIconModel.getIcon(aliases.get(0).getIconName(), IconModel.DEFAULT_ICON_SIZE));
label.setForeground(ThemeManager.getInstance().getReadableColor(aliases.get(0).getDisplayColor(),
label.getBackground()));
}
else
{
label.setText(null);
label.setIcon(null);
label.setForeground(table.getForeground());
}
}
else
{
label.setText(null);
label.setIcon(null);
label.setForeground(table.getForeground());
}
return label;
}
}
/**
* Abstract cell renderer for identifiers
*/
public abstract class IdentifierCellRenderer extends DefaultTableCellRenderer
{
private final static String EMPTY_VALUE = "-----";
private TalkgroupFormatPreference mTalkgroupFormatPreference;
public IdentifierCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
{
mTalkgroupFormatPreference = talkgroupFormatPreference;
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof ChannelMetadata)
{
ChannelMetadata channelMetadata = (ChannelMetadata)value;
Identifier identifier = getIdentifier(channelMetadata);
String text = mTalkgroupFormatPreference.format(identifier);
if(text == null || text.isEmpty())
{
text = EMPTY_VALUE;
}
else if(hasAdditionalIdentifier(channelMetadata))
{
text = text + " " + getAdditionalIdentifier(channelMetadata);
}
label.setText(text);
}
else
{
label.setText(EMPTY_VALUE);
}
return label;
}
public abstract Identifier getIdentifier(ChannelMetadata channelMetadata);
public abstract boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata);
public abstract Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata);
}
/**
* Cell renderer for the FROM identifier
*/
public class FromCellRenderer extends IdentifierCellRenderer
{
public FromCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
{
super(talkgroupFormatPreference);
}
@Override
public Identifier getIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.getFromIdentifier();
}
@Override
public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.getTalkerAliasIdentifier();
}
@Override
public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.hasTalkerAliasIdentifier();
}
}
/**
* Cell renderer for the TO identifier
*/
public class ToCellRenderer extends IdentifierCellRenderer
{
public ToCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
{
super(talkgroupFormatPreference);
}
@Override
public Identifier getIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.getToIdentifier();
}
@Override
public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata) {return null;}
@Override
public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata) {return false;}
}
public class ColoredStateCellRenderer extends DefaultTableCellRenderer
{
public ColoredStateCellRenderer()
{
setHorizontalAlignment(SwingConstants.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus,
int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
Color background = table.getBackground();
Color foreground = table.getForeground();
if(value instanceof ChannelStateIdentifier)
{
State state = ((ChannelStateIdentifier)value).getValue();
label.setText(state.getDisplayValue());
if(mBackgroundColors.containsKey(state))
{
background = mBackgroundColors.get(state);
}
if(mForegroundColors.containsKey(state))
{
foreground = mForegroundColors.get(state);
}
}
else
{
setText("----");
}
setBackground(background);
setForeground(foreground);
return label;
}
}
public class MouseSupport extends MouseAdapter
{
@Override
public void mouseClicked(MouseEvent e)
{
if(e.getButton() == MouseEvent.BUTTON3) //Right click for context
{
JPopupMenu popupMenu = new JPopupMenu();
boolean populated = false;
int viewRowIndex = mTable.rowAtPoint(e.getPoint());
if(viewRowIndex >= 0)
{
int modelRowIndex = mTable.convertRowIndexToModel(viewRowIndex);
if(modelRowIndex >= 0)
{
ChannelMetadata metadata = mChannelProcessingManager.getChannelMetadataModel().getChannelMetadata(modelRowIndex);
if(metadata != null)
{
Channel channel = mChannelProcessingManager.getChannelMetadataModel()
.getChannelFromMetadata(metadata);
if(channel != null)
{
JMenuItem viewChannel = new JMenuItem("View/Edit: " + channel.getShortTitle());
viewChannel.addActionListener(e2 -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
popupMenu.add(viewChannel);
populated = true;
}
}
}
}
if(!populated)
{
popupMenu.add(new JMenuItem("No Actions Available"));
}
popupMenu.show(mTable, e.getX(), e.getY());
}
}
}
/**
* Listener to be notified when a channel and associated channel metadata(s) are added to the underlying
* channel metadata model.
*
* When a channel is added, it is compared the the last user selected channel and if they are the same, it
* invokes a selection event on the channel metadata row so that the channel metadata is re-selected. This is
* primarily a hack to counter-act the DMR Capacity+ REST channel rotation where a channel is converted to a
* traffic channel and the previous channel is restarted. The UI effect is that the user selected channel row
* in the Now Playing window continually loses selection over the channel and causes the user to perpetually
* chase the channel row.
*/
public class ChannelAddListener implements Listener<ChannelAndMetadata>
{
@Override
public void receive(ChannelAndMetadata channelAndMetadata)
{
if(mUserSelectedChannel != null &&
mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
{
List<ChannelMetadata> metadata = channelAndMetadata.getChannelMetadata();
if(metadata.size() > 0)
{
int modelRow = mChannelProcessingManager.getChannelMetadataModel().getRow(metadata.get(0));
if(modelRow >= 0)
{
int tableRow = mTable.convertRowIndexToView(modelRow);
mTable.getSelectionModel().setSelectionInterval(tableRow, tableRow);
}
}
}
}
}
}

View File

@@ -1,138 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.channel.metadata;
import com.jidesoft.swing.JideSplitPane;
import com.jidesoft.swing.JideTabbedPane;
import io.github.dsheirer.channel.details.ChannelDetailPanel;
import io.github.dsheirer.gui.channel.ChannelSpectrumPanel;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.module.decode.event.DecodeEventPanel;
import io.github.dsheirer.module.decode.event.MessageActivityPanel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.settings.SettingsManager;
import net.miginfocom.swing.MigLayout;
import javax.swing.JPanel;
/**
* Swing panel for Now Playing channels table and channel details tab set.
*/
public class NowPlayingPanel extends JPanel
{
private final ChannelMetadataPanel mChannelMetadataPanel;
private final ChannelDetailPanel mChannelDetailPanel;
private final DecodeEventPanel mDecodeEventPanel;
private final MessageActivityPanel mMessageActivityPanel;
private final ChannelSpectrumPanel mChannelSpectrumSquelchPanel;
private JideTabbedPane mTabbedPane;
private JideSplitPane mSplitPane;
private boolean mDetailTabsVisible;
/**
* GUI panel that combines the currently decoding channels metadata table and viewers for channel details,
* messages, events, and spectral view.
*/
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
SettingsManager settingsManager, boolean detailTabsVisible)
{
mChannelDetailPanel = new ChannelDetailPanel(playlistManager.getChannelProcessingManager());
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager);
mDetailTabsVisible = detailTabsVisible;
init();
}
/**
* Change the visibility of the channel details tabs panel.
* @param visible true to show or false to hide.
*/
public void setDetailTabsVisible(boolean visible)
{
//Only adjust if there is a change in state
if(visible ^ mDetailTabsVisible)
{
mDetailTabsVisible = visible;
if(mDetailTabsVisible)
{
getSplitPane().add(getTabbedPane());
}
else
{
getSplitPane().remove(getTabbedPane());
}
revalidate();
}
}
private JideTabbedPane getTabbedPane()
{
if(mTabbedPane == null)
{
mTabbedPane = new JideTabbedPane();
mTabbedPane.addTab("Details", mChannelDetailPanel);
mTabbedPane.addTab("Events", mDecodeEventPanel);
mTabbedPane.addTab("Messages", mMessageActivityPanel);
mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel);
mTabbedPane.setFont(this.getFont());
//Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing
mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane()
.indexOfComponent(mChannelSpectrumSquelchPanel)));
}
return mTabbedPane;
}
/**
* Split pane for channels table and channel details tabs.
*/
private JideSplitPane getSplitPane()
{
if(mSplitPane == null)
{
mSplitPane = new JideSplitPane(JideSplitPane.VERTICAL_SPLIT);
mSplitPane.setShowGripper(true);
}
return mSplitPane;
}
private void init()
{
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
getSplitPane().add(mChannelMetadataPanel);
if(mDetailTabsVisible)
{
getSplitPane().add(getTabbedPane());
}
add(getSplitPane());
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelDetailPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mDecodeEventPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mMessageActivityPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelSpectrumSquelchPanel);
}
}

View File

@@ -21,8 +21,8 @@ package io.github.dsheirer.controller;
import com.jidesoft.swing.JideTabbedPane;
import io.github.dsheirer.audio.playback.AudioPanel;
import io.github.dsheirer.audio.playback.AudioPlaybackManager;
import io.github.dsheirer.channel.metadata.NowPlayingPanel;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.nowplaying.NowPlayingPanel;
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
@@ -63,7 +63,7 @@ public class ControllerPanel extends JPanel
{
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
playlistManager.getAliasModel());
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, detailTabsVisible);
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
new RadioReferencePlaylistActions(playlistManager));

View File

@@ -1,483 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.channel;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
import io.github.dsheirer.gui.power.SignalPowerView;
import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
import io.github.dsheirer.gui.symbol.SymbolView;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.module.decode.PrimaryDecoder;
import io.github.dsheirer.module.decode.am.AMDecoder;
import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
import io.github.dsheirer.source.Source;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import java.awt.Component;
import java.awt.EventQueue;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.List;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
import net.miginfocom.swing.MigLayout;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSplitPane;
/**
* Display for channel FFT and squelch details
*/
public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingChain>
{
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumPanel.class);
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
private static final String LABEL_PREFIX_TUNER = "Tuner:";
private static final String LABEL_PREFIX_CHANNEL = "Channel:";
private static final String LABEL_PREFIX_DECODER = "Decoder:";
private final PlaylistManager mPlaylistManager;
private ProcessingChain mProcessingChain;
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
private final JFXPanel mSpectralPanel = new JFXPanel();
//Created on the JavaFX thread - guarded by this. Channel state is kept here until the display exists.
private ChannelSpectralDisplay mChannelSpectralDisplay;
private long mChannelFrequency;
private int mChannelSampleRate;
private int mChannelBandwidth;
private Long mCarrierOffset;
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
private final JLabel mTunerPPMLabel;
private final JLabel mTunerCorrectionLabel;
private final JLabel mChannelCorrectionLabel;
private final JLabel mDecoderCorrectionLabel;
private final JLabel mDummyLabel = new JLabel(" ");
private boolean mPanelVisible = false;
private final NoiseSquelchView mNoiseSquelchView;
private final SignalPowerView mSignalPowerView;
private final SymbolView mSymbolView = new SymbolView();
private final JFXPanel mNoiseSquelchPanel;
private final JFXPanel mSymbolPanel;
private JSplitPane mSplitPane;
private FeedbackDecoder mFeedbackDecoder;
/**
* Constructs an instance.
*/
public ChannelSpectrumPanel(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
mSignalPowerView = new SignalPowerView(mPlaylistManager);
//Register the three swappable right-pane components with the theme manager. Only one is
//attached to the split pane at a time, so the LAF-toggle walk of Window.getWindows() can
//miss the detached ones - they get refreshed explicitly via registerSwing.
ThemeManager.getInstance().registerSwing(mSignalPowerView);
setLayout(new MigLayout("insets 0", "[grow,fill]", "[grow,fill]"));
JPanel fftPanel = new JPanel();
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
JPanel labelPanel = new JPanel();
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", ""));
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
mTunerPPMLabel.setEnabled(false);
labelPanel.add(mTunerPPMLabel);
labelPanel.add(new JLabel("Corrections (Hz) "));
mTunerCorrectionLabel = new JLabel(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setEnabled(false);
labelPanel.add(mTunerCorrectionLabel);
mChannelCorrectionLabel = new JLabel(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setEnabled(false);
labelPanel.add(mChannelCorrectionLabel);
mDecoderCorrectionLabel = new JLabel(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setEnabled(false);
labelPanel.add(mDecoderCorrectionLabel);
labelPanel.add(mDummyLabel);
JButton logIndexesButton = new JButton("Log Settings");
logIndexesButton.addActionListener(e -> {
if(mProcessingChain != null)
{
Source source = mProcessingChain.getSource();
if(source instanceof PolyphaseChannelSource pcs)
{
List<Integer> indexes = pcs.getOutputProcessorIndexes();
double sampleRate = pcs.getSampleRate();
long indexCenterFrequency = pcs.getIndexCenterFrequency();
long appliedFrequencyOffset = pcs.getFrequencyOffset();
long requestedCenterFrequency = pcs.getFrequency();
StringBuilder sb = new StringBuilder();
sb.append("Polyphase Channel - BW: ").append(FREQUENCY_FORMAT.format(sampleRate / 1E6d));
sb.append(" Center/Requested/Mixer: ").append(FREQUENCY_FORMAT.format(indexCenterFrequency / 1E6d));
sb.append("/").append(FREQUENCY_FORMAT.format(requestedCenterFrequency / 1E6d));
sb.append("/").append(FREQUENCY_FORMAT.format(appliedFrequencyOffset / 1E6d));
sb.append(" Polyphase Indexes: ").append(indexes);
sb.append(" Tuner SR:").append(FREQUENCY_FORMAT.format(pcs.getTunerSampleRate() / 1E6d));
sb.append(" CF:").append(FREQUENCY_FORMAT.format(pcs.getTunerCenterFrequency() / 1E6d));
LOGGER.info(sb.toString());
LOGGER.info("Output Processor: " + pcs.getStateDescription());
}
else if(source instanceof HalfBandTunerChannelSource<?> hbtcs)
{
StringBuilder sb = new StringBuilder();
sb.append("Heterodyne Channel - CF:").append(FREQUENCY_FORMAT.format(hbtcs.getFrequency() / 1E6d));
sb.append(" SR:").append(FREQUENCY_FORMAT.format(hbtcs.getSampleRate() / 1E6d));
sb.append(" Mixer:").append(FREQUENCY_FORMAT.format(hbtcs.getMixerFrequency() / 1E6d));
LOGGER.info(sb.toString());
}
else
{
LOGGER.info("Unsupported channel type: " + (source != null ? source.getClass() : " null"));
}
}
});
//This is a debug button to log the current settings to the app log.
// labelPanel.add(mLogIndexesButton);
fftPanel.add(labelPanel, "wrap");
fftPanel.add(mSpectralPanel);
ThemeManager.getInstance().registerSwing(mSpectralPanel);
Platform.runLater(() -> {
ChannelSpectralDisplay display = new ChannelSpectralDisplay();
Scene scene = new Scene(display);
ThemeManager.getInstance().register(scene);
mSpectralPanel.setScene(scene);
displayCreated(display);
});
mNoiseSquelchPanel = new JFXPanel();
mSymbolPanel = new JFXPanel();
ThemeManager.getInstance().registerSwing(mNoiseSquelchPanel);
ThemeManager.getInstance().registerSwing(mSymbolPanel);
//Spin noise squelch panel construction off onto the JavafX UI thread.
Platform.runLater(() -> {
Scene scene = new Scene(mNoiseSquelchView);
ThemeManager.getInstance().register(scene);
mNoiseSquelchPanel.setScene(scene);
Scene scene2 = new Scene(mSymbolView);
URL resource = getClass().getResource("/sdrtrunk_style.css");
if(resource != null)
{
scene2.getStylesheets().add(resource.toExternalForm());
}
else
{
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
}
ThemeManager.getInstance().register(scene2);
//Use the same color theme as the channel spectral display
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
mSymbolPanel.setScene(scene2);
});
mSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
mSplitPane.add(fftPanel, JSplitPane.LEFT);
mSplitPane.add(mNoiseSquelchPanel, JSplitPane.RIGHT);
add(mSplitPane);
// mSplitPane.setDividerLocation(0.5);
}
/**
* Stores the spectral display once it's created on the JavaFX thread and applies the current channel state.
*/
private synchronized void displayCreated(ChannelSpectralDisplay display)
{
mChannelSpectralDisplay = display;
if(mChannelSampleRate > 0)
{
display.setChannel(mChannelFrequency, mChannelSampleRate, mChannelBandwidth);
}
if(mCarrierOffset != null)
{
display.setCarrierOffset(mCarrierOffset);
}
updateFFTProcessing();
}
private synchronized void setChannel(long frequency, int sampleRate, int bandwidth)
{
mChannelFrequency = frequency;
mChannelSampleRate = sampleRate;
mChannelBandwidth = bandwidth;
mCarrierOffset = null;
if(mChannelSpectralDisplay != null)
{
mChannelSpectralDisplay.setChannel(frequency, sampleRate, bandwidth);
}
}
private synchronized void setCarrierOffset(long offset)
{
mCarrierOffset = offset;
if(mChannelSpectralDisplay != null)
{
mChannelSpectralDisplay.setCarrierOffset(offset);
}
}
/**
* Pads the value to a fixed width and returns the previx and padded value.
* @param prefix of the label
* @param value to pad with spaces
* @return padded, prefixed label
*/
private String getPaddedLabel(String prefix, Number value)
{
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
}
/**
* Signals this panel to indicate if this panel is visible to turn on the FFT processor when the panel is visible
* and turn off the FFT processor when it's not.
*
* Note: this method is intended to be called by the Swing event thread to ensure that only a single thread is
* invoking either this method, or the receive() method, since there is no thread synchronization between these
* two methods and they each depend on stable access to the mPanelVisible variable.
*
* @param visible true to indicate that this panel is showing/visible.
*/
public void setPanelVisible(boolean visible)
{
mPanelVisible = visible;
updateFFTProcessing();
mNoiseSquelchView.setShowing(visible);
mSymbolView.setShowing(visible);
}
/**
* Updates processing state for the spectral display. Turns on DFT processing when we have a processing chain and
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
*/
private synchronized void updateFFTProcessing()
{
if(mChannelSpectralDisplay == null)
{
return;
}
boolean active = mPanelVisible && mProcessingChain != null;
if(active)
{
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
}
else
{
mSampleStreamTapModule.removeListener();
}
mChannelSpectralDisplay.setActive(active);
}
private void broadcast(SourceEvent sourceEvent)
{
if(mProcessingChain != null)
{
mProcessingChain.broadcast(sourceEvent);
}
}
/**
* Resets controls when changing processing chain source. Note: this must be called on the Swing
* dispatch thread because it directly invokes swing components.
*/
private void reset()
{
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
mTunerPPMLabel.setEnabled(false);
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setEnabled(false);
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setEnabled(false);
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setEnabled(false);
setChannel(0, 0, 0);
}
/**
* Receive notifications of request to provide display of processing chain details.
*/
@Override
public void receive(ProcessingChain processingChain)
{
//Disconnect the previous processing chain.
if(mProcessingChain != null)
{
mNoiseSquelchView.setController(null);
mSignalPowerView.setProcessingChain(null);
mSymbolView.removeSymbolProvider();
mSymbolView.setProtocol("");
mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
mProcessingChain.removeModule(mSampleStreamTapModule);
mFeedbackDecoder = null;
}
//Invoking reset - we're on the Swing dispatch thread here
reset();
mProcessingChain = processingChain;
if(mProcessingChain != null)
{
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
{
setRightComponent(mNoiseSquelchPanel);
mNoiseSquelchView.setController(nbfmDecoder);
}
else if(primaryDecoder instanceof AMDecoder)
{
setRightComponent(mSignalPowerView);
mSignalPowerView.setProcessingChain(mProcessingChain);
}
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
{
setRightComponent(mSymbolPanel);
mSymbolView.setSymbolProvider(feedbackDecoder);
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
mFeedbackDecoder = feedbackDecoder;
}
mProcessingChain.addModule(mSampleStreamTapModule);
Source source = mProcessingChain.getSource();
int bandwidth = 0;
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
if(!tunerChannels.isEmpty())
{
bandwidth = tunerChannels.getFirst().getBandwidth();
}
}
if(source instanceof TunerChannelSource tcs)
{
setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
}
}
updateFFTProcessing();
}
/**
* Shows the component on the right side of the split pane.
* @param component to show.
*/
private void setRightComponent(Component component)
{
Component rightComponent = mSplitPane.getRightComponent();
if(rightComponent != component)
{
mSplitPane.remove(rightComponent);
mSplitPane.setRightComponent(component);
mSplitPane.setDividerLocation(0.4);
}
}
/**
* Processor for source event stream to capture power level and squelch related source events.
*/
private class SourceEventProcessor implements Listener<SourceEvent>
{
@Override
public void receive(SourceEvent sourceEvent)
{
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS)
{
if(sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
{
//Note: we flip the sign on the error measurement because the value represents the amount of offset the PLL
//has to apply to move the signal to center/baseband
EventQueue.invokeLater(() -> {
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
mTunerPPMLabel.setEnabled(true);
if(status.isAutoPPM())
{
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
}
else
{
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
}
mTunerCorrectionLabel.setEnabled(true);
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
mChannelCorrectionLabel.setEnabled(true);
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
mDecoderCorrectionLabel.setEnabled(true);
//We se the offset cursor line to negative so that it aligns with where the signal is located
//instead of the correction that's needed to bring the signal to zero.
setCarrierOffset(-status.getDecoderCorrection());
});
}
}
//TODO: is this still needed?
mSignalPowerView.receive(sourceEvent);
}
}
}

View File

@@ -0,0 +1,309 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.channel;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.gui.power.SignalPowerView;
import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
import io.github.dsheirer.gui.symbol.SymbolView;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.module.decode.PrimaryDecoder;
import io.github.dsheirer.module.decode.am.AMDecoder;
import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
import io.github.dsheirer.source.Source;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SplitPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Display for channel spectrum, frequency corrections and the decoder squelch, power or symbol view.
*
* Use this class on the JavaFX application thread.
*/
public class ChannelSpectrumView extends SplitPane implements Listener<ProcessingChain>
{
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumView.class);
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
private static final String LABEL_PREFIX_TUNER = "Tuner:";
private static final String LABEL_PREFIX_CHANNEL = "Channel:";
private static final String LABEL_PREFIX_DECODER = "Decoder:";
private static final double DIVIDER_POSITION = 0.4;
private final PlaylistManager mPlaylistManager;
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
private final ChannelSpectralDisplay mChannelSpectralDisplay = new ChannelSpectralDisplay();
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
private final Label mTunerPPMLabel = new Label(LABEL_PREFIX_PPM);
private final Label mTunerCorrectionLabel = new Label(LABEL_PREFIX_TUNER);
private final Label mChannelCorrectionLabel = new Label(LABEL_PREFIX_CHANNEL);
private final Label mDecoderCorrectionLabel = new Label(LABEL_PREFIX_DECODER);
private final NoiseSquelchView mNoiseSquelchView;
private final SignalPowerView mSignalPowerView;
private final SymbolView mSymbolView = new SymbolView();
private ProcessingChain mProcessingChain;
private boolean mPanelVisible = false;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for the channel configurations
*/
public ChannelSpectrumView(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
mSignalPowerView = new SignalPowerView(mPlaylistManager);
URL resource = getClass().getResource("/sdrtrunk_style.css");
if(resource != null)
{
mSymbolView.getStylesheets().add(resource.toExternalForm());
}
else
{
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
}
//Use the same color theme as the channel spectral display
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
Region leftSpacer = new Region();
Region rightSpacer = new Region();
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
HBox labels = new HBox(10, leftSpacer, mTunerPPMLabel, new Label("Corrections (Hz) "), mTunerCorrectionLabel,
mChannelCorrectionLabel, mDecoderCorrectionLabel, rightSpacer);
labels.setAlignment(Pos.CENTER);
labels.setPadding(new Insets(2));
VBox.setVgrow(mChannelSpectralDisplay, Priority.ALWAYS);
VBox spectrumBox = new VBox(labels, mChannelSpectralDisplay);
spectrumBox.setMinWidth(0);
getItems().addAll(spectrumBox, mNoiseSquelchView);
reset();
}
/**
* Pads the value to a fixed width and returns the prefix and padded value.
*/
private static String getPaddedLabel(String prefix, Number value)
{
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
}
/**
* Signals this view to indicate if it is visible, to turn on the DFT processor when the view is visible and turn
* it off when it's not.
* @param visible true to indicate that this view is showing/visible.
*/
public void setPanelVisible(boolean visible)
{
mPanelVisible = visible;
updateFFTProcessing();
mNoiseSquelchView.setShowing(visible);
mSymbolView.setShowing(visible);
}
/**
* Turns on DFT processing when there is a processing chain and the user has this view selected and visible.
* Otherwise, turns off DFT processing.
*/
private void updateFFTProcessing()
{
boolean active = mPanelVisible && mProcessingChain != null;
if(active)
{
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
}
else
{
mSampleStreamTapModule.removeListener();
}
mChannelSpectralDisplay.setActive(active);
}
/**
* Resets controls when changing processing chain source.
*/
private void reset()
{
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
mTunerPPMLabel.setDisable(true);
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setDisable(true);
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setDisable(true);
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setDisable(true);
mChannelSpectralDisplay.setChannel(0, 0, 0);
}
/**
* Shows the processing chain details. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
//Disconnect the previous processing chain.
if(mProcessingChain != null)
{
mNoiseSquelchView.setController(null);
mSignalPowerView.setProcessingChain(null);
mSymbolView.removeSymbolProvider();
mSymbolView.setProtocol("");
mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
mProcessingChain.removeModule(mSampleStreamTapModule);
}
reset();
mProcessingChain = processingChain;
if(mProcessingChain != null)
{
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
{
setRightComponent(mNoiseSquelchView);
mNoiseSquelchView.setController(nbfmDecoder);
}
else if(primaryDecoder instanceof AMDecoder)
{
setRightComponent(mSignalPowerView);
mSignalPowerView.setProcessingChain(mProcessingChain);
}
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
{
setRightComponent(mSymbolView);
mSymbolView.setSymbolProvider(feedbackDecoder);
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
}
mProcessingChain.addModule(mSampleStreamTapModule);
Source source = mProcessingChain.getSource();
int bandwidth = 0;
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
if(!tunerChannels.isEmpty())
{
bandwidth = tunerChannels.getFirst().getBandwidth();
}
}
if(source instanceof TunerChannelSource tcs)
{
mChannelSpectralDisplay.setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
}
}
updateFFTProcessing();
}
/**
* Shows the node on the right side of the split pane.
*/
private void setRightComponent(Node node)
{
if(getItems().size() < 2 || getItems().get(1) != node)
{
if(getItems().size() > 1)
{
getItems().remove(1);
}
getItems().add(node);
setDividerPositions(DIVIDER_POSITION);
}
}
/**
* Processor for source event stream to capture power level and squelch related source events.
*/
private class SourceEventProcessor implements Listener<SourceEvent>
{
@Override
public void receive(SourceEvent sourceEvent)
{
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS &&
sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
{
Platform.runLater(() -> {
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
mTunerPPMLabel.setDisable(false);
if(status.isAutoPPM())
{
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
}
else
{
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
}
mTunerCorrectionLabel.setDisable(false);
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
mChannelCorrectionLabel.setDisable(false);
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
mDecoderCorrectionLabel.setDisable(false);
});
//The offset cursor line is negative so that it aligns with where the signal is located instead of the
//correction that's needed to bring the signal to zero.
mChannelSpectralDisplay.setCarrierOffset(-status.getDecoderCorrection());
}
mSignalPowerView.receive(sourceEvent);
}
}
}

View File

@@ -1,349 +0,0 @@
package io.github.dsheirer.gui.control;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Power meter for displaying signal power levels in dB scale with optional peak value and squelch threshold lines.
*/
public class DbPowerMeter extends JComponent
{
public static final double DEFAULT_MINIMUM_POWER = -110.0d;
public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
private static final int BAR_WIDTH = 30;
private static final int PADDING = 3;
private static final int DOUBLE_PADDING = PADDING * 2;
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final Color COLOR_BAR = Color.LIGHT_GRAY;
private static final Color COLOR_THRESHOLD = Color.BLUE;
private static final Color COLOR_PEAK = Color.PINK;
private double mMinimumValue = DEFAULT_MINIMUM_POWER;
private double mMaximumValue = DEFAULT_MAXIMUM_POWER;
private double mExtent = mMaximumValue - mMinimumValue;
private double mPeak = mMinimumValue;
private double mPower = mMinimumValue;
private double mSquelchThreshold = mMinimumValue;
private boolean mPeakVisible = false;
private boolean mSquelchThresholdVisible = false;
/**
* Constructs an instance.
*/
public DbPowerMeter()
{
setPreferredSize(new Dimension(90, 100));
setBorder(BorderFactory.createTitledBorder("Power (dB)"));
}
/**
* Resets peak, power and squelch to minimums
*/
public void reset()
{
mPeak = mMinimumValue;
mPower = mMinimumValue;
mSquelchThreshold = mMinimumValue;
repaint();
}
/**
* Primary method for rendering the control
*/
@Override
protected void paintComponent(Graphics g)
{
super.paintComponent(g);
//Draw meter border
g.setColor(getForeground());
g.drawRect(getInsets().left, getInsets().top, BAR_WIDTH, getHeight() - getInsets().top - getInsets().bottom);
//Draw filled bar
g.setColor(COLOR_BAR);
int fillHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
int height = (int) (fillHeight * getPowerPercent() - PADDING);
int top = (fillHeight - height) + getInsets().top + PADDING;
g.fillRect(getInsets().left + PADDING, top, BAR_WIDTH - DOUBLE_PADDING, height);
//Draw threshold
if(isSquelchThresholdVisible())
{
g.setColor(COLOR_THRESHOLD);
drawPercentLine(g, getSquelchThresholdPercent());
}
//Draw peak line
if(isPeakVisible())
{
g.setColor(COLOR_PEAK);
drawPercentLine(g, getPeakPercent());
}
//Draw legend/scale
g.setColor(getForeground());
for(double legendValue: getScaleValues())
{
drawScaleText(g, legendValue);
}
}
/**
* Draws a line as a percentage of the value range (extent) using the current graphics color.
*/
private void drawPercentLine(Graphics g, double percentValue)
{
int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
int height = (int) (totalHeight * percentValue - PADDING);
int top = (totalHeight - height) + getInsets().top + PADDING;
int left = getInsets().left;
g.drawLine(left, top, left + BAR_WIDTH, top);
}
/**
* Draws a tick and text for the value
* @param g graphics object
* @param value to draw
*/
private void drawScaleText(Graphics g, double value)
{
double percent = getPercent(value);
int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
int height = (int) (totalHeight * percent - PADDING);
int top = (totalHeight - height) + getInsets().top + PADDING;
int left = getInsets().left + BAR_WIDTH - PADDING;
int textOffset = getFontMetrics(getFont()).getMaxAscent() / 2 - 2;
String label = DECIMAL_FORMAT.format(value);
g.drawString(label, left + DOUBLE_PADDING, top + textOffset);
}
/**
* Scale values to display, dynamically adapted to the height of the control
*/
private List<Double> getScaleValues()
{
double ascent = getFontMetrics(getFont()).getMaxAscent() * 2;
double labelCount = getHeight() / ascent;
double interval = getExtent() / labelCount;
List<Double> values = new ArrayList<>();
for(double x = 0; x > getMinimumValue(); x -= interval)
{
values.add(x);
}
return values;
}
/**
* Current power level
* @return power level (dB)
*/
public double getPower()
{
return mPower;
}
/**
* Sets the current power level constrained to the minimum and maximum values for this control.
* @param power (dB)
*/
public void setPower(double power)
{
if(power > getMaximumValue())
{
mPower = getMaximumValue();
}
else if(power < getMinimumValue())
{
mPower = getMinimumValue();
}
else
{
mPower = power;
}
repaint();
}
/**
* Squelch threshold value.
* @return threshold (dB)
*/
public double getSquelchThreshold()
{
return mSquelchThreshold;
}
/**
* Sets the squelch thresold value
* @param squelchThreshold (dB)
*/
public void setSquelchThreshold(double squelchThreshold)
{
mSquelchThreshold = squelchThreshold;
repaint();
}
/**
* Minimum displayable power level
* @return minimum (dB) - defaults to -110 dB
*/
public double getMinimumValue()
{
return mMinimumValue;
}
/**
* Sets the minimum displayable power level
* @param minimumValue (dB)
*/
public void setMinimumValue(double minimumValue)
{
mMinimumValue = minimumValue;
updateExtent();
repaint();
}
/**
* Maximum displayable power level
* @return maximum (dB) - defaults to 0 dB
*/
public double getMaximumValue()
{
return mMaximumValue;
}
/**
* Sets the maximum displayable power level
* @param maximumValue (dB)
*/
public void setMaximumValue(double maximumValue)
{
mMaximumValue = maximumValue;
updateExtent();
repaint();
}
/**
* Updates the min/max extent
*/
private void updateExtent()
{
mExtent = getMaximumValue() - getMinimumValue();
}
/**
* Extent or range of displayable values.
* @return extent (dB)
*/
private double getExtent()
{
return mExtent;
}
/**
* Peak value line
* @return peak (dB)
*/
public double getPeak()
{
return mPeak;
}
/**
* Sets the peak value line
* @param peak (db)
*/
public void setPeak(double peak)
{
mPeak = peak;
repaint();
}
/**
* Indicates if the peak line is set to visible and the current peak value is within the min/max range.
*/
public boolean isPeakVisible()
{
return mPeakVisible & isValidValue(getPeak());
}
/**
* Sets the peak line visibility
* @param peakVisible value
*/
public void setPeakVisible(boolean peakVisible)
{
mPeakVisible = peakVisible;
repaint();
}
/**
* Indicates if the squelch threshold line is set to visible and the current squelch threshold value is
* within the min/max displayable value range.
* @return true if visible.
*/
public boolean isSquelchThresholdVisible()
{
return mSquelchThresholdVisible & isValidValue(getSquelchThreshold());
}
/**
* Sets the visibility of the squelch threshold line
* @param squelchThresholdVisible visibility
*/
public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
{
mSquelchThresholdVisible = squelchThresholdVisible;
repaint();
}
/**
* Calculates the percentage of the min/max extent for the value
* @param value to calculate
* @return percentage of displayable value range
*/
private double getPercent(double value)
{
return (value - getMinimumValue()) / getExtent();
}
/**
* Power as percentage of displayable range
*/
private double getPowerPercent()
{
return getPercent(mPower);
}
/**
* Squelch threshold as percentage of displayable range
*/
private double getSquelchThresholdPercent()
{
return getPercent(mSquelchThreshold);
}
/**
* Peak as percentage of displayable range
*/
private double getPeakPercent()
{
return getPercent(mPeak);
}
/**
* Indicates if the value is valid, meaning it falls within the min/max range of displayable values.
*/
private boolean isValidValue(double value)
{
return getMinimumValue() <= value && value <= getMaximumValue();
}
}

View File

@@ -0,0 +1,64 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import com.google.common.base.Joiner;
import io.github.dsheirer.alias.Alias;
import java.util.List;
/**
* Alias table cell value: the alias names to show, with the color and icon of the alias that styles the cell.
* @param text of the alias names
* @param color of the styling alias as a 32-bit RGB integer
* @param iconName of the styling alias, or null
*/
public record AliasDisplay(String text, int color, String iconName) implements Comparable<AliasDisplay>
{
/**
* Creates the display for the aliases, styled by the first alias.
* @param aliases to display
* @return display or null when there are no aliases.
*/
public static AliasDisplay of(List<Alias> aliases)
{
if(aliases == null || aliases.isEmpty())
{
return null;
}
return of(Joiner.on(", ").skipNulls().join(aliases), aliases.getFirst());
}
/**
* Creates the display for the text, styled by the alias
* @param text to display
* @param alias to style the display
* @return display
*/
public static AliasDisplay of(String text, Alias alias)
{
return new AliasDisplay(text, alias.getColor(), alias.getIconName());
}
@Override
public int compareTo(AliasDisplay other)
{
return String.CASE_INSENSITIVE_ORDER.compare(text, other.text);
}
}

View File

@@ -0,0 +1,120 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.sample.Listener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* Activity summary details for the selected channel.
*/
public class ChannelDetailView extends VBox implements Listener<ProcessingChain>
{
private static final String EMPTY_DETAILS = "Please select a channel to view details";
private final ChannelProcessingManager mChannelProcessingManager;
private final Label mSystemLabel = new Label();
private final Label mSiteLabel = new Label();
private final Label mNameLabel = new Label();
private final TextArea mDetailTextArea = new TextArea(EMPTY_DETAILS);
private ProcessingChain mProcessingChain;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param channelProcessingManager for the channel of the processing chain
*/
public ChannelDetailView(ChannelProcessingManager channelProcessingManager)
{
mChannelProcessingManager = channelProcessingManager;
Button refreshButton = new Button("Refresh");
refreshButton.setTooltip(new Tooltip("Update the channel details"));
refreshButton.setOnAction(event -> refresh());
HBox labels = new HBox(5, new Label("System:"), grow(mSystemLabel), new Label("Site:"), grow(mSiteLabel),
new Label("Channel Name:"), grow(mNameLabel), refreshButton);
labels.setAlignment(Pos.CENTER_LEFT);
labels.setPadding(new Insets(3, 2, 3, 2));
mDetailTextArea.setEditable(false);
VBox.setVgrow(mDetailTextArea, Priority.ALWAYS);
getChildren().addAll(labels, mDetailTextArea);
}
/**
* Updates the details for the currently selected channel. Call on the JavaFX application thread.
*/
public void refresh()
{
receive(mProcessingChain);
}
private static Label grow(Label label)
{
label.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(label, Priority.ALWAYS);
return label;
}
/**
* Shows the details for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
mProcessingChain = processingChain;
Channel channel = mChannelProcessingManager.getChannel(processingChain);
mSystemLabel.setText(channel != null ? channel.getSystem() : null);
mSiteLabel.setText(channel != null ? channel.getSite() : null);
mNameLabel.setText(channel != null ?
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null);
if(processingChain != null)
{
StringBuilder sb = new StringBuilder();
for(DecoderState decoderState: processingChain.getDecoderStates())
{
sb.append(decoderState.getActivitySummary());
}
//Keep the scroll position, so that a refresh doesn't move the user back to the top
double scrollTop = mDetailTextArea.getScrollTop();
mDetailTextArea.setText(sb.toString());
mDetailTextArea.setScrollTop(scrollTop);
}
else
{
mDetailTextArea.setText(EMPTY_DETAILS);
}
}
}

View File

@@ -0,0 +1,463 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.metadata.ChannelAndMetadata;
import io.github.dsheirer.channel.metadata.ChannelMetadata;
import io.github.dsheirer.channel.metadata.ChannelMetadataField;
import io.github.dsheirer.channel.metadata.ChannelMetadataModel;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
import io.github.dsheirer.gui.table.ColumnConfigurationButton;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
import io.github.dsheirer.sample.Broadcaster;
import io.github.dsheirer.sample.Listener;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
/**
* Table of the channel metadata for the currently processing channels. Selecting a row broadcasts the processing
* chain for the channel to the registered listeners.
*/
public class ChannelMetadataView extends VBox implements ChannelMetadataModel.Listener
{
private static final String TABLE_KEY = "now.playing.channels";
private static final String SWING_TABLE_KEY = "channel.metadata.panel";
private static final String EMPTY_IDENTIFIER = "-----";
private static final String EMPTY_STATE = "----";
private static final Map<State,String> STATE_STYLES = new EnumMap<>(State.class);
static
{
//Colors match the Swing version of the table (java.awt.Color constants)
STATE_STYLES.put(State.ACTIVE, style("#00FFFF", "#0000FF"));
STATE_STYLES.put(State.CALL, style("#0000FF", "#FFFF00"));
STATE_STYLES.put(State.CONTROL, style("#FFC800", "#0000FF"));
STATE_STYLES.put(State.DATA, style("#00FF00", "#0000FF"));
STATE_STYLES.put(State.ENCRYPTED, style("#FF00FF", "#FFFFFF"));
STATE_STYLES.put(State.FADE, style("#C0C0C0", "#404040"));
STATE_STYLES.put(State.IDLE, style("#FFFFFF", "#404040"));
STATE_STYLES.put(State.RESET, style("#FFAFAF", "#FFFF00"));
STATE_STYLES.put(State.TEARDOWN, style("#404040", "#FFFFFF"));
}
private final ChannelProcessingManager mChannelProcessingManager;
private final ChannelMetadataModel mChannelMetadataModel;
private final IconModel mIconModel;
private final TalkgroupFormatPreference mTalkgroupFormatPreference;
private final TableView<Row> mTableView = new TableView<>();
private final ObservableList<Row> mRows = FXCollections.observableArrayList();
//Changed on the JavaFX thread and read on the decoder threads that send channel metadata updates
private final Map<ChannelMetadata,Row> mRowMap = new ConcurrentHashMap<>();
private final Broadcaster<ProcessingChain> mSelectedProcessingChainBroadcaster = new Broadcaster<>();
private final TableColumnMonitor<Row> mTableColumnMonitor;
private Channel mUserSelectedChannel;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param channelProcessingManager for the channel metadata and processing chains
* @param iconModel for alias icons
* @param userPreferences for talkgroup formatting and column settings
*/
public ChannelMetadataView(ChannelProcessingManager channelProcessingManager, IconModel iconModel,
UserPreferences userPreferences)
{
mChannelProcessingManager = channelProcessingManager;
mChannelMetadataModel = mChannelProcessingManager.getChannelMetadataModel();
mIconModel = iconModel;
mTalkgroupFormatPreference = userPreferences.getTalkgroupFormatPreference();
mTableView.getColumns().addAll(createColumns());
mTableView.setItems(mRows);
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
mTableView.setPlaceholder(new javafx.scene.control.Label("No channels are processing"));
mTableView.setRowFactory(tableView -> createRow());
mTableView.getSelectionModel().selectedItemProperty().addListener((observable, oldRow, newRow) -> selected(newRow));
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, TABLE_KEY, userPreferences.getSwingPreference(),
SWING_TABLE_KEY);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox toolbar = new HBox(5, spacer, new ColumnConfigurationButton(mTableColumnMonitor, "Now Playing Columns"));
toolbar.setAlignment(Pos.CENTER_RIGHT);
toolbar.setPadding(new Insets(2, 2, 2, 2));
VBox.setVgrow(mTableView, Priority.ALWAYS);
getChildren().addAll(toolbar, mTableView);
for(ChannelMetadata channelMetadata: mChannelMetadataModel.addListener(this))
{
addRow(channelMetadata);
}
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Adds the listener to receive the processing chain for the channel that the user selects in the table, or null
* when the selection is cleared. Notifications arrive on the JavaFX application thread.
*/
public void addProcessingChainSelectionListener(Listener<ProcessingChain> listener)
{
mSelectedProcessingChainBroadcaster.addListener(listener);
}
/**
* Prepares this view for disposal.
*/
public void dispose()
{
mChannelMetadataModel.removeListener(this);
MyEventBus.getGlobalEventBus().unregister(this);
}
/**
* Receives preference update notifications via the event bus
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
//Talkgroup format changes the identifiers and the application preference can change the alias colors
if(preferenceType == PreferenceType.TALKGROUP_FORMAT || preferenceType == PreferenceType.APPLICATION)
{
Platform.runLater(() -> mRows.forEach(Row::refresh));
}
}
@Override
public void added(ChannelAndMetadata channelAndMetadata)
{
List<ChannelMetadata> channelMetadata = new ArrayList<>(channelAndMetadata.getChannelMetadata());
Platform.runLater(() -> {
for(ChannelMetadata metadata: channelMetadata)
{
addRow(metadata);
}
//The DMR Capacity+ rest channel rotation converts the selected channel to a traffic channel and restarts
//the previous channel. Re-select the row of the restarted channel so that the user isn't chasing it.
if(mUserSelectedChannel != null && !channelMetadata.isEmpty() &&
mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
{
Row row = mRowMap.get(channelMetadata.getFirst());
if(row != null)
{
mTableView.getSelectionModel().select(row);
}
}
});
}
@Override
public void removed(ChannelMetadata channelMetadata)
{
Platform.runLater(() -> {
Row row = mRowMap.remove(channelMetadata);
if(row != null)
{
mRows.remove(row);
}
});
}
@Override
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
{
Row row = mRowMap.get(channelMetadata);
if(row != null)
{
row.scheduleRefresh();
}
}
/**
* Adds a row for the channel metadata. Call on the JavaFX application thread.
*/
private void addRow(ChannelMetadata channelMetadata)
{
if(!mRowMap.containsKey(channelMetadata))
{
Row row = new Row(channelMetadata);
mRowMap.put(channelMetadata, row);
mRows.add(row);
}
}
private void selected(Row row)
{
ProcessingChain processingChain = null;
if(row != null)
{
mUserSelectedChannel = mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata());
processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
}
mSelectedProcessingChainBroadcaster.broadcast(processingChain);
}
private TableRow<Row> createRow()
{
TableRow<Row> tableRow = new TableRow<>();
ContextMenu contextMenu = new ContextMenu();
contextMenu.setOnShowing(event -> {
contextMenu.getItems().clear();
Row row = tableRow.getItem();
Channel channel = row != null ? mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata()) : null;
if(channel != null)
{
MenuItem viewChannel = new MenuItem("View/Edit: " + channel.getShortTitle());
viewChannel.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
contextMenu.getItems().add(viewChannel);
}
else
{
MenuItem none = new MenuItem("No Actions Available");
none.setDisable(true);
contextMenu.getItems().add(none);
}
});
tableRow.setContextMenu(contextMenu);
return tableRow;
}
private List<TableColumn<Row,?>> createColumns()
{
List<TableColumn<Row,?>> columns = new ArrayList<>();
TableColumn<Row,State> status = NowPlayingCells.column("status", "Status", null, Pos.CENTER,
row -> {
ChannelStateIdentifier identifier = row.getChannelMetadata().getChannelStateIdentifier();
return identifier != null ? identifier.getValue() : null;
}, Row::revisionProperty);
status.setCellFactory(column -> new NowPlayingCells.AlignedCell<>(column)
{
@Override
protected void updateItem(State state, boolean empty)
{
super.updateItem(state, empty);
if(empty)
{
setText(null);
setStyle(null);
}
else if(state == null)
{
setText(EMPTY_STATE);
setStyle(null);
}
else
{
setText(state.getDisplayValue());
setStyle(STATE_STYLES.get(state));
}
}
});
columns.add(status);
columns.add(NowPlayingCells.column("decoder", "Decoder", null, Pos.CENTER,
row -> toText(row.getChannelMetadata().getDecoderTypeConfigurationIdentifier()), Row::revisionProperty));
columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
row -> {
String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getFromIdentifier());
return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
}, Row::revisionProperty));
//Talker alias sent over the air by the radio (e.g. DMR or P25)
columns.add(NowPlayingCells.column("talker", "Talker", null, Pos.CENTER,
row -> {
Identifier talkerAlias = row.getChannelMetadata().getTalkerAliasIdentifier();
return talkerAlias != null && talkerAlias.getValue() != null ? talkerAlias.getValue().toString() : null;
}, Row::revisionProperty));
TableColumn<Row,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias", "From Alias",
Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getFromIdentifierAliases()),
Row::revisionProperty);
fromAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
columns.add(fromAlias);
columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
row -> {
String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getToIdentifier());
return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
}, Row::revisionProperty));
TableColumn<Row,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias", "To Alias",
Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getToIdentifierAliases()),
Row::revisionProperty);
toAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
columns.add(toAlias);
columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
row -> getLogicalChannelName(row.getChannelMetadata()), Row::revisionProperty));
TableColumn<Row,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null, Pos.CENTER,
row -> row.getChannelMetadata().getFrequencyConfigurationIdentifier() != null ?
row.getChannelMetadata().getFrequencyConfigurationIdentifier().getValue() : null,
Row::revisionProperty);
DecimalFormat frequencyFormat = new DecimalFormat("#.00000");
frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
columns.add(frequency);
columns.add(NowPlayingCells.column("channel.name", "Channel Name", null, Pos.CENTER,
row -> toText(row.getChannelMetadata().getChannelNameConfigurationIdentifier()), Row::revisionProperty));
//The channel rows are in channel start order, the same as the Swing version of the table
for(TableColumn<Row,?> column: columns)
{
column.setSortable(false);
}
//Column numbers in the Swing version of the table, for its stored column widths. Talker is a new column.
String[] swingColumns = {"status", "decoder", "from", "from.alias", "to", "to.alias", "channel", "frequency",
"channel.name"};
for(TableColumn<Row,?> column: columns)
{
int swingColumn = Arrays.asList(swingColumns).indexOf(column.getId());
if(swingColumn >= 0)
{
TableColumnMonitor.setLegacyIndex(column, swingColumn);
}
}
return columns;
}
/**
* Decoder logical channel name with the timeslot, if any
*/
private static String getLogicalChannelName(ChannelMetadata channelMetadata)
{
DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
if(channelMetadata.hasTimeslot())
{
if(id == null)
{
return "TS:" + channelMetadata.getTimeslot();
}
return id.getValue().replace(" TS1", "").replace(" TS2", "") + " TS:" + channelMetadata.getTimeslot();
}
return id != null ? id.getValue() : null;
}
private static String toText(Identifier identifier)
{
return identifier != null ? identifier.toString() : null;
}
private static String style(String background, String foreground)
{
return "-fx-background-color: " + background + "; -fx-text-fill: " + foreground + ";";
}
/**
* Table row item for a channel metadata. The revision increments each time the channel metadata changes so that
* the row cells update.
*/
public static class Row
{
private final ChannelMetadata mChannelMetadata;
private final IntegerProperty mRevision = new SimpleIntegerProperty();
private final AtomicBoolean mRefreshPending = new AtomicBoolean();
Row(ChannelMetadata channelMetadata)
{
mChannelMetadata = channelMetadata;
}
public ChannelMetadata getChannelMetadata()
{
return mChannelMetadata;
}
public IntegerProperty revisionProperty()
{
return mRevision;
}
/**
* Updates the row cells. Call on the JavaFX application thread.
*/
void refresh()
{
mRevision.set(mRevision.get() + 1);
}
/**
* Schedules a row cell update on the JavaFX application thread. Can be called from any thread. Updates
* that arrive while one is pending are combined.
*/
void scheduleRefresh()
{
if(mRefreshPending.compareAndSet(false, true))
{
Platform.runLater(() -> {
mRefreshPending.set(false);
refresh();
});
}
}
}
}

View File

@@ -0,0 +1,407 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import com.google.common.base.Joiner;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.event.DecodeEventHistory;
import io.github.dsheirer.module.decode.event.IDecodeEvent;
import io.github.dsheirer.module.decode.event.filter.DecodeEventFilterSet;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.TableColumn;
/**
* Table of the decode events for the selected channel.
*/
public class DecodeEventView extends HistoryTableView<IDecodeEvent,IDecodeEvent> implements Listener<ProcessingChain>
{
private static final String TABLE_KEY = "now.playing.events";
private static final String SWING_TABLE_KEY = "decode.event.panel";
private final TimestampFormatter mTimestampFormatter;
private final FilterSet<IDecodeEvent> mFilterSet = new DecodeEventFilterSet();
private DecodeEventHistory mCurrentEventHistory;
private Listener<IDecodeEvent> mCurrentEventListener;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param aliasModel for aliasing identifiers
*/
public DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel)
{
this(iconModel, userPreferences, aliasModel, new TimestampFormatter(userPreferences));
}
private DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel,
TimestampFormatter timestampFormatter)
{
super(createColumns(iconModel, userPreferences, aliasModel, timestampFormatter), TABLE_KEY,
userPreferences.getSwingPreference(), SWING_TABLE_KEY, "Event Filter Editor", "Event Columns");
mTimestampFormatter = timestampFormatter;
getHistoryControls().setFilterSet(mFilterSet);
//Filter changes come from the Swing filter editor
mFilterSet.register(() -> Platform.runLater(this::refilter));
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Prepares this view for disposal
*/
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);
receive(null);
}
/**
* Receives preference update notifications via the event bus
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT ||
preferenceType == PreferenceType.APPLICATION)
{
Platform.runLater(this::refreshAll);
}
}
@Override
protected boolean include(IDecodeEvent event)
{
return mFilterSet.canProcess(event) && mFilterSet.passes(event);
}
/**
* Shows the decode events for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
if(mCurrentEventHistory != null)
{
mCurrentEventHistory.removeListener(mCurrentEventListener);
}
mCurrentEventHistory = null;
mCurrentEventListener = null;
if(processingChain != null)
{
DecodeEventHistory history = processingChain.getDecodeEventHistory();
//A listener for each history so that events from the previous channel are ignored after a change
Listener<IDecodeEvent> listener = new Listener<>()
{
@Override
public void receive(IDecodeEvent event)
{
Platform.runLater(() -> {
if(mCurrentEventListener == this)
{
add(event);
}
});
}
};
mCurrentEventHistory = history;
mCurrentEventListener = listener;
setItems(history.getItems());
history.addListener(listener);
getHistoryControls().setControlsEnabled(true);
}
else
{
clear();
getHistoryControls().setControlsEnabled(false);
}
}
@Override
protected void refreshAll()
{
mTimestampFormatter.update();
super.refreshAll();
}
private static List<TableColumn<Row<IDecodeEvent>,?>> createColumns(IconModel iconModel,
UserPreferences userPreferences,
AliasModel aliasModel,
TimestampFormatter timestampFormatter)
{
List<TableColumn<Row<IDecodeEvent>,?>> columns = new ArrayList<>();
TableColumn<Row<IDecodeEvent>,Long> time = NowPlayingCells.column("time", "Time", null, Pos.CENTER,
row -> row.getItem().getTimeStart(), Row::revisionProperty);
time.setCellFactory(NowPlayingCells.formatted(timestampFormatter::format));
columns.add(time);
TableColumn<Row<IDecodeEvent>,Long> duration = NowPlayingCells.column("duration", "Duration", null,
Pos.CENTER, row -> row.getItem().getDuration(), Row::revisionProperty);
DecimalFormat durationFormat = new DecimalFormat("0.0");
duration.setCellFactory(NowPlayingCells.formatted(value -> value > 0 ? durationFormat.format(value / 1e3d) : null));
columns.add(duration);
columns.add(NowPlayingCells.column("event", "Event", null, Pos.CENTER_LEFT,
row -> row.getItem().getEventType() != null ? row.getItem().getEventType().getLabel() : null,
Row::revisionProperty));
columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.FROM, userPreferences),
Row::revisionProperty));
//Talker alias sent over the air by the radio (e.g. DMR, P25 or NXDN)
columns.add(NowPlayingCells.column("talker", "Talker", null, Pos.CENTER,
row -> getTalkerAlias(row.getItem().getIdentifierCollection()), Row::revisionProperty));
TableColumn<Row<IDecodeEvent>,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias",
"From Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.FROM, aliasModel),
Row::revisionProperty);
fromAlias.setCellFactory(NowPlayingCells.alias(iconModel));
columns.add(fromAlias);
columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.TO, userPreferences),
Row::revisionProperty));
TableColumn<Row<IDecodeEvent>,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias",
"To Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.TO, aliasModel),
Row::revisionProperty);
toAlias.setCellFactory(NowPlayingCells.alias(iconModel));
columns.add(toAlias);
columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
row -> getChannel(row.getItem()), Row::revisionProperty));
TableColumn<Row<IDecodeEvent>,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null,
Pos.CENTER, row -> {
IChannelDescriptor descriptor = row.getItem().getChannelDescriptor();
return descriptor != null && descriptor.getDownlinkFrequency() > 0 ?
descriptor.getDownlinkFrequency() : null;
}, Row::revisionProperty);
DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
columns.add(frequency);
columns.add(NowPlayingCells.column("details", "Details", null, Pos.CENTER_LEFT,
row -> row.getItem().getDetails(), Row::revisionProperty));
//Column numbers in the Swing version of the table, for its stored column widths. Talker is a new column.
List<String> swingColumns = List.of("time", "duration", "event", "from", "from.alias", "to", "to.alias",
"channel", "frequency", "details");
for(TableColumn<Row<IDecodeEvent>,?> column: columns)
{
if(swingColumns.contains(column.getId()))
{
TableColumnMonitor.setLegacyIndex(column, swingColumns.indexOf(column.getId()));
}
}
return columns;
}
/**
* Talker alias(es) in the identifier collection
*/
private static String getTalkerAlias(IdentifierCollection identifierCollection)
{
if(identifierCollection == null)
{
return null;
}
List<String> aliases = new ArrayList<>();
for(Identifier identifier: identifierCollection.getIdentifiers(Form.TALKER_ALIAS))
{
if(identifier.getValue() != null)
{
aliases.add(identifier.getValue().toString());
}
}
return aliases.isEmpty() ? null : String.join(",", aliases);
}
/**
* Identifiers with the role, without talker aliases, which have their own column
*/
private static List<Identifier> getIdentifiers(IdentifierCollection identifierCollection, Role role)
{
List<Identifier> identifiers = new ArrayList<>(identifierCollection.getIdentifiers(role));
identifiers.removeIf(identifier -> identifier.getForm() == Form.TALKER_ALIAS);
return identifiers;
}
/**
* Event timestamp formatter that uses the timestamp format preference.
*/
private static class TimestampFormatter
{
private final UserPreferences mUserPreferences;
private SimpleDateFormat mFormat;
TimestampFormatter(UserPreferences userPreferences)
{
mUserPreferences = userPreferences;
update();
}
/**
* Updates the format from the preference
*/
void update()
{
mFormat = mUserPreferences.getDecodeEventPreference().getTimestampFormat().getFormatter();
}
String format(long timestamp)
{
return mFormat.format(new Date(timestamp));
}
}
/**
* Channel descriptor and timeslot for the event
*/
private static String getChannel(IDecodeEvent event)
{
IChannelDescriptor channelDescriptor = event.getChannelDescriptor();
boolean timeslot = event.hasTimeslot() && !event.toString().contains("TS");
if(channelDescriptor != null)
{
return timeslot ? channelDescriptor + " TS" + event.getTimeslot() : channelDescriptor.toString();
}
return timeslot ? "TS" + event.getTimeslot() : null;
}
/**
* Formats the identifiers for the role as a comma separated list
*/
private static String formatIdentifiers(IdentifierCollection identifierCollection, Role role,
UserPreferences userPreferences)
{
if(identifierCollection == null)
{
return null;
}
List<Identifier> identifiers = getIdentifiers(identifierCollection, role);
if(identifiers == null || identifiers.isEmpty())
{
return null;
}
StringBuilder sb = new StringBuilder();
for(Identifier identifier: identifiers)
{
if(!sb.isEmpty())
{
sb.append(",");
}
if(identifier.getForm() == Form.TALKGROUP || identifier.getForm() == Form.RADIO ||
identifier.getForm() == Form.PATCH_GROUP)
{
sb.append(userPreferences.getTalkgroupFormatPreference().format(identifier));
}
else
{
sb.append(identifier);
}
}
return sb.toString();
}
/**
* Aliases for the identifiers with the role. The display is styled by the first alias of the last aliased
* identifier, the same as the Swing version of the table.
*/
private static AliasDisplay getAliases(IdentifierCollection identifierCollection, Role role, AliasModel aliasModel)
{
if(identifierCollection == null)
{
return null;
}
List<Identifier> identifiers = getIdentifiers(identifierCollection, role);
if(identifiers == null || identifiers.isEmpty())
{
return null;
}
AliasList aliasList = aliasModel.getAliasList(identifierCollection);
if(aliasList == null)
{
return null;
}
StringBuilder sb = new StringBuilder();
Alias styleAlias = null;
for(Identifier identifier: identifiers)
{
List<Alias> aliases = aliasList.getAliases(identifier);
if(!aliases.isEmpty())
{
if(!sb.isEmpty())
{
sb.append(",");
}
sb.append(Joiner.on(", ").skipNulls().join(aliases));
styleAlias = aliases.getFirst();
}
}
return styleAlias != null ? AliasDisplay.of(sb.toString(), styleAlias) : null;
}
}

View File

@@ -0,0 +1,153 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.filter.FilterEditor;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.gui.table.ColumnConfigurationButton;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import java.awt.EventQueue;
import java.util.function.IntConsumer;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
/**
* Toolbar for a history table: filter editor, clear, history size and column configuration controls.
*/
public class HistoryControls<T> extends HBox
{
/**
* Default number of items in the history
*/
public static final int DEFAULT_HISTORY_SIZE = 200;
private static final int MAXIMUM_HISTORY_SIZE = 2000;
private final String mFilterEditorTitle;
private final Button mFilterButton = new Button("Filters");
private final Button mClearButton = new Button("Clear");
private final Label mHistoryLabel = new Label("History:");
private final Slider mHistorySlider = new Slider(0, MAXIMUM_HISTORY_SIZE, DEFAULT_HISTORY_SIZE);
private final Label mHistoryValueLabel = new Label(String.valueOf(DEFAULT_HISTORY_SIZE));
//Swing filter editor, accessed on the Swing event thread
private FilterEditor<T> mFilterEditor;
private FilterSet<T> mFilterSet;
/**
* Constructs an instance
* @param filterEditorTitle for the filter editor window
* @param clear action for the clear button
* @param historySizeListener to receive history size changes
* @param tableColumnMonitor for the column configuration button
* @param columnsTitle for the column configuration editor
*/
public HistoryControls(String filterEditorTitle, Runnable clear, IntConsumer historySizeListener,
TableColumnMonitor<?> tableColumnMonitor, String columnsTitle)
{
super(5);
mFilterEditorTitle = filterEditorTitle;
setAlignment(Pos.CENTER_LEFT);
setPadding(new Insets(3, 2, 3, 2));
mFilterButton.setTooltip(new Tooltip("Edit filters"));
mFilterButton.setOnAction(event -> showFilterEditor());
mClearButton.setTooltip(new Tooltip("Clears the history"));
mClearButton.setOnAction(event -> clear.run());
mHistorySlider.setTooltip(new Tooltip("Adjust history size. Double-click to reset to default " +
DEFAULT_HISTORY_SIZE));
mHistorySlider.setBlockIncrement(25);
mHistorySlider.setPrefWidth(200);
mHistorySlider.valueProperty().addListener((observable, oldValue, newValue) -> {
int size = newValue.intValue();
mHistoryValueLabel.setText(String.valueOf(size));
historySizeListener.accept(size);
});
mHistorySlider.setOnMouseClicked(event -> {
if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2)
{
mHistorySlider.setValue(DEFAULT_HISTORY_SIZE);
}
});
mHistoryValueLabel.setMinWidth(40);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
getChildren().addAll(mFilterButton, mClearButton, mHistoryLabel, mHistorySlider, mHistoryValueLabel, spacer,
new ColumnConfigurationButton(tableColumnMonitor, columnsTitle));
setControlsEnabled(false);
}
/**
* Sets the enabled state of the history controls. The column configuration button is always enabled.
*/
public void setControlsEnabled(boolean enabled)
{
mFilterButton.setDisable(!enabled);
mClearButton.setDisable(!enabled);
mHistoryLabel.setDisable(!enabled);
mHistorySlider.setDisable(!enabled);
mHistoryValueLabel.setDisable(!enabled);
}
/**
* Updates the filter set for the filter editor.
* @param filterSet to edit, or null
*/
public void setFilterSet(FilterSet<T> filterSet)
{
mFilterSet = filterSet;
EventQueue.invokeLater(() -> {
if(mFilterEditor != null && filterSet != null)
{
mFilterEditor.updateFilterSet(filterSet);
}
});
}
/**
* Shows the filter editor. The filter editor is a Swing window.
*/
private void showFilterEditor()
{
FilterSet<T> filterSet = mFilterSet;
if(filterSet != null)
{
EventQueue.invokeLater(() -> {
if(mFilterEditor == null)
{
mFilterEditor = new FilterEditor<>(mFilterEditorTitle, null, filterSet);
}
mFilterEditor.setVisible(true);
mFilterEditor.toFront();
});
}
}
}

View File

@@ -0,0 +1,206 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.preference.swing.SwingPreference;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* Table of a history of items, newest first, with filter, clear, history size and column configuration controls.
*
* Use this class on the JavaFX application thread.
*
* @param <T> type of the history items
* @param <F> type of the filtered objects
*/
public abstract class HistoryTableView<T,F> extends VBox
{
private final ObservableList<Row<T>> mRows = FXCollections.observableArrayList();
private final FilteredList<Row<T>> mFilteredRows = new FilteredList<>(mRows);
private final Map<T,Row<T>> mRowMap = new HashMap<>();
private final TableView<Row<T>> mTableView = new TableView<>();
private final TableColumnMonitor<Row<T>> mTableColumnMonitor;
private final HistoryControls<F> mHistoryControls;
private int mHistorySize = HistoryControls.DEFAULT_HISTORY_SIZE;
/**
* Constructs an instance
* @param columns for the table
* @param tableKey for the stored column settings
* @param swingPreference with the legacy Swing column widths
* @param swingTableKey of the legacy Swing table
* @param filterEditorTitle for the filter editor window
* @param columnsTitle for the column configuration editor window
*/
protected HistoryTableView(List<TableColumn<Row<T>,?>> columns, String tableKey, SwingPreference swingPreference,
String swingTableKey, String filterEditorTitle, String columnsTitle)
{
mTableView.getColumns().addAll(columns);
SortedList<Row<T>> sortedRows = new SortedList<>(mFilteredRows);
sortedRows.comparatorProperty().bind(mTableView.comparatorProperty());
mTableView.setItems(sortedRows);
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
mTableView.setPlaceholder(new Label(""));
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, tableKey, swingPreference, swingTableKey);
mHistoryControls = new HistoryControls<>(filterEditorTitle, this::clear, this::setHistorySize,
mTableColumnMonitor, columnsTitle);
VBox.setVgrow(mTableView, Priority.ALWAYS);
getChildren().addAll(mHistoryControls, mTableView);
refilter();
}
/**
* Indicates if the item passes the current filters
*/
protected abstract boolean include(T item);
/**
* History controls toolbar
*/
protected HistoryControls<F> getHistoryControls()
{
return mHistoryControls;
}
/**
* Re-applies the filters to the items, after a filter change.
*/
protected void refilter()
{
mFilteredRows.setPredicate(row -> include(row.getItem()));
}
/**
* Adds the item to the top of the history, or updates the row of the item when it is already in the history.
* Removes the oldest items when the history is larger than the history size.
*/
protected void add(T item)
{
Row<T> row = mRowMap.get(item);
if(row != null)
{
//Replace the row with itself so that the filter re-checks the updated item
int index = mRows.indexOf(row);
if(index >= 0)
{
mRows.set(index, row);
}
row.refresh();
}
else
{
row = new Row<>(item);
mRowMap.put(item, row);
mRows.addFirst(row);
trim();
}
}
/**
* Replaces the history with the items, oldest first.
*/
protected void setItems(List<T> items)
{
clear();
for(T item: items)
{
add(item);
}
}
/**
* Removes all items from the history
*/
protected void clear()
{
mRows.clear();
mRowMap.clear();
}
/**
* Updates the cells of all rows, after a change to the display formats.
*/
protected void refreshAll()
{
mRows.forEach(Row::refresh);
}
private void setHistorySize(int historySize)
{
mHistorySize = historySize;
trim();
}
private void trim()
{
while(mRows.size() > mHistorySize)
{
mRowMap.remove(mRows.removeLast().getItem());
}
}
/**
* Table row item for a history item. The revision increments each time the item changes so that the row cells
* update.
*/
public static class Row<T>
{
private final T mItem;
private final IntegerProperty mRevision = new SimpleIntegerProperty();
Row(T item)
{
mItem = item;
}
public T getItem()
{
return mItem;
}
public IntegerProperty revisionProperty()
{
return mRevision;
}
void refresh()
{
mRevision.set(mRevision.get() + 1);
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.MessageHistory;
import io.github.dsheirer.message.StuffBitsMessage;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.event.MessageItem;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.TableColumn;
/**
* Table of the decoded messages for the selected channel.
*/
public class MessageActivityView extends HistoryTableView<MessageItem,IMessage> implements Listener<ProcessingChain>
{
private static final String TABLE_KEY = "now.playing.messages";
private static final String SWING_TABLE_KEY = "message.activity.panel";
private MessageHistory mCurrentMessageHistory;
private Listener<IMessage> mCurrentMessageListener;
private FilterSet<IMessage> mMessageFilterSet;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param userPreferences for the column settings
*/
public MessageActivityView(UserPreferences userPreferences)
{
super(createColumns(), TABLE_KEY, userPreferences.getSwingPreference(), SWING_TABLE_KEY,
"Message Filter Editor", "Message Columns");
}
@Override
protected boolean include(MessageItem item)
{
FilterSet<IMessage> filterSet = mMessageFilterSet;
if(filterSet != null && item.getMessage() != null)
{
return filterSet.canProcess(item.getMessage()) && filterSet.passes(item.getMessage());
}
return false;
}
/**
* Shows the messages for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
if(mCurrentMessageHistory != null)
{
mCurrentMessageHistory.removeListener(mCurrentMessageListener);
}
//Unregister from changes made to the filter set
if(mMessageFilterSet != null)
{
mMessageFilterSet.register(null);
}
mCurrentMessageHistory = null;
mCurrentMessageListener = null;
if(processingChain != null)
{
MessageHistory history = processingChain.getMessageHistory();
FilterSet<IMessage> filterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
mMessageFilterSet = filterSet;
//Filter changes come from the Swing filter editor
filterSet.register(() -> Platform.runLater(() -> {
if(mMessageFilterSet == filterSet)
{
refilter();
}
}));
getHistoryControls().setFilterSet(filterSet);
//A listener for each history so that messages from the previous channel are ignored after a change
Listener<IMessage> listener = new Listener<>()
{
@Override
public void receive(IMessage message)
{
//Don't process tail bits or stuff bits message fragments
if(!(message instanceof StuffBitsMessage))
{
MessageItem item = new MessageItem(message);
Platform.runLater(() -> {
if(mCurrentMessageListener == this)
{
add(item);
}
});
}
}
};
mCurrentMessageHistory = history;
mCurrentMessageListener = listener;
List<MessageItem> items = new ArrayList<>();
for(IMessage message: history.getItems())
{
items.add(new MessageItem(message));
}
setItems(items);
refilter();
history.addListener(listener);
getHistoryControls().setControlsEnabled(true);
}
else
{
mMessageFilterSet = null;
clear();
getHistoryControls().setControlsEnabled(false);
}
}
private static List<TableColumn<Row<MessageItem>,?>> createColumns()
{
SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
List<TableColumn<Row<MessageItem>,?>> columns = new ArrayList<>();
columns.add(NowPlayingCells.column("time", "Time", null, Pos.CENTER_LEFT,
row -> row.getItem().getTimestamp(timeFormat), null));
columns.add(NowPlayingCells.column("protocol", "Protocol", null, Pos.CENTER_LEFT,
row -> row.getItem().getProtocol(), null));
columns.add(NowPlayingCells.column("timeslot", "Timeslot", null, Pos.CENTER_LEFT,
row -> row.getItem().getTimeslot(), null));
columns.add(NowPlayingCells.column("message", "Message", null, Pos.CENTER_LEFT,
row -> row.getItem().getText(), null));
return columns;
}
}

View File

@@ -0,0 +1,166 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.Icon;
import io.github.dsheirer.icon.IconModel;
import java.util.function.Function;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.image.ImageView;
import javafx.util.Callback;
import javax.swing.UIManager;
/**
* Table column and cell helpers for the Now Playing tables.
*/
public final class NowPlayingCells
{
private static final String ALIGNMENT = "now.playing.cell.alignment";
private NowPlayingCells()
{
}
/**
* Creates a table column.
* @param id that identifies the column for storing the column settings. Don't change it, or users lose the
* stored order, visibility and width of the column.
* @param text for the column header
* @param configurationName for the column in the column configuration editor, or null to use the header text
* @param alignment of the cell contents
* @param value of the column for the row item. The value is recalculated each time the row revision changes.
* @param revision of the row item, or null when the row value doesn't change
*/
public static <S,T> TableColumn<S,T> column(String id, String text, String configurationName, Pos alignment,
Function<S,T> value, Function<S,ObservableValue<?>> revision)
{
TableColumn<S,T> column = new TableColumn<>(text);
column.setId(id);
if(configurationName != null)
{
TableColumnMonitor.setName(column, configurationName);
}
column.getProperties().put(ALIGNMENT, alignment);
column.setCellFactory(formatted(Object::toString));
if(revision == null)
{
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue())));
}
else
{
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue()),
revision.apply(param.getValue())));
}
return column;
}
/**
* Cell factory that shows the cell value formatted as text.
* @param formatter for non-null values. Can return null for an empty cell.
*/
public static <S,T> Callback<TableColumn<S,T>,TableCell<S,T>> formatted(Function<T,String> formatter)
{
return column -> new AlignedCell<>(column)
{
@Override
protected void updateItem(T item, boolean empty)
{
super.updateItem(item, empty);
setText(empty || item == null ? null : formatter.apply(item));
}
};
}
/**
* Cell factory for alias values that shows the alias names in the alias color with the alias icon.
* @param iconModel for the alias icons
*/
public static <S> Callback<TableColumn<S,AliasDisplay>,TableCell<S,AliasDisplay>> alias(IconModel iconModel)
{
return column -> new AlignedCell<>(column)
{
@Override
protected void updateItem(AliasDisplay item, boolean empty)
{
super.updateItem(item, empty);
if(empty || item == null)
{
setText(null);
setGraphic(null);
setStyle(null);
}
else
{
setText(item.text());
Icon icon = iconModel.getIcon(item.iconName());
setGraphic(icon != null && icon.getFxImage() != null ? new ImageView(icon.getFxImage()) : null);
java.awt.Color color = new java.awt.Color(item.color());
java.awt.Color background = UIManager.getColor("Table.background");
java.awt.Color readable = background != null ?
ThemeManager.getInstance().getReadableColor(color, background) : color;
String fill = toWebColor(readable.getRGB());
//An inline style, since the themed table row style otherwise replaces the text fill
setStyle("-fx-text-fill: " + fill + ";");
}
}
};
}
/**
* Formats the RGB value as a web color.
* @param rgb color value
* @return web color, e.g. #FF0000
*/
public static String toWebColor(int rgb)
{
return String.format("#%06X", rgb & 0xFFFFFF);
}
/**
* Table cell that aligns its contents with the alignment of its column.
*/
public static class AlignedCell<S,T> extends TableCell<S,T>
{
/**
* Constructs an instance
* @param column of the cell
*/
public AlignedCell(TableColumn<S,T> column)
{
if(column.getProperties().get(ALIGNMENT) instanceof Pos alignment)
{
setAlignment(alignment);
}
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
/**
* Swing panel that holds the JavaFX Now Playing view for the main application window. The JavaFX view is created
* on the JavaFX application thread after construction.
*/
public class NowPlayingPanel extends JFXPanel
{
private NowPlayingView mNowPlayingView;
private boolean mDetailTabsVisible;
/**
* Constructs an instance
* @param playlistManager for the channels
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param detailTabsVisible to show the channel detail tabs
*/
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
boolean detailTabsVisible)
{
mDetailTabsVisible = detailTabsVisible;
Platform.setImplicitExit(false);
ThemeManager.getInstance().registerSwing(this);
Platform.runLater(() -> {
NowPlayingView view;
synchronized(NowPlayingPanel.this)
{
view = new NowPlayingView(playlistManager, iconModel, userPreferences, mDetailTabsVisible);
mNowPlayingView = view;
}
Scene scene = new Scene(view);
ThemeManager.getInstance().register(scene);
setScene(scene);
});
}
/**
* Changes the visibility of the channel details tabs. Can be called from any thread.
* @param visible true to show or false to hide.
*/
public synchronized void setDetailTabsVisible(boolean visible)
{
mDetailTabsVisible = visible;
if(mNowPlayingView != null)
{
NowPlayingView view = mNowPlayingView;
Platform.runLater(() -> view.setDetailTabsVisible(visible));
}
}
}

View File

@@ -0,0 +1,121 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.channel.ChannelSpectrumView;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import javafx.geometry.Orientation;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
/**
* Now Playing view: the table of currently processing channels above the details, events, messages and channel tabs
* for the selected channel.
*
* Use this class on the JavaFX application thread.
*/
public class NowPlayingView extends SplitPane
{
private static final double DIVIDER_POSITION = 0.5;
private final ChannelMetadataView mChannelMetadataView;
private final ChannelDetailView mChannelDetailView;
private final DecodeEventView mDecodeEventView;
private final MessageActivityView mMessageActivityView;
private final ChannelSpectrumView mChannelSpectrumView;
private final TabPane mTabPane = new TabPane();
private boolean mDetailTabsVisible;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for the channels
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param detailTabsVisible to show the detail tabs
*/
public NowPlayingView(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
boolean detailTabsVisible)
{
setOrientation(Orientation.VERTICAL);
mChannelMetadataView = new ChannelMetadataView(playlistManager.getChannelProcessingManager(), iconModel, userPreferences);
mChannelDetailView = new ChannelDetailView(playlistManager.getChannelProcessingManager());
mDecodeEventView = new DecodeEventView(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityView = new MessageActivityView(userPreferences);
mChannelSpectrumView = new ChannelSpectrumView(playlistManager);
Tab detailsTab = new Tab("Details", mChannelDetailView);
Tab channelTab = new Tab("Channel", mChannelSpectrumView);
mTabPane.getTabs().addAll(detailsTab, new Tab("Events", mDecodeEventView),
new Tab("Messages", mMessageActivityView), channelTab);
mTabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
//Turn the channel spectrum processing on only while the channel tab is selected
mTabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) -> {
mChannelSpectrumView.setPanelVisible(newTab == channelTab);
//Show current details each time the user opens the details tab
if(newTab == detailsTab)
{
mChannelDetailView.refresh();
}
});
mChannelMetadataView.addProcessingChainSelectionListener(mChannelDetailView);
mChannelMetadataView.addProcessingChainSelectionListener(mDecodeEventView);
mChannelMetadataView.addProcessingChainSelectionListener(mMessageActivityView);
mChannelMetadataView.addProcessingChainSelectionListener(mChannelSpectrumView);
getItems().add(mChannelMetadataView);
setDetailTabsVisible(detailTabsVisible);
}
/**
* Changes the visibility of the channel details tabs.
* @param visible true to show or false to hide.
*/
public void setDetailTabsVisible(boolean visible)
{
if(visible && !getItems().contains(mTabPane))
{
getItems().add(mTabPane);
setDividerPositions(DIVIDER_POSITION);
}
else if(!visible)
{
getItems().remove(mTabPane);
}
mDetailTabsVisible = visible;
//The channel tab is hidden with the detail tabs, so stop its spectrum processing
mChannelSpectrumView.setPanelVisible(mDetailTabsVisible &&
mTabPane.getSelectionModel().getSelectedIndex() == mTabPane.getTabs().size() - 1);
}
/**
* Prepares this view for disposal.
*/
public void dispose()
{
mChannelMetadataView.dispose();
mDecodeEventView.dispose();
}
}

View File

@@ -21,7 +21,7 @@ package io.github.dsheirer.gui.playlist.channel;
import io.github.dsheirer.gui.control.AccessibilityUtil;
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
import io.github.dsheirer.gui.control.DbPowerMeter;
import io.github.dsheirer.gui.power.PowerMeter;
import io.github.dsheirer.gui.control.HexFormatter;
import io.github.dsheirer.gui.control.IntegerFormatter;
import io.github.dsheirer.gui.playlist.eventlog.EventLogConfigurationEditor;
@@ -80,8 +80,8 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
private TextField mSquelchThresholdField;
private ToggleSwitch mSquelchAutoTrackSwitch;
private TextFormatter<Integer> mTalkgroupTextFormatter;
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)DbPowerMeter.DEFAULT_MINIMUM_POWER,
(int)DbPowerMeter.DEFAULT_MAXIMUM_POWER);
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)PowerMeter.DEFAULT_MINIMUM_POWER,
(int)PowerMeter.DEFAULT_MAXIMUM_POWER);
private EventLogConfigurationEditor mEventLogConfigurationEditor;
private TalkgroupValueChangeListener mTalkgroupValueChangeListener = new TalkgroupValueChangeListener();
private IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);

View File

@@ -0,0 +1,235 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.power;
import java.text.DecimalFormat;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
/**
* Vertical bar meter for a decibel power level, with optional peak and squelch threshold lines and a value scale.
*
* Use this class on the JavaFX application thread.
*/
public class PowerMeter extends Region
{
public static final double DEFAULT_MINIMUM_POWER = -110.0d;
public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
private static final double BAR_WIDTH = 30;
private static final double PADDING = 3;
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final Color COLOR_BAR = Color.LIGHTGRAY;
private static final Color COLOR_THRESHOLD = Color.BLUE;
private static final Color COLOR_PEAK = Color.PINK;
private final Canvas mCanvas = new Canvas();
private final ObjectProperty<Paint> mForeground = new SimpleObjectProperty<>(Color.BLACK);
private final ObjectProperty<Font> mFont = new SimpleObjectProperty<>(Font.getDefault());
private final double mMinimumValue = DEFAULT_MINIMUM_POWER;
private final double mMaximumValue = DEFAULT_MAXIMUM_POWER;
private double mPeak = mMinimumValue;
private double mPower = mMinimumValue;
private double mSquelchThreshold = mMinimumValue;
private boolean mPeakVisible = false;
private boolean mSquelchThresholdVisible = false;
/**
* Constructs an instance.
*/
public PowerMeter()
{
getChildren().add(mCanvas);
setPrefSize(90, 100);
setMinSize(70, 60);
mForeground.addListener((observable, oldValue, newValue) -> draw());
mFont.addListener((observable, oldValue, newValue) -> draw());
}
/**
* Color for the meter outline and scale text. Bind it to a themed control's text color.
*/
public ObjectProperty<Paint> foregroundProperty()
{
return mForeground;
}
/**
* Font for the scale text.
*/
public ObjectProperty<Font> fontProperty()
{
return mFont;
}
@Override
protected void layoutChildren()
{
double width = snapSizeX(getWidth());
double height = snapSizeY(getHeight());
if(mCanvas.getWidth() != width || mCanvas.getHeight() != height)
{
mCanvas.setWidth(width);
mCanvas.setHeight(height);
draw();
}
}
/**
* Resets peak, power and squelch to minimums
*/
public void reset()
{
mPeak = mMinimumValue;
mPower = mMinimumValue;
mSquelchThreshold = mMinimumValue;
draw();
}
/**
* Sets the current power level constrained to the minimum and maximum values for this control.
* @param power (dB)
*/
public void setPower(double power)
{
mPower = Math.clamp(power, mMinimumValue, mMaximumValue);
draw();
}
/**
* Sets the peak value line
* @param peak (db)
*/
public void setPeak(double peak)
{
mPeak = peak;
draw();
}
/**
* Sets the squelch threshold value
* @param squelchThreshold (dB)
*/
public void setSquelchThreshold(double squelchThreshold)
{
mSquelchThreshold = squelchThreshold;
draw();
}
/**
* Sets the peak line visibility
*/
public void setPeakVisible(boolean peakVisible)
{
mPeakVisible = peakVisible;
draw();
}
/**
* Sets the visibility of the squelch threshold line
*/
public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
{
mSquelchThresholdVisible = squelchThresholdVisible;
draw();
}
private void draw()
{
double width = mCanvas.getWidth();
double height = mCanvas.getHeight();
GraphicsContext gc = mCanvas.getGraphicsContext2D();
gc.clearRect(0, 0, width, height);
if(width <= 0 || height <= 0)
{
return;
}
double meterHeight = height - 1;
//Meter outline
gc.setStroke(mForeground.get());
gc.setLineWidth(1);
gc.strokeRect(0.5, 0.5, BAR_WIDTH, meterHeight - 0.5);
//Filled bar
gc.setFill(COLOR_BAR);
double top = getY(getPercent(mPower), meterHeight);
gc.fillRect(PADDING, top, BAR_WIDTH - 2 * PADDING + 1, meterHeight - PADDING - top);
if(mSquelchThresholdVisible && isValidValue(mSquelchThreshold))
{
gc.setStroke(COLOR_THRESHOLD);
drawLine(gc, getY(getPercent(mSquelchThreshold), meterHeight));
}
if(mPeakVisible && isValidValue(mPeak))
{
gc.setStroke(COLOR_PEAK);
drawLine(gc, getY(getPercent(mPeak), meterHeight));
}
//Scale, with the number of labels adapted to the height of the control
Font font = mFont.get() != null ? mFont.get() : Font.getDefault();
gc.setFont(font);
gc.setFill(mForeground.get());
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.CENTER);
double extent = mMaximumValue - mMinimumValue;
double labelCount = Math.max(1, height / (font.getSize() * 2.5));
double interval = extent / labelCount;
for(double value = mMaximumValue; value > mMinimumValue; value -= interval)
{
double y = Math.clamp(getY(getPercent(value), meterHeight), font.getSize() / 2, height - font.getSize() / 2);
gc.fillText(DECIMAL_FORMAT.format(value), BAR_WIDTH + PADDING * 2, y);
}
}
private void drawLine(GraphicsContext gc, double y)
{
double snapped = Math.floor(y) + 0.5;
gc.strokeLine(0.5, snapped, BAR_WIDTH + 0.5, snapped);
}
private double getY(double percent, double meterHeight)
{
double fillHeight = meterHeight - 2 * PADDING;
return PADDING + fillHeight * (1.0 - percent);
}
private double getPercent(double value)
{
return (value - mMinimumValue) / (mMaximumValue - mMinimumValue);
}
private boolean isValidValue(double value)
{
return mMinimumValue <= value && value <= mMaximumValue;
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -21,99 +21,109 @@ package io.github.dsheirer.gui.power;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.dsp.squelch.ISquelchConfiguration;
import io.github.dsheirer.gui.control.DbPowerMeter;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.source.SourceEvent;
import java.awt.EventQueue;
import java.text.DecimalFormat;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.swing.IconFontSwing;
import net.miginfocom.swing.MigLayout;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import jiconfont.javafx.IconNode;
/**
* Swing view for displaying signal power measurements with integrated squelch control.
* Signal power meter with squelch threshold controls for the AM decoder.
*/
public class SignalPowerView extends JPanel
public class SignalPowerView extends HBox
{
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final String NOT_AVAILABLE = "Not Available";
private final DbPowerMeter mPowerMeter = new DbPowerMeter();
private final PeakMonitor mPeakMonitor = new PeakMonitor(DbPowerMeter.DEFAULT_MINIMUM_POWER);
private final JLabel mPowerLabel;
private final JLabel mPeakLabel;
private final JLabel mSquelchLabel;
private final JLabel mSquelchValueLabel;
private final JButton mSquelchUpButton;
private final JButton mSquelchDownButton;
private final JCheckBox mSquelchAutoTrackCheckBox;
private double mSquelchThreshold;
private final PowerMeter mPowerMeter = new PowerMeter();
private final PeakMonitor mPeakMonitor = new PeakMonitor(PowerMeter.DEFAULT_MINIMUM_POWER);
private final Label mPowerLabel = new Label("0");
private final Label mPeakLabel = new Label("0");
private final Label mSquelchLabel = new Label("Squelch:");
private final Label mSquelchValueLabel = new Label(NOT_AVAILABLE);
private final Button mSquelchUpButton = new Button();
private final Button mSquelchDownButton = new Button();
private final CheckBox mSquelchAutoTrackCheckBox = new CheckBox("Auto Track");
private final AtomicBoolean mPowerUpdatePending = new AtomicBoolean();
private volatile double mPower;
private volatile double mPeak;
private volatile double mSquelchThreshold;
private final PlaylistManager mPlaylistManager;
private ProcessingChain mProcessingChain;
private volatile ProcessingChain mProcessingChain;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for saving squelch changes to the channel configuration
*/
public SignalPowerView(PlaylistManager playlistManager)
{
super(10);
mPlaylistManager = playlistManager;
setPadding(new Insets(5));
setLayout(new MigLayout("", "[][][][grow,fill]", "[grow,fill]"));
Label meterTitle = new Label("Power (dB)");
mPowerMeter.setPeakVisible(true);
mPowerMeter.setSquelchThresholdVisible(true);
add(mPowerMeter);
//Follow the themed label text color and font so that the meter is readable in light and dark themes
mPowerMeter.foregroundProperty().bind(meterTitle.textFillProperty());
mPowerMeter.fontProperty().bind(meterTitle.fontProperty());
VBox.setVgrow(mPowerMeter, Priority.ALWAYS);
VBox meterBox = new VBox(3, meterTitle, mPowerMeter);
meterBox.setMinWidth(90);
JPanel valuePanel = new JPanel();
valuePanel.setLayout(new MigLayout("", "[right][left][][]", ""));
mPeakLabel.setTooltip(new Tooltip("Current peak power level in decibels."));
mPowerLabel.setTooltip(new Tooltip("Current Power level in decibels"));
mSquelchValueLabel.setTooltip(new Tooltip("Squelch threshold value in decibels"));
mPeakLabel = new JLabel("0");
mPeakLabel.setToolTipText("Current peak power level in decibels.");
valuePanel.add(new JLabel("Peak:"));
valuePanel.add(mPeakLabel, "wrap");
mSquelchUpButton.setGraphic(icon(FontAwesome.ANGLE_UP));
mSquelchUpButton.setTooltip(new Tooltip("Increases the squelch threshold value"));
mSquelchUpButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
mPowerLabel = new JLabel("0");
mPowerLabel.setToolTipText("Current Power level in decibels");
valuePanel.add(new JLabel("Power:"));
valuePanel.add(mPowerLabel, "wrap");
mSquelchDownButton.setGraphic(icon(FontAwesome.ANGLE_DOWN));
mSquelchDownButton.setTooltip(new Tooltip("Decreases the squelch threshold value."));
mSquelchDownButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
mSquelchLabel = new JLabel("Squelch:");
mSquelchLabel.setEnabled(false);
valuePanel.add(mSquelchLabel);
mSquelchValueLabel = new JLabel(NOT_AVAILABLE);
mSquelchValueLabel.setToolTipText("Squelch threshold value in decibels");
mSquelchValueLabel.setEnabled(false);
valuePanel.add(mSquelchValueLabel, "wrap");
IconFontSwing.register(FontAwesome.getIconFont());
Icon iconUp = IconFontSwing.buildIcon(FontAwesome.ANGLE_UP, 12);
mSquelchUpButton = new JButton(iconUp);
mSquelchUpButton.setToolTipText("Increases the squelch threshold value");
mSquelchUpButton.setEnabled(false);
mSquelchUpButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
valuePanel.add(mSquelchUpButton);
Icon iconDown = IconFontSwing.buildIcon(FontAwesome.ANGLE_DOWN, 12);
mSquelchDownButton = new JButton(iconDown);
mSquelchDownButton.setToolTipText("Decreases the squelch threshold value.");
mSquelchDownButton.setEnabled(false);
mSquelchDownButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
valuePanel.add(mSquelchDownButton, "wrap");
mSquelchAutoTrackCheckBox = new JCheckBox("Auto Track");
mSquelchAutoTrackCheckBox.setToolTipText("Enable or disable monitoring of the noise floor to auto-adjust the " +
"squelch threshold value maintaining a consistent level/buffer above the noise floor");
mSquelchAutoTrackCheckBox.setEnabled(false);
mSquelchAutoTrackCheckBox.addActionListener(e ->
{
mSquelchAutoTrackCheckBox.setTooltip(new Tooltip("Enable or disable monitoring of the noise floor to " +
"auto-adjust the squelch threshold value maintaining a consistent level/buffer above the noise floor"));
mSquelchAutoTrackCheckBox.setOnAction(e -> {
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
setConfigSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected());
});
valuePanel.add(mSquelchAutoTrackCheckBox, "span,left");
add(valuePanel);
GridPane values = new GridPane();
values.setHgap(5);
values.setVgap(5);
values.add(new Label("Peak:"), 0, 0);
values.add(mPeakLabel, 1, 0);
values.add(new Label("Power:"), 0, 1);
values.add(mPowerLabel, 1, 1);
values.add(mSquelchLabel, 0, 2);
values.add(mSquelchValueLabel, 1, 2);
values.add(new HBox(3, mSquelchUpButton, mSquelchDownButton), 0, 3, 2, 1);
values.add(mSquelchAutoTrackCheckBox, 0, 4, 2, 1);
values.setAlignment(Pos.TOP_LEFT);
getChildren().addAll(meterBox, values);
reset();
}
private IconNode icon(FontAwesome fontAwesome)
{
IconNode iconNode = new IconNode(fontAwesome);
iconNode.fillProperty().bind(mSquelchLabel.textFillProperty());
return iconNode;
}
/**
@@ -125,9 +135,11 @@ public class SignalPowerView extends JPanel
*/
private void setConfigSquelchThreshold(int threshold)
{
if(mProcessingChain != null)
ProcessingChain processingChain = mProcessingChain;
if(processingChain != null)
{
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(processingChain);
if(channel != null && channel.getDecodeConfiguration() instanceof ISquelchConfiguration configuration)
{
@@ -154,15 +166,16 @@ public class SignalPowerView extends JPanel
private void broadcast(SourceEvent sourceEvent)
{
if(mProcessingChain != null)
ProcessingChain processingChain = mProcessingChain;
if(processingChain != null)
{
mProcessingChain.broadcast(sourceEvent);
processingChain.broadcast(sourceEvent);
}
}
/**
* Resets controls when changing processing chain source. Note: this must be called on the Swing
* dispatch thread because it directly invokes swing components.
* Resets controls when changing processing chain source. Call on the JavaFX application thread.
*/
private void reset()
{
@@ -170,44 +183,53 @@ public class SignalPowerView extends JPanel
mPowerMeter.reset();
mPeakLabel.setText("0");
mPowerLabel.setText("0");
mSquelchLabel.setEnabled(false);
mSquelchValueLabel.setText("Not Available");
mSquelchValueLabel.setEnabled(false);
mSquelchUpButton.setEnabled(false);
mSquelchDownButton.setEnabled(false);
mSquelchAutoTrackCheckBox.setEnabled(false);
mSquelchLabel.setDisable(true);
mSquelchValueLabel.setText(NOT_AVAILABLE);
mSquelchValueLabel.setDisable(true);
mSquelchUpButton.setDisable(true);
mSquelchDownButton.setDisable(true);
mSquelchAutoTrackCheckBox.setDisable(true);
mSquelchAutoTrackCheckBox.setSelected(false);
}
/**
* Processes power and squelch source events from the processing chain. Can be called from any thread.
*/
public void receive(SourceEvent sourceEvent)
{
switch(sourceEvent.getEvent())
{
case NOTIFICATION_CHANNEL_POWER ->
{
final double power = sourceEvent.getValue().doubleValue();
final double peak = mPeakMonitor.process(power);
mPower = sourceEvent.getValue().doubleValue();
mPeak = mPeakMonitor.process(mPower);
EventQueue.invokeLater(() -> {
mPowerMeter.setPower(power);
mPowerLabel.setText(DECIMAL_FORMAT.format(power));
mPowerMeter.setPeak(peak);
mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
});
//Power notifications are frequent, so combine the updates that arrive while one is pending
if(mPowerUpdatePending.compareAndSet(false, true))
{
Platform.runLater(() -> {
mPowerUpdatePending.set(false);
double power = mPower;
double peak = mPeak;
mPowerMeter.setPower(power);
mPowerLabel.setText(DECIMAL_FORMAT.format(power));
mPowerMeter.setPeak(peak);
mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
});
}
}
case NOTIFICATION_SQUELCH_THRESHOLD ->
{
final double threshold = sourceEvent.getValue().doubleValue();
mSquelchThreshold = threshold;
EventQueue.invokeLater(() -> {
Platform.runLater(() -> {
mPowerMeter.setSquelchThreshold(threshold);
mSquelchLabel.setEnabled(true);
mSquelchValueLabel.setEnabled(true);
mSquelchLabel.setDisable(false);
mSquelchValueLabel.setDisable(false);
mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold));
mSquelchDownButton.setEnabled(true);
mSquelchUpButton.setEnabled(true);
mSquelchDownButton.setDisable(false);
mSquelchUpButton.setDisable(false);
});
}
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
@@ -215,16 +237,16 @@ public class SignalPowerView extends JPanel
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
{
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
EventQueue.invokeLater(() -> {
Platform.runLater(() -> {
mSquelchAutoTrackCheckBox.setSelected(autoTrack);
mSquelchAutoTrackCheckBox.setEnabled(true);
mSquelchAutoTrackCheckBox.setDisable(false);
});
}
}
}
/**
* Sets the processing chain for this view
* Sets the processing chain for this view. Call on the JavaFX application thread.
*/
public void setProcessingChain(ProcessingChain processingChain)
{

View File

@@ -0,0 +1,57 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.table;
import javafx.scene.control.Button;
import javafx.scene.control.Tooltip;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.javafx.IconNode;
/**
* Button that opens the column configuration editor for a table.
*/
public class ColumnConfigurationButton extends Button
{
private final TableColumnMonitor<?> mTableColumnMonitor;
private ColumnConfigurationEditor mEditor;
/**
* Constructs an instance
* @param tableColumnMonitor for the table to configure
* @param title for the editor window
*/
public ColumnConfigurationButton(TableColumnMonitor<?> tableColumnMonitor, String title)
{
super("Columns");
mTableColumnMonitor = tableColumnMonitor;
IconNode iconNode = new IconNode(FontAwesome.COLUMNS);
//Follow the themed text color so that the icon is readable in light and dark themes
iconNode.fillProperty().bind(textFillProperty());
setGraphic(iconNode);
setTooltip(new Tooltip("Choose the columns to show and the column order"));
setOnAction(event -> {
if(mEditor == null)
{
mEditor = new ColumnConfigurationEditor(mTableColumnMonitor, title);
}
mEditor.show(this);
});
}
}

View File

@@ -0,0 +1,199 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.table;
import io.github.dsheirer.gui.theme.ThemeManager;
import javafx.collections.ListChangeListener;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.TableColumn;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.CheckBoxListCell;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.util.StringConverter;
/**
* Editor window for choosing the columns to show in a table and the order of the columns. Changes apply to the
* table immediately and are stored by the table column monitor.
*/
public class ColumnConfigurationEditor
{
private final TableColumnMonitor<?> mTableColumnMonitor;
private final String mTitle;
private Stage mStage;
private ListView<TableColumn<?,?>> mListView;
private Button mMoveUpButton;
private Button mMoveDownButton;
private Button mResetButton;
/**
* Constructs an instance
* @param tableColumnMonitor for the table
* @param title for the window
*/
public ColumnConfigurationEditor(TableColumnMonitor<?> tableColumnMonitor, String title)
{
mTableColumnMonitor = tableColumnMonitor;
mTitle = title;
}
/**
* Shows the editor next to the node, or brings it to the front when it is already showing.
* @param anchor node for positioning the editor window
*/
public void show(Node anchor)
{
if(mStage == null)
{
mStage = createStage();
}
if(!mStage.isShowing())
{
mListView.getSelectionModel().clearSelection();
if(anchor != null && anchor.getScene() != null)
{
Bounds bounds = anchor.localToScreen(anchor.getBoundsInLocal());
if(bounds != null)
{
mStage.setX(bounds.getMinX());
mStage.setY(bounds.getMaxY());
}
}
mStage.show();
}
mStage.toFront();
}
@SuppressWarnings({"unchecked", "rawtypes"})
private Stage createStage()
{
Stage stage = new Stage();
stage.setTitle(mTitle);
mListView = new ListView<>();
//The list shares the table's column list, so that column header drags in the table show here too
mListView.setItems((javafx.collections.ObservableList)mTableColumnMonitor.getTableView().getColumns());
mListView.setCellFactory(CheckBoxListCell.forListView(TableColumn::visibleProperty,
new StringConverter<>()
{
@Override
public String toString(TableColumn<?,?> column)
{
return column != null ? TableColumnMonitor.getName(column) : null;
}
@Override
public TableColumn<?,?> fromString(String string)
{
return null;
}
}));
mListView.setPrefHeight(300);
mListView.setPrefWidth(220);
mListView.getSelectionModel().selectedIndexProperty().addListener((observable, oldValue, newValue) -> updateButtons());
mListView.getItems().addListener((ListChangeListener<TableColumn<?,?>>)c -> updateButtons());
mMoveUpButton = new Button("Move Up");
mMoveUpButton.setTooltip(new Tooltip("Move the selected column toward the left side of the table"));
mMoveUpButton.setOnAction(event -> move(-1));
mMoveDownButton = new Button("Move Down");
mMoveDownButton.setTooltip(new Tooltip("Move the selected column toward the right side of the table"));
mMoveDownButton.setOnAction(event -> move(1));
mResetButton = new Button("Reset");
mResetButton.setTooltip(new Tooltip("Restore the default columns and column order"));
mResetButton.setOnAction(event -> {
mTableColumnMonitor.reset();
mListView.getSelectionModel().clearSelection();
updateButtons();
});
for(Button button: new Button[]{mMoveUpButton, mMoveDownButton, mResetButton})
{
button.setMaxWidth(Double.MAX_VALUE);
}
Region spacer = new Region();
VBox.setVgrow(spacer, Priority.ALWAYS);
Button closeButton = new Button("Close");
closeButton.setMaxWidth(Double.MAX_VALUE);
closeButton.setOnAction(event -> stage.hide());
VBox buttonBox = new VBox(5, mMoveUpButton, mMoveDownButton, mResetButton, spacer, closeButton);
HBox listBox = new HBox(10, mListView, buttonBox);
HBox.setHgrow(mListView, Priority.ALWAYS);
VBox.setVgrow(listBox, Priority.ALWAYS);
Label instructions = new Label("Check the columns to show. You can also drag column headers in the " +
"table to change the column order.");
instructions.setWrapText(true);
instructions.setMaxWidth(320);
VBox content = new VBox(10, instructions, listBox);
content.setPadding(new Insets(10));
content.setAlignment(Pos.TOP_LEFT);
Scene scene = new Scene(content);
ThemeManager.getInstance().register(scene);
stage.setScene(scene);
stage.setOnShown(event -> updateButtons());
//Reset tracks whether the table has the default columns, so update it when visibility changes too
for(TableColumn<?,?> column: mTableColumnMonitor.getDefaultOrder())
{
column.visibleProperty().addListener((observable, oldValue, newValue) -> updateButtons());
}
return stage;
}
@SuppressWarnings({"unchecked", "rawtypes"})
private void move(int direction)
{
TableColumn column = mListView.getSelectionModel().getSelectedItem();
if(column != null && ((TableColumnMonitor)mTableColumnMonitor).move(column, direction))
{
mListView.getSelectionModel().select(column);
mListView.scrollTo(column);
}
}
private void updateButtons()
{
int index = mListView.getSelectionModel().getSelectedIndex();
mMoveUpButton.setDisable(index <= 0);
mMoveDownButton.setDisable(index < 0 || index >= mListView.getItems().size() - 1);
mResetButton.setDisable(mTableColumnMonitor.isDefault());
}
}

View File

@@ -0,0 +1,424 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.table;
import io.github.dsheirer.preference.swing.SwingPreference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.prefs.Preferences;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
/**
* Stores and restores the order, visibility and width of the columns of a JavaFX table across application sessions,
* and restores the default column order and visibility on request.
*
* Each column must have a unique, stable ID (TableColumn.setId()), which is the key for its stored settings. The
* order and visibility of the columns when this monitor is constructed are the defaults. A column that is added to
* the table in a later version of the application is shown at its default position.
*
* Use this class only on the JavaFX application thread.
*/
public class TableColumnMonitor<S>
{
/**
* Column property that holds the name shown for the column in the column configuration editor, when the
* column header text doesn't identify the column by itself (e.g. two columns titled 'Alias').
*/
public static final String CONFIGURATION_NAME = "column.configuration.name";
/**
* Column property that holds the column number of the column in the Swing version of the table, for the stored
* Swing column widths.
*/
public static final String LEGACY_INDEX = "column.legacy.index";
private static final String ORDER = ".column.order";
private static final String HIDDEN = ".column.hidden";
private static final String WIDTH = ".column.width.";
private static final String SEPARATOR = ",";
private final TableView<S> mTableView;
private final String mKey;
private final Preferences mPreferences;
private final List<TableColumn<S,?>> mDefaultOrder;
private final Map<TableColumn<S,?>,Boolean> mDefaultVisibility = new HashMap<>();
private boolean mRestoring;
/**
* Constructs an instance and applies the stored column settings to the table.
* @param tableView to monitor
* @param key that uniquely identifies the table, used as the prefix for its stored settings
*/
public TableColumnMonitor(TableView<S> tableView, String key)
{
this(tableView, key, Preferences.userNodeForPackage(TableColumnMonitor.class), null, null);
}
/**
* Constructs an instance and applies the stored column settings to the table. Columns that don't have a stored
* width yet use the width stored by the Swing version of the table, so that users keep their column widths.
* @param tableView to monitor
* @param key that uniquely identifies the table, used as the prefix for its stored settings
* @param swingPreference that holds the column widths of the Swing table
* @param swingKey of the Swing table. The Swing column number is the default index of the column.
*/
public TableColumnMonitor(TableView<S> tableView, String key, SwingPreference swingPreference, String swingKey)
{
this(tableView, key, Preferences.userNodeForPackage(TableColumnMonitor.class), swingPreference, swingKey);
}
/**
* Constructs an instance and applies the stored column settings to the table.
* @param tableView to monitor
* @param key that uniquely identifies the table, used as the prefix for its stored settings
* @param preferences node for storing the settings
* @param swingPreference that holds legacy Swing column widths, or null
* @param swingKey for the legacy Swing column widths, or null
*/
TableColumnMonitor(TableView<S> tableView, String key, Preferences preferences, SwingPreference swingPreference,
String swingKey)
{
mTableView = tableView;
mKey = key;
mPreferences = preferences;
mDefaultOrder = List.copyOf(tableView.getColumns());
Set<String> ids = new HashSet<>();
for(TableColumn<S,?> column: mDefaultOrder)
{
if(column.getId() == null || column.getId().isEmpty() || column.getId().contains(SEPARATOR) ||
!ids.add(column.getId()))
{
throw new IllegalArgumentException("Each table column needs a unique ID without commas - column [" +
column.getText() + "] has ID [" + column.getId() + "]");
}
mDefaultVisibility.put(column, column.isVisible());
}
restore(swingPreference, swingKey);
mTableView.getColumns().addListener((ListChangeListener<TableColumn<S,?>>)c -> {
if(!mRestoring)
{
storeOrder();
}
});
for(TableColumn<S,?> column: mDefaultOrder)
{
column.visibleProperty().addListener((observable, wasVisible, visible) -> {
if(!mRestoring)
{
//Don't let the user hide every column, since that leaves no column header for getting them back
if(!visible && getVisibleColumnCount() == 0)
{
column.setVisible(true);
return;
}
storeVisibility();
}
});
column.widthProperty().addListener((observable, oldWidth, width) -> {
if(!mRestoring && column.isVisible() && width.doubleValue() > 0)
{
mPreferences.putDouble(getWidthKey(column), width.doubleValue());
}
});
}
}
/**
* Table that is monitored.
*/
public TableView<S> getTableView()
{
return mTableView;
}
/**
* Columns in their default order.
*/
public List<TableColumn<S,?>> getDefaultOrder()
{
return mDefaultOrder;
}
/**
* Name of the column for display in the column configuration editor.
* @param column to name
* @return configuration name for the column, or the column header text.
*/
public static String getName(TableColumn<?,?> column)
{
Object name = column.getProperties().get(CONFIGURATION_NAME);
return name != null ? name.toString() : column.getText();
}
/**
* Sets the name of the column for display in the column configuration editor
* @param column to name
* @param name to show in the configuration editor
*/
public static void setName(TableColumn<?,?> column, String name)
{
column.getProperties().put(CONFIGURATION_NAME, name);
}
/**
* Sets the column number of the column in the Swing version of the table, for migrating the Swing column width.
* When any column of a table has a legacy index, only those columns use Swing column widths. Otherwise, the
* default column index is the Swing column number.
* @param column to set
* @param index of the column in the Swing table
*/
public static void setLegacyIndex(TableColumn<?,?> column, int index)
{
column.getProperties().put(LEGACY_INDEX, index);
}
/**
* Restores the default column order and visibility and removes the stored order and visibility settings. The
* column widths aren't changed.
*/
public void reset()
{
mRestoring = true;
try
{
if(!mTableView.getColumns().equals(mDefaultOrder))
{
mTableView.getColumns().setAll(mDefaultOrder);
}
for(TableColumn<S,?> column: mDefaultOrder)
{
column.setVisible(mDefaultVisibility.get(column));
}
}
finally
{
mRestoring = false;
}
mPreferences.remove(mKey + ORDER);
mPreferences.remove(mKey + HIDDEN);
}
/**
* Indicates if the column order and visibility are the defaults.
*/
public boolean isDefault()
{
if(!mTableView.getColumns().equals(mDefaultOrder))
{
return false;
}
for(TableColumn<S,?> column: mDefaultOrder)
{
if(column.isVisible() != mDefaultVisibility.get(column))
{
return false;
}
}
return true;
}
/**
* Moves the column one position toward the start (-1) or the end (+1) of the table.
* @param column to move
* @param direction -1 or +1
* @return true if the column moved
*/
public boolean move(TableColumn<S,?> column, int direction)
{
ObservableList<TableColumn<S,?>> columns = mTableView.getColumns();
int index = columns.indexOf(column);
int target = index + Integer.signum(direction);
if(index < 0 || direction == 0 || target < 0 || target >= columns.size())
{
return false;
}
//Rearrange a copy and apply it in one change, since a table view can't hold the same column twice
List<TableColumn<S,?>> reordered = new ArrayList<>(columns);
Collections.swap(reordered, index, target);
columns.setAll(reordered);
return true;
}
/**
* Applies the stored column settings to the table.
*/
private void restore(SwingPreference swingPreference, String swingKey)
{
mRestoring = true;
try
{
Map<String,TableColumn<S,?>> columnMap = new LinkedHashMap<>();
for(TableColumn<S,?> column: mDefaultOrder)
{
columnMap.put(column.getId(), column);
}
String order = mPreferences.get(mKey + ORDER, null);
if(order != null)
{
List<TableColumn<S,?>> ordered = new ArrayList<>();
for(String id: order.split(SEPARATOR))
{
TableColumn<S,?> column = columnMap.get(id);
if(column != null && !ordered.contains(column))
{
ordered.add(column);
}
}
//Columns that aren't in the stored order are new, so show them at their default position
for(int x = 0; x < mDefaultOrder.size(); x++)
{
TableColumn<S,?> column = mDefaultOrder.get(x);
if(!ordered.contains(column))
{
ordered.add(Math.min(x, ordered.size()), column);
}
}
mTableView.getColumns().setAll(ordered);
}
String hidden = mPreferences.get(mKey + HIDDEN, null);
if(hidden != null)
{
Set<String> hiddenIds = Set.of(hidden.isEmpty() ? new String[0] : hidden.split(SEPARATOR));
for(TableColumn<S,?> column: mDefaultOrder)
{
column.setVisible(!hiddenIds.contains(column.getId()));
}
if(getVisibleColumnCount() == 0)
{
for(TableColumn<S,?> column: mDefaultOrder)
{
column.setVisible(mDefaultVisibility.get(column));
}
}
}
boolean hasLegacyIndexes = mDefaultOrder.stream()
.anyMatch(column -> column.getProperties().containsKey(LEGACY_INDEX));
for(int x = 0; x < mDefaultOrder.size(); x++)
{
TableColumn<S,?> column = mDefaultOrder.get(x);
double width = mPreferences.getDouble(getWidthKey(column), Double.NaN);
Integer legacyIndex = column.getProperties().get(LEGACY_INDEX) instanceof Integer index ? index :
(hasLegacyIndexes ? null : x);
if(Double.isNaN(width) && swingPreference != null && swingKey != null && legacyIndex != null)
{
int swingWidth = swingPreference.getInt(swingKey + ".column." + legacyIndex, Integer.MAX_VALUE);
if(swingWidth != Integer.MAX_VALUE && swingWidth > 0)
{
width = swingWidth;
}
}
if(!Double.isNaN(width) && width > 0)
{
column.setPrefWidth(width);
}
}
}
finally
{
mRestoring = false;
}
}
private int getVisibleColumnCount()
{
int count = 0;
for(TableColumn<S,?> column: mDefaultOrder)
{
if(column.isVisible())
{
count++;
}
}
return count;
}
private void storeOrder()
{
List<String> ids = new ArrayList<>();
for(TableColumn<S,?> column: mTableView.getColumns())
{
ids.add(column.getId());
}
mPreferences.put(mKey + ORDER, String.join(SEPARATOR, ids));
}
private void storeVisibility()
{
List<String> ids = new ArrayList<>();
for(TableColumn<S,?> column: mDefaultOrder)
{
if(!column.isVisible())
{
ids.add(column.getId());
}
}
mPreferences.put(mKey + HIDDEN, String.join(SEPARATOR, ids));
}
private String getWidthKey(TableColumn<S,?> column)
{
return mKey + WIDTH + column.getId();
}
}

View File

@@ -19,7 +19,9 @@
package io.github.dsheirer.identifier.alias;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
@@ -116,6 +118,16 @@ public class TalkerAliasManager
}
}
/**
* Current FROM radio in the identifier collection
* @param identifierCollection to inspect
* @return FROM radio identifier, or null
*/
public static Identifier getFromRadio(IdentifierCollection identifierCollection)
{
return identifierCollection.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
}
/**
* Creates a summary listing of talker aliases
* @return summary.

View File

@@ -0,0 +1,323 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.identifier.alias;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Keeps the talker alias in a decoder's identifier collection matched to the radio that is talking, and assigns each
* received talker alias to the right radio in the talker alias manager. Use one instance for each decoder state (or
* timeslot) on the decoder state's thread.
*
* Talker aliases can arrive:
* <ul>
* <li>During the call, after the talking radio is identified (most protocols).</li>
* <li>During the call, before the talking radio is identified. The alias is shown for the call and stored for
* the radio once the radio is identified.</li>
* <li>Between calls. Depending on the protocol, the alias belongs to the talker of the call that just ended (e.g.
* P25 Phase 1 L3Harris aliases in the TDULC messages) or to the talker of the next call (e.g. P25 Phase 2
* L3Harris aliases that arrive just before the Push-To-Talk). See {@link BetweenCalls}.</li>
* <li>With their own radio identifier (e.g. Motorola), which is stored for that radio.</li>
* </ul>
*
* The decoder state calls {@link #callActivity(long)} after it updates the identifier collection for call activity,
* {@link #callEnded()} when a call ends, before it removes the FROM identifiers, and {@link #received} for each
* talker alias.
*/
public class TalkerAliasTracker
{
/**
* Talker that an alias received between calls belongs to
*/
public enum BetweenCalls
{
/**
* The talker of the call that just ended
*/
PREVIOUS_TALKER,
/**
* The talker of the next call, when it starts within {@link #NEXT_CALL_WINDOW_MS}
*/
NEXT_TALKER
}
/**
* Maximum time from a talker alias received between calls to the start of the next call, for the alias to belong
* to the next call's talker.
*/
public static final long NEXT_CALL_WINDOW_MS = 2000;
/**
* Call that a received talker alias belongs to
*/
public enum Assignment
{
/**
* The call in progress. The alias is shown in the identifier collection.
*/
CURRENT_CALL,
/**
* The call that just ended. The alias isn't shown, since no call is in progress.
*/
ENDED_CALL,
/**
* The next call. The alias is held and shown when the next call starts.
*/
NEXT_CALL,
/**
* Neither, e.g. an alias for a radio that isn't talking. The alias is stored for its radio, if known.
*/
UNASSIGNED
}
private final MutableIdentifierCollection mIdentifierCollection;
private final Supplier<TalkerAliasManager> mTalkerAliasManager;
private final BetweenCalls mBetweenCalls;
private boolean mCallActive;
private TalkerAliasIdentifier mNextCallAlias;
private long mNextCallAliasTimestamp;
private Identifier mTalker;
private RadioIdentifier mEndedCallRadio;
/**
* Constructs an instance
* @param identifierCollection of the decoder state
* @param talkerAliasManager for storing the talker alias of each radio. The supplier can return null when the
* decoder doesn't have a talker alias manager.
* @param betweenCalls talker that an alias received between calls belongs to, for the protocol
*/
public TalkerAliasTracker(MutableIdentifierCollection identifierCollection,
Supplier<TalkerAliasManager> talkerAliasManager, BetweenCalls betweenCalls)
{
mIdentifierCollection = identifierCollection;
mTalkerAliasManager = talkerAliasManager;
mBetweenCalls = betweenCalls;
}
/**
* Signals call activity, after the decoder updates the identifier collection from a call message. Keeps the
* talker alias matched to the talking radio: removes the alias when a different radio talks, stores an alias that
* arrived before the talking radio was identified, and adds the stored alias for the talking radio.
* @param timestamp of the call message
*/
public void callActivity(long timestamp)
{
if(!mCallActive && mNextCallAlias != null && Math.abs(timestamp - mNextCallAliasTimestamp) <= NEXT_CALL_WINDOW_MS)
{
//The alias received just before this call belongs to this call's talker. It's stored for the talker once
//the talker is identified.
mIdentifierCollection.update(mNextCallAlias);
}
mNextCallAlias = null;
mCallActive = true;
mEndedCallRadio = null;
update();
}
/**
* Keeps the talker alias matched to the talking radio after an identifier collection update, without signaling
* call activity.
*/
public void update()
{
Identifier current = getFromRadio();
if(current != null)
{
Identifier alias = getTalkerAlias();
TalkerAliasManager manager = mTalkerAliasManager.get();
if(mTalker != null && !isSameRadio(mTalker, current))
{
//A different radio is talking
mIdentifierCollection.remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
else if(mTalker == null && alias instanceof TalkerAliasIdentifier talkerAlias &&
current instanceof RadioIdentifier radio && manager != null)
{
//The alias arrived during this call before the radio was identified
manager.update(radio, talkerAlias);
}
if(manager != null)
{
manager.enrichMutable(mIdentifierCollection);
}
}
mTalker = current;
}
/**
* Signals the end of the call. Call before removing the FROM identifiers from the identifier collection.
* Remembers the talker for an alias that arrives after the call, and removes the talker alias. Can be called
* more than once for the same call.
*/
public void callEnded()
{
Identifier current = getFromRadio();
if(current instanceof RadioIdentifier radio)
{
mEndedCallRadio = radio;
}
else if(mTalker instanceof RadioIdentifier radio)
{
mEndedCallRadio = radio;
}
mCallActive = false;
mTalker = null;
mIdentifierCollection.remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
/**
* Resets the tracker, e.g. when the decoder state resets.
*/
public void reset()
{
mCallActive = false;
mTalker = null;
mEndedCallRadio = null;
mNextCallAlias = null;
}
/**
* Processes a received talker alias: stores it for its radio and shows it when the radio is talking.
*
* @param talkerAlias that was received
* @param aliasRadio that the alias identifies itself (e.g. Motorola), or null when the alias belongs to the
* talker of the current call, or of the previous or next call, depending on the protocol.
* @param timestamp of the alias message
* @return the call that the alias belongs to.
*/
public Assignment received(TalkerAliasIdentifier talkerAlias, RadioIdentifier aliasRadio, long timestamp)
{
if(talkerAlias == null)
{
return Assignment.UNASSIGNED;
}
Identifier current = getFromRadio();
RadioIdentifier radio = aliasRadio;
if(radio == null)
{
if(current instanceof RadioIdentifier currentRadio)
{
radio = currentRadio;
}
else if(!mCallActive)
{
if(mBetweenCalls == BetweenCalls.NEXT_TALKER)
{
//Hold the alias for the next call's talker
mNextCallAlias = talkerAlias;
mNextCallAliasTimestamp = timestamp;
return Assignment.NEXT_CALL;
}
radio = mEndedCallRadio;
}
}
TalkerAliasManager manager = mTalkerAliasManager.get();
if(radio != null && manager != null)
{
manager.update(radio, talkerAlias);
}
if(current != null && radio != null && isSameRadio(current, radio))
{
mIdentifierCollection.update(talkerAlias);
return Assignment.CURRENT_CALL;
}
if(radio == null && mCallActive)
{
//The talking radio isn't identified yet. The alias is stored for the radio once it's identified.
mIdentifierCollection.update(talkerAlias);
return Assignment.CURRENT_CALL;
}
if(radio != null && !mCallActive && mEndedCallRadio != null && isSameRadio(radio, mEndedCallRadio))
{
return Assignment.ENDED_CALL;
}
return Assignment.UNASSIGNED;
}
/**
* Talker of the call that just ended, or null.
*/
public RadioIdentifier getEndedCallRadio()
{
return mEndedCallRadio;
}
/**
* Talking radio for the current call, or null.
*/
public RadioIdentifier getCurrentRadio()
{
return getFromRadio() instanceof RadioIdentifier radio ? radio : null;
}
/**
* Indicates if a call is in progress
*/
public boolean isCallActive()
{
return mCallActive;
}
/**
* Indicates if the identifiers are the same radio, comparing radio values so that a radio and its fully qualified
* variant match.
*/
public static boolean isSameRadio(Identifier a, Identifier b)
{
if(a instanceof RadioIdentifier ra && b instanceof RadioIdentifier rb)
{
return Objects.equals(ra.getValue(), rb.getValue());
}
return Objects.equals(a, b);
}
private Identifier getFromRadio()
{
return TalkerAliasManager.getFromRadio(mIdentifierCollection);
}
private Identifier getTalkerAlias()
{
return mIdentifierCollection.getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
}

View File

@@ -20,12 +20,12 @@ package io.github.dsheirer.module.decode.dmr;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent.Event;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.channel.state.TimeslotDecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelConfigurationChangeNotification;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
@@ -33,9 +33,10 @@ import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.DmrTalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
import io.github.dsheirer.identifier.integer.IntegerIdentifier;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
import io.github.dsheirer.log.LoggingSuppressor;
import io.github.dsheirer.message.EmptyTimeslotPlaceholderMessage;
@@ -126,6 +127,11 @@ public class DMRDecoderState extends TimeslotDecoderState
private DMRNetworkConfigurationMonitor mNetworkConfigurationMonitor;
private DMRTrafficChannelManager mTrafficChannelManager;
private DecodeEvent mCurrentCallEvent;
//Call event of the call that just ended, for a talker alias that completes after the call ends
private DecodeEvent mEndedCallEvent;
//Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio
private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(),
this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER);
private boolean mIgnoreCRCChecksums;
private DMRDecoderState mSisterDecoderState;
@@ -1315,24 +1321,54 @@ public class DMRDecoderState extends TimeslotDecoderState
case FULL_STANDARD_TALKER_ALIAS_COMPLETE:
if(message instanceof TalkerAliasComplete tac && tac.hasTalkerAliasIdentifier())
{
getIdentifierCollection().update(tac.getTalkerAliasIdentifier());
Identifier fromRadio = getIdentifierCollection().getFromIdentifier();
if(hasTrafficChannelManager() && fromRadio instanceof RadioIdentifier radio)
{
mTrafficChannelManager.getTalkerAliasManager().update(radio, tac.getTalkerAliasIdentifier());
}
if(mCurrentCallEvent != null)
{
broadcast(mCurrentCallEvent);
}
processTalkerAlias(tac.getTalkerAliasIdentifier(), tac.getTimestamp());
}
break;
}
}
/**
* Talker alias manager shared with the traffic channel manager, or null when there's no traffic channel manager.
*/
private TalkerAliasManager getTalkerAliasManager()
{
return hasTrafficChannelManager() ? mTrafficChannelManager.getTalkerAliasManager() : null;
}
/**
* Processes a talker alias: stores it for the talking radio and adds it to the call that it belongs to.
* @param talkerAlias that was received
* @param timestamp of the message
*/
private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, long timestamp)
{
switch(mTalkerAliasTracker.received(talkerAlias, null, timestamp))
{
case CURRENT_CALL ->
{
if(mCurrentCallEvent != null)
{
mCurrentCallEvent.setIdentifierCollection(getIdentifierCollection().copyOf());
broadcast(mCurrentCallEvent);
}
}
case ENDED_CALL ->
{
//Alias completed after the call ended: add it to that call's event when it's the same talker
if(mEndedCallEvent != null && TalkerAliasTracker.isSameRadio(mTalkerAliasTracker.getEndedCallRadio(),
mEndedCallEvent.getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM)))
{
MutableIdentifierCollection mic = new MutableIdentifierCollection(
mEndedCallEvent.getIdentifierCollection().getIdentifiers());
mic.update(talkerAlias);
mEndedCallEvent.setIdentifierCollection(mic);
broadcast(mEndedCallEvent);
}
}
default -> {} //Stored for the radio, for the radio's next call
}
}
/**
* Updates the current call with encryption information.
* @param embeddedEncryptionParameters decoded from the Voice Frame F
@@ -1340,6 +1376,8 @@ public class DMRDecoderState extends TimeslotDecoderState
*/
private void updateEncryptedCall(EmbeddedEncryptionParameters embeddedEncryptionParameters, boolean isGroup, long timestamp)
{
mTalkerAliasTracker.callActivity(timestamp);
if(mCurrentCallEvent != null)
{
String details = mCurrentCallEvent.getDetails();;
@@ -1377,6 +1415,9 @@ public class DMRDecoderState extends TimeslotDecoderState
*/
private void updateCurrentCall(DecodeEventType type, String details, long timestamp)
{
//Keep the talker alias matched to the talking radio before the call event copies the identifiers
mTalkerAliasTracker.callActivity(timestamp);
Event event = (mCurrentCallEvent == null ? Event.START : Event.CONTINUATION);
//Create a repeater channel descriptor if we don't have one
@@ -1428,10 +1469,12 @@ public class DMRDecoderState extends TimeslotDecoderState
{
mCurrentCallEvent.end(timestamp);
broadcast(mCurrentCallEvent);
mEndedCallEvent = mCurrentCallEvent;
mCurrentCallEvent = null;
}
getIdentifierCollection().remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
//Remembers the talker for a talker alias that completes after the call, and removes the talker alias
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(IdentifierClass.USER, Form.TONE, Role.FROM);
}

View File

@@ -381,6 +381,9 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
public void processChannelGrant(DMRChannel channel, IdentifierCollection identifierCollection,
Opcode opcode, long timestamp, boolean encrypted)
{
//Apply the stored talker alias for the radio, learned from the radio's earlier calls
identifierCollection = mTalkerAliasManager.enrich(identifierCollection);
mLock.lock();
try
@@ -419,11 +422,11 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
}
else if(event != null) //Update current event
{
Identifier from = getIdentifier(identifierCollection, Role.FROM);
Identifier from = getFrom(identifierCollection);
if(from != null)
{
Identifier currentFrom = getIdentifier(event.getIdentifierCollection(), Role.FROM);
Identifier currentFrom = getFrom(event.getIdentifierCollection());
if(currentFrom != null && !Objects.equals(from, currentFrom))
{
event.end(timestamp);
@@ -660,6 +663,15 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
* @param collection containing a TO identifier
* @return TO identifier or null
*/
/**
* FROM identifier of the collection, preferring the radio over other FROM identifiers such as a talker alias.
*/
private Identifier getFrom(IdentifierCollection collection)
{
Identifier radio = TalkerAliasManager.getFromRadio(collection);
return radio != null ? radio : getIdentifier(collection, Role.FROM);
}
private Identifier getIdentifier(IdentifierCollection collection, Role role)
{
List<Identifier> identifiers = collection.getIdentifiers(role);

View File

@@ -1,126 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.event;
import java.awt.EventQueue;
import java.util.LinkedList;
import java.util.List;
import javax.swing.table.AbstractTableModel;
/**
* AbstractTableModel implementation supporting clearable method options.
*/
public abstract class ClearableHistoryModel<T> extends AbstractTableModel
{
public static final int DEFAULT_HISTORY_SIZE = 200;
private LinkedList<T> mItems = new LinkedList<>();
private int mHistorySize = DEFAULT_HISTORY_SIZE;
/**
* Access an item/row by the model index value.
* @param index to retrieve
* @return item or null
*/
public T getItem(int index)
{
if(index < mItems.size())
{
return mItems.get(index);
}
return null;
}
/**
* Adds the item to the top of the item list and removes any tail items while the item list size exceeds the
* maximum history size for this model.
* @param item to add
*/
public void add(T item)
{
if(mItems.contains(item))
{
int itemRow = mItems.indexOf(item);
fireTableRowsUpdated(itemRow, itemRow);
}
else
{
mItems.addFirst(item);
fireTableRowsInserted(0, 0);
while(mItems.size() > mHistorySize)
{
mItems.removeLast();
fireTableRowsDeleted(mItems.size() - 1, mItems.size() - 1);
}
}
}
/**
* Clears all messages from history
*/
public void clear()
{
EventQueue.invokeLater(() -> {
mItems.clear();
fireTableDataChanged();
});
}
/**
* Clears the current messages and loads the messages argument
*/
public void clearAndSet(List<T> items)
{
EventQueue.invokeLater(() -> {
mItems.clear();
fireTableDataChanged();
for(T item: items)
{
add(item);
}
});
}
/**
* Current history size
* @return history size
*/
public int getHistorySize()
{
return mHistorySize;
}
/**
* Sets the history size
* @param historySize
*/
public void setHistorySize(int historySize)
{
mHistorySize = historySize;
}
@Override
public int getRowCount()
{
return mItems.size();
}
}

View File

@@ -1,171 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.event;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.sample.Listener;
import java.awt.EventQueue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Decode event table model
*/
public class DecodeEventModel extends ClearableHistoryModel<IDecodeEvent> implements Listener<IDecodeEvent>
{
private static final long serialVersionUID = 1L;
private final static Logger mLog = LoggerFactory.getLogger(DecodeEventModel.class);
public static final int COLUMN_TIME = 0;
public static final int COLUMN_DURATION = 1;
public static final int COLUMN_EVENT = 2;
public static final int COLUMN_FROM_ID = 3;
public static final int COLUMN_FROM_ALIAS = 4;
public static final int COLUMN_TO_ID = 5;
public static final int COLUMN_TO_ALIAS = 6;
public static final int COLUMN_CHANNEL = 7;
public static final int COLUMN_FREQUENCY = 8;
public static final int COLUMN_DETAILS = 9;
protected String[] mHeaders = new String[]{"Time", "Duration", "Event", "From", "Alias", "To", "Alias", "Channel", "Frequency", "Details"};
public DecodeEventModel()
{
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Receives preference update notifications via the event bus
* @param preferenceType that was updated
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT)
{
fireTableDataChanged();
}
}
/**
* Adds, updates or deletes the event from the model. Producers can send
* the same call event multiple times to indicate that information in the
* event is updated. Producers can also mark the event as invalid and the
* event will be removed from the model.
*/
public void receive(final IDecodeEvent event)
{
EventQueue.invokeLater(() -> add(event));
}
@Override
public int getColumnCount()
{
return mHeaders.length;
}
public String getColumnName(int column)
{
return mHeaders[column];
}
@Override
public Object getValueAt(int rowIndex, int columnIndex)
{
IDecodeEvent event = getItem(rowIndex);
if(event != null)
{
switch(columnIndex)
{
case COLUMN_TIME:
return event.getTimeStart();
case COLUMN_DURATION:
return event.getDuration();
case COLUMN_EVENT:
return event.getEventType().getLabel();
case COLUMN_FROM_ID:
return event.getIdentifierCollection();
case COLUMN_FROM_ALIAS:
return event.getIdentifierCollection();
case COLUMN_TO_ID:
return event.getIdentifierCollection();
case COLUMN_TO_ALIAS:
return event.getIdentifierCollection();
case COLUMN_CHANNEL:
IChannelDescriptor channelDescriptor = event.getChannelDescriptor();
if(channelDescriptor != null)
{
if(event.hasTimeslot() && !event.toString().contains("TS"))
{
return channelDescriptor + " TS" + event.getTimeslot();
}
else
{
return channelDescriptor.toString();
}
}
else
{
if(event.hasTimeslot() && !event.toString().contains("TS"))
{
return "TS" + event.getTimeslot();
}
else
{
return null;
}
}
case COLUMN_FREQUENCY:
return event.getChannelDescriptor();
case COLUMN_DETAILS:
return event.getDetails();
}
}
return null;
}
@Override
public Class<?> getColumnClass(int columnIndex)
{
switch(columnIndex)
{
case COLUMN_DURATION:
case COLUMN_TIME:
return Long.class;
case COLUMN_DETAILS:
case COLUMN_EVENT:
return String.class;
case COLUMN_FREQUENCY:
case COLUMN_FROM_ALIAS:
case COLUMN_FROM_ID:
case COLUMN_TO_ALIAS:
case COLUMN_TO_ID:
return IdentifierCollection.class;
case COLUMN_CHANNEL:
return String.class;
}
return super.getColumnClass(columnIndex);
}
}

View File

@@ -1,440 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.event;
import com.google.common.base.Joiner;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.event.filter.DecodeEventFilterSet;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
import io.github.dsheirer.sample.Listener;
import java.awt.Color;
import java.awt.Component;
import java.awt.EventQueue;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.ImageIcon;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.RowFilter;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;
/**
* Decode event table view
*/
public class DecodeEventPanel extends JPanel implements Listener<ProcessingChain>
{
private static final long serialVersionUID = 1L;
private final static Logger mLog = LoggerFactory.getLogger(DecodeEventPanel.class);
private static final String TABLE_PREFERENCE_KEY = "decode.event.panel";
private final JTable mTable;
private final DecodeEventModel mEventModel = new DecodeEventModel();
private DecodeEventHistory mCurrentEventHistory;
private final IconModel mIconModel;
private final AliasModel mAliasModel;
private final UserPreferences mUserPreferences;
private final TimestampCellRenderer mTimestampCellRenderer;
private final FilterSet<IDecodeEvent> mFilterSet = new DecodeEventFilterSet();
private final HistoryManagementPanel<IDecodeEvent> mHistoryManagementPanel;
/**
* View for call event table
* @param iconModel to display alias icons in table rows
*/
public DecodeEventPanel(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel)
{
MyEventBus.getGlobalEventBus().register(this);
setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[][grow,fill]"));
mIconModel = iconModel;
mAliasModel = aliasModel;
mUserPreferences = userPreferences;
mTimestampCellRenderer = new TimestampCellRenderer();
mTable = new JTable(mEventModel);
TableRowSorter<TableModel> tableRowSorter = new TableRowSorter<>(mEventModel);
tableRowSorter.setRowFilter(new EventRowFilter());
mTable.setRowSorter(tableRowSorter);
JTableColumnWidthMonitor tableColumnWidthMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
updateCellRenderers();
mHistoryManagementPanel = new HistoryManagementPanel<>(mEventModel, "Event Filter Editor");
mHistoryManagementPanel.updateFilterSet(mFilterSet);
add(mHistoryManagementPanel, "span,growx");
JScrollPane emptyScroller = new JScrollPane(mTable);
add(emptyScroller);
//Register filter change listener to refresh the table any time the event filters are changed.
mFilterSet.register(mEventModel::fireTableDataChanged);
}
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);
}
/**
* Receives preference update notifications via the event bus
* @param preferenceType that was updated
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT)
{
EventQueue.invokeLater(mTimestampCellRenderer::updatePreferences);
}
}
private void updateCellRenderers()
{
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_TIME).setCellRenderer(mTimestampCellRenderer);
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_DURATION).setCellRenderer(new DurationCellRenderer());
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_FROM_ID).setCellRenderer(new IdentifierCellRenderer(Role.FROM));
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_FROM_ALIAS).setCellRenderer(new AliasedIdentifierCellRenderer(Role.FROM));
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_TO_ID).setCellRenderer(new IdentifierCellRenderer(Role.TO));
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_TO_ALIAS).setCellRenderer(new AliasedIdentifierCellRenderer(Role.TO));
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_CHANNEL).setCellRenderer(new ChannelDescriptorCellRenderer());
mTable.getColumnModel().getColumn(DecodeEventModel.COLUMN_FREQUENCY).setCellRenderer(new FrequencyCellRenderer());
}
@Override
public void receive(final ProcessingChain processingChain)
{
if(mCurrentEventHistory != null)
{
mCurrentEventHistory.removeListener(mEventModel);
}
EventQueue.invokeLater(() -> {
if(processingChain != null)
{
mCurrentEventHistory = processingChain.getDecodeEventHistory();
mEventModel.clearAndSet(mCurrentEventHistory.getItems());
processingChain.getDecodeEventHistory().addListener(mEventModel);
mHistoryManagementPanel.setEnabled(true);
}
else
{
mCurrentEventHistory = null;
mEventModel.clearAndSet(Collections.emptyList());
mHistoryManagementPanel.setEnabled(false);
}
});
}
/**
* Custom cell renderer for displaying identifiers from an identifier collection
*/
public class IdentifierCellRenderer extends DefaultTableCellRenderer
{
protected Role mRole;
/**
* Constructs an instance of the cell renderer.
*
* @param role of the identifier
*/
public IdentifierCellRenderer(Role role)
{
mRole = role;
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof IdentifierCollection)
{
List<Identifier> identifiers = ((IdentifierCollection)value).getIdentifiers(mRole);
label.setText(format(identifiers));
}
else
{
label.setText(null);
}
return label;
}
/**
* Formats a list of identifiers as a comma separated list of values
* @param identifiers to format
* @return formatted list or null
*/
protected String format(List<Identifier> identifiers)
{
if(identifiers == null || identifiers.isEmpty())
{
return null;
}
StringBuilder sb = new StringBuilder();
for(Identifier identifier: identifiers)
{
if(!sb.isEmpty())
{
sb.append(",");
}
if(identifier.getForm() == Form.TALKGROUP || identifier.getForm() == Form.RADIO || identifier.getForm() == Form.PATCH_GROUP)
{
sb.append(mUserPreferences.getTalkgroupFormatPreference().format(identifier));
}
else
{
sb.append(identifier);
}
}
return sb.toString();
}
}
/**
* Cell renderer for identifier aliases
*/
public class AliasedIdentifierCellRenderer extends DefaultTableCellRenderer
{
private Role mRole;
/**
* Constructs an instance of the cell renderer.
*
* @param role of the identifier
*/
public AliasedIdentifierCellRenderer(Role role)
{
mRole = role;
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
Color color = mTable.getForeground();
ImageIcon icon = null;
String text = null;
if(value instanceof IdentifierCollection identifierCollection)
{
List<Identifier> identifiers = identifierCollection.getIdentifiers(mRole);
if(identifiers != null && !identifiers.isEmpty())
{
AliasList aliasList = mAliasModel.getAliasList(identifierCollection);
if(aliasList != null)
{
StringBuilder sb = new StringBuilder();
for(Identifier identifier: identifiers)
{
List<Alias> aliases = aliasList.getAliases(identifier);
if(!aliases.isEmpty())
{
if(!sb.isEmpty())
{
sb.append(",");
}
sb.append(Joiner.on(", ").skipNulls().join(aliases));
color = ThemeManager.getInstance().getReadableColor(
aliases.getFirst().getDisplayColor(), label.getBackground());
icon = mIconModel.getIcon(aliases.getFirst().getIconName(), IconModel.DEFAULT_ICON_SIZE);
}
}
text = sb.toString();
}
}
}
label.setText(text);
label.setForeground(color);
label.setIcon(icon);
return label;
}
}
public class TimestampCellRenderer extends DefaultTableCellRenderer
{
private SimpleDateFormat mTimestampFormatter;
public TimestampCellRenderer()
{
setHorizontalAlignment(JLabel.CENTER);
updatePreferences();
}
public void updatePreferences()
{
mTimestampFormatter = mUserPreferences.getDecodeEventPreference().getTimestampFormat().getFormatter();
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof Long)
{
label.setText(mTimestampFormatter.format(new Date((long)value)));
}
else
{
label.setText(null);
}
return label;
}
}
public static class DurationCellRenderer extends DefaultTableCellRenderer
{
private final DecimalFormat mDecimalFormat = new DecimalFormat("0.0");
public DurationCellRenderer()
{
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
String formatted = null;
if(value instanceof Long)
{
long duration = (long)value;
if(duration > 0)
{
formatted = mDecimalFormat.format((double)duration / 1e3d);
}
}
label.setText(formatted);
return label;
}
}
/**
* Frequency value cell renderer
*/
public static class FrequencyCellRenderer extends DefaultTableCellRenderer
{
private final DecimalFormat mFrequencyFormatter = new DecimalFormat("0.00000");
public FrequencyCellRenderer()
{
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
String formatted = null;
if(value instanceof IChannelDescriptor channelDescriptor)
{
long frequency = channelDescriptor.getDownlinkFrequency();
if(frequency > 0)
{
formatted = mFrequencyFormatter.format(frequency / 1e6d);
}
}
label.setText(formatted);
return label;
}
}
/**
* Channel descriptor value cell renderer
*/
public static class ChannelDescriptorCellRenderer extends DefaultTableCellRenderer
{
public ChannelDescriptorCellRenderer()
{
setHorizontalAlignment(JLabel.CENTER);
}
}
/**
* Row filter for decode events
*/
public class EventRowFilter extends RowFilter<TableModel, Integer>
{
@Override
public boolean include(Entry<? extends TableModel, ? extends Integer> entry)
{
if(entry.getModel() instanceof DecodeEventModel model)
{
IDecodeEvent event = model.getItem(entry.getIdentifier());
if(event != null)
{
return mFilterSet.canProcess(event) && mFilterSet.passes(event);
}
}
return false;
}
}
}

View File

@@ -1,212 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.event;
import io.github.dsheirer.filter.FilterEditor;
import io.github.dsheirer.filter.FilterSet;
import java.awt.EventQueue;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import net.miginfocom.swing.MigLayout;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.SwingUtilities;
/**
* History management panel with controls for managing item histories.
*/
public class HistoryManagementPanel<T> extends JPanel
{
private ClearableHistoryModel mModel;
private FilterSet<T> mFilterSet;
private FilterEditor<T> mFilterEditor;
private JButton mClearButton;
private JButton mFilterButton;
private JSlider mHistorySlider;
private JLabel mHistoryTitleLabel;
private JLabel mHistoryValueLabel;
private String mFilterEditorTitle;
/**
* Constructs an instance
* @param model to manage
*/
public HistoryManagementPanel(ClearableHistoryModel model, String filterEditorTitle)
{
mModel = model;
mFilterEditorTitle = filterEditorTitle;
setLayout(new MigLayout("insets 6 1 5 5", "[]5[]10[]5[]5[][grow]", ""));
add(getFilterButton());
add(getClearButton());
add(getHistoryTitleLabel());
add(getHistorySlider());
add(getHistoryValueLabel());
setEnabled(false);
}
/**
* Updates the filter set
* @param filterSet to use
*/
public void updateFilterSet(FilterSet<T> filterSet)
{
mFilterSet = filterSet;
if(mFilterEditor != null)
{
getFilterEditor().updateFilterSet(filterSet);
}
}
/**
* Overrides the panel method to also set the enabled state for the child controls.
* @param enabled true if this component should be enabled, false otherwise
*/
@Override
public void setEnabled(boolean enabled)
{
super.setEnabled(enabled);
getClearButton().setEnabled(enabled);
getFilterButton().setEnabled(enabled);
getHistoryValueLabel().setEnabled(enabled);
getHistoryTitleLabel().setEnabled(enabled);
getHistorySlider().setEnabled(enabled);
}
/**
* Filter editor
* @return editor lazily constructed
*/
private FilterEditor<T> getFilterEditor()
{
if(mFilterEditor == null)
{
mFilterEditor = new FilterEditor<>(mFilterEditorTitle, getFilterButton(), mFilterSet);
}
return mFilterEditor;
}
/**
* Filter button for accessing the filter editor
* @return filter button
*/
private JButton getFilterButton()
{
if(mFilterButton == null)
{
mFilterButton = new JButton("Filters");
mFilterButton.setToolTipText("Edit filters");
mFilterButton.addActionListener(arg0 -> EventQueue.invokeLater(() -> getFilterEditor().setVisible(true)));
}
return mFilterButton;
}
/**
* Clear button
* @return clear button
*/
private JButton getClearButton()
{
if(mClearButton == null)
{
mClearButton = new JButton("Clear");
mClearButton.addActionListener(e -> mModel.clear());
mClearButton.setToolTipText("Clears the history");
}
return mClearButton;
}
/**
* History value label.
* @return label
*/
private JLabel getHistoryValueLabel()
{
if(mHistoryValueLabel == null)
{
mHistoryValueLabel = new JLabel(String.valueOf(ClearableHistoryModel.DEFAULT_HISTORY_SIZE));
}
return mHistoryValueLabel;
}
/**
* History title label
* @return label
*/
private JLabel getHistoryTitleLabel()
{
if(mHistoryTitleLabel == null)
{
mHistoryTitleLabel = new JLabel("History:");
}
return mHistoryTitleLabel;
}
/**
* History value slider control
* @return slider
*/
private JSlider getHistorySlider()
{
if(mHistorySlider == null)
{
mHistorySlider = new JSlider();
mHistorySlider.setToolTipText("Adjust history size. Double-click to reset to default 200");
mHistorySlider.setMinimum(0);
mHistorySlider.setMaximum(2000);
mHistorySlider.setMinorTickSpacing(25);
mHistorySlider.setMajorTickSpacing(500);
mHistorySlider.setPaintTicks(false);
mHistorySlider.setPaintLabels(false);
mHistorySlider.addMouseListener(new MouseListener()
{
@Override
public void mouseClicked(MouseEvent arg0)
{
if(SwingUtilities.isLeftMouseButton(arg0) && arg0.getClickCount() == 2)
{
mHistorySlider.setValue(ClearableHistoryModel.DEFAULT_HISTORY_SIZE);
}
}
public void mouseEntered(MouseEvent arg0) {}
public void mouseExited(MouseEvent arg0) {}
public void mousePressed(MouseEvent arg0) {}
public void mouseReleased(MouseEvent arg0) {}
});
mHistorySlider.setValue(mModel.getHistorySize());
mHistorySlider.addChangeListener(e -> {
mModel.setHistorySize(mHistorySlider.getValue());
getHistoryValueLabel().setText(String.valueOf(mHistorySlider.getValue()));
});
}
return mHistorySlider;
}
}

View File

@@ -1,98 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.event;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.StuffBitsMessage;
import io.github.dsheirer.sample.Listener;
import java.awt.EventQueue;
import java.text.SimpleDateFormat;
/**
* Table Model for decoded IMessages.
*/
public class MessageActivityModel extends ClearableHistoryModel<MessageItem> implements Listener<IMessage>
{
private static final long serialVersionUID = 1L;
private static final int TIME = 0;
private static final int PROTOCOL = 1;
private static final int TIMESLOT = 2;
private static final int MESSAGE = 3;
private String[] mHeaders = new String[]{"Time", "Protocol", "Timeslot", "Message"};
private SimpleDateFormat mSDFTime = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
/**
* Constructor
*/
public MessageActivityModel()
{
}
/**
* Implements the listener interface and wraps the IMessage in table-compatible message item wrapper.
* @param message to add to the model
*/
public void receive(final IMessage message)
{
//Don't process tail bits or stuff bits message fragments
if(message instanceof StuffBitsMessage)
{
return;
}
EventQueue.invokeLater(() -> add(new MessageItem(message)));
}
@Override
public int getColumnCount()
{
return mHeaders.length;
}
public String getColumnName(int column)
{
return mHeaders[column];
}
@Override
public Object getValueAt(int rowIndex, int columnIndex)
{
MessageItem item = getItem(rowIndex);
if(item != null)
{
switch(columnIndex)
{
case TIME:
return item.getTimestamp(mSDFTime);
case PROTOCOL:
return item.getProtocol();
case TIMESLOT:
return item.getTimeslot();
case MESSAGE:
return item.getText();
default:
break;
}
}
return null;
}
}

View File

@@ -1,145 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.event;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.MessageHistory;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
import io.github.dsheirer.sample.Listener;
import java.util.ArrayList;
import java.util.List;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.RowFilter;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;
/**
* Panel to display decoded messages/activity.
*/
public class MessageActivityPanel extends JPanel implements Listener<ProcessingChain>
{
private static final long serialVersionUID = 1L;
private static final Logger mLog = LoggerFactory.getLogger(MessageActivityPanel.class);
private final String TABLE_PREFERENCE_KEY = "message.activity.panel";
private MessageActivityModel mMessageModel = new MessageActivityModel();
private MessageHistory mCurrentMessageHistory;
private JTable mTable = new JTable(mMessageModel);
private TableRowSorter<TableModel> mTableRowSorter;
private JTableColumnWidthMonitor mTableColumnWidthMonitor;
private UserPreferences mUserPreferences;
private FilterSet<IMessage> mMessageFilterSet;
private HistoryManagementPanel<IMessage> mHistoryManagementPanel;
/**
* Constructs an instance
* @param userPreferences
*/
public MessageActivityPanel(UserPreferences userPreferences)
{
mUserPreferences = userPreferences;
mTableRowSorter = new TableRowSorter<>(mMessageModel);
mTableRowSorter.setRowFilter(new MessageRowFilter());
mTable.setRowSorter(mTableRowSorter);
mTableColumnWidthMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
setLayout(new MigLayout("insets 0 0 0 0", "[][grow,fill]", "[]0[grow,fill]"));
mHistoryManagementPanel = new HistoryManagementPanel<>(mMessageModel, "Message Filter Editor");
add(mHistoryManagementPanel, "span,growx");
add(new JScrollPane(mTable), "span,grow");
}
/**
* Updates the message activity model with message history from the specified processing chain
*/
@Override
public void receive(ProcessingChain processingChain)
{
if(mCurrentMessageHistory != null)
{
mCurrentMessageHistory.removeListener(mMessageModel);
}
//Unregister from changes made to the filter set
if(mMessageFilterSet != null)
{
mMessageFilterSet.register(null);
}
if(processingChain != null)
{
mCurrentMessageHistory = processingChain.getMessageHistory();
mMessageFilterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
//Register filter change listener to refresh the table any time the event filters are changed.
mMessageFilterSet.register(() -> mMessageModel.fireTableDataChanged());
if(mHistoryManagementPanel != null)
{
mHistoryManagementPanel.updateFilterSet(mMessageFilterSet);
}
List<MessageItem> currentHistory = new ArrayList<>();
for(IMessage message: mCurrentMessageHistory.getItems())
{
currentHistory.add(new MessageItem(message));
}
mMessageModel.clearAndSet(currentHistory);
mCurrentMessageHistory.addListener(mMessageModel);
mHistoryManagementPanel.setEnabled(true);
}
else
{
mCurrentMessageHistory = null;
mMessageFilterSet = null;
mMessageModel.clear();
mHistoryManagementPanel.setEnabled(false);
}
}
/**
* Row visibility filter for messages
*/
public class MessageRowFilter extends RowFilter<TableModel, Integer>
{
@Override
public boolean include(Entry<? extends TableModel, ? extends Integer> entry)
{
if(entry.getModel() instanceof MessageActivityModel model)
{
MessageItem item = model.getItem(entry.getIdentifier());
if(mMessageFilterSet != null && item != null && item.getMessage() != null)
{
IMessage message = item.getMessage();
return mMessageFilterSet.canProcess(message) && mMessageFilterSet.passes(message);
}
}
return false;
}
}
}

View File

@@ -24,17 +24,19 @@ import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.event.DecodeEventType;
import io.github.dsheirer.module.decode.event.PlottableDecodeEvent;
import io.github.dsheirer.module.decode.nxdn.identifier.NXDNTalkerAliasIdentifier;
import io.github.dsheirer.module.decode.nxdn.layer3.NXDNLayer3Message;
import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.ControlChannelInformation;
import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.SiteInformation;
@@ -86,6 +88,9 @@ public class NXDNDecoderState extends DecoderState
private final Channel mChannel;
private final NXDNNetworkConfigurationMonitor mNetworkConfigurationMonitor = new NXDNNetworkConfigurationMonitor();
private final NXDNTrafficChannelManager mTrafficChannelManager;
//Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio
private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(),
this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER);
private boolean mEncryptedCallStateDetermined = false;
private boolean mEncryptedCall = false;
private int mDisconnectResponseCount = 0;
@@ -139,7 +144,9 @@ public class NXDNDecoderState extends DecoderState
switch(event.getEvent())
{
case REQUEST_RESET:
mTalkerAliasTracker.callEnded();
resetState();
mTalkerAliasTracker.reset();
break;
case NOTIFICATION_SOURCE_FREQUENCY:
setCurrentFrequency(event.getFrequency());
@@ -190,6 +197,32 @@ public class NXDNDecoderState extends DecoderState
}
}
/**
* Talker alias manager shared with the traffic channel manager, or null when there's no traffic channel manager.
*/
private TalkerAliasManager getTalkerAliasManager()
{
return hasTrafficChannelManager() ? mTrafficChannelManager.getTalkerAliasManager() : null;
}
/**
* Processes a talker alias: stores it for the talking radio and adds it to the call that it belongs to.
* @param talkerAlias that was received
* @param timestamp of the message
*/
private void processTalkerAlias(NXDNTalkerAliasIdentifier talkerAlias, long timestamp)
{
TalkerAliasTracker.Assignment assignment = mTalkerAliasTracker.received(talkerAlias, null, timestamp);
if(hasTrafficChannelManager() && (assignment == TalkerAliasTracker.Assignment.CURRENT_CALL ||
assignment == TalkerAliasTracker.Assignment.ENDED_CALL))
{
RadioIdentifier radio = assignment == TalkerAliasTracker.Assignment.ENDED_CALL ?
mTalkerAliasTracker.getEndedCallRadio() : mTalkerAliasTracker.getCurrentRadio();
mTrafficChannelManager.processTalkerAlias(getCurrentChannel(), talkerAlias, radio, timestamp);
}
}
/**
* Broadcasts the arguments as a new decode event
* @param identifiers involved in the event
@@ -265,6 +298,9 @@ public class NXDNDecoderState extends DecoderState
{
mIdleDuringCallCount = 0;
getIdentifierCollection().update(vc.getIdentifiers());
//Keep the talker alias matched to the radio that is talking
mTalkerAliasTracker.callActivity(vc.getTimestamp());
mEncryptedCallStateDetermined = true;
mEncryptedCall = vc.getEncryptionKeyIdentifier().isEncrypted();
state = mEncryptedCall ? State.ENCRYPTED : State.CALL;
@@ -326,6 +362,7 @@ public class NXDNDecoderState extends DecoderState
mEncryptedCall = false;
mIdleDuringCallCount = IDLE_DURING_CALL_MAX_COUNT;
//Only remove the FROM identifier, anticipating a continuation call for the same talkgroup
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(Role.FROM);
break;
case CONTROL_OUT_09_CC_DATA_CALL_RESPONSE:
@@ -404,6 +441,7 @@ public class NXDNDecoderState extends DecoderState
mTrafficChannelManager.processEndCall(getCurrentChannel(), layer3.getTimestamp());
mEncryptedCallStateDetermined = false;
mEncryptedCall = false;
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(IdentifierClass.USER);
mDisconnectResponseCount++;
@@ -424,6 +462,7 @@ public class NXDNDecoderState extends DecoderState
case CONTROL_OUT_27_BC_ADJACENT_SITE_INFORMATION:
case CONTROL_OUT_28_BC_FAILURE_STATUS_INFORMATION:
mNetworkConfigurationMonitor.process(layer3);
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(IdentifierClass.USER);
break;
case CONTROL_OUT_26_BC_CONTROL_CHANNEL_INFORMATION:
@@ -645,12 +684,7 @@ public class NXDNDecoderState extends DecoderState
{
state = State.CALL;
event = DecoderStateEvent.Event.CONTINUATION;
var radio = getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
if(radio instanceof RadioIdentifier ri)
{
mTrafficChannelManager.processTalkerAlias(getCurrentChannel(), tac.getTalkerAlias(), ri, tac.getTimestamp());
}
processTalkerAlias(tac.getTalkerAlias(), tac.getTimestamp());
}
break;

View File

@@ -26,10 +26,14 @@ import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.IChannelEventListener;
import io.github.dsheirer.controller.channel.IChannelEventProvider;
import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.module.decode.event.DecodeEvent;
@@ -440,9 +444,11 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements
}
/**
* Adds or updates the talker alias for the tracked call event.
* Adds or updates the talker alias for the tracked call event, when the event is for the talker, or when the
* event's talker isn't identified yet.
* @param channel for the call
* @param talkerAlias to add or update
* @param radio that the alias belongs to, or null when the talker isn't identified yet
* @param timestamp for the talker alias message
*/
public void processTalkerAlias(IChannelDescriptor channel, NXDNTalkerAliasIdentifier talkerAlias,
@@ -457,11 +463,20 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements
if(tracker != null)
{
tracker.addIdentifierIfMissing(talkerAlias);
broadcast(tracker);
Identifier eventRadio = tracker.getEvent().getIdentifierCollection()
.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
if(radio == null || eventRadio == null || TalkerAliasTracker.isSameRadio(radio, eventRadio))
{
tracker.addIdentifierIfMissing(talkerAlias);
broadcast(tracker);
}
}
getTalkerAliasManager().update(radio, talkerAlias);
if(radio != null)
{
getTalkerAliasManager().update(radio, talkerAlias);
}
}
finally
{
@@ -537,7 +552,8 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements
{
NXDNChannelEventTracker tracker = getTrackerRemoveIfStale(frequency, timestamp);
MutableIdentifierCollection ic = getMergedIdentifierCollection(identifiers);
mTalkerAliasManager.enrich(ic);
//Apply the stored talker alias for the radio (enrich() returns a copy, so update the collection in place)
mTalkerAliasManager.enrichMutable(ic);
if(tracker != null)
{
if(tracker.isSameCallCheckingToAndFrom(ic, timestamp))

View File

@@ -19,20 +19,24 @@
package io.github.dsheirer.module.decode.p25;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelEvent.Event;
import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.IChannelEventListener;
import io.github.dsheirer.controller.channel.IChannelEventProvider;
import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier;
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
import io.github.dsheirer.identifier.patch.PatchGroupPreLoadDataContent;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.identifier.scramble.ScrambleParameterIdentifier;
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
import io.github.dsheirer.log.LoggingSuppressor;
@@ -942,6 +946,43 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
}
}
/**
* Adds a talker alias that completed after the call ended (e.g. a P25 Phase 1 L3Harris talker alias in the TDULC
* messages) to the call event that just ended on the frequency and timeslot, when that event's FROM radio is the
* talker. The event duration isn't changed.
*
* @param frequency of the traffic channel
* @param timeslot of the traffic channel (TIMESLOT_1 for Phase 1)
* @param radio that the talker alias belongs to
* @param talkerAlias to add
*/
public void processTrafficTalkerAliasAfterCall(long frequency, int timeslot, RadioIdentifier radio,
Identifier talkerAlias)
{
mLock.lock();
try
{
P25ChannelEventTracker tracker = getTracker(frequency, timeslot);
if(tracker != null && tracker.isComplete() && radio != null && talkerAlias != null)
{
Identifier from = tracker.getEvent().getIdentifierCollection()
.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
if(from instanceof RadioIdentifier eventRadio && eventRadio.getValue().equals(radio.getValue()))
{
tracker.addIdentifierIfMissing(talkerAlias);
broadcast(tracker);
}
}
}
finally
{
mLock.unlock();
}
}
/**
* Updates all current identifiers for an ongoing call event on the frequency and updates the event duration timestamp.
*

View File

@@ -21,11 +21,11 @@ package io.github.dsheirer.module.decode.p25.phase1;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent.Event;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.IChannelEventListener;
import io.github.dsheirer.identifier.Form;
@@ -33,6 +33,9 @@ import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier;
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
@@ -199,6 +202,9 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
* algorithm ID takes precedence over the link control service options when it is known.
*/
private Boolean mCallEncryptedByAlgorithm = null;
//Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio
private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(),
this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER);
/**
* Constructs an APCO-25 decoder state with an optional traffic channel manager.
@@ -327,12 +333,12 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
}
else if(iMessage instanceof MotorolaTalkerAliasComplete tac && tac.isValid())
{
mTrafficChannelManager.getTalkerAliasManager().update(tac.getRadio(), tac.getAlias());
mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), tac.getAlias(), tac.getTimestamp(), tac.toString());
//Motorola talker aliases identify their radio
processTalkerAlias(tac.getAlias(), tac.getRadio(), tac.getTimestamp(), tac.toString());
}
else if(iMessage instanceof LCHarrisTalkerAliasComplete talkerAlias)
{
processTalkerAlias(talkerAlias);
processTalkerAlias(talkerAlias.getTalkerAlias(), null, talkerAlias.getTimestamp(), talkerAlias.toString());
}
else if(iMessage instanceof LinkControlWord lcw)
{
@@ -341,19 +347,34 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
}
/**
* Process a fully reassembled L3Harris talker alias on the current traffic channel.
* @param talkerAlias reassembled.
* Talker alias manager shared with the traffic channel manager
*/
private void processTalkerAlias(LCHarrisTalkerAliasComplete talkerAlias)
private TalkerAliasManager getTalkerAliasManager()
{
Identifier identifier = getIdentifierCollection().getFromIdentifier();
return mTrafficChannelManager.getTalkerAliasManager();
}
if(identifier instanceof RadioIdentifier radioIdentifier)
/**
* Processes a talker alias: stores it for its radio and adds it to the call that it belongs to. L3Harris talker
* aliases can complete during the call, or after the call ends in the TDULC messages, and belong to the talker of
* that call.
* @param talkerAlias that was received
* @param radio identified by the alias message, or null when the alias belongs to the talker of the current or
* just ended call
* @param timestamp of the message
* @param context of the message
*/
private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, RadioIdentifier radio, long timestamp,
String context)
{
switch(mTalkerAliasTracker.received(talkerAlias, radio, timestamp))
{
mTrafficChannelManager.getTalkerAliasManager().update(radioIdentifier, talkerAlias.getTalkerAlias());
case CURRENT_CALL -> mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), talkerAlias,
timestamp, context);
case ENDED_CALL -> mTrafficChannelManager.processTrafficTalkerAliasAfterCall(getCurrentFrequency(),
P25P1Message.TIMESLOT_1, mTalkerAliasTracker.getEndedCallRadio(), talkerAlias);
default -> {} //Stored for the radio, for the radio's next call
}
mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), talkerAlias.getTalkerAlias(), talkerAlias.getTimestamp(), talkerAlias.toString());
}
/**
@@ -414,12 +435,31 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
}
}
/**
* Updates the identifier collection with the channel user identifiers from the link control word and keeps the
* talker alias matched to the radio that is talking.
* @param isCallActivity true for a call in progress, or false for a terminator link control after the call
*/
private void updateChannelUserIdentifiers(LinkControlWord lcw, long timestamp, boolean isCallActivity)
{
getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp));
if(isCallActivity)
{
mTalkerAliasTracker.callActivity(timestamp);
}
else
{
mTalkerAliasTracker.update();
}
}
/**
* Link Control (LC) Channel user (ie current user on this channel).
*/
private void processLCChannelUser(LinkControlWord lcw, long timestamp)
{
getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp));
updateChannelUserIdentifiers(lcw, timestamp, true);
DecodeEventType decodeEventType = getLCDecodeEventType(lcw);
ServiceOptions serviceOptions = null;
@@ -443,7 +483,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
serviceOptions = VoiceServiceOptions.createUnencrypted();
}
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
MutableIdentifierCollection mic = getMutableIdentifierCollection(getIdentifierCollection().getIdentifiers(), timestamp);
mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), getCurrentChannel(), decodeEventType,
serviceOptions, mic, timestamp, null, lcw.toString());
@@ -853,6 +892,8 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
//A call starts. The HDU doesn't identify the talker.
mTalkerAliasTracker.callActivity(message.getTimestamp());
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
@@ -921,9 +962,22 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
{
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
clearCallUser();
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
}
/**
* Removes the FROM radio and talker alias at the end of a call, so that the next call doesn't start (e.g. with an
* HDU, which doesn't identify the talker) with the previous talker. The next call's link control adds the radio
* that is talking and its stored talker alias, the same as the other protocols.
*/
private void clearCallUser()
{
//Remembers the talker for a talker alias that completes after the call ends
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(IdentifierClass.USER, Role.FROM);
}
/**
* Process Terminator Data Unit with Link Control (TDULC) message and forwards valid Link Control Word message for
* additional processing.
@@ -942,6 +996,8 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
processLC(lcw, message.getTimestamp(), true);
//After the terminator link control, which would otherwise add the radio and alias back
clearCallUser();
}
}
}
@@ -1878,7 +1934,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
case UNIT_TO_UNIT_VOICE_CHANNEL_USER_EXTENDED:
if(isTerminator)
{
getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp));
updateChannelUserIdentifiers(lcw, timestamp, false);
}
else
{
@@ -2159,6 +2215,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
{
case REQUEST_RESET:
resetState();
mTalkerAliasTracker.reset();
mNetworkConfigurationMonitor.reset();
break;
case NOTIFICATION_SOURCE_FREQUENCY:

View File

@@ -29,6 +29,7 @@ import io.github.dsheirer.module.decode.p25.phase1.message.lc.IExtendedSourceMes
import io.github.dsheirer.module.decode.p25.phase1.message.lc.LinkControlWord;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.HarrisTalkerAliasAssembler;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBase;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasComplete;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSBlock1;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSBlock2;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSComplete;
@@ -183,6 +184,8 @@ public class P25P1MessageProcessor implements Listener<IMessage>
else if(message instanceof TDULCMessage tdulc)
{
boolean motorolaTalkerAliasComplete = false;
LCHarrisTalkerAliasComplete harrisTalkerAliasComplete = null;
boolean harrisTalkerAliasBlock = false;
LinkControlWord lcw = tdulc.getLinkControlWord();
if(lcw instanceof IExtendedSourceMessage esm)
@@ -199,22 +202,39 @@ public class P25P1MessageProcessor implements Listener<IMessage>
}
}
//L3Harris can carry the talker alias in the TDULC messages after the call ends
else if(lcw != null && lcw.isValid() && lcw instanceof LCHarrisTalkerAliasBase harrisTalkerAlias)
{
harrisTalkerAliasBlock = true;
harrisTalkerAliasComplete = mHarrisTalkerAliasAssembler.process(harrisTalkerAlias, message.getTimestamp());
}
//Motorola carries the talker alias in the TDULC
else if(mMotorolaTalkerAliasAssembler.add(lcw, message.getTimestamp()))
{
motorolaTalkerAliasComplete = true;
}
//Harris carries the talker alias in the LDU1 link control messages, so reset the assembler on TDULC.
mHarrisTalkerAliasAssembler.reset();
//Reset the L3Harris assembler on other TDULC messages so that alias blocks from different calls
//aren't combined. Keep the blocks while the TDULC messages carry the alias.
if(!harrisTalkerAliasBlock)
{
mHarrisTalkerAliasAssembler.reset();
}
dispatch(tdulc);
// Dispatch the complete talker alias after the TDULC to ensure correct log sequencing.
// Dispatch the complete talker alias after the TDULC to ensure correct log sequencing. The decoder
// state assigns it to the talker of the call that just ended.
if(motorolaTalkerAliasComplete)
{
dispatch(mMotorolaTalkerAliasAssembler.assemble());
}
if(harrisTalkerAliasComplete != null)
{
dispatch(harrisTalkerAliasComplete);
}
}
else if(message instanceof HDUMessage hdu && mHeldTDULCMessage != null)
{

View File

@@ -103,9 +103,10 @@ public class HarrisTalkerAliasAssembler
{
if(mBlock1 != null && mBlock2 != null)
{
String fragment2 = mBlock2.getPayloadFragmentString();
String fragment3 = mBlock3 != null ? mBlock3.getPayloadFragmentString() : null;
String fragment4 = mBlock4 != null ? mBlock4.getPayloadFragmentString() : null;
//Untrimmed, so that a space at the start or end of a fragment is kept within the alias
String fragment2 = mBlock2.getPayloadFragmentText();
String fragment3 = mBlock3 != null ? mBlock3.getPayloadFragmentText() : null;
String fragment4 = mBlock4 != null ? mBlock4.getPayloadFragmentText() : null;
return new LCHarrisTalkerAliasComplete(mBlock1.getMessage(), fragment2, fragment3, fragment4, mTimestamp);
}

View File

@@ -61,6 +61,16 @@ public abstract class LCHarrisTalkerAliasBase extends LinkControlWord
return new String(getPayloadFragment().toByteArray()).trim();
}
/**
* Payload fragment as a string, including leading and trailing spaces, for joining the fragments of an alias.
* A space at the start or end of a fragment can be a space within the alias.
* @return fragment
*/
public String getPayloadFragmentText()
{
return new String(getPayloadFragment().toByteArray());
}
/**
* List of identifiers contained in this message
*/

View File

@@ -93,7 +93,8 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes
{
if(mTalkerAlias == null)
{
String alias = getPayloadFragmentString();
//Join the untrimmed fragments and trim the result, so that a space at a fragment boundary is kept
String alias = getPayloadFragmentText();
if(mFragment2 != null && !alias.contains(mFragment2))
{
@@ -110,7 +111,7 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes
}
}
mTalkerAlias = P25TalkerAliasIdentifier.create(alias);
mTalkerAlias = P25TalkerAliasIdentifier.create(alias.trim());
}
return mTalkerAlias;
@@ -134,6 +135,16 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes
return new String(getPayloadFragment().toByteArray()).trim();
}
/**
* Payload fragment as a string, including leading and trailing spaces, for joining the fragments of an alias.
* A space at the start or end of a fragment can be a space within the alias.
* @return fragment
*/
public String getPayloadFragmentText()
{
return new String(getPayloadFragment().toByteArray());
}
@Override
public long getTimestamp()
{

View File

@@ -22,19 +22,21 @@ import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.IBandChannelDescriptor;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent.Event;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.channel.state.TimeslotDecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierUpdateListener;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.P25TalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
import io.github.dsheirer.identifier.encryption.EncryptionKey;
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
import io.github.dsheirer.identifier.patch.PatchGroupManager;
@@ -147,6 +149,9 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
private PatchGroupManager mPatchGroupManager;
private P25P2NetworkConfigurationMonitor mNetworkConfigurationMonitor = new P25P2NetworkConfigurationMonitor();
private P25TrafficChannelManager mTrafficChannelManager;
//Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio
private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(),
this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.NEXT_TALKER);
private int mEndPttOnFacchCounter = 0;
/**
@@ -190,6 +195,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
*/
protected void resetState()
{
mTalkerAliasTracker.callEnded();
super.resetState();
mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), System.currentTimeMillis(), "RESET STATE INVOKED");
mEndPttOnFacchCounter = 0;
@@ -239,6 +245,8 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
broadcast(new DecoderStateEvent(this, Event.CONTINUATION, State.CALL, getTimeslot()));
}
mTalkerAliasTracker.callActivity(message.getTimestamp());
//If we're tracking the call event, update the duration on it
mTrafficChannelManager.processP2TrafficVoice(getCurrentFrequency(), getTimeslot(), message.getTimestamp());
}
@@ -265,7 +273,8 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
}
else if(message instanceof MotorolaTalkerAliasComplete tac && tac.isValid())
{
mTrafficChannelManager.getTalkerAliasManager().update(tac.getRadio(), tac.getAlias());
//Motorola talker aliases identify their radio
processTalkerAlias(tac.getAlias(), tac.getRadio(), tac.getTimestamp());
}
}
}
@@ -285,6 +294,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
case MAC_6_HANGTIME:
//During hangtime, the from talker is no longer involved in the call ... remove it because sometimes
//we don't get the end PTT to signal the end.
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(Role.FROM);
break;
}
@@ -763,7 +773,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
}
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
mTalkerAliasTracker.update();
continueState(State.ACTIVE);
}
@@ -1103,14 +1113,15 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
}
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
if(message.getMacPduType() == MacPduType.MAC_3_IDLE || message.getMacPduType() == MacPduType.MAC_6_HANGTIME)
{
mTalkerAliasTracker.update();
continueState(State.ACTIVE);
}
else
{
mTalkerAliasTracker.callActivity(message.getTimestamp());
if(mac instanceof IServiceOptionsProvider sop)
{
IChannelDescriptor currentChannel = mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(),
@@ -1153,7 +1164,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
}
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
mTalkerAliasTracker.callActivity(message.getTimestamp());
if(mac instanceof PushToTalk ptt)
{
@@ -1225,6 +1236,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
if(mac instanceof EndPushToTalk)
{
//No matter what, remove the FROM identifier on end PTT.
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(Role.FROM);
//Only reset the identifiers if the call event is closed out, otherwise we might have a timing issue
@@ -1519,6 +1531,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
if(mac instanceof MacRelease mr)
{
mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), message.getTimestamp(), "MAC RELEASE: " + mac.toString());
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(IdentifierClass.USER);
broadcast(message, mac, DecodeEventType.COMMAND,
(mr.isForcedPreemption() ? "FORCED " : "") + "CALL PREEMPTION" +
@@ -1720,17 +1733,34 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
{
if(mac instanceof L3HarrisTalkerAlias talkerAlias)
{
P25TalkerAliasIdentifier alias = talkerAlias.getAlias();
getIdentifierCollection().update(alias);
mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(), getTimeslot(), alias, message.getTimestamp());
processTalkerAlias(talkerAlias.getAlias(), null, message.getTimestamp());
}
}
//Add the alias to the talker alias manager if we know the associated radio
Identifier from = getIdentifierCollection().getFromIdentifier();
/**
* Talker alias manager shared with the traffic channel manager
*/
private TalkerAliasManager getTalkerAliasManager()
{
return mTrafficChannelManager.getTalkerAliasManager();
}
if(from instanceof RadioIdentifier ri)
{
mTrafficChannelManager.getTalkerAliasManager().update(ri, alias);
}
/**
* Processes a talker alias: stores it for its radio and adds it to the call that it belongs to.
* @param talkerAlias that was received
* @param radio identified by the alias message, or null when the alias belongs to the talker of the current or
* just ended call
* @param timestamp of the message
*/
private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, RadioIdentifier radio, long timestamp)
{
switch(mTalkerAliasTracker.received(talkerAlias, radio, timestamp))
{
case CURRENT_CALL -> mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(), getTimeslot(),
talkerAlias, timestamp);
case ENDED_CALL -> mTrafficChannelManager.processTrafficTalkerAliasAfterCall(getCurrentFrequency(),
getTimeslot(), mTalkerAliasTracker.getEndedCallRadio(), talkerAlias);
default -> {} //Stored for the radio, for the radio's next call
}
}
@@ -1855,6 +1885,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
{
case REQUEST_RESET:
resetState();
mTalkerAliasTracker.reset();
mNetworkConfigurationMonitor.reset();
break;
case NOTIFICATION_SOURCE_FREQUENCY:

View File

@@ -75,19 +75,22 @@ public class L3HarrisTalkerAlias extends MacStructureVendor
{
if(mAliasIdentifier == null)
{
int length = getLength() - 2;
//The alias fills the rest of the structure after the opcode, vendor and length octets
int start = ALIAS_START + getOffset();
int end = Math.min(getOffset() + getLength() * 8, getMessage().size());
if(length > 0)
if(end > start)
{
length *= 8;
length += getOffset();
byte[] bytes = getMessage().getSubMessage(start, end).getBytes();
int length = 0;
if(length > getMessage().size())
//The alias is padded with zero bytes
while(length < bytes.length && bytes[length] != 0)
{
length = getMessage().size();
length++;
}
String alias = new String(getMessage().getSubMessage(ALIAS_START + getOffset(), length).getBytes()).trim();
String alias = new String(bytes, 0, length).trim();
mAliasIdentifier = P25TalkerAliasIdentifier.create(alias);
}
}

View File

@@ -0,0 +1,232 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.table;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.prefs.BackingStoreException;
import java.util.prefs.Preferences;
import javafx.application.Platform;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests storing, restoring and resetting table column order and visibility.
*/
public class TableColumnMonitorTest
{
private static final String KEY = "test.table";
private Preferences mPreferences;
@BeforeAll
public static void startToolkit() throws Exception
{
//Run without a display
System.setProperty("glass.platform", "Headless");
System.setProperty("prism.order", "sw");
CompletableFuture<Void> started = new CompletableFuture<>();
try
{
Platform.startup(() -> started.complete(null));
}
catch(IllegalStateException e)
{
started.complete(null); //Already started
}
started.get(10, TimeUnit.SECONDS);
}
@BeforeEach
public void createPreferences()
{
mPreferences = Preferences.userRoot().node("sdrtrunk-test/" + UUID.randomUUID());
}
@AfterEach
public void removePreferences() throws BackingStoreException
{
mPreferences.removeNode();
}
/**
* Runs the test on the JavaFX application thread.
*/
private static void onFx(Runnable runnable) throws Exception
{
CompletableFuture<Void> result = new CompletableFuture<>();
Platform.runLater(() -> {
try
{
runnable.run();
result.complete(null);
}
catch(Throwable t)
{
result.completeExceptionally(t);
}
});
result.get(10, TimeUnit.SECONDS);
}
private static TableView<String> createTable(String... ids)
{
TableView<String> tableView = new TableView<>();
for(String id: ids)
{
TableColumn<String,String> column = new TableColumn<>(id.toUpperCase());
column.setId(id);
tableView.getColumns().add(column);
}
return tableView;
}
private static List<String> ids(TableView<String> tableView)
{
return tableView.getColumns().stream().map(TableColumn::getId).toList();
}
private static List<String> visibleIds(TableView<String> tableView)
{
return tableView.getColumns().stream().filter(TableColumn::isVisible).map(TableColumn::getId).toList();
}
private TableColumnMonitor<String> monitor(TableView<String> tableView)
{
return new TableColumnMonitor<>(tableView, KEY, mPreferences, null, null);
}
@Test
public void defaultsWhenNothingIsStored() throws Exception
{
onFx(() -> {
TableView<String> table = createTable("a", "b", "c");
TableColumnMonitor<String> monitor = monitor(table);
assertEquals(List.of("a", "b", "c"), ids(table));
assertEquals(List.of("a", "b", "c"), visibleIds(table));
assertTrue(monitor.isDefault());
});
}
@Test
public void orderAndVisibilityAreRestored() throws Exception
{
onFx(() -> {
TableView<String> table = createTable("a", "b", "c", "d");
TableColumnMonitor<String> monitor = monitor(table);
assertTrue(monitor.move(table.getColumns().get(0), 1));
table.getColumns().get(3).setVisible(false);
assertEquals(List.of("b", "a", "c", "d"), ids(table));
assertFalse(monitor.isDefault());
//A new session
TableView<String> restored = createTable("a", "b", "c", "d");
monitor(restored);
assertEquals(List.of("b", "a", "c", "d"), ids(restored));
assertEquals(List.of("b", "a", "c"), visibleIds(restored));
});
}
@Test
public void resetRestoresDefaultsAndKeepsWidths() throws Exception
{
onFx(() -> {
TableView<String> table = createTable("a", "b", "c");
TableColumnMonitor<String> monitor = monitor(table);
monitor.move(table.getColumns().get(2), -1);
table.getColumns().get(0).setVisible(false);
mPreferences.putDouble(KEY + ".column.width.b", 123.0);
monitor.reset();
assertEquals(List.of("a", "b", "c"), ids(table));
assertEquals(List.of("a", "b", "c"), visibleIds(table));
assertTrue(monitor.isDefault());
assertNull(mPreferences.get(KEY + ".column.order", null));
assertNull(mPreferences.get(KEY + ".column.hidden", null));
TableView<String> restored = createTable("a", "b", "c");
monitor(restored);
assertEquals(List.of("a", "b", "c"), ids(restored));
assertEquals(123.0, restored.getColumns().get(1).getPrefWidth());
});
}
@Test
public void newColumnIsShownAtItsDefaultPosition() throws Exception
{
onFx(() -> {
mPreferences.put(KEY + ".column.order", "c,a,removed");
mPreferences.put(KEY + ".column.hidden", "a");
TableView<String> table = createTable("a", "b", "c");
monitor(table);
assertEquals(List.of("c", "b", "a"), ids(table));
assertEquals(List.of("c", "b"), visibleIds(table));
});
}
@Test
public void columnAddedBetweenStoredColumnsTakesItsDefaultPosition() throws Exception
{
onFx(() -> {
//Stored by a version without the 'talker' column, with the user's order and a hidden column
mPreferences.put(KEY + ".column.order", "status,from,from.alias,to");
mPreferences.put(KEY + ".column.hidden", "to");
TableView<String> table = createTable("status", "from", "talker", "from.alias", "to");
TableColumnMonitor<String> monitor = monitor(table);
assertEquals(List.of("status", "from", "talker", "from.alias", "to"), ids(table));
assertEquals(List.of("status", "from", "talker", "from.alias"), visibleIds(table));
monitor.reset();
assertEquals(List.of("status", "from", "talker", "from.alias", "to"), visibleIds(table));
});
}
@Test
public void lastVisibleColumnCantBeHidden() throws Exception
{
onFx(() -> {
TableView<String> table = createTable("a", "b");
monitor(table);
table.getColumns().get(0).setVisible(false);
table.getColumns().get(1).setVisible(false);
assertEquals(List.of("b"), visibleIds(table));
});
}
@Test
public void columnsNeedUniqueIds() throws Exception
{
onFx(() -> assertThrows(IllegalArgumentException.class, () -> monitor(createTable("a", "a"))));
}
}

View File

@@ -0,0 +1,259 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.identifier.alias;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.module.decode.p25.identifier.radio.APCO25FullyQualifiedRadioIdentifier;
import io.github.dsheirer.module.decode.p25.identifier.radio.APCO25RadioIdentifier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests assigning talker aliases to the right radio and keeping the shown talker alias matched to the talking radio.
*/
public class TalkerAliasTrackerTest
{
private static final RadioIdentifier RADIO_A = (RadioIdentifier)APCO25RadioIdentifier.createFrom(100);
private static final RadioIdentifier RADIO_B = (RadioIdentifier)APCO25RadioIdentifier.createFrom(200);
private static final TalkerAliasIdentifier ALIAS_A = P25TalkerAliasIdentifier.create("ENGINE 7");
private static final TalkerAliasIdentifier ALIAS_B = P25TalkerAliasIdentifier.create("MEDIC 12");
private MutableIdentifierCollection mIdentifiers;
private TalkerAliasManager mManager;
private TalkerAliasTracker mTracker;
private long mTime = 1_000_000;
@BeforeEach
public void setup()
{
mIdentifiers = new MutableIdentifierCollection();
mManager = new TalkerAliasManager();
mTracker = new TalkerAliasTracker(mIdentifiers, () -> mManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER);
}
/**
* Call message from the radio: the decoder updates the identifiers and signals call activity
*/
private void talk(RadioIdentifier radio)
{
mIdentifiers.update(radio);
mTracker.callActivity(mTime);
mTime += 100;
}
/**
* Call ends: the decoder signals the end and removes the FROM identifiers
*/
private void endCall()
{
mTracker.callEnded();
mIdentifiers.remove(Role.FROM);
}
private Identifier getShownAlias()
{
return mIdentifiers.getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
private Identifier getStoredAlias(RadioIdentifier radio)
{
MutableIdentifierCollection mic = new MutableIdentifierCollection();
mic.update(radio);
mManager.enrichMutable(mic);
return mic.getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
@Test
public void aliasDuringCallIsShownAndStored()
{
talk(RADIO_A);
assertEquals(TalkerAliasTracker.Assignment.CURRENT_CALL, mTracker.received(ALIAS_A, null, mTime));
assertEquals(ALIAS_A, getShownAlias());
assertEquals(ALIAS_A, getStoredAlias(RADIO_A));
endCall();
assertNull(getShownAlias());
//The talker's next call shows the stored alias right away
talk(RADIO_A);
assertEquals(ALIAS_A, getShownAlias());
}
@Test
public void aliasBeforeTheRadioIsIdentifiedIsStoredForTheRadio()
{
mTracker.callActivity(mTime); //Call starts without identifying the talker (e.g. a P25 Phase 1 HDU)
assertEquals(TalkerAliasTracker.Assignment.CURRENT_CALL, mTracker.received(ALIAS_A, null, mTime));
assertEquals(ALIAS_A, getShownAlias());
talk(RADIO_A);
assertEquals(ALIAS_A, getShownAlias(), "Alias is kept when the talker is identified");
assertEquals(ALIAS_A, getStoredAlias(RADIO_A), "and stored for the talker");
}
@Test
public void aliasBeforeTheRadioInANewCallIsntAssignedToThePreviousTalker()
{
talk(RADIO_A);
endCall();
mTracker.callActivity(mTime); //Next call starts without identifying the talker
mTracker.received(ALIAS_B, null, mTime);
assertNull(getStoredAlias(RADIO_A), "Not stored for the previous call's talker");
talk(RADIO_B);
assertEquals(ALIAS_B, getStoredAlias(RADIO_B));
}
@Test
public void aliasAfterTheCallEndsBelongsToThatCallsTalker()
{
talk(RADIO_A);
endCall();
assertEquals(TalkerAliasTracker.Assignment.ENDED_CALL, mTracker.received(ALIAS_A, null, mTime));
assertEquals(RADIO_A, mTracker.getEndedCallRadio());
assertNull(getShownAlias(), "Not shown with no call in progress");
assertEquals(ALIAS_A, getStoredAlias(RADIO_A));
talk(RADIO_B);
assertNull(getShownAlias(), "Not shown for a different talker");
endCall();
talk(RADIO_A);
assertEquals(ALIAS_A, getShownAlias());
}
@Test
public void callEndedMoreThanOnceKeepsTheTalker()
{
talk(RADIO_A);
endCall();
endCall(); //e.g. repeated TDULC or End PTT messages
assertEquals(TalkerAliasTracker.Assignment.ENDED_CALL, mTracker.received(ALIAS_A, null, mTime));
assertEquals(ALIAS_A, getStoredAlias(RADIO_A));
}
@Test
public void aliasIsRemovedWhenADifferentRadioTalks()
{
talk(RADIO_A);
mTracker.received(ALIAS_A, null, mTime);
talk(RADIO_B); //Talker changes without a call end
assertNull(getShownAlias());
mManager.update(RADIO_B, ALIAS_B);
talk(RADIO_B);
assertEquals(ALIAS_B, getShownAlias());
}
@Test
public void aliasIsKeptWhenTheRadioBecomesFullyQualified()
{
talk(RADIO_A);
mTracker.received(ALIAS_A, null, mTime);
mIdentifiers.update(APCO25FullyQualifiedRadioIdentifier.createFrom(100, 1, 2, 100));
mTracker.callActivity(mTime);
assertEquals(ALIAS_A, getShownAlias());
}
@Test
public void aliasWithItsOwnRadio()
{
//Motorola talker aliases identify their radio
talk(RADIO_A);
assertEquals(TalkerAliasTracker.Assignment.UNASSIGNED, mTracker.received(ALIAS_B, RADIO_B, mTime),
"Alias for a radio that isn't talking");
assertNull(getShownAlias());
assertEquals(ALIAS_B, getStoredAlias(RADIO_B));
assertEquals(TalkerAliasTracker.Assignment.CURRENT_CALL, mTracker.received(ALIAS_A, RADIO_A, mTime));
assertEquals(ALIAS_A, getShownAlias());
endCall();
assertEquals(TalkerAliasTracker.Assignment.ENDED_CALL, mTracker.received(ALIAS_A, RADIO_A, mTime));
assertEquals(TalkerAliasTracker.Assignment.UNASSIGNED, mTracker.received(ALIAS_B, RADIO_B, mTime));
}
@Test
public void aliasWithNoCallOrTalkerIsIgnored()
{
assertEquals(TalkerAliasTracker.Assignment.UNASSIGNED, mTracker.received(ALIAS_A, null, mTime));
assertNull(getShownAlias());
}
@Test
public void worksWithoutATalkerAliasManager()
{
TalkerAliasTracker tracker = new TalkerAliasTracker(mIdentifiers, () -> null,
TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER);
mIdentifiers.update(RADIO_A);
tracker.callActivity(mTime);
assertEquals(TalkerAliasTracker.Assignment.CURRENT_CALL, tracker.received(ALIAS_A, null, mTime));
assertEquals(ALIAS_A, getShownAlias());
mIdentifiers.update(RADIO_B);
tracker.callActivity(mTime);
assertNull(getShownAlias());
}
@Test
public void nextTalkerAliasBelongsToTheNextCall()
{
//P25 Phase 2 L3Harris: the alias arrives just before the next call's Push-To-Talk
mTracker = new TalkerAliasTracker(mIdentifiers, () -> mManager, TalkerAliasTracker.BetweenCalls.NEXT_TALKER);
talk(RADIO_A);
endCall();
assertEquals(TalkerAliasTracker.Assignment.NEXT_CALL, mTracker.received(ALIAS_B, null, mTime));
assertNull(getStoredAlias(RADIO_A), "Not stored for the previous call's talker");
assertNull(getShownAlias(), "Not shown until the next call starts");
talk(RADIO_B);
assertEquals(ALIAS_B, getShownAlias());
assertEquals(ALIAS_B, getStoredAlias(RADIO_B));
}
@Test
public void nextTalkerAliasBeforeACallWithoutARadio()
{
mTracker = new TalkerAliasTracker(mIdentifiers, () -> mManager, TalkerAliasTracker.BetweenCalls.NEXT_TALKER);
mTracker.received(ALIAS_B, null, mTime);
mTracker.callActivity(mTime + 50); //Voice before the talker is identified
assertEquals(ALIAS_B, getShownAlias());
talk(RADIO_B);
assertEquals(ALIAS_B, getShownAlias());
assertEquals(ALIAS_B, getStoredAlias(RADIO_B));
}
@Test
public void nextTalkerAliasExpires()
{
mTracker = new TalkerAliasTracker(mIdentifiers, () -> mManager, TalkerAliasTracker.BetweenCalls.NEXT_TALKER);
mTracker.received(ALIAS_B, null, mTime);
mTime += TalkerAliasTracker.NEXT_CALL_WINDOW_MS + 1;
talk(RADIO_A);
assertNull(getShownAlias(), "Too long before the call to belong to it");
assertNull(getStoredAlias(RADIO_A));
}
}

View File

@@ -0,0 +1,92 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.dmr;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.DmrTalkerAliasIdentifier;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.module.decode.dmr.channel.DMRAbsoluteChannel;
import io.github.dsheirer.module.decode.dmr.identifier.DMRRadio;
import io.github.dsheirer.module.decode.dmr.identifier.DMRTalkgroup;
import io.github.dsheirer.module.decode.dmr.message.data.csbk.Opcode;
import io.github.dsheirer.module.decode.event.IDecodeEvent;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests that DMR channel grant events show the stored talker alias of the radio, learned from its earlier calls.
*/
public class DMRTrafficChannelManagerTalkerAliasTest
{
private static String getAlias(IDecodeEvent event)
{
Identifier alias = event.getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS,
Role.FROM);
return alias != null ? alias.getValue().toString() : null;
}
private static MutableIdentifierCollection grant(int radio, int talkgroup)
{
MutableIdentifierCollection mic = new MutableIdentifierCollection();
mic.update(DMRTalkgroup.create(talkgroup));
mic.update(DMRRadio.createFrom(radio));
return mic;
}
@Test
public void channelGrantShowsTheStoredTalkerAlias()
{
DMRTrafficChannelManager manager = new DMRTrafficChannelManager(new Channel("Control"));
List<IDecodeEvent> events = new ArrayList<>();
manager.addDecodeEventListener(event -> {
if(!events.contains(event))
{
events.add(event);
}
});
manager.getTalkerAliasManager().update((RadioIdentifier)DMRRadio.createFrom(100),
DmrTalkerAliasIdentifier.create("ENGINE 7"));
//Radio 100 has a stored alias, radio 200 doesn't
manager.processChannelGrant(new DMRAbsoluteChannel(1, 1, 0, 0), grant(100, 10),
Opcode.STANDARD_TALKGROUP_VOICE_CHANNEL_GRANT, 1000, false);
manager.processChannelGrant(new DMRAbsoluteChannel(2, 1, 0, 0), grant(200, 20),
Opcode.STANDARD_TALKGROUP_VOICE_CHANNEL_GRANT, 1000, false);
assertEquals(2, events.size());
assertEquals("ENGINE 7", getAlias(events.get(0)));
assertNull(getAlias(events.get(1)));
//Repeated grants for the same call update the event instead of starting a new one
manager.processChannelGrant(new DMRAbsoluteChannel(1, 1, 0, 0), grant(100, 10),
Opcode.STANDARD_TALKGROUP_VOICE_CHANNEL_GRANT, 1500, false);
assertEquals(2, events.size());
assertFalse(events.get(0).getDuration() < 0);
}
}

View File

@@ -0,0 +1,292 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.p25.phase1;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.module.decode.p25.P25TrafficChannelManager;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.module.decode.p25.phase1.message.P25P1Message;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.standard.LCGroupVoiceChannelUser;
import io.github.dsheirer.protocol.Protocol;
import io.github.dsheirer.module.decode.event.IDecodeEvent;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.HarrisTalkerAliasAssembler;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBase;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBlock1;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBlock2;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBlock3;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBlock4;
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasComplete;
import java.util.ArrayList;
import java.util.List;
import io.github.dsheirer.module.decode.p25.phase1.message.tdu.TDUMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests that P25 Phase 1 talker aliases are assigned to the right radio, including L3Harris talker aliases that
* complete after the call ends, and that the FROM radio and talker alias don't carry over to the next call.
*/
public class P25P1DecoderStateTalkerAliasTest
{
private static final int NAC = 0x293;
private static final int RADIO_A = 1001;
private static final int RADIO_B = 2002;
private static final int TALKGROUP = 100;
private P25P1DecoderState mDecoderState;
private P25TrafficChannelManager mTrafficChannelManager;
private final MutableIdentifierCollection mIdentifiers = new MutableIdentifierCollection();
private final List<IDecodeEvent> mEvents = new ArrayList<>();
private long mTimestamp = 1_700_000_000_000L;
@BeforeEach
public void setup()
{
Channel channel = new Channel("Test");
channel.setDecodeConfiguration(new DecodeConfigP25Phase1());
mTrafficChannelManager = new P25TrafficChannelManager(channel);
mTrafficChannelManager.addDecodeEventListener(event -> {
if(!mEvents.contains(event))
{
mEvents.add(event);
}
});
mDecoderState = new P25P1DecoderState(channel, mTrafficChannelManager);
mDecoderState.setDecoderStateListener(event -> {});
//Mirrors the decoder state identifier collection, the same as the channel metadata and audio modules
mDecoderState.setIdentifierUpdateListener(mIdentifiers);
mDecoderState.start();
}
/**
* Call from the radio: group voice channel user link control messages, as carried in the LDU1s.
*/
private void talk(int radio)
{
//A call carries the channel user link control in each LDU1
for(int x = 0; x < 3; x++)
{
CorrectedBinaryMessage message = new CorrectedBinaryMessage(72);
message.load(32, 16, TALKGROUP); //Group address
message.load(48, 24, radio); //Source address
ChannelUser lcw = new ChannelUser(message, mTimestamp);
lcw.setValid(true);
mDecoderState.receive(lcw);
mTimestamp += 360;
}
}
private void endCall()
{
mDecoderState.receive(new TDUMessage(new CorrectedBinaryMessage(28), NAC, mTimestamp));
mTimestamp += 500;
}
/**
* Completed L3Harris talker alias, reassembled from link control blocks that carry 7 characters each.
*/
private void harrisTalkerAlias(String alias)
{
mDecoderState.receive(assemble(alias, mTimestamp));
mTimestamp += 180;
}
/**
* Creates the L3Harris talker alias blocks for the alias and reassembles them with the assembler.
* @param alias of up to 28 characters
* @param timestamp for the blocks
* @return reassembled talker alias
*/
static LCHarrisTalkerAliasComplete assemble(String alias, long timestamp)
{
HarrisTalkerAliasAssembler assembler = new HarrisTalkerAliasAssembler();
LCHarrisTalkerAliasComplete complete = null;
int blocks = Math.max(2, (alias.length() + 6) / 7);
for(int block = 0; block < blocks; block++)
{
CorrectedBinaryMessage message = new CorrectedBinaryMessage(72);
message.load(2, 6, 50 + block); //L3Harris talker alias block opcode
message.load(8, 8, 164); //L3Harris vendor
for(int x = 0; x < 7 && block * 7 + x < alias.length(); x++)
{
message.load(16 + x * 8, 8, alias.charAt(block * 7 + x));
}
LCHarrisTalkerAliasBase lcw = switch(block)
{
case 0 -> new LCHarrisTalkerAliasBlock1(message);
case 1 -> new LCHarrisTalkerAliasBlock2(message);
case 2 -> new LCHarrisTalkerAliasBlock3(message);
default -> new LCHarrisTalkerAliasBlock4(message);
};
lcw.setValid(true);
LCHarrisTalkerAliasComplete assembled = assembler.process(lcw, timestamp);
if(assembled != null)
{
complete = assembled;
}
}
return complete;
}
private Identifier getFromRadio()
{
return mIdentifiers.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
}
private String getTalkerAlias()
{
Identifier alias = mIdentifiers.getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
return alias != null ? alias.getValue().toString() : null;
}
/**
* Group voice channel user link control as a message, the same way that the decoder state receives the
* reassembled link control messages.
*/
private static class ChannelUser extends LCGroupVoiceChannelUser implements IMessage
{
private final long mMessageTimestamp;
ChannelUser(CorrectedBinaryMessage message, long timestamp)
{
super(message, timestamp, false);
mMessageTimestamp = timestamp;
}
@Override
public long getTimestamp()
{
return mMessageTimestamp;
}
@Override
public Protocol getProtocol()
{
return Protocol.APCO25;
}
@Override
public int getTimeslot()
{
return P25P1Message.TIMESLOT_1;
}
}
@Test
public void harrisAliasAfterCallEndIsStoredForThatCallsTalker()
{
talk(RADIO_A);
assertEquals(RADIO_A, getFromRadio().getValue());
endCall();
assertNull(getFromRadio(), "FROM radio is cleared at the end of the call");
//Alias completes after the call ended
harrisTalkerAlias("ENGINE 7");
assertNull(getTalkerAlias(), "No call in progress, so the alias isn't shown");
//A different talker doesn't get the alias
talk(RADIO_B);
assertEquals(RADIO_B, getFromRadio().getValue());
assertNull(getTalkerAlias());
endCall();
//The talker's next call shows the alias
talk(RADIO_A);
assertEquals("ENGINE 7", getTalkerAlias());
endCall();
assertNull(getTalkerAlias(), "Talker alias is cleared at the end of the call");
}
@Test
public void harrisAliasDuringCallIsShownAndStored()
{
talk(RADIO_A);
harrisTalkerAlias("ENGINE 7");
assertEquals("ENGINE 7", getTalkerAlias());
endCall();
talk(RADIO_A);
assertEquals("ENGINE 7", getTalkerAlias());
}
@Test
public void aliasIsReplacedWhenTheTalkerChangesWithoutACallEnd()
{
talk(RADIO_A);
harrisTalkerAlias("ENGINE 7");
talk(RADIO_B);
assertEquals(RADIO_B, getFromRadio().getValue());
assertNull(getTalkerAlias());
}
private static String getEventTalkerAlias(IDecodeEvent event)
{
Identifier alias = event.getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS,
Role.FROM);
return alias != null ? alias.getValue().toString() : null;
}
private static Identifier getEventRadio(IDecodeEvent event)
{
return event.getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
}
@Test
public void harrisAliasAfterCallEndIsAddedToTheEndedCallEvent()
{
talk(RADIO_A);
endCall();
talk(RADIO_B);
endCall();
assertEquals(2, mEvents.size());
long duration = mEvents.get(1).getDuration();
//Alias completes after radio B's call ended
harrisTalkerAlias("ENGINE 7");
assertEquals(2, mEvents.size(), "No new event");
assertNull(getEventTalkerAlias(mEvents.get(0)), "Radio A's call event isn't changed");
assertEquals(RADIO_B, getEventRadio(mEvents.get(1)).getValue());
assertEquals("ENGINE 7", getEventTalkerAlias(mEvents.get(1)));
assertEquals(duration, mEvents.get(1).getDuration(), "Event duration isn't changed");
}
@Test
public void spaceAtAFragmentBoundaryIsKept()
{
//Characters 7 and 8 are spaces, at the end of block 1 and the start of block 2
assertEquals("ENGINE 7", assemble("ENGINE 7", 0).getTalkerAlias().getValue());
assertEquals("ENGINE 7", assemble("ENGINE 7", 0).getTalkerAlias().getValue());
assertEquals("BATTALION CHIEF 1", assemble("BATTALION CHIEF 1", 0).getTalkerAlias().getValue());
//Trailing padding is removed
assertEquals("A", assemble("A", 0).getTalkerAlias().getValue());
}
}

View File

@@ -0,0 +1,70 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.p25.phase2.message.mac.structure.l3harris;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests decoding P25 Phase 2 L3Harris talker alias MAC structures, using structures captured from a Duke Energy
* Florida recording: opcode A8, vendor A4 (L3Harris), length 0x11 (17 octets), then 14 alias characters padded with
* zero bytes.
*/
public class L3HarrisTalkerAliasTest
{
/**
* Creates the alias structure at bit offset 8 (after the MAC header octet) in a FACCH or SACCH sized message.
*/
private static L3HarrisTalkerAlias create(int messageBits, int... structure)
{
CorrectedBinaryMessage message = new CorrectedBinaryMessage(messageBits);
for(int x = 0; x < structure.length && (x + 2) * 8 <= messageBits; x++)
{
message.load((x + 1) * 8, 8, structure[x]);
}
return new L3HarrisTalkerAlias(message, 8);
}
@Test
public void allFourteenCharactersAreDecoded()
{
//FACCH: FL-DCC-02_StPe
L3HarrisTalkerAlias alias = create(156, 0xA8, 0xA4, 0x11, 0x46, 0x4C, 0x2D, 0x44, 0x43, 0x43, 0x2D, 0x30, 0x32,
0x5F, 0x53, 0x74, 0x50, 0x65, 0xC5);
assertEquals("FL-DCC-02_StPe", alias.getAlias().getValue());
//SACCH: LBVA-17168-SBK
alias = create(180, 0xA8, 0xA4, 0x11, 0x4C, 0x42, 0x56, 0x41, 0x2D, 0x31, 0x37, 0x31, 0x36, 0x38, 0x2D, 0x53,
0x42, 0x4B, 0x00, 0x00, 0x00, 0xA7);
assertEquals("LBVA-17168-SBK", alias.getAlias().getValue());
}
@Test
public void zeroPaddingEndsTheAlias()
{
//A shorter alias padded with zero bytes, followed by unrelated bytes after the structure
L3HarrisTalkerAlias alias = create(156, 0xA8, 0xA4, 0x11, 0x46, 0x4C, 0x2D, 0x44, 0x43, 0x43, 0x2D, 0x30, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x44);
assertEquals("FL-DCC-0", alias.getAlias().getValue());
}
}