mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -05:00
Compare commits
1 Commits
2660-now-p
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5474a5a448 |
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -18,67 +18,73 @@
|
|||||||
*/
|
*/
|
||||||
package io.github.dsheirer.channel.metadata;
|
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.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.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.slf4j.Logger;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import javax.swing.table.AbstractTableModel;
|
/**
|
||||||
|
* Model of the channel metadata for the currently processing channels, with a map from each channel metadata to the
|
||||||
public class ChannelMetadataModel extends AbstractTableModel implements IChannelMetadataUpdateListener
|
* 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;
|
* Listener for changes to the channel metadata model. Notifications arrive on the thread that changed the
|
||||||
public static final int COLUMN_USER_FROM = 2;
|
* model, sometimes while the model is locked, so implementations must not block and must not call back into
|
||||||
public static final int COLUMN_USER_FROM_ALIAS = 3;
|
* the model from the notification thread.
|
||||||
public static final int COLUMN_USER_TO = 4;
|
*/
|
||||||
public static final int COLUMN_USER_TO_ALIAS = 5;
|
public interface Listener
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
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
|
* Constructs an instance
|
||||||
* @param preferenceType that was updated
|
|
||||||
*/
|
*/
|
||||||
@Subscribe
|
public ChannelMetadataModel()
|
||||||
public void preferenceUpdated(PreferenceType preferenceType)
|
|
||||||
{
|
{
|
||||||
if(preferenceType == PreferenceType.TALKGROUP_FORMAT)
|
}
|
||||||
{
|
|
||||||
EventQueue.invokeLater(() -> {
|
/**
|
||||||
for(int row = 0; row < mChannelMetadata.size(); row++)
|
* Registers the listener to receive model change notifications.
|
||||||
{
|
* @param listener to register
|
||||||
fireTableCellUpdated(row, COLUMN_USER_FROM);
|
* @return the channel metadata in the model when the listener is registered, so that the listener starts from
|
||||||
fireTableCellUpdated(row, COLUMN_USER_TO);
|
* 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()
|
public String getDiagnosticInformation()
|
||||||
{
|
{
|
||||||
StringBuilder sb = new StringBuilder();
|
Map<ChannelMetadata,Channel> mapCopy;
|
||||||
Map<ChannelMetadata,Channel> mapCopy = new HashMap<>(mMetadataChannelMap);
|
List<ChannelMetadata> metadataCopy;
|
||||||
List<ChannelMetadata> metadataCopy = new ArrayList<>(mChannelMetadata);
|
|
||||||
|
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
mapCopy = new HashMap<>(mMetadataChannelMap);
|
||||||
|
metadataCopy = new ArrayList<>(mChannelMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("Channel Metadata Model Diagnostics\n");
|
sb.append("Channel Metadata Model Diagnostics\n");
|
||||||
|
|
||||||
for(ChannelMetadata channelMetadata: metadataCopy)
|
for(ChannelMetadata channelMetadata: metadataCopy)
|
||||||
@@ -122,51 +134,34 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the model for disposal.
|
||||||
|
*/
|
||||||
public void dispose()
|
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);
|
if(!mChannelMetadata.contains(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())
|
|
||||||
{
|
{
|
||||||
mChannelMetadata.add(channelMetadata);
|
mChannelMetadata.add(channelMetadata);
|
||||||
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
|
|
||||||
int index = mChannelMetadata.indexOf(channelMetadata);
|
|
||||||
fireTableRowsInserted(index, index);
|
|
||||||
channelMetadata.setUpdateEventListener(ChannelMetadataModel.this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(mChannelAddListener != null)
|
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
|
||||||
{
|
channelMetadata.setUpdateEventListener(this);
|
||||||
mChannelAddListener.receive(channelAndMetadata);
|
}
|
||||||
}
|
|
||||||
});
|
for(Listener listener: mListeners)
|
||||||
|
{
|
||||||
|
listener.added(channelAndMetadata);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +169,7 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
|
|||||||
* @param channelMetadatas
|
* @param channelMetadatas
|
||||||
* @param channel
|
* @param channel
|
||||||
*/
|
*/
|
||||||
public void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
|
public synchronized void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
|
||||||
{
|
{
|
||||||
for(ChannelMetadata channelMetadata: channelMetadatas)
|
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
|
channelMetadata.removeUpdateEventListener();
|
||||||
EventQueue.invokeLater(() -> {
|
mMetadataChannelMap.remove(channelMetadata);
|
||||||
channelMetadata.removeUpdateEventListener();
|
|
||||||
int index = mChannelMetadata.indexOf(channelMetadata);
|
|
||||||
mChannelMetadata.remove(channelMetadata);
|
|
||||||
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 new ArrayList<>(mChannelMetadata);
|
||||||
{
|
|
||||||
return mChannelMetadata.get(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the channel that matches the metadata or null
|
* Returns the channel that matches the metadata or null
|
||||||
*/
|
*/
|
||||||
public Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
|
public synchronized Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
|
||||||
{
|
{
|
||||||
return mMetadataChannelMap.get(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
|
@Override
|
||||||
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
|
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
|
||||||
{
|
{
|
||||||
final int rowIndex = mChannelMetadata.indexOf(channelMetadata);
|
for(Listener listener: mListeners)
|
||||||
|
|
||||||
if(rowIndex >= 0)
|
|
||||||
{
|
{
|
||||||
//Execute on the swing thread to avoid threading issues
|
listener.updated(channelMetadata, channelMetadataField);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,8 @@ package io.github.dsheirer.controller;
|
|||||||
import com.jidesoft.swing.JideTabbedPane;
|
import com.jidesoft.swing.JideTabbedPane;
|
||||||
import io.github.dsheirer.audio.playback.AudioPanel;
|
import io.github.dsheirer.audio.playback.AudioPanel;
|
||||||
import io.github.dsheirer.audio.playback.AudioPlaybackManager;
|
import io.github.dsheirer.audio.playback.AudioPlaybackManager;
|
||||||
import io.github.dsheirer.channel.metadata.NowPlayingPanel;
|
|
||||||
import io.github.dsheirer.eventbus.MyEventBus;
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
|
import io.github.dsheirer.gui.nowplaying.NowPlayingPanel;
|
||||||
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
||||||
import io.github.dsheirer.icon.IconModel;
|
import io.github.dsheirer.icon.IconModel;
|
||||||
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||||
@@ -63,7 +63,7 @@ public class ControllerPanel extends JPanel
|
|||||||
{
|
{
|
||||||
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
||||||
playlistManager.getAliasModel());
|
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,
|
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
|
||||||
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
|
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||||
new RadioReferencePlaylistActions(playlistManager));
|
new RadioReferencePlaylistActions(playlistManager));
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ package io.github.dsheirer.gui.playlist.channel;
|
|||||||
|
|
||||||
import io.github.dsheirer.gui.control.AccessibilityUtil;
|
import io.github.dsheirer.gui.control.AccessibilityUtil;
|
||||||
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
|
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.HexFormatter;
|
||||||
import io.github.dsheirer.gui.control.IntegerFormatter;
|
import io.github.dsheirer.gui.control.IntegerFormatter;
|
||||||
import io.github.dsheirer.gui.playlist.eventlog.EventLogConfigurationEditor;
|
import io.github.dsheirer.gui.playlist.eventlog.EventLogConfigurationEditor;
|
||||||
@@ -80,8 +80,8 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
private TextField mSquelchThresholdField;
|
private TextField mSquelchThresholdField;
|
||||||
private ToggleSwitch mSquelchAutoTrackSwitch;
|
private ToggleSwitch mSquelchAutoTrackSwitch;
|
||||||
private TextFormatter<Integer> mTalkgroupTextFormatter;
|
private TextFormatter<Integer> mTalkgroupTextFormatter;
|
||||||
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)DbPowerMeter.DEFAULT_MINIMUM_POWER,
|
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)PowerMeter.DEFAULT_MINIMUM_POWER,
|
||||||
(int)DbPowerMeter.DEFAULT_MAXIMUM_POWER);
|
(int)PowerMeter.DEFAULT_MAXIMUM_POWER);
|
||||||
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
||||||
private TalkgroupValueChangeListener mTalkgroupValueChangeListener = new TalkgroupValueChangeListener();
|
private TalkgroupValueChangeListener mTalkgroupValueChangeListener = new TalkgroupValueChangeListener();
|
||||||
private IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);
|
private IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);
|
||||||
|
|||||||
235
src/main/java/io/github/dsheirer/gui/power/PowerMeter.java
Normal file
235
src/main/java/io/github/dsheirer/gui/power/PowerMeter.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
* 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
|
* 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.controller.channel.Channel;
|
||||||
import io.github.dsheirer.dsp.squelch.ISquelchConfiguration;
|
import io.github.dsheirer.dsp.squelch.ISquelchConfiguration;
|
||||||
import io.github.dsheirer.gui.control.DbPowerMeter;
|
|
||||||
import io.github.dsheirer.module.ProcessingChain;
|
import io.github.dsheirer.module.ProcessingChain;
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
import io.github.dsheirer.source.SourceEvent;
|
import io.github.dsheirer.source.SourceEvent;
|
||||||
import java.awt.EventQueue;
|
|
||||||
import java.text.DecimalFormat;
|
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.icons.font_awesome.FontAwesome;
|
||||||
import jiconfont.swing.IconFontSwing;
|
import jiconfont.javafx.IconNode;
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
|
||||||
private static final String NOT_AVAILABLE = "Not Available";
|
private static final String NOT_AVAILABLE = "Not Available";
|
||||||
private final DbPowerMeter mPowerMeter = new DbPowerMeter();
|
private final PowerMeter mPowerMeter = new PowerMeter();
|
||||||
private final PeakMonitor mPeakMonitor = new PeakMonitor(DbPowerMeter.DEFAULT_MINIMUM_POWER);
|
private final PeakMonitor mPeakMonitor = new PeakMonitor(PowerMeter.DEFAULT_MINIMUM_POWER);
|
||||||
private final JLabel mPowerLabel;
|
private final Label mPowerLabel = new Label("0");
|
||||||
private final JLabel mPeakLabel;
|
private final Label mPeakLabel = new Label("0");
|
||||||
private final JLabel mSquelchLabel;
|
private final Label mSquelchLabel = new Label("Squelch:");
|
||||||
private final JLabel mSquelchValueLabel;
|
private final Label mSquelchValueLabel = new Label(NOT_AVAILABLE);
|
||||||
private final JButton mSquelchUpButton;
|
private final Button mSquelchUpButton = new Button();
|
||||||
private final JButton mSquelchDownButton;
|
private final Button mSquelchDownButton = new Button();
|
||||||
private final JCheckBox mSquelchAutoTrackCheckBox;
|
private final CheckBox mSquelchAutoTrackCheckBox = new CheckBox("Auto Track");
|
||||||
private double mSquelchThreshold;
|
private final AtomicBoolean mPowerUpdatePending = new AtomicBoolean();
|
||||||
|
private volatile double mPower;
|
||||||
|
private volatile double mPeak;
|
||||||
|
private volatile double mSquelchThreshold;
|
||||||
private final PlaylistManager mPlaylistManager;
|
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)
|
public SignalPowerView(PlaylistManager playlistManager)
|
||||||
{
|
{
|
||||||
|
super(10);
|
||||||
mPlaylistManager = playlistManager;
|
mPlaylistManager = playlistManager;
|
||||||
|
setPadding(new Insets(5));
|
||||||
|
|
||||||
setLayout(new MigLayout("", "[][][][grow,fill]", "[grow,fill]"));
|
Label meterTitle = new Label("Power (dB)");
|
||||||
mPowerMeter.setPeakVisible(true);
|
mPowerMeter.setPeakVisible(true);
|
||||||
mPowerMeter.setSquelchThresholdVisible(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();
|
mPeakLabel.setTooltip(new Tooltip("Current peak power level in decibels."));
|
||||||
valuePanel.setLayout(new MigLayout("", "[right][left][][]", ""));
|
mPowerLabel.setTooltip(new Tooltip("Current Power level in decibels"));
|
||||||
|
mSquelchValueLabel.setTooltip(new Tooltip("Squelch threshold value in decibels"));
|
||||||
|
|
||||||
mPeakLabel = new JLabel("0");
|
mSquelchUpButton.setGraphic(icon(FontAwesome.ANGLE_UP));
|
||||||
mPeakLabel.setToolTipText("Current peak power level in decibels.");
|
mSquelchUpButton.setTooltip(new Tooltip("Increases the squelch threshold value"));
|
||||||
valuePanel.add(new JLabel("Peak:"));
|
mSquelchUpButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
|
||||||
valuePanel.add(mPeakLabel, "wrap");
|
|
||||||
|
|
||||||
mPowerLabel = new JLabel("0");
|
mSquelchDownButton.setGraphic(icon(FontAwesome.ANGLE_DOWN));
|
||||||
mPowerLabel.setToolTipText("Current Power level in decibels");
|
mSquelchDownButton.setTooltip(new Tooltip("Decreases the squelch threshold value."));
|
||||||
valuePanel.add(new JLabel("Power:"));
|
mSquelchDownButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
|
||||||
valuePanel.add(mPowerLabel, "wrap");
|
|
||||||
|
|
||||||
mSquelchLabel = new JLabel("Squelch:");
|
mSquelchAutoTrackCheckBox.setTooltip(new Tooltip("Enable or disable monitoring of the noise floor to " +
|
||||||
mSquelchLabel.setEnabled(false);
|
"auto-adjust the squelch threshold value maintaining a consistent level/buffer above the noise floor"));
|
||||||
valuePanel.add(mSquelchLabel);
|
mSquelchAutoTrackCheckBox.setOnAction(e -> {
|
||||||
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 ->
|
|
||||||
{
|
|
||||||
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
|
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
|
||||||
setConfigSquelchAutoTrack(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)
|
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)
|
if(channel != null && channel.getDecodeConfiguration() instanceof ISquelchConfiguration configuration)
|
||||||
{
|
{
|
||||||
@@ -154,15 +166,16 @@ public class SignalPowerView extends JPanel
|
|||||||
|
|
||||||
private void broadcast(SourceEvent sourceEvent)
|
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
|
* Resets controls when changing processing chain source. Call on the JavaFX application thread.
|
||||||
* dispatch thread because it directly invokes swing components.
|
|
||||||
*/
|
*/
|
||||||
private void reset()
|
private void reset()
|
||||||
{
|
{
|
||||||
@@ -170,44 +183,53 @@ public class SignalPowerView extends JPanel
|
|||||||
mPowerMeter.reset();
|
mPowerMeter.reset();
|
||||||
mPeakLabel.setText("0");
|
mPeakLabel.setText("0");
|
||||||
mPowerLabel.setText("0");
|
mPowerLabel.setText("0");
|
||||||
mSquelchLabel.setEnabled(false);
|
mSquelchLabel.setDisable(true);
|
||||||
mSquelchValueLabel.setText("Not Available");
|
mSquelchValueLabel.setText(NOT_AVAILABLE);
|
||||||
mSquelchValueLabel.setEnabled(false);
|
mSquelchValueLabel.setDisable(true);
|
||||||
mSquelchUpButton.setEnabled(false);
|
mSquelchUpButton.setDisable(true);
|
||||||
mSquelchDownButton.setEnabled(false);
|
mSquelchDownButton.setDisable(true);
|
||||||
mSquelchAutoTrackCheckBox.setEnabled(false);
|
mSquelchAutoTrackCheckBox.setDisable(true);
|
||||||
mSquelchAutoTrackCheckBox.setSelected(false);
|
mSquelchAutoTrackCheckBox.setSelected(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes power and squelch source events from the processing chain. Can be called from any thread.
|
||||||
|
*/
|
||||||
public void receive(SourceEvent sourceEvent)
|
public void receive(SourceEvent sourceEvent)
|
||||||
{
|
{
|
||||||
switch(sourceEvent.getEvent())
|
switch(sourceEvent.getEvent())
|
||||||
{
|
{
|
||||||
case NOTIFICATION_CHANNEL_POWER ->
|
case NOTIFICATION_CHANNEL_POWER ->
|
||||||
{
|
{
|
||||||
final double power = sourceEvent.getValue().doubleValue();
|
mPower = sourceEvent.getValue().doubleValue();
|
||||||
final double peak = mPeakMonitor.process(power);
|
mPeak = mPeakMonitor.process(mPower);
|
||||||
|
|
||||||
EventQueue.invokeLater(() -> {
|
//Power notifications are frequent, so combine the updates that arrive while one is pending
|
||||||
mPowerMeter.setPower(power);
|
if(mPowerUpdatePending.compareAndSet(false, true))
|
||||||
mPowerLabel.setText(DECIMAL_FORMAT.format(power));
|
{
|
||||||
|
Platform.runLater(() -> {
|
||||||
mPowerMeter.setPeak(peak);
|
mPowerUpdatePending.set(false);
|
||||||
mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
|
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 ->
|
case NOTIFICATION_SQUELCH_THRESHOLD ->
|
||||||
{
|
{
|
||||||
final double threshold = sourceEvent.getValue().doubleValue();
|
final double threshold = sourceEvent.getValue().doubleValue();
|
||||||
mSquelchThreshold = threshold;
|
mSquelchThreshold = threshold;
|
||||||
|
|
||||||
EventQueue.invokeLater(() -> {
|
Platform.runLater(() -> {
|
||||||
mPowerMeter.setSquelchThreshold(threshold);
|
mPowerMeter.setSquelchThreshold(threshold);
|
||||||
mSquelchLabel.setEnabled(true);
|
mSquelchLabel.setDisable(false);
|
||||||
mSquelchValueLabel.setEnabled(true);
|
mSquelchValueLabel.setDisable(false);
|
||||||
mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold));
|
mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold));
|
||||||
mSquelchDownButton.setEnabled(true);
|
mSquelchDownButton.setDisable(false);
|
||||||
mSquelchUpButton.setEnabled(true);
|
mSquelchUpButton.setDisable(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
|
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
|
||||||
@@ -215,16 +237,16 @@ public class SignalPowerView extends JPanel
|
|||||||
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
|
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
|
||||||
{
|
{
|
||||||
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
|
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
|
||||||
EventQueue.invokeLater(() -> {
|
Platform.runLater(() -> {
|
||||||
mSquelchAutoTrackCheckBox.setSelected(autoTrack);
|
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)
|
public void setProcessingChain(ProcessingChain processingChain)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
package io.github.dsheirer.identifier.alias;
|
package io.github.dsheirer.identifier.alias;
|
||||||
|
|
||||||
|
import io.github.dsheirer.identifier.Form;
|
||||||
import io.github.dsheirer.identifier.Identifier;
|
import io.github.dsheirer.identifier.Identifier;
|
||||||
|
import io.github.dsheirer.identifier.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.Role;
|
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
|
* Creates a summary listing of talker aliases
|
||||||
* @return summary.
|
* @return summary.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,12 @@ package io.github.dsheirer.module.decode.dmr;
|
|||||||
|
|
||||||
import com.google.common.eventbus.Subscribe;
|
import com.google.common.eventbus.Subscribe;
|
||||||
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
|
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.Event;
|
||||||
|
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||||
import io.github.dsheirer.channel.state.State;
|
import io.github.dsheirer.channel.state.State;
|
||||||
import io.github.dsheirer.channel.state.TimeslotDecoderState;
|
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.ChannelType;
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
import io.github.dsheirer.controller.channel.ChannelConfigurationChangeNotification;
|
import io.github.dsheirer.controller.channel.ChannelConfigurationChangeNotification;
|
||||||
import io.github.dsheirer.identifier.Form;
|
import io.github.dsheirer.identifier.Form;
|
||||||
import io.github.dsheirer.identifier.Identifier;
|
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.IdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.Role;
|
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.integer.IntegerIdentifier;
|
||||||
import io.github.dsheirer.identifier.radio.RadioIdentifier;
|
|
||||||
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
||||||
import io.github.dsheirer.log.LoggingSuppressor;
|
import io.github.dsheirer.log.LoggingSuppressor;
|
||||||
import io.github.dsheirer.message.EmptyTimeslotPlaceholderMessage;
|
import io.github.dsheirer.message.EmptyTimeslotPlaceholderMessage;
|
||||||
@@ -126,6 +127,11 @@ public class DMRDecoderState extends TimeslotDecoderState
|
|||||||
private DMRNetworkConfigurationMonitor mNetworkConfigurationMonitor;
|
private DMRNetworkConfigurationMonitor mNetworkConfigurationMonitor;
|
||||||
private DMRTrafficChannelManager mTrafficChannelManager;
|
private DMRTrafficChannelManager mTrafficChannelManager;
|
||||||
private DecodeEvent mCurrentCallEvent;
|
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 boolean mIgnoreCRCChecksums;
|
||||||
private DMRDecoderState mSisterDecoderState;
|
private DMRDecoderState mSisterDecoderState;
|
||||||
|
|
||||||
@@ -1315,24 +1321,54 @@ public class DMRDecoderState extends TimeslotDecoderState
|
|||||||
case FULL_STANDARD_TALKER_ALIAS_COMPLETE:
|
case FULL_STANDARD_TALKER_ALIAS_COMPLETE:
|
||||||
if(message instanceof TalkerAliasComplete tac && tac.hasTalkerAliasIdentifier())
|
if(message instanceof TalkerAliasComplete tac && tac.hasTalkerAliasIdentifier())
|
||||||
{
|
{
|
||||||
getIdentifierCollection().update(tac.getTalkerAliasIdentifier());
|
processTalkerAlias(tac.getTalkerAliasIdentifier(), tac.getTimestamp());
|
||||||
|
|
||||||
Identifier fromRadio = getIdentifierCollection().getFromIdentifier();
|
|
||||||
|
|
||||||
if(hasTrafficChannelManager() && fromRadio instanceof RadioIdentifier radio)
|
|
||||||
{
|
|
||||||
mTrafficChannelManager.getTalkerAliasManager().update(radio, tac.getTalkerAliasIdentifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
if(mCurrentCallEvent != null)
|
|
||||||
{
|
|
||||||
broadcast(mCurrentCallEvent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
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.
|
* Updates the current call with encryption information.
|
||||||
* @param embeddedEncryptionParameters decoded from the Voice Frame F
|
* @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)
|
private void updateEncryptedCall(EmbeddedEncryptionParameters embeddedEncryptionParameters, boolean isGroup, long timestamp)
|
||||||
{
|
{
|
||||||
|
mTalkerAliasTracker.callActivity(timestamp);
|
||||||
|
|
||||||
if(mCurrentCallEvent != null)
|
if(mCurrentCallEvent != null)
|
||||||
{
|
{
|
||||||
String details = mCurrentCallEvent.getDetails();;
|
String details = mCurrentCallEvent.getDetails();;
|
||||||
@@ -1377,6 +1415,9 @@ public class DMRDecoderState extends TimeslotDecoderState
|
|||||||
*/
|
*/
|
||||||
private void updateCurrentCall(DecodeEventType type, String details, long timestamp)
|
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);
|
Event event = (mCurrentCallEvent == null ? Event.START : Event.CONTINUATION);
|
||||||
|
|
||||||
//Create a repeater channel descriptor if we don't have one
|
//Create a repeater channel descriptor if we don't have one
|
||||||
@@ -1428,10 +1469,12 @@ public class DMRDecoderState extends TimeslotDecoderState
|
|||||||
{
|
{
|
||||||
mCurrentCallEvent.end(timestamp);
|
mCurrentCallEvent.end(timestamp);
|
||||||
broadcast(mCurrentCallEvent);
|
broadcast(mCurrentCallEvent);
|
||||||
|
mEndedCallEvent = mCurrentCallEvent;
|
||||||
mCurrentCallEvent = null;
|
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);
|
getIdentifierCollection().remove(IdentifierClass.USER, Form.TONE, Role.FROM);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,9 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
|
|||||||
public void processChannelGrant(DMRChannel channel, IdentifierCollection identifierCollection,
|
public void processChannelGrant(DMRChannel channel, IdentifierCollection identifierCollection,
|
||||||
Opcode opcode, long timestamp, boolean encrypted)
|
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();
|
mLock.lock();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -419,11 +422,11 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
|
|||||||
}
|
}
|
||||||
else if(event != null) //Update current event
|
else if(event != null) //Update current event
|
||||||
{
|
{
|
||||||
Identifier from = getIdentifier(identifierCollection, Role.FROM);
|
Identifier from = getFrom(identifierCollection);
|
||||||
|
|
||||||
if(from != null)
|
if(from != null)
|
||||||
{
|
{
|
||||||
Identifier currentFrom = getIdentifier(event.getIdentifierCollection(), Role.FROM);
|
Identifier currentFrom = getFrom(event.getIdentifierCollection());
|
||||||
if(currentFrom != null && !Objects.equals(from, currentFrom))
|
if(currentFrom != null && !Objects.equals(from, currentFrom))
|
||||||
{
|
{
|
||||||
event.end(timestamp);
|
event.end(timestamp);
|
||||||
@@ -660,6 +663,15 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
|
|||||||
* @param collection containing a TO identifier
|
* @param collection containing a TO identifier
|
||||||
* @return TO identifier or null
|
* @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)
|
private Identifier getIdentifier(IdentifierCollection collection, Role role)
|
||||||
{
|
{
|
||||||
List<Identifier> identifiers = collection.getIdentifiers(role);
|
List<Identifier> identifiers = collection.getIdentifiers(role);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,17 +24,19 @@ import io.github.dsheirer.channel.state.DecoderState;
|
|||||||
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||||
import io.github.dsheirer.channel.state.State;
|
import io.github.dsheirer.channel.state.State;
|
||||||
import io.github.dsheirer.controller.channel.Channel;
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
import io.github.dsheirer.identifier.Form;
|
|
||||||
import io.github.dsheirer.identifier.Identifier;
|
import io.github.dsheirer.identifier.Identifier;
|
||||||
import io.github.dsheirer.identifier.IdentifierClass;
|
import io.github.dsheirer.identifier.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.Role;
|
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.identifier.radio.RadioIdentifier;
|
||||||
import io.github.dsheirer.message.IMessage;
|
import io.github.dsheirer.message.IMessage;
|
||||||
import io.github.dsheirer.module.decode.DecoderType;
|
import io.github.dsheirer.module.decode.DecoderType;
|
||||||
import io.github.dsheirer.module.decode.event.DecodeEventType;
|
import io.github.dsheirer.module.decode.event.DecodeEventType;
|
||||||
import io.github.dsheirer.module.decode.event.PlottableDecodeEvent;
|
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.NXDNLayer3Message;
|
||||||
import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.ControlChannelInformation;
|
import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.ControlChannelInformation;
|
||||||
import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.SiteInformation;
|
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 Channel mChannel;
|
||||||
private final NXDNNetworkConfigurationMonitor mNetworkConfigurationMonitor = new NXDNNetworkConfigurationMonitor();
|
private final NXDNNetworkConfigurationMonitor mNetworkConfigurationMonitor = new NXDNNetworkConfigurationMonitor();
|
||||||
private final NXDNTrafficChannelManager mTrafficChannelManager;
|
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 mEncryptedCallStateDetermined = false;
|
||||||
private boolean mEncryptedCall = false;
|
private boolean mEncryptedCall = false;
|
||||||
private int mDisconnectResponseCount = 0;
|
private int mDisconnectResponseCount = 0;
|
||||||
@@ -139,7 +144,9 @@ public class NXDNDecoderState extends DecoderState
|
|||||||
switch(event.getEvent())
|
switch(event.getEvent())
|
||||||
{
|
{
|
||||||
case REQUEST_RESET:
|
case REQUEST_RESET:
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
resetState();
|
resetState();
|
||||||
|
mTalkerAliasTracker.reset();
|
||||||
break;
|
break;
|
||||||
case NOTIFICATION_SOURCE_FREQUENCY:
|
case NOTIFICATION_SOURCE_FREQUENCY:
|
||||||
setCurrentFrequency(event.getFrequency());
|
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
|
* Broadcasts the arguments as a new decode event
|
||||||
* @param identifiers involved in the event
|
* @param identifiers involved in the event
|
||||||
@@ -265,6 +298,9 @@ public class NXDNDecoderState extends DecoderState
|
|||||||
{
|
{
|
||||||
mIdleDuringCallCount = 0;
|
mIdleDuringCallCount = 0;
|
||||||
getIdentifierCollection().update(vc.getIdentifiers());
|
getIdentifierCollection().update(vc.getIdentifiers());
|
||||||
|
//Keep the talker alias matched to the radio that is talking
|
||||||
|
mTalkerAliasTracker.callActivity(vc.getTimestamp());
|
||||||
|
|
||||||
mEncryptedCallStateDetermined = true;
|
mEncryptedCallStateDetermined = true;
|
||||||
mEncryptedCall = vc.getEncryptionKeyIdentifier().isEncrypted();
|
mEncryptedCall = vc.getEncryptionKeyIdentifier().isEncrypted();
|
||||||
state = mEncryptedCall ? State.ENCRYPTED : State.CALL;
|
state = mEncryptedCall ? State.ENCRYPTED : State.CALL;
|
||||||
@@ -326,6 +362,7 @@ public class NXDNDecoderState extends DecoderState
|
|||||||
mEncryptedCall = false;
|
mEncryptedCall = false;
|
||||||
mIdleDuringCallCount = IDLE_DURING_CALL_MAX_COUNT;
|
mIdleDuringCallCount = IDLE_DURING_CALL_MAX_COUNT;
|
||||||
//Only remove the FROM identifier, anticipating a continuation call for the same talkgroup
|
//Only remove the FROM identifier, anticipating a continuation call for the same talkgroup
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
getIdentifierCollection().remove(Role.FROM);
|
getIdentifierCollection().remove(Role.FROM);
|
||||||
break;
|
break;
|
||||||
case CONTROL_OUT_09_CC_DATA_CALL_RESPONSE:
|
case CONTROL_OUT_09_CC_DATA_CALL_RESPONSE:
|
||||||
@@ -404,6 +441,7 @@ public class NXDNDecoderState extends DecoderState
|
|||||||
mTrafficChannelManager.processEndCall(getCurrentChannel(), layer3.getTimestamp());
|
mTrafficChannelManager.processEndCall(getCurrentChannel(), layer3.getTimestamp());
|
||||||
mEncryptedCallStateDetermined = false;
|
mEncryptedCallStateDetermined = false;
|
||||||
mEncryptedCall = false;
|
mEncryptedCall = false;
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
getIdentifierCollection().remove(IdentifierClass.USER);
|
getIdentifierCollection().remove(IdentifierClass.USER);
|
||||||
mDisconnectResponseCount++;
|
mDisconnectResponseCount++;
|
||||||
|
|
||||||
@@ -424,6 +462,7 @@ public class NXDNDecoderState extends DecoderState
|
|||||||
case CONTROL_OUT_27_BC_ADJACENT_SITE_INFORMATION:
|
case CONTROL_OUT_27_BC_ADJACENT_SITE_INFORMATION:
|
||||||
case CONTROL_OUT_28_BC_FAILURE_STATUS_INFORMATION:
|
case CONTROL_OUT_28_BC_FAILURE_STATUS_INFORMATION:
|
||||||
mNetworkConfigurationMonitor.process(layer3);
|
mNetworkConfigurationMonitor.process(layer3);
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
getIdentifierCollection().remove(IdentifierClass.USER);
|
getIdentifierCollection().remove(IdentifierClass.USER);
|
||||||
break;
|
break;
|
||||||
case CONTROL_OUT_26_BC_CONTROL_CHANNEL_INFORMATION:
|
case CONTROL_OUT_26_BC_CONTROL_CHANNEL_INFORMATION:
|
||||||
@@ -645,12 +684,7 @@ public class NXDNDecoderState extends DecoderState
|
|||||||
{
|
{
|
||||||
state = State.CALL;
|
state = State.CALL;
|
||||||
event = DecoderStateEvent.Event.CONTINUATION;
|
event = DecoderStateEvent.Event.CONTINUATION;
|
||||||
var radio = getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
|
processTalkerAlias(tac.getTalkerAlias(), tac.getTimestamp());
|
||||||
|
|
||||||
if(radio instanceof RadioIdentifier ri)
|
|
||||||
{
|
|
||||||
mTrafficChannelManager.processTalkerAlias(getCurrentChannel(), tac.getTalkerAlias(), ri, tac.getTimestamp());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ import io.github.dsheirer.controller.channel.ChannelEvent;
|
|||||||
import io.github.dsheirer.controller.channel.IChannelEventListener;
|
import io.github.dsheirer.controller.channel.IChannelEventListener;
|
||||||
import io.github.dsheirer.controller.channel.IChannelEventProvider;
|
import io.github.dsheirer.controller.channel.IChannelEventProvider;
|
||||||
import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest;
|
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.Identifier;
|
||||||
|
import io.github.dsheirer.identifier.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
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.TalkerAliasManager;
|
||||||
|
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
|
||||||
import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier;
|
import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier;
|
||||||
import io.github.dsheirer.identifier.radio.RadioIdentifier;
|
import io.github.dsheirer.identifier.radio.RadioIdentifier;
|
||||||
import io.github.dsheirer.module.decode.event.DecodeEvent;
|
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 channel for the call
|
||||||
* @param talkerAlias to add or update
|
* @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
|
* @param timestamp for the talker alias message
|
||||||
*/
|
*/
|
||||||
public void processTalkerAlias(IChannelDescriptor channel, NXDNTalkerAliasIdentifier talkerAlias,
|
public void processTalkerAlias(IChannelDescriptor channel, NXDNTalkerAliasIdentifier talkerAlias,
|
||||||
@@ -457,11 +463,20 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements
|
|||||||
|
|
||||||
if(tracker != null)
|
if(tracker != null)
|
||||||
{
|
{
|
||||||
tracker.addIdentifierIfMissing(talkerAlias);
|
Identifier eventRadio = tracker.getEvent().getIdentifierCollection()
|
||||||
broadcast(tracker);
|
.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
|
finally
|
||||||
{
|
{
|
||||||
@@ -537,7 +552,8 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements
|
|||||||
{
|
{
|
||||||
NXDNChannelEventTracker tracker = getTrackerRemoveIfStale(frequency, timestamp);
|
NXDNChannelEventTracker tracker = getTrackerRemoveIfStale(frequency, timestamp);
|
||||||
MutableIdentifierCollection ic = getMergedIdentifierCollection(identifiers);
|
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 != null)
|
||||||
{
|
{
|
||||||
if(tracker.isSameCallCheckingToAndFrom(ic, timestamp))
|
if(tracker.isSameCallCheckingToAndFrom(ic, timestamp))
|
||||||
|
|||||||
@@ -19,20 +19,24 @@
|
|||||||
package io.github.dsheirer.module.decode.p25;
|
package io.github.dsheirer.module.decode.p25;
|
||||||
|
|
||||||
import io.github.dsheirer.channel.IChannelDescriptor;
|
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.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.Event;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelEvent;
|
||||||
import io.github.dsheirer.controller.channel.IChannelEventListener;
|
import io.github.dsheirer.controller.channel.IChannelEventListener;
|
||||||
import io.github.dsheirer.controller.channel.IChannelEventProvider;
|
import io.github.dsheirer.controller.channel.IChannelEventProvider;
|
||||||
import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest;
|
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.Identifier;
|
||||||
|
import io.github.dsheirer.identifier.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
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.TalkerAliasManager;
|
||||||
import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier;
|
import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier;
|
||||||
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
|
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
|
||||||
import io.github.dsheirer.identifier.patch.PatchGroupPreLoadDataContent;
|
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.scramble.ScrambleParameterIdentifier;
|
||||||
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
||||||
import io.github.dsheirer.log.LoggingSuppressor;
|
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.
|
* Updates all current identifiers for an ongoing call event on the frequency and updates the event duration timestamp.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ package io.github.dsheirer.module.decode.p25.phase1;
|
|||||||
import com.google.common.eventbus.Subscribe;
|
import com.google.common.eventbus.Subscribe;
|
||||||
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
|
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
|
||||||
import io.github.dsheirer.channel.state.DecoderState;
|
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.Event;
|
||||||
|
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||||
import io.github.dsheirer.channel.state.State;
|
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.ChannelType;
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
import io.github.dsheirer.controller.channel.ChannelEvent;
|
import io.github.dsheirer.controller.channel.ChannelEvent;
|
||||||
import io.github.dsheirer.controller.channel.IChannelEventListener;
|
import io.github.dsheirer.controller.channel.IChannelEventListener;
|
||||||
import io.github.dsheirer.identifier.Form;
|
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.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.Role;
|
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.configuration.FrequencyConfigurationIdentifier;
|
||||||
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
|
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
|
||||||
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
|
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.
|
* algorithm ID takes precedence over the link control service options when it is known.
|
||||||
*/
|
*/
|
||||||
private Boolean mCallEncryptedByAlgorithm = null;
|
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.
|
* 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())
|
else if(iMessage instanceof MotorolaTalkerAliasComplete tac && tac.isValid())
|
||||||
{
|
{
|
||||||
mTrafficChannelManager.getTalkerAliasManager().update(tac.getRadio(), tac.getAlias());
|
//Motorola talker aliases identify their radio
|
||||||
mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), tac.getAlias(), tac.getTimestamp(), tac.toString());
|
processTalkerAlias(tac.getAlias(), tac.getRadio(), tac.getTimestamp(), tac.toString());
|
||||||
}
|
}
|
||||||
else if(iMessage instanceof LCHarrisTalkerAliasComplete talkerAlias)
|
else if(iMessage instanceof LCHarrisTalkerAliasComplete talkerAlias)
|
||||||
{
|
{
|
||||||
processTalkerAlias(talkerAlias);
|
processTalkerAlias(talkerAlias.getTalkerAlias(), null, talkerAlias.getTimestamp(), talkerAlias.toString());
|
||||||
}
|
}
|
||||||
else if(iMessage instanceof LinkControlWord lcw)
|
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.
|
* Talker alias manager shared with the traffic channel manager
|
||||||
* @param talkerAlias reassembled.
|
|
||||||
*/
|
*/
|
||||||
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).
|
* Link Control (LC) Channel user (ie current user on this channel).
|
||||||
*/
|
*/
|
||||||
private void processLCChannelUser(LinkControlWord lcw, long timestamp)
|
private void processLCChannelUser(LinkControlWord lcw, long timestamp)
|
||||||
{
|
{
|
||||||
getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp));
|
updateChannelUserIdentifiers(lcw, timestamp, true);
|
||||||
DecodeEventType decodeEventType = getLCDecodeEventType(lcw);
|
DecodeEventType decodeEventType = getLCDecodeEventType(lcw);
|
||||||
|
|
||||||
ServiceOptions serviceOptions = null;
|
ServiceOptions serviceOptions = null;
|
||||||
@@ -443,7 +483,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
|||||||
serviceOptions = VoiceServiceOptions.createUnencrypted();
|
serviceOptions = VoiceServiceOptions.createUnencrypted();
|
||||||
}
|
}
|
||||||
|
|
||||||
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
|
|
||||||
MutableIdentifierCollection mic = getMutableIdentifierCollection(getIdentifierCollection().getIdentifiers(), timestamp);
|
MutableIdentifierCollection mic = getMutableIdentifierCollection(getIdentifierCollection().getIdentifiers(), timestamp);
|
||||||
mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), getCurrentChannel(), decodeEventType,
|
mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), getCurrentChannel(), decodeEventType,
|
||||||
serviceOptions, mic, timestamp, null, lcw.toString());
|
serviceOptions, mic, timestamp, null, lcw.toString());
|
||||||
@@ -853,6 +892,8 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
|||||||
Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
|
Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
|
||||||
|
|
||||||
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
|
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
|
||||||
|
//A call starts. The HDU doesn't identify the talker.
|
||||||
|
mTalkerAliasTracker.callActivity(message.getTimestamp());
|
||||||
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
|
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
|
||||||
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
|
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
|
||||||
|
|
||||||
@@ -921,9 +962,22 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
|||||||
{
|
{
|
||||||
mCallEncryptedByAlgorithm = null;
|
mCallEncryptedByAlgorithm = null;
|
||||||
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
|
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
|
||||||
|
clearCallUser();
|
||||||
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
|
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
|
* Process Terminator Data Unit with Link Control (TDULC) message and forwards valid Link Control Word message for
|
||||||
* additional processing.
|
* additional processing.
|
||||||
@@ -942,6 +996,8 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
|||||||
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
|
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
|
||||||
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
|
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
|
||||||
processLC(lcw, message.getTimestamp(), true);
|
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:
|
case UNIT_TO_UNIT_VOICE_CHANNEL_USER_EXTENDED:
|
||||||
if(isTerminator)
|
if(isTerminator)
|
||||||
{
|
{
|
||||||
getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp));
|
updateChannelUserIdentifiers(lcw, timestamp, false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2159,6 +2215,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
|||||||
{
|
{
|
||||||
case REQUEST_RESET:
|
case REQUEST_RESET:
|
||||||
resetState();
|
resetState();
|
||||||
|
mTalkerAliasTracker.reset();
|
||||||
mNetworkConfigurationMonitor.reset();
|
mNetworkConfigurationMonitor.reset();
|
||||||
break;
|
break;
|
||||||
case NOTIFICATION_SOURCE_FREQUENCY:
|
case NOTIFICATION_SOURCE_FREQUENCY:
|
||||||
|
|||||||
@@ -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.LinkControlWord;
|
||||||
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.HarrisTalkerAliasAssembler;
|
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.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.LCHarrisTalkerGPSBlock1;
|
||||||
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSBlock2;
|
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSBlock2;
|
||||||
import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSComplete;
|
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)
|
else if(message instanceof TDULCMessage tdulc)
|
||||||
{
|
{
|
||||||
boolean motorolaTalkerAliasComplete = false;
|
boolean motorolaTalkerAliasComplete = false;
|
||||||
|
LCHarrisTalkerAliasComplete harrisTalkerAliasComplete = null;
|
||||||
|
boolean harrisTalkerAliasBlock = false;
|
||||||
LinkControlWord lcw = tdulc.getLinkControlWord();
|
LinkControlWord lcw = tdulc.getLinkControlWord();
|
||||||
|
|
||||||
if(lcw instanceof IExtendedSourceMessage esm)
|
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
|
//Motorola carries the talker alias in the TDULC
|
||||||
else if(mMotorolaTalkerAliasAssembler.add(lcw, message.getTimestamp()))
|
else if(mMotorolaTalkerAliasAssembler.add(lcw, message.getTimestamp()))
|
||||||
{
|
{
|
||||||
motorolaTalkerAliasComplete = true;
|
motorolaTalkerAliasComplete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Harris carries the talker alias in the LDU1 link control messages, so reset the assembler on TDULC.
|
//Reset the L3Harris assembler on other TDULC messages so that alias blocks from different calls
|
||||||
mHarrisTalkerAliasAssembler.reset();
|
//aren't combined. Keep the blocks while the TDULC messages carry the alias.
|
||||||
|
if(!harrisTalkerAliasBlock)
|
||||||
|
{
|
||||||
|
mHarrisTalkerAliasAssembler.reset();
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(tdulc);
|
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)
|
if(motorolaTalkerAliasComplete)
|
||||||
{
|
{
|
||||||
dispatch(mMotorolaTalkerAliasAssembler.assemble());
|
dispatch(mMotorolaTalkerAliasAssembler.assemble());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(harrisTalkerAliasComplete != null)
|
||||||
|
{
|
||||||
|
dispatch(harrisTalkerAliasComplete);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if(message instanceof HDUMessage hdu && mHeldTDULCMessage != null)
|
else if(message instanceof HDUMessage hdu && mHeldTDULCMessage != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -103,9 +103,10 @@ public class HarrisTalkerAliasAssembler
|
|||||||
{
|
{
|
||||||
if(mBlock1 != null && mBlock2 != null)
|
if(mBlock1 != null && mBlock2 != null)
|
||||||
{
|
{
|
||||||
String fragment2 = mBlock2.getPayloadFragmentString();
|
//Untrimmed, so that a space at the start or end of a fragment is kept within the alias
|
||||||
String fragment3 = mBlock3 != null ? mBlock3.getPayloadFragmentString() : null;
|
String fragment2 = mBlock2.getPayloadFragmentText();
|
||||||
String fragment4 = mBlock4 != null ? mBlock4.getPayloadFragmentString() : null;
|
String fragment3 = mBlock3 != null ? mBlock3.getPayloadFragmentText() : null;
|
||||||
|
String fragment4 = mBlock4 != null ? mBlock4.getPayloadFragmentText() : null;
|
||||||
return new LCHarrisTalkerAliasComplete(mBlock1.getMessage(), fragment2, fragment3, fragment4, mTimestamp);
|
return new LCHarrisTalkerAliasComplete(mBlock1.getMessage(), fragment2, fragment3, fragment4, mTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ public abstract class LCHarrisTalkerAliasBase extends LinkControlWord
|
|||||||
return new String(getPayloadFragment().toByteArray()).trim();
|
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
|
* List of identifiers contained in this message
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes
|
|||||||
{
|
{
|
||||||
if(mTalkerAlias == null)
|
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))
|
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;
|
return mTalkerAlias;
|
||||||
@@ -134,6 +135,16 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes
|
|||||||
return new String(getPayloadFragment().toByteArray()).trim();
|
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
|
@Override
|
||||||
public long getTimestamp()
|
public long getTimestamp()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,19 +22,21 @@ import com.google.common.eventbus.Subscribe;
|
|||||||
import io.github.dsheirer.channel.IBandChannelDescriptor;
|
import io.github.dsheirer.channel.IBandChannelDescriptor;
|
||||||
import io.github.dsheirer.channel.IChannelDescriptor;
|
import io.github.dsheirer.channel.IChannelDescriptor;
|
||||||
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
|
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.Event;
|
||||||
|
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||||
import io.github.dsheirer.channel.state.State;
|
import io.github.dsheirer.channel.state.State;
|
||||||
import io.github.dsheirer.channel.state.TimeslotDecoderState;
|
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.ChannelType;
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
import io.github.dsheirer.identifier.Form;
|
import io.github.dsheirer.identifier.Form;
|
||||||
import io.github.dsheirer.identifier.Identifier;
|
import io.github.dsheirer.identifier.Identifier;
|
||||||
import io.github.dsheirer.identifier.IdentifierClass;
|
import io.github.dsheirer.identifier.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.IdentifierUpdateListener;
|
import io.github.dsheirer.identifier.IdentifierUpdateListener;
|
||||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||||
import io.github.dsheirer.identifier.Role;
|
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.encryption.EncryptionKey;
|
||||||
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
|
import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
|
||||||
import io.github.dsheirer.identifier.patch.PatchGroupManager;
|
import io.github.dsheirer.identifier.patch.PatchGroupManager;
|
||||||
@@ -147,6 +149,9 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
private PatchGroupManager mPatchGroupManager;
|
private PatchGroupManager mPatchGroupManager;
|
||||||
private P25P2NetworkConfigurationMonitor mNetworkConfigurationMonitor = new P25P2NetworkConfigurationMonitor();
|
private P25P2NetworkConfigurationMonitor mNetworkConfigurationMonitor = new P25P2NetworkConfigurationMonitor();
|
||||||
private P25TrafficChannelManager mTrafficChannelManager;
|
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;
|
private int mEndPttOnFacchCounter = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,6 +195,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
*/
|
*/
|
||||||
protected void resetState()
|
protected void resetState()
|
||||||
{
|
{
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
super.resetState();
|
super.resetState();
|
||||||
mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), System.currentTimeMillis(), "RESET STATE INVOKED");
|
mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), System.currentTimeMillis(), "RESET STATE INVOKED");
|
||||||
mEndPttOnFacchCounter = 0;
|
mEndPttOnFacchCounter = 0;
|
||||||
@@ -239,6 +245,8 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
broadcast(new DecoderStateEvent(this, Event.CONTINUATION, State.CALL, getTimeslot()));
|
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
|
//If we're tracking the call event, update the duration on it
|
||||||
mTrafficChannelManager.processP2TrafficVoice(getCurrentFrequency(), getTimeslot(), message.getTimestamp());
|
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())
|
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:
|
case MAC_6_HANGTIME:
|
||||||
//During hangtime, the from talker is no longer involved in the call ... remove it because sometimes
|
//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.
|
//we don't get the end PTT to signal the end.
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
getIdentifierCollection().remove(Role.FROM);
|
getIdentifierCollection().remove(Role.FROM);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -763,7 +773,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
|
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
|
mTalkerAliasTracker.update();
|
||||||
|
|
||||||
continueState(State.ACTIVE);
|
continueState(State.ACTIVE);
|
||||||
}
|
}
|
||||||
@@ -1103,14 +1113,15 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
|
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
|
|
||||||
|
|
||||||
if(message.getMacPduType() == MacPduType.MAC_3_IDLE || message.getMacPduType() == MacPduType.MAC_6_HANGTIME)
|
if(message.getMacPduType() == MacPduType.MAC_3_IDLE || message.getMacPduType() == MacPduType.MAC_6_HANGTIME)
|
||||||
{
|
{
|
||||||
|
mTalkerAliasTracker.update();
|
||||||
continueState(State.ACTIVE);
|
continueState(State.ACTIVE);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
mTalkerAliasTracker.callActivity(message.getTimestamp());
|
||||||
|
|
||||||
if(mac instanceof IServiceOptionsProvider sop)
|
if(mac instanceof IServiceOptionsProvider sop)
|
||||||
{
|
{
|
||||||
IChannelDescriptor currentChannel = mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(),
|
IChannelDescriptor currentChannel = mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(),
|
||||||
@@ -1153,7 +1164,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
|
getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection());
|
mTalkerAliasTracker.callActivity(message.getTimestamp());
|
||||||
|
|
||||||
if(mac instanceof PushToTalk ptt)
|
if(mac instanceof PushToTalk ptt)
|
||||||
{
|
{
|
||||||
@@ -1225,6 +1236,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
if(mac instanceof EndPushToTalk)
|
if(mac instanceof EndPushToTalk)
|
||||||
{
|
{
|
||||||
//No matter what, remove the FROM identifier on end PTT.
|
//No matter what, remove the FROM identifier on end PTT.
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
getIdentifierCollection().remove(Role.FROM);
|
getIdentifierCollection().remove(Role.FROM);
|
||||||
|
|
||||||
//Only reset the identifiers if the call event is closed out, otherwise we might have a timing issue
|
//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)
|
if(mac instanceof MacRelease mr)
|
||||||
{
|
{
|
||||||
mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), message.getTimestamp(), "MAC RELEASE: " + mac.toString());
|
mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), message.getTimestamp(), "MAC RELEASE: " + mac.toString());
|
||||||
|
mTalkerAliasTracker.callEnded();
|
||||||
getIdentifierCollection().remove(IdentifierClass.USER);
|
getIdentifierCollection().remove(IdentifierClass.USER);
|
||||||
broadcast(message, mac, DecodeEventType.COMMAND,
|
broadcast(message, mac, DecodeEventType.COMMAND,
|
||||||
(mr.isForcedPreemption() ? "FORCED " : "") + "CALL PREEMPTION" +
|
(mr.isForcedPreemption() ? "FORCED " : "") + "CALL PREEMPTION" +
|
||||||
@@ -1720,17 +1733,34 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie
|
|||||||
{
|
{
|
||||||
if(mac instanceof L3HarrisTalkerAlias talkerAlias)
|
if(mac instanceof L3HarrisTalkerAlias talkerAlias)
|
||||||
{
|
{
|
||||||
P25TalkerAliasIdentifier alias = talkerAlias.getAlias();
|
processTalkerAlias(talkerAlias.getAlias(), null, message.getTimestamp());
|
||||||
getIdentifierCollection().update(alias);
|
}
|
||||||
mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(), getTimeslot(), alias, 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)
|
/**
|
||||||
{
|
* Processes a talker alias: stores it for its radio and adds it to the call that it belongs to.
|
||||||
mTrafficChannelManager.getTalkerAliasManager().update(ri, alias);
|
* @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:
|
case REQUEST_RESET:
|
||||||
resetState();
|
resetState();
|
||||||
|
mTalkerAliasTracker.reset();
|
||||||
mNetworkConfigurationMonitor.reset();
|
mNetworkConfigurationMonitor.reset();
|
||||||
break;
|
break;
|
||||||
case NOTIFICATION_SOURCE_FREQUENCY:
|
case NOTIFICATION_SOURCE_FREQUENCY:
|
||||||
|
|||||||
@@ -75,19 +75,22 @@ public class L3HarrisTalkerAlias extends MacStructureVendor
|
|||||||
{
|
{
|
||||||
if(mAliasIdentifier == null)
|
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;
|
byte[] bytes = getMessage().getSubMessage(start, end).getBytes();
|
||||||
length += getOffset();
|
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);
|
mAliasIdentifier = P25TalkerAliasIdentifier.create(alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user