diff --git a/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P1ConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P1ConfigurationEditor.java index af6daf4e..f54cb156 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P1ConfigurationEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P1ConfigurationEditor.java @@ -73,6 +73,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor private EventLogConfigurationEditor mEventLogConfigurationEditor; private RecordConfigurationEditor mRecordConfigurationEditor; private ToggleSwitch mIgnoreDataCallsButton; + private ToggleSwitch mIgnoreEncryptedCallsButton; private Spinner mTrafficChannelPoolSizeSpinner; private SegmentedButton mModulationSegmentedButton; private ToggleButton mC4FMToggleButton; @@ -157,6 +158,15 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor GridPane.setConstraints(directionLabel, 5, row); gridPane.getChildren().add(directionLabel); + GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 6, row); + gridPane.getChildren().add(getIgnoreEncryptedCallsButton()); + + Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls"); + ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton()); + GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT); + GridPane.setConstraints(ignoreEncryptedLabel, 7, row); + gridPane.getChildren().add(ignoreEncryptedLabel); + row++; Label modulationHelpLabel = new Label("C4FM: repeaters and non-simulcast trunked systems. LSM: simulcast trunked systems."); @@ -343,6 +353,21 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor return mLSMToggleButton; } + private ToggleSwitch getIgnoreEncryptedCallsButton() + { + if(mIgnoreEncryptedCallsButton == null) + { + mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch(); + mIgnoreEncryptedCallsButton.setDisable(true); + mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " + + "Phase 2: the channel is still tuned when the other timeslot has a clear call.")); + mIgnoreEncryptedCallsButton.selectedProperty() + .addListener((observable, oldValue, newValue) -> modifiedProperty().set(true)); + } + + return mIgnoreEncryptedCallsButton; + } + private ToggleSwitch getIgnoreDataCallsButton() { if(mIgnoreDataCallsButton == null) @@ -398,11 +423,13 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor protected void setDecoderConfiguration(DecodeConfiguration config) { getIgnoreDataCallsButton().setDisable(config == null); + getIgnoreEncryptedCallsButton().setDisable(config == null); getTrafficChannelPoolSizeSpinner().setDisable(config == null); if(config instanceof DecodeConfigP25Phase1 decodeConfig) { getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls()); + getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls()); getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize()); if(decodeConfig.getModulation() == Modulation.C4FM) { @@ -427,6 +454,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor else { getIgnoreDataCallsButton().setSelected(false); + getIgnoreEncryptedCallsButton().setSelected(false); getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(0); getJmbeAGCButton().setDisable(true); getJmbeNoiseGainSlider().setDisable(true); @@ -454,6 +482,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor } config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected()); + config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected()); config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue()); config.setModulation(getC4FMToggleButton().isSelected() ? Modulation.C4FM : Modulation.CQPSK); config.setAGC(getJmbeAGCButton().selectedProperty().getValue()); diff --git a/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P2ConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P2ConfigurationEditor.java index 916e1302..09417c9e 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P2ConfigurationEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/channel/P25P2ConfigurationEditor.java @@ -71,6 +71,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor private IntegerTextField mSystemTextField; private IntegerTextField mNacTextField; private ToggleSwitch mIgnoreDataCallsButton; + private ToggleSwitch mIgnoreEncryptedCallsButton; private Spinner mTrafficChannelPoolSizeSpinner; private ToggleSwitch mJmbeAGCButton; private Slider mJmbeToneGainSlider; @@ -142,6 +143,15 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor GridPane.setConstraints(directionLabel, 3, row); gridPane.getChildren().add(directionLabel); + GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 4, row); + gridPane.getChildren().add(getIgnoreEncryptedCallsButton()); + + Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls"); + ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton()); + GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT); + GridPane.setConstraints(ignoreEncryptedLabel, 5, row); + gridPane.getChildren().add(ignoreEncryptedLabel); + Label wacnLabel = new Label("WACN"); wacnLabel.setLabelFor(getWacnTextField()); GridPane.setHalignment(wacnLabel, HPos.RIGHT); @@ -300,6 +310,21 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor return mEventLogConfigurationEditor; } + private ToggleSwitch getIgnoreEncryptedCallsButton() + { + if(mIgnoreEncryptedCallsButton == null) + { + mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch(); + mIgnoreEncryptedCallsButton.setDisable(true); + mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " + + "Phase 2: the channel is still tuned when the other timeslot has a clear call.")); + mIgnoreEncryptedCallsButton.selectedProperty() + .addListener((observable, oldValue, newValue) -> modifiedProperty().set(true)); + } + + return mIgnoreEncryptedCallsButton; + } + private ToggleSwitch getIgnoreDataCallsButton() { if(mIgnoreDataCallsButton == null) @@ -417,6 +442,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor getIgnoreDataCallsButton().setDisable(false); getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls()); + getIgnoreEncryptedCallsButton().setDisable(false); + getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls()); getTrafficChannelPoolSizeSpinner().setDisable(false); getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize()); getJmbeAGCButton().setSelected(decodeConfig.isAGC()); @@ -433,6 +460,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor getSystemTextField().setDisable(true); getNacTextField().setDisable(true); getIgnoreDataCallsButton().setDisable(true); + getIgnoreEncryptedCallsButton().setDisable(true); + getIgnoreEncryptedCallsButton().setSelected(false); getTrafficChannelPoolSizeSpinner().setDisable(true); getJmbeAGCButton().setDisable(true); getJmbeNoiseGainSlider().setDisable(true); @@ -465,6 +494,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor int nac = getNacTextField().get(); config.setScrambleParameters(new ScrambleParameters(wacn, system, nac)); config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected()); + config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected()); config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue()); config.setAGC(getJmbeAGCButton().selectedProperty().getValue()); config.setComfortNoiseGain((float)getJmbeNoiseGainSlider().getValue()); diff --git a/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java b/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java index 77cbb70e..cd92d35a 100644 --- a/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java +++ b/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java @@ -922,6 +922,7 @@ public class DecoderFactory DecodeConfigP25Phase1 originalP25P1 = (DecodeConfigP25Phase1)config; DecodeConfigP25Phase1 copyP25P1 = new DecodeConfigP25Phase1(); copyP25P1.setIgnoreDataCalls(originalP25P1.getIgnoreDataCalls()); + copyP25P1.setIgnoreEncryptedCalls(originalP25P1.getIgnoreEncryptedCalls()); copyP25P1.setModulation(originalP25P1.getModulation()); copyP25P1.setTrafficChannelPoolSize(originalP25P1.getTrafficChannelPoolSize()); transferJMBESettings(originalP25P1, copyP25P1); @@ -929,6 +930,9 @@ public class DecoderFactory case P25_PHASE2: DecodeConfigP25Phase2 originalP25P2 = (DecodeConfigP25Phase2)config; DecodeConfigP25Phase2 copyP25P2 = new DecodeConfigP25Phase2(); + copyP25P2.setIgnoreDataCalls(originalP25P2.getIgnoreDataCalls()); + copyP25P2.setIgnoreEncryptedCalls(originalP25P2.getIgnoreEncryptedCalls()); + copyP25P2.setTrafficChannelPoolSize(originalP25P2.getTrafficChannelPoolSize()); transferJMBESettings(originalP25P2, copyP25P2); if(originalP25P2.getScrambleParameters() != null) diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/P25ChannelEventTracker.java b/src/main/java/io/github/dsheirer/module/decode/p25/P25ChannelEventTracker.java index c667cb3b..9bca890f 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/P25ChannelEventTracker.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/P25ChannelEventTracker.java @@ -28,6 +28,8 @@ import io.github.dsheirer.module.decode.event.ChannelEventTracker; */ public class P25ChannelEventTracker extends ChannelEventTracker { + private boolean mEncrypted; + /** * Constructs an instance * @@ -37,4 +39,24 @@ public class P25ChannelEventTracker extends ChannelEventTracker mMessageListener; private boolean mIgnoreDataCalls; + private boolean mIgnoreEncryptedCalls; //Used only for data calls private final DecodeEventDuplicateDetector mDuplicateDetector = new DecodeEventDuplicateDetector(); private final TalkerAliasManager mTalkerAliasManager = new TalkerAliasManager(); @@ -139,6 +140,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I if(parentChannel.getDecodeConfiguration() instanceof DecodeConfigP25Phase1 phase1) { mIgnoreDataCalls = phase1.getIgnoreDataCalls(); + mIgnoreEncryptedCalls = phase1.getIgnoreEncryptedCalls(); createPhase1TrafficChannels(phase1.getTrafficChannelPoolSize(), phase1); DecodeConfigP25Phase2 phase2 = new DecodeConfigP25Phase2(); DecoderFactory.transferJMBESettings(phase1, phase2); @@ -147,6 +149,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I else if(parentChannel.getDecodeConfiguration() instanceof DecodeConfigP25Phase2 phase2) { mIgnoreDataCalls = phase2.getIgnoreDataCalls(); + mIgnoreEncryptedCalls = phase2.getIgnoreEncryptedCalls(); DecodeConfigP25Phase1 phase1 = new DecodeConfigP25Phase1(); DecoderFactory.transferJMBESettings(phase2, phase1); createPhase1TrafficChannels(phase2.getTrafficChannelPoolSize(), phase1); @@ -619,7 +622,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I //update the ending timestamp so that the duration value is correctly calculated trackerTS2.updateDurationTraffic(timestamp); - broadcast(trackerTS1); + broadcast(trackerTS2); } finally { @@ -1149,6 +1152,30 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I return completed; } + /** + * Indicates if the service options identify the call as encrypted. + * @param serviceOptions to check, optional null + * @return true if encrypted + */ + private static boolean isEncrypted(ServiceOptions serviceOptions) + { + return serviceOptions != null && serviceOptions.isEncrypted(); + } + + /** + * Updates the tracker's encrypted flag from the current service options. When the talker changed (ie a new + * tracker was created from a channel grant that carries service options) the tracker takes the current value. + * Otherwise, the encrypted flag is only ever set (never cleared) since many update messages carry empty service + * options, which would otherwise make a known-encrypted call look unencrypted. + */ + private static void updateEncrypted(P25ChannelEventTracker tracker, ServiceOptions serviceOptions) + { + if(isEncrypted(serviceOptions)) + { + tracker.setEncrypted(true); + } + } + /** * Sends a channel start request to the ChannelProcessingManager. * @@ -1238,6 +1265,8 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I addTracker(tracker, frequency, P25P1Message.TIMESLOT_1); } + updateEncrypted(tracker, serviceOptions); + //The tracked event can have an empty FROM identifier at start of call ... update here tracker.addIdentifierIfMissing(from); @@ -1248,7 +1277,8 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I //Even though we have a tracked event, the initial channel grant may have been rejected. Check to see if there //is a traffic channel allocated. If not, allocate one and update the event description. - if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant)) + if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant) && + !(mIgnoreEncryptedCalls && tracker.isEncrypted())) { Channel trafficChannel = mAvailablePhase1TrafficChannelQueue.poll(); @@ -1293,6 +1323,22 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I return; } + //Don't allocate a traffic channel for an encrypted call, unless a traffic channel is already allocated for the + //frequency in which case the call will be processed by the running traffic channel. + if(mIgnoreEncryptedCalls && isEncrypted(serviceOptions) && !mAllocatedTrafficChannelMap.containsKey(frequency)) + { + P25ChannelGrantEvent event = P25ChannelGrantEvent.builder(decodeEventType, timestamp, serviceOptions) + .channelDescriptor(apco25Channel) + .details("IGNORED: ENCRYPTED - PHASE 1 CHANNEL GRANT " + serviceOptions) + .identifiers(ic) + .build(); + tracker = new P25ChannelEventTracker(event); + tracker.setEncrypted(true); + addTracker(tracker, frequency, P25P1Message.TIMESLOT_1); + broadcast(tracker); + return; + } + String details = isDataChannelGrant ? "PHASE 1 DATA CHANNEL GRANT " : "PHASE 1 CHANNEL GRANT " + (serviceOptions != null ? serviceOptions : ""); @@ -1302,6 +1348,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I .identifiers(ic) .build(); tracker = new P25ChannelEventTracker(event); + updateEncrypted(tracker, serviceOptions); addTracker(tracker, frequency, P25P1Message.TIMESLOT_1); //Allocate a traffic channel for the downlink frequency if one isn't already allocated @@ -1367,14 +1414,19 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I broadcast(tracker); } + updateEncrypted(tracker, serviceOptions); + //update the ending timestamp so that the duration value is correctly calculated tracker.updateDurationControl(timestamp); broadcast(tracker); //Even though we have a tracked event, the initial channel grant may have been rejected. Check to see if there //is a traffic channel allocated. If not, allocate one and update the event description. + //Note: a phase 2 traffic channel carries two timeslots. An encrypted call on this timeslot doesn't cause + //the channel to be allocated, but a clear call on the other timeslot will allocate the channel through + //its own tracker and then both timeslots are processed by the traffic channel. if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant) && - (getCurrentControlFrequency() != frequency)) + !(mIgnoreEncryptedCalls && tracker.isEncrypted()) && (getCurrentControlFrequency() != frequency)) { Channel trafficChannel = mAvailablePhase2TrafficChannelQueue.poll(); @@ -1404,7 +1456,25 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I .build(); tracker = new P25ChannelEventTracker(event); - addTracker(tracker, frequency, P25P1Message.TIMESLOT_1); + addTracker(tracker, frequency, timeslot); + broadcast(tracker); + return; + } + + //Don't allocate a traffic channel for an encrypted call. If the frequency is already allocated (ie there's a + //call on the other timeslot), the running traffic channel will process this timeslot as well. + if(mIgnoreEncryptedCalls && isEncrypted(serviceOptions) && !mAllocatedTrafficChannelMap.containsKey(frequency)) + { + P25ChannelGrantEvent event = P25ChannelGrantEvent.builder(decodeEventType, timestamp, serviceOptions) + .channelDescriptor(apco25Channel) + .details("IGNORED: ENCRYPTED - PHASE 2 CHANNEL GRANT " + serviceOptions) + .identifiers(ic) + .timeslot(timeslot) + .build(); + + tracker = new P25ChannelEventTracker(event); + tracker.setEncrypted(true); + addTracker(tracker, frequency, timeslot); broadcast(tracker); return; } @@ -1417,6 +1487,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I .build(); tracker = new P25ChannelEventTracker(event); + updateEncrypted(tracker, serviceOptions); addTracker(tracker, frequency, timeslot); //Allocate a traffic channel for the downlink frequency if one isn't already allocated diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/DecodeConfigP25.java b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/DecodeConfigP25.java index 30682153..f7b47970 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/phase1/DecodeConfigP25.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/phase1/DecodeConfigP25.java @@ -34,6 +34,7 @@ public abstract class DecodeConfigP25 extends DecodeConfigJMBE { private int mTrafficChannelPoolSize = TRAFFIC_CHANNEL_LIMIT_DEFAULT; private boolean mIgnoreDataCalls = false; + private boolean mIgnoreEncryptedCalls = false; public DecodeConfigP25() { @@ -50,6 +51,25 @@ public abstract class DecodeConfigP25 extends DecodeConfigJMBE mIgnoreDataCalls = ignore; } + /** + * Indicates if the traffic channel manager should not allocate (ie tune) a traffic channel for a channel grant + * where the service options indicate the call is encrypted. + */ + @JacksonXmlProperty(isAttribute = true, localName = "ignore_encrypted_calls") + public boolean getIgnoreEncryptedCalls() + { + return mIgnoreEncryptedCalls; + } + + /** + * Sets the ignore encrypted calls option. + * @param ignore true to not allocate traffic channels for encrypted calls. + */ + public void setIgnoreEncryptedCalls(boolean ignore) + { + mIgnoreEncryptedCalls = ignore; + } + @JacksonXmlProperty(isAttribute = true, localName = "traffic_channel_pool_size") public int getTrafficChannelPoolSize() diff --git a/src/test/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManagerEncryptedTest.java b/src/test/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManagerEncryptedTest.java new file mode 100644 index 00000000..a57c41df --- /dev/null +++ b/src/test/java/io/github/dsheirer/module/decode/p25/P25TrafficChannelManagerEncryptedTest.java @@ -0,0 +1,314 @@ +/* + * ***************************************************************************** + * 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; + +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest; +import io.github.dsheirer.identifier.MutableIdentifierCollection; +import io.github.dsheirer.module.decode.event.IDecodeEvent; +import io.github.dsheirer.module.decode.p25.identifier.channel.APCO25Channel; +import io.github.dsheirer.module.decode.p25.identifier.channel.P25Channel; +import io.github.dsheirer.module.decode.p25.identifier.radio.APCO25RadioIdentifier; +import io.github.dsheirer.module.decode.p25.identifier.talkgroup.APCO25Talkgroup; +import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25; +import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1; +import io.github.dsheirer.module.decode.p25.phase1.message.IFrequencyBand; +import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.Opcode; +import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2; +import io.github.dsheirer.module.decode.p25.phase2.message.mac.MacOpcode; +import io.github.dsheirer.module.decode.DecoderFactory; +import io.github.dsheirer.module.decode.p25.reference.ServiceOptions; +import io.github.dsheirer.module.decode.p25.reference.VoiceServiceOptions; +import io.github.dsheirer.source.config.SourceConfigTuner; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +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.assertTrue; + +/** + * Verifies the ignore encrypted calls option of the P25 traffic channel manager. + */ +public class P25TrafficChannelManagerEncryptedTest +{ + private static final long BASE = 851_000_000L; + private static final long SPACING = 12_500L; + private static final int TALKGROUP_A = 100; + private static final int TALKGROUP_B = 200; + + /** + * Captures traffic channel start requests and decode events. + */ + private static class Harness + { + private final P25TrafficChannelManager mManager; + private final List mStartedFrequencies = new ArrayList<>(); + private final List mEvents = new ArrayList<>(); + + Harness(DecodeConfigP25 config) + { + Channel parent = new Channel("control"); + parent.setDecodeConfiguration(config); + mManager = new P25TrafficChannelManager(parent); + EventBus eventBus = new EventBus(); + eventBus.register(this); + mManager.setInterModuleEventBus(eventBus); + mManager.addDecodeEventListener(mEvents::add); + } + + @Subscribe + public void start(ChannelStartProcessingRequest request) + { + mStartedFrequencies.add(request.getChannel().getSourceConfiguration() instanceof + SourceConfigTuner sct ? sct.getFrequency() : -1L); + } + + String lastDetails() + { + return mEvents.get(mEvents.size() - 1).getDetails(); + } + } + + private static DecodeConfigP25Phase1 phase1(boolean ignoreEncrypted) + { + DecodeConfigP25Phase1 config = new DecodeConfigP25Phase1(); + config.setIgnoreEncryptedCalls(ignoreEncrypted); + return config; + } + + private static DecodeConfigP25Phase2 phase2(boolean ignoreEncrypted) + { + DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2(); + config.setIgnoreEncryptedCalls(ignoreEncrypted); + return config; + } + + /** + * Frequency band where the downlink frequency for channel number N is BASE + (N / timeslots) * SPACING + */ + private static IFrequencyBand band(int timeslots) + { + return new IFrequencyBand() + { + @Override public boolean isValid() {return true;} + @Override public int getIdentifier() {return timeslots == 1 ? 1 : 2;} + @Override public long getChannelSpacing() {return SPACING;} + @Override public long getBaseFrequency() {return BASE;} + @Override public int getBandwidth() {return 12500;} + @Override public long getTransmitOffset() {return 0;} + @Override public long getDownlinkFrequency(int channelNumber) {return BASE + (channelNumber / timeslots) * SPACING;} + @Override public long getUplinkFrequency(int channelNumber) {return getDownlinkFrequency(channelNumber);} + @Override public boolean isTDMA() {return timeslots > 1;} + @Override public int getTimeslotCount() {return timeslots;} + }; + } + + private static APCO25Channel fdma(int channelNumber) + { + P25Channel channel = new P25Channel(1, channelNumber); + channel.setFrequencyBand(band(1)); + return new APCO25Channel(channel); + } + + /** + * @param logicalChannel number (ie frequency index) + * @param timeslot 1 or 2 + */ + private static APCO25Channel tdma(int logicalChannel, int timeslot) + { + P25Channel channel = new P25Channel(2, logicalChannel * 2 + (timeslot - 1)); + channel.setFrequencyBand(band(2)); + return new APCO25Channel(channel); + } + + private static MutableIdentifierCollection ic(int talkgroup, Integer radio) + { + MutableIdentifierCollection mic = new MutableIdentifierCollection(); + mic.update(APCO25Talkgroup.create(talkgroup)); + if(radio != null) + { + mic.update(APCO25RadioIdentifier.createFrom(radio)); + } + return mic; + } + + private static void p1Grant(Harness h, APCO25Channel channel, ServiceOptions so, int tg, Integer radio, long ts) + { + h.mManager.processP1ControlDirectedChannelGrant(channel, so, ic(tg, radio), Opcode.OSP_GROUP_VOICE_CHANNEL_GRANT, ts, "test"); + } + + private static void p1Update(Harness h, APCO25Channel channel, int tg, long ts) + { + h.mManager.processP1ControlAnnouncedTrafficUpdate(channel, null, ic(tg, null), + Opcode.OSP_GROUP_VOICE_CHANNEL_GRANT_UPDATE, ts, "test"); + } + + private static void p2Grant(Harness h, APCO25Channel channel, ServiceOptions so, int tg, Integer radio, long ts) + { + h.mManager.processP2ChannelGrant(channel, so, ic(tg, radio), MacOpcode.PHASE1_C0_GROUP_VOICE_CHANNEL_GRANT_EXPLICIT, ts, "test"); + } + + /** + * Mirrors the decoder which uses empty (ie unencrypted) service options for updates that don't carry them. + */ + private static void p2Update(Harness h, APCO25Channel channel, int tg, long ts) + { + h.mManager.processP2ChannelUpdate(channel, new VoiceServiceOptions(0), ic(tg, null), + MacOpcode.PHASE1_42_GROUP_VOICE_CHANNEL_GRANT_UPDATE_IMPLICIT, ts, "test"); + } + + @Test + void phase1EncryptedGrantIsTunedWhenOptionDisabled() + { + Harness h = new Harness(phase1(false)); + p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000); + assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies); + } + + @Test + void phase1EncryptedGrantIsIgnoredIncludingLaterUpdates() + { + Harness h = new Harness(phase1(true)); + p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000); + p1Update(h, fdma(10), TALKGROUP_A, 1500); + p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 2000); + assertTrue(h.mStartedFrequencies.isEmpty()); + assertTrue(h.lastDetails().startsWith("IGNORED: ENCRYPTED"), h.lastDetails()); + } + + @Test + void phase1ClearGrantIsTuned() + { + Harness h = new Harness(phase1(true)); + p1Grant(h, fdma(10), VoiceServiceOptions.createUnencrypted(), TALKGROUP_A, 1, 1000); + assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies); + } + + @Test + void phase1DifferentTalkerWithClearGrantIsTuned() + { + Harness h = new Harness(phase1(true)); + p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000); + p1Grant(h, fdma(10), VoiceServiceOptions.createUnencrypted(), TALKGROUP_A, 2, 1500); + assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies); + } + + @Test + void phase2EncryptedGrantIsIgnoredIncludingLaterUpdates() + { + Harness h = new Harness(phase2(true)); + p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000); + p2Update(h, tdma(5, 1), TALKGROUP_A, 1500); + assertTrue(h.mStartedFrequencies.isEmpty()); + assertTrue(h.lastDetails().startsWith("IGNORED: ENCRYPTED"), h.lastDetails()); + } + + @Test + void phase2ClearCallOnOtherTimeslotTunesChannel() + { + Harness h = new Harness(phase2(true)); + p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000); + assertTrue(h.mStartedFrequencies.isEmpty()); + p2Grant(h, tdma(5, 2), VoiceServiceOptions.createUnencrypted(), TALKGROUP_B, 2, 1100); + assertEquals(List.of(BASE + 5 * SPACING), h.mStartedFrequencies); + + //Further updates on either timeslot don't request another channel + p2Update(h, tdma(5, 1), TALKGROUP_A, 1500); + p2Update(h, tdma(5, 2), TALKGROUP_B, 1500); + assertEquals(1, h.mStartedFrequencies.size()); + } + + @Test + void phase2EncryptedGrantOnAlreadyTunedChannelIsNotLabeledIgnored() + { + Harness h = new Harness(phase2(true)); + p2Grant(h, tdma(5, 2), VoiceServiceOptions.createUnencrypted(), TALKGROUP_B, 2, 1000); + p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1100); + assertEquals(1, h.mStartedFrequencies.size()); + assertTrue(h.lastDetails().startsWith("PHASE 2 CHANNEL GRANT"), h.lastDetails()); + } + + @Test + void phase2EncryptedGrantIsTunedWhenOptionDisabled() + { + Harness h = new Harness(phase2(false)); + p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000); + assertEquals(List.of(BASE + 5 * SPACING), h.mStartedFrequencies); + } + + private static Set distinctEvents(Harness h, int timeslot) + { + Set events = Collections.newSetFromMap(new IdentityHashMap<>()); + for(IDecodeEvent event : h.mEvents) + { + if(event.getTimeslot() == timeslot) + { + events.add(event); + } + } + return events; + } + + @Test + void phase2IgnoredDataCallOnTimeslot2IsTrackedOnTimeslot2() + { + DecodeConfigP25Phase2 config = phase2(false); + config.setIgnoreDataCalls(true); + Harness h = new Harness(config); + h.mManager.processP1ControlDirectedChannelGrant(tdma(5, 2), null, ic(TALKGROUP_A, 1), + Opcode.OSP_GROUP_DATA_CHANNEL_GRANT, 1000, "test"); + h.mManager.processP1ControlDirectedChannelGrant(tdma(5, 2), null, ic(TALKGROUP_A, 1), + Opcode.OSP_GROUP_DATA_CHANNEL_GRANT, 1500, "test"); + assertTrue(h.mStartedFrequencies.isEmpty()); + assertEquals(1, distinctEvents(h, 2).size(), "repeated grant should update the timeslot 2 event"); + assertTrue(distinctEvents(h, 1).isEmpty()); + } + + @Test + void phase2DataChannelBroadcastsBothTimeslotEvents() + { + Harness h = new Harness(phase2(false)); + h.mManager.processP2DataChannel(tdma(5, 1), 1000); + assertEquals(1, distinctEvents(h, 1).size()); + assertEquals(1, distinctEvents(h, 2).size()); + } + + @Test + void phase2CopyKeepsTrafficChannelSettings() + { + DecodeConfigP25Phase2 original = phase2(true); + original.setIgnoreDataCalls(true); + original.setTrafficChannelPoolSize(7); + DecodeConfigP25Phase2 copy = (DecodeConfigP25Phase2)DecoderFactory.copy(original); + assertTrue(copy.getIgnoreDataCalls()); + assertTrue(copy.getIgnoreEncryptedCalls()); + assertEquals(7, copy.getTrafficChannelPoolSize()); + + DecodeConfigP25Phase2 defaults = (DecodeConfigP25Phase2)DecoderFactory.copy(phase2(false)); + assertFalse(defaults.getIgnoreDataCalls()); + assertFalse(defaults.getIgnoreEncryptedCalls()); + } +}