mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-14 09:37:36 -05:00
Compare commits
1 Commits
master
...
2641-radio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe4471b96 |
@@ -168,7 +168,7 @@ dependencies {
|
|||||||
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
|
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
|
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
|
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
|
implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle
|
||||||
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
|
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
|
||||||
implementation 'org.apache.commons:commons-lang3:3.21.0'
|
implementation 'org.apache.commons:commons-lang3:3.21.0'
|
||||||
implementation 'org.apache.commons:commons-math3:3.6.1'
|
implementation 'org.apache.commons:commons-math3:3.6.1'
|
||||||
|
|||||||
@@ -82,5 +82,14 @@ dependencyResolutionManagement {
|
|||||||
metadataSources { artifact() }
|
metadataSources { artifact() }
|
||||||
content { includeModule 'io.github.dsheirer', 'jbladerf' }
|
content { includeModule 'io.github.dsheirer', 'jbladerf' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//radio-reference-api is published as a GitHub release. Its dependencies (jackson-dataformat-xml, slf4j-api)
|
||||||
|
//are not pulled in transitively and are declared directly in build.gradle
|
||||||
|
ivy {
|
||||||
|
url = 'https://github.com/DSheirer/radio-reference-api/releases/download'
|
||||||
|
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
|
||||||
|
metadataSources { artifact() }
|
||||||
|
content { includeModule 'io.github.dsheirer', 'radio-reference-api' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import io.github.dsheirer.channel.metadata.NowPlayingPanel;
|
|||||||
import io.github.dsheirer.eventbus.MyEventBus;
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
||||||
import io.github.dsheirer.icon.IconModel;
|
import io.github.dsheirer.icon.IconModel;
|
||||||
|
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||||
import io.github.dsheirer.map.MapPanel;
|
import io.github.dsheirer.map.MapPanel;
|
||||||
import io.github.dsheirer.map.MapService;
|
import io.github.dsheirer.map.MapService;
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
@@ -63,7 +64,9 @@ public class ControllerPanel extends JPanel
|
|||||||
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
||||||
playlistManager.getAliasModel());
|
playlistManager.getAliasModel());
|
||||||
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
|
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
|
||||||
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, settingsManager);
|
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
|
||||||
|
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||||
|
new RadioReferencePlaylistActions(playlistManager));
|
||||||
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
|
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import io.github.dsheirer.preference.UserPreferences;
|
|||||||
import io.github.dsheirer.properties.SystemProperties;
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
import io.github.dsheirer.record.AudioRecordingManager;
|
import io.github.dsheirer.record.AudioRecordingManager;
|
||||||
import io.github.dsheirer.sample.Listener;
|
import io.github.dsheirer.sample.Listener;
|
||||||
|
import io.github.dsheirer.settings.MapViewSetting;
|
||||||
import io.github.dsheirer.settings.SettingsManager;
|
import io.github.dsheirer.settings.SettingsManager;
|
||||||
import io.github.dsheirer.source.tuner.Tuner;
|
import io.github.dsheirer.source.tuner.Tuner;
|
||||||
import io.github.dsheirer.source.tuner.TunerEvent;
|
import io.github.dsheirer.source.tuner.TunerEvent;
|
||||||
@@ -184,6 +185,15 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
|
|
||||||
mSettingsManager = new SettingsManager();
|
mSettingsManager = new SettingsManager();
|
||||||
|
|
||||||
|
//Older versions stored the map view in the settings file. Move it to the map preferences once.
|
||||||
|
MapViewSetting legacyMapView = mSettingsManager.getLegacyMapView();
|
||||||
|
|
||||||
|
if(legacyMapView != null)
|
||||||
|
{
|
||||||
|
mUserPreferences.getMapPreference().migrateLegacyView(legacyMapView.getLatitude(),
|
||||||
|
legacyMapView.getLongitude(), legacyMapView.getZoom());
|
||||||
|
}
|
||||||
|
|
||||||
AliasModel aliasModel = new AliasModel();
|
AliasModel aliasModel = new AliasModel();
|
||||||
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
|
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
|
||||||
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
|
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import javafx.scene.layout.VBox;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -92,8 +91,9 @@ public class AgencyEditor extends VBox
|
|||||||
|
|
||||||
if(agencies != null && !agencies.isEmpty())
|
if(agencies != null && !agencies.isEmpty())
|
||||||
{
|
{
|
||||||
Collections.sort(agencies, new AgencyComparator());
|
//The agencies list belongs to a cached state or county info shared with other callers, so sort the items
|
||||||
getAgencyListView().getItems().addAll(agencies);
|
getAgencyListView().getItems().addAll(agencies);
|
||||||
|
getAgencyListView().getItems().sort(new AgencyComparator());
|
||||||
|
|
||||||
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);
|
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.playlist.radioreference;
|
|||||||
|
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
import io.github.dsheirer.preference.UserPreferences;
|
import io.github.dsheirer.preference.UserPreferences;
|
||||||
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
import io.github.dsheirer.rrapi.type.Category;
|
import io.github.dsheirer.rrapi.type.Category;
|
||||||
import io.github.dsheirer.rrapi.type.Frequency;
|
import io.github.dsheirer.rrapi.type.Frequency;
|
||||||
import io.github.dsheirer.rrapi.type.Mode;
|
import io.github.dsheirer.rrapi.type.Mode;
|
||||||
@@ -153,19 +154,18 @@ public class AgencyFrequencyEditor extends GridPane
|
|||||||
{
|
{
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
if(categories != null)
|
|
||||||
{
|
|
||||||
Collections.sort(categories, new CategoryComparator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if(categories != null && !categories.isEmpty())
|
if(categories != null && !categories.isEmpty())
|
||||||
{
|
{
|
||||||
|
//The categories list belongs to a cached agency or county info shared with other callers, so change a copy
|
||||||
|
List<Category> sorted = new ArrayList<>(categories);
|
||||||
|
sorted.sort(new CategoryComparator());
|
||||||
|
|
||||||
// Only allow the combined list of all categories if there aren't that many
|
// Only allow the combined list of all categories if there aren't that many
|
||||||
if (categories.size() < 10) {
|
if (sorted.size() < 10) {
|
||||||
categories.add(0, ALL_CATEGORIES);
|
sorted.add(0, ALL_CATEGORIES);
|
||||||
}
|
}
|
||||||
getCategoryComboBox().getItems().addAll(categories);
|
getCategoryComboBox().getItems().addAll(sorted);
|
||||||
getCategoryComboBox().getSelectionModel().select(categories.get(0));
|
getCategoryComboBox().getSelectionModel().select(sorted.get(0));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -441,6 +441,17 @@ public class AgencyFrequencyEditor extends GridPane
|
|||||||
|
|
||||||
Collections.sort(frequencies, new FrequencyComparator());
|
Collections.sort(frequencies, new FrequencyComparator());
|
||||||
|
|
||||||
|
//Load the cached modes map here so that the mode column doesn't call the service on the FX
|
||||||
|
//thread. On failure, the mode column shows an error and the frequencies are still listed.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mRadioReference.getService().getModesMap();
|
||||||
|
}
|
||||||
|
catch(RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
mLog.warn("Unable to retrieve radio reference modes - " + rre.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
getFrequencyTableView().getItems().addAll(frequencies);
|
getFrequencyTableView().getItems().addAll(frequencies);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -449,7 +460,10 @@ public class AgencyFrequencyEditor extends GridPane
|
|||||||
catch(Throwable t)
|
catch(Throwable t)
|
||||||
{
|
{
|
||||||
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t);
|
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t);
|
||||||
Platform.runLater(() -> new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait());
|
Platform.runLater(() -> {
|
||||||
|
setLoading(false);
|
||||||
|
new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox
|
|||||||
|
|
||||||
if(item != null)
|
if(item != null)
|
||||||
{
|
{
|
||||||
mFrequency = (long)(item.getDownlink() * 1E6);
|
mFrequency = item.getDownlinkHz();
|
||||||
getAlphaTagTextField().setText(item.getAlphaTag());
|
getAlphaTagTextField().setText(item.getAlphaTag());
|
||||||
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
|
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
|
||||||
getToneTextField().setText(item.getTone());
|
getToneTextField().setText(item.getTone());
|
||||||
@@ -413,46 +413,7 @@ public class FrequencyEditor extends VBox
|
|||||||
*/
|
*/
|
||||||
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
|
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
|
||||||
{
|
{
|
||||||
if(modeDecoderType.hasDecoderType())
|
return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, name);
|
||||||
{
|
|
||||||
Channel channel = new Channel();
|
|
||||||
channel.setSystem(system);
|
|
||||||
channel.setSite(site);
|
|
||||||
channel.setName(name);
|
|
||||||
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
|
||||||
sourceConfigTuner.setFrequency(frequency);
|
|
||||||
channel.setSourceConfiguration(sourceConfigTuner);
|
|
||||||
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
|
|
||||||
|
|
||||||
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
|
|
||||||
{
|
|
||||||
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
|
|
||||||
}
|
|
||||||
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
|
|
||||||
{
|
|
||||||
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
|
|
||||||
}
|
|
||||||
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
|
|
||||||
{
|
|
||||||
if(modeDecoderType == ModeDecoderType.NXDN48)
|
|
||||||
{
|
|
||||||
nxdn.setTransmissionMode(TransmissionMode.M4800);
|
|
||||||
}
|
|
||||||
else if(modeDecoderType == ModeDecoderType.NXDN96)
|
|
||||||
{
|
|
||||||
nxdn.setTransmissionMode(TransmissionMode.M9600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.setDecodeConfiguration(decodeConfiguration);
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TextField getSystemTextField()
|
private TextField getSystemTextField()
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference.Location;
|
||||||
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.binding.Bindings;
|
||||||
|
import javafx.beans.binding.BooleanBinding;
|
||||||
|
import javafx.geometry.HPos;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonBar;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.Dialog;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.stage.Window;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog to set the user's location, used to rank radio reference results by distance. The location can be looked
|
||||||
|
* up from a United States zip code (premium radio reference account) or entered as decimal degrees.
|
||||||
|
*/
|
||||||
|
public class LocationDialog extends Dialog<LocationDialog.Result>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Button to clear the location
|
||||||
|
*/
|
||||||
|
public static final ButtonType CLEAR = new ButtonType("Clear", ButtonBar.ButtonData.LEFT);
|
||||||
|
|
||||||
|
private static final String COORDINATE = "([-+]?\\d+(?:[.,]\\d+)?)\\s*°?\\s*([NSEWnsew])?";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A latitude and longitude pair, e.g. "43.0481, -76.1474" as copied from Google Maps, or "43.0481° N, 76.1474° W".
|
||||||
|
* A comma separator must be followed by whitespace, so that a decimal comma isn't taken for a separator.
|
||||||
|
*/
|
||||||
|
private static final Pattern COORDINATE_PAIR = Pattern.compile("^\\s*" + COORDINATE + "\\s*(?:,\\s+|;\\s*|\\s+)" +
|
||||||
|
COORDINATE + "\\s*$");
|
||||||
|
private static final Pattern SINGLE_COORDINATE = Pattern.compile("^\\s*" + COORDINATE + "\\s*$");
|
||||||
|
|
||||||
|
private final RadioReference mRadioReference;
|
||||||
|
private final TextField mZipcodeText = new TextField();
|
||||||
|
private final Button mLookupButton = new Button("Look Up");
|
||||||
|
private final Label mLookupStatus = new Label();
|
||||||
|
private final TextField mLatitudeText = new TextField();
|
||||||
|
private final TextField mLongitudeText = new TextField();
|
||||||
|
private final Label mValidationText = new Label();
|
||||||
|
private String mLabel;
|
||||||
|
private boolean mUpdatingFields;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the dialog
|
||||||
|
* @param location to store, or null to clear the location
|
||||||
|
*/
|
||||||
|
public record Result(Location location)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @param radioReference service, for zip code lookups
|
||||||
|
* @param current location, or null
|
||||||
|
* @param owner window
|
||||||
|
*/
|
||||||
|
public LocationDialog(RadioReference radioReference, Location current, Window owner)
|
||||||
|
{
|
||||||
|
mRadioReference = radioReference;
|
||||||
|
|
||||||
|
setTitle("sdrtrunk - Location");
|
||||||
|
setHeaderText("Your location is shown on the map and used to find nearby Radio Reference frequencies");
|
||||||
|
|
||||||
|
if(owner != null)
|
||||||
|
{
|
||||||
|
initOwner(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDialogPane().setContent(createContent());
|
||||||
|
getDialogPane().getButtonTypes().addAll(CLEAR, ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
|
||||||
|
if(current != null)
|
||||||
|
{
|
||||||
|
mUpdatingFields = true;
|
||||||
|
mLatitudeText.setText(format(current.latitude()));
|
||||||
|
mLongitudeText.setText(format(current.longitude()));
|
||||||
|
mUpdatingFields = false;
|
||||||
|
mLabel = current.label();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
getDialogPane().lookupButton(CLEAR).setDisable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
BooleanBinding valid = Bindings.createBooleanBinding(() -> getLocation() != null,
|
||||||
|
mLatitudeText.textProperty(), mLongitudeText.textProperty());
|
||||||
|
getDialogPane().lookupButton(ButtonType.OK).disableProperty().bind(valid.not());
|
||||||
|
updateValidationText();
|
||||||
|
|
||||||
|
setResultConverter(buttonType -> {
|
||||||
|
if(buttonType == ButtonType.OK)
|
||||||
|
{
|
||||||
|
return new Result(getLocation());
|
||||||
|
}
|
||||||
|
else if(buttonType == CLEAR)
|
||||||
|
{
|
||||||
|
return new Result(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node createContent()
|
||||||
|
{
|
||||||
|
GridPane gridPane = new GridPane();
|
||||||
|
gridPane.setPadding(new Insets(10, 10, 5, 10));
|
||||||
|
gridPane.setHgap(5.0);
|
||||||
|
gridPane.setVgap(5.0);
|
||||||
|
|
||||||
|
Label zipcodeLabel = new Label("Zip Code:");
|
||||||
|
zipcodeLabel.setLabelFor(mZipcodeText);
|
||||||
|
GridPane.setHalignment(zipcodeLabel, HPos.RIGHT);
|
||||||
|
gridPane.add(zipcodeLabel, 0, 0);
|
||||||
|
|
||||||
|
mZipcodeText.setPrefColumnCount(6);
|
||||||
|
mZipcodeText.setOnAction(event -> lookup());
|
||||||
|
gridPane.add(mZipcodeText, 1, 0);
|
||||||
|
|
||||||
|
mLookupButton.setOnAction(event -> lookup());
|
||||||
|
gridPane.add(mLookupButton, 2, 0);
|
||||||
|
|
||||||
|
//Zip code lookups need a premium account
|
||||||
|
boolean premium = mRadioReference.premiumAccountProperty().get();
|
||||||
|
mZipcodeText.setDisable(!premium);
|
||||||
|
mLookupButton.setDisable(!premium);
|
||||||
|
mLookupStatus.setText(premium ? "United States zip codes only" :
|
||||||
|
"Zip code lookup requires a premium Radio Reference login");
|
||||||
|
mLookupStatus.setWrapText(true);
|
||||||
|
gridPane.add(mLookupStatus, 1, 1, 4, 1);
|
||||||
|
|
||||||
|
Label latitudeLabel = new Label("Latitude:");
|
||||||
|
latitudeLabel.setLabelFor(mLatitudeText);
|
||||||
|
GridPane.setHalignment(latitudeLabel, HPos.RIGHT);
|
||||||
|
gridPane.add(latitudeLabel, 0, 2);
|
||||||
|
|
||||||
|
mLatitudeText.setPrefColumnCount(10);
|
||||||
|
mLatitudeText.setPromptText("43.0481");
|
||||||
|
GridPane.setHgrow(mLatitudeText, Priority.ALWAYS);
|
||||||
|
gridPane.add(mLatitudeText, 1, 2, 2, 1);
|
||||||
|
|
||||||
|
Label longitudeLabel = new Label("Longitude:");
|
||||||
|
longitudeLabel.setLabelFor(mLongitudeText);
|
||||||
|
GridPane.setHalignment(longitudeLabel, HPos.RIGHT);
|
||||||
|
gridPane.add(longitudeLabel, 3, 2);
|
||||||
|
|
||||||
|
mLongitudeText.setPrefColumnCount(10);
|
||||||
|
mLongitudeText.setPromptText("-76.1474");
|
||||||
|
GridPane.setHgrow(mLongitudeText, Priority.ALWAYS);
|
||||||
|
gridPane.add(mLongitudeText, 4, 2);
|
||||||
|
|
||||||
|
Label hint = new Label("Decimal degrees. You can paste \"latitude, longitude\" from a map into either field.");
|
||||||
|
hint.setWrapText(true);
|
||||||
|
gridPane.add(hint, 1, 3, 4, 1);
|
||||||
|
|
||||||
|
mValidationText.setWrapText(true);
|
||||||
|
//Inline style, because the theme stylesheets override a label's text fill
|
||||||
|
mValidationText.setStyle("-fx-text-fill: orangered;");
|
||||||
|
gridPane.add(mValidationText, 1, 4, 4, 1);
|
||||||
|
|
||||||
|
mLatitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue));
|
||||||
|
mLongitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue));
|
||||||
|
|
||||||
|
return gridPane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an edit of either coordinate field: splits a pasted pair into both fields and forgets the zip code label
|
||||||
|
* because the location no longer comes from the zip code.
|
||||||
|
*/
|
||||||
|
private void coordinatesEdited(String text)
|
||||||
|
{
|
||||||
|
if(mUpdatingFields)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mLabel = null;
|
||||||
|
|
||||||
|
double[] pair = parsePair(text);
|
||||||
|
|
||||||
|
if(pair != null)
|
||||||
|
{
|
||||||
|
//Changing a text field from its own change listener isn't allowed, so split the pair afterwards
|
||||||
|
Platform.runLater(() -> setCoordinates(pair[0], pair[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValidationText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setCoordinates(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
mUpdatingFields = true;
|
||||||
|
mLatitudeText.setText(format(latitude));
|
||||||
|
mLongitudeText.setText(format(longitude));
|
||||||
|
mUpdatingFields = false;
|
||||||
|
updateValidationText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateValidationText()
|
||||||
|
{
|
||||||
|
String latitudeText = mLatitudeText.getText();
|
||||||
|
String longitudeText = mLongitudeText.getText();
|
||||||
|
|
||||||
|
if(isBlank(latitudeText) && isBlank(longitudeText))
|
||||||
|
{
|
||||||
|
mValidationText.setText(null);
|
||||||
|
}
|
||||||
|
else if(isBlank(latitudeText) || isBlank(longitudeText))
|
||||||
|
{
|
||||||
|
mValidationText.setText("Enter both a latitude and a longitude");
|
||||||
|
}
|
||||||
|
else if(Double.isNaN(parseCoordinate(latitudeText, true)))
|
||||||
|
{
|
||||||
|
mValidationText.setText("Latitude must be a number from -90 to 90");
|
||||||
|
}
|
||||||
|
else if(Double.isNaN(parseCoordinate(longitudeText, false)))
|
||||||
|
{
|
||||||
|
mValidationText.setText("Longitude must be a number from -180 to 180");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mValidationText.setText(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location from the fields
|
||||||
|
* @return location, or null if the fields aren't valid
|
||||||
|
*/
|
||||||
|
private Location getLocation()
|
||||||
|
{
|
||||||
|
double latitude = parseCoordinate(mLatitudeText.getText(), true);
|
||||||
|
double longitude = parseCoordinate(mLongitudeText.getText(), false);
|
||||||
|
|
||||||
|
if(Location.isValid(latitude, longitude))
|
||||||
|
{
|
||||||
|
return new Location(latitude, longitude, mLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up the zip code in the background and fills the coordinates
|
||||||
|
*/
|
||||||
|
private void lookup()
|
||||||
|
{
|
||||||
|
String zipcode = mZipcodeText.getText() != null ? mZipcodeText.getText().trim() : "";
|
||||||
|
|
||||||
|
if(!zipcode.matches("\\d{5}"))
|
||||||
|
{
|
||||||
|
mLookupStatus.setText("Enter a five digit zip code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mLookupButton.setDisable(true);
|
||||||
|
mLookupStatus.setText("Looking up " + zipcode + "...");
|
||||||
|
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
Location location = null;
|
||||||
|
String error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
location = mRadioReference.lookupZipcode(Integer.parseInt(zipcode));
|
||||||
|
}
|
||||||
|
catch(RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
error = switch(rre.getErrorType())
|
||||||
|
{
|
||||||
|
case NETWORK, TIMEOUT -> "Unable to reach Radio Reference";
|
||||||
|
case INVALID_REQUEST -> "Radio Reference has no location for zip code " + zipcode;
|
||||||
|
default -> "Lookup failed: " + rre.getMessage();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch(RuntimeException re)
|
||||||
|
{
|
||||||
|
error = "Lookup failed: " + re.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Location result = location;
|
||||||
|
final String message = error;
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
mLookupButton.setDisable(false);
|
||||||
|
|
||||||
|
if(result != null)
|
||||||
|
{
|
||||||
|
setCoordinates(result.latitude(), result.longitude());
|
||||||
|
mLabel = result.label();
|
||||||
|
mLookupStatus.setText(result.label());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mLookupStatus.setText(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a latitude and longitude pair
|
||||||
|
* @param text to parse
|
||||||
|
* @return latitude and longitude, or null if the text isn't a valid pair
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static double[] parsePair(String text)
|
||||||
|
{
|
||||||
|
if(text == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = COORDINATE_PAIR.matcher(text);
|
||||||
|
|
||||||
|
if(matcher.matches())
|
||||||
|
{
|
||||||
|
double latitude = toDegrees(matcher.group(1), matcher.group(2), true);
|
||||||
|
double longitude = toDegrees(matcher.group(3), matcher.group(4), false);
|
||||||
|
|
||||||
|
if(Location.isValid(latitude, longitude))
|
||||||
|
{
|
||||||
|
return new double[]{latitude, longitude};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one coordinate in decimal degrees, with an optional degree sign and hemisphere letter
|
||||||
|
* @param text to parse
|
||||||
|
* @param latitude true for a latitude, false for a longitude
|
||||||
|
* @return degrees, or NaN if the text isn't a valid coordinate
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static double parseCoordinate(String text, boolean latitude)
|
||||||
|
{
|
||||||
|
if(text == null)
|
||||||
|
{
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = SINGLE_COORDINATE.matcher(text);
|
||||||
|
|
||||||
|
if(matcher.matches())
|
||||||
|
{
|
||||||
|
double degrees = toDegrees(matcher.group(1), matcher.group(2), latitude);
|
||||||
|
|
||||||
|
if(Math.abs(degrees) <= (latitude ? 90.0 : 180.0))
|
||||||
|
{
|
||||||
|
return degrees;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a number and an optional hemisphere letter to signed degrees
|
||||||
|
* @return degrees, or NaN if the hemisphere letter doesn't match the coordinate type
|
||||||
|
*/
|
||||||
|
private static double toDegrees(String number, String hemisphere, boolean latitude)
|
||||||
|
{
|
||||||
|
//Accept a decimal comma
|
||||||
|
double degrees = Double.parseDouble(number.replace(',', '.'));
|
||||||
|
|
||||||
|
if(hemisphere != null)
|
||||||
|
{
|
||||||
|
char letter = Character.toUpperCase(hemisphere.charAt(0));
|
||||||
|
boolean latitudeLetter = letter == 'N' || letter == 'S';
|
||||||
|
|
||||||
|
if(latitudeLetter != latitude || degrees < 0)
|
||||||
|
{
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(letter == 'S' || letter == 'W')
|
||||||
|
{
|
||||||
|
degrees = -degrees;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return degrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats degrees with up to six decimal places (about 0.1 meter)
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static String format(double degrees)
|
||||||
|
{
|
||||||
|
return BigDecimal.valueOf(degrees).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String text)
|
||||||
|
{
|
||||||
|
return text == null || text.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import io.github.dsheirer.rrapi.RadioReferenceException;
|
|||||||
import io.github.dsheirer.rrapi.response.Fault;
|
import io.github.dsheirer.rrapi.response.Fault;
|
||||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
||||||
import io.github.dsheirer.service.radioreference.RadioReference;
|
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.Bindings;
|
import javafx.beans.binding.Bindings;
|
||||||
import javafx.event.ActionEvent;
|
import javafx.event.ActionEvent;
|
||||||
@@ -49,7 +50,6 @@ import jiconfont.javafx.IconNode;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.net.ConnectException;
|
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -60,6 +60,8 @@ import java.util.Date;
|
|||||||
public class LoginDialog extends Dialog<AuthorizationInformation>
|
public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||||
{
|
{
|
||||||
private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class);
|
private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class);
|
||||||
|
private static final String TEST_CONNECTION = "Test Connection";
|
||||||
|
private static final String TESTING_CONNECTION = "Testing...";
|
||||||
private UserPreferences mUserPreferences;
|
private UserPreferences mUserPreferences;
|
||||||
private TextField mUserNameText;
|
private TextField mUserNameText;
|
||||||
private PasswordField mPasswordField;
|
private PasswordField mPasswordField;
|
||||||
@@ -278,7 +280,7 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
|||||||
{
|
{
|
||||||
if(mTestConnectionButton == null)
|
if(mTestConnectionButton == null)
|
||||||
{
|
{
|
||||||
mTestConnectionButton = new Button("Test Connection");
|
mTestConnectionButton = new Button(TEST_CONNECTION);
|
||||||
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
|
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
|
||||||
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
|
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
|
||||||
{
|
{
|
||||||
@@ -316,98 +318,26 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Platform.runLater(new Runnable()
|
getTestConnectionButton().setText(TESTING_CONNECTION);
|
||||||
{
|
|
||||||
@Override
|
//The test calls the web service, which can take up to the connect and request timeouts, so it runs
|
||||||
public void run()
|
//off the FX thread and the result is shown on the FX thread
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
RadioReference.LoginStatus loginStatus = null;
|
||||||
|
Exception error = null;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
boolean success = false;
|
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
||||||
RadioReference.LoginStatus loginStatus;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
|
||||||
|
|
||||||
if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
|
|
||||||
{
|
|
||||||
getTestPassIcon().setVisible(true);
|
|
||||||
getTestFailIcon().setVisible(false);
|
|
||||||
getTestExpiredIcon().setVisible(false);
|
|
||||||
//Success is only shown as an icon, so report it in the button's accessible name
|
|
||||||
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
|
|
||||||
}
|
|
||||||
else if (loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
|
|
||||||
{
|
|
||||||
getTestPassIcon().setVisible(false);
|
|
||||||
getTestFailIcon().setVisible(false);
|
|
||||||
getTestExpiredIcon().setVisible(true);
|
|
||||||
|
|
||||||
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
|
|
||||||
alert.setHeaderText("Expired Premium Account");
|
|
||||||
alert.setTitle("Radio Reference Account issue");
|
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
|
||||||
alert.showAndWait();
|
|
||||||
}
|
|
||||||
else if (loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
|
|
||||||
{
|
|
||||||
getTestPassIcon().setVisible(false);
|
|
||||||
getTestFailIcon().setVisible(true);
|
|
||||||
getTestExpiredIcon().setVisible(false);
|
|
||||||
|
|
||||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Please verify username and password", ButtonType.OK);
|
|
||||||
alert.setHeaderText("Login Failed");
|
|
||||||
alert.setTitle("Test Failed");
|
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
|
||||||
alert.showAndWait();
|
|
||||||
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Only way to get here is via an exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(RadioReferenceException rre)
|
|
||||||
{
|
|
||||||
|
|
||||||
// Set an error state
|
|
||||||
getTestPassIcon().setVisible(false);
|
|
||||||
getTestFailIcon().setVisible(true);
|
|
||||||
getTestExpiredIcon().setVisible(false);
|
|
||||||
|
|
||||||
if(rre.getCause() instanceof ConnectException)
|
|
||||||
{
|
|
||||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Please check network or radio reference availability", ButtonType.OK);
|
|
||||||
alert.setHeaderText("No Network Connection");
|
|
||||||
alert.setTitle("Test Failed");
|
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
|
||||||
alert.showAndWait();
|
|
||||||
mLog.error("No network connection to radioreference.com");
|
|
||||||
}
|
|
||||||
else if(rre.hasFault())
|
|
||||||
{
|
|
||||||
Fault fault = rre.getFault();
|
|
||||||
|
|
||||||
Alert alert = new Alert(Alert.AlertType.ERROR, fault.getFaultString(), ButtonType.OK);
|
|
||||||
alert.setHeaderText(fault.getFaultCode());
|
|
||||||
alert.setTitle("Test Failed");
|
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
|
||||||
alert.showAndWait();
|
|
||||||
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Error: " + rre.getMessage(), ButtonType.OK);
|
|
||||||
alert.setHeaderText("Unknown Error");
|
|
||||||
alert.setTitle("Test Failed");
|
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
|
||||||
alert.showAndWait();
|
|
||||||
mLog.error("Error testing connection to radioreference.com", rre);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getTestConnectionButton().setDisable(false);
|
|
||||||
}
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
final RadioReference.LoginStatus status = loginStatus;
|
||||||
|
final Exception exception = error;
|
||||||
|
Platform.runLater(() -> showTestConnectionResult(status, exception));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -416,6 +346,111 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
|||||||
return mTestConnectionButton;
|
return mTestConnectionButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the result of a connection test. Must be called on the FX thread.
|
||||||
|
* @param loginStatus from the test, or null if the test failed with an error
|
||||||
|
* @param error from the test, or null
|
||||||
|
*/
|
||||||
|
private void showTestConnectionResult(RadioReference.LoginStatus loginStatus, Exception error)
|
||||||
|
{
|
||||||
|
getTestConnectionButton().setText(TEST_CONNECTION);
|
||||||
|
getTestConnectionButton().setDisable(false);
|
||||||
|
|
||||||
|
//Results that arrive after the dialog closes are only logged
|
||||||
|
boolean showing = isShowing();
|
||||||
|
|
||||||
|
if(error != null)
|
||||||
|
{
|
||||||
|
// Set an error state
|
||||||
|
getTestPassIcon().setVisible(false);
|
||||||
|
getTestFailIcon().setVisible(true);
|
||||||
|
getTestExpiredIcon().setVisible(false);
|
||||||
|
|
||||||
|
RadioReferenceException.ErrorType errorType = error instanceof RadioReferenceException rre ?
|
||||||
|
rre.getErrorType() : RadioReferenceException.ErrorType.UNKNOWN;
|
||||||
|
|
||||||
|
//The service has a 10 second connect timeout, so an unreachable service is reported as TIMEOUT as well
|
||||||
|
//as NETWORK
|
||||||
|
if(errorType == RadioReferenceException.ErrorType.NETWORK ||
|
||||||
|
errorType == RadioReferenceException.ErrorType.TIMEOUT)
|
||||||
|
{
|
||||||
|
mLog.error("No network connection to radioreference.com - " + error.getMessage());
|
||||||
|
|
||||||
|
if(showing)
|
||||||
|
{
|
||||||
|
showTestFailedAlert(Alert.AlertType.ERROR, "No Network Connection",
|
||||||
|
"Please check network or radio reference availability");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(error instanceof RadioReferenceException rre && rre.hasFault())
|
||||||
|
{
|
||||||
|
Fault fault = rre.getFault();
|
||||||
|
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
|
||||||
|
|
||||||
|
if(showing)
|
||||||
|
{
|
||||||
|
showTestFailedAlert(Alert.AlertType.ERROR, fault.getFaultCode(), fault.getFaultString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mLog.error("Error testing connection to radioreference.com", error);
|
||||||
|
|
||||||
|
if(showing)
|
||||||
|
{
|
||||||
|
showTestFailedAlert(Alert.AlertType.ERROR, "Unknown Error", "Error: " + error.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
|
||||||
|
{
|
||||||
|
getTestPassIcon().setVisible(true);
|
||||||
|
getTestFailIcon().setVisible(false);
|
||||||
|
getTestExpiredIcon().setVisible(false);
|
||||||
|
//Success is only shown as an icon, so report it in the button's accessible name
|
||||||
|
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
|
||||||
|
}
|
||||||
|
else if(loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
|
||||||
|
{
|
||||||
|
getTestPassIcon().setVisible(false);
|
||||||
|
getTestFailIcon().setVisible(false);
|
||||||
|
getTestExpiredIcon().setVisible(true);
|
||||||
|
|
||||||
|
if(showing)
|
||||||
|
{
|
||||||
|
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
|
||||||
|
alert.setHeaderText("Expired Premium Account");
|
||||||
|
alert.setTitle("Radio Reference Account issue");
|
||||||
|
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
|
||||||
|
{
|
||||||
|
getTestPassIcon().setVisible(false);
|
||||||
|
getTestFailIcon().setVisible(true);
|
||||||
|
getTestExpiredIcon().setVisible(false);
|
||||||
|
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
|
||||||
|
|
||||||
|
if(showing)
|
||||||
|
{
|
||||||
|
showTestFailedAlert(Alert.AlertType.ERROR, "Login Failed", "Please verify username and password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an alert for a failed connection test. Must be called on the FX thread.
|
||||||
|
*/
|
||||||
|
private void showTestFailedAlert(Alert.AlertType alertType, String header, String content)
|
||||||
|
{
|
||||||
|
Alert alert = new Alert(alertType, content, ButtonType.OK);
|
||||||
|
alert.setHeaderText(header);
|
||||||
|
alert.setTitle("Test Failed");
|
||||||
|
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
|
||||||
private IconNode getTestPassIcon()
|
private IconNode getTestPassIcon()
|
||||||
{
|
{
|
||||||
if(mTestPassIcon == null)
|
if(mTestPassIcon == null)
|
||||||
|
|||||||
@@ -70,8 +70,28 @@ public enum ModeDecoderType
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup the entry that matches the mode
|
* Lookup the matching entry for a radio reference mode name, e.g. FMN
|
||||||
*
|
* @param name of the mode
|
||||||
|
* @return matching entry or UNKNOWN
|
||||||
|
*/
|
||||||
|
public static ModeDecoderType fromName(String name)
|
||||||
|
{
|
||||||
|
if(name != null)
|
||||||
|
{
|
||||||
|
for(ModeDecoderType modeDecoderType : ModeDecoderType.values())
|
||||||
|
{
|
||||||
|
if(modeDecoderType.getValue().contentEquals(name.trim().toLowerCase()))
|
||||||
|
{
|
||||||
|
return modeDecoderType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup the matching entry for the radio reference mode
|
||||||
* @param mode to match
|
* @param mode to match
|
||||||
* @return matching entry or UNKNOWN
|
* @return matching entry or UNKNOWN
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
|
import io.github.dsheirer.module.decode.DecoderFactory;
|
||||||
|
import io.github.dsheirer.module.decode.DecoderType;
|
||||||
|
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
|
||||||
|
import io.github.dsheirer.module.decode.nbfm.DecodeConfigNBFM;
|
||||||
|
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
|
||||||
|
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
|
||||||
|
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
|
||||||
|
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
|
||||||
|
import io.github.dsheirer.rrapi.type.Site;
|
||||||
|
import io.github.dsheirer.rrapi.type.SiteFrequency;
|
||||||
|
import io.github.dsheirer.rrapi.type.System;
|
||||||
|
import io.github.dsheirer.rrapi.type.SystemInformation;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfigTuner;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates playlist channels from radio reference frequencies and trunked system sites.
|
||||||
|
*/
|
||||||
|
public final class RadioReferenceChannelFactory
|
||||||
|
{
|
||||||
|
private static final Logger mLog = LoggerFactory.getLogger(RadioReferenceChannelFactory.class);
|
||||||
|
|
||||||
|
private RadioReferenceChannelFactory()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a conventional channel for a radio reference frequency
|
||||||
|
* @param modeDecoderType of the frequency's radio reference mode
|
||||||
|
* @param frequency in Hertz
|
||||||
|
* @param system name for the channel
|
||||||
|
* @param site name for the channel
|
||||||
|
* @param name of the channel
|
||||||
|
* @return channel, or null when sdrtrunk has no decoder for the mode
|
||||||
|
*/
|
||||||
|
public static Channel conventional(ModeDecoderType modeDecoderType, long frequency, String system, String site,
|
||||||
|
String name)
|
||||||
|
{
|
||||||
|
if(modeDecoderType.hasDecoderType())
|
||||||
|
{
|
||||||
|
Channel channel = new Channel();
|
||||||
|
channel.setSystem(system);
|
||||||
|
channel.setSite(site);
|
||||||
|
channel.setName(name);
|
||||||
|
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||||
|
sourceConfigTuner.setFrequency(frequency);
|
||||||
|
channel.setSourceConfiguration(sourceConfigTuner);
|
||||||
|
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
|
||||||
|
|
||||||
|
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
|
||||||
|
{
|
||||||
|
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
|
||||||
|
}
|
||||||
|
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
|
||||||
|
{
|
||||||
|
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
|
||||||
|
}
|
||||||
|
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
|
||||||
|
{
|
||||||
|
if(modeDecoderType == ModeDecoderType.NXDN48)
|
||||||
|
{
|
||||||
|
nxdn.setTransmissionMode(TransmissionMode.M4800);
|
||||||
|
}
|
||||||
|
else if(modeDecoderType == ModeDecoderType.NXDN96)
|
||||||
|
{
|
||||||
|
nxdn.setTransmissionMode(TransmissionMode.M9600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.setDecodeConfiguration(decodeConfiguration);
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder type for a trunked system site's control channel. A P25 Phase II site with an FDMA control channel
|
||||||
|
* uses the Phase 1 decoder, which follows the Phase II traffic channels.
|
||||||
|
* @return decoder type, or null when sdrtrunk can't decode the site's control channel
|
||||||
|
*/
|
||||||
|
public static DecoderType getControlDecoderType(RadioReferenceDecoder decoder, System system, Site site)
|
||||||
|
{
|
||||||
|
//Motorola Type II control channels with P25 voice channels aren't supported
|
||||||
|
if(decoder.isHybridMotorolaP25(system))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DecoderType decoderType = decoder.getDecoderType(system, site);
|
||||||
|
|
||||||
|
if(decoderType == DecoderType.P25_PHASE2 && (site == null || site.getTdmaControlChannel() == 0))
|
||||||
|
{
|
||||||
|
return DecoderType.P25_PHASE1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control and alternate control channel frequencies of the site, control channels first
|
||||||
|
*/
|
||||||
|
public static List<Long> getControlFrequencies(Site site)
|
||||||
|
{
|
||||||
|
List<Long> frequencies = new ArrayList<>();
|
||||||
|
|
||||||
|
if(site != null && site.getSiteFrequencies() != null)
|
||||||
|
{
|
||||||
|
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
|
||||||
|
{
|
||||||
|
if(SiteEditor.isControl(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
|
||||||
|
{
|
||||||
|
frequencies.add(siteFrequency.getFrequencyHz());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
|
||||||
|
{
|
||||||
|
if(SiteEditor.isAlternate(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
|
||||||
|
{
|
||||||
|
frequencies.add(siteFrequency.getFrequencyHz());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frequencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel for each of the site's repeaters on the frequencies, for systems without a control channel
|
||||||
|
* (LTR), where each repeater carries its own trunking data. The channels are named by logical channel number,
|
||||||
|
* like the playlist editor's site editor does.
|
||||||
|
* @param decoder for the radio reference system details
|
||||||
|
* @param system of the site
|
||||||
|
* @param systemInformation of the system
|
||||||
|
* @param site of the repeaters
|
||||||
|
* @param frequencies of the repeaters to create channels for
|
||||||
|
* @return channels, or an empty list when sdrtrunk can't decode the system
|
||||||
|
*/
|
||||||
|
public static List<Channel> repeaters(RadioReferenceDecoder decoder, System system,
|
||||||
|
SystemInformation systemInformation, Site site, List<Long> frequencies)
|
||||||
|
{
|
||||||
|
List<Channel> channels = new ArrayList<>();
|
||||||
|
DecoderType decoderType = decoder.getDecoderType(system, site);
|
||||||
|
|
||||||
|
if(decoderType == null || site == null || site.getSiteFrequencies() == null)
|
||||||
|
{
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
|
||||||
|
{
|
||||||
|
if(frequencies.contains(siteFrequency.getFrequencyHz()))
|
||||||
|
{
|
||||||
|
Channel channel = new Channel();
|
||||||
|
channel.setSystem(system.getName());
|
||||||
|
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
|
||||||
|
site.getDescription().trim() : "Site " + site.getSiteNumber());
|
||||||
|
channel.setName("LCN " + siteFrequency.getLogicalChannelNumber());
|
||||||
|
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
|
||||||
|
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||||
|
sourceConfigTuner.setFrequency(siteFrequency.getFrequencyHz());
|
||||||
|
channel.setSourceConfiguration(sourceConfigTuner);
|
||||||
|
channels.add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
|
||||||
|
* alternate control channels.
|
||||||
|
* @param decoder for the radio reference system details
|
||||||
|
* @param system of the site
|
||||||
|
* @param systemInformation of the system
|
||||||
|
* @param site to create a channel for
|
||||||
|
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
|
||||||
|
*/
|
||||||
|
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
|
||||||
|
Site site)
|
||||||
|
{
|
||||||
|
return trunkedSite(decoder, system, systemInformation, site, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
|
||||||
|
* alternate control channels, starting with the first frequency when it's one of them.
|
||||||
|
* @param decoder for the radio reference system details
|
||||||
|
* @param system of the site
|
||||||
|
* @param systemInformation of the system
|
||||||
|
* @param site to create a channel for
|
||||||
|
* @param firstFrequency to list first, so the channel starts on it, or null for control channels first
|
||||||
|
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
|
||||||
|
*/
|
||||||
|
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
|
||||||
|
Site site, Long firstFrequency)
|
||||||
|
{
|
||||||
|
DecoderType decoderType = getControlDecoderType(decoder, system, site);
|
||||||
|
List<Long> frequencies = getControlFrequencies(site);
|
||||||
|
|
||||||
|
//A multi-frequency channel starts on its first frequency
|
||||||
|
if(firstFrequency != null && frequencies.remove(firstFrequency))
|
||||||
|
{
|
||||||
|
frequencies.add(0, firstFrequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(decoderType == null || frequencies.isEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel channel = new Channel();
|
||||||
|
channel.setSystem(system.getName());
|
||||||
|
String siteName = "Site " + site.getSiteNumber();
|
||||||
|
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
|
||||||
|
site.getDescription().trim() : siteName);
|
||||||
|
channel.setName(siteName);
|
||||||
|
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
|
||||||
|
|
||||||
|
if(frequencies.size() == 1)
|
||||||
|
{
|
||||||
|
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||||
|
sourceConfigTuner.setFrequency(frequencies.get(0));
|
||||||
|
channel.setSourceConfiguration(sourceConfigTuner);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SourceConfigTunerMultipleFrequency sourceConfig = new SourceConfigTunerMultipleFrequency();
|
||||||
|
|
||||||
|
for(long frequency: frequencies)
|
||||||
|
{
|
||||||
|
sourceConfig.addFrequency(frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.setSourceConfiguration(sourceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,16 @@
|
|||||||
|
|
||||||
package io.github.dsheirer.gui.playlist.radioreference;
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
|
import io.github.dsheirer.module.decode.DecoderFactory;
|
||||||
|
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
|
||||||
|
import io.github.dsheirer.module.decode.dmr.DecodeConfigDMR;
|
||||||
|
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
|
||||||
|
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
|
||||||
|
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
|
||||||
|
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
|
||||||
|
import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2;
|
||||||
|
import io.github.dsheirer.module.decode.p25.phase2.enumeration.ScrambleParameters;
|
||||||
|
import io.github.dsheirer.rrapi.type.RadioNetwork;
|
||||||
import io.github.dsheirer.alias.Alias;
|
import io.github.dsheirer.alias.Alias;
|
||||||
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
|
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
|
||||||
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
||||||
@@ -382,7 +392,7 @@ public class RadioReferenceDecoder
|
|||||||
|
|
||||||
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
|
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
|
||||||
timeslotFrequency.setNumber(lcn);
|
timeslotFrequency.setNumber(lcn);
|
||||||
timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6));
|
timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz());
|
||||||
frequencies.add(timeslotFrequency);
|
frequencies.add(timeslotFrequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +442,7 @@ public class RadioReferenceDecoder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frequencies.add(new ChannelFrequency(lcn, (long)(siteFrequency.getFrequency() * 1E6), 0));
|
frequencies.add(new ChannelFrequency(lcn, siteFrequency.getFrequencyHz(), 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
return frequencies;
|
return frequencies;
|
||||||
@@ -577,4 +587,126 @@ public class RadioReferenceDecoder
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel decode configuration for the specified site.
|
||||||
|
* @param decoderType to create
|
||||||
|
* @param site information and frequencies
|
||||||
|
* @param systemInformation with frequency mapping
|
||||||
|
* @return decode configuration
|
||||||
|
*/
|
||||||
|
public DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
||||||
|
{
|
||||||
|
if(decoderType == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(decoderType)
|
||||||
|
{
|
||||||
|
case DMR:
|
||||||
|
DecodeConfigDMR dmr = new DecodeConfigDMR();
|
||||||
|
List<TimeslotFrequency> timeslotFrequencies = getTimeslotFrequencies(systemInformation, site);
|
||||||
|
dmr.setTimeslotMap(timeslotFrequencies);
|
||||||
|
return dmr;
|
||||||
|
case NXDN:
|
||||||
|
Flavor flavor = getFlavor(systemInformation);
|
||||||
|
|
||||||
|
TransmissionMode mode = TransmissionMode.M4800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 9600 bps Flavors
|
||||||
|
* ----------------------------
|
||||||
|
* 29 - NEXEDGE 9600
|
||||||
|
* 45 - Conventional Networked
|
||||||
|
*
|
||||||
|
* 4800 bps Flavors
|
||||||
|
* ---------------------------
|
||||||
|
* 11 - Narrowband Networked
|
||||||
|
* 42 - NEXEDGE 4800
|
||||||
|
* 44 - Icom IDAS Type C
|
||||||
|
*
|
||||||
|
* Type-D (also 4800 bps) Flavors
|
||||||
|
* ---------------------------
|
||||||
|
* 36 - Icom IDAS Type D
|
||||||
|
* 47 - Kenwood Type D
|
||||||
|
*/
|
||||||
|
mode = switch(flavor.getName())
|
||||||
|
{
|
||||||
|
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
|
||||||
|
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
|
||||||
|
default -> TransmissionMode.M4800;
|
||||||
|
};
|
||||||
|
|
||||||
|
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
|
||||||
|
List<ChannelFrequency> channelMap = getChannelMap(systemInformation, site);
|
||||||
|
nxdn.setChannelMap(channelMap);
|
||||||
|
return nxdn;
|
||||||
|
case P25_PHASE1:
|
||||||
|
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
|
||||||
|
|
||||||
|
if(isLSM(site))
|
||||||
|
{
|
||||||
|
if(p1config instanceof DecodeConfigP25Phase1)
|
||||||
|
{
|
||||||
|
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p1config;
|
||||||
|
case P25_PHASE2:
|
||||||
|
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
|
||||||
|
|
||||||
|
int nac = 0;
|
||||||
|
|
||||||
|
if(site != null && site.getNac() != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nac = Integer.parseInt(site.getNac(), 16);
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
//Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int wacn = 0;
|
||||||
|
int system = 0;
|
||||||
|
|
||||||
|
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
|
||||||
|
!systemInformation.getRadioNetworks().isEmpty())
|
||||||
|
{
|
||||||
|
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
|
||||||
|
|
||||||
|
if(radioNetwork.getWacn() != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
//Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(radioNetwork.getSystemId() != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
//Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
||||||
|
return config;
|
||||||
|
default:
|
||||||
|
return DecoderFactory.getDecodeConfiguration(decoderType);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
package io.github.dsheirer.gui.playlist.radioreference;
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
|
import com.google.common.eventbus.Subscribe;
|
||||||
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
|
import io.github.dsheirer.preference.PreferenceType;
|
||||||
import io.github.dsheirer.preference.UserPreferences;
|
import io.github.dsheirer.preference.UserPreferences;
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
import io.github.dsheirer.rrapi.type.Agency;
|
import io.github.dsheirer.rrapi.type.Agency;
|
||||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
||||||
@@ -31,6 +35,7 @@ import io.github.dsheirer.rrapi.type.CountyInfo;
|
|||||||
import io.github.dsheirer.rrapi.type.State;
|
import io.github.dsheirer.rrapi.type.State;
|
||||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||||
import io.github.dsheirer.service.radioreference.RadioReference;
|
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||||
|
import io.github.dsheirer.service.radioreference.UserLocation;
|
||||||
import io.github.dsheirer.util.ThreadPool;
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.Bindings;
|
import javafx.beans.binding.Bindings;
|
||||||
@@ -42,12 +47,12 @@ import javafx.scene.control.Label;
|
|||||||
import javafx.scene.control.Tab;
|
import javafx.scene.control.Tab;
|
||||||
import javafx.scene.control.TabPane;
|
import javafx.scene.control.TabPane;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.ColumnConstraints;
|
import javafx.scene.layout.ColumnConstraints;
|
||||||
import javafx.scene.layout.GridPane;
|
import javafx.scene.layout.GridPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
import javafx.util.StringConverter;
|
import javafx.util.StringConverter;
|
||||||
@@ -58,6 +63,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
|
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
|
||||||
@@ -68,6 +74,10 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
private PlaylistManager mPlaylistManager;
|
private PlaylistManager mPlaylistManager;
|
||||||
private VBox mTopBox;
|
private VBox mTopBox;
|
||||||
private HBox mCredentialsBox;
|
private HBox mCredentialsBox;
|
||||||
|
private HBox mLocationBox;
|
||||||
|
private Label mLocationText;
|
||||||
|
private final AtomicLong mLocationRequest = new AtomicLong();
|
||||||
|
private volatile String mLocationKey;
|
||||||
private Label mRRStatusText;
|
private Label mRRStatusText;
|
||||||
private TextField mUserNameText;
|
private TextField mUserNameText;
|
||||||
private TextField mAccountExpiresText;
|
private TextField mAccountExpiresText;
|
||||||
@@ -113,20 +123,41 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
{
|
{
|
||||||
refreshCountries();
|
refreshCountries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//The preferred county fallback location needs a premium login
|
||||||
|
refreshLocation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refreshLocation();
|
||||||
|
|
||||||
|
//Updates the location when it's changed elsewhere, e.g. from the map
|
||||||
|
MyEventBus.getGlobalEventBus().register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the location text when the location or the preferred county changes
|
||||||
|
* @param preferenceType that was updated
|
||||||
|
*/
|
||||||
|
@Subscribe
|
||||||
|
public void preferenceUpdated(PreferenceType preferenceType)
|
||||||
|
{
|
||||||
|
if(preferenceType == PreferenceType.RADIO_REFERENCE)
|
||||||
|
{
|
||||||
|
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
|
||||||
|
String key = preference.getLocation() + "/" + preference.getPreferredCountyId();
|
||||||
|
|
||||||
|
if(!key.equals(mLocationKey))
|
||||||
|
{
|
||||||
|
mLocationKey = key;
|
||||||
|
Platform.runLater(this::refreshLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void login()
|
private void login()
|
||||||
{
|
{
|
||||||
if(mUserPreferences.getRadioReferencePreference().hasStoredCredentials())
|
//Logs in on a background thread, unless the spectral display has already logged in
|
||||||
{
|
mRadioReference.loginWithStoredCredentials();
|
||||||
AuthorizationInformation credentials = mUserPreferences.getRadioReferencePreference().getAuthorizationInformation();
|
|
||||||
|
|
||||||
if(credentials != null)
|
|
||||||
{
|
|
||||||
ThreadPool.CACHED.execute(() -> accept(credentials));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,7 +385,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
mTopBox = new VBox();
|
mTopBox = new VBox();
|
||||||
mTopBox.setSpacing(5);
|
mTopBox.setSpacing(5);
|
||||||
mTopBox.setPadding(new Insets(10,10,10,10));
|
mTopBox.setPadding(new Insets(10,10,10,10));
|
||||||
mTopBox.getChildren().addAll(getCredentialsBox(), getComboBoxPane());
|
mTopBox.getChildren().addAll(getCredentialsBox(), getLocationBox(), getComboBoxPane());
|
||||||
}
|
}
|
||||||
|
|
||||||
return mTopBox;
|
return mTopBox;
|
||||||
@@ -416,13 +447,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
mCredentialsBox = new HBox();
|
mCredentialsBox = new HBox();
|
||||||
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
|
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
|
||||||
mCredentialsBox.setSpacing(5.0);
|
mCredentialsBox.setSpacing(5.0);
|
||||||
Region leftFiller = new Region();
|
|
||||||
HBox.setHgrow(leftFiller, Priority.ALWAYS);
|
|
||||||
mCredentialsBox.getChildren().add(leftFiller);
|
|
||||||
mCredentialsBox.getChildren().add(getRRPremiumReqText());
|
mCredentialsBox.getChildren().add(getRRPremiumReqText());
|
||||||
Region rightFiller = new Region();
|
|
||||||
HBox.setHgrow(rightFiller, Priority.ALWAYS);
|
|
||||||
mCredentialsBox.getChildren().add(rightFiller);
|
|
||||||
mCredentialsBox.getChildren().add(new Label("User Name:"));
|
mCredentialsBox.getChildren().add(new Label("User Name:"));
|
||||||
mCredentialsBox.getChildren().add(getUserNameText());
|
mCredentialsBox.getChildren().add(getUserNameText());
|
||||||
mCredentialsBox.getChildren().add(new Label("Expires:"));
|
mCredentialsBox.getChildren().add(new Label("Expires:"));
|
||||||
@@ -446,8 +471,8 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
Bindings.and(
|
Bindings.and(
|
||||||
mRadioReference.availableProperty(),
|
mRadioReference.availableProperty(),
|
||||||
mRadioReference.premiumAccountProperty().not()));
|
mRadioReference.premiumAccountProperty().not()));
|
||||||
mRRStatusText.setTextFill(Color.ORANGERED);
|
//Inline style, because the theme stylesheets override a label's text fill
|
||||||
mRRStatusText.setStyle("-fx-font-weight: bold;"); // I guess
|
mRRStatusText.setStyle("-fx-text-fill: orangered; -fx-font-weight: bold;");
|
||||||
}
|
}
|
||||||
|
|
||||||
return mRRStatusText;
|
return mRRStatusText;
|
||||||
@@ -725,6 +750,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
if(selected != null)
|
if(selected != null)
|
||||||
{
|
{
|
||||||
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
|
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
|
||||||
|
refreshLocation();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -732,6 +758,111 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
return mCountyComboBox;
|
return mCountyComboBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row that shows the user's location and a button to change it
|
||||||
|
*/
|
||||||
|
private HBox getLocationBox()
|
||||||
|
{
|
||||||
|
if(mLocationBox == null)
|
||||||
|
{
|
||||||
|
mLocationBox = new HBox();
|
||||||
|
mLocationBox.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
mLocationBox.setSpacing(5.0);
|
||||||
|
|
||||||
|
Button setLocationButton = new Button("My Location:");
|
||||||
|
setLocationButton.setOnAction(event -> {
|
||||||
|
LocationDialog dialog = new LocationDialog(mRadioReference,
|
||||||
|
mUserPreferences.getRadioReferencePreference().getLocation(), getScene().getWindow());
|
||||||
|
//The map follows the location through the preference update
|
||||||
|
dialog.showAndWait().ifPresent(result -> {
|
||||||
|
mUserPreferences.getRadioReferencePreference().setLocation(result.location());
|
||||||
|
refreshLocation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mLocationBox.getChildren().addAll(setLocationButton, getLocationText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return mLocationBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Label getLocationText()
|
||||||
|
{
|
||||||
|
if(mLocationText == null)
|
||||||
|
{
|
||||||
|
mLocationText = new Label();
|
||||||
|
mLocationText.setTooltip(new Tooltip("Your location is shown on the map and used to find nearby Radio " +
|
||||||
|
"Reference frequencies. When it isn't set, the center of the selected county is used."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mLocationText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the location text: the user's location, or else the preferred county fallback, which is looked up in
|
||||||
|
* the background.
|
||||||
|
*/
|
||||||
|
private void refreshLocation()
|
||||||
|
{
|
||||||
|
long request = mLocationRequest.incrementAndGet();
|
||||||
|
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||||
|
|
||||||
|
if(location != null)
|
||||||
|
{
|
||||||
|
getLocationText().setText(location.describe());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String notRanked = "Not set";
|
||||||
|
|
||||||
|
if(mUserPreferences.getRadioReferencePreference().getPreferredCountyId() <= 0 ||
|
||||||
|
!mRadioReference.premiumAccountProperty().get())
|
||||||
|
{
|
||||||
|
getLocationText().setText(notRanked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocationText().setText("Not set");
|
||||||
|
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
String text;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UserLocation userLocation = mRadioReference.getUserLocation();
|
||||||
|
|
||||||
|
if(userLocation == null)
|
||||||
|
{
|
||||||
|
text = notRanked;
|
||||||
|
}
|
||||||
|
else if(userLocation.source() == UserLocation.Source.PREFERRED_COUNTY)
|
||||||
|
{
|
||||||
|
text = "Not set - using the center of " + userLocation.description();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//Set in the meantime - a newer refresh shows it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
mLog.warn("Unable to look up the preferred county location - " + rre.getMessage());
|
||||||
|
text = "Not set - unable to look up the selected county";
|
||||||
|
}
|
||||||
|
|
||||||
|
final String locationText = text;
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
//A newer refresh supersedes this one
|
||||||
|
if(mLocationRequest.get() == request)
|
||||||
|
{
|
||||||
|
getLocationText().setText(locationText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumer interface method for receiving login credentials
|
* Consumer interface method for receiving login credentials
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelException;
|
||||||
|
import io.github.dsheirer.module.decode.DecoderType;
|
||||||
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
|
import io.github.dsheirer.rrapi.RadioReferenceService;
|
||||||
|
import io.github.dsheirer.rrapi.type.Site;
|
||||||
|
import io.github.dsheirer.rrapi.type.SystemInformation;
|
||||||
|
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfigTuner;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfiguration;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.ConcurrentModificationException;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist actions for radio reference frequencies and trunked system sites: find the playlist channel that already
|
||||||
|
* decodes a frequency or site, enable or disable it, or add a channel and start it. A frequency or site that already
|
||||||
|
* has a playlist channel with the same protocol isn't added again.
|
||||||
|
*
|
||||||
|
* The add, enable and disable actions run in the background and report errors to the caller's error handler, on the
|
||||||
|
* calling thread pool: the handler shows the message on its own UI thread.
|
||||||
|
*/
|
||||||
|
public class RadioReferencePlaylistActions
|
||||||
|
{
|
||||||
|
private static final Logger mLog = LoggerFactory.getLogger(RadioReferencePlaylistActions.class);
|
||||||
|
private final PlaylistManager mPlaylistManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @param playlistManager for the channels, channel processing and the radio reference service
|
||||||
|
*/
|
||||||
|
public RadioReferencePlaylistActions(PlaylistManager playlistManager)
|
||||||
|
{
|
||||||
|
mPlaylistManager = playlistManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder types that count as the same protocol when looking for an existing channel: P25 Phase 1 and Phase 2
|
||||||
|
* (a Phase 2 system's control channel is often decoded with the Phase 1 decoder), and the LTR variants.
|
||||||
|
*/
|
||||||
|
public static Set<DecoderType> getProtocolFamily(DecoderType decoderType)
|
||||||
|
{
|
||||||
|
if(decoderType == null)
|
||||||
|
{
|
||||||
|
return EnumSet.noneOf(DecoderType.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch(decoderType)
|
||||||
|
{
|
||||||
|
case P25_PHASE1, P25_PHASE2 -> EnumSet.of(DecoderType.P25_PHASE1, DecoderType.P25_PHASE2);
|
||||||
|
case LTR, LTR_NET, PASSPORT -> EnumSet.of(DecoderType.LTR, DecoderType.LTR_NET, DecoderType.PASSPORT);
|
||||||
|
default -> EnumSet.of(decoderType);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder types for a site's control channel, from the site's radio reference protocol
|
||||||
|
* @return decoder types, or an empty set when sdrtrunk can't decode the site's control channel
|
||||||
|
*/
|
||||||
|
public static Set<DecoderType> getSiteDecoderTypes(SiteRecord site)
|
||||||
|
{
|
||||||
|
String protocol = site.protocol() != null ? site.protocol().toLowerCase() : "";
|
||||||
|
|
||||||
|
if(protocol.startsWith("project 25"))
|
||||||
|
{
|
||||||
|
return getProtocolFamily(DecoderType.P25_PHASE1);
|
||||||
|
}
|
||||||
|
else if(protocol.startsWith("dmr"))
|
||||||
|
{
|
||||||
|
return EnumSet.of(DecoderType.DMR);
|
||||||
|
}
|
||||||
|
else if(protocol.startsWith("nxdn"))
|
||||||
|
{
|
||||||
|
return EnumSet.of(DecoderType.NXDN);
|
||||||
|
}
|
||||||
|
else if(protocol.startsWith("ltr"))
|
||||||
|
{
|
||||||
|
return getProtocolFamily(DecoderType.LTR);
|
||||||
|
}
|
||||||
|
else if(protocol.startsWith("mpt"))
|
||||||
|
{
|
||||||
|
return EnumSet.of(DecoderType.MPT1327);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Motorola Type II, EDACS and other control channels aren't supported
|
||||||
|
return EnumSet.noneOf(DecoderType.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the site's system has no control channel, so each repeater has its own channel: LTR, where every
|
||||||
|
* repeater carries its own trunking data
|
||||||
|
*/
|
||||||
|
public static boolean isRepeaterSite(SiteRecord site)
|
||||||
|
{
|
||||||
|
return getSiteDecoderTypes(site).contains(DecoderType.LTR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if sdrtrunk can decode the site: its control channel, which radio reference lists, or its repeaters
|
||||||
|
*/
|
||||||
|
public static boolean isSupported(SiteRecord site)
|
||||||
|
{
|
||||||
|
return getUnsupportedReason(site) == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why sdrtrunk can't decode the site
|
||||||
|
* @return reason, or null when the site is supported
|
||||||
|
*/
|
||||||
|
public static String getUnsupportedReason(SiteRecord site)
|
||||||
|
{
|
||||||
|
if(getSiteDecoderTypes(site).isEmpty())
|
||||||
|
{
|
||||||
|
return (site.protocol() != null ? site.protocol() : "The protocol") + " isn't supported";
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isRepeaterSite(site) ? site.channels().isEmpty() : getControlFrequencies(site).isEmpty())
|
||||||
|
{
|
||||||
|
return isRepeaterSite(site) ? "Radio Reference lists no repeaters" :
|
||||||
|
"Radio Reference lists no control channel";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequencies of all of the site's channels
|
||||||
|
*/
|
||||||
|
public static List<Long> getSiteFrequencies(SiteRecord site)
|
||||||
|
{
|
||||||
|
List<Long> frequencies = new ArrayList<>();
|
||||||
|
|
||||||
|
for(SiteRecord.SiteChannel channel: site.channels())
|
||||||
|
{
|
||||||
|
frequencies.add(channel.frequencyHz());
|
||||||
|
}
|
||||||
|
|
||||||
|
return frequencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control and alternate control channel frequencies of the site, control channels first
|
||||||
|
*/
|
||||||
|
public static List<Long> getControlFrequencies(SiteRecord site)
|
||||||
|
{
|
||||||
|
List<Long> frequencies = new ArrayList<>();
|
||||||
|
|
||||||
|
for(String use: List.of("Control", "Alternate control"))
|
||||||
|
{
|
||||||
|
for(SiteRecord.SiteChannel channel: site.channels())
|
||||||
|
{
|
||||||
|
if(use.equals(channel.use()))
|
||||||
|
{
|
||||||
|
frequencies.add(channel.frequencyHz());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frequencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder type for a conventional candidate's radio reference mode, or null when sdrtrunk has no decoder for it
|
||||||
|
*/
|
||||||
|
public static DecoderType getDecoderType(FrequencyCandidate candidate)
|
||||||
|
{
|
||||||
|
ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode());
|
||||||
|
return mode.hasDecoderType() ? mode.getDecoderType() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist channel, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder types
|
||||||
|
* @return channel, or null
|
||||||
|
*/
|
||||||
|
public Channel findChannel(Collection<Long> frequencies, Set<DecoderType> decoderTypes)
|
||||||
|
{
|
||||||
|
return findChannel(getChannels(), frequencies, decoderTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel in the list, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder
|
||||||
|
* types
|
||||||
|
* @return channel, or null
|
||||||
|
*/
|
||||||
|
static Channel findChannel(List<Channel> channels, Collection<Long> frequencies, Set<DecoderType> decoderTypes)
|
||||||
|
{
|
||||||
|
if(frequencies.isEmpty() || decoderTypes.isEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(Channel channel: channels)
|
||||||
|
{
|
||||||
|
if(channel.isTrafficChannel() || channel.getDecodeConfiguration() == null ||
|
||||||
|
!decoderTypes.contains(channel.getDecodeConfiguration().getDecoderType()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(long frequency: getFrequencies(channel.getSourceConfiguration()))
|
||||||
|
{
|
||||||
|
if(frequencies.contains(frequency))
|
||||||
|
{
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist channel that decodes the site, tuned to one of its control channels
|
||||||
|
* @return channel, or null
|
||||||
|
*/
|
||||||
|
public Channel findSiteChannel(SiteRecord site)
|
||||||
|
{
|
||||||
|
return findChannel(getControlFrequencies(site), getSiteDecoderTypes(site));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist channels that decode the site: its control channel, or each of its repeaters that has a channel
|
||||||
|
*/
|
||||||
|
public List<Channel> findSiteChannels(SiteRecord site)
|
||||||
|
{
|
||||||
|
List<Channel> channels = new ArrayList<>();
|
||||||
|
|
||||||
|
if(isRepeaterSite(site))
|
||||||
|
{
|
||||||
|
List<Channel> playlist = getChannels();
|
||||||
|
|
||||||
|
for(long frequency: getSiteFrequencies(site))
|
||||||
|
{
|
||||||
|
Channel channel = findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site));
|
||||||
|
|
||||||
|
if(channel != null && !channels.contains(channel))
|
||||||
|
{
|
||||||
|
channels.add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Channel channel = findSiteChannel(site);
|
||||||
|
|
||||||
|
if(channel != null)
|
||||||
|
{
|
||||||
|
channels.add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequencies of a repeater site that don't have a playlist channel yet
|
||||||
|
*/
|
||||||
|
public List<Long> getMissingRepeaterFrequencies(SiteRecord site)
|
||||||
|
{
|
||||||
|
List<Long> missing = new ArrayList<>();
|
||||||
|
List<Channel> playlist = getChannels();
|
||||||
|
|
||||||
|
for(long frequency: getSiteFrequencies(site))
|
||||||
|
{
|
||||||
|
if(findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site)) == null)
|
||||||
|
{
|
||||||
|
missing.add(frequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the channels that aren't decoding, in the background
|
||||||
|
*/
|
||||||
|
public void enableAll(List<Channel> channels, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
for(Channel channel: channels)
|
||||||
|
{
|
||||||
|
if(!channel.isProcessing())
|
||||||
|
{
|
||||||
|
enable(channel, onError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the channels that are decoding, in the background
|
||||||
|
*/
|
||||||
|
public void disableAll(List<Channel> channels, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
for(Channel channel: channels)
|
||||||
|
{
|
||||||
|
if(channel.isProcessing())
|
||||||
|
{
|
||||||
|
disable(channel, onError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist channel that decodes the conventional frequency with the same protocol
|
||||||
|
* @return channel, or null
|
||||||
|
*/
|
||||||
|
public Channel findConventionalChannel(FrequencyCandidate candidate)
|
||||||
|
{
|
||||||
|
return findChannel(List.of(candidate.frequencyHz()), getProtocolFamily(getDecoderType(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts decoding the playlist channel, in the background
|
||||||
|
*/
|
||||||
|
public void enable(Channel channel, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(!channel.isProcessing())
|
||||||
|
{
|
||||||
|
mPlaylistManager.getChannelProcessingManager().start(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ChannelException ce)
|
||||||
|
{
|
||||||
|
mLog.error("Error starting channel [" + channel.getName() + "] - " + ce.getMessage());
|
||||||
|
onError.accept("Can't start " + channel.getShortTitle() + ": " + ce.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops decoding the playlist channel, in the background
|
||||||
|
*/
|
||||||
|
public void disable(Channel channel, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mPlaylistManager.getChannelProcessingManager().stop(channel);
|
||||||
|
}
|
||||||
|
catch(ChannelException ce)
|
||||||
|
{
|
||||||
|
mLog.error("Error stopping channel [" + channel.getName() + "] - " + ce.getMessage());
|
||||||
|
onError.accept("Can't stop " + channel.getShortTitle() + ": " + ce.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio reference details of a site, for creating its channels
|
||||||
|
*/
|
||||||
|
private record SiteDetails(RadioReferenceDecoder decoder, io.github.dsheirer.rrapi.type.System system,
|
||||||
|
SystemInformation systemInformation, Site site)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the site's system details from radio reference. Call from a background thread.
|
||||||
|
* @return details, or null after reporting the error
|
||||||
|
*/
|
||||||
|
private SiteDetails getSiteDetails(SiteRecord siteRecord, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RadioReference radioReference = mPlaylistManager.getRadioReference();
|
||||||
|
RadioReferenceService service = radioReference.getService();
|
||||||
|
SystemInformation systemInformation = service.getSystemInformation(siteRecord.systemId());
|
||||||
|
Site site = null;
|
||||||
|
|
||||||
|
for(Site candidate: service.getSites(siteRecord.systemId()))
|
||||||
|
{
|
||||||
|
if(candidate.getSiteId() == siteRecord.siteId())
|
||||||
|
{
|
||||||
|
site = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(systemInformation == null || site == null)
|
||||||
|
{
|
||||||
|
onError.accept("Radio Reference didn't provide the details of " + siteRecord.systemName() + " site " +
|
||||||
|
siteRecord.siteNumber());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioReferenceDecoder decoder = new RadioReferenceDecoder(radioReference.getUserPreferences(),
|
||||||
|
service.getTypesMap(), service.getFlavorsMap(), service.getVoicesMap(), service.getTagsMap());
|
||||||
|
io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System();
|
||||||
|
system.setSystemId(siteRecord.systemId());
|
||||||
|
system.setName(systemInformation.getName() != null ? systemInformation.getName() : siteRecord.systemName());
|
||||||
|
system.setTypeId(systemInformation.getTypeId());
|
||||||
|
system.setFlavorId(systemInformation.getFlavorId());
|
||||||
|
system.setVoiceId(systemInformation.getVoiceId());
|
||||||
|
return new SiteDetails(decoder, system, systemInformation, site);
|
||||||
|
}
|
||||||
|
catch(RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
mLog.error("Error getting radio reference site details - " + rre.getMessage());
|
||||||
|
onError.accept("Can't get the site details from Radio Reference: " + rre.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels.
|
||||||
|
* A site with a control channel gets one channel for its control channels. A repeater site (LTR) gets a channel
|
||||||
|
* for each repeater that doesn't have one yet, and all of its channels are started. Fetches the system details
|
||||||
|
* from radio reference in the background.
|
||||||
|
*/
|
||||||
|
public void addSite(SiteRecord siteRecord, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
addSite(siteRecord, null, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels, like
|
||||||
|
* {@link #addSite(SiteRecord, Consumer)}. A new control channel starts on the first frequency when it's one of the
|
||||||
|
* site's control or alternate control channels.
|
||||||
|
* @param firstFrequency to start the new control channel on, e.g. the frequency the user picked, or null
|
||||||
|
*/
|
||||||
|
public void addSite(SiteRecord siteRecord, Long firstFrequency, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
SiteDetails details = getSiteDetails(siteRecord, onError);
|
||||||
|
|
||||||
|
if(details == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isRepeaterSite(siteRecord))
|
||||||
|
{
|
||||||
|
List<Channel> channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(),
|
||||||
|
details.systemInformation(), details.site(), getMissingRepeaterFrequencies(siteRecord));
|
||||||
|
List<Channel> existing = findSiteChannels(siteRecord);
|
||||||
|
|
||||||
|
if(channels.isEmpty() && existing.isEmpty())
|
||||||
|
{
|
||||||
|
onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + " site " +
|
||||||
|
siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" + siteRecord.protocol() +
|
||||||
|
")" : ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enableAll(existing, onError);
|
||||||
|
|
||||||
|
//The channel model's list backs the playlist editor, so it's changed on the JavaFX thread
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
for(Channel channel: channels)
|
||||||
|
{
|
||||||
|
mPlaylistManager.getChannelModel().addChannel(channel);
|
||||||
|
enable(channel, onError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel channel = RadioReferenceChannelFactory.trunkedSite(details.decoder(), details.system(),
|
||||||
|
details.systemInformation(), details.site(), firstFrequency);
|
||||||
|
|
||||||
|
if(channel == null)
|
||||||
|
{
|
||||||
|
onError.accept("sdrtrunk can't decode the control channel of " + siteRecord.systemName() +
|
||||||
|
" site " + siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" +
|
||||||
|
siteRecord.protocol() + ")" : ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAndStart(channel, onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a playlist channel for one repeater of a repeater site (LTR) and starts it, or starts its existing channel
|
||||||
|
*/
|
||||||
|
public void addRepeater(SiteRecord siteRecord, long frequency, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.execute(() -> {
|
||||||
|
SiteDetails details = getSiteDetails(siteRecord, onError);
|
||||||
|
|
||||||
|
if(details == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Channel> channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(),
|
||||||
|
details.systemInformation(), details.site(), List.of(frequency));
|
||||||
|
|
||||||
|
if(channels.isEmpty())
|
||||||
|
{
|
||||||
|
onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + (siteRecord.protocol() != null ?
|
||||||
|
" (" + siteRecord.protocol() + ")" : ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAndStart(channels.get(0), onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a playlist channel for the conventional frequency and starts it, or starts the existing channel
|
||||||
|
*/
|
||||||
|
public void addConventional(FrequencyCandidate candidate, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode());
|
||||||
|
String[] source = candidate.source() != null ? candidate.source().split(" › ") : new String[0];
|
||||||
|
//The source is owner, category and subcategory: the channel's system is the owner, the site the subcategory
|
||||||
|
String system = source.length > 0 ? source[0] : null;
|
||||||
|
String site = source.length > 2 ? source[2] : source.length > 1 ? source[1] : null;
|
||||||
|
String name = candidate.alphaTag() != null ? candidate.alphaTag() : candidate.title();
|
||||||
|
Channel channel = RadioReferenceChannelFactory.conventional(mode, candidate.frequencyHz(), system, site, name);
|
||||||
|
|
||||||
|
if(channel == null)
|
||||||
|
{
|
||||||
|
onError.accept("sdrtrunk can't decode " + (candidate.mode() != null ? candidate.mode() : "the mode of") +
|
||||||
|
" " + candidate.title());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAndStart(channel, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the channel to the playlist and starts it, unless the playlist already has a channel for its frequencies
|
||||||
|
* and protocol, which is started instead
|
||||||
|
*/
|
||||||
|
private void addAndStart(Channel channel, Consumer<String> onError)
|
||||||
|
{
|
||||||
|
Channel existing = findChannel(getFrequencies(channel.getSourceConfiguration()),
|
||||||
|
getProtocolFamily(channel.getDecodeConfiguration().getDecoderType()));
|
||||||
|
|
||||||
|
if(existing != null)
|
||||||
|
{
|
||||||
|
enable(existing, onError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//The channel model's list backs the playlist editor, so it's changed on the JavaFX thread
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
mPlaylistManager.getChannelModel().addChannel(channel);
|
||||||
|
enable(channel, onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy of the playlist channels, safe to read from any thread
|
||||||
|
*/
|
||||||
|
private List<Channel> getChannels()
|
||||||
|
{
|
||||||
|
for(int attempt = 0; attempt < 3; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new ArrayList<>(mPlaylistManager.getChannelModel().getChannels());
|
||||||
|
}
|
||||||
|
catch(ConcurrentModificationException e)
|
||||||
|
{
|
||||||
|
//The playlist changed while copying - try again
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tuned frequencies of a channel source
|
||||||
|
*/
|
||||||
|
public static List<Long> getFrequencies(SourceConfiguration sourceConfiguration)
|
||||||
|
{
|
||||||
|
if(sourceConfiguration instanceof SourceConfigTuner tuner)
|
||||||
|
{
|
||||||
|
return List.of(tuner.getFrequency());
|
||||||
|
}
|
||||||
|
else if(sourceConfiguration instanceof SourceConfigTunerMultipleFrequency multiple)
|
||||||
|
{
|
||||||
|
return new ArrayList<>(multiple.getFrequencies());
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,118 +247,7 @@ public class SiteEditor extends GridPane
|
|||||||
*/
|
*/
|
||||||
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
||||||
{
|
{
|
||||||
if(decoderType == null)
|
return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, systemInformation);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch(decoderType)
|
|
||||||
{
|
|
||||||
case DMR:
|
|
||||||
DecodeConfigDMR dmr = new DecodeConfigDMR();
|
|
||||||
List<TimeslotFrequency> timeslotFrequencies = mRadioReferenceDecoder
|
|
||||||
.getTimeslotFrequencies(systemInformation, site);
|
|
||||||
dmr.setTimeslotMap(timeslotFrequencies);
|
|
||||||
return dmr;
|
|
||||||
case NXDN:
|
|
||||||
Flavor flavor = mRadioReferenceDecoder.getFlavor(systemInformation);
|
|
||||||
|
|
||||||
TransmissionMode mode = TransmissionMode.M4800;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 9600 bps Flavors
|
|
||||||
* ----------------------------
|
|
||||||
* 29 - NEXEDGE 9600
|
|
||||||
* 45 - Conventional Networked
|
|
||||||
*
|
|
||||||
* 4800 bps Flavors
|
|
||||||
* ---------------------------
|
|
||||||
* 11 - Narrowband Networked
|
|
||||||
* 42 - NEXEDGE 4800
|
|
||||||
* 44 - Icom IDAS Type C
|
|
||||||
*
|
|
||||||
* Type-D (also 4800 bps) Flavors
|
|
||||||
* ---------------------------
|
|
||||||
* 36 - Icom IDAS Type D
|
|
||||||
* 47 - Kenwood Type D
|
|
||||||
*/
|
|
||||||
mode = switch(flavor.getName())
|
|
||||||
{
|
|
||||||
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
|
|
||||||
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
|
|
||||||
default -> TransmissionMode.M4800;
|
|
||||||
};
|
|
||||||
|
|
||||||
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
|
|
||||||
List<ChannelFrequency> channelMap = mRadioReferenceDecoder.getChannelMap(systemInformation, site);
|
|
||||||
nxdn.setChannelMap(channelMap);
|
|
||||||
return nxdn;
|
|
||||||
case P25_PHASE1:
|
|
||||||
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
|
|
||||||
|
|
||||||
if(mRadioReferenceDecoder.isLSM(site))
|
|
||||||
{
|
|
||||||
if(p1config instanceof DecodeConfigP25Phase1)
|
|
||||||
{
|
|
||||||
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p1config;
|
|
||||||
case P25_PHASE2:
|
|
||||||
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
|
|
||||||
|
|
||||||
int nac = 0;
|
|
||||||
|
|
||||||
if(site != null && site.getNac() != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
nac = Integer.parseInt(site.getNac(), 16);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
//Do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int wacn = 0;
|
|
||||||
int system = 0;
|
|
||||||
|
|
||||||
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
|
|
||||||
!systemInformation.getRadioNetworks().isEmpty())
|
|
||||||
{
|
|
||||||
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
|
|
||||||
|
|
||||||
if(radioNetwork.getWacn() != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
//Do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(radioNetwork.getSystemId() != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
//Do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
|
||||||
return config;
|
|
||||||
default:
|
|
||||||
return DecoderFactory.getDecodeConfiguration(decoderType);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,7 +510,7 @@ public class SiteEditor extends GridPane
|
|||||||
if(controls.size() == 1)
|
if(controls.size() == 1)
|
||||||
{
|
{
|
||||||
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||||
sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6));
|
sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz());
|
||||||
channel.setSourceConfiguration(sourceConfigTuner);
|
channel.setSourceConfiguration(sourceConfigTuner);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -630,7 +519,7 @@ public class SiteEditor extends GridPane
|
|||||||
|
|
||||||
for(SiteFrequency control: controls)
|
for(SiteFrequency control: controls)
|
||||||
{
|
{
|
||||||
sourceConfig.addFrequency((long)(control.getFrequency() * 1E6));
|
sourceConfig.addFrequency(control.getFrequencyHz());
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.setSourceConfiguration(sourceConfig);
|
channel.setSourceConfiguration(sourceConfig);
|
||||||
@@ -856,7 +745,7 @@ public class SiteEditor extends GridPane
|
|||||||
|
|
||||||
private static long getFrequency(SiteFrequency siteFrequency)
|
private static long getFrequency(SiteFrequency siteFrequency)
|
||||||
{
|
{
|
||||||
return (long)(siteFrequency.getFrequency() * 1E6);
|
return siteFrequency.getFrequencyHz();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -139,8 +139,9 @@ public class SystemEditor extends VBox
|
|||||||
|
|
||||||
if(systems != null && !systems.isEmpty())
|
if(systems != null && !systems.isEmpty())
|
||||||
{
|
{
|
||||||
Collections.sort(systems, new SystemComparator());
|
//The systems list belongs to a cached state or county info shared with other callers, so sort the items
|
||||||
getSystemComboBox().getItems().addAll(systems);
|
getSystemComboBox().getItems().addAll(systems);
|
||||||
|
getSystemComboBox().getItems().sort(new SystemComparator());
|
||||||
|
|
||||||
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
|
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
|
||||||
|
|
||||||
@@ -302,7 +303,16 @@ public class SystemEditor extends VBox
|
|||||||
//have a county ID of 99,999 as observed from API (undocumented).
|
//have a county ID of 99,999 as observed from API (undocumented).
|
||||||
if(countyId > 0 && countyId != 99999)
|
if(countyId > 0 && countyId != 99999)
|
||||||
{
|
{
|
||||||
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
|
//The county name is only for display, so a failed lookup shouldn't fail the system load
|
||||||
|
try
|
||||||
|
{
|
||||||
|
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
|
||||||
|
}
|
||||||
|
catch(RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
mLog.warn("Unable to retrieve county [" + countyId + "] for site [" +
|
||||||
|
site.getSiteNumber() + "] of system [" + system.getSystemId() + "] - " + rre.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enrichedSites.add(new EnrichedSite(site, countyInfo));
|
enrichedSites.add(new EnrichedSite(site, countyInfo));
|
||||||
|
|||||||
@@ -264,9 +264,11 @@ public class SystemTalkgroupSelectionEditor extends GridPane
|
|||||||
|
|
||||||
if(talkgroups != null && !talkgroups.isEmpty())
|
if(talkgroups != null && !talkgroups.isEmpty())
|
||||||
{
|
{
|
||||||
Collections.sort(talkgroups, Comparator.comparingInt(Talkgroup::getDecimalValue));
|
//The talkgroup list is shared by the caching service and is read-only, so sort a copy
|
||||||
|
List<Talkgroup> sorted = new ArrayList<>(talkgroups);
|
||||||
|
sorted.sort(Comparator.comparingInt(Talkgroup::getDecimalValue));
|
||||||
|
|
||||||
for(Talkgroup talkgroup: talkgroups)
|
for(Talkgroup talkgroup: sorted)
|
||||||
{
|
{
|
||||||
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
|
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text and placement of the cursor readout: the frequency under the mouse, and the level in the spectrum, in a box
|
||||||
|
* beside the mouse. The frequency layer draws the readout. A layer that replaces the readout with a larger callout
|
||||||
|
* (see {@link SpectralDisplayLayer#replacesCursorReadout}) uses the same text and placement, so the readout appears to
|
||||||
|
* grow into the callout.
|
||||||
|
*/
|
||||||
|
public final class CursorReadout
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Horizontal distance between the mouse and the box
|
||||||
|
*/
|
||||||
|
public static final double GAP_X = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical distance between the box and the mouse below it
|
||||||
|
*/
|
||||||
|
public static final double GAP_Y = 4;
|
||||||
|
|
||||||
|
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
||||||
|
private static final DecimalFormat LEVEL_FORMAT = new DecimalFormat("0.0");
|
||||||
|
|
||||||
|
private CursorReadout()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readout lines: the frequency under the mouse, and the level when the mouse is over the spectrum plot
|
||||||
|
*/
|
||||||
|
public static List<String> lines(DisplayCursor cursor, RenderArea area, SpectralDisplayModel model)
|
||||||
|
{
|
||||||
|
List<String> lines = new ArrayList<>(2);
|
||||||
|
lines.add(FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz");
|
||||||
|
|
||||||
|
if(cursor.section() == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
|
||||||
|
{
|
||||||
|
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
|
||||||
|
lines.add(LEVEL_FORMAT.format(decibels) + " dBFS");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left edge of a box beside the mouse: to the right of the mouse, or to the left when it doesn't fit
|
||||||
|
*/
|
||||||
|
public static double x(DisplayCursor cursor, double width, RenderArea area)
|
||||||
|
{
|
||||||
|
double x = cursor.x() + GAP_X;
|
||||||
|
|
||||||
|
if(x + width > area.width())
|
||||||
|
{
|
||||||
|
x = cursor.x() - GAP_X - width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top edge of a box above the mouse, kept inside the plot
|
||||||
|
*/
|
||||||
|
public static double y(DisplayCursor cursor, double height, RenderArea area)
|
||||||
|
{
|
||||||
|
return Math.max(0, Math.min(area.plotHeight() - height, cursor.y() - height - GAP_Y));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,6 +284,24 @@ public class SpectralDisplay extends StackPane
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if a layer draws its own callout in place of the cursor readout for the mouse position.
|
||||||
|
* @param cursor mouse position
|
||||||
|
* @param area of the section under the mouse
|
||||||
|
*/
|
||||||
|
public boolean isCursorReadoutReplaced(DisplayCursor cursor, RenderArea area)
|
||||||
|
{
|
||||||
|
for(SpectralDisplayLayer layer: mLayers)
|
||||||
|
{
|
||||||
|
if(layer.replacesCursorReadout(cursor, area))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layers in drawing order.
|
* Layers in drawing order.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ public abstract class SpectralDisplayLayer
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if this layer draws its own callout in place of the cursor readout for the mouse position, so the
|
||||||
|
* frequency layer skips the readout. The callout should start with the {@link CursorReadout} lines and use its
|
||||||
|
* placement. Called on the JavaFX application thread before the layers draw the section under the mouse.
|
||||||
|
* @param cursor mouse position
|
||||||
|
* @param area of the section under the mouse
|
||||||
|
*/
|
||||||
|
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context menu items for a right-click at the frequency.
|
* Context menu items for a right-click at the frequency.
|
||||||
* @param section that was clicked
|
* @param section that was clicked
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ package io.github.dsheirer.gui.spectrum;
|
|||||||
|
|
||||||
import io.github.dsheirer.controller.channel.ChannelModel;
|
import io.github.dsheirer.controller.channel.ChannelModel;
|
||||||
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||||
|
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||||
import io.github.dsheirer.gui.spectrum.layer.ChannelLayer;
|
import io.github.dsheirer.gui.spectrum.layer.ChannelLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.layer.RadioReferenceLayer;
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
import io.github.dsheirer.properties.SystemProperties;
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
import io.github.dsheirer.source.ISourceEventProcessor;
|
import io.github.dsheirer.source.ISourceEventProcessor;
|
||||||
@@ -56,9 +58,11 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
|
|||||||
public static final String SETTINGS_PREFIX = "spectral.display";
|
public static final String SETTINGS_PREFIX = "spectral.display";
|
||||||
private static final String CHANNEL_DISPLAY = "channel.display";
|
private static final String CHANNEL_DISPLAY = "channel.display";
|
||||||
private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details";
|
private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details";
|
||||||
|
private static final String RADIO_REFERENCE_HOVER = "radioreference.hover";
|
||||||
private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings";
|
private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings";
|
||||||
|
|
||||||
private final ChannelLayer mChannelLayer;
|
private final ChannelLayer mChannelLayer;
|
||||||
|
private final RadioReferenceLayer mRadioReferenceLayer;
|
||||||
private final DiscoveredTunerModel mDiscoveredTunerModel;
|
private final DiscoveredTunerModel mDiscoveredTunerModel;
|
||||||
private final Object mTunerLock = new Object();
|
private final Object mTunerLock = new Object();
|
||||||
private final ReadOnlyObjectWrapper<Tuner> mDisplayedTuner = new ReadOnlyObjectWrapper<>();
|
private final ReadOnlyObjectWrapper<Tuner> mDisplayedTuner = new ReadOnlyObjectWrapper<>();
|
||||||
@@ -75,9 +79,12 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
|
|||||||
public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel,
|
public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel,
|
||||||
boolean saveSettings)
|
boolean saveSettings)
|
||||||
{
|
{
|
||||||
this(playlistManager != null ? playlistManager.getChannelModel() : null,
|
this(new ChannelLayer(playlistManager != null ? playlistManager.getChannelModel() : null,
|
||||||
playlistManager != null ? playlistManager.getChannelProcessingManager() : null,
|
playlistManager != null ? playlistManager.getChannelProcessingManager() : null),
|
||||||
discoveredTunerModel, saveSettings);
|
new RadioReferenceLayer(playlistManager != null && playlistManager.getRadioReference() != null ?
|
||||||
|
playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||||
|
playlistManager != null ? new RadioReferencePlaylistActions(playlistManager) : null),
|
||||||
|
discoveredTunerModel, saveSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,14 +97,17 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
|
|||||||
public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager,
|
public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager,
|
||||||
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
||||||
{
|
{
|
||||||
this(new ChannelLayer(channelModel, channelProcessingManager), discoveredTunerModel, saveSettings);
|
this(new ChannelLayer(channelModel, channelProcessingManager), new RadioReferenceLayer(null, null),
|
||||||
|
discoveredTunerModel, saveSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TunerSpectralDisplay(ChannelLayer channelLayer, DiscoveredTunerModel discoveredTunerModel,
|
private TunerSpectralDisplay(ChannelLayer channelLayer, RadioReferenceLayer radioReferenceLayer,
|
||||||
boolean saveSettings)
|
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
||||||
{
|
{
|
||||||
super(SETTINGS_PREFIX, saveSettings, channelLayer);
|
//The radio reference layer draws after the channel layer, so its card is on top
|
||||||
|
super(SETTINGS_PREFIX, saveSettings, channelLayer, radioReferenceLayer);
|
||||||
mChannelLayer = channelLayer;
|
mChannelLayer = channelLayer;
|
||||||
|
mRadioReferenceLayer = radioReferenceLayer;
|
||||||
mDiscoveredTunerModel = discoveredTunerModel;
|
mDiscoveredTunerModel = discoveredTunerModel;
|
||||||
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
|
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
|
||||||
|
|
||||||
@@ -108,6 +118,8 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
|
|||||||
mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true));
|
mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true));
|
||||||
mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name()));
|
mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name()));
|
||||||
mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv));
|
mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv));
|
||||||
|
mRadioReferenceLayer.hoverCandidatesProperty().set(properties.get(key(RADIO_REFERENCE_HOVER), true));
|
||||||
|
mRadioReferenceLayer.hoverCandidatesProperty().addListener((ob, ov, nv) -> save(RADIO_REFERENCE_HOVER, nv));
|
||||||
|
|
||||||
installConfigPanel();
|
installConfigPanel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.spectrum.layer;
|
|||||||
|
|
||||||
import io.github.dsheirer.controller.channel.Channel;
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
import io.github.dsheirer.controller.channel.ChannelEvent;
|
import io.github.dsheirer.controller.channel.ChannelEvent;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelException;
|
||||||
import io.github.dsheirer.controller.channel.ChannelModel;
|
import io.github.dsheirer.controller.channel.ChannelModel;
|
||||||
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||||
import io.github.dsheirer.eventbus.MyEventBus;
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
@@ -34,18 +35,22 @@ import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
|||||||
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
||||||
import io.github.dsheirer.sample.Listener;
|
import io.github.dsheirer.sample.Listener;
|
||||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import java.text.DecimalFormat;
|
import java.text.DecimalFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.ConcurrentModificationException;
|
import java.util.ConcurrentModificationException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.geometry.VPos;
|
import javafx.geometry.VPos;
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
import javafx.scene.control.ComboBox;
|
import javafx.scene.control.ComboBox;
|
||||||
import javafx.scene.control.MenuItem;
|
import javafx.scene.control.MenuItem;
|
||||||
import javafx.scene.control.Tooltip;
|
import javafx.scene.control.Tooltip;
|
||||||
@@ -717,6 +722,12 @@ public class ChannelLayer extends SpectralDisplayLayer
|
|||||||
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
|
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
|
||||||
frequency <= tunerChannel.getMaxFrequency())
|
frequency <= tunerChannel.getMaxFrequency())
|
||||||
{
|
{
|
||||||
|
//Traffic channels are started and stopped by their decoder, not from the playlist
|
||||||
|
if(!channel.isTrafficChannel() && mChannelProcessingManager != null)
|
||||||
|
{
|
||||||
|
items.add(createEnableItem(channel));
|
||||||
|
}
|
||||||
|
|
||||||
MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle());
|
MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle());
|
||||||
item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
|
item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
|
||||||
items.add(item);
|
items.add(item);
|
||||||
@@ -728,6 +739,52 @@ public class ChannelLayer extends SpectralDisplayLayer
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu item that starts the channel when it's disabled, or stops it when it's enabled, in the background
|
||||||
|
*/
|
||||||
|
private MenuItem createEnableItem(Channel channel)
|
||||||
|
{
|
||||||
|
boolean processing = channel.isProcessing();
|
||||||
|
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
|
||||||
|
item.setOnAction(e -> ThreadPool.CACHED.execute(() -> {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(processing)
|
||||||
|
{
|
||||||
|
mChannelProcessingManager.stop(channel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mChannelProcessingManager.start(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ChannelException ce)
|
||||||
|
{
|
||||||
|
showError((processing ? "Can't stop " : "Can't start ") + channel.getShortTitle(), ce.getMessage());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an error alert over the display. Can be called from any thread.
|
||||||
|
*/
|
||||||
|
private void showError(String header, String message)
|
||||||
|
{
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
|
||||||
|
alert.setTitle("Channel");
|
||||||
|
alert.setHeaderText(header);
|
||||||
|
|
||||||
|
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
|
||||||
|
{
|
||||||
|
alert.initOwner(getSpectralDisplay().getScene().getWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel box screen position.
|
* Channel box screen position.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
package io.github.dsheirer.gui.spectrum.layer;
|
package io.github.dsheirer.gui.spectrum.layer;
|
||||||
|
|
||||||
|
import io.github.dsheirer.gui.spectrum.CursorReadout;
|
||||||
import io.github.dsheirer.gui.spectrum.DisplayCursor;
|
import io.github.dsheirer.gui.spectrum.DisplayCursor;
|
||||||
import io.github.dsheirer.gui.spectrum.DisplaySection;
|
import io.github.dsheirer.gui.spectrum.DisplaySection;
|
||||||
import io.github.dsheirer.gui.spectrum.RenderArea;
|
import io.github.dsheirer.gui.spectrum.RenderArea;
|
||||||
@@ -26,6 +27,7 @@ import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
|
|||||||
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
||||||
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
||||||
import java.text.DecimalFormat;
|
import java.text.DecimalFormat;
|
||||||
|
import java.util.List;
|
||||||
import javafx.geometry.VPos;
|
import javafx.geometry.VPos;
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
@@ -44,8 +46,6 @@ public class FrequencyLayer extends SpectralDisplayLayer
|
|||||||
private static final double INSET_PADDING = 8.0;
|
private static final double INSET_PADDING = 8.0;
|
||||||
private static final double MAJOR_TICK = 6.0;
|
private static final double MAJOR_TICK = 6.0;
|
||||||
private static final double MINOR_TICK = 3.0;
|
private static final double MINOR_TICK = 3.0;
|
||||||
private static final DecimalFormat CURSOR_FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
|
||||||
private static final DecimalFormat CURSOR_LEVEL_FORMAT = new DecimalFormat("0.0");
|
|
||||||
|
|
||||||
private final Text mMeasure = new Text();
|
private final Text mMeasure = new Text();
|
||||||
private final double mTextHeight;
|
private final double mTextHeight;
|
||||||
@@ -178,49 +178,34 @@ public class FrequencyLayer extends SpectralDisplayLayer
|
|||||||
gc.setLineWidth(1.0);
|
gc.setLineWidth(1.0);
|
||||||
gc.strokeLine(x, 0, x, area.plotHeight());
|
gc.strokeLine(x, 0, x, area.plotHeight());
|
||||||
|
|
||||||
if(cursor.section() != section)
|
//Another layer may show the readout lines in a larger callout
|
||||||
|
if(cursor.section() != section || getSpectralDisplay().isCursorReadoutReplaced(cursor, area))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String frequency = CURSOR_FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz";
|
List<String> lines = CursorReadout.lines(cursor, area, model);
|
||||||
String level = null;
|
double textWidth = 0;
|
||||||
|
|
||||||
if(section == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
|
for(String line: lines)
|
||||||
{
|
{
|
||||||
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
|
textWidth = Math.max(textWidth, measure(line));
|
||||||
level = CURSOR_LEVEL_FORMAT.format(decibels) + " dBFS";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double textWidth = measure(frequency);
|
|
||||||
|
|
||||||
if(level != null)
|
|
||||||
{
|
|
||||||
textWidth = Math.max(textWidth, measure(level));
|
|
||||||
}
|
|
||||||
|
|
||||||
double lines = level != null ? 2 : 1;
|
|
||||||
double boxWidth = textWidth + 8;
|
double boxWidth = textWidth + 8;
|
||||||
double boxHeight = (mTextHeight * lines) + 4;
|
double boxHeight = (mTextHeight * lines.size()) + 4;
|
||||||
double boxX = cursor.x() + 8;
|
double boxX = CursorReadout.x(cursor, boxWidth, area);
|
||||||
|
double boxY = CursorReadout.y(cursor, boxHeight, area);
|
||||||
if(boxX + boxWidth > area.width())
|
|
||||||
{
|
|
||||||
boxX = cursor.x() - 8 - boxWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
double boxY = Math.max(0, Math.min(area.plotHeight() - boxHeight, cursor.y() - boxHeight - 4));
|
|
||||||
gc.setFill(getTheme().getBackground(0.7));
|
gc.setFill(getTheme().getBackground(0.7));
|
||||||
gc.fillRoundRect(boxX, boxY, boxWidth, boxHeight, 6, 6);
|
gc.fillRoundRect(boxX, boxY, boxWidth, boxHeight, 6, 6);
|
||||||
gc.setFill(color);
|
gc.setFill(color);
|
||||||
gc.setFont(FONT);
|
gc.setFont(FONT);
|
||||||
gc.setTextAlign(TextAlignment.LEFT);
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
gc.setTextBaseline(VPos.TOP);
|
gc.setTextBaseline(VPos.TOP);
|
||||||
gc.fillText(frequency, boxX + 4, boxY + 2);
|
|
||||||
|
|
||||||
if(level != null)
|
for(int index = 0; index < lines.size(); index++)
|
||||||
{
|
{
|
||||||
gc.fillText(level, boxX + 4, boxY + 2 + mTextHeight);
|
gc.fillText(lines.get(index), boxX + 4, boxY + 2 + (index * mTextHeight));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum.layer;
|
||||||
|
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
|
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||||
|
import io.github.dsheirer.module.decode.DecoderType;
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
|
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.MenuItem;
|
||||||
|
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
|
||||||
|
import io.github.dsheirer.gui.spectrum.CursorReadout;
|
||||||
|
import io.github.dsheirer.gui.spectrum.DisplayCursor;
|
||||||
|
import io.github.dsheirer.gui.spectrum.DisplaySection;
|
||||||
|
import io.github.dsheirer.gui.spectrum.RenderArea;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
|
||||||
|
import io.github.dsheirer.service.radioreference.DistanceFormat;
|
||||||
|
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.RankedCandidate;
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import javafx.animation.PauseTransition;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.FontWeight;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextAlignment;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio reference candidates layer. When the mouse rests over the spectrum or the waterfall, shows a card next to the
|
||||||
|
* mouse with the radio reference frequencies near the frequency under the mouse, nearest to the user's location first.
|
||||||
|
*
|
||||||
|
* The candidates come from the radio reference frequency index, which needs a premium account and the user's location.
|
||||||
|
* Looking up a frequency doesn't call the web service.
|
||||||
|
*/
|
||||||
|
public class RadioReferenceLayer extends SpectralDisplayLayer
|
||||||
|
{
|
||||||
|
private static final Duration HOVER_DELAY = Duration.millis(400);
|
||||||
|
private static final Font FONT = Font.font(11);
|
||||||
|
private static final Font TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11);
|
||||||
|
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
||||||
|
private static final long MIN_TOLERANCE_HZ = 3_000;
|
||||||
|
private static final double TOLERANCE_PIXELS = 3.0;
|
||||||
|
private static final int MAX_CANDIDATES = 6;
|
||||||
|
private static final double MAX_TEXT_WIDTH = 380;
|
||||||
|
private static final double PADDING = 6;
|
||||||
|
private static final double GAP = 5;
|
||||||
|
//Space beside the text and below the readout lines, as in the cursor readout, so the readout lines stay in place
|
||||||
|
private static final double PADDING_X = 4;
|
||||||
|
//Space kept between the card and the edges of the section
|
||||||
|
private static final double EDGE_MARGIN = 2;
|
||||||
|
private static final double READOUT_PADDING = 2;
|
||||||
|
private static final String HEADER = "Radio Reference";
|
||||||
|
|
||||||
|
private final RadioReferenceFrequencyIndex mIndex;
|
||||||
|
private final RadioReferencePlaylistActions mActions;
|
||||||
|
private final BooleanProperty mHoverCandidates = new SimpleBooleanProperty(true);
|
||||||
|
private final Text mMeasure = new Text();
|
||||||
|
private final double mTextHeight;
|
||||||
|
private PauseTransition mHoverTimer;
|
||||||
|
private DisplaySection mHoverSection;
|
||||||
|
private double mHoverX = Double.NaN;
|
||||||
|
private double mHoverY = Double.NaN;
|
||||||
|
private boolean mHoverReady;
|
||||||
|
private long mResultsFrequency = Long.MIN_VALUE;
|
||||||
|
private long mResultsTolerance;
|
||||||
|
private List<RankedCandidate> mResults = Collections.emptyList();
|
||||||
|
private Card mCard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate text of the card, measured once per lookup
|
||||||
|
* @param entries lines of each candidate: the title, then the details
|
||||||
|
* @param more line, or null
|
||||||
|
* @param textWidth of the widest line
|
||||||
|
* @param lineCount of the header, candidate and more lines
|
||||||
|
* @param maxTextWidth that the lines were shortened to
|
||||||
|
* @param maxHeight that the card was limited to
|
||||||
|
* @param readoutLines that the card was sized for
|
||||||
|
*/
|
||||||
|
private record Card(List<List<String>> entries, String more, double textWidth, int lineCount,
|
||||||
|
double maxTextWidth, double maxHeight, int readoutLines)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param index of the radio reference frequencies near the user, or null for a layer that shows nothing
|
||||||
|
* @param actions to add candidates to the playlist or enable their channels, or null for no context menu items
|
||||||
|
*/
|
||||||
|
public RadioReferenceLayer(RadioReferenceFrequencyIndex index, RadioReferencePlaylistActions actions)
|
||||||
|
{
|
||||||
|
mIndex = index;
|
||||||
|
mActions = actions;
|
||||||
|
mMeasure.setFont(FONT);
|
||||||
|
mMeasure.setText("Ag");
|
||||||
|
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||||
|
mHoverCandidates.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the card of radio reference candidates when the mouse rests over the display
|
||||||
|
*/
|
||||||
|
public BooleanProperty hoverCandidatesProperty()
|
||||||
|
{
|
||||||
|
return mHoverCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void attached()
|
||||||
|
{
|
||||||
|
if(mIndex != null)
|
||||||
|
{
|
||||||
|
mIndex.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
if(mHoverTimer != null)
|
||||||
|
{
|
||||||
|
mHoverTimer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getConfigurationTitle()
|
||||||
|
{
|
||||||
|
return "Radio Reference";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ConfigurationRow> getConfigurationRows()
|
||||||
|
{
|
||||||
|
if(mIndex == null)
|
||||||
|
{
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigurationRow hover = ConfigurationRow.toggle("Hover Candidates", mHoverCandidates);
|
||||||
|
Tooltip.install(hover.control(), new Tooltip("When the mouse rests over a signal, show the Radio Reference " +
|
||||||
|
"frequencies near it, nearest to your location first"));
|
||||||
|
Label status = new Label();
|
||||||
|
status.textProperty().bind(mIndex.statusProperty());
|
||||||
|
status.setWrapText(true);
|
||||||
|
status.setMaxWidth(220);
|
||||||
|
return List.of(hover, new ConfigurationRow("Nearby", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context menu items for the candidates at the frequency, in the card's order: add a candidate to the playlist and
|
||||||
|
* start it, or enable or disable the playlist channel that already decodes it. One item per site, and per
|
||||||
|
* frequency and protocol, so an item never adds a duplicate channel.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
|
||||||
|
{
|
||||||
|
if(mIndex == null || mActions == null)
|
||||||
|
{
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
//The card's candidates when the mouse hasn't moved since the card was shown
|
||||||
|
List<RankedCandidate> candidates = frequency == mResultsFrequency ? mResults :
|
||||||
|
mIndex.lookup(frequency, mResultsTolerance > 0 ? mResultsTolerance : MIN_TOLERANCE_HZ);
|
||||||
|
List<MenuItem> items = new ArrayList<>();
|
||||||
|
Set<String> offered = new HashSet<>();
|
||||||
|
|
||||||
|
for(RankedCandidate ranked: candidates)
|
||||||
|
{
|
||||||
|
if(offered.size() >= MAX_CANDIDATES)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
FrequencyCandidate candidate = ranked.candidate();
|
||||||
|
|
||||||
|
if(candidate.isSiteFrequency())
|
||||||
|
{
|
||||||
|
SiteRecord site = mIndex.getSite(candidate.systemId(), candidate.siteId());
|
||||||
|
|
||||||
|
//A repeater site (LTR) has a channel per repeater, otherwise one channel for the site
|
||||||
|
String key = site == null ? null : RadioReferencePlaylistActions.isRepeaterSite(site) ?
|
||||||
|
"repeater:" + candidate.frequencyHz() : "site:" + site.systemId() + ":" + site.siteId();
|
||||||
|
|
||||||
|
if(site != null && offered.add(key))
|
||||||
|
{
|
||||||
|
addSiteItem(items, site, candidate, frequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DecoderType decoderType = RadioReferencePlaylistActions.getDecoderType(candidate);
|
||||||
|
Set<DecoderType> family = RadioReferencePlaylistActions.getProtocolFamily(decoderType);
|
||||||
|
String protocol = family.isEmpty() ? String.valueOf(candidate.mode()) : family.iterator().next().name();
|
||||||
|
|
||||||
|
if(offered.add("frequency:" + candidate.frequencyHz() + ":" + protocol))
|
||||||
|
{
|
||||||
|
addConventionalItem(items, candidate, decoderType, frequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!items.isEmpty())
|
||||||
|
{
|
||||||
|
MenuItem header = new MenuItem("Radio Reference");
|
||||||
|
header.setDisable(true);
|
||||||
|
items.add(0, header);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSiteItem(List<MenuItem> items, SiteRecord site, FrequencyCandidate candidate, long frequency)
|
||||||
|
{
|
||||||
|
String label = site.systemName() + " \u00B7 Site " + site.siteNumber() +
|
||||||
|
(site.description() != null ? " " + site.description() : "");
|
||||||
|
String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site);
|
||||||
|
|
||||||
|
if(unsupported != null)
|
||||||
|
{
|
||||||
|
MenuItem item = new MenuItem(label + " \u2013 " + unsupported);
|
||||||
|
item.setDisable(true);
|
||||||
|
items.add(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(RadioReferencePlaylistActions.isRepeaterSite(site))
|
||||||
|
{
|
||||||
|
addRepeaterItem(items, site, candidate.frequencyHz(), label, frequency);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel existing = mActions.findSiteChannel(site);
|
||||||
|
|
||||||
|
if(existing != null)
|
||||||
|
{
|
||||||
|
//The channel layer offers the channels at the clicked frequency
|
||||||
|
if(!covers(existing, frequency))
|
||||||
|
{
|
||||||
|
items.add(createEnableItem(existing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MenuItem item = new MenuItem("Add and Start: " + label);
|
||||||
|
//Start on the right-clicked frequency when it's a control or alternate control channel
|
||||||
|
item.setOnAction(event -> mActions.addSite(site, candidate.frequencyHz(), this::showError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu item for one repeater of a repeater site (LTR): add and start it, or enable or disable its channel
|
||||||
|
*/
|
||||||
|
private void addRepeaterItem(List<MenuItem> items, SiteRecord site, long repeaterFrequency, String label,
|
||||||
|
long frequency)
|
||||||
|
{
|
||||||
|
Channel existing = mActions.findChannel(List.of(repeaterFrequency),
|
||||||
|
RadioReferencePlaylistActions.getSiteDecoderTypes(site));
|
||||||
|
|
||||||
|
if(existing != null)
|
||||||
|
{
|
||||||
|
if(!covers(existing, frequency))
|
||||||
|
{
|
||||||
|
items.add(createEnableItem(existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String lcn = site.channels().stream().filter(channel -> channel.frequencyHz() == repeaterFrequency &&
|
||||||
|
channel.logicalChannelNumber() > 0).map(channel -> " LCN " + channel.logicalChannelNumber())
|
||||||
|
.findFirst().orElse("");
|
||||||
|
MenuItem item = new MenuItem("Add and Start: " + label + lcn + " (" +
|
||||||
|
FREQUENCY_FORMAT.format(repeaterFrequency / 1E6) + " MHz)");
|
||||||
|
item.setOnAction(event -> mActions.addRepeater(site, repeaterFrequency, this::showError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addConventionalItem(List<MenuItem> items, FrequencyCandidate candidate, DecoderType decoderType,
|
||||||
|
long frequency)
|
||||||
|
{
|
||||||
|
String label = candidate.title() + " (" + FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz" +
|
||||||
|
(candidate.mode() != null ? " " + candidate.mode() : "") + ")";
|
||||||
|
|
||||||
|
if(decoderType == null)
|
||||||
|
{
|
||||||
|
MenuItem item = new MenuItem(label + " \u2013 mode isn't supported");
|
||||||
|
item.setDisable(true);
|
||||||
|
items.add(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel existing = mActions.findConventionalChannel(candidate);
|
||||||
|
|
||||||
|
if(existing != null)
|
||||||
|
{
|
||||||
|
if(!covers(existing, frequency))
|
||||||
|
{
|
||||||
|
items.add(createEnableItem(existing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MenuItem item = new MenuItem("Add and Start: " + label);
|
||||||
|
item.setOnAction(event -> mActions.addConventional(candidate, this::showError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu item that enables the playlist channel when it's disabled, or disables it when it's enabled
|
||||||
|
*/
|
||||||
|
private MenuItem createEnableItem(Channel channel)
|
||||||
|
{
|
||||||
|
boolean processing = channel.isProcessing();
|
||||||
|
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
|
||||||
|
item.setOnAction(event -> {
|
||||||
|
if(processing)
|
||||||
|
{
|
||||||
|
mActions.disable(channel, this::showError);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mActions.enable(channel, this::showError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if one of the channel's tuner channels includes the frequency
|
||||||
|
*/
|
||||||
|
private static boolean covers(Channel channel, long frequency)
|
||||||
|
{
|
||||||
|
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||||
|
{
|
||||||
|
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
|
||||||
|
frequency <= tunerChannel.getMaxFrequency())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an error alert over the display. Can be called from any thread.
|
||||||
|
*/
|
||||||
|
private void showError(String message)
|
||||||
|
{
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
|
||||||
|
alert.setTitle("Radio Reference");
|
||||||
|
alert.setHeaderText("Can't add or start the channel");
|
||||||
|
|
||||||
|
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
|
||||||
|
{
|
||||||
|
alert.initOwner(getSpectralDisplay().getScene().getWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
render(gc, area, DisplaySection.SPECTRUM);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
render(gc, area, DisplaySection.WATERFALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the card in the section under the mouse once the mouse has rested for the hover delay
|
||||||
|
*/
|
||||||
|
private void render(GraphicsContext gc, RenderArea area, DisplaySection section)
|
||||||
|
{
|
||||||
|
DisplayCursor cursor = getSpectralDisplay() != null ? getSpectralDisplay().getDisplayCursor() : null;
|
||||||
|
trackHover(cursor);
|
||||||
|
|
||||||
|
if(cursor != null && cursor.section() == section && replacesCursorReadout(cursor, area))
|
||||||
|
{
|
||||||
|
drawCard(gc, area, cursor, getCandidates(cursor.frequency(), getTolerance(getModel(), area)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card replaces the cursor readout while it's shown: it ends with the readout lines, in the readout's place.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
//Only for the position the mouse rested at - the readout comes back as soon as the mouse moves
|
||||||
|
if(mIndex == null || !mHoverCandidates.get() || !mHoverReady || model == null || !model.hasSource() ||
|
||||||
|
cursor == null || cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY ||
|
||||||
|
area.width() <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !getCandidates(cursor.frequency(), getTolerance(model, area)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency tolerance for the candidates: a few pixels or a DFT bin at the current zoom, but at least 3 kHz
|
||||||
|
*/
|
||||||
|
private static long getTolerance(SpectralDisplayModel model, RenderArea area)
|
||||||
|
{
|
||||||
|
double hertzPerPixel = model.getDisplayBandwidth() / area.width();
|
||||||
|
return Math.max(MIN_TOLERANCE_HZ, Math.round(Math.max(TOLERANCE_PIXELS * hertzPerPixel, model.getBinWidth())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restarts the hover delay each time the mouse moves, and hides the card until it passes
|
||||||
|
*/
|
||||||
|
private void trackHover(DisplayCursor cursor)
|
||||||
|
{
|
||||||
|
if(cursor == null)
|
||||||
|
{
|
||||||
|
if(mHoverSection != null)
|
||||||
|
{
|
||||||
|
mHoverSection = null;
|
||||||
|
mHoverReady = false;
|
||||||
|
getHoverTimer().stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY)
|
||||||
|
{
|
||||||
|
mHoverSection = cursor.section();
|
||||||
|
mHoverX = cursor.x();
|
||||||
|
mHoverY = cursor.y();
|
||||||
|
mHoverReady = false;
|
||||||
|
getHoverTimer().playFromStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PauseTransition getHoverTimer()
|
||||||
|
{
|
||||||
|
if(mHoverTimer == null)
|
||||||
|
{
|
||||||
|
mHoverTimer = new PauseTransition(HOVER_DELAY);
|
||||||
|
mHoverTimer.setOnFinished(event -> {
|
||||||
|
mHoverReady = true;
|
||||||
|
requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mHoverTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidates for the frequency, looked up again only when the frequency or the tolerance changes
|
||||||
|
*/
|
||||||
|
private List<RankedCandidate> getCandidates(long frequency, long tolerance)
|
||||||
|
{
|
||||||
|
if(frequency != mResultsFrequency || tolerance != mResultsTolerance)
|
||||||
|
{
|
||||||
|
mResults = mIndex.lookup(frequency, tolerance);
|
||||||
|
mResultsFrequency = frequency;
|
||||||
|
mResultsTolerance = tolerance;
|
||||||
|
mCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the card in place of the cursor readout: beside the mouse and above it, placed like the readout, with the
|
||||||
|
* candidates above the readout lines so the frequency and level stay where the readout showed them. The card
|
||||||
|
* fits the larger side of the mouse and the height of the section, showing as many candidates as fit.
|
||||||
|
*/
|
||||||
|
private void drawCard(GraphicsContext gc, RenderArea area, DisplayCursor cursor, List<RankedCandidate> candidates)
|
||||||
|
{
|
||||||
|
//The level line changes with the mouse height while the candidates don't
|
||||||
|
List<String> readout = CursorReadout.lines(cursor, area, getModel());
|
||||||
|
double readoutWidth = 0;
|
||||||
|
|
||||||
|
for(String line: readout)
|
||||||
|
{
|
||||||
|
readoutWidth = Math.max(readoutWidth, measure(line, FONT));
|
||||||
|
}
|
||||||
|
|
||||||
|
double room = Math.max(area.width() - cursor.x(), cursor.x()) - CursorReadout.GAP_X - EDGE_MARGIN;
|
||||||
|
double maxTextWidth = Math.max(readoutWidth, Math.min(MAX_TEXT_WIDTH, room - (PADDING_X * 2)));
|
||||||
|
//Above the mouse, so the readout lines stay where the readout showed them, when a candidate fits there
|
||||||
|
double above = cursor.y() - CursorReadout.GAP_Y;
|
||||||
|
double maxHeight = above >= cardHeight(5, 1, true, readout.size()) ? above : area.plotHeight() - EDGE_MARGIN;
|
||||||
|
|
||||||
|
if(mCard == null || mCard.maxTextWidth() != maxTextWidth || mCard.maxHeight() != maxHeight ||
|
||||||
|
mCard.readoutLines() != readout.size())
|
||||||
|
{
|
||||||
|
mCard = createCard(candidates, maxTextWidth, maxHeight, readout.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
Card card = mCard;
|
||||||
|
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||||
|
double width = Math.max(card.textWidth(), readoutWidth) + (PADDING_X * 2);
|
||||||
|
double height = cardHeight(card.lineCount(), card.entries().size(), card.more() != null, readout.size());
|
||||||
|
double x = Math.max(0, CursorReadout.x(cursor, width, area));
|
||||||
|
double y = CursorReadout.y(cursor, height, area);
|
||||||
|
|
||||||
|
gc.setFill(theme.getBackground(0.92));
|
||||||
|
gc.fillRoundRect(x, y, width, height, 8, 8);
|
||||||
|
gc.setStroke(opaque(theme.getLine()));
|
||||||
|
gc.setLineWidth(1.0);
|
||||||
|
gc.strokeRoundRect(Math.floor(x) + 0.5, Math.floor(y) + 0.5, width, height, 8, 8);
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.setTextBaseline(VPos.TOP);
|
||||||
|
|
||||||
|
double textX = x + PADDING_X;
|
||||||
|
double lineY = y + PADDING;
|
||||||
|
gc.setFill(theme.getCursor());
|
||||||
|
gc.setFont(TITLE_FONT);
|
||||||
|
gc.fillText(HEADER, textX, lineY);
|
||||||
|
lineY += mTextHeight + GAP;
|
||||||
|
|
||||||
|
for(List<String> lines: card.entries())
|
||||||
|
{
|
||||||
|
gc.setFill(theme.getCursor());
|
||||||
|
gc.setFont(TITLE_FONT);
|
||||||
|
gc.fillText(lines.get(0), textX, lineY);
|
||||||
|
lineY += mTextHeight;
|
||||||
|
gc.setFill(opaque(theme.getLine()));
|
||||||
|
gc.setFont(FONT);
|
||||||
|
|
||||||
|
for(int line = 1; line < lines.size(); line++)
|
||||||
|
{
|
||||||
|
gc.fillText(lines.get(line), textX, lineY);
|
||||||
|
lineY += mTextHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineY += GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(card.more() != null)
|
||||||
|
{
|
||||||
|
gc.setFill(opaque(theme.getLine()));
|
||||||
|
gc.setFont(FONT);
|
||||||
|
gc.fillText(card.more(), textX, lineY);
|
||||||
|
lineY += mTextHeight + GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
//The readout lines, separated from the candidates, in the readout's style
|
||||||
|
double separator = Math.floor(lineY - (GAP / 2.0)) + 0.5;
|
||||||
|
gc.setStroke(theme.getLine(0.5));
|
||||||
|
gc.strokeLine(textX, separator, x + width - PADDING_X, separator);
|
||||||
|
gc.setFill(theme.getCursor());
|
||||||
|
gc.setFont(FONT);
|
||||||
|
|
||||||
|
for(String line: readout)
|
||||||
|
{
|
||||||
|
gc.fillText(line, textX, lineY);
|
||||||
|
lineY += mTextHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of a card with the lines
|
||||||
|
* @param lineCount of the header, candidate and more lines
|
||||||
|
* @param entries number of candidates
|
||||||
|
* @param more true when the card has a more line
|
||||||
|
* @param readoutLines number of readout lines
|
||||||
|
*/
|
||||||
|
private double cardHeight(int lineCount, int entries, boolean more, int readoutLines)
|
||||||
|
{
|
||||||
|
int gaps = 1 + entries + (more ? 1 : 0);
|
||||||
|
return ((lineCount + readoutLines) * mTextHeight) + (gaps * GAP) + PADDING + READOUT_PADDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card text for as many candidates as fit the height: each candidate's title, details and source, shortened to
|
||||||
|
* the width
|
||||||
|
*/
|
||||||
|
private Card createCard(List<RankedCandidate> candidates, double maxTextWidth, double maxHeight,
|
||||||
|
int readoutLines)
|
||||||
|
{
|
||||||
|
int limit = Math.min(candidates.size(), MAX_CANDIDATES);
|
||||||
|
List<List<String>> entries = new ArrayList<>();
|
||||||
|
double textWidth = measure(HEADER, TITLE_FONT);
|
||||||
|
int lineCount = 1;
|
||||||
|
|
||||||
|
for(int index = 0; index < limit; index++)
|
||||||
|
{
|
||||||
|
RankedCandidate candidate = candidates.get(index);
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
String title = candidate.candidate().title() != null ? candidate.candidate().title() : "(untitled)";
|
||||||
|
lines.add(fit(title, TITLE_FONT, maxTextWidth));
|
||||||
|
lines.add(fit(describe(candidate), FONT, maxTextWidth));
|
||||||
|
String source = source(candidate.candidate());
|
||||||
|
|
||||||
|
if(source != null)
|
||||||
|
{
|
||||||
|
lines.add(fit(source, FONT, maxTextWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
//Keep room for the more line when candidates are left out
|
||||||
|
boolean more = index + 1 < candidates.size();
|
||||||
|
|
||||||
|
if(!entries.isEmpty() && cardHeight(lineCount + lines.size() + (more ? 1 : 0), entries.size() + 1, more,
|
||||||
|
readoutLines) > maxHeight)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
textWidth = Math.max(textWidth, measure(lines.get(0), TITLE_FONT));
|
||||||
|
|
||||||
|
for(int line = 1; line < lines.size(); line++)
|
||||||
|
{
|
||||||
|
textWidth = Math.max(textWidth, measure(lines.get(line), FONT));
|
||||||
|
}
|
||||||
|
|
||||||
|
lineCount += lines.size();
|
||||||
|
entries.add(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
String more = candidates.size() > entries.size() ? "+ " + (candidates.size() - entries.size()) + " more" : null;
|
||||||
|
|
||||||
|
if(more != null)
|
||||||
|
{
|
||||||
|
textWidth = Math.max(textWidth, measure(more, FONT));
|
||||||
|
lineCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Card(entries, more, textWidth, lineCount, maxTextWidth, maxHeight, readoutLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency, mode or use, tone, encryption and distance of the candidate
|
||||||
|
*/
|
||||||
|
private String describe(RankedCandidate ranked)
|
||||||
|
{
|
||||||
|
FrequencyCandidate candidate = ranked.candidate();
|
||||||
|
return join(FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz", candidate.use(), candidate.mode(),
|
||||||
|
candidate.tone(), candidate.encrypted() ? "Encrypted" : null,
|
||||||
|
ranked.hasDistance() ? DistanceFormat.format(ranked.distanceKilometers()) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the candidate is listed, with its alpha tag when the title isn't the alpha tag
|
||||||
|
*/
|
||||||
|
private static String source(FrequencyCandidate candidate)
|
||||||
|
{
|
||||||
|
String alphaTag = candidate.alphaTag() != null && !candidate.alphaTag().equals(candidate.title()) ?
|
||||||
|
candidate.alphaTag() : null;
|
||||||
|
return join(alphaTag, candidate.source());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text shortened with an ellipsis to fit the width
|
||||||
|
*/
|
||||||
|
private String fit(String text, Font font, double maxWidth)
|
||||||
|
{
|
||||||
|
if(measure(text, font) <= maxWidth)
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = text.length();
|
||||||
|
|
||||||
|
while(length > 1 && measure(text.substring(0, length) + "…", font) > maxWidth)
|
||||||
|
{
|
||||||
|
length--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.substring(0, length) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
private double measure(String text, Font font)
|
||||||
|
{
|
||||||
|
mMeasure.setFont(font);
|
||||||
|
mMeasure.setText(text);
|
||||||
|
double width = mMeasure.getLayoutBounds().getWidth();
|
||||||
|
mMeasure.setFont(FONT);
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String join(String... values)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
for(String value: values)
|
||||||
|
{
|
||||||
|
if(value != null && !value.isEmpty())
|
||||||
|
{
|
||||||
|
if(!sb.isEmpty())
|
||||||
|
{
|
||||||
|
sb.append(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.isEmpty() ? null : sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color opaque(Color color)
|
||||||
|
{
|
||||||
|
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import java.lang.ref.WeakReference;
|
|||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javax.swing.LookAndFeel;
|
import javax.swing.LookAndFeel;
|
||||||
@@ -69,6 +70,7 @@ public class ThemeManager
|
|||||||
private static final ThemeManager INSTANCE = new ThemeManager();
|
private static final ThemeManager INSTANCE = new ThemeManager();
|
||||||
|
|
||||||
private final Set<WeakReference<Scene>> mScenes = new LinkedHashSet<>();
|
private final Set<WeakReference<Scene>> mScenes = new LinkedHashSet<>();
|
||||||
|
private final AtomicBoolean mWindowListenerAttached = new AtomicBoolean();
|
||||||
private final Set<WeakReference<java.awt.Component>> mSwingRoots = new LinkedHashSet<>();
|
private final Set<WeakReference<java.awt.Component>> mSwingRoots = new LinkedHashSet<>();
|
||||||
private final String mDarkStylesheetUrl;
|
private final String mDarkStylesheetUrl;
|
||||||
private volatile UserPreferences mUserPreferences;
|
private volatile UserPreferences mUserPreferences;
|
||||||
@@ -135,7 +137,18 @@ public class ThemeManager
|
|||||||
*/
|
*/
|
||||||
private void attachJavaFxWindowListener()
|
private void attachJavaFxWindowListener()
|
||||||
{
|
{
|
||||||
|
if(mWindowListenerAttached.get())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Runnable attach = () -> {
|
Runnable attach = () -> {
|
||||||
|
//Attach once, even if several attempts were scheduled before the first one ran
|
||||||
|
if(!mWindowListenerAttached.compareAndSet(false, true))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
javafx.stage.Window.getWindows().addListener(
|
javafx.stage.Window.getWindows().addListener(
|
||||||
@@ -176,8 +189,9 @@ public class ThemeManager
|
|||||||
}
|
}
|
||||||
catch(IllegalStateException ignored)
|
catch(IllegalStateException ignored)
|
||||||
{
|
{
|
||||||
//FX toolkit not started yet - first JFXPanel construction will start it; we
|
//FX toolkit not started yet, which is the case when initialize() runs at application
|
||||||
//re-attempt the attach lazily inside applyAll() via the Window.getWindows() walk.
|
//startup, before the first JFXPanel. register() attaches the listener once the
|
||||||
|
//first JavaFX scene is registered, which happens after the toolkit starts.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +262,9 @@ public class ThemeManager
|
|||||||
mScenes.add(new WeakReference<>(scene));
|
mScenes.add(new WeakReference<>(scene));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//The toolkit is running once a scene exists, so attach the window listener if initialize()
|
||||||
|
//couldn't, so that ad-hoc dialogs and alerts are themed
|
||||||
|
attachJavaFxWindowListener();
|
||||||
applyToScene(scene, isDarkMode());
|
applyToScene(scene, isDarkMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package io.github.dsheirer.map;
|
package io.github.dsheirer.map;
|
||||||
|
|
||||||
import io.github.dsheirer.settings.SettingsManager;
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
import org.jdesktop.swingx.JXMapViewer;
|
import org.jdesktop.swingx.JXMapViewer;
|
||||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||||
|
|
||||||
@@ -33,14 +35,23 @@ import java.awt.geom.Point2D;
|
|||||||
public class MapMouseListener extends MouseInputAdapter implements MouseWheelListener
|
public class MapMouseListener extends MouseInputAdapter implements MouseWheelListener
|
||||||
{
|
{
|
||||||
private JXMapViewer mJXMapViewer;
|
private JXMapViewer mJXMapViewer;
|
||||||
private SettingsManager mSettingsManager;
|
private Consumer<GeoPosition> mMyLocationListener;
|
||||||
|
private Function<Point,List<JMenuItem>> mMenuItemProvider;
|
||||||
private Point mPreviousPoint;
|
private Point mPreviousPoint;
|
||||||
private Point mCurrentPoint;
|
private Point mCurrentPoint;
|
||||||
|
|
||||||
public MapMouseListener( JXMapViewer viewer, SettingsManager settingsManager )
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @param viewer map
|
||||||
|
* @param myLocationListener receives the location chosen with the 'Set My Location Here' menu item
|
||||||
|
* @param menuItemProvider of additional context menu items for the point that was right-clicked, listed first
|
||||||
|
*/
|
||||||
|
public MapMouseListener( JXMapViewer viewer, Consumer<GeoPosition> myLocationListener,
|
||||||
|
Function<Point,List<JMenuItem>> menuItemProvider )
|
||||||
{
|
{
|
||||||
mJXMapViewer = viewer;
|
mJXMapViewer = viewer;
|
||||||
mSettingsManager = settingsManager;
|
mMyLocationListener = myLocationListener;
|
||||||
|
mMenuItemProvider = menuItemProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -137,17 +148,26 @@ public class MapMouseListener extends MouseInputAdapter implements MouseWheelLis
|
|||||||
{
|
{
|
||||||
JPopupMenu popup = new JPopupMenu();
|
JPopupMenu popup = new JPopupMenu();
|
||||||
|
|
||||||
JMenuItem mapViewItem = new JMenuItem( "Set Default Location & Zoom" );
|
if( mMenuItemProvider != null )
|
||||||
|
{
|
||||||
|
List<JMenuItem> items = mMenuItemProvider.apply( event.getPoint() );
|
||||||
|
|
||||||
|
if( items != null && !items.isEmpty() )
|
||||||
|
{
|
||||||
|
items.forEach( popup::add );
|
||||||
|
popup.addSeparator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JMenuItem mapViewItem = new JMenuItem( "Set My Location Here" );
|
||||||
|
mapViewItem.setToolTipText( "Sets your location, which is also used by the playlist editor's " +
|
||||||
|
"Radio Reference tab, to this point" );
|
||||||
mapViewItem.addActionListener( new ActionListener()
|
mapViewItem.addActionListener( new ActionListener()
|
||||||
{
|
{
|
||||||
@Override
|
@Override
|
||||||
public void actionPerformed( ActionEvent arg0 )
|
public void actionPerformed( ActionEvent arg0 )
|
||||||
{
|
{
|
||||||
GeoPosition position = mJXMapViewer
|
mMyLocationListener.accept( mJXMapViewer.convertPointToGeoPosition( mCurrentPoint ) );
|
||||||
.convertPointToGeoPosition( mCurrentPoint );
|
|
||||||
|
|
||||||
mSettingsManager.setMapViewSetting( "Default", position,
|
|
||||||
mJXMapViewer.getZoom() );
|
|
||||||
}
|
}
|
||||||
} );
|
} );
|
||||||
popup.add( mapViewItem );
|
popup.add( mapViewItem );
|
||||||
|
|||||||
@@ -18,17 +18,30 @@
|
|||||||
*/
|
*/
|
||||||
package io.github.dsheirer.map;
|
package io.github.dsheirer.map;
|
||||||
|
|
||||||
|
import com.google.common.eventbus.Subscribe;
|
||||||
import io.github.dsheirer.alias.AliasModel;
|
import io.github.dsheirer.alias.AliasModel;
|
||||||
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
import io.github.dsheirer.icon.IconModel;
|
import io.github.dsheirer.icon.IconModel;
|
||||||
import io.github.dsheirer.identifier.Form;
|
import io.github.dsheirer.identifier.Form;
|
||||||
import io.github.dsheirer.identifier.Identifier;
|
import io.github.dsheirer.identifier.Identifier;
|
||||||
import io.github.dsheirer.identifier.IdentifierClass;
|
import io.github.dsheirer.identifier.IdentifierClass;
|
||||||
import io.github.dsheirer.identifier.Role;
|
import io.github.dsheirer.identifier.Role;
|
||||||
import io.github.dsheirer.settings.MapViewSetting;
|
import io.github.dsheirer.preference.PreferenceType;
|
||||||
import io.github.dsheirer.settings.SettingsManager;
|
import io.github.dsheirer.preference.UserPreferences;
|
||||||
|
import io.github.dsheirer.preference.map.MapPreference;
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
|
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex;
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
import java.awt.EventQueue;
|
import java.awt.EventQueue;
|
||||||
|
import java.awt.Point;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import net.miginfocom.swing.MigLayout;
|
import net.miginfocom.swing.MigLayout;
|
||||||
import org.jdesktop.swingx.JXMapViewer;
|
import org.jdesktop.swingx.JXMapViewer;
|
||||||
import org.jdesktop.swingx.OSMTileFactoryInfo;
|
import org.jdesktop.swingx.OSMTileFactoryInfo;
|
||||||
@@ -37,11 +50,14 @@ import org.jdesktop.swingx.input.ZoomMouseWheelListenerCursor;
|
|||||||
import org.jdesktop.swingx.mapviewer.DefaultTileFactory;
|
import org.jdesktop.swingx.mapviewer.DefaultTileFactory;
|
||||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||||
import org.jdesktop.swingx.mapviewer.TileFactoryInfo;
|
import org.jdesktop.swingx.mapviewer.TileFactoryInfo;
|
||||||
|
import org.jdesktop.swingx.painter.CompoundPainter;
|
||||||
|
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JCheckBox;
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JComboBox;
|
import javax.swing.JComboBox;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JMenuItem;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
import javax.swing.JSpinner;
|
import javax.swing.JSpinner;
|
||||||
@@ -49,6 +65,9 @@ import javax.swing.JSplitPane;
|
|||||||
import javax.swing.JTable;
|
import javax.swing.JTable;
|
||||||
import javax.swing.JToggleButton;
|
import javax.swing.JToggleButton;
|
||||||
import javax.swing.SpinnerNumberModel;
|
import javax.swing.SpinnerNumberModel;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import javax.swing.ToolTipManager;
|
||||||
import javax.swing.event.ChangeEvent;
|
import javax.swing.event.ChangeEvent;
|
||||||
import javax.swing.event.ChangeListener;
|
import javax.swing.event.ChangeListener;
|
||||||
|
|
||||||
@@ -61,12 +80,28 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
|||||||
|
|
||||||
private static final int ZOOM_MINIMUM = 1;
|
private static final int ZOOM_MINIMUM = 1;
|
||||||
private static final int ZOOM_MAXIMUM = 16;
|
private static final int ZOOM_MAXIMUM = 16;
|
||||||
|
//Delay after the last pan or zoom before the map view is stored
|
||||||
|
private static final int SAVE_VIEW_DELAY_MS = 500;
|
||||||
|
|
||||||
private static final String FOLLOW = "Follow";
|
private static final String FOLLOW = "Follow";
|
||||||
private static final String UNFOLLOW = "Unfollow";
|
private static final String UNFOLLOW = "Unfollow";
|
||||||
private static final String SELECT_A_TRACK = "(select a track)";
|
private static final String SELECT_A_TRACK = "(select a track)";
|
||||||
private static final String NO_SYSTEM_NAME = "(no system name)";
|
private static final String NO_SYSTEM_NAME = "(no system name)";
|
||||||
private SettingsManager mSettingsManager;
|
private static final String LOCATION_NOT_SET = "Not set";
|
||||||
|
private Timer mSaveViewTimer;
|
||||||
|
private final RadioReferenceFrequencyIndex mFrequencyIndex;
|
||||||
|
private final RadioReferenceSitePainter mSitePainter;
|
||||||
|
private final RadioReferencePlaylistActions mPlaylistActions;
|
||||||
|
private JCheckBox mShowSitesCheckBox;
|
||||||
|
private JLabel mSiteCountLabel;
|
||||||
|
private int mDefaultTooltipDismissDelay = -1;
|
||||||
|
private UserPreferences mUserPreferences;
|
||||||
|
private final UserLocationPainter mUserLocationPainter = new UserLocationPainter();
|
||||||
|
private JLabel mUserLocationLabel;
|
||||||
|
private JButton mSetUserLocationButton;
|
||||||
|
private JButton mCenterOnUserLocationButton;
|
||||||
|
//Location most recently chosen on the map, which doesn't re-center the map when the preference update arrives
|
||||||
|
private GeoPosition mMapChosenPosition;
|
||||||
private MapService mMapService;
|
private MapService mMapService;
|
||||||
private JXMapViewer mMapViewer;
|
private JXMapViewer mMapViewer;
|
||||||
private PlottableEntityPainter mMapPainter;
|
private PlottableEntityPainter mMapPainter;
|
||||||
@@ -93,15 +128,354 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
|||||||
* @param mapService for accessing entities to plot
|
* @param mapService for accessing entities to plot
|
||||||
* @param aliasModel for alias lookup
|
* @param aliasModel for alias lookup
|
||||||
* @param iconModel for icon lookup
|
* @param iconModel for icon lookup
|
||||||
* @param settingsManager for user specified options/settings.
|
* @param userPreferences for the map view and the user's location, which is shared with the radio reference editor
|
||||||
|
* @param frequencyIndex of the radio reference frequencies near the user, for the trunked system sites, or null
|
||||||
|
* @param playlistActions to add sites to the playlist or enable their channels, or null
|
||||||
*/
|
*/
|
||||||
public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, SettingsManager settingsManager)
|
public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, UserPreferences userPreferences,
|
||||||
|
RadioReferenceFrequencyIndex frequencyIndex, RadioReferencePlaylistActions playlistActions)
|
||||||
{
|
{
|
||||||
mSettingsManager = settingsManager;
|
mUserPreferences = userPreferences;
|
||||||
|
mFrequencyIndex = frequencyIndex;
|
||||||
|
mPlaylistActions = playlistActions;
|
||||||
|
mSitePainter = new RadioReferenceSitePainter(frequencyIndex != null ? frequencyIndex::getSites :
|
||||||
|
Collections::emptyList);
|
||||||
|
mSitePainter.setShowSites(userPreferences.getMapPreference().isShowRadioReferenceSites());
|
||||||
mMapService = mapService;
|
mMapService = mapService;
|
||||||
mMapPainter = new PlottableEntityPainter(aliasModel, iconModel);
|
mMapPainter = new PlottableEntityPainter(aliasModel, iconModel);
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
||||||
|
//Show the stored location without moving the map, which starts at the stored default location
|
||||||
|
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||||
|
mUserLocationPainter.setPosition(location != null ? toGeoPosition(location) : null);
|
||||||
|
updateUserLocationLabel(location);
|
||||||
|
|
||||||
|
if(mFrequencyIndex != null)
|
||||||
|
{
|
||||||
|
//The index fills in the background
|
||||||
|
mFrequencyIndex.addUpdateListener(() -> EventQueue.invokeLater(this::sitesUpdated));
|
||||||
|
}
|
||||||
|
|
||||||
|
sitesUpdated();
|
||||||
|
MyEventBus.getGlobalEventBus().register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the site count and repaints the sites. Call on the Swing thread.
|
||||||
|
*/
|
||||||
|
private void sitesUpdated()
|
||||||
|
{
|
||||||
|
int count = mFrequencyIndex != null ? mFrequencyIndex.getSites().size() : 0;
|
||||||
|
getSiteCountLabel().setText(count == 1 ? "1 site" : count + " sites");
|
||||||
|
getMapViewer().repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JCheckBox getShowSitesCheckBox()
|
||||||
|
{
|
||||||
|
if(mShowSitesCheckBox == null)
|
||||||
|
{
|
||||||
|
mShowSitesCheckBox = new JCheckBox("Radio Reference Sites", mSitePainter.isShowSites());
|
||||||
|
mShowSitesCheckBox.setEnabled(mFrequencyIndex != null);
|
||||||
|
mShowSitesCheckBox.setToolTipText("Show the trunked radio system sites within " +
|
||||||
|
(int)RadioReferenceFrequencyIndex.RADIUS_KILOMETERS + " km of your location. Requires a premium " +
|
||||||
|
"Radio Reference login and your location.");
|
||||||
|
mShowSitesCheckBox.addActionListener(e -> {
|
||||||
|
boolean show = mShowSitesCheckBox.isSelected();
|
||||||
|
mSitePainter.setShowSites(show);
|
||||||
|
mUserPreferences.getMapPreference().setShowRadioReferenceSites(show);
|
||||||
|
getMapViewer().repaint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mShowSitesCheckBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JLabel getSiteCountLabel()
|
||||||
|
{
|
||||||
|
if(mSiteCountLabel == null)
|
||||||
|
{
|
||||||
|
mSiteCountLabel = new JLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return mSiteCountLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context menu items for the radio reference sites under the point: add a site to the playlist and start it, or
|
||||||
|
* enable or disable the playlist channel that already decodes it
|
||||||
|
*/
|
||||||
|
private List<JMenuItem> getSiteMenuItems(Point point)
|
||||||
|
{
|
||||||
|
List<JMenuItem> items = new ArrayList<>();
|
||||||
|
|
||||||
|
if(mPlaylistActions == null)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SiteRecord> sites = mSitePainter.getSitesAt(getMapViewer(), point);
|
||||||
|
|
||||||
|
for(SiteRecord site: sites.subList(0, Math.min(sites.size(), 3)))
|
||||||
|
{
|
||||||
|
String label = site.systemName() + " \u00B7 Site " + site.siteNumber() +
|
||||||
|
(site.description() != null ? " " + site.description() : "");
|
||||||
|
String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site);
|
||||||
|
|
||||||
|
if(unsupported != null)
|
||||||
|
{
|
||||||
|
JMenuItem item = new JMenuItem(label + " \u2013 " + unsupported);
|
||||||
|
item.setEnabled(false);
|
||||||
|
items.add(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(RadioReferencePlaylistActions.isRepeaterSite(site))
|
||||||
|
{
|
||||||
|
addRepeaterSiteItems(items, site, label);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Channel existing = mPlaylistActions.findSiteChannel(site);
|
||||||
|
|
||||||
|
if(existing != null)
|
||||||
|
{
|
||||||
|
boolean processing = existing.isProcessing();
|
||||||
|
JMenuItem item = new JMenuItem((processing ? "Disable: " : "Enable: ") + existing.getShortTitle());
|
||||||
|
item.addActionListener(e -> {
|
||||||
|
if(processing)
|
||||||
|
{
|
||||||
|
mPlaylistActions.disable(existing, this::showSiteError);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mPlaylistActions.enable(existing, this::showSiteError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
JMenuItem item = new JMenuItem("Add and Start: " + label);
|
||||||
|
item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu items for a site whose repeaters each have a channel (LTR): add and start the repeaters that aren't in the
|
||||||
|
* playlist, and enable or disable the site's channels
|
||||||
|
*/
|
||||||
|
private void addRepeaterSiteItems(List<JMenuItem> items, SiteRecord site, String label)
|
||||||
|
{
|
||||||
|
List<Channel> existing = mPlaylistActions.findSiteChannels(site);
|
||||||
|
int missing = mPlaylistActions.getMissingRepeaterFrequencies(site).size();
|
||||||
|
boolean anyDecoding = existing.stream().anyMatch(Channel::isProcessing);
|
||||||
|
boolean anyStopped = existing.stream().anyMatch(channel -> !channel.isProcessing());
|
||||||
|
|
||||||
|
if(missing > 0)
|
||||||
|
{
|
||||||
|
JMenuItem item = new JMenuItem("Add and Start: " + label + " (" + missing + " repeater" +
|
||||||
|
(missing > 1 ? "s" : "") + ")");
|
||||||
|
item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
else if(anyStopped)
|
||||||
|
{
|
||||||
|
JMenuItem item = new JMenuItem("Enable: " + label + " (" + existing.size() + " channels)");
|
||||||
|
item.addActionListener(e -> mPlaylistActions.enableAll(existing, this::showSiteError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(anyDecoding)
|
||||||
|
{
|
||||||
|
long decoding = existing.stream().filter(Channel::isProcessing).count();
|
||||||
|
JMenuItem item = new JMenuItem("Disable: " + label + " (" + decoding + " channel" +
|
||||||
|
(decoding > 1 ? "s" : "") + ")");
|
||||||
|
item.addActionListener(e -> mPlaylistActions.disableAll(existing, this::showSiteError));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an error from a site action. Can be called from any thread.
|
||||||
|
*/
|
||||||
|
private void showSiteError(String message)
|
||||||
|
{
|
||||||
|
EventQueue.invokeLater(() -> JOptionPane.showMessageDialog(this, message, "Radio Reference",
|
||||||
|
JOptionPane.ERROR_MESSAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card for the radio reference sites under the point, or null when there are none
|
||||||
|
*/
|
||||||
|
private String getSiteCard(Point point)
|
||||||
|
{
|
||||||
|
List<SiteRecord> sites = mSitePainter.getSitesAt(getMapViewer(), point);
|
||||||
|
return sites.isEmpty() ? null : RadioReferenceSiteCard.html(sites,
|
||||||
|
mUserPreferences.getRadioReferencePreference().getLocation());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the user's location on the map when it changes, here or in the radio reference editor
|
||||||
|
* @param preferenceType that was updated
|
||||||
|
*/
|
||||||
|
@Subscribe
|
||||||
|
public void preferenceUpdated(PreferenceType preferenceType)
|
||||||
|
{
|
||||||
|
if(preferenceType == PreferenceType.RADIO_REFERENCE)
|
||||||
|
{
|
||||||
|
EventQueue.invokeLater(this::updateUserLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the user's location on the map. When the location changes, the map centers on it, unless it was chosen on
|
||||||
|
* the map. Clearing the location removes the pin and leaves the map as is. Call on the Swing thread.
|
||||||
|
*/
|
||||||
|
private void updateUserLocation()
|
||||||
|
{
|
||||||
|
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||||
|
GeoPosition position = location != null ? toGeoPosition(location) : null;
|
||||||
|
updateUserLocationLabel(location);
|
||||||
|
|
||||||
|
if(!Objects.equals(position, mUserLocationPainter.getPosition()))
|
||||||
|
{
|
||||||
|
mUserLocationPainter.setPosition(position);
|
||||||
|
|
||||||
|
//A location chosen on the map is already in view
|
||||||
|
if(position != null && !position.equals(mMapChosenPosition))
|
||||||
|
{
|
||||||
|
getMapViewer().setAddressLocation(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
mMapChosenPosition = null;
|
||||||
|
|
||||||
|
getMapViewer().repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateUserLocationLabel(RadioReferencePreference.Location location)
|
||||||
|
{
|
||||||
|
getUserLocationLabel().setText(location != null ? location.coordinates() : LOCATION_NOT_SET);
|
||||||
|
getCenterOnUserLocationButton().setEnabled(location != null);
|
||||||
|
getUserLocationLabel().setToolTipText(location != null ? location.describe() :
|
||||||
|
"Set your location with the Set Current button or in the playlist editor's Radio Reference tab");
|
||||||
|
}
|
||||||
|
|
||||||
|
private JLabel getUserLocationLabel()
|
||||||
|
{
|
||||||
|
if(mUserLocationLabel == null)
|
||||||
|
{
|
||||||
|
mUserLocationLabel = new JLabel(LOCATION_NOT_SET);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mUserLocationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button that sets the user's location to the center of the map
|
||||||
|
*/
|
||||||
|
private JButton getSetUserLocationButton()
|
||||||
|
{
|
||||||
|
if(mSetUserLocationButton == null)
|
||||||
|
{
|
||||||
|
mSetUserLocationButton = new JButton("Set Current");
|
||||||
|
mSetUserLocationButton.setToolTipText("Sets your location, which is also used by the playlist editor's " +
|
||||||
|
"Radio Reference tab, to the center of the map");
|
||||||
|
mSetUserLocationButton.addActionListener(e -> setUserLocation(getMapViewer().getCenterPosition()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mSetUserLocationButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button that centers the map on the user's location. Enabled when the location is set.
|
||||||
|
*/
|
||||||
|
private JButton getCenterOnUserLocationButton()
|
||||||
|
{
|
||||||
|
if(mCenterOnUserLocationButton == null)
|
||||||
|
{
|
||||||
|
mCenterOnUserLocationButton = new JButton("Center On");
|
||||||
|
mCenterOnUserLocationButton.setToolTipText("Centers the map on your location");
|
||||||
|
mCenterOnUserLocationButton.setEnabled(false);
|
||||||
|
mCenterOnUserLocationButton.addActionListener(e -> {
|
||||||
|
GeoPosition position = mUserLocationPainter.getPosition();
|
||||||
|
|
||||||
|
if(position != null)
|
||||||
|
{
|
||||||
|
getMapViewer().setCenterPosition(position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mCenterOnUserLocationButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user's location to a point chosen on the map, with the Set Current button or the map's 'Set My Location
|
||||||
|
* Here' menu item. The pin, the location label and the radio reference editor follow through the preference
|
||||||
|
* update. The map isn't re-centered, since the point is already in view.
|
||||||
|
* Call on the Swing thread.
|
||||||
|
* @param position chosen on the map
|
||||||
|
*/
|
||||||
|
private void setUserLocation(GeoPosition position)
|
||||||
|
{
|
||||||
|
double latitude = round(position.getLatitude());
|
||||||
|
double longitude = round(normalizeLongitude(position.getLongitude()));
|
||||||
|
|
||||||
|
if(RadioReferencePreference.Location.isValid(latitude, longitude))
|
||||||
|
{
|
||||||
|
mMapChosenPosition = new GeoPosition(latitude, longitude);
|
||||||
|
mUserPreferences.getRadioReferencePreference()
|
||||||
|
.setLocation(new RadioReferencePreference.Location(latitude, longitude, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the map's center point and zoom level, so the map shows the same view when the application starts.
|
||||||
|
* Call on the Swing thread.
|
||||||
|
*/
|
||||||
|
private void saveView()
|
||||||
|
{
|
||||||
|
GeoPosition center = getMapViewer().getCenterPosition();
|
||||||
|
MapPreference preference = mUserPreferences.getMapPreference();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
preference.setCenter(new MapPreference.Center(round(center.getLatitude()),
|
||||||
|
round(normalizeLongitude(center.getLongitude()))));
|
||||||
|
}
|
||||||
|
catch(IllegalArgumentException e)
|
||||||
|
{
|
||||||
|
//Not a storable position - keep the previous center
|
||||||
|
}
|
||||||
|
|
||||||
|
preference.setZoom(getMapViewer().getZoom());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a longitude into -180 to 180 degrees, since the map can be panned past +/-180 degrees
|
||||||
|
*/
|
||||||
|
private static double normalizeLongitude(double longitude)
|
||||||
|
{
|
||||||
|
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds degrees to six decimal places (about 0.1 meter)
|
||||||
|
*/
|
||||||
|
private static double round(double degrees)
|
||||||
|
{
|
||||||
|
return Math.round(degrees * 1_000_000.0) / 1_000_000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GeoPosition toGeoPosition(RadioReferencePreference.Location location)
|
||||||
|
{
|
||||||
|
return new GeoPosition(location.latitude(), location.longitude());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void init()
|
private void init()
|
||||||
@@ -111,15 +485,23 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
|||||||
|
|
||||||
JPanel cp = new JPanel();
|
JPanel cp = new JPanel();
|
||||||
cp.setLayout(new MigLayout("insets 5", "[grow,fill][grow,fill][grow,fill]",
|
cp.setLayout(new MigLayout("insets 5", "[grow,fill][grow,fill][grow,fill]",
|
||||||
"[grow,fill][][][][][][grow,fill][]"));
|
"[][][][][grow,fill][][][][][][grow,fill][]"));
|
||||||
cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap");
|
|
||||||
|
|
||||||
cp.add(getMapZoomSpinner());
|
//The columns stretch the labels to the column width, so the label aligns its own text
|
||||||
JPanel zoomCenterPanel = new JPanel();
|
cp.add(new JLabel("My Location:", SwingConstants.RIGHT), "align right");
|
||||||
zoomCenterPanel.setLayout(new MigLayout("insets 0", "[]5[grow,fill]", ""));
|
cp.add(getUserLocationLabel(), "span 2, wrap");
|
||||||
zoomCenterPanel.add(new JLabel("Zoom"));
|
|
||||||
zoomCenterPanel.add(getCenterOnSelectedCheckBox());
|
cp.add(getSetUserLocationButton());
|
||||||
cp.add(zoomCenterPanel, "span 2, wrap");
|
cp.add(getCenterOnUserLocationButton(), "wrap");
|
||||||
|
|
||||||
|
cp.add(new JLabel("Zoom:", SwingConstants.RIGHT), "align right");
|
||||||
|
//Spinner at its natural width, rather than filling the column
|
||||||
|
cp.add(getMapZoomSpinner(), "wmax pref, wrap");
|
||||||
|
|
||||||
|
cp.add(getShowSitesCheckBox(), "span 2");
|
||||||
|
cp.add(getSiteCountLabel(), "wrap");
|
||||||
|
|
||||||
|
cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap");
|
||||||
|
|
||||||
cp.add(getDeleteTrackButton());
|
cp.add(getDeleteTrackButton());
|
||||||
cp.add(getDeleteAllTracksButton());
|
cp.add(getDeleteAllTracksButton());
|
||||||
@@ -132,7 +514,9 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
|||||||
cp.add(getTrackHistoryLengthComboBox());
|
cp.add(getTrackHistoryLengthComboBox());
|
||||||
cp.add(new JLabel("Track History Length"), "span 2, wrap");
|
cp.add(new JLabel("Track History Length"), "span 2, wrap");
|
||||||
|
|
||||||
cp.add(new JLabel("Selected System:"), "align right");
|
cp.add(getCenterOnSelectedCheckBox(), "span 3, wrap");
|
||||||
|
|
||||||
|
cp.add(new JLabel("Selected System:", SwingConstants.RIGHT), "align right");
|
||||||
cp.add(getSelectedTrackSystemLabel(), "span 2, wrap");
|
cp.add(getSelectedTrackSystemLabel(), "span 2, wrap");
|
||||||
|
|
||||||
cp.add(new JScrollPane(getTrackHistoryTable()), "span 3,wrap");
|
cp.add(new JScrollPane(getTrackHistoryTable()), "span 3,wrap");
|
||||||
@@ -526,12 +910,47 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
|||||||
{
|
{
|
||||||
if(mMapViewer == null)
|
if(mMapViewer == null)
|
||||||
{
|
{
|
||||||
mMapViewer = new JXMapViewer();
|
//The tooltip is the card of the radio reference sites under the mouse
|
||||||
|
mMapViewer = new JXMapViewer()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public String getToolTipText(MouseEvent event)
|
||||||
|
{
|
||||||
|
return getSiteCard(event.getPoint());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Point getToolTipLocation(MouseEvent event)
|
||||||
|
{
|
||||||
|
return new Point(event.getX() + 16, event.getY() + 16);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ToolTipManager.sharedInstance().registerComponent(mMapViewer);
|
||||||
|
//Keep the card up long enough to read the channels while the mouse is over the map
|
||||||
|
mMapViewer.addMouseListener(new MouseAdapter()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void mouseEntered(MouseEvent event)
|
||||||
|
{
|
||||||
|
mDefaultTooltipDismissDelay = ToolTipManager.sharedInstance().getDismissDelay();
|
||||||
|
ToolTipManager.sharedInstance().setDismissDelay(60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseExited(MouseEvent event)
|
||||||
|
{
|
||||||
|
if(mDefaultTooltipDismissDelay >= 0)
|
||||||
|
{
|
||||||
|
ToolTipManager.sharedInstance().setDismissDelay(mDefaultTooltipDismissDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the entity painter as the overlay painter and register this panel to receive new messages (plots)
|
* Set the entity painter as the overlay painter and register this panel to receive new messages (plots)
|
||||||
*/
|
*/
|
||||||
mMapViewer.setOverlayPainter(mMapPainter);
|
//Radio reference sites under the tracks, and the user's location pin over the tracks
|
||||||
|
mMapViewer.setOverlayPainter(new CompoundPainter<>(mSitePainter, mMapPainter, mUserLocationPainter));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map image source
|
* Map image source
|
||||||
@@ -546,20 +965,46 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
|||||||
tileFactory.setThreadPoolSize(8);
|
tileFactory.setThreadPoolSize(8);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set initial location and zoom for the map upon display
|
* Set initial location and zoom for the map upon display: the last view, or else the user's location, or
|
||||||
|
* else Syracuse, NY.
|
||||||
*/
|
*/
|
||||||
GeoPosition syracuse = new GeoPosition(43.048, -76.147);
|
MapPreference mapPreference = mUserPreferences.getMapPreference();
|
||||||
int zoom = 7;
|
MapPreference.Center center = mapPreference.getCenter();
|
||||||
|
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||||
|
GeoPosition initial;
|
||||||
|
|
||||||
MapViewSetting view = mSettingsManager.getMapViewSetting("Default", syracuse, zoom);
|
if(center != null)
|
||||||
|
{
|
||||||
|
initial = new GeoPosition(center.latitude(), center.longitude());
|
||||||
|
}
|
||||||
|
else if(location != null)
|
||||||
|
{
|
||||||
|
initial = toGeoPosition(location);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
initial = new GeoPosition(43.048, -76.147);
|
||||||
|
}
|
||||||
|
|
||||||
mMapViewer.setAddressLocation(view.getGeoPosition());
|
//Set the zoom on the map too: the spinner doesn't update the map when the value equals its initial value
|
||||||
mMapZoomSpinnerModel.setValue(view.getZoom());
|
mMapViewer.setZoom(mapPreference.getZoom());
|
||||||
|
mMapViewer.setAddressLocation(initial);
|
||||||
|
mMapZoomSpinnerModel.setValue(mapPreference.getZoom());
|
||||||
|
|
||||||
|
//Remember the view after each pan or zoom, once the map stops moving
|
||||||
|
mSaveViewTimer = new Timer(SAVE_VIEW_DELAY_MS, e -> saveView());
|
||||||
|
mSaveViewTimer.setRepeats(false);
|
||||||
|
mMapViewer.addPropertyChangeListener(event -> {
|
||||||
|
if("centerPosition".equals(event.getPropertyName()) || "zoom".equals(event.getPropertyName()))
|
||||||
|
{
|
||||||
|
mSaveViewTimer.restart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a mouse adapter for panning and scrolling
|
* Add a mouse adapter for panning and scrolling
|
||||||
*/
|
*/
|
||||||
MapMouseListener listener = new MapMouseListener(mMapViewer, mSettingsManager);
|
MapMouseListener listener = new MapMouseListener(mMapViewer, this::setUserLocation, this::getSiteMenuItems);
|
||||||
mMapViewer.addMouseListener(listener);
|
mMapViewer.addMouseListener(listener);
|
||||||
mMapViewer.addMouseMotionListener(listener);
|
mMapViewer.addMouseMotionListener(listener);
|
||||||
|
|
||||||
|
|||||||
187
src/main/java/io/github/dsheirer/map/RadioReferenceSiteCard.java
Normal file
187
src/main/java/io/github/dsheirer/map/RadioReferenceSiteCard.java
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.map;
|
||||||
|
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||||
|
import io.github.dsheirer.rrapi.type.Coverage;
|
||||||
|
import io.github.dsheirer.service.radioreference.DistanceFormat;
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML card with the details of radio reference trunked system sites, shown when the mouse is over a site's tower on
|
||||||
|
* the map: the system and protocol, the site, and the site's channels.
|
||||||
|
*/
|
||||||
|
public final class RadioReferenceSiteCard
|
||||||
|
{
|
||||||
|
private static final int MAX_SITES = 3;
|
||||||
|
private static final int CHANNEL_COLUMNS = 3;
|
||||||
|
private static final int MAX_CHANNELS = 24;
|
||||||
|
private static final String SEPARATOR = " · ";
|
||||||
|
|
||||||
|
private RadioReferenceSiteCard()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card for the sites
|
||||||
|
* @param sites under the mouse, nearest first
|
||||||
|
* @param location of the user, for the distances, or null
|
||||||
|
* @return HTML for a tooltip
|
||||||
|
*/
|
||||||
|
public static String html(List<SiteRecord> sites, RadioReferencePreference.Location location)
|
||||||
|
{
|
||||||
|
DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
|
||||||
|
StringBuilder sb = new StringBuilder("<html><body style='padding:3px'>");
|
||||||
|
int shown = Math.min(sites.size(), MAX_SITES);
|
||||||
|
|
||||||
|
for(int index = 0; index < shown; index++)
|
||||||
|
{
|
||||||
|
SiteRecord site = sites.get(index);
|
||||||
|
|
||||||
|
if(index > 0)
|
||||||
|
{
|
||||||
|
sb.append("<hr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("<div><b>").append(escape(site.systemName() != null ? site.systemName() : "(unnamed system)"))
|
||||||
|
.append("</b></div>");
|
||||||
|
appendLine(sb, site.protocol(), site.voice());
|
||||||
|
|
||||||
|
String siteName = "Site " + site.siteNumber();
|
||||||
|
String distance = location != null ? DistanceFormat.format(Coverage.distanceKilometers(
|
||||||
|
location.latitude(), location.longitude(), site.latitude(), site.longitude())) : null;
|
||||||
|
appendLine(sb, siteName, site.description(), site.county(), distance);
|
||||||
|
appendLine(sb, site.nac() != null ? "NAC " + site.nac() : null,
|
||||||
|
site.rfss() > 0 ? "RFSS " + site.rfss() : null, site.zone() > 0 ? "Zone " + site.zone() : null);
|
||||||
|
appendChannels(sb, site.channels(), frequencyFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(sites.size() > shown)
|
||||||
|
{
|
||||||
|
sb.append("<hr><div>+ ").append(sites.size() - shown).append(" more site")
|
||||||
|
.append(sites.size() - shown > 1 ? "s" : "").append(" here</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("</body></html>");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels in columns, control channels marked C and alternate control channels marked A
|
||||||
|
*/
|
||||||
|
private static void appendChannels(StringBuilder sb, List<SiteRecord.SiteChannel> channels,
|
||||||
|
DecimalFormat frequencyFormat)
|
||||||
|
{
|
||||||
|
if(channels == null || channels.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int shown = Math.min(channels.size(), MAX_CHANNELS);
|
||||||
|
boolean control = false;
|
||||||
|
sb.append("<table cellspacing='0' cellpadding='1'>");
|
||||||
|
|
||||||
|
for(int index = 0; index < shown; index++)
|
||||||
|
{
|
||||||
|
if(index % CHANNEL_COLUMNS == 0)
|
||||||
|
{
|
||||||
|
sb.append("<tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteRecord.SiteChannel channel = channels.get(index);
|
||||||
|
sb.append("<td style='padding-right:8px'>").append(frequencyFormat.format(channel.frequencyHz() / 1E6));
|
||||||
|
|
||||||
|
if("Control".equals(channel.use()))
|
||||||
|
{
|
||||||
|
sb.append(" <b>C</b>");
|
||||||
|
control = true;
|
||||||
|
}
|
||||||
|
else if("Alternate control".equals(channel.use()))
|
||||||
|
{
|
||||||
|
sb.append(" <b>A</b>");
|
||||||
|
control = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("</td>");
|
||||||
|
|
||||||
|
if(index % CHANNEL_COLUMNS == CHANNEL_COLUMNS - 1)
|
||||||
|
{
|
||||||
|
sb.append("</tr>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(shown % CHANNEL_COLUMNS != 0)
|
||||||
|
{
|
||||||
|
sb.append("</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("</table>");
|
||||||
|
List<String> notes = new ArrayList<>();
|
||||||
|
|
||||||
|
if(channels.size() > shown)
|
||||||
|
{
|
||||||
|
notes.add("+ " + (channels.size() - shown) + " more channels");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(control)
|
||||||
|
{
|
||||||
|
notes.add("C control, A alternate control");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!notes.isEmpty())
|
||||||
|
{
|
||||||
|
appendLine(sb, notes.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a line with the non-empty values separated by dots, or nothing when all are empty
|
||||||
|
*/
|
||||||
|
private static void appendLine(StringBuilder sb, String... values)
|
||||||
|
{
|
||||||
|
StringBuilder line = new StringBuilder();
|
||||||
|
|
||||||
|
for(String value: values)
|
||||||
|
{
|
||||||
|
if(value != null && !value.isBlank())
|
||||||
|
{
|
||||||
|
if(!line.isEmpty())
|
||||||
|
{
|
||||||
|
line.append(SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
line.append(escape(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!line.isEmpty())
|
||||||
|
{
|
||||||
|
sb.append("<div>").append(line).append("</div>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escape(String text)
|
||||||
|
{
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.map;
|
||||||
|
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
|
import java.awt.BasicStroke;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Point;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
import java.awt.Stroke;
|
||||||
|
import java.awt.geom.Arc2D;
|
||||||
|
import java.awt.geom.Ellipse2D;
|
||||||
|
import java.awt.geom.Path2D;
|
||||||
|
import java.awt.geom.Point2D;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.jdesktop.swingx.JXMapViewer;
|
||||||
|
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||||
|
import org.jdesktop.swingx.painter.AbstractPainter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a radio tower at each radio reference trunked system site, with the base of the tower at the site location.
|
||||||
|
*/
|
||||||
|
public class RadioReferenceSitePainter extends AbstractPainter<JXMapViewer>
|
||||||
|
{
|
||||||
|
private static final Color TOWER = new Color(240, 128, 32);
|
||||||
|
private static final Color OUTLINE = new Color(40, 40, 40, 200);
|
||||||
|
private static final Stroke TOWER_STROKE = new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
|
||||||
|
private static final Stroke OUTLINE_STROKE = new BasicStroke(3.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
|
||||||
|
private static final double HEIGHT = 16;
|
||||||
|
private static final double HALF_BASE = 5;
|
||||||
|
//Hit area around the tower, in pixels from the site location
|
||||||
|
private static final Rectangle HIT_AREA = new Rectangle(-10, -24, 20, 28);
|
||||||
|
|
||||||
|
private final Supplier<List<SiteRecord>> mSites;
|
||||||
|
private volatile boolean mShowSites = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @param sites to paint, read each time the map is painted
|
||||||
|
*/
|
||||||
|
public RadioReferenceSitePainter(Supplier<List<SiteRecord>> sites)
|
||||||
|
{
|
||||||
|
mSites = sites;
|
||||||
|
setAntialiasing(true);
|
||||||
|
setCacheable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the sites
|
||||||
|
*/
|
||||||
|
public void setShowSites(boolean show)
|
||||||
|
{
|
||||||
|
mShowSites = show;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isShowSites()
|
||||||
|
{
|
||||||
|
return mShowSites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sites whose tower is under the point, nearest first
|
||||||
|
* @param map viewer
|
||||||
|
* @param point in the viewer
|
||||||
|
* @return sites, or an empty list when the sites are hidden or none is under the point
|
||||||
|
*/
|
||||||
|
public List<SiteRecord> getSitesAt(JXMapViewer map, Point point)
|
||||||
|
{
|
||||||
|
if(!mShowSites)
|
||||||
|
{
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle viewport = map.getViewportBounds();
|
||||||
|
List<SiteRecord> sites = new ArrayList<>();
|
||||||
|
List<Double> distances = new ArrayList<>();
|
||||||
|
|
||||||
|
for(SiteRecord site: mSites.get())
|
||||||
|
{
|
||||||
|
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()),
|
||||||
|
map.getZoom());
|
||||||
|
double dx = point.x - (pixel.getX() - viewport.getX());
|
||||||
|
double dy = point.y - (pixel.getY() - viewport.getY());
|
||||||
|
|
||||||
|
if(HIT_AREA.contains(dx, dy))
|
||||||
|
{
|
||||||
|
//Nearest to the middle of the tower first
|
||||||
|
double distance = Math.hypot(dx, dy + (HEIGHT / 2.0));
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
while(index < distances.size() && distances.get(index) <= distance)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
sites.add(index, site);
|
||||||
|
distances.add(index, distance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height)
|
||||||
|
{
|
||||||
|
if(!mShowSites)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle viewport = map.getViewportBounds();
|
||||||
|
|
||||||
|
for(SiteRecord site: mSites.get())
|
||||||
|
{
|
||||||
|
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()),
|
||||||
|
map.getZoom());
|
||||||
|
double x = pixel.getX() - viewport.getX();
|
||||||
|
double y = pixel.getY() - viewport.getY();
|
||||||
|
|
||||||
|
if(x >= -20 && x <= width + 20 && y >= -4 && y <= height + 30)
|
||||||
|
{
|
||||||
|
paintTower(g, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a tower with its base at the point: an A-frame mast with a cross brace and radio waves at the top
|
||||||
|
*/
|
||||||
|
private static void paintTower(Graphics2D g, double x, double y)
|
||||||
|
{
|
||||||
|
double top = y - HEIGHT;
|
||||||
|
Path2D mast = new Path2D.Double();
|
||||||
|
mast.moveTo(x - HALF_BASE, y);
|
||||||
|
mast.lineTo(x, top);
|
||||||
|
mast.lineTo(x + HALF_BASE, y);
|
||||||
|
mast.moveTo(x - (HALF_BASE * 0.55), y - (HEIGHT * 0.45));
|
||||||
|
mast.lineTo(x + (HALF_BASE * 0.55), y - (HEIGHT * 0.45));
|
||||||
|
|
||||||
|
Arc2D leftInner = new Arc2D.Double(x - 5, top - 4, 10, 8, 135, 90, Arc2D.OPEN);
|
||||||
|
Arc2D rightInner = new Arc2D.Double(x - 5, top - 4, 10, 8, -45, 90, Arc2D.OPEN);
|
||||||
|
Arc2D leftOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, 135, 90, Arc2D.OPEN);
|
||||||
|
Arc2D rightOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, -45, 90, Arc2D.OPEN);
|
||||||
|
|
||||||
|
g.setStroke(OUTLINE_STROKE);
|
||||||
|
g.setColor(OUTLINE);
|
||||||
|
g.draw(mast);
|
||||||
|
g.draw(leftInner);
|
||||||
|
g.draw(rightInner);
|
||||||
|
g.draw(leftOuter);
|
||||||
|
g.draw(rightOuter);
|
||||||
|
|
||||||
|
g.setStroke(TOWER_STROKE);
|
||||||
|
g.setColor(TOWER);
|
||||||
|
g.draw(mast);
|
||||||
|
g.draw(leftInner);
|
||||||
|
g.draw(rightInner);
|
||||||
|
g.draw(leftOuter);
|
||||||
|
g.draw(rightOuter);
|
||||||
|
g.fill(new Ellipse2D.Double(x - 2, top - 2, 4, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/main/java/io/github/dsheirer/map/UserLocationPainter.java
Normal file
109
src/main/java/io/github/dsheirer/map/UserLocationPainter.java
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.map;
|
||||||
|
|
||||||
|
import java.awt.BasicStroke;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
import java.awt.geom.Ellipse2D;
|
||||||
|
import java.awt.geom.Line2D;
|
||||||
|
import java.awt.geom.Point2D;
|
||||||
|
import org.jdesktop.swingx.JXMapViewer;
|
||||||
|
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||||
|
import org.jdesktop.swingx.painter.AbstractPainter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a push-pin at the user's location, with the tip of the pin at the location.
|
||||||
|
*/
|
||||||
|
public class UserLocationPainter extends AbstractPainter<JXMapViewer>
|
||||||
|
{
|
||||||
|
private static final Color NEEDLE = new Color(90, 90, 90);
|
||||||
|
private static final Color HEAD = new Color(220, 40, 40);
|
||||||
|
private static final Color HEAD_OUTLINE = new Color(110, 0, 0);
|
||||||
|
private static final Color HIGHLIGHT = new Color(255, 255, 255, 170);
|
||||||
|
private static final Color SHADOW = new Color(0, 0, 0, 70);
|
||||||
|
private static final double NEEDLE_LENGTH = 14.0;
|
||||||
|
private static final double HEAD_RADIUS = 7.0;
|
||||||
|
|
||||||
|
private volatile GeoPosition mPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
*/
|
||||||
|
public UserLocationPainter()
|
||||||
|
{
|
||||||
|
setAntialiasing(true);
|
||||||
|
setCacheable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the location to mark
|
||||||
|
* @param position of the user, or null to remove the pin
|
||||||
|
*/
|
||||||
|
public void setPosition(GeoPosition position)
|
||||||
|
{
|
||||||
|
mPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location that is marked
|
||||||
|
* @return position, or null
|
||||||
|
*/
|
||||||
|
public GeoPosition getPosition()
|
||||||
|
{
|
||||||
|
return mPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height)
|
||||||
|
{
|
||||||
|
GeoPosition position = mPosition;
|
||||||
|
|
||||||
|
if(position == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle viewportBounds = map.getViewportBounds();
|
||||||
|
Point2D point = map.getTileFactory().geoToPixel(position, map.getZoom());
|
||||||
|
double x = point.getX() - viewportBounds.getX();
|
||||||
|
double y = point.getY() - viewportBounds.getY();
|
||||||
|
double headY = y - NEEDLE_LENGTH - HEAD_RADIUS;
|
||||||
|
|
||||||
|
//Shadow where the needle enters the map, so the tip reads as the location
|
||||||
|
g.setColor(SHADOW);
|
||||||
|
g.fill(new Ellipse2D.Double(x - 4, y - 2, 8, 4));
|
||||||
|
|
||||||
|
g.setColor(NEEDLE);
|
||||||
|
g.setStroke(new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
|
||||||
|
g.draw(new Line2D.Double(x, y, x, headY));
|
||||||
|
|
||||||
|
Ellipse2D head = new Ellipse2D.Double(x - HEAD_RADIUS, headY - HEAD_RADIUS, HEAD_RADIUS * 2, HEAD_RADIUS * 2);
|
||||||
|
g.setColor(HEAD);
|
||||||
|
g.fill(head);
|
||||||
|
g.setColor(HEAD_OUTLINE);
|
||||||
|
g.setStroke(new BasicStroke(1.0f));
|
||||||
|
g.draw(head);
|
||||||
|
|
||||||
|
g.setColor(HIGHLIGHT);
|
||||||
|
g.fill(new Ellipse2D.Double(x - HEAD_RADIUS / 2 - 1, headY - HEAD_RADIUS / 2 - 1, 4, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ public enum PreferenceType
|
|||||||
DIRECTORY,
|
DIRECTORY,
|
||||||
DUPLICATE_CALL_DETECTION,
|
DUPLICATE_CALL_DETECTION,
|
||||||
JMBE_LIBRARY,
|
JMBE_LIBRARY,
|
||||||
|
MAP,
|
||||||
MP3,
|
MP3,
|
||||||
MULTI_FREQUENCY,
|
MULTI_FREQUENCY,
|
||||||
PLAYLIST,
|
PLAYLIST,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import io.github.dsheirer.preference.duplicate.CallManagementPreference;
|
|||||||
import io.github.dsheirer.preference.event.DecodeEventPreference;
|
import io.github.dsheirer.preference.event.DecodeEventPreference;
|
||||||
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
|
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
|
||||||
import io.github.dsheirer.preference.javafx.JavaFxPreferences;
|
import io.github.dsheirer.preference.javafx.JavaFxPreferences;
|
||||||
|
import io.github.dsheirer.preference.map.MapPreference;
|
||||||
import io.github.dsheirer.preference.mp3.MP3Preference;
|
import io.github.dsheirer.preference.mp3.MP3Preference;
|
||||||
import io.github.dsheirer.preference.playback.PlaybackPreference;
|
import io.github.dsheirer.preference.playback.PlaybackPreference;
|
||||||
import io.github.dsheirer.preference.playlist.PlaylistPreference;
|
import io.github.dsheirer.preference.playlist.PlaylistPreference;
|
||||||
@@ -62,6 +63,7 @@ public class UserPreferences implements Listener<PreferenceType>
|
|||||||
private DirectoryPreference mDirectoryPreference;
|
private DirectoryPreference mDirectoryPreference;
|
||||||
private CallManagementPreference mCallManagementPreference;
|
private CallManagementPreference mCallManagementPreference;
|
||||||
private JmbeLibraryPreference mJmbeLibraryPreference;
|
private JmbeLibraryPreference mJmbeLibraryPreference;
|
||||||
|
private MapPreference mMapPreference;
|
||||||
private MP3Preference mMP3Preference;
|
private MP3Preference mMP3Preference;
|
||||||
private PlaybackPreference mPlaybackPreference;
|
private PlaybackPreference mPlaybackPreference;
|
||||||
private PlaylistPreference mPlaylistPreference;
|
private PlaylistPreference mPlaylistPreference;
|
||||||
@@ -205,6 +207,14 @@ public class UserPreferences implements Listener<PreferenceType>
|
|||||||
return mSwingPreference;
|
return mSwingPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map view preferences: the last center point and zoom level
|
||||||
|
*/
|
||||||
|
public MapPreference getMapPreference()
|
||||||
|
{
|
||||||
|
return mMapPreference;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call management and duplicate call detection preferences
|
* Call management and duplicate call detection preferences
|
||||||
*/
|
*/
|
||||||
@@ -224,6 +234,7 @@ public class UserPreferences implements Listener<PreferenceType>
|
|||||||
mDirectoryPreference = new DirectoryPreference(this::receive);
|
mDirectoryPreference = new DirectoryPreference(this::receive);
|
||||||
mCallManagementPreference = new CallManagementPreference(this::receive);
|
mCallManagementPreference = new CallManagementPreference(this::receive);
|
||||||
mJmbeLibraryPreference = new JmbeLibraryPreference(this::receive);
|
mJmbeLibraryPreference = new JmbeLibraryPreference(this::receive);
|
||||||
|
mMapPreference = new MapPreference(this::receive);
|
||||||
mMP3Preference = new MP3Preference(this::receive);
|
mMP3Preference = new MP3Preference(this::receive);
|
||||||
mPlaybackPreference = new PlaybackPreference(this::receive);
|
mPlaybackPreference = new PlaybackPreference(this::receive);
|
||||||
mPlaylistPreference = new PlaylistPreference(this::receive, mDirectoryPreference);
|
mPlaylistPreference = new PlaylistPreference(this::receive, mDirectoryPreference);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.preference.map;
|
||||||
|
|
||||||
|
import io.github.dsheirer.preference.Preference;
|
||||||
|
import io.github.dsheirer.preference.PreferenceType;
|
||||||
|
import io.github.dsheirer.sample.Listener;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.prefs.Preferences;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map preferences: the last center point and zoom level of the map, restored when the application starts, and the
|
||||||
|
* map layers to show. The user's own location is a radio reference preference, shown on the map as a pin.
|
||||||
|
*/
|
||||||
|
public class MapPreference extends Preference
|
||||||
|
{
|
||||||
|
private static final Logger mLog = LoggerFactory.getLogger(MapPreference.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level when none is stored
|
||||||
|
*/
|
||||||
|
public static final int DEFAULT_ZOOM = 7;
|
||||||
|
|
||||||
|
private static final String CENTER = "center";
|
||||||
|
private static final String ZOOM = "zoom";
|
||||||
|
private static final String SHOW_RADIO_REFERENCE_SITES = "show.radioreference.sites";
|
||||||
|
private final Preferences mPreferences = Preferences.userNodeForPackage(MapPreference.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map center point
|
||||||
|
* @param latitude in decimal degrees, -90 to 90
|
||||||
|
* @param longitude in decimal degrees, -180 to 180
|
||||||
|
*/
|
||||||
|
public record Center(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @throws IllegalArgumentException if the coordinates are out of range
|
||||||
|
*/
|
||||||
|
public Center
|
||||||
|
{
|
||||||
|
if(!Double.isFinite(latitude) || !Double.isFinite(longitude) || Math.abs(latitude) > 90.0 ||
|
||||||
|
Math.abs(longitude) > 180.0)
|
||||||
|
{
|
||||||
|
throw new IllegalArgumentException("Invalid map center: " + latitude + ", " + longitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @param updateListener to receive notifications that a preference has been updated
|
||||||
|
*/
|
||||||
|
public MapPreference(Listener<PreferenceType> updateListener)
|
||||||
|
{
|
||||||
|
super(updateListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PreferenceType getPreferenceType()
|
||||||
|
{
|
||||||
|
return PreferenceType.MAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last center point of the map. Can be called from any thread.
|
||||||
|
* @return center, or null if none is stored
|
||||||
|
*/
|
||||||
|
public Center getCenter()
|
||||||
|
{
|
||||||
|
String value = mPreferences.get(CENTER, null);
|
||||||
|
|
||||||
|
if(value != null)
|
||||||
|
{
|
||||||
|
String[] parts = value.split(",");
|
||||||
|
|
||||||
|
if(parts.length == 2)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new Center(Double.parseDouble(parts[0]), Double.parseDouble(parts[1]));
|
||||||
|
}
|
||||||
|
catch(IllegalArgumentException e)
|
||||||
|
{
|
||||||
|
mLog.warn("Ignoring invalid stored map center [" + value + "]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the center point of the map
|
||||||
|
* @param center to store
|
||||||
|
*/
|
||||||
|
public void setCenter(Center center)
|
||||||
|
{
|
||||||
|
if(center != null && !Objects.equals(center, getCenter()))
|
||||||
|
{
|
||||||
|
//Stored as one value so that a reader never sees the latitude of one point with the longitude of another
|
||||||
|
mPreferences.put(CENTER, center.latitude() + "," + center.longitude());
|
||||||
|
notifyPreferenceUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last zoom level of the map. Can be called from any thread.
|
||||||
|
* @return zoom level, or {@link #DEFAULT_ZOOM} if none is stored
|
||||||
|
*/
|
||||||
|
public int getZoom()
|
||||||
|
{
|
||||||
|
return mPreferences.getInt(ZOOM, DEFAULT_ZOOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the zoom level of the map
|
||||||
|
* @param zoom level
|
||||||
|
*/
|
||||||
|
public void setZoom(int zoom)
|
||||||
|
{
|
||||||
|
if(zoom != getZoom())
|
||||||
|
{
|
||||||
|
mPreferences.putInt(ZOOM, zoom);
|
||||||
|
notifyPreferenceUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the map shows the radio reference trunked system sites near the user
|
||||||
|
*/
|
||||||
|
public boolean isShowRadioReferenceSites()
|
||||||
|
{
|
||||||
|
return mPreferences.getBoolean(SHOW_RADIO_REFERENCE_SITES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the radio reference trunked system sites on the map
|
||||||
|
*/
|
||||||
|
public void setShowRadioReferenceSites(boolean show)
|
||||||
|
{
|
||||||
|
if(show != isShowRadioReferenceSites())
|
||||||
|
{
|
||||||
|
mPreferences.putBoolean(SHOW_RADIO_REFERENCE_SITES, show);
|
||||||
|
notifyPreferenceUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the map view stored in the settings file by older versions of the application into this preference.
|
||||||
|
* Does nothing once a center point is stored here.
|
||||||
|
* @param latitude of the stored map view
|
||||||
|
* @param longitude of the stored map view
|
||||||
|
* @param zoom of the stored map view
|
||||||
|
*/
|
||||||
|
public void migrateLegacyView(double latitude, double longitude, int zoom)
|
||||||
|
{
|
||||||
|
if(getCenter() == null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
setCenter(new Center(latitude, longitude));
|
||||||
|
setZoom(zoom);
|
||||||
|
mLog.info("Moved the map view from the settings file to the map preferences");
|
||||||
|
}
|
||||||
|
catch(IllegalArgumentException e)
|
||||||
|
{
|
||||||
|
mLog.warn("Ignoring invalid map view from the settings file: " + latitude + ", " + longitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import io.github.dsheirer.service.radioreference.RadioReference;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.prefs.Preferences;
|
import java.util.prefs.Preferences;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +59,8 @@ public class RadioReferencePreference extends Preference
|
|||||||
private static final String SHOW_CHANNEL_EDITOR_COUNTY = "create.and.show.editor.county";
|
private static final String SHOW_CHANNEL_EDITOR_COUNTY = "create.and.show.editor.county";
|
||||||
private static final String ENCRYPTED_TALKGROUP_DO_NOT_MONITOR = "encrypted.talkgroup.import.do.not.monitor";
|
private static final String ENCRYPTED_TALKGROUP_DO_NOT_MONITOR = "encrypted.talkgroup.import.do.not.monitor";
|
||||||
private static final String CREATE_AND_SHOW_CHANNEL_EDITOR = "create.and.show.channel.editor";
|
private static final String CREATE_AND_SHOW_CHANNEL_EDITOR = "create.and.show.channel.editor";
|
||||||
|
private static final String LOCATION = "location";
|
||||||
|
private static final String LOCATION_LABEL = "location.label";
|
||||||
|
|
||||||
private String mUserName;
|
private String mUserName;
|
||||||
private String mPassword;
|
private String mPassword;
|
||||||
@@ -513,4 +516,116 @@ public class RadioReferencePreference extends Preference
|
|||||||
mPreferences.putBoolean(CREATE_AND_SHOW_CHANNEL_EDITOR, show);
|
mPreferences.putBoolean(CREATE_AND_SHOW_CHANNEL_EDITOR, show);
|
||||||
notifyPreferenceUpdated();
|
notifyPreferenceUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's location, for ranking radio reference results by distance.
|
||||||
|
* @param latitude in decimal degrees, -90 to 90
|
||||||
|
* @param longitude in decimal degrees, -180 to 180
|
||||||
|
* @param label describing the location, e.g. the city and zip code that it was looked up from, or null when the
|
||||||
|
* coordinates were entered by hand
|
||||||
|
*/
|
||||||
|
public record Location(double latitude, double longitude, String label)
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructs an instance
|
||||||
|
* @throws IllegalArgumentException if the coordinates are out of range
|
||||||
|
*/
|
||||||
|
public Location
|
||||||
|
{
|
||||||
|
if(!isValid(latitude, longitude))
|
||||||
|
{
|
||||||
|
throw new IllegalArgumentException("Invalid location: " + latitude + ", " + longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
label = label == null || label.isBlank() ? null : label.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the coordinates are within range
|
||||||
|
* @param latitude in decimal degrees
|
||||||
|
* @param longitude in decimal degrees
|
||||||
|
* @return true if valid
|
||||||
|
*/
|
||||||
|
public static boolean isValid(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
return Double.isFinite(latitude) && Double.isFinite(longitude) && Math.abs(latitude) <= 90.0 &&
|
||||||
|
Math.abs(longitude) <= 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates for display
|
||||||
|
* @return text such as "43.0481, -76.1474"
|
||||||
|
*/
|
||||||
|
public String coordinates()
|
||||||
|
{
|
||||||
|
return String.format(Locale.ROOT, "%.4f, %.4f", latitude, longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location for display
|
||||||
|
* @return text such as "Syracuse, New York 13202 (43.0481, -76.1474)", or the coordinates when there's no label
|
||||||
|
*/
|
||||||
|
public String describe()
|
||||||
|
{
|
||||||
|
return label != null ? label + " (" + coordinates() + ")" : coordinates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's location, for ranking radio reference results by distance. Can be called from any thread.
|
||||||
|
* @return location, or null if the user hasn't set one
|
||||||
|
*/
|
||||||
|
public Location getLocation()
|
||||||
|
{
|
||||||
|
String value = mPreferences.get(LOCATION, null);
|
||||||
|
|
||||||
|
if(value != null)
|
||||||
|
{
|
||||||
|
String[] parts = value.split(",");
|
||||||
|
|
||||||
|
if(parts.length == 2)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new Location(Double.parseDouble(parts[0]), Double.parseDouble(parts[1]),
|
||||||
|
mPreferences.get(LOCATION_LABEL, null));
|
||||||
|
}
|
||||||
|
catch(IllegalArgumentException e)
|
||||||
|
{
|
||||||
|
mLog.warn("Ignoring invalid stored radio reference location [" + value + "]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user's location
|
||||||
|
* @param location to store, or null to clear the location
|
||||||
|
*/
|
||||||
|
public void setLocation(Location location)
|
||||||
|
{
|
||||||
|
if(location != null)
|
||||||
|
{
|
||||||
|
//Stored as one value so that a reader never sees the latitude of one location with the longitude of another
|
||||||
|
mPreferences.put(LOCATION, location.latitude() + "," + location.longitude());
|
||||||
|
|
||||||
|
if(location.label() != null)
|
||||||
|
{
|
||||||
|
mPreferences.put(LOCATION_LABEL, location.label());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mPreferences.remove(LOCATION_LABEL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mPreferences.remove(LOCATION);
|
||||||
|
mPreferences.remove(LOCATION_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyPreferenceUpdated();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
/*
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
*
|
*
|
||||||
* * ******************************************************************************
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* * Copyright (C) 2014-2020 Dennis Sheirer
|
* it under the terms of the GNU General Public License as published by
|
||||||
* *
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* * This program is free software: you can redistribute it and/or modify
|
* (at your option) any later version.
|
||||||
* * it under the terms of the GNU General Public License as published by
|
|
||||||
* * the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* * (at your option) any later version.
|
|
||||||
* *
|
|
||||||
* * This program is distributed in the hope that it will be useful,
|
|
||||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* * GNU General Public License for more details.
|
|
||||||
* *
|
|
||||||
* * You should have received a copy of the GNU General Public License
|
|
||||||
* * along with this program. If not, see <http://www.gnu.org/licenses/>
|
|
||||||
* * *****************************************************************************
|
|
||||||
*
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
*
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.github.dsheirer.service.radioreference;
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.google.common.cache.CacheBuilder;
|
import com.google.common.cache.CacheBuilder;
|
||||||
import com.google.common.cache.CacheLoader;
|
import com.google.common.cache.CacheLoader;
|
||||||
import com.google.common.cache.LoadingCache;
|
import com.google.common.cache.LoadingCache;
|
||||||
|
import com.google.common.util.concurrent.ExecutionError;
|
||||||
|
import com.google.common.util.concurrent.UncheckedExecutionException;
|
||||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
import io.github.dsheirer.rrapi.RadioReferenceService;
|
import io.github.dsheirer.rrapi.RadioReferenceService;
|
||||||
import io.github.dsheirer.rrapi.type.AgencyInfo;
|
import io.github.dsheirer.rrapi.type.AgencyInfo;
|
||||||
@@ -35,21 +35,39 @@ import io.github.dsheirer.rrapi.type.Site;
|
|||||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||||
import io.github.dsheirer.rrapi.type.SystemInformation;
|
import io.github.dsheirer.rrapi.type.SystemInformation;
|
||||||
import io.github.dsheirer.rrapi.type.Talkgroup;
|
import io.github.dsheirer.rrapi.type.Talkgroup;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides local caching for frequently retrieved items from the radio reference service
|
* Provides local caching for frequently retrieved items from the radio reference service.
|
||||||
|
*
|
||||||
|
* Cached entries expire after {@link #CACHE_EXPIRATION} so that a long running application sees changes to the radio
|
||||||
|
* reference database. Failed calls aren't cached: the error is thrown to the caller and the next call tries again.
|
||||||
|
* Cached lists are read-only because they are shared by all callers.
|
||||||
*/
|
*/
|
||||||
public class CachingRadioReferenceService extends RadioReferenceService
|
public class CachingRadioReferenceService extends RadioReferenceService
|
||||||
{
|
{
|
||||||
private LoadingCache<Integer,AgencyInfo> mAgencyInfoCache;
|
/**
|
||||||
private LoadingCache<Integer,CountyInfo> mCountyInfoCache;
|
* Limit for receiving each response. The JDK HTTP client applies the request timeout until the response body is
|
||||||
private LoadingCache<Integer,StateInfo> mStateInfoCache;
|
* fully received, so this is longer than the library's 30 second default to allow the talkgroup list of a large
|
||||||
private LoadingCache<Integer,SystemInformation> mSystemInfoCache;
|
* system to download on a slow connection.
|
||||||
private LoadingCache<Integer,List<Site>> mSystemSitesCache;
|
*/
|
||||||
private LoadingCache<Integer,List<Frequency>> mSubCategoryFrequencyCache;
|
public static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(2);
|
||||||
private LoadingCache<Integer,List<Talkgroup>> mTalkgroupCache;
|
|
||||||
|
/**
|
||||||
|
* Time that a cached entry is kept after it is retrieved
|
||||||
|
*/
|
||||||
|
public static final Duration CACHE_EXPIRATION = Duration.ofHours(1);
|
||||||
|
|
||||||
|
private final LoadingCache<Integer,AgencyInfo> mAgencyInfoCache;
|
||||||
|
private final LoadingCache<Integer,CountyInfo> mCountyInfoCache;
|
||||||
|
private final LoadingCache<Integer,StateInfo> mStateInfoCache;
|
||||||
|
private final LoadingCache<Integer,SystemInformation> mSystemInfoCache;
|
||||||
|
private final LoadingCache<Integer,List<Site>> mSystemSitesCache;
|
||||||
|
private final LoadingCache<Integer,List<Frequency>> mSubCategoryFrequencyCache;
|
||||||
|
private final LoadingCache<Integer,List<Talkgroup>> mTalkgroupCache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs an instance of the service
|
* Constructs an instance of the service
|
||||||
@@ -59,175 +77,164 @@ public class CachingRadioReferenceService extends RadioReferenceService
|
|||||||
*/
|
*/
|
||||||
public CachingRadioReferenceService(AuthorizationInformation authorizationInformation) throws RadioReferenceException
|
public CachingRadioReferenceService(AuthorizationInformation authorizationInformation) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
super(authorizationInformation);
|
super(authorizationInformation, DEFAULT_CONNECT_TIMEOUT, REQUEST_TIMEOUT);
|
||||||
|
|
||||||
mAgencyInfoCache = CacheBuilder.newBuilder()
|
mAgencyInfoCache = cache(1000, super::getAgencyInfo);
|
||||||
.maximumSize(1000)
|
mCountyInfoCache = cache(1000, super::getCountyInfo);
|
||||||
.build(new CacheLoader<Integer,AgencyInfo>()
|
mStateInfoCache = cache(100, super::getStateInfo);
|
||||||
{
|
mSystemInfoCache = cache(100, super::getSystemInformation);
|
||||||
@Override
|
mSystemSitesCache = cache(1000, id -> readOnly(super.getSites(id)));
|
||||||
public AgencyInfo load(Integer key) throws Exception
|
mSubCategoryFrequencyCache = cache(1000, id -> readOnly(super.getSubCategoryFrequencies(id)));
|
||||||
{
|
mTalkgroupCache = cache(100, id -> readOnly(super.getTalkgroups(id)));
|
||||||
return CachingRadioReferenceService.super.getAgencyInfo(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mCountyInfoCache = CacheBuilder.newBuilder()
|
|
||||||
.maximumSize(1000)
|
|
||||||
.build(new CacheLoader<Integer,CountyInfo>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public CountyInfo load(Integer key) throws Exception
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getCountyInfo(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mStateInfoCache = CacheBuilder.newBuilder()
|
|
||||||
.maximumSize(100)
|
|
||||||
.build(new CacheLoader<Integer,StateInfo>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public StateInfo load(Integer key) throws Exception
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getStateInfo(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mSystemInfoCache = CacheBuilder.newBuilder()
|
|
||||||
.maximumSize(100)
|
|
||||||
.build(new CacheLoader<Integer,SystemInformation>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public SystemInformation load(Integer key) throws Exception
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getSystemInformation(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mSystemSitesCache = CacheBuilder.newBuilder()
|
|
||||||
.maximumSize(1000)
|
|
||||||
.build(new CacheLoader<Integer,List<Site>>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public List<Site> load(Integer key) throws Exception
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getSites(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mSubCategoryFrequencyCache = CacheBuilder.newBuilder()
|
|
||||||
.maximumSize(1000)
|
|
||||||
.build(new CacheLoader<Integer,List<Frequency>>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public List<Frequency> load(Integer key) throws Exception
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getSubCategoryFrequencies(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mTalkgroupCache = CacheBuilder.newBuilder()
|
|
||||||
.maximumSize(100)
|
|
||||||
.build(new CacheLoader<Integer,List<Talkgroup>>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public List<Talkgroup> load(Integer key) throws Exception
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getTalkgroups(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgencyInfo getAgencyInfo(int agencyId) throws RadioReferenceException
|
public AgencyInfo getAgencyInfo(int agencyId) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
return get(mAgencyInfoCache, agencyId);
|
||||||
{
|
|
||||||
return mAgencyInfoCache.get(agencyId);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getAgencyInfo(agencyId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CountyInfo getCountyInfo(final int countyId) throws RadioReferenceException
|
public CountyInfo getCountyInfo(final int countyId) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
return get(mCountyInfoCache, countyId);
|
||||||
{
|
|
||||||
return mCountyInfoCache.get(countyId);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getCountyInfo(countyId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StateInfo getStateInfo(final int stateId) throws RadioReferenceException
|
public StateInfo getStateInfo(final int stateId) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
return get(mStateInfoCache, stateId);
|
||||||
{
|
|
||||||
return mStateInfoCache.get(stateId);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getStateInfo(stateId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SystemInformation getSystemInformation(int systemId) throws RadioReferenceException
|
public SystemInformation getSystemInformation(int systemId) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
return get(mSystemInfoCache, systemId);
|
||||||
{
|
|
||||||
return mSystemInfoCache.get(systemId);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getSystemInformation(systemId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sites for the system
|
||||||
|
* @param systemId to query
|
||||||
|
* @return read-only list of sites
|
||||||
|
* @throws RadioReferenceException if there is an error
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<Site> getSites(int systemId) throws RadioReferenceException
|
public List<Site> getSites(int systemId) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
return get(mSystemSitesCache, systemId);
|
||||||
{
|
|
||||||
return mSystemSitesCache.get(systemId);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getSites(systemId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequencies for the subcategory
|
||||||
|
* @param subCategoryId to query
|
||||||
|
* @return read-only list of frequencies
|
||||||
|
* @throws RadioReferenceException if there is an error
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<Frequency> getSubCategoryFrequencies(int subCategoryId) throws RadioReferenceException
|
public List<Frequency> getSubCategoryFrequencies(int subCategoryId) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
return get(mSubCategoryFrequencyCache, subCategoryId);
|
||||||
{
|
|
||||||
return mSubCategoryFrequencyCache.get(subCategoryId);
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
return CachingRadioReferenceService.super.getSubCategoryFrequencies(subCategoryId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talkgroups for the system
|
||||||
|
* @param systemId to query
|
||||||
|
* @return read-only list of talkgroups
|
||||||
|
* @throws RadioReferenceException if there is an error
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<Talkgroup> getTalkgroups(int systemId) throws RadioReferenceException
|
public List<Talkgroup> getTalkgroups(int systemId) throws RadioReferenceException
|
||||||
|
{
|
||||||
|
return get(mTalkgroupCache, systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a value from the web service
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
@VisibleForTesting
|
||||||
|
interface Loader<V>
|
||||||
|
{
|
||||||
|
V load(int id) throws RadioReferenceException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cache that loads values with the loader
|
||||||
|
* @param maximumSize of the cache
|
||||||
|
* @param loader to call the web service
|
||||||
|
* @return cache
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static <V> LoadingCache<Integer,V> cache(int maximumSize, Loader<V> loader)
|
||||||
|
{
|
||||||
|
return CacheBuilder.newBuilder()
|
||||||
|
.maximumSize(maximumSize)
|
||||||
|
.expireAfterWrite(CACHE_EXPIRATION)
|
||||||
|
.build(new CacheLoader<Integer,V>()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public V load(Integer id) throws RadioReferenceException
|
||||||
|
{
|
||||||
|
return loader.load(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the cached value, or loads it from the web service.
|
||||||
|
* @param cache to query
|
||||||
|
* @param id of the value
|
||||||
|
* @return value, or null if the web service didn't provide one. Null values aren't cached.
|
||||||
|
* @throws RadioReferenceException thrown by the web service call
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static <V> V get(LoadingCache<Integer,V> cache, int id) throws RadioReferenceException
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return mTalkgroupCache.get(systemId);
|
return cache.get(id);
|
||||||
}
|
}
|
||||||
catch(Exception e)
|
catch(CacheLoader.InvalidCacheLoadException e)
|
||||||
{
|
{
|
||||||
return CachingRadioReferenceService.super.getTalkgroups(systemId);
|
//The loader returned null, which the cache doesn't store
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch(ExecutionException e)
|
||||||
|
{
|
||||||
|
if(e.getCause() instanceof RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
throw rre;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RadioReferenceException("Error retrieving value from radio reference service", e.getCause());
|
||||||
|
}
|
||||||
|
catch(UncheckedExecutionException e)
|
||||||
|
{
|
||||||
|
if(e.getCause() instanceof RuntimeException re)
|
||||||
|
{
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
catch(ExecutionError e)
|
||||||
|
{
|
||||||
|
if(e.getCause() instanceof Error error)
|
||||||
|
{
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of the list
|
||||||
|
* @param list to wrap, or null
|
||||||
|
* @return read-only list, or null if the list is null
|
||||||
|
*/
|
||||||
|
private static <T> List<T> readOnly(List<T> list)
|
||||||
|
{
|
||||||
|
return list != null ? Collections.unmodifiableList(list) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats distances for display, in miles where the locale's country measures road distances in miles, otherwise in
|
||||||
|
* kilometers.
|
||||||
|
*/
|
||||||
|
public final class DistanceFormat
|
||||||
|
{
|
||||||
|
private static final double KILOMETERS_PER_MILE = 1.609344;
|
||||||
|
private static final boolean MILES = usesMiles(Locale.getDefault());
|
||||||
|
|
||||||
|
private DistanceFormat()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance for display in the default locale's unit, e.g. 12 mi or < 1 km
|
||||||
|
*/
|
||||||
|
public static String format(double kilometers)
|
||||||
|
{
|
||||||
|
return format(kilometers, MILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance for display, in miles or kilometers
|
||||||
|
*/
|
||||||
|
public static String format(double kilometers, boolean miles)
|
||||||
|
{
|
||||||
|
double value = miles ? kilometers / KILOMETERS_PER_MILE : kilometers;
|
||||||
|
String unit = miles ? " mi" : " km";
|
||||||
|
return value < 1.0 ? "< 1" + unit : Math.round(value) + unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the locale's country measures road distances in miles
|
||||||
|
*/
|
||||||
|
public static boolean usesMiles(Locale locale)
|
||||||
|
{
|
||||||
|
String country = locale.getCountry();
|
||||||
|
return "US".equals(country) || "GB".equals(country) || "LR".equals(country) || "MM".equals(country);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio reference frequency near the user, a candidate for a signal at that frequency. Stored in the frequency index
|
||||||
|
* file, so changing the fields needs a new index file version.
|
||||||
|
*
|
||||||
|
* @param frequencyHz downlink frequency in Hertz
|
||||||
|
* @param title of the frequency: the description or alpha tag of a conventional frequency, or the system name of a
|
||||||
|
* trunked site frequency
|
||||||
|
* @param alphaTag of a conventional frequency, or null
|
||||||
|
* @param mode of a conventional frequency (e.g. FMN) or the system type of a trunked site frequency, or null
|
||||||
|
* @param tone of a conventional frequency as radio reference lists it, or null
|
||||||
|
* @param use of a trunked site frequency (e.g. Control), or null
|
||||||
|
* @param encrypted true if radio reference lists the frequency as encrypted
|
||||||
|
* @param source where the frequency is listed: county or agency, category and subcategory, or system site
|
||||||
|
* @param latitude of the frequency's coverage (subcategory, county, agency or site), or null when unknown
|
||||||
|
* @param longitude of the frequency's coverage, or null when unknown
|
||||||
|
* @param systemId radio reference id of the trunked system of a site frequency, or null for a conventional frequency
|
||||||
|
* @param siteId radio reference id of the site of a site frequency, or null for a conventional frequency
|
||||||
|
*/
|
||||||
|
public record FrequencyCandidate(long frequencyHz, String title, String alphaTag, String mode, String tone, String use,
|
||||||
|
boolean encrypted, String source, Double latitude, Double longitude, Integer systemId,
|
||||||
|
Integer siteId)
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Indicates if this is a trunked system site frequency. Not stored in the index file.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isSiteFrequency()
|
||||||
|
{
|
||||||
|
return systemId != null && siteId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the candidate has a location
|
||||||
|
*/
|
||||||
|
public boolean hasLocation()
|
||||||
|
{
|
||||||
|
return latitude != null && longitude != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,9 +21,9 @@ package io.github.dsheirer.service.radioreference;
|
|||||||
|
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import io.github.dsheirer.preference.UserPreferences;
|
import io.github.dsheirer.preference.UserPreferences;
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
import io.github.dsheirer.rrapi.RadioReferenceService;
|
import io.github.dsheirer.rrapi.RadioReferenceService;
|
||||||
import io.github.dsheirer.rrapi.response.Fault;
|
|
||||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
||||||
import io.github.dsheirer.rrapi.type.CountryInfo;
|
import io.github.dsheirer.rrapi.type.CountryInfo;
|
||||||
import io.github.dsheirer.rrapi.type.County;
|
import io.github.dsheirer.rrapi.type.County;
|
||||||
@@ -32,6 +32,8 @@ import io.github.dsheirer.rrapi.type.Flavor;
|
|||||||
import io.github.dsheirer.rrapi.type.State;
|
import io.github.dsheirer.rrapi.type.State;
|
||||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||||
import io.github.dsheirer.rrapi.type.UserInfo;
|
import io.github.dsheirer.rrapi.type.UserInfo;
|
||||||
|
import io.github.dsheirer.rrapi.type.ZipInfo;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
@@ -39,6 +41,9 @@ import java.util.Date;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Scanner;
|
import java.util.Scanner;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
@@ -57,12 +62,14 @@ public class RadioReference
|
|||||||
private RadioReferenceService mRadioReferenceService;
|
private RadioReferenceService mRadioReferenceService;
|
||||||
private UserPreferences mUserPreferences;
|
private UserPreferences mUserPreferences;
|
||||||
private AuthorizationInformation mAuthorizationInformation;
|
private AuthorizationInformation mAuthorizationInformation;
|
||||||
|
private final AtomicLong mLoginRequest = new AtomicLong();
|
||||||
|
private final RadioReferenceFrequencyIndex mFrequencyIndex;
|
||||||
private StringProperty mUserName = new SimpleStringProperty();
|
private StringProperty mUserName = new SimpleStringProperty();
|
||||||
private StringProperty mPassword = new SimpleStringProperty();
|
private StringProperty mPassword = new SimpleStringProperty();
|
||||||
private StringProperty mAccountExpiresProperty = new SimpleStringProperty();
|
private StringProperty mAccountExpiresProperty = new SimpleStringProperty();
|
||||||
private BooleanProperty mAvailable = new SimpleBooleanProperty();
|
private BooleanProperty mAvailable = new SimpleBooleanProperty();
|
||||||
private BooleanProperty mPremiumAccount = new SimpleBooleanProperty();
|
private BooleanProperty mPremiumAccount = new SimpleBooleanProperty();
|
||||||
private LoginStatus mLoginStatus = LoginStatus.UNKNOWN;
|
private volatile LoginStatus mLoginStatus = LoginStatus.UNKNOWN;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum to determine "validity" of a RR Login
|
* Enum to determine "validity" of a RR Login
|
||||||
@@ -82,10 +89,47 @@ public class RadioReference
|
|||||||
public RadioReference(UserPreferences userPreferences)
|
public RadioReference(UserPreferences userPreferences)
|
||||||
{
|
{
|
||||||
mUserPreferences = userPreferences;
|
mUserPreferences = userPreferences;
|
||||||
|
mFrequencyIndex = new RadioReferenceFrequencyIndex(this, userPreferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Externally monitored and controlled status indicator for the service logged-on state
|
* User preferences, for the location, credentials and talkgroup formats
|
||||||
|
*/
|
||||||
|
public UserPreferences getUserPreferences()
|
||||||
|
{
|
||||||
|
return mUserPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the radio reference frequencies near the user's location. Idle until started.
|
||||||
|
*/
|
||||||
|
public RadioReferenceFrequencyIndex getFrequencyIndex()
|
||||||
|
{
|
||||||
|
return mFrequencyIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in with the credentials stored in the user preferences, if there are any and no login has been made yet.
|
||||||
|
* Logs in on a background thread and updates the properties on the FX thread, so the JavaFX toolkit must be
|
||||||
|
* running.
|
||||||
|
*/
|
||||||
|
public void loginWithStoredCredentials()
|
||||||
|
{
|
||||||
|
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
|
||||||
|
|
||||||
|
if(mLoginRequest.get() == 0 && preference.hasStoredCredentials())
|
||||||
|
{
|
||||||
|
AuthorizationInformation credentials = preference.getAuthorizationInformation();
|
||||||
|
|
||||||
|
if(credentials != null)
|
||||||
|
{
|
||||||
|
setAuthorizationInformation(credentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Externally monitored and controlled status indicator for the service logged-on state. Updated on the FX thread.
|
||||||
*/
|
*/
|
||||||
public BooleanProperty availableProperty()
|
public BooleanProperty availableProperty()
|
||||||
{
|
{
|
||||||
@@ -93,7 +137,7 @@ public class RadioReference
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Externally monitored and controlled status indicator for a Premium RR Account
|
* Externally monitored and controlled status indicator for a Premium RR Account. Updated on the FX thread.
|
||||||
*/
|
*/
|
||||||
public BooleanProperty premiumAccountProperty()
|
public BooleanProperty premiumAccountProperty()
|
||||||
{
|
{
|
||||||
@@ -141,7 +185,7 @@ public class RadioReference
|
|||||||
* @return service
|
* @return service
|
||||||
* @throws RadioReferenceException if there is an issue creating the service (ie login credentials missing)
|
* @throws RadioReferenceException if there is an issue creating the service (ie login credentials missing)
|
||||||
*/
|
*/
|
||||||
public RadioReferenceService getService() throws RadioReferenceException
|
public synchronized RadioReferenceService getService() throws RadioReferenceException
|
||||||
{
|
{
|
||||||
if(mRadioReferenceService == null)
|
if(mRadioReferenceService == null)
|
||||||
{
|
{
|
||||||
@@ -162,6 +206,102 @@ public class RadioReference
|
|||||||
return mRadioReferenceService;
|
return mRadioReferenceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location to rank radio reference results by distance from the user: the location set in the preferences, or
|
||||||
|
* else the center of the preferred county. Calls the web service for the preferred county, so don't call this
|
||||||
|
* on the FX thread.
|
||||||
|
* @return location, or null if the user hasn't set a location and there's no preferred county with a location, or
|
||||||
|
* the account can't access the service
|
||||||
|
* @throws RadioReferenceException if the preferred county lookup fails
|
||||||
|
*/
|
||||||
|
public UserLocation getUserLocation() throws RadioReferenceException
|
||||||
|
{
|
||||||
|
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
|
||||||
|
RadioReferencePreference.Location location = preference.getLocation();
|
||||||
|
|
||||||
|
if(location != null)
|
||||||
|
{
|
||||||
|
return new UserLocation(location.latitude(), location.longitude(), location.label(),
|
||||||
|
UserLocation.Source.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
int countyId = preference.getPreferredCountyId();
|
||||||
|
|
||||||
|
if(countyId > 0 && premiumAccountProperty().get())
|
||||||
|
{
|
||||||
|
CountyInfo countyInfo = getService().getCountyInfo(countyId);
|
||||||
|
|
||||||
|
if(countyInfo != null && countyInfo.getCoverage().hasLocation())
|
||||||
|
{
|
||||||
|
return new UserLocation(countyInfo.getLatitude(), countyInfo.getLongitude(), countyInfo.getName(),
|
||||||
|
UserLocation.Source.PREFERRED_COUNTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up the location of a United States zip code
|
||||||
|
* @param zipcode five digit zip code
|
||||||
|
* @return location with a label naming the city, state and zip code
|
||||||
|
* @throws RadioReferenceException if the lookup fails or the service has no location for the zip code
|
||||||
|
*/
|
||||||
|
public RadioReferencePreference.Location lookupZipcode(int zipcode) throws RadioReferenceException
|
||||||
|
{
|
||||||
|
RadioReferenceService service = getService();
|
||||||
|
ZipInfo zipInfo = service.getZipcodeInfo(zipcode);
|
||||||
|
String zip = String.format("%05d", zipcode);
|
||||||
|
|
||||||
|
if(zipInfo != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double latitude = Double.parseDouble(zipInfo.getLatitude().trim());
|
||||||
|
double longitude = Double.parseDouble(zipInfo.getLongitude().trim());
|
||||||
|
|
||||||
|
//The service returns zeros for some unknown zip codes
|
||||||
|
if(RadioReferencePreference.Location.isValid(latitude, longitude) && (latitude != 0.0 || longitude != 0.0))
|
||||||
|
{
|
||||||
|
String state = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StateInfo stateInfo = service.getStateInfo(zipInfo.getStateId());
|
||||||
|
state = stateInfo != null ? stateInfo.getName() : null;
|
||||||
|
}
|
||||||
|
catch(RadioReferenceException rre)
|
||||||
|
{
|
||||||
|
//The state name is only for the label
|
||||||
|
mLog.debug("Unable to look up state [" + zipInfo.getStateId() + "] for zip code " + zip);
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder label = new StringBuilder();
|
||||||
|
|
||||||
|
if(zipInfo.getCity() != null && !zipInfo.getCity().isBlank())
|
||||||
|
{
|
||||||
|
label.append(zipInfo.getCity().trim()).append(state != null ? ", " : " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(state != null)
|
||||||
|
{
|
||||||
|
label.append(state).append(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
label.append(zip);
|
||||||
|
return new RadioReferencePreference.Location(latitude, longitude, label.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(NullPointerException | NumberFormatException e)
|
||||||
|
{
|
||||||
|
//Fall through - no usable coordinates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RadioReferenceException(RadioReferenceException.ErrorType.INVALID_REQUEST,
|
||||||
|
"Radio Reference has no location for zip code " + zip);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests the connection to radio reference service using the provided credentials.
|
* Tests the connection to radio reference service using the provided credentials.
|
||||||
* @param userName for radio reference account
|
* @param userName for radio reference account
|
||||||
@@ -207,23 +347,12 @@ public class RadioReference
|
|||||||
{
|
{
|
||||||
mLog.error("Radio Reference Test Connection Failed - user [" + userName + "] error [" + rre.getMessage() + "]");
|
mLog.error("Radio Reference Test Connection Failed - user [" + userName + "] error [" + rre.getMessage() + "]");
|
||||||
|
|
||||||
if (rre.hasFault())
|
if(rre.getErrorType() == RadioReferenceException.ErrorType.AUTHENTICATION)
|
||||||
{
|
{
|
||||||
Fault fault = rre.getFault();
|
return LoginStatus.INVALID_LOGIN;
|
||||||
|
}
|
||||||
|
|
||||||
if (fault.getFaultCode() != null && fault.getFaultCode().contentEquals("AUTH"))
|
throw rre;
|
||||||
{
|
|
||||||
return LoginStatus.INVALID_LOGIN;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw rre;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw rre;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,47 +403,27 @@ public class RadioReference
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with credentials, and update properties based on the login status
|
* Logs in with the current credentials on the calling thread, then updates the login status and the properties on
|
||||||
|
* the FX thread, unless a newer login request has started in the meantime.
|
||||||
|
* @param request number of this login request
|
||||||
|
* @param result to complete with the login status once the properties are updated
|
||||||
*/
|
*/
|
||||||
private void login()
|
private void login(long request, CompletableFuture<LoginStatus> result)
|
||||||
{
|
{
|
||||||
|
UserInfo userInfo = null;
|
||||||
|
LoginStatus loginStatus;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
UserInfo userInfo = getService().getUserInfo();
|
userInfo = getService().getUserInfo();
|
||||||
|
loginStatus = userInfo != null ? CheckExpDate(userInfo.getExpirationDate()) : LoginStatus.UNKNOWN;
|
||||||
if(userInfo != null)
|
|
||||||
{
|
|
||||||
accountExpiresProperty().setValue(userInfo.getExpirationDate());
|
|
||||||
mLoginStatus = CheckExpDate(userInfo.getExpirationDate());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
accountExpiresProperty().setValue(null);
|
|
||||||
mLoginStatus = LoginStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(mLoginStatus == LoginStatus.VALID_PREMIUM)
|
|
||||||
{
|
|
||||||
availableProperty().set(true);
|
|
||||||
premiumAccountProperty().set(true);
|
|
||||||
}
|
|
||||||
else if (mLoginStatus == LoginStatus.EXPIRED_PREMIUM)
|
|
||||||
{
|
|
||||||
availableProperty().set(true);
|
|
||||||
premiumAccountProperty().set(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
availableProperty().set(false);
|
|
||||||
premiumAccountProperty().set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(userInfo != null)
|
if(userInfo != null)
|
||||||
{
|
{
|
||||||
mLog.info("Radio Reference Account - User:" + userInfo.getUserName() +
|
mLog.info("Radio Reference Account - User:" + userInfo.getUserName() +
|
||||||
" Expires:" + userInfo.getExpirationDate() +
|
" Expires:" + userInfo.getExpirationDate() +
|
||||||
" Login Status:" + mLoginStatus +
|
" Login Status:" + loginStatus +
|
||||||
" Premium Access Enabled:" + premiumAccountProperty().get());
|
" Premium Access Enabled:" + (loginStatus == LoginStatus.VALID_PREMIUM));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -323,12 +432,32 @@ public class RadioReference
|
|||||||
}
|
}
|
||||||
catch(RadioReferenceException rre)
|
catch(RadioReferenceException rre)
|
||||||
{
|
{
|
||||||
accountExpiresProperty().setValue(null);
|
loginStatus = rre.getErrorType() == RadioReferenceException.ErrorType.AUTHENTICATION ?
|
||||||
mLoginStatus = LoginStatus.UNKNOWN;
|
LoginStatus.INVALID_LOGIN : LoginStatus.UNKNOWN;
|
||||||
availableProperty().set(false);
|
|
||||||
premiumAccountProperty().set(false);
|
|
||||||
mLog.error("Error checking radio reference premium account status. Disabling premium access. Error: " + rre.getMessage());
|
mLog.error("Error checking radio reference premium account status. Disabling premium access. Error: " + rre.getMessage());
|
||||||
}
|
}
|
||||||
|
catch(RuntimeException re)
|
||||||
|
{
|
||||||
|
//Always update the properties and complete the result, whatever the error
|
||||||
|
loginStatus = LoginStatus.UNKNOWN;
|
||||||
|
mLog.error("Error checking radio reference premium account status. Disabling premium access.", re);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String expirationDate = userInfo != null ? userInfo.getExpirationDate() : null;
|
||||||
|
final LoginStatus status = loginStatus;
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
//A newer login request supersedes this one
|
||||||
|
if(mLoginRequest.get() == request)
|
||||||
|
{
|
||||||
|
mLoginStatus = status;
|
||||||
|
accountExpiresProperty().setValue(expirationDate);
|
||||||
|
availableProperty().set(status == LoginStatus.VALID_PREMIUM || status == LoginStatus.EXPIRED_PREMIUM);
|
||||||
|
premiumAccountProperty().set(status == LoginStatus.VALID_PREMIUM);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.complete(status);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,28 +475,36 @@ public class RadioReference
|
|||||||
* Authorization Information object for use with radio reference service
|
* Authorization Information object for use with radio reference service
|
||||||
* @return instance or null if the user credentials are not set
|
* @return instance or null if the user credentials are not set
|
||||||
*/
|
*/
|
||||||
private AuthorizationInformation getAuthorizationInformation()
|
private synchronized AuthorizationInformation getAuthorizationInformation()
|
||||||
{
|
{
|
||||||
return mAuthorizationInformation;
|
return mAuthorizationInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the credentials and attempts to login to the radio reference service
|
* Applies the credentials and logs in to the radio reference service in the background. The properties are
|
||||||
* @param authorizationInformation
|
* updated on the FX thread, so the JavaFX toolkit must be running. Can be called from any thread.
|
||||||
|
* @param authorizationInformation credentials
|
||||||
|
* @return future that completes with the login status, on the FX thread, once the properties are updated
|
||||||
*/
|
*/
|
||||||
public void setAuthorizationInformation(AuthorizationInformation authorizationInformation)
|
public CompletableFuture<LoginStatus> setAuthorizationInformation(AuthorizationInformation authorizationInformation)
|
||||||
{
|
{
|
||||||
mAuthorizationInformation = authorizationInformation;
|
synchronized(this)
|
||||||
mUserName.setValue(authorizationInformation.getUserName());
|
|
||||||
mPassword.setValue(authorizationInformation.getPassword());
|
|
||||||
|
|
||||||
//Set the service to null so that the next call to getService() will recreate it
|
|
||||||
if(mRadioReferenceService != null)
|
|
||||||
{
|
{
|
||||||
|
mAuthorizationInformation = authorizationInformation;
|
||||||
|
//Set the service to null so that the next call to getService() will recreate it with the new credentials
|
||||||
mRadioReferenceService = null;
|
mRadioReferenceService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
login();
|
long request = mLoginRequest.incrementAndGet();
|
||||||
|
CompletableFuture<LoginStatus> result = new CompletableFuture<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
mUserName.setValue(authorizationInformation.getUserName());
|
||||||
|
mPassword.setValue(authorizationInformation.getPassword());
|
||||||
|
});
|
||||||
|
|
||||||
|
ThreadPool.CACHED.execute(() -> login(request, result));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args)
|
public static void main(String[] args)
|
||||||
@@ -387,7 +524,9 @@ public class RadioReference
|
|||||||
credentials = getAuthorizatonInformation(username, password);
|
credentials = getAuthorizatonInformation(username, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
radioReference.setAuthorizationInformation(credentials);
|
//Login updates the properties on the FX thread
|
||||||
|
Platform.startup(() -> {});
|
||||||
|
radioReference.setAuthorizationInformation(credentials).join();
|
||||||
|
|
||||||
if(radioReference.availableProperty().get())
|
if(radioReference.availableProperty().get())
|
||||||
{
|
{
|
||||||
@@ -438,5 +577,7 @@ public class RadioReference
|
|||||||
mLog.error("Error", rre);
|
mLog.error("Error", rre);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Platform.exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trunked radio system site near the user, from the radio reference frequency index. Stored in the index file.
|
||||||
|
*
|
||||||
|
* @param systemId radio reference system id
|
||||||
|
* @param systemName of the trunked system
|
||||||
|
* @param protocol of the system: the radio reference system type and flavor, e.g. Project 25 Phase II, or null
|
||||||
|
* @param voice of the system, e.g. APCO-25 Common Air Interface Exclusive, or null
|
||||||
|
* @param siteId radio reference site id
|
||||||
|
* @param siteNumber of the site in the system
|
||||||
|
* @param description of the site, or null
|
||||||
|
* @param county where the site is, or null
|
||||||
|
* @param latitude of the site
|
||||||
|
* @param longitude of the site
|
||||||
|
* @param rangeKilometers of the site's coverage, or 0 when unknown
|
||||||
|
* @param nac P25 network access code, or null
|
||||||
|
* @param rfss P25 RF subsystem, or 0 when not listed
|
||||||
|
* @param zone of the site, or 0 when not listed
|
||||||
|
* @param channels of the site
|
||||||
|
*/
|
||||||
|
public record SiteRecord(int systemId, String systemName, String protocol, String voice, int siteId, int siteNumber,
|
||||||
|
String description, String county, double latitude, double longitude, double rangeKilometers,
|
||||||
|
String nac, int rfss, int zone, List<SiteChannel> channels)
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Site frequency
|
||||||
|
* @param frequencyHz downlink frequency in Hertz
|
||||||
|
* @param logicalChannelNumber of the frequency, or 0 when not listed
|
||||||
|
* @param use of the frequency, e.g. Control, or null for a voice channel
|
||||||
|
*/
|
||||||
|
public record SiteChannel(long frequencyHz, int logicalChannelNumber, String use)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate bounding boxes of the United States states and Canadian provinces and territories, used to find the
|
||||||
|
* states that may have counties near a location. The radio reference web service can't search by location and its
|
||||||
|
* states have no coordinates, so this table picks the candidate states, and the radio reference county centers then
|
||||||
|
* decide which counties are near. The boxes only need to be close: candidates are found with a margin added.
|
||||||
|
*
|
||||||
|
* @param countryCode ISO country code
|
||||||
|
* @param stateCode postal abbreviation
|
||||||
|
* @param minimumLatitude southern edge in degrees
|
||||||
|
* @param maximumLatitude northern edge in degrees
|
||||||
|
* @param minimumLongitude western edge in degrees
|
||||||
|
* @param maximumLongitude eastern edge in degrees
|
||||||
|
*/
|
||||||
|
public record StateBounds(String countryCode, String stateCode, double minimumLatitude, double maximumLatitude,
|
||||||
|
double minimumLongitude, double maximumLongitude)
|
||||||
|
{
|
||||||
|
//Extra margin in degrees for the approximate boxes
|
||||||
|
private static final double MARGIN_DEGREES = 0.25;
|
||||||
|
private static final double KILOMETERS_PER_DEGREE = 111.32;
|
||||||
|
|
||||||
|
private static final List<StateBounds> STATES = List.of(
|
||||||
|
us("AL", 30.14, 35.01, -88.47, -84.89), us("AK", 51.21, 71.39, -179.99, -129.99),
|
||||||
|
us("AZ", 31.33, 37.00, -114.82, -109.04), us("AR", 33.00, 36.50, -94.62, -89.64),
|
||||||
|
us("CA", 32.53, 42.01, -124.41, -114.13), us("CO", 36.99, 41.00, -109.06, -102.04),
|
||||||
|
us("CT", 40.98, 42.05, -73.73, -71.79), us("DE", 38.45, 39.84, -75.79, -75.05),
|
||||||
|
us("DC", 38.79, 38.99, -77.12, -76.91), us("FL", 24.40, 31.00, -87.63, -80.03),
|
||||||
|
us("GA", 30.36, 35.00, -85.61, -80.84), us("HI", 18.91, 28.40, -178.33, -154.81),
|
||||||
|
us("ID", 41.99, 49.00, -117.24, -111.04), us("IL", 36.97, 42.51, -91.51, -87.50),
|
||||||
|
us("IN", 37.77, 41.76, -88.10, -84.78), us("IA", 40.38, 43.50, -96.64, -90.14),
|
||||||
|
us("KS", 36.99, 40.00, -102.05, -94.59), us("KY", 36.50, 39.15, -89.57, -81.96),
|
||||||
|
us("LA", 28.93, 33.02, -94.04, -88.82), us("ME", 43.06, 47.46, -71.08, -66.95),
|
||||||
|
us("MD", 37.91, 39.72, -79.49, -75.05), us("MA", 41.24, 42.89, -73.51, -69.93),
|
||||||
|
us("MI", 41.70, 48.31, -90.42, -82.41), us("MN", 43.50, 49.38, -97.24, -89.49),
|
||||||
|
us("MS", 30.17, 35.00, -91.66, -88.10), us("MO", 35.99, 40.61, -95.77, -89.10),
|
||||||
|
us("MT", 44.36, 49.00, -116.05, -104.04), us("NE", 40.00, 43.00, -104.05, -95.31),
|
||||||
|
us("NV", 35.00, 42.00, -120.01, -114.04), us("NH", 42.70, 45.31, -72.56, -70.61),
|
||||||
|
us("NJ", 38.93, 41.36, -75.56, -73.89), us("NM", 31.33, 37.00, -109.05, -103.00),
|
||||||
|
us("NY", 40.50, 45.02, -79.76, -71.86), us("NC", 33.84, 36.59, -84.32, -75.46),
|
||||||
|
us("ND", 45.94, 49.00, -104.05, -96.55), us("OH", 38.40, 41.98, -84.82, -80.52),
|
||||||
|
us("OK", 33.62, 37.00, -103.00, -94.43), us("OR", 41.99, 46.29, -124.57, -116.46),
|
||||||
|
us("PA", 39.72, 42.27, -80.52, -74.69), us("RI", 41.15, 42.02, -71.86, -71.12),
|
||||||
|
us("SC", 32.03, 35.22, -83.35, -78.54), us("SD", 42.48, 45.95, -104.06, -96.44),
|
||||||
|
us("TN", 34.98, 36.68, -90.31, -81.65), us("TX", 25.84, 36.50, -106.65, -93.51),
|
||||||
|
us("UT", 37.00, 42.00, -114.05, -109.04), us("VT", 42.73, 45.02, -73.44, -71.46),
|
||||||
|
us("VA", 36.54, 39.47, -83.68, -75.24), us("WA", 45.54, 49.00, -124.76, -116.92),
|
||||||
|
us("WV", 37.20, 40.64, -82.64, -77.72), us("WI", 42.49, 47.08, -92.89, -86.81),
|
||||||
|
us("WY", 40.99, 45.01, -111.06, -104.05), us("PR", 17.88, 18.52, -67.95, -65.22),
|
||||||
|
ca("AB", 48.99, 60.00, -120.00, -110.00), ca("BC", 48.30, 60.00, -139.06, -114.03),
|
||||||
|
ca("MB", 48.99, 60.00, -102.03, -88.94), ca("NB", 44.60, 48.07, -69.06, -63.77),
|
||||||
|
ca("NL", 46.61, 60.37, -67.80, -52.62), ca("NS", 43.37, 47.04, -66.43, -59.68),
|
||||||
|
ca("NT", 60.00, 78.76, -136.45, -101.98), ca("NU", 51.65, 83.11, -120.68, -61.19),
|
||||||
|
ca("ON", 41.68, 56.86, -95.16, -74.32), ca("PE", 45.95, 47.07, -64.42, -61.97),
|
||||||
|
ca("QC", 44.99, 62.59, -79.76, -57.10), ca("SK", 49.00, 60.00, -110.00, -101.36),
|
||||||
|
ca("YT", 60.00, 69.65, -141.00, -123.81));
|
||||||
|
|
||||||
|
private static StateBounds us(String code, double minimumLatitude, double maximumLatitude,
|
||||||
|
double minimumLongitude, double maximumLongitude)
|
||||||
|
{
|
||||||
|
return new StateBounds("US", code, minimumLatitude, maximumLatitude, minimumLongitude, maximumLongitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StateBounds ca(String code, double minimumLatitude, double maximumLatitude,
|
||||||
|
double minimumLongitude, double maximumLongitude)
|
||||||
|
{
|
||||||
|
return new StateBounds("CA", code, minimumLatitude, maximumLatitude, minimumLongitude, maximumLongitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Country and state code, e.g. US-NY
|
||||||
|
*/
|
||||||
|
public String key()
|
||||||
|
{
|
||||||
|
return countryCode + "-" + stateCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate distance in degrees from the location to the box, or 0 when the location is inside
|
||||||
|
*/
|
||||||
|
private double distanceDegrees(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
double latitudeDistance = Math.max(0, Math.max(minimumLatitude - latitude, latitude - maximumLatitude));
|
||||||
|
double longitudeDistance = Math.max(0, Math.max(minimumLongitude - longitude, longitude - maximumLongitude));
|
||||||
|
return Math.hypot(latitudeDistance, longitudeDistance * Math.cos(Math.toRadians(latitude)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* States whose boxes are within the radius of the location, plus a margin, nearest first
|
||||||
|
* @param latitude of the location
|
||||||
|
* @param longitude of the location
|
||||||
|
* @param radiusKilometers around the location
|
||||||
|
* @return states, or an empty list when the location isn't near the United States or Canada
|
||||||
|
*/
|
||||||
|
public static List<StateBounds> near(double latitude, double longitude, double radiusKilometers)
|
||||||
|
{
|
||||||
|
double margin = (radiusKilometers / KILOMETERS_PER_DEGREE) + MARGIN_DEGREES;
|
||||||
|
List<StateBounds> near = new ArrayList<>();
|
||||||
|
|
||||||
|
for(StateBounds state: STATES)
|
||||||
|
{
|
||||||
|
if(state.distanceDegrees(latitude, longitude) <= margin)
|
||||||
|
{
|
||||||
|
near.add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
near.sort(Comparator.comparingDouble(state -> state.distanceDegrees(latitude, longitude)));
|
||||||
|
return near;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location used to rank radio reference results by distance from the user.
|
||||||
|
*
|
||||||
|
* @param latitude in decimal degrees
|
||||||
|
* @param longitude in decimal degrees
|
||||||
|
* @param description of the location for display, e.g. "Syracuse, New York 13202" or the preferred county name
|
||||||
|
* @param source of the location
|
||||||
|
*/
|
||||||
|
public record UserLocation(double latitude, double longitude, String description, Source source)
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Where the location came from
|
||||||
|
*/
|
||||||
|
public enum Source
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Set by the user, by hand or from a zip code lookup
|
||||||
|
*/
|
||||||
|
USER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center of the user's preferred county, used when the user hasn't set a location. Less accurate: a county
|
||||||
|
* can be large.
|
||||||
|
*/
|
||||||
|
PREFERRED_COUNTY
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,13 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
|
|||||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map view stored in the settings file by older versions of the application. Only read so that settings files from
|
||||||
|
* older versions still load and their default view can be moved to the map preferences (MapPreference). The settings
|
||||||
|
* manager drops map views after loading, so they aren't saved again.
|
||||||
|
*/
|
||||||
@JsonSubTypes.Type(value = MapViewSetting.class, name = "mapViewSetting")
|
@JsonSubTypes.Type(value = MapViewSetting.class, name = "mapViewSetting")
|
||||||
|
@Deprecated
|
||||||
public class MapViewSetting extends Setting
|
public class MapViewSetting extends Setting
|
||||||
{
|
{
|
||||||
private double mLatitude;
|
private double mLatitude;
|
||||||
|
|||||||
@@ -186,18 +186,4 @@ public class Settings
|
|||||||
|
|
||||||
return icons;
|
return icons;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MapViewSetting getMapViewSetting(String name)
|
|
||||||
{
|
|
||||||
for(Setting setting : mSettings)
|
|
||||||
{
|
|
||||||
if(setting instanceof MapViewSetting &&
|
|
||||||
setting.getName().contentEquals(name))
|
|
||||||
{
|
|
||||||
return (MapViewSetting)setting;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import io.github.dsheirer.properties.SystemProperties;
|
|||||||
import io.github.dsheirer.sample.Listener;
|
import io.github.dsheirer.sample.Listener;
|
||||||
import io.github.dsheirer.source.tuner.configuration.TunerConfigurationEvent;
|
import io.github.dsheirer.source.tuner.configuration.TunerConfigurationEvent;
|
||||||
import io.github.dsheirer.util.ThreadPool;
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -51,6 +50,7 @@ public class SettingsManager implements Listener<TunerConfigurationEvent>
|
|||||||
private List<SettingChangeListener> mListeners = new ArrayList<>();
|
private List<SettingChangeListener> mListeners = new ArrayList<>();
|
||||||
private boolean mLoadingSettings = false;
|
private boolean mLoadingSettings = false;
|
||||||
private AtomicBoolean mSettingsSavePending = new AtomicBoolean();
|
private AtomicBoolean mSettingsSavePending = new AtomicBoolean();
|
||||||
|
private MapViewSetting mLegacyMapView;
|
||||||
|
|
||||||
public SettingsManager()
|
public SettingsManager()
|
||||||
{
|
{
|
||||||
@@ -198,32 +198,15 @@ public class SettingsManager implements Listener<TunerConfigurationEvent>
|
|||||||
broadcastSettingChange(setting);
|
broadcastSettingChange(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MapViewSetting getMapViewSetting(String name, GeoPosition position, int zoom)
|
/**
|
||||||
|
* Default map view read from a settings file written by an older version of the application, which stored the
|
||||||
|
* map view here. The map view is stored in the map preferences now.
|
||||||
|
* @return map view, or null if the settings file didn't have one
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public MapViewSetting getLegacyMapView()
|
||||||
{
|
{
|
||||||
MapViewSetting loc = mSettings.getMapViewSetting(name);
|
return mLegacyMapView;
|
||||||
|
|
||||||
if(loc != null)
|
|
||||||
{
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
MapViewSetting newLoc = new MapViewSetting(name, position, zoom);
|
|
||||||
|
|
||||||
addSetting(newLoc);
|
|
||||||
|
|
||||||
return newLoc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMapViewSetting(String name, GeoPosition position, int zoom)
|
|
||||||
{
|
|
||||||
MapViewSetting loc = getMapViewSetting(name, position, zoom);
|
|
||||||
|
|
||||||
loc.setGeoPosition(position);
|
|
||||||
loc.setZoom(zoom);
|
|
||||||
|
|
||||||
scheduleSettingsSave();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void save()
|
private void save()
|
||||||
@@ -296,6 +279,21 @@ public class SettingsManager implements Listener<TunerConfigurationEvent>
|
|||||||
mSettings = new Settings();
|
mSettings = new Settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Older versions stored the map view here. Keep the default view so that it can be moved to the map
|
||||||
|
//preferences, and remove the map views so that they aren't saved again.
|
||||||
|
for(Setting setting: new ArrayList<>(mSettings.getSettings()))
|
||||||
|
{
|
||||||
|
if(setting instanceof MapViewSetting mapViewSetting)
|
||||||
|
{
|
||||||
|
if("Default".equals(mapViewSetting.getName()))
|
||||||
|
{
|
||||||
|
mLegacyMapView = mapViewSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
mSettings.removeSetting(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mLoadingSettings = false;
|
mLoadingSettings = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference.Location;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests coordinate parsing and formatting for the location dialog, and location validation
|
||||||
|
*/
|
||||||
|
public class LocationDialogTest
|
||||||
|
{
|
||||||
|
private static final double DELTA = 1e-9;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesPastedPairs()
|
||||||
|
{
|
||||||
|
assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43.0481, -76.1474"), DELTA);
|
||||||
|
assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair(" 43.0481 -76.1474 "), DELTA);
|
||||||
|
assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43.0481; -76.1474"), DELTA);
|
||||||
|
assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43.0481° N, 76.1474° W"), DELTA);
|
||||||
|
assertArrayEquals(new double[]{-33.8688, 151.2093}, LocationDialog.parsePair("33.8688 S 151.2093 E"), DELTA);
|
||||||
|
assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43,0481 -76,1474"), DELTA,
|
||||||
|
"decimal commas with a space separator");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidPairs()
|
||||||
|
{
|
||||||
|
assertNull(LocationDialog.parsePair("43,048"), "a decimal comma isn't a separator");
|
||||||
|
assertNull(LocationDialog.parsePair("43.0481"));
|
||||||
|
assertNull(LocationDialog.parsePair("95, 10"), "latitude out of range");
|
||||||
|
assertNull(LocationDialog.parsePair("43, 190"), "longitude out of range");
|
||||||
|
assertNull(LocationDialog.parsePair("43.0481 E, 76.1474 W"), "longitude letter on the latitude");
|
||||||
|
assertNull(LocationDialog.parsePair("syracuse"));
|
||||||
|
assertNull(LocationDialog.parsePair(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesSingleCoordinates()
|
||||||
|
{
|
||||||
|
assertEquals(-76.1474, LocationDialog.parseCoordinate("-76.1474", false), DELTA);
|
||||||
|
assertEquals(-76.1474, LocationDialog.parseCoordinate("76.1474° W", false), DELTA);
|
||||||
|
assertEquals(43.5, LocationDialog.parseCoordinate("43,5", true), DELTA, "decimal comma");
|
||||||
|
assertEquals(90.0, LocationDialog.parseCoordinate("90", true), DELTA);
|
||||||
|
assertTrue(Double.isNaN(LocationDialog.parseCoordinate("91", true)));
|
||||||
|
assertTrue(Double.isNaN(LocationDialog.parseCoordinate("181", false)));
|
||||||
|
assertTrue(Double.isNaN(LocationDialog.parseCoordinate("-5 S", true)), "sign and hemisphere letter");
|
||||||
|
assertTrue(Double.isNaN(LocationDialog.parseCoordinate("43 E", true)), "longitude letter on a latitude");
|
||||||
|
assertTrue(Double.isNaN(LocationDialog.parseCoordinate("abc", true)));
|
||||||
|
assertTrue(Double.isNaN(LocationDialog.parseCoordinate("", true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatsCoordinates()
|
||||||
|
{
|
||||||
|
assertEquals("43.048123", LocationDialog.format(43.04812345));
|
||||||
|
assertEquals("-76", LocationDialog.format(-76.0));
|
||||||
|
assertEquals("Syracuse, New York 13202 (43.0481, -76.1474)",
|
||||||
|
new Location(43.04812, -76.14742, "Syracuse, New York 13202").describe());
|
||||||
|
assertEquals("43.0481, -76.1474", new Location(43.04812, -76.14742, null).describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void locationValidation()
|
||||||
|
{
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new Location(91, 0, null));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new Location(0, -181, null));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new Location(Double.NaN, 0, null));
|
||||||
|
assertNull(new Location(43, -76, " ").label(), "blank label");
|
||||||
|
assertEquals("Syracuse", new Location(43, -76, " Syracuse ").label());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
|
import io.github.dsheirer.module.decode.DecoderFactory;
|
||||||
|
import io.github.dsheirer.module.decode.DecoderType;
|
||||||
|
import io.github.dsheirer.rrapi.type.Flavor;
|
||||||
|
import io.github.dsheirer.rrapi.type.Site;
|
||||||
|
import io.github.dsheirer.rrapi.type.SiteFrequency;
|
||||||
|
import io.github.dsheirer.rrapi.type.SystemInformation;
|
||||||
|
import io.github.dsheirer.rrapi.type.Type;
|
||||||
|
import io.github.dsheirer.rrapi.type.Voice;
|
||||||
|
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfigTuner;
|
||||||
|
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests matching radio reference frequencies and sites to playlist channels, and creating site channels, without the
|
||||||
|
* web service
|
||||||
|
*/
|
||||||
|
public class RadioReferencePlaylistActionsTest
|
||||||
|
{
|
||||||
|
private static SiteRecord site(String protocol, String... uses)
|
||||||
|
{
|
||||||
|
java.util.ArrayList<SiteRecord.SiteChannel> channels = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
for(int index = 0; index < uses.length; index++)
|
||||||
|
{
|
||||||
|
channels.add(new SiteRecord.SiteChannel(851_000_000L + (index * 250_000L), index + 1,
|
||||||
|
uses[index].isEmpty() ? null : uses[index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SiteRecord(1, "System", protocol, null, 10, 3, "Syracuse", null, 43.0, -76.0, 10, null, 0, 0,
|
||||||
|
channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Channel channel(DecoderType decoderType, long... frequencies)
|
||||||
|
{
|
||||||
|
Channel channel = new Channel("Test");
|
||||||
|
channel.setDecodeConfiguration(DecoderFactory.getDecodeConfiguration(decoderType));
|
||||||
|
|
||||||
|
if(frequencies.length == 1)
|
||||||
|
{
|
||||||
|
SourceConfigTuner tuner = new SourceConfigTuner();
|
||||||
|
tuner.setFrequency(frequencies[0]);
|
||||||
|
channel.setSourceConfiguration(tuner);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SourceConfigTunerMultipleFrequency multiple = new SourceConfigTunerMultipleFrequency();
|
||||||
|
|
||||||
|
for(long frequency: frequencies)
|
||||||
|
{
|
||||||
|
multiple.addFrequency(frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.setSourceConfiguration(multiple);
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void siteProtocols()
|
||||||
|
{
|
||||||
|
assertEquals(RadioReferencePlaylistActions.getProtocolFamily(DecoderType.P25_PHASE1),
|
||||||
|
RadioReferencePlaylistActions.getSiteDecoderTypes(site("Project 25 Phase II", "Control")));
|
||||||
|
assertTrue(RadioReferencePlaylistActions.getSiteDecoderTypes(site("DMR Tier III", "Control"))
|
||||||
|
.contains(DecoderType.DMR));
|
||||||
|
assertTrue(RadioReferencePlaylistActions.isSupported(site("NXDN NEXEDGE 4800", "Control")));
|
||||||
|
assertFalse(RadioReferencePlaylistActions.isSupported(site("Motorola Type II Smartnet", "Control")),
|
||||||
|
"Motorola control channels aren't decoded");
|
||||||
|
assertFalse(RadioReferencePlaylistActions.isSupported(site("Project 25 Phase I", "", "")),
|
||||||
|
"no control channel listed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ltrSitesHaveRepeatersInsteadOfAControlChannel()
|
||||||
|
{
|
||||||
|
SiteRecord ltr = site("LTR Standard", "", "", "");
|
||||||
|
assertTrue(RadioReferencePlaylistActions.isRepeaterSite(ltr));
|
||||||
|
assertTrue(RadioReferencePlaylistActions.isSupported(ltr), "no control channel is needed");
|
||||||
|
assertNull(RadioReferencePlaylistActions.getUnsupportedReason(ltr));
|
||||||
|
assertEquals(List.of(851_000_000L, 851_250_000L, 851_500_000L),
|
||||||
|
RadioReferencePlaylistActions.getSiteFrequencies(ltr));
|
||||||
|
|
||||||
|
assertEquals("Motorola Type II Smartnet isn't supported",
|
||||||
|
RadioReferencePlaylistActions.getUnsupportedReason(site("Motorola Type II Smartnet", "Control")));
|
||||||
|
assertEquals("Radio Reference lists no control channel",
|
||||||
|
RadioReferencePlaylistActions.getUnsupportedReason(site("Project 25 Phase I", "", "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ltrRepeaterChannels()
|
||||||
|
{
|
||||||
|
Type type = new Type();
|
||||||
|
type.setTypeId(1);
|
||||||
|
type.setName("LTR");
|
||||||
|
Flavor standard = new Flavor();
|
||||||
|
standard.setFlavorId(2);
|
||||||
|
standard.setName("Standard");
|
||||||
|
Voice analog = new Voice();
|
||||||
|
analog.setVoiceId(3);
|
||||||
|
analog.setName("Analog");
|
||||||
|
RadioReferenceDecoder decoder = new RadioReferenceDecoder(null, Map.of(1, type), Map.of(2, standard),
|
||||||
|
Map.of(3, analog), Map.of());
|
||||||
|
io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System();
|
||||||
|
system.setName("Adams Communications");
|
||||||
|
system.setTypeId(1);
|
||||||
|
system.setFlavorId(2);
|
||||||
|
system.setVoiceId(3);
|
||||||
|
Site site = new Site();
|
||||||
|
site.setSiteNumber(1);
|
||||||
|
site.setDescription("Watertown");
|
||||||
|
SiteFrequency lcn1 = frequency(461.0125, "");
|
||||||
|
lcn1.setLogicalChannelNumber(1);
|
||||||
|
SiteFrequency lcn2 = frequency(461.0375, "");
|
||||||
|
lcn2.setLogicalChannelNumber(2);
|
||||||
|
site.setSiteFrequencies(List.of(lcn1, lcn2));
|
||||||
|
|
||||||
|
List<Channel> channels = RadioReferenceChannelFactory.repeaters(decoder, system, new SystemInformation(), site,
|
||||||
|
List.of(461_037_500L));
|
||||||
|
|
||||||
|
assertEquals(1, channels.size(), "only the requested repeater");
|
||||||
|
assertEquals(DecoderType.LTR, channels.get(0).getDecodeConfiguration().getDecoderType());
|
||||||
|
assertEquals("LCN 2", channels.get(0).getName());
|
||||||
|
assertEquals("Adams Communications", channels.get(0).getSystem());
|
||||||
|
assertEquals("Watertown", channels.get(0).getSite());
|
||||||
|
assertEquals(List.of(461_037_500L),
|
||||||
|
RadioReferencePlaylistActions.getFrequencies(channels.get(0).getSourceConfiguration()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void controlFrequenciesFirst()
|
||||||
|
{
|
||||||
|
SiteRecord site = site("Project 25 Phase I", "", "Alternate control", "Control", "");
|
||||||
|
assertEquals(List.of(851_500_000L, 851_250_000L), RadioReferencePlaylistActions.getControlFrequencies(site));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findsExistingChannelByFrequencyAndProtocol()
|
||||||
|
{
|
||||||
|
Channel nbfm = channel(DecoderType.NBFM, 154_430_000);
|
||||||
|
Channel p25 = channel(DecoderType.P25_PHASE2, 851_000_000, 851_250_000);
|
||||||
|
Channel traffic = new Channel("Traffic", Channel.ChannelType.TRAFFIC);
|
||||||
|
traffic.setDecodeConfiguration(DecoderFactory.getDecodeConfiguration(DecoderType.NBFM));
|
||||||
|
SourceConfigTuner tuner = new SourceConfigTuner();
|
||||||
|
tuner.setFrequency(460_000_000);
|
||||||
|
traffic.setSourceConfiguration(tuner);
|
||||||
|
List<Channel> channels = List.of(nbfm, p25, traffic);
|
||||||
|
|
||||||
|
assertSame(nbfm, RadioReferencePlaylistActions.findChannel(channels, List.of(154_430_000L),
|
||||||
|
RadioReferencePlaylistActions.getProtocolFamily(DecoderType.NBFM)));
|
||||||
|
assertNull(RadioReferencePlaylistActions.findChannel(channels, List.of(154_430_000L),
|
||||||
|
RadioReferencePlaylistActions.getProtocolFamily(DecoderType.P25_PHASE1)), "same frequency, other protocol");
|
||||||
|
assertSame(p25, RadioReferencePlaylistActions.findChannel(channels, List.of(851_250_000L),
|
||||||
|
RadioReferencePlaylistActions.getProtocolFamily(DecoderType.P25_PHASE1)), "P25 phases are one protocol");
|
||||||
|
assertNull(RadioReferencePlaylistActions.findChannel(channels, List.of(460_000_000L),
|
||||||
|
RadioReferencePlaylistActions.getProtocolFamily(DecoderType.NBFM)), "traffic channels are ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conventionalDecoderType()
|
||||||
|
{
|
||||||
|
FrequencyCandidate fmn = new FrequencyCandidate(154_430_000, "Fire", null, "FMN", null, null, false, "x",
|
||||||
|
null, null, null, null);
|
||||||
|
FrequencyCandidate dstar = new FrequencyCandidate(145_000_000, "Ham", null, "D-STAR", null, null, false, "x",
|
||||||
|
null, null, null, null);
|
||||||
|
assertEquals(DecoderType.NBFM, RadioReferencePlaylistActions.getDecoderType(fmn));
|
||||||
|
assertNull(RadioReferencePlaylistActions.getDecoderType(dstar));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trunkedSiteChannel()
|
||||||
|
{
|
||||||
|
Type type = new Type();
|
||||||
|
type.setTypeId(1);
|
||||||
|
type.setName("Project 25");
|
||||||
|
Flavor phase2 = new Flavor();
|
||||||
|
phase2.setFlavorId(2);
|
||||||
|
phase2.setName("Phase II");
|
||||||
|
Voice voice = new Voice();
|
||||||
|
voice.setVoiceId(3);
|
||||||
|
voice.setName("APCO-25 Common Air Interface Exclusive");
|
||||||
|
RadioReferenceDecoder decoder = new RadioReferenceDecoder(null, Map.of(1, type), Map.of(2, phase2),
|
||||||
|
Map.of(3, voice), Map.of());
|
||||||
|
|
||||||
|
io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System();
|
||||||
|
system.setSystemId(42);
|
||||||
|
system.setName("Onondaga County Interop");
|
||||||
|
system.setTypeId(1);
|
||||||
|
system.setFlavorId(2);
|
||||||
|
system.setVoiceId(3);
|
||||||
|
|
||||||
|
Site site = new Site();
|
||||||
|
site.setSiteNumber(3);
|
||||||
|
site.setDescription("Syracuse");
|
||||||
|
site.setTdmaControlChannel(0);
|
||||||
|
site.setSiteFrequencies(List.of(frequency(851.0125, ""), frequency(851.2625, "a"), frequency(851.5125, "d")));
|
||||||
|
|
||||||
|
Channel channel = RadioReferenceChannelFactory.trunkedSite(decoder, system, new SystemInformation(), site);
|
||||||
|
|
||||||
|
assertEquals(DecoderType.P25_PHASE1, channel.getDecodeConfiguration().getDecoderType(),
|
||||||
|
"FDMA control channel of a Phase II site");
|
||||||
|
assertEquals(List.of(851_512_500L, 851_262_500L),
|
||||||
|
RadioReferencePlaylistActions.getFrequencies(channel.getSourceConfiguration()), "control, then alternate");
|
||||||
|
assertEquals("Onondaga County Interop", channel.getSystem());
|
||||||
|
assertEquals("Syracuse", channel.getSite());
|
||||||
|
assertEquals("Site 3", channel.getName());
|
||||||
|
|
||||||
|
Channel startOnAlternate = RadioReferenceChannelFactory.trunkedSite(decoder, system, new SystemInformation(),
|
||||||
|
site, 851_262_500L);
|
||||||
|
assertEquals(List.of(851_262_500L, 851_512_500L),
|
||||||
|
RadioReferencePlaylistActions.getFrequencies(startOnAlternate.getSourceConfiguration()),
|
||||||
|
"the picked alternate control channel first");
|
||||||
|
|
||||||
|
Channel voicePicked = RadioReferenceChannelFactory.trunkedSite(decoder, system, new SystemInformation(),
|
||||||
|
site, 851_012_500L);
|
||||||
|
assertEquals(List.of(851_512_500L, 851_262_500L),
|
||||||
|
RadioReferencePlaylistActions.getFrequencies(voicePicked.getSourceConfiguration()),
|
||||||
|
"a voice channel isn't added: control channels first");
|
||||||
|
|
||||||
|
Type motorola = new Type();
|
||||||
|
motorola.setTypeId(1);
|
||||||
|
motorola.setName("Motorola");
|
||||||
|
RadioReferenceDecoder motorolaDecoder = new RadioReferenceDecoder(null, Map.of(1, motorola),
|
||||||
|
Map.of(2, phase2), Map.of(3, voice), Map.of());
|
||||||
|
assertNull(RadioReferenceChannelFactory.trunkedSite(motorolaDecoder, system, new SystemInformation(), site),
|
||||||
|
"Motorola Type II control channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteFrequency frequency(double megahertz, String use)
|
||||||
|
{
|
||||||
|
SiteFrequency frequency = new SiteFrequency();
|
||||||
|
frequency.setFrequency(megahertz);
|
||||||
|
frequency.setUse(use);
|
||||||
|
return frequency;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||||
|
import java.awt.Point;
|
||||||
|
import java.awt.geom.Point2D;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.jdesktop.swingx.JXMapViewer;
|
||||||
|
import org.jdesktop.swingx.mapviewer.DefaultTileFactory;
|
||||||
|
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||||
|
import org.jdesktop.swingx.mapviewer.TileFactoryInfo;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the radio reference site markers and card on the map, without loading map tiles
|
||||||
|
*/
|
||||||
|
public class RadioReferenceSiteMapTest
|
||||||
|
{
|
||||||
|
private static SiteRecord site(String system, String description, double latitude, double longitude, int channels)
|
||||||
|
{
|
||||||
|
List<SiteRecord.SiteChannel> list = new ArrayList<>();
|
||||||
|
|
||||||
|
for(int index = 0; index < channels; index++)
|
||||||
|
{
|
||||||
|
list.add(new SiteRecord.SiteChannel(851_012_500L + (index * 250_000L), index + 1,
|
||||||
|
index == 0 ? "Control" : index == 1 ? "Alternate control" : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SiteRecord(1, system, "Project 25 Phase II", "APCO-25 Common Air Interface Exclusive", 10, 3,
|
||||||
|
description, "Onondaga County", latitude, longitude, 10, "1A3", 1, 2, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hitTestFindsTowersUnderTheMouse()
|
||||||
|
{
|
||||||
|
//Offline tile source with the OSM geometry - nothing is loaded without painting
|
||||||
|
JXMapViewer map = new JXMapViewer();
|
||||||
|
map.setTileFactory(new DefaultTileFactory(new TileFactoryInfo(1, 17, 19, 256, true, true,
|
||||||
|
"file:///nonexistent", "x", "y", "z")));
|
||||||
|
map.setSize(800, 600);
|
||||||
|
map.setZoom(6);
|
||||||
|
map.setCenterPosition(new GeoPosition(43.05, -76.15));
|
||||||
|
|
||||||
|
SiteRecord syracuse = site("System A", "Syracuse", 43.0481, -76.1474, 4);
|
||||||
|
SiteRecord sameTower = site("System B", "Syracuse", 43.0481, -76.1474, 4);
|
||||||
|
SiteRecord fulton = site("System C", "Fulton", 43.3226, -76.4171, 4);
|
||||||
|
RadioReferenceSitePainter painter = new RadioReferenceSitePainter(() -> List.of(syracuse, sameTower, fulton));
|
||||||
|
|
||||||
|
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(43.0481, -76.1474), map.getZoom());
|
||||||
|
Point tower = new Point((int)Math.round(pixel.getX() - map.getViewportBounds().getX()),
|
||||||
|
(int)Math.round(pixel.getY() - map.getViewportBounds().getY()) - 8);
|
||||||
|
|
||||||
|
assertEquals(List.of("System A", "System B"),
|
||||||
|
painter.getSitesAt(map, tower).stream().map(SiteRecord::systemName).toList(), "both towers at the spot");
|
||||||
|
assertTrue(painter.getSitesAt(map, new Point(tower.x + 60, tower.y)).isEmpty());
|
||||||
|
|
||||||
|
painter.setShowSites(false);
|
||||||
|
assertTrue(painter.getSitesAt(map, tower).isEmpty(), "hidden sites aren't found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cardShowsSystemSiteAndChannels()
|
||||||
|
{
|
||||||
|
String html = RadioReferenceSiteCard.html(List.of(site("Fire & Rescue <Interop>", "Syracuse", 43.0481,
|
||||||
|
-76.1474, 30)), null);
|
||||||
|
|
||||||
|
assertTrue(html.contains("<b>Fire & Rescue <Interop></b>"), "escaped system name");
|
||||||
|
assertTrue(html.contains("Project 25 Phase II"));
|
||||||
|
assertTrue(html.contains("Site 3 · Syracuse · Onondaga County"));
|
||||||
|
assertTrue(html.contains("NAC 1A3 · RFSS 1 · Zone 2"));
|
||||||
|
assertTrue(html.contains("851.01250 <b>C</b>"), "control channel marked");
|
||||||
|
assertTrue(html.contains("851.26250 <b>A</b>"), "alternate control channel marked");
|
||||||
|
assertTrue(html.contains("+ 6 more channels"), "24 channels shown");
|
||||||
|
assertFalse(html.contains(" mi") || html.contains(" km"), "no distance without a location");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cardLimitsSites()
|
||||||
|
{
|
||||||
|
List<SiteRecord> sites = new ArrayList<>();
|
||||||
|
|
||||||
|
for(int index = 0; index < 5; index++)
|
||||||
|
{
|
||||||
|
sites.add(site("System " + index, "Syracuse", 43.0481, -76.1474, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
String html = RadioReferenceSiteCard.html(sites, null);
|
||||||
|
assertTrue(html.contains("System 2"));
|
||||||
|
assertFalse(html.contains("System 3"));
|
||||||
|
assertTrue(html.contains("+ 2 more sites here"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import com.google.common.cache.LoadingCache;
|
||||||
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the caching and error handling of the caching radio reference service, without calling the web service
|
||||||
|
*/
|
||||||
|
public class CachingRadioReferenceServiceTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
void failedCallIsThrownOnceAndNotCached()
|
||||||
|
{
|
||||||
|
AtomicInteger calls = new AtomicInteger();
|
||||||
|
RadioReferenceException error = new RadioReferenceException(RadioReferenceException.ErrorType.TIMEOUT, "timeout");
|
||||||
|
LoadingCache<Integer,String> cache = CachingRadioReferenceService.cache(10, id -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
RadioReferenceException thrown = assertThrows(RadioReferenceException.class,
|
||||||
|
() -> CachingRadioReferenceService.get(cache, 1));
|
||||||
|
assertSame(error, thrown, "the web service exception is thrown unwrapped");
|
||||||
|
assertEquals(1, calls.get(), "a failed call isn't repeated");
|
||||||
|
|
||||||
|
assertThrows(RadioReferenceException.class, () -> CachingRadioReferenceService.get(cache, 1));
|
||||||
|
assertEquals(2, calls.get(), "a failed call isn't cached");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void valueIsCached() throws RadioReferenceException
|
||||||
|
{
|
||||||
|
AtomicInteger calls = new AtomicInteger();
|
||||||
|
LoadingCache<Integer,String> cache = CachingRadioReferenceService.cache(10, id -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return "value " + id;
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals("value 1", CachingRadioReferenceService.get(cache, 1));
|
||||||
|
assertEquals("value 1", CachingRadioReferenceService.get(cache, 1));
|
||||||
|
assertEquals(1, calls.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullValueIsReturnedAndNotCached() throws RadioReferenceException
|
||||||
|
{
|
||||||
|
AtomicInteger calls = new AtomicInteger();
|
||||||
|
LoadingCache<Integer,String> cache = CachingRadioReferenceService.cache(10, id -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
assertNull(CachingRadioReferenceService.get(cache, 1));
|
||||||
|
assertEquals(1, calls.get(), "a null result isn't loaded a second time");
|
||||||
|
assertNull(CachingRadioReferenceService.get(cache, 1));
|
||||||
|
assertEquals(2, calls.get(), "a null result isn't cached");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runtimeExceptionIsThrownUnwrapped()
|
||||||
|
{
|
||||||
|
IllegalStateException error = new IllegalStateException("parse error");
|
||||||
|
LoadingCache<Integer,String> cache = CachingRadioReferenceService.cache(10, id -> {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
assertSame(error, assertThrows(IllegalStateException.class, () -> CachingRadioReferenceService.get(cache, 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.github.dsheirer.rrapi.type.Site;
|
||||||
|
import io.github.dsheirer.rrapi.type.SiteFrequency;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.RankedCandidate;
|
||||||
|
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.Source;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.NavigableMap;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the frequency index lookup and candidate building, without calling the web service
|
||||||
|
*/
|
||||||
|
public class RadioReferenceFrequencyIndexTest
|
||||||
|
{
|
||||||
|
//Fulton, NY
|
||||||
|
private static final double LATITUDE = 43.331042;
|
||||||
|
private static final double LONGITUDE = -76.37563;
|
||||||
|
|
||||||
|
private static FrequencyCandidate candidate(long hz, String title, Double latitude, Double longitude)
|
||||||
|
{
|
||||||
|
return new FrequencyCandidate(hz, title, null, "FMN", null, null, false, "Test", latitude, longitude, null,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ranksByDistanceThenFrequencyOffset()
|
||||||
|
{
|
||||||
|
NavigableMap<Long,List<FrequencyCandidate>> frequencies = RadioReferenceFrequencyIndex.toFrequencies(List.of(
|
||||||
|
new Source("county:1", 0, List.of(
|
||||||
|
candidate(154_430_000, "Syracuse 34 km", 43.0481, -76.1474),
|
||||||
|
candidate(154_430_000, "Fulton 0 km", LATITUDE, LONGITUDE),
|
||||||
|
candidate(154_432_500, "Oswego 15 km offset", 43.4553, -76.5105),
|
||||||
|
candidate(154_430_000, "Unknown location", null, null)), null),
|
||||||
|
new Source("county:2", 0, List.of(
|
||||||
|
candidate(154_445_000, "Outside tolerance", LATITUDE, LONGITUDE)), null)));
|
||||||
|
|
||||||
|
List<RankedCandidate> ranked = RadioReferenceFrequencyIndex.rank(frequencies, 154_430_000, 3_000,
|
||||||
|
LATITUDE, LONGITUDE);
|
||||||
|
|
||||||
|
assertEquals(List.of("Fulton 0 km", "Oswego 15 km offset", "Syracuse 34 km", "Unknown location"),
|
||||||
|
ranked.stream().map(c -> c.candidate().title()).toList());
|
||||||
|
assertEquals(0.0, ranked.get(0).distanceKilometers(), 0.01);
|
||||||
|
assertEquals(2_500, ranked.get(1).offsetHz());
|
||||||
|
assertFalse(ranked.get(3).hasDistance());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameDistanceRanksByFrequencyOffset()
|
||||||
|
{
|
||||||
|
NavigableMap<Long,List<FrequencyCandidate>> frequencies = RadioReferenceFrequencyIndex.toFrequencies(List.of(
|
||||||
|
new Source("county:1", 0, List.of(
|
||||||
|
candidate(460_012_500, "Offset", LATITUDE, LONGITUDE),
|
||||||
|
candidate(460_000_000, "Exact", LATITUDE, LONGITUDE)), null)));
|
||||||
|
|
||||||
|
List<RankedCandidate> ranked = RadioReferenceFrequencyIndex.rank(frequencies, 460_000_000, 12_500,
|
||||||
|
LATITUDE, LONGITUDE);
|
||||||
|
|
||||||
|
assertEquals(List.of("Exact", "Offset"), ranked.stream().map(c -> c.candidate().title()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesDuplicateListings()
|
||||||
|
{
|
||||||
|
FrequencyCandidate frequency = candidate(155_475_000, "National Law Enforcement", LATITUDE, LONGITUDE);
|
||||||
|
NavigableMap<Long,List<FrequencyCandidate>> frequencies = RadioReferenceFrequencyIndex.toFrequencies(List.of(
|
||||||
|
new Source("county:1", 0, List.of(frequency), null), new Source("agency:2", 0, List.of(frequency), null)));
|
||||||
|
|
||||||
|
assertEquals(1, RadioReferenceFrequencyIndex.rank(frequencies, 155_475_000, 0, LATITUDE, LONGITUDE).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void siteCandidatesWithinRadius()
|
||||||
|
{
|
||||||
|
Site near = site(1, "Syracuse", 43.0481, -76.1474, "c", 851.0125, "", 852.5375);
|
||||||
|
Site far = site(2, "Buffalo", 42.8864, -78.8784, "c", 851.5125);
|
||||||
|
RadioReferenceFrequencyIndex.BuildParameters parameters =
|
||||||
|
new RadioReferenceFrequencyIndex.BuildParameters(LATITUDE, LONGITUDE);
|
||||||
|
|
||||||
|
List<FrequencyCandidate> candidates = RadioReferenceFrequencyIndex.siteCandidates(List.of(near, far), 42,
|
||||||
|
"Onondaga County Interop", "Project 25 Phase II", parameters);
|
||||||
|
|
||||||
|
assertEquals(2, candidates.size(), "only the site within the radius");
|
||||||
|
assertEquals(851_012_500, candidates.get(0).frequencyHz());
|
||||||
|
assertEquals("Control", candidates.get(0).use());
|
||||||
|
assertNull(candidates.get(1).use());
|
||||||
|
assertEquals("Onondaga County Interop", candidates.get(0).title());
|
||||||
|
assertEquals("Site 1 · Syracuse", candidates.get(0).source());
|
||||||
|
assertTrue(candidates.get(0).hasLocation());
|
||||||
|
assertEquals(42, candidates.get(0).systemId());
|
||||||
|
assertTrue(candidates.get(0).isSiteFrequency());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void siteRecordsWithinRadius()
|
||||||
|
{
|
||||||
|
Site near = site(1, "Syracuse", 43.0481, -76.1474, 851.5125, "c", 851.0125);
|
||||||
|
near.setSiteId(1234);
|
||||||
|
near.setNac("1A3");
|
||||||
|
near.setRfss(1);
|
||||||
|
near.setZoneNumber(2);
|
||||||
|
near.setCountyId(1855);
|
||||||
|
Site far = site(2, "Buffalo", 42.8864, -78.8784, "c", 851.5125);
|
||||||
|
io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System();
|
||||||
|
system.setSystemId(42);
|
||||||
|
system.setName("Onondaga County Interop");
|
||||||
|
|
||||||
|
List<SiteRecord> records = RadioReferenceFrequencyIndex.siteRecords(List.of(near, far), system,
|
||||||
|
"Project 25 Phase II", "APCO-25 Common Air Interface Exclusive", java.util.Map.of(1855, "Onondaga County"),
|
||||||
|
new RadioReferenceFrequencyIndex.BuildParameters(LATITUDE, LONGITUDE));
|
||||||
|
|
||||||
|
assertEquals(1, records.size(), "only the site within the radius");
|
||||||
|
SiteRecord record = records.get(0);
|
||||||
|
assertEquals(42, record.systemId());
|
||||||
|
assertEquals("Onondaga County Interop", record.systemName());
|
||||||
|
assertEquals("Project 25 Phase II", record.protocol());
|
||||||
|
assertEquals(1234, record.siteId());
|
||||||
|
assertEquals("Syracuse", record.description());
|
||||||
|
assertEquals("Onondaga County", record.county());
|
||||||
|
assertEquals("1A3", record.nac());
|
||||||
|
assertEquals(1, record.rfss());
|
||||||
|
assertEquals(2, record.zone());
|
||||||
|
assertEquals(List.of(851_012_500L, 851_512_500L),
|
||||||
|
record.channels().stream().map(SiteRecord.SiteChannel::frequencyHz).toList(), "sorted by frequency");
|
||||||
|
assertEquals("Control", record.channels().get(0).use());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void siteFrequencyUse()
|
||||||
|
{
|
||||||
|
assertEquals("Control", RadioReferenceFrequencyIndex.siteFrequencyUse("c"));
|
||||||
|
assertEquals("Control", RadioReferenceFrequencyIndex.siteFrequencyUse("d"));
|
||||||
|
assertEquals("Alternate control", RadioReferenceFrequencyIndex.siteFrequencyUse("a"));
|
||||||
|
assertNull(RadioReferenceFrequencyIndex.siteFrequencyUse(""));
|
||||||
|
assertNull(RadioReferenceFrequencyIndex.siteFrequencyUse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site with frequencies, each frequency followed by its use code when the next value is a string
|
||||||
|
*/
|
||||||
|
private static Site site(int number, String description, double latitude, double longitude, Object... values)
|
||||||
|
{
|
||||||
|
Site site = new Site();
|
||||||
|
site.setSiteNumber(number);
|
||||||
|
site.setDescription(description);
|
||||||
|
site.setLatitude(latitude);
|
||||||
|
site.setLongitude(longitude);
|
||||||
|
site.setRange(10);
|
||||||
|
java.util.ArrayList<SiteFrequency> frequencies = new java.util.ArrayList<>();
|
||||||
|
String use = null;
|
||||||
|
|
||||||
|
for(Object value: values)
|
||||||
|
{
|
||||||
|
if(value instanceof String code)
|
||||||
|
{
|
||||||
|
use = code;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SiteFrequency frequency = new SiteFrequency();
|
||||||
|
frequency.setFrequency((Double)value);
|
||||||
|
frequency.setUse(use);
|
||||||
|
frequencies.add(frequency);
|
||||||
|
use = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
site.setSiteFrequencies(frequencies);
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.service.radioreference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests finding the states around a location
|
||||||
|
*/
|
||||||
|
public class StateBoundsTest
|
||||||
|
{
|
||||||
|
private static List<String> near(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
return StateBounds.near(latitude, longitude, RadioReferenceFrequencyIndex.RADIUS_KILOMETERS).stream()
|
||||||
|
.map(StateBounds::key).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void locationStateIsFirst()
|
||||||
|
{
|
||||||
|
//Fulton, NY: across Lake Ontario from Ontario, more than 80 km from Pennsylvania
|
||||||
|
List<String> fulton = near(43.331042, -76.37563);
|
||||||
|
assertEquals("US-NY", fulton.get(0));
|
||||||
|
assertFalse(fulton.contains("US-PA"));
|
||||||
|
assertFalse(fulton.contains("US-AZ"));
|
||||||
|
|
||||||
|
//Phoenix, AZ
|
||||||
|
assertEquals(List.of("US-AZ"), near(33.4484, -112.0740));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void includesNeighborsNearABorder()
|
||||||
|
{
|
||||||
|
//Kansas City, MO is on the Kansas border
|
||||||
|
List<String> kansasCity = near(39.0997, -94.5786);
|
||||||
|
assertTrue(kansasCity.contains("US-MO"));
|
||||||
|
assertTrue(kansasCity.contains("US-KS"));
|
||||||
|
|
||||||
|
//Windsor, ON is across the river from Detroit, MI
|
||||||
|
List<String> windsor = near(42.3149, -83.0364);
|
||||||
|
assertTrue(windsor.contains("CA-ON"));
|
||||||
|
assertTrue(windsor.contains("US-MI"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outsideTheUnitedStatesAndCanada()
|
||||||
|
{
|
||||||
|
//London, UK
|
||||||
|
assertTrue(near(51.5074, -0.1278).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user