diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java index 00251fd2..cac20102 100644 --- a/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java +++ b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelDetailView.java @@ -56,7 +56,7 @@ public class ChannelDetailView extends VBox implements Listener Button refreshButton = new Button("Refresh"); refreshButton.setTooltip(new Tooltip("Update the channel details")); - refreshButton.setOnAction(event -> receive(mProcessingChain)); + 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); @@ -68,6 +68,14 @@ public class ChannelDetailView extends VBox implements Listener 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); diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java index 781dceab..52d9fde1 100644 --- a/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java +++ b/src/main/java/io/github/dsheirer/gui/nowplaying/ChannelMetadataView.java @@ -42,6 +42,7 @@ 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; @@ -315,19 +316,15 @@ public class ChannelMetadataView extends VBox implements ChannelMetadataModel.Li columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER, row -> { - ChannelMetadata metadata = row.getChannelMetadata(); - String text = mTalkgroupFormatPreference.format(metadata.getFromIdentifier()); + String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getFromIdentifier()); + return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text; + }, Row::revisionProperty)); - if(text == null || text.isEmpty()) - { - return EMPTY_IDENTIFIER; - } - else if(metadata.hasTalkerAliasIdentifier()) - { - return text + " " + metadata.getTalkerAliasIdentifier(); - } - - return text; + //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 fromAlias = NowPlayingCells.column("from.alias", "Alias", "From Alias", @@ -368,6 +365,20 @@ public class ChannelMetadataView extends VBox implements ChannelMetadataModel.Li 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 column: columns) + { + int swingColumn = Arrays.asList(swingColumns).indexOf(column.getId()); + + if(swingColumn >= 0) + { + TableColumnMonitor.setLegacyIndex(column, swingColumn); + } + } + return columns; } diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java index 3c07187f..0db7ffdf 100644 --- a/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java +++ b/src/main/java/io/github/dsheirer/gui/nowplaying/DecodeEventView.java @@ -26,6 +26,7 @@ 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; @@ -188,6 +189,10 @@ public class DecodeEventView extends HistoryTableView 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,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias", "From Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.FROM, aliasModel), Row::revisionProperty); @@ -220,9 +225,54 @@ public class DecodeEventView extends HistoryTableView 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 swingColumns = List.of("time", "duration", "event", "from", "from.alias", "to", "to.alias", + "channel", "frequency", "details"); + + for(TableColumn,?> 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 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 getIdentifiers(IdentifierCollection identifierCollection, Role role) + { + List 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. */ @@ -278,7 +328,7 @@ public class DecodeEventView extends HistoryTableView return null; } - List identifiers = identifierCollection.getIdentifiers(role); + List identifiers = getIdentifiers(identifierCollection, role); if(identifiers == null || identifiers.isEmpty()) { @@ -319,7 +369,7 @@ public class DecodeEventView extends HistoryTableView return null; } - List identifiers = identifierCollection.getIdentifiers(role); + List identifiers = getIdentifiers(identifierCollection, role); if(identifiers == null || identifiers.isEmpty()) { diff --git a/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java index 2ccac4f0..963a3811 100644 --- a/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java +++ b/src/main/java/io/github/dsheirer/gui/nowplaying/NowPlayingView.java @@ -62,14 +62,22 @@ public class NowPlayingView extends SplitPane mMessageActivityView = new MessageActivityView(userPreferences); mChannelSpectrumView = new ChannelSpectrumView(playlistManager); + Tab detailsTab = new Tab("Details", mChannelDetailView); Tab channelTab = new Tab("Channel", mChannelSpectrumView); - mTabPane.getTabs().addAll(new Tab("Details", mChannelDetailView), new Tab("Events", mDecodeEventView), + 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)); + 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); diff --git a/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java b/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java index ada34c42..d4d13669 100644 --- a/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java +++ b/src/main/java/io/github/dsheirer/gui/table/TableColumnMonitor.java @@ -50,6 +50,11 @@ public class TableColumnMonitor * 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."; @@ -186,6 +191,18 @@ public class TableColumnMonitor 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. @@ -326,14 +343,19 @@ public class TableColumnMonitor } } + boolean hasLegacyIndexes = mDefaultOrder.stream() + .anyMatch(column -> column.getProperties().containsKey(LEGACY_INDEX)); + for(int x = 0; x < mDefaultOrder.size(); x++) { TableColumn 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) + if(Double.isNaN(width) && swingPreference != null && swingKey != null && legacyIndex != null) { - int swingWidth = swingPreference.getInt(swingKey + ".column." + x, Integer.MAX_VALUE); + int swingWidth = swingPreference.getInt(swingKey + ".column." + legacyIndex, Integer.MAX_VALUE); if(swingWidth != Integer.MAX_VALUE && swingWidth > 0) { diff --git a/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasManager.java b/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasManager.java index d16c699a..cd987084 100644 --- a/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasManager.java +++ b/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasManager.java @@ -19,7 +19,9 @@ package io.github.dsheirer.identifier.alias; +import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; +import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.identifier.MutableIdentifierCollection; import io.github.dsheirer.identifier.Role; @@ -116,6 +118,16 @@ public class TalkerAliasManager } } + /** + * Current FROM radio in the identifier collection + * @param identifierCollection to inspect + * @return FROM radio identifier, or null + */ + public static Identifier getFromRadio(IdentifierCollection identifierCollection) + { + return identifierCollection.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM); + } + /** * Creates a summary listing of talker aliases * @return summary. diff --git a/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasTracker.java b/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasTracker.java new file mode 100644 index 00000000..132a0226 --- /dev/null +++ b/src/main/java/io/github/dsheirer/identifier/alias/TalkerAliasTracker.java @@ -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 + * **************************************************************************** + */ +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: + *
    + *
  • During the call, after the talking radio is identified (most protocols).
  • + *
  • 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.
  • + *
  • 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}.
  • + *
  • With their own radio identifier (e.g. Motorola), which is stored for that radio.
  • + *
+ * + * 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 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, 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); + } +} diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java index 7ae18b12..25094378 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java @@ -20,12 +20,12 @@ package io.github.dsheirer.module.decode.dmr; import com.google.common.eventbus.Subscribe; import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent; -import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.DecoderStateEvent.Event; +import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.State; import io.github.dsheirer.channel.state.TimeslotDecoderState; -import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.Channel.ChannelType; +import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.ChannelConfigurationChangeNotification; import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; @@ -33,9 +33,10 @@ import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.identifier.MutableIdentifierCollection; import io.github.dsheirer.identifier.Role; -import io.github.dsheirer.identifier.alias.DmrTalkerAliasIdentifier; +import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier; +import io.github.dsheirer.identifier.alias.TalkerAliasManager; +import io.github.dsheirer.identifier.alias.TalkerAliasTracker; import io.github.dsheirer.identifier.integer.IntegerIdentifier; -import io.github.dsheirer.identifier.radio.RadioIdentifier; import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier; import io.github.dsheirer.log.LoggingSuppressor; import io.github.dsheirer.message.EmptyTimeslotPlaceholderMessage; @@ -126,6 +127,11 @@ public class DMRDecoderState extends TimeslotDecoderState private DMRNetworkConfigurationMonitor mNetworkConfigurationMonitor; private DMRTrafficChannelManager mTrafficChannelManager; private DecodeEvent mCurrentCallEvent; + //Call event of the call that just ended, for a talker alias that completes after the call ends + private DecodeEvent mEndedCallEvent; + //Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio + private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(), + this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER); private boolean mIgnoreCRCChecksums; private DMRDecoderState mSisterDecoderState; @@ -1315,24 +1321,54 @@ public class DMRDecoderState extends TimeslotDecoderState case FULL_STANDARD_TALKER_ALIAS_COMPLETE: if(message instanceof TalkerAliasComplete tac && tac.hasTalkerAliasIdentifier()) { - getIdentifierCollection().update(tac.getTalkerAliasIdentifier()); - - Identifier fromRadio = getIdentifierCollection().getFromIdentifier(); - - if(hasTrafficChannelManager() && fromRadio instanceof RadioIdentifier radio) - { - mTrafficChannelManager.getTalkerAliasManager().update(radio, tac.getTalkerAliasIdentifier()); - } - - if(mCurrentCallEvent != null) - { - broadcast(mCurrentCallEvent); - } + processTalkerAlias(tac.getTalkerAliasIdentifier(), tac.getTimestamp()); } break; } } + /** + * Talker alias manager shared with the traffic channel manager, or null when there's no traffic channel manager. + */ + private TalkerAliasManager getTalkerAliasManager() + { + return hasTrafficChannelManager() ? mTrafficChannelManager.getTalkerAliasManager() : null; + } + + /** + * Processes a talker alias: stores it for the talking radio and adds it to the call that it belongs to. + * @param talkerAlias that was received + * @param timestamp of the message + */ + private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, long timestamp) + { + switch(mTalkerAliasTracker.received(talkerAlias, null, timestamp)) + { + case CURRENT_CALL -> + { + if(mCurrentCallEvent != null) + { + mCurrentCallEvent.setIdentifierCollection(getIdentifierCollection().copyOf()); + broadcast(mCurrentCallEvent); + } + } + case ENDED_CALL -> + { + //Alias completed after the call ended: add it to that call's event when it's the same talker + if(mEndedCallEvent != null && TalkerAliasTracker.isSameRadio(mTalkerAliasTracker.getEndedCallRadio(), + mEndedCallEvent.getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM))) + { + MutableIdentifierCollection mic = new MutableIdentifierCollection( + mEndedCallEvent.getIdentifierCollection().getIdentifiers()); + mic.update(talkerAlias); + mEndedCallEvent.setIdentifierCollection(mic); + broadcast(mEndedCallEvent); + } + } + default -> {} //Stored for the radio, for the radio's next call + } + } + /** * Updates the current call with encryption information. * @param embeddedEncryptionParameters decoded from the Voice Frame F @@ -1340,6 +1376,8 @@ public class DMRDecoderState extends TimeslotDecoderState */ private void updateEncryptedCall(EmbeddedEncryptionParameters embeddedEncryptionParameters, boolean isGroup, long timestamp) { + mTalkerAliasTracker.callActivity(timestamp); + if(mCurrentCallEvent != null) { String details = mCurrentCallEvent.getDetails();; @@ -1377,6 +1415,9 @@ public class DMRDecoderState extends TimeslotDecoderState */ private void updateCurrentCall(DecodeEventType type, String details, long timestamp) { + //Keep the talker alias matched to the talking radio before the call event copies the identifiers + mTalkerAliasTracker.callActivity(timestamp); + Event event = (mCurrentCallEvent == null ? Event.START : Event.CONTINUATION); //Create a repeater channel descriptor if we don't have one @@ -1428,10 +1469,12 @@ public class DMRDecoderState extends TimeslotDecoderState { mCurrentCallEvent.end(timestamp); broadcast(mCurrentCallEvent); + mEndedCallEvent = mCurrentCallEvent; mCurrentCallEvent = null; } - getIdentifierCollection().remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM); + //Remembers the talker for a talker alias that completes after the call, and removes the talker alias + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(IdentifierClass.USER, Form.TONE, Role.FROM); } diff --git a/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNDecoderState.java b/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNDecoderState.java index 813aef6b..3fd478df 100644 --- a/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNDecoderState.java +++ b/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNDecoderState.java @@ -24,17 +24,19 @@ import io.github.dsheirer.channel.state.DecoderState; import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.State; import io.github.dsheirer.controller.channel.Channel; -import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.identifier.MutableIdentifierCollection; import io.github.dsheirer.identifier.Role; +import io.github.dsheirer.identifier.alias.TalkerAliasManager; +import io.github.dsheirer.identifier.alias.TalkerAliasTracker; import io.github.dsheirer.identifier.radio.RadioIdentifier; import io.github.dsheirer.message.IMessage; import io.github.dsheirer.module.decode.DecoderType; import io.github.dsheirer.module.decode.event.DecodeEventType; import io.github.dsheirer.module.decode.event.PlottableDecodeEvent; +import io.github.dsheirer.module.decode.nxdn.identifier.NXDNTalkerAliasIdentifier; import io.github.dsheirer.module.decode.nxdn.layer3.NXDNLayer3Message; import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.ControlChannelInformation; import io.github.dsheirer.module.decode.nxdn.layer3.broadcast.SiteInformation; @@ -86,6 +88,9 @@ public class NXDNDecoderState extends DecoderState private final Channel mChannel; private final NXDNNetworkConfigurationMonitor mNetworkConfigurationMonitor = new NXDNNetworkConfigurationMonitor(); private final NXDNTrafficChannelManager mTrafficChannelManager; + //Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio + private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(), + this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER); private boolean mEncryptedCallStateDetermined = false; private boolean mEncryptedCall = false; private int mDisconnectResponseCount = 0; @@ -139,7 +144,9 @@ public class NXDNDecoderState extends DecoderState switch(event.getEvent()) { case REQUEST_RESET: + mTalkerAliasTracker.callEnded(); resetState(); + mTalkerAliasTracker.reset(); break; case NOTIFICATION_SOURCE_FREQUENCY: setCurrentFrequency(event.getFrequency()); @@ -190,6 +197,32 @@ public class NXDNDecoderState extends DecoderState } } + /** + * Talker alias manager shared with the traffic channel manager, or null when there's no traffic channel manager. + */ + private TalkerAliasManager getTalkerAliasManager() + { + return hasTrafficChannelManager() ? mTrafficChannelManager.getTalkerAliasManager() : null; + } + + /** + * Processes a talker alias: stores it for the talking radio and adds it to the call that it belongs to. + * @param talkerAlias that was received + * @param timestamp of the message + */ + private void processTalkerAlias(NXDNTalkerAliasIdentifier talkerAlias, long timestamp) + { + TalkerAliasTracker.Assignment assignment = mTalkerAliasTracker.received(talkerAlias, null, timestamp); + + if(hasTrafficChannelManager() && (assignment == TalkerAliasTracker.Assignment.CURRENT_CALL || + assignment == TalkerAliasTracker.Assignment.ENDED_CALL)) + { + RadioIdentifier radio = assignment == TalkerAliasTracker.Assignment.ENDED_CALL ? + mTalkerAliasTracker.getEndedCallRadio() : mTalkerAliasTracker.getCurrentRadio(); + mTrafficChannelManager.processTalkerAlias(getCurrentChannel(), talkerAlias, radio, timestamp); + } + } + /** * Broadcasts the arguments as a new decode event * @param identifiers involved in the event @@ -265,6 +298,9 @@ public class NXDNDecoderState extends DecoderState { mIdleDuringCallCount = 0; getIdentifierCollection().update(vc.getIdentifiers()); + //Keep the talker alias matched to the radio that is talking + mTalkerAliasTracker.callActivity(vc.getTimestamp()); + mEncryptedCallStateDetermined = true; mEncryptedCall = vc.getEncryptionKeyIdentifier().isEncrypted(); state = mEncryptedCall ? State.ENCRYPTED : State.CALL; @@ -326,6 +362,7 @@ public class NXDNDecoderState extends DecoderState mEncryptedCall = false; mIdleDuringCallCount = IDLE_DURING_CALL_MAX_COUNT; //Only remove the FROM identifier, anticipating a continuation call for the same talkgroup + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(Role.FROM); break; case CONTROL_OUT_09_CC_DATA_CALL_RESPONSE: @@ -404,6 +441,7 @@ public class NXDNDecoderState extends DecoderState mTrafficChannelManager.processEndCall(getCurrentChannel(), layer3.getTimestamp()); mEncryptedCallStateDetermined = false; mEncryptedCall = false; + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(IdentifierClass.USER); mDisconnectResponseCount++; @@ -424,6 +462,7 @@ public class NXDNDecoderState extends DecoderState case CONTROL_OUT_27_BC_ADJACENT_SITE_INFORMATION: case CONTROL_OUT_28_BC_FAILURE_STATUS_INFORMATION: mNetworkConfigurationMonitor.process(layer3); + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(IdentifierClass.USER); break; case CONTROL_OUT_26_BC_CONTROL_CHANNEL_INFORMATION: @@ -645,12 +684,7 @@ public class NXDNDecoderState extends DecoderState { state = State.CALL; event = DecoderStateEvent.Event.CONTINUATION; - var radio = getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM); - - if(radio instanceof RadioIdentifier ri) - { - mTrafficChannelManager.processTalkerAlias(getCurrentChannel(), tac.getTalkerAlias(), ri, tac.getTimestamp()); - } + processTalkerAlias(tac.getTalkerAlias(), tac.getTimestamp()); } break; diff --git a/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNTrafficChannelManager.java b/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNTrafficChannelManager.java index ecd6164a..fdfaa22c 100644 --- a/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNTrafficChannelManager.java +++ b/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNTrafficChannelManager.java @@ -26,10 +26,14 @@ import io.github.dsheirer.controller.channel.ChannelEvent; import io.github.dsheirer.controller.channel.IChannelEventListener; import io.github.dsheirer.controller.channel.IChannelEventProvider; import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest; +import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; +import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.identifier.MutableIdentifierCollection; +import io.github.dsheirer.identifier.Role; import io.github.dsheirer.identifier.alias.TalkerAliasManager; +import io.github.dsheirer.identifier.alias.TalkerAliasTracker; import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier; import io.github.dsheirer.identifier.radio.RadioIdentifier; import io.github.dsheirer.module.decode.event.DecodeEvent; @@ -440,9 +444,11 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements } /** - * Adds or updates the talker alias for the tracked call event. + * Adds or updates the talker alias for the tracked call event, when the event is for the talker, or when the + * event's talker isn't identified yet. * @param channel for the call * @param talkerAlias to add or update + * @param radio that the alias belongs to, or null when the talker isn't identified yet * @param timestamp for the talker alias message */ public void processTalkerAlias(IChannelDescriptor channel, NXDNTalkerAliasIdentifier talkerAlias, @@ -457,11 +463,20 @@ public class NXDNTrafficChannelManager extends TrafficChannelManager implements if(tracker != null) { - tracker.addIdentifierIfMissing(talkerAlias); - broadcast(tracker); + Identifier eventRadio = tracker.getEvent().getIdentifierCollection() + .getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM); + + if(radio == null || eventRadio == null || TalkerAliasTracker.isSameRadio(radio, eventRadio)) + { + tracker.addIdentifierIfMissing(talkerAlias); + broadcast(tracker); + } } - getTalkerAliasManager().update(radio, talkerAlias); + if(radio != null) + { + getTalkerAliasManager().update(radio, talkerAlias); + } } finally { diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManager.java b/src/main/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManager.java index fb1e1b7f..3b5800c3 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManager.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManager.java @@ -19,20 +19,24 @@ package io.github.dsheirer.module.decode.p25; import io.github.dsheirer.channel.IChannelDescriptor; -import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.Channel.ChannelType; -import io.github.dsheirer.controller.channel.ChannelEvent; +import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.ChannelEvent.Event; +import io.github.dsheirer.controller.channel.ChannelEvent; import io.github.dsheirer.controller.channel.IChannelEventListener; import io.github.dsheirer.controller.channel.IChannelEventProvider; import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest; +import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; +import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.identifier.MutableIdentifierCollection; +import io.github.dsheirer.identifier.Role; import io.github.dsheirer.identifier.alias.TalkerAliasManager; import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier; import io.github.dsheirer.identifier.patch.PatchGroupIdentifier; import io.github.dsheirer.identifier.patch.PatchGroupPreLoadDataContent; +import io.github.dsheirer.identifier.radio.RadioIdentifier; import io.github.dsheirer.identifier.scramble.ScrambleParameterIdentifier; import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier; import io.github.dsheirer.log.LoggingSuppressor; @@ -942,6 +946,43 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I } } + /** + * Adds a talker alias that completed after the call ended (e.g. a P25 Phase 1 L3Harris talker alias in the TDULC + * messages) to the call event that just ended on the frequency and timeslot, when that event's FROM radio is the + * talker. The event duration isn't changed. + * + * @param frequency of the traffic channel + * @param timeslot of the traffic channel (TIMESLOT_1 for Phase 1) + * @param radio that the talker alias belongs to + * @param talkerAlias to add + */ + public void processTrafficTalkerAliasAfterCall(long frequency, int timeslot, RadioIdentifier radio, + Identifier talkerAlias) + { + mLock.lock(); + + try + { + P25ChannelEventTracker tracker = getTracker(frequency, timeslot); + + if(tracker != null && tracker.isComplete() && radio != null && talkerAlias != null) + { + Identifier from = tracker.getEvent().getIdentifierCollection() + .getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM); + + if(from instanceof RadioIdentifier eventRadio && eventRadio.getValue().equals(radio.getValue())) + { + tracker.addIdentifierIfMissing(talkerAlias); + broadcast(tracker); + } + } + } + finally + { + mLock.unlock(); + } + } + /** * Updates all current identifiers for an ongoing call event on the frequency and updates the event duration timestamp. * diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderState.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderState.java index 5e67294b..22091d07 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderState.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderState.java @@ -21,11 +21,11 @@ package io.github.dsheirer.module.decode.p25.phase1; import com.google.common.eventbus.Subscribe; import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent; import io.github.dsheirer.channel.state.DecoderState; -import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.DecoderStateEvent.Event; +import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.State; -import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.Channel.ChannelType; +import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.ChannelEvent; import io.github.dsheirer.controller.channel.IChannelEventListener; import io.github.dsheirer.identifier.Form; @@ -33,6 +33,9 @@ import io.github.dsheirer.identifier.Identifier; import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.MutableIdentifierCollection; import io.github.dsheirer.identifier.Role; +import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier; +import io.github.dsheirer.identifier.alias.TalkerAliasManager; +import io.github.dsheirer.identifier.alias.TalkerAliasTracker; import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier; import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier; import io.github.dsheirer.identifier.patch.PatchGroupIdentifier; @@ -199,6 +202,9 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList * algorithm ID takes precedence over the link control service options when it is known. */ private Boolean mCallEncryptedByAlgorithm = null; + //Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio + private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(), + this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER); /** * Constructs an APCO-25 decoder state with an optional traffic channel manager. @@ -327,12 +333,12 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList } else if(iMessage instanceof MotorolaTalkerAliasComplete tac && tac.isValid()) { - mTrafficChannelManager.getTalkerAliasManager().update(tac.getRadio(), tac.getAlias()); - mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), tac.getAlias(), tac.getTimestamp(), tac.toString()); + //Motorola talker aliases identify their radio + processTalkerAlias(tac.getAlias(), tac.getRadio(), tac.getTimestamp(), tac.toString()); } else if(iMessage instanceof LCHarrisTalkerAliasComplete talkerAlias) { - processTalkerAlias(talkerAlias); + processTalkerAlias(talkerAlias.getTalkerAlias(), null, talkerAlias.getTimestamp(), talkerAlias.toString()); } else if(iMessage instanceof LinkControlWord lcw) { @@ -341,19 +347,34 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList } /** - * Process a fully reassembled L3Harris talker alias on the current traffic channel. - * @param talkerAlias reassembled. + * Talker alias manager shared with the traffic channel manager */ - private void processTalkerAlias(LCHarrisTalkerAliasComplete talkerAlias) + private TalkerAliasManager getTalkerAliasManager() { - Identifier identifier = getIdentifierCollection().getFromIdentifier(); + return mTrafficChannelManager.getTalkerAliasManager(); + } - if(identifier instanceof RadioIdentifier radioIdentifier) + /** + * Processes a talker alias: stores it for its radio and adds it to the call that it belongs to. L3Harris talker + * aliases can complete during the call, or after the call ends in the TDULC messages, and belong to the talker of + * that call. + * @param talkerAlias that was received + * @param radio identified by the alias message, or null when the alias belongs to the talker of the current or + * just ended call + * @param timestamp of the message + * @param context of the message + */ + private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, RadioIdentifier radio, long timestamp, + String context) + { + switch(mTalkerAliasTracker.received(talkerAlias, radio, timestamp)) { - mTrafficChannelManager.getTalkerAliasManager().update(radioIdentifier, talkerAlias.getTalkerAlias()); + case CURRENT_CALL -> mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), talkerAlias, + timestamp, context); + case ENDED_CALL -> mTrafficChannelManager.processTrafficTalkerAliasAfterCall(getCurrentFrequency(), + P25P1Message.TIMESLOT_1, mTalkerAliasTracker.getEndedCallRadio(), talkerAlias); + default -> {} //Stored for the radio, for the radio's next call } - - mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), talkerAlias.getTalkerAlias(), talkerAlias.getTimestamp(), talkerAlias.toString()); } /** @@ -414,12 +435,31 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList } } + /** + * Updates the identifier collection with the channel user identifiers from the link control word and keeps the + * talker alias matched to the radio that is talking. + * @param isCallActivity true for a call in progress, or false for a terminator link control after the call + */ + private void updateChannelUserIdentifiers(LinkControlWord lcw, long timestamp, boolean isCallActivity) + { + getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp)); + + if(isCallActivity) + { + mTalkerAliasTracker.callActivity(timestamp); + } + else + { + mTalkerAliasTracker.update(); + } + } + /** * Link Control (LC) Channel user (ie current user on this channel). */ private void processLCChannelUser(LinkControlWord lcw, long timestamp) { - getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp)); + updateChannelUserIdentifiers(lcw, timestamp, true); DecodeEventType decodeEventType = getLCDecodeEventType(lcw); ServiceOptions serviceOptions = null; @@ -443,7 +483,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList serviceOptions = VoiceServiceOptions.createUnencrypted(); } - mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection()); MutableIdentifierCollection mic = getMutableIdentifierCollection(getIdentifierCollection().getIdentifiers(), timestamp); mTrafficChannelManager.processP1TrafficCurrentUser(getCurrentFrequency(), getCurrentChannel(), decodeEventType, serviceOptions, mic, timestamp, null, lcw.toString()); @@ -853,6 +892,8 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList Identifier radio = getIdentifierCollection().getFromIdentifier(); mCallEncryptedByAlgorithm = headerData.isEncryptedAudio(); + //A call starts. The HDU doesn't identify the talker. + mTalkerAliasTracker.callActivity(message.getTimestamp()); mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio, headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp()); @@ -921,9 +962,22 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList { mCallEncryptedByAlgorithm = null; mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message); + clearCallUser(); broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE)); } + /** + * Removes the FROM radio and talker alias at the end of a call, so that the next call doesn't start (e.g. with an + * HDU, which doesn't identify the talker) with the previous talker. The next call's link control adds the radio + * that is talking and its stored talker alias, the same as the other protocols. + */ + private void clearCallUser() + { + //Remembers the talker for a talker alias that completes after the call ends + mTalkerAliasTracker.callEnded(); + getIdentifierCollection().remove(IdentifierClass.USER, Role.FROM); + } + /** * Process Terminator Data Unit with Link Control (TDULC) message and forwards valid Link Control Word message for * additional processing. @@ -942,6 +996,8 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message); broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE)); processLC(lcw, message.getTimestamp(), true); + //After the terminator link control, which would otherwise add the radio and alias back + clearCallUser(); } } } @@ -1878,7 +1934,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList case UNIT_TO_UNIT_VOICE_CHANNEL_USER_EXTENDED: if(isTerminator) { - getIdentifierCollection().update(mPatchGroupManager.update(lcw.getIdentifiers(), timestamp)); + updateChannelUserIdentifiers(lcw, timestamp, false); } else { @@ -2159,6 +2215,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList { case REQUEST_RESET: resetState(); + mTalkerAliasTracker.reset(); mNetworkConfigurationMonitor.reset(); break; case NOTIFICATION_SOURCE_FREQUENCY: diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1MessageProcessor.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1MessageProcessor.java index 9b4851d2..3692087b 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1MessageProcessor.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/P25P1MessageProcessor.java @@ -29,6 +29,7 @@ import io.github.dsheirer.module.decode.p25.phase1.message.lc.IExtendedSourceMes import io.github.dsheirer.module.decode.p25.phase1.message.lc.LinkControlWord; import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.HarrisTalkerAliasAssembler; import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasBase; +import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerAliasComplete; import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSBlock1; import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSBlock2; import io.github.dsheirer.module.decode.p25.phase1.message.lc.l3harris.LCHarrisTalkerGPSComplete; @@ -183,6 +184,8 @@ public class P25P1MessageProcessor implements Listener else if(message instanceof TDULCMessage tdulc) { boolean motorolaTalkerAliasComplete = false; + LCHarrisTalkerAliasComplete harrisTalkerAliasComplete = null; + boolean harrisTalkerAliasBlock = false; LinkControlWord lcw = tdulc.getLinkControlWord(); if(lcw instanceof IExtendedSourceMessage esm) @@ -199,22 +202,39 @@ public class P25P1MessageProcessor implements Listener } } + //L3Harris can carry the talker alias in the TDULC messages after the call ends + else if(lcw != null && lcw.isValid() && lcw instanceof LCHarrisTalkerAliasBase harrisTalkerAlias) + { + harrisTalkerAliasBlock = true; + harrisTalkerAliasComplete = mHarrisTalkerAliasAssembler.process(harrisTalkerAlias, message.getTimestamp()); + } + //Motorola carries the talker alias in the TDULC else if(mMotorolaTalkerAliasAssembler.add(lcw, message.getTimestamp())) { motorolaTalkerAliasComplete = true; } - //Harris carries the talker alias in the LDU1 link control messages, so reset the assembler on TDULC. - mHarrisTalkerAliasAssembler.reset(); + //Reset the L3Harris assembler on other TDULC messages so that alias blocks from different calls + //aren't combined. Keep the blocks while the TDULC messages carry the alias. + if(!harrisTalkerAliasBlock) + { + mHarrisTalkerAliasAssembler.reset(); + } dispatch(tdulc); - // Dispatch the complete talker alias after the TDULC to ensure correct log sequencing. + // Dispatch the complete talker alias after the TDULC to ensure correct log sequencing. The decoder + // state assigns it to the talker of the call that just ended. if(motorolaTalkerAliasComplete) { dispatch(mMotorolaTalkerAliasAssembler.assemble()); } + + if(harrisTalkerAliasComplete != null) + { + dispatch(harrisTalkerAliasComplete); + } } else if(message instanceof HDUMessage hdu && mHeldTDULCMessage != null) { diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/HarrisTalkerAliasAssembler.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/HarrisTalkerAliasAssembler.java index aa96f70b..318f353a 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/HarrisTalkerAliasAssembler.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/HarrisTalkerAliasAssembler.java @@ -103,9 +103,10 @@ public class HarrisTalkerAliasAssembler { if(mBlock1 != null && mBlock2 != null) { - String fragment2 = mBlock2.getPayloadFragmentString(); - String fragment3 = mBlock3 != null ? mBlock3.getPayloadFragmentString() : null; - String fragment4 = mBlock4 != null ? mBlock4.getPayloadFragmentString() : null; + //Untrimmed, so that a space at the start or end of a fragment is kept within the alias + String fragment2 = mBlock2.getPayloadFragmentText(); + String fragment3 = mBlock3 != null ? mBlock3.getPayloadFragmentText() : null; + String fragment4 = mBlock4 != null ? mBlock4.getPayloadFragmentText() : null; return new LCHarrisTalkerAliasComplete(mBlock1.getMessage(), fragment2, fragment3, fragment4, mTimestamp); } diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasBase.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasBase.java index e70eb155..f2688730 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasBase.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasBase.java @@ -61,6 +61,16 @@ public abstract class LCHarrisTalkerAliasBase extends LinkControlWord return new String(getPayloadFragment().toByteArray()).trim(); } + /** + * Payload fragment as a string, including leading and trailing spaces, for joining the fragments of an alias. + * A space at the start or end of a fragment can be a space within the alias. + * @return fragment + */ + public String getPayloadFragmentText() + { + return new String(getPayloadFragment().toByteArray()); + } + /** * List of identifiers contained in this message */ diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasComplete.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasComplete.java index 89fa21ae..5199c53f 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasComplete.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/message/lc/l3harris/LCHarrisTalkerAliasComplete.java @@ -93,7 +93,8 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes { if(mTalkerAlias == null) { - String alias = getPayloadFragmentString(); + //Join the untrimmed fragments and trim the result, so that a space at a fragment boundary is kept + String alias = getPayloadFragmentText(); if(mFragment2 != null && !alias.contains(mFragment2)) { @@ -110,7 +111,7 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes } } - mTalkerAlias = P25TalkerAliasIdentifier.create(alias); + mTalkerAlias = P25TalkerAliasIdentifier.create(alias.trim()); } return mTalkerAlias; @@ -134,6 +135,16 @@ public class LCHarrisTalkerAliasComplete extends LinkControlWord implements IMes return new String(getPayloadFragment().toByteArray()).trim(); } + /** + * Payload fragment as a string, including leading and trailing spaces, for joining the fragments of an alias. + * A space at the start or end of a fragment can be a space within the alias. + * @return fragment + */ + public String getPayloadFragmentText() + { + return new String(getPayloadFragment().toByteArray()); + } + @Override public long getTimestamp() { diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase2/P25P2DecoderState.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase2/P25P2DecoderState.java index 04e0fc53..96405d1b 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase2/P25P2DecoderState.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase2/P25P2DecoderState.java @@ -22,19 +22,21 @@ import com.google.common.eventbus.Subscribe; import io.github.dsheirer.channel.IBandChannelDescriptor; import io.github.dsheirer.channel.IChannelDescriptor; import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent; -import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.DecoderStateEvent.Event; +import io.github.dsheirer.channel.state.DecoderStateEvent; import io.github.dsheirer.channel.state.State; import io.github.dsheirer.channel.state.TimeslotDecoderState; -import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.Channel.ChannelType; +import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.IdentifierUpdateListener; import io.github.dsheirer.identifier.MutableIdentifierCollection; import io.github.dsheirer.identifier.Role; -import io.github.dsheirer.identifier.alias.P25TalkerAliasIdentifier; +import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier; +import io.github.dsheirer.identifier.alias.TalkerAliasManager; +import io.github.dsheirer.identifier.alias.TalkerAliasTracker; import io.github.dsheirer.identifier.encryption.EncryptionKey; import io.github.dsheirer.identifier.patch.PatchGroupIdentifier; import io.github.dsheirer.identifier.patch.PatchGroupManager; @@ -147,6 +149,9 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie private PatchGroupManager mPatchGroupManager; private P25P2NetworkConfigurationMonitor mNetworkConfigurationMonitor = new P25P2NetworkConfigurationMonitor(); private P25TrafficChannelManager mTrafficChannelManager; + //Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio + private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(), + this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.NEXT_TALKER); private int mEndPttOnFacchCounter = 0; /** @@ -190,6 +195,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie */ protected void resetState() { + mTalkerAliasTracker.callEnded(); super.resetState(); mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), System.currentTimeMillis(), "RESET STATE INVOKED"); mEndPttOnFacchCounter = 0; @@ -239,6 +245,8 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie broadcast(new DecoderStateEvent(this, Event.CONTINUATION, State.CALL, getTimeslot())); } + mTalkerAliasTracker.callActivity(message.getTimestamp()); + //If we're tracking the call event, update the duration on it mTrafficChannelManager.processP2TrafficVoice(getCurrentFrequency(), getTimeslot(), message.getTimestamp()); } @@ -265,7 +273,8 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie } else if(message instanceof MotorolaTalkerAliasComplete tac && tac.isValid()) { - mTrafficChannelManager.getTalkerAliasManager().update(tac.getRadio(), tac.getAlias()); + //Motorola talker aliases identify their radio + processTalkerAlias(tac.getAlias(), tac.getRadio(), tac.getTimestamp()); } } } @@ -285,6 +294,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie case MAC_6_HANGTIME: //During hangtime, the from talker is no longer involved in the call ... remove it because sometimes //we don't get the end PTT to signal the end. + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(Role.FROM); break; } @@ -763,7 +773,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp())); } - mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection()); + mTalkerAliasTracker.update(); continueState(State.ACTIVE); } @@ -1103,14 +1113,15 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp())); } - mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection()); - if(message.getMacPduType() == MacPduType.MAC_3_IDLE || message.getMacPduType() == MacPduType.MAC_6_HANGTIME) { + mTalkerAliasTracker.update(); continueState(State.ACTIVE); } else { + mTalkerAliasTracker.callActivity(message.getTimestamp()); + if(mac instanceof IServiceOptionsProvider sop) { IChannelDescriptor currentChannel = mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(), @@ -1153,7 +1164,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie getIdentifierCollection().update(mPatchGroupManager.update(identifier, message.getTimestamp())); } - mTrafficChannelManager.getTalkerAliasManager().enrichMutable(getIdentifierCollection()); + mTalkerAliasTracker.callActivity(message.getTimestamp()); if(mac instanceof PushToTalk ptt) { @@ -1225,6 +1236,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie if(mac instanceof EndPushToTalk) { //No matter what, remove the FROM identifier on end PTT. + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(Role.FROM); //Only reset the identifiers if the call event is closed out, otherwise we might have a timing issue @@ -1519,6 +1531,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie if(mac instanceof MacRelease mr) { mTrafficChannelManager.processP2TrafficCallEnd(getCurrentFrequency(), getTimeslot(), message.getTimestamp(), "MAC RELEASE: " + mac.toString()); + mTalkerAliasTracker.callEnded(); getIdentifierCollection().remove(IdentifierClass.USER); broadcast(message, mac, DecodeEventType.COMMAND, (mr.isForcedPreemption() ? "FORCED " : "") + "CALL PREEMPTION" + @@ -1720,17 +1733,34 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie { if(mac instanceof L3HarrisTalkerAlias talkerAlias) { - P25TalkerAliasIdentifier alias = talkerAlias.getAlias(); - getIdentifierCollection().update(alias); - mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(), getTimeslot(), alias, message.getTimestamp()); + processTalkerAlias(talkerAlias.getAlias(), null, message.getTimestamp()); + } + } - //Add the alias to the talker alias manager if we know the associated radio - Identifier from = getIdentifierCollection().getFromIdentifier(); + /** + * Talker alias manager shared with the traffic channel manager + */ + private TalkerAliasManager getTalkerAliasManager() + { + return mTrafficChannelManager.getTalkerAliasManager(); + } - if(from instanceof RadioIdentifier ri) - { - mTrafficChannelManager.getTalkerAliasManager().update(ri, alias); - } + /** + * Processes a talker alias: stores it for its radio and adds it to the call that it belongs to. + * @param talkerAlias that was received + * @param radio identified by the alias message, or null when the alias belongs to the talker of the current or + * just ended call + * @param timestamp of the message + */ + private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, RadioIdentifier radio, long timestamp) + { + switch(mTalkerAliasTracker.received(talkerAlias, radio, timestamp)) + { + case CURRENT_CALL -> mTrafficChannelManager.processP2TrafficCurrentUser(getCurrentFrequency(), getTimeslot(), + talkerAlias, timestamp); + case ENDED_CALL -> mTrafficChannelManager.processTrafficTalkerAliasAfterCall(getCurrentFrequency(), + getTimeslot(), mTalkerAliasTracker.getEndedCallRadio(), talkerAlias); + default -> {} //Stored for the radio, for the radio's next call } } @@ -1855,6 +1885,7 @@ public class P25P2DecoderState extends TimeslotDecoderState implements Identifie { case REQUEST_RESET: resetState(); + mTalkerAliasTracker.reset(); mNetworkConfigurationMonitor.reset(); break; case NOTIFICATION_SOURCE_FREQUENCY: diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAlias.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAlias.java index 60521ac5..553b17b1 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAlias.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAlias.java @@ -75,19 +75,22 @@ public class L3HarrisTalkerAlias extends MacStructureVendor { if(mAliasIdentifier == null) { - int length = getLength() - 2; + //The alias fills the rest of the structure after the opcode, vendor and length octets + int start = ALIAS_START + getOffset(); + int end = Math.min(getOffset() + getLength() * 8, getMessage().size()); - if(length > 0) + if(end > start) { - length *= 8; - length += getOffset(); + byte[] bytes = getMessage().getSubMessage(start, end).getBytes(); + int length = 0; - if(length > getMessage().size()) + //The alias is padded with zero bytes + while(length < bytes.length && bytes[length] != 0) { - length = getMessage().size(); + length++; } - String alias = new String(getMessage().getSubMessage(ALIAS_START + getOffset(), length).getBytes()).trim(); + String alias = new String(bytes, 0, length).trim(); mAliasIdentifier = P25TalkerAliasIdentifier.create(alias); } } diff --git a/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java b/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java index 17af2316..ef305cdc 100644 --- a/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java +++ b/src/test/java/io/github/dsheirer/gui/table/TableColumnMonitorTest.java @@ -195,6 +195,23 @@ public class TableColumnMonitorTest }); } + @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 table = createTable("status", "from", "talker", "from.alias", "to"); + TableColumnMonitor 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 { diff --git a/src/test/java/io/github/dsheirer/identifier/alias/TalkerAliasTrackerTest.java b/src/test/java/io/github/dsheirer/identifier/alias/TalkerAliasTrackerTest.java new file mode 100644 index 00000000..03d5e967 --- /dev/null +++ b/src/test/java/io/github/dsheirer/identifier/alias/TalkerAliasTrackerTest.java @@ -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 + * **************************************************************************** + */ +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)); + } +} diff --git a/src/test/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderStateTalkerAliasTest.java b/src/test/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderStateTalkerAliasTest.java new file mode 100644 index 00000000..d926466f --- /dev/null +++ b/src/test/java/io/github/dsheirer/module/decode/p25/phase1/P25P1DecoderStateTalkerAliasTest.java @@ -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 + * **************************************************************************** + */ +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 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()); + } +} diff --git a/src/test/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAliasTest.java b/src/test/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAliasTest.java new file mode 100644 index 00000000..dadc983e --- /dev/null +++ b/src/test/java/io/github/dsheirer/module/decode/p25/phase2/message/mac/structure/l3harris/L3HarrisTalkerAliasTest.java @@ -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 + * **************************************************************************** + */ +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()); + } +}