mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -05:00
#2641 Enhanced radio reference integration. With a premium subscription and when you set your location on the map, the Spectral display includes radio reference layer that presents popup card in the spectrum that shows candidate systems or frequencies ordered by distance from your location. Background thread builds an offline cache of data to support this. Map plots this cache as tower pins on the map and you can hover and active site frequencies from the map. (#2642)
Updates sdrtrunk to use latest radio reference api jar. Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
@@ -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