#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:
Denny Sheirer
2026-11-14 09:37:29 -05:00
committed by GitHub
parent 9641d2ef59
commit 5bce541631
51 changed files with 7124 additions and 622 deletions

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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 &amp; Rescue &lt;Interop&gt;</b>"), "escaped system name");
assertTrue(html.contains("Project 25 Phase II"));
assertTrue(html.contains("Site 3 &middot; Syracuse &middot; Onondaga County"));
assertTrue(html.contains("NAC 1A3 &middot; RFSS 1 &middot; 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"));
}
}

View File

@@ -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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}