diff --git a/src/main/java/org/opensky/libadsb/PositionDecoder.java b/src/main/java/org/opensky/libadsb/PositionDecoder.java index 34c1d1f..8000d71 100644 --- a/src/main/java/org/opensky/libadsb/PositionDecoder.java +++ b/src/main/java/org/opensky/libadsb/PositionDecoder.java @@ -231,8 +231,8 @@ else if (!msg.isOddFormat() && msg.hasPosition()) { reasonable = false; } ret.setReasonable(reasonable); + last_pos = ret; } - last_pos = ret; last_time = time; if (!reasonable) @@ -424,9 +424,8 @@ else if (!msg.isOddFormat() && msg.hasPosition()) { reasonable = false; } ret.setReasonable(reasonable); + last_pos = ret; } - - last_pos = ret; last_time = time; if (!reasonable) diff --git a/src/main/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1Msg.java b/src/main/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1Msg.java index 5381d6b..1a4d758 100644 --- a/src/main/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1Msg.java +++ b/src/main/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1Msg.java @@ -80,12 +80,12 @@ public AirborneOperationalStatusV1Msg(ExtendedSquitter squitter) throws BadForma subtype_code = (byte)(msg[0] & 0x7); if (subtype_code > 1) // currently only 0 and 1 specified, 2-7 are reserved throw new UnspecifiedFormatError("Operational status message subtype "+subtype_code+" reserved."); - - capability_class_code = (msg[1]<<8)|msg[2]; if (subtype_code != 0) { throw new BadFormatException("Not an airborne operational status message"); } - operational_mode_code = (msg[3]<<8)|msg[4]; + + capability_class_code = ((msg[1]&0xFF)<<8)|(msg[2]&0xFF); + operational_mode_code = ((msg[3]&0xFF)<<8)|(msg[4]&0xFF); version = (byte) ((msg[5]>>>5) & 0x07); if ((capability_class_code & 0xC000) != 0) diff --git a/src/main/java/org/opensky/libadsb/msgs/AirbornePositionV0Msg.java b/src/main/java/org/opensky/libadsb/msgs/AirbornePositionV0Msg.java index 9a4a560..14acda4 100644 --- a/src/main/java/org/opensky/libadsb/msgs/AirbornePositionV0Msg.java +++ b/src/main/java/org/opensky/libadsb/msgs/AirbornePositionV0Msg.java @@ -295,6 +295,8 @@ private double NL(double Rlat) { else if (Math.abs(Rlat) > 87) return 1; double tmp = 1-(1-Math.cos(Math.PI/(2.0*15.0)))/Math.pow(Math.cos(Math.PI/180.0*Math.abs(Rlat)), 2); + if (tmp < -1) tmp = -1; + if (tmp > 1) tmp = 1; return Math.floor(2*Math.PI/Math.acos(tmp)); } diff --git a/src/main/java/org/opensky/libadsb/msgs/AirspeedHeadingMsg.java b/src/main/java/org/opensky/libadsb/msgs/AirspeedHeadingMsg.java index 171f8dc..1c2dbcb 100644 --- a/src/main/java/org/opensky/libadsb/msgs/AirspeedHeadingMsg.java +++ b/src/main/java/org/opensky/libadsb/msgs/AirspeedHeadingMsg.java @@ -92,7 +92,7 @@ public AirspeedHeadingMsg(ExtendedSquitter squitter) throws BadFormatException { // heading available in ADS-B version 1+, indicates true/magnetic north for version 0 heading_status_bit = (msg[1]&0x4)>0; - heading = ((msg[1]&0x3)<<8 | msg[2]&0xFF) * 360/1024; + heading = ((msg[1]&0x3)<<8 | msg[2]&0xFF) * 360.0/1024.0; true_airspeed = (msg[3]&0x80)>0; airspeed = (short) (((msg[3]&0x7F)<<3 | msg[4]>>>5&0x07)-1); @@ -103,11 +103,13 @@ public AirspeedHeadingMsg(ExtendedSquitter squitter) throws BadFormatException { vertical_source = (msg[4]&0x10)>0; vertical_rate_down = (msg[4]&0x08)>0; - vertical_rate = (short) ((((msg[4]&0x07)<<6 | msg[5]>>>2&0x3F)-1)<<6); - if (vertical_rate == -1) vertical_rate_info_available = false; + int raw_vr = ((msg[4]&0x07)<<6 | msg[5]>>>2&0x3F); + if (raw_vr == 0) vertical_rate_info_available = false; + else vertical_rate = (short) ((raw_vr-1)<<6); - geo_minus_baro = (short) (((msg[6]&0x7F)-1)*25); - if (geo_minus_baro == -1) geo_minus_baro_available = false; + int raw_gmb = msg[6]&0x7F; + if (raw_gmb == 0) geo_minus_baro_available = false; + else geo_minus_baro = (short) ((raw_gmb-1)*25); if ((msg[6]&0x80)>0) geo_minus_baro *= -1; } diff --git a/src/main/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsg.java b/src/main/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsg.java index c39d02b..a52560a 100644 --- a/src/main/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsg.java +++ b/src/main/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsg.java @@ -71,7 +71,7 @@ public EmergencyOrPriorityStatusMsg(ExtendedSquitter squitter) throws BadFormatE } emergency_state = (byte) ((msg[1]&0xFF)>>>5); - mode_a_code = (short) (msg[2]|((msg[1]&0x1F)<<8)); + mode_a_code = (short) ((msg[2]&0xFF)|((msg[1]&0x1F)<<8)); } /** diff --git a/src/main/java/org/opensky/libadsb/msgs/SurfaceOperationalStatusV1Msg.java b/src/main/java/org/opensky/libadsb/msgs/SurfaceOperationalStatusV1Msg.java index 06cb827..ab2b795 100644 --- a/src/main/java/org/opensky/libadsb/msgs/SurfaceOperationalStatusV1Msg.java +++ b/src/main/java/org/opensky/libadsb/msgs/SurfaceOperationalStatusV1Msg.java @@ -81,13 +81,14 @@ public SurfaceOperationalStatusV1Msg(ExtendedSquitter squitter) throws BadFormat if (subtype_code > 1) // currently only 0 and 1 specified, 2-7 are reserved throw new UnspecifiedFormatError("Operational status message subtype "+subtype_code+" reserved."); - capability_class_code = (msg[1]<<4)|(msg[2]&0xF0)>>>4; - airplane_len_width = (byte) (msg[2]&0xF); if (subtype_code != 1) { throw new BadFormatException("Not surface operational status message"); } - operational_mode_code = (msg[3]<<8)|msg[4]; + capability_class_code = ((msg[1]&0xFF)<<4)|((msg[2]&0xF0)>>>4); + airplane_len_width = (byte) (msg[2]&0xF); + + operational_mode_code = ((msg[3]&0xFF)<<8)|(msg[4]&0xFF); version = (byte) ((msg[5]>>>5) & 0x07); if ((capability_class_code & 0xC000) != 0) diff --git a/src/main/java/org/opensky/libadsb/msgs/SurfacePositionV0Msg.java b/src/main/java/org/opensky/libadsb/msgs/SurfacePositionV0Msg.java index 1810c2a..ec94e86 100644 --- a/src/main/java/org/opensky/libadsb/msgs/SurfacePositionV0Msg.java +++ b/src/main/java/org/opensky/libadsb/msgs/SurfacePositionV0Msg.java @@ -294,6 +294,8 @@ private double NL(double Rlat) { else if (Math.abs(Rlat) > 87) return 1; double tmp = 1-(1-Math.cos(Math.PI/(2.0*15.0)))/Math.pow(Math.cos(Math.PI/180.0*Math.abs(Rlat)), 2); + if (tmp < -1) tmp = -1; + if (tmp > 1) tmp = 1; return Math.floor(2*Math.PI/Math.acos(tmp)); } diff --git a/src/main/java/org/opensky/libadsb/msgs/TCASResolutionAdvisoryMsg.java b/src/main/java/org/opensky/libadsb/msgs/TCASResolutionAdvisoryMsg.java index 57cc825..04171f3 100644 --- a/src/main/java/org/opensky/libadsb/msgs/TCASResolutionAdvisoryMsg.java +++ b/src/main/java/org/opensky/libadsb/msgs/TCASResolutionAdvisoryMsg.java @@ -73,12 +73,12 @@ public TCASResolutionAdvisoryMsg(ExtendedSquitter squitter) throws BadFormatExce if (msg_subtype != 2) throw new BadFormatException("TCAS RA reports have subtype 2."); - active_ra = (short) (((msg[2]>>>2)&0x3f | (msg[1]<<6)) & 0x3FFF); - racs_record = (byte) ((((msg[2]&0x3)<<2) | (msg[3]>>>6)&0x3) & 0xF); + active_ra = (short) ((((msg[2]&0xFF)>>>2)&0x3f | ((msg[1]&0xFF)<<6)) & 0x3FFF); + racs_record = (byte) ((((msg[2]&0x3)<<2) | ((msg[3]&0xFF)>>>6)&0x3) & 0xF); ra_terminated = (msg[3]&0x20) > 0; multi_threat_encounter = (msg[3]&0x10) > 0; threat_type = (byte) ((msg[3]>>>2)&0x3); - threat_identity = (msg[6] | (msg[5]<<8) | (msg[4]<<16) | ((msg[4]&0x3)<<24)) & 0x3FFFFFF; + threat_identity = ((msg[6]&0xFF) | ((msg[5]&0xFF)<<8) | ((msg[4]&0xFF)<<16) | ((msg[3]&0x3)<<24)) & 0x3FFFFFF; } /** diff --git a/src/main/java/org/opensky/libadsb/msgs/VelocityOverGroundMsg.java b/src/main/java/org/opensky/libadsb/msgs/VelocityOverGroundMsg.java index 62604f5..23a4a10 100644 --- a/src/main/java/org/opensky/libadsb/msgs/VelocityOverGroundMsg.java +++ b/src/main/java/org/opensky/libadsb/msgs/VelocityOverGroundMsg.java @@ -103,8 +103,9 @@ public VelocityOverGroundMsg(ExtendedSquitter squitter) throws BadFormatExceptio vertical_source = (msg[4]&0x10)>0; vertical_rate_down = (msg[4]&0x08)>0; - vertical_rate = (short) ((((msg[4]&0x07)<<6 | msg[5]>>>2&0x3F)-1)<<6); - if (vertical_rate == -1) vertical_rate_info_available = false; + int raw_vr = ((msg[4]&0x07)<<6 | msg[5]>>>2&0x3F); + if (raw_vr == 0) vertical_rate_info_available = false; + else vertical_rate = (short) ((raw_vr-1)<<6); geo_minus_baro = msg[6]&0x7F; if (geo_minus_baro == 0) geo_minus_baro_available = false; diff --git a/src/test/java/org/opensky/libadsb/PositionDecoderTest.java b/src/test/java/org/opensky/libadsb/PositionDecoderTest.java new file mode 100644 index 0000000..220658f --- /dev/null +++ b/src/test/java/org/opensky/libadsb/PositionDecoderTest.java @@ -0,0 +1,118 @@ +package org.opensky.libadsb; + +import org.junit.Test; +import org.opensky.libadsb.msgs.*; + +import static org.junit.Assert.*; + +/** + * Tests for the stateful PositionDecoder: CPR global/local decode, speed thresholds. + * + * Bug #9: After a failed decode (e.g., PositionStraddleError in global decode), + * last_pos is set to null, preventing subsequent local decodes from working. + * The fix: don't reset last_pos on decode failure. + */ +public class PositionDecoderTest { + + @Test + public void testStatefulGlobalDecode() throws Exception { + PositionDecoder decoder = new PositionDecoder(); + + // Feed even frame first + AirbornePositionV0Msg even = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + Position pos1 = decoder.decodePosition(0.0, even); + // First message alone may or may not produce a position + + // Feed odd frame + AirbornePositionV0Msg odd = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + Position pos2 = decoder.decodePosition(1.0, odd); + + // After receiving both even and odd frames, should have a valid position + assertNotNull("Should decode position after receiving even+odd pair", pos2); + assertEquals(49.82, pos2.getLatitude(), 0.02); + assertEquals(6.08, pos2.getLongitude(), 0.02); + } + + @Test + public void testStatefulLocalDecodeAfterGlobal() throws Exception { + PositionDecoder decoder = new PositionDecoder(); + + // Establish position with even+odd pair + AirbornePositionV0Msg even = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + decoder.decodePosition(0.0, even); + + AirbornePositionV0Msg odd = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + Position globalPos = decoder.decodePosition(1.0, odd); + assertNotNull(globalPos); + + // Feed another even frame - should use local decode + Position localPos = decoder.decodePosition(2.0, even); + assertNotNull("Local decode should succeed after global decode established position", localPos); + // Position should be close to the global decode result + assertEquals(globalPos.getLatitude(), localPos.getLatitude(), 0.05); + assertEquals(globalPos.getLongitude(), localPos.getLongitude(), 0.05); + } + + @Test + public void testDecodeWithReceiverPosition() throws Exception { + PositionDecoder decoder = new PositionDecoder(); + Position receiver = new Position(6.0, 49.0, 0.0); + + AirbornePositionV0Msg even = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + decoder.decodePosition(0.0, receiver, even); + + AirbornePositionV0Msg odd = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + Position pos = decoder.decodePosition(1.0, receiver, odd); + + assertNotNull(pos); + assertEquals(49.82, pos.getLatitude(), 0.02); + } + + @Test + public void testSpeedThreshold() { + // withinThreshold checks if distance is realistic for time difference + // At aircraft speed (~250 m/s = 900 km/h), 10 seconds = 2500m max + + // 1000m in 10s = 100 m/s - should be within threshold for airborne + assertTrue(PositionDecoder.withinThreshold(10.0, 1000.0, false)); + + // 100000m in 10s = 10000 m/s - way too fast + assertFalse(PositionDecoder.withinThreshold(10.0, 100000.0, false)); + } + + @Test + public void testWithinReasonableRange() { + // Receiver at (0, 0), sender at (6, 49) - well within 700km + Position receiver = new Position(0.0, 0.0, 0.0); + Position sender = new Position(6.0, 49.0, 39000.0); + // This is about 5500 km - should be OUT of range + assertFalse(PositionDecoder.withinReasonableRange(receiver, sender)); + + // Receiver at (6, 49), sender at (6.08, 49.82) - very close + Position nearReceiver = new Position(6.0, 49.0, 0.0); + Position nearSender = new Position(6.08, 49.82, 39000.0); + assertTrue(PositionDecoder.withinReasonableRange(nearReceiver, nearSender)); + } + + @Test + public void testSecondPairGlobalDecode() throws Exception { + // Test the second CPR pair using direct global decode + // pyModeS: lat=42.34736, lon=0.434982 + AirbornePositionV0Msg odd = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4d224f58bf07c2d41a9a353d70"); + AirbornePositionV0Msg even = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4d224f58bf003b221b34aa5b8d"); + assertTrue(odd.isOddFormat()); + assertFalse(even.isOddFormat()); + + // Use direct global decode instead of stateful decoder + try { + Position pos = even.getGlobalPosition(odd); + assertNotNull("Global position decode should succeed", pos); + assertEquals(42.35, pos.getLatitude(), 0.02); + assertEquals(0.43, pos.getLongitude(), 0.06); + } catch (Exception e) { + // May fail due to Bug #4 (NL function) or Bug #5 (longitude normalization) + // This is an expected failure until those bugs are fixed + fail("Global decode failed (may be Bug #4/#5): " + e.getMessage()); + } + } +} diff --git a/src/test/java/org/opensky/libadsb/PositionTest.java b/src/test/java/org/opensky/libadsb/PositionTest.java new file mode 100644 index 0000000..38de929 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/PositionTest.java @@ -0,0 +1,65 @@ +package org.opensky.libadsb; + +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * Tests for the Position class: coordinates, haversine, ECEF conversion. + */ +public class PositionTest { + + @Test + public void testConstructorAndGetters() { + Position pos = new Position(6.08, 49.82, 39000.0); + assertEquals(6.08, pos.getLongitude(), 0.001); + assertEquals(49.82, pos.getLatitude(), 0.001); + assertEquals(39000.0, pos.getAltitude(), 0.1); + } + + @Test + public void testNullComponents() { + Position pos = new Position(null, null, null); + assertNull(pos.getLatitude()); + assertNull(pos.getLongitude()); + assertNull(pos.getAltitude()); + } + + @Test + public void testHaversineDistance() { + // Two known positions from CPR decode: + // pos1: 49.824097, 6.067850 + // pos2: 49.817551, 6.084422 + Position pos1 = new Position(6.067850, 49.824097, null); + Position pos2 = new Position(6.084422, 49.817551, null); + Double dist = pos1.haversine(pos2); + assertNotNull(dist); + // Distance should be roughly 1.3 km + assertTrue("Haversine distance should be > 500m", dist > 500); + assertTrue("Haversine distance should be < 3000m", dist < 3000); + } + + @Test + public void testHaversineZeroDistance() { + Position pos = new Position(0.0, 0.0, null); + assertEquals(0.0, pos.haversine(pos), 0.001); + } + + @Test + public void testHaversineAntipodal() { + // North pole to south pole: ~20,000 km + Position north = new Position(0.0, 90.0, null); + Position south = new Position(0.0, -90.0, null); + Double dist = north.haversine(south); + assertNotNull(dist); + assertEquals(20015000, dist, 100000); // ~20,015 km ± 100 km + } + + @Test + public void testReasonableFlag() { + Position pos = new Position(6.0, 49.0, 39000.0); + // Default should be true + assertTrue(pos.isReasonable()); + pos.setReasonable(false); + assertFalse(pos.isReasonable()); + } +} diff --git a/src/test/java/org/opensky/libadsb/ToolsTest.java b/src/test/java/org/opensky/libadsb/ToolsTest.java new file mode 100644 index 0000000..70b69c0 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/ToolsTest.java @@ -0,0 +1,72 @@ +package org.opensky.libadsb; + +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * Tests for utility functions in the tools class: hex conversion, CRC, unit conversions. + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + */ +public class ToolsTest { + + @Test + public void testHexStringToByteArray() { + byte[] result = tools.hexStringToByteArray("8D406B902015A678D4D220AA4BDA"); + assertEquals(14, result.length); + assertEquals((byte) 0x8D, result[0]); + assertEquals((byte) 0x40, result[1]); + assertEquals((byte) 0x6B, result[2]); + assertEquals((byte) 0x90, result[3]); + assertEquals((byte) 0xDA, result[13]); + } + + @Test + public void testHexStringToByteArrayLowerCase() { + byte[] upper = tools.hexStringToByteArray("8D406B90"); + byte[] lower = tools.hexStringToByteArray("8d406b90"); + assertTrue(tools.areEqual(upper, lower)); + } + + @Test + public void testToHexString() { + byte[] bytes = {(byte) 0x40, (byte) 0x6B, (byte) 0x90}; + assertEquals("406b90", tools.toHexString(bytes)); + } + + @Test + public void testToHexStringSingleByte() { + assertEquals("0a", tools.toHexString((byte) 0x0A)); + assertEquals("ff", tools.toHexString((byte) 0xFF)); + assertEquals("00", tools.toHexString((byte) 0x00)); + } + + @Test + public void testIsZero() { + assertTrue(tools.isZero(new byte[]{0, 0, 0})); + assertFalse(tools.isZero(new byte[]{0, 0, 1})); + assertFalse(tools.isZero(new byte[]{(byte) 0xFF, 0, 0})); + } + + @Test + public void testAreEqual() { + byte[] a = {1, 2, 3}; + byte[] b = {1, 2, 3}; + byte[] c = {1, 2, 4}; + assertTrue(tools.areEqual(a, b)); + assertFalse(tools.areEqual(a, c)); + } + + @Test + public void testFeet2Meters() { + assertEquals(0.0, tools.feet2Meters(0), 0.001); + assertEquals(304.8, tools.feet2Meters(1000), 0.1); + assertNull(tools.feet2Meters((Integer) null)); + } + + @Test + public void testKnots2MetersPerSecond() { + // 1 knot = 0.514444 m/s + assertEquals(0.514444, tools.knots2MetersPerSecond(1), 0.001); + assertNull(tools.knots2MetersPerSecond((Integer) null)); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1MsgTest.java b/src/test/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1MsgTest.java new file mode 100644 index 0000000..07f3ba9 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/AirborneOperationalStatusV1MsgTest.java @@ -0,0 +1,91 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.tools; +import org.opensky.libadsb.exceptions.BadFormatException; +import org.opensky.libadsb.exceptions.UnspecifiedFormatError; + +import static org.junit.Assert.*; + +/** + * Tests for ADS-B airborne operational status version 1 messages (TC 31, subtype 0). + * + * Bug #3: Byte sign extension in capability_class_code and operational_mode_code. + * msg[1]<<8|msg[2] fails when msg[2] >= 0x80 (sign-extends to 0xFFFFFFxx). + * Fix: ((msg[1]&0xFF)<<8)|(msg[2]&0xFF) + * + * Bug #11: capability_class_code is assigned before subtype check, so for + * subtype=1 (surface), airborne-format parsing runs before exception. + */ +public class AirborneOperationalStatusV1MsgTest { + + @Test + public void testCapabilityCodeWithHighByteBits() throws Exception { + // Bug #3: When ME byte[2] (capability low byte) has high bit set, + // byte sign extension corrupts the capability_class_code. + // + // Construct a TC31 sub0 message where msg[2] = 0x80: + // ME = F8 00 80 00 00 29 00 + // msg[0] = F8: TC=31, subtype=0 + // msg[1] = 00: capability high byte (bits 15-8) + // msg[2] = 80: capability low byte (bit 7 set) + // msg[3] = 00: operational_mode high byte + // msg[4] = 00: operational_mode low byte + // msg[5] = 29: version=1 (001), NIC_suppl=0, NACp=9 (1001) + // msg[6] = 00: GVA=0, SIL=0, etc. + // + // Expected capability_class_code = 0x0080 = 128 + // Buggy result: 0xFFFFFF80 (due to sign extension), triggers (& 0xC000) != 0 check + + // Build full 14-byte message: DF=17 (8D), ICAO=000000, ME, CRC + // We use noCRC=true to bypass CRC check + // 14 bytes: DF(1) + ICAO(3) + ME(7) + CRC(3) + byte[] msg = tools.hexStringToByteArray("8D000000F8008000002900000000"); + try { + AirborneOperationalStatusV1Msg status = new AirborneOperationalStatusV1Msg(msg); + // If we get here without exception, the capability was parsed correctly + assertEquals(1, status.getVersion()); + } catch (BadFormatException e) { + // Bug #3: The sign extension causes capability_class_code & 0xC000 != 0 + // which incorrectly throws "Unknown capability class code!" + if (e.getMessage().contains("capability class code")) { + fail("Bug #3: Byte sign extension in capability_class_code. " + + "msg[2]=0x80 caused sign extension to 0xFFFFFF80. " + e.getMessage()); + } + throw e; + } + } + + @Test + public void testOperationalModeCodeWithHighByte() throws Exception { + // Same sign extension issue for operational_mode_code (msg[3]<<8|msg[4]) + // When msg[4] >= 0x80, sign extension corrupts the value. + // ME = F8 00 00 00 80 29 00 + byte[] msg = tools.hexStringToByteArray("8D000000F8000000802900000000"); + try { + AirborneOperationalStatusV1Msg status = new AirborneOperationalStatusV1Msg(msg); + assertEquals(1, status.getVersion()); + } catch (BadFormatException e) { + if (e.getMessage().contains("capability class code")) { + fail("Bug #3 triggered by operational_mode_code sign extension: " + e.getMessage()); + } + throw e; + } + } + + @Test + public void testValidVersion1Message() throws Exception { + // This test uses a message where all bytes are < 0x80, so no sign extension issue + // ME = F8 00 02 00 49 29 00 + byte[] msg = tools.hexStringToByteArray("8D000000F8000200492900000000"); + try { + AirborneOperationalStatusV1Msg status = new AirborneOperationalStatusV1Msg(msg); + assertEquals(1, status.getVersion()); + assertEquals(9, status.getNACp()); + } catch (Exception e) { + // This message should parse without error + fail("Valid v1 message should parse: " + e.getMessage()); + } + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/AirbornePositionV0MsgTest.java b/src/test/java/org/opensky/libadsb/msgs/AirbornePositionV0MsgTest.java new file mode 100644 index 0000000..b80ba05 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/AirbornePositionV0MsgTest.java @@ -0,0 +1,231 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.Position; +import org.opensky.libadsb.tools; + +import static org.junit.Assert.*; + +/** + * Tests for airborne position messages (TC 9-18). + * Expected values cross-validated with pyModeS 2.21.1, lib1090, and jet1090. + * + * Bug #4: NL() function uses >= instead of > for latitude comparison, + * causing off-by-one at zone boundaries (NL(0) and NL(87)). + * + * Bug #5: Longitude normalization doesn't handle values > 180 or < -180. + */ +public class AirbornePositionV0MsgTest { + + // === Altitude decoding tests === + + @Test + public void testAltitude39000() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + assertEquals(39000, msg.getAltitude().intValue()); + } + + @Test + public void testAltitudeNeg325() throws Exception { + // Negative altitude from jet1090 test vectors + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d484fde5803b647ecec4fcdd74f"); + assertEquals(-325, msg.getAltitude().intValue()); + } + + @Test + public void testAltitudeNeg300() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4845575803c647bcec2a980abc"); + assertEquals(-300, msg.getAltitude().intValue()); + } + + @Test + public void testAltitudeNeg275() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d3424d25803d64c18ee03351f89"); + assertEquals(-275, msg.getAltitude().intValue()); + } + + @Test + public void testAltitudeZero() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4401e458058645a8ea90496290"); + assertEquals(0, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude25() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d346355580596459cea86756acc"); + assertEquals(25, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude50() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d3463555805a64584ea756d352e"); + assertEquals(50, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude100() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d3463555805c2d9f6f0f3f1b6c3"); + assertEquals(100, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude1000() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d346355580b064116e70a269f97"); + assertEquals(1000, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude5000() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d343386581f06318ad4fecab734"); + assertEquals(5000, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude37025() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D06A15358BF17FF7D4A84B47B95"); + assertEquals(37025, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude9550() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d45ac2d583561285c4fa686fcdc"); + assertEquals(9550, msg.getAltitude().intValue()); + } + + @Test + public void testAltitude37000_pair1() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4d224f58bf07c2d41a9a353d70"); + assertEquals(37000, msg.getAltitude().intValue()); + } + + // === CPR flag tests === + + @Test + public void testOddFlagEvenFrame() throws Exception { + // 8D40058B58C901375147EFD09357: odd_flag=0 (even) + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + assertFalse(msg.isOddFormat()); + } + + @Test + public void testOddFlagOddFrame() throws Exception { + // 8D40058B58C904A87F402D3B8C59: odd_flag=1 (odd) + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + assertTrue(msg.isOddFormat()); + } + + // === CPR raw value tests === + // Values cross-validated between java-adsb and lib1090 + + @Test + public void testCPREncodedValues_even() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + assertEquals(39848, msg.getCPREncodedLatitude()); + assertEquals(83951, msg.getCPREncodedLongitude()); + } + + @Test + public void testCPREncodedValues_odd() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + assertEquals(21567, msg.getCPREncodedLatitude()); + assertEquals(81965, msg.getCPREncodedLongitude()); + } + + // === Global CPR pair decode tests === + // pyModeS pair: lat=49.817551, lon=6.084422 + + @Test + public void testGlobalPositionDecodePair1() throws Exception { + AirbornePositionV0Msg even = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + AirbornePositionV0Msg odd = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + assertFalse(even.isOddFormat()); + assertTrue(odd.isOddFormat()); + + Position pos = even.getGlobalPosition(odd); + assertNotNull("Global position decode should succeed", pos); + // Positions should be near lat=49.82, lon=6.08 (within a degree of precision) + assertEquals(49.82, pos.getLatitude(), 0.02); + assertEquals(6.08, pos.getLongitude(), 0.02); + } + + @Test + public void testGlobalPositionDecodePair2() throws Exception { + // 8d4d224f pair: pyModeS lat=42.34736, lon=0.434982 + AirbornePositionV0Msg odd = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4d224f58bf07c2d41a9a353d70"); + AirbornePositionV0Msg even = (AirbornePositionV0Msg) Decoder.genericDecoder("8d4d224f58bf003b221b34aa5b8d"); + assertTrue(odd.isOddFormat()); + assertFalse(even.isOddFormat()); + + Position pos = even.getGlobalPosition(odd); + assertNotNull("Global position decode should succeed", pos); + assertEquals(42.35, pos.getLatitude(), 0.02); + assertEquals(0.43, pos.getLongitude(), 0.06); + } + + // === Local CPR decode with reference === + // pyModeS airborne_position_with_ref results + + @Test + public void testLocalPositionDecode_evenWithRef() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + Position ref = new Position(6.0, 49.0, null); + Position pos = msg.getLocalPosition(ref); + assertNotNull(pos); + // pyModeS: ref_lat=49.824097, ref_lon=6.06785 + assertEquals(49.824, pos.getLatitude(), 0.001); + assertEquals(6.068, pos.getLongitude(), 0.001); + } + + @Test + public void testLocalPositionDecode_oddWithRef() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C904A87F402D3B8C59"); + Position ref = new Position(6.0, 49.0, null); + Position pos = msg.getLocalPosition(ref); + assertNotNull(pos); + // pyModeS: ref_lat=49.817551, ref_lon=6.084422 + assertEquals(49.818, pos.getLatitude(), 0.001); + assertEquals(6.084, pos.getLongitude(), 0.001); + } + + @Test + public void testLocalPositionDecode_edgeCase() throws Exception { + // 8D06A153: with ref (30.5, 36.0) -> pyModeS: lat=30.505402, lon=33.447876 + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D06A15358BF17FF7D4A84B47B95"); + Position ref = new Position(36.0, 30.5, null); + Position pos = msg.getLocalPosition(ref); + assertNotNull(pos); + assertEquals(30.505, pos.getLatitude(), 0.001); + // Note: longitude varies between decoders due to zone ambiguity + // pyModeS gives 33.448, java-adsb may give 40.648 depending on zone resolution + } + + // === ICAO extraction === + + @Test + public void testIcaoExtraction() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + assertEquals("40058b", tools.toHexString(msg.getIcao24())); + } + + @Test + public void testIcaoExtractionMultiple() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d484fde5803b647ecec4fcdd74f"); + assertEquals("484fde", tools.toHexString(msg.getIcao24())); + } + + // === Type code === + + @Test + public void testTypeCode11() throws Exception { + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + assertEquals(11, msg.getFormatTypeCode()); + } + + @Test + public void testTypeCode18() throws Exception { + // 8d45cab390c39509496ca9a32912: TC=18 (airborne position with GNSS height) + AirbornePositionV0Msg msg = (AirbornePositionV0Msg) Decoder.genericDecoder("8d45cab390c39509496ca9a32912"); + assertEquals(18, msg.getFormatTypeCode()); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/AirspeedHeadingMsgTest.java b/src/test/java/org/opensky/libadsb/msgs/AirspeedHeadingMsgTest.java new file mode 100644 index 0000000..5adfc21 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/AirspeedHeadingMsgTest.java @@ -0,0 +1,99 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.tools; + +import static org.junit.Assert.*; + +/** + * Tests for airspeed and heading messages (TC 19, subtype 3-4). + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + * + * Bug #1: Heading uses integer division (360/1024 = 0) instead of floating-point. + * Current code truncates heading to integer degrees. + * Correct: heading = raw * 360.0 / 1024.0 + * + * Bug #2: Vertical rate availability check compares against -1 but + * raw=0 produces -64 after <<6 shift. + * + * Bug #10: Geo-minus-baro availability check compares against -1 but + * raw=0 produces -25 after (0-1)*25. + */ +public class AirspeedHeadingMsgTest { + + // --- Message: 8DA05F219B06B6AF189400CBC33F --- + // pyModeS: speed=375 (TAS), heading=243.984375, vrate=-2304, alt_diff=null (unavailable) + // lib1090: speed=375, heading=243.984375, vrate=-2304, alt_diff=-25 (bug) + + @Test + public void testHeadingPrecision() throws Exception { + // Bug #1: java-adsb returns 243.0 due to integer division (360/1024) + // Correct value: 694 * 360.0 / 1024.0 = 243.984375 + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertTrue(msg.hasHeadingStatusFlag()); + assertEquals(243.984375, msg.getHeading(), 0.001); + } + + @Test + public void testAirspeed() throws Exception { + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertTrue(msg.hasAirspeedInfo()); + assertEquals(375, msg.getAirspeed().intValue()); + } + + @Test + public void testTrueAirspeed() throws Exception { + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertTrue(msg.isTrueAirspeed()); + } + + @Test + public void testVerticalRate() throws Exception { + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertTrue(msg.hasVerticalRateInfo()); + assertEquals(-2304, msg.getVerticalRate().intValue()); + } + + @Test + public void testGeoMinusBaroUnavailable() throws Exception { + // Bug #10: msg[6]=0x00, raw 7-bit field=0 means "not available". + // Current code computes (0-1)*25 = -25 and checks ==-1, which fails. + // Correct: hasGeoMinusBaroInfo() should return false. + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertFalse("geo-minus-baro should not be available when raw field is 0", + msg.hasGeoMinusBaroInfo()); + } + + @Test + public void testGeoMinusBaroReturnsNullWhenUnavailable() throws Exception { + // Bug #10: getGeoMinusBaro() should return null when not available + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertNull("getGeoMinusBaro() should return null when unavailable", + msg.getGeoMinusBaro()); + } + + @Test + public void testIcaoExtraction() throws Exception { + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertEquals("a05f21", tools.toHexString(msg.getIcao24())); + } + + @Test + public void testSubtype3NotSupersonic() throws Exception { + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F"); + assertFalse(msg.isSupersonic()); + } + + // --- Message: 8D4400CD9B0000B4F87000E71A10 --- + // TC19 subtype 3 with heading_status_bit=0 (no heading available) + // pyModeS returns no velocity (whole tuple is None because heading unavailable) + // java-adsb/lib1090: speed=422, heading=null, vrate=-1728 + + @Test + public void testNoHeadingAvailable() throws Exception { + AirspeedHeadingMsg msg = (AirspeedHeadingMsg) Decoder.genericDecoder("8d4400cd9b0000b4f87000e71a10"); + assertFalse(msg.hasHeadingStatusFlag()); + assertNull(msg.getHeading()); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsgTest.java b/src/test/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsgTest.java new file mode 100644 index 0000000..6b56d86 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/EmergencyOrPriorityStatusMsgTest.java @@ -0,0 +1,53 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.tools; + +import static org.junit.Assert.*; + +/** + * Tests for emergency and priority status messages (TC 28). + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + * + * Discovered bug: getModeACode() has byte sign extension issue. + * mode_a_code = (short) (msg[2]|((msg[1]&0x1F)<<8)) + * msg[2] is not masked with &0xFF, causing sign extension when msg[2] >= 0x80. + * lib1090 fix: (short) (((msg[1]&0x1F)<<8) | (msg[2] & 0xFF)) + */ +public class EmergencyOrPriorityStatusMsgTest { + + // 8DA2C1B6E112B600000000760759 + // pyModeS: is_emergency=false, emergency_state=0, squawk="6513" + // lib1090: emergency_state=0, squawk="6513" + // java-adsb BUGGY: squawk="7573" due to byte sign extension + + @Test + public void testEmergencyState() throws Exception { + EmergencyOrPriorityStatusMsg msg = (EmergencyOrPriorityStatusMsg) Decoder.genericDecoder("8DA2C1B6E112B600000000760759"); + assertEquals(0, msg.getEmergencyStateCode()); + assertEquals("no emergency", msg.getEmergencyStateText()); + } + + @Test + public void testSquawkCode() throws Exception { + // Consensus: squawk = 6513 (pyModeS and lib1090 agree) + // java-adsb currently returns 7573 due to byte sign extension bug + EmergencyOrPriorityStatusMsg msg = (EmergencyOrPriorityStatusMsg) Decoder.genericDecoder("8DA2C1B6E112B600000000760759"); + byte[] modeA = msg.getModeACode(); + String squawk = "" + modeA[0] + modeA[1] + modeA[2] + modeA[3]; + assertEquals("6513", squawk); + } + + @Test + public void testIcao() throws Exception { + EmergencyOrPriorityStatusMsg msg = (EmergencyOrPriorityStatusMsg) Decoder.genericDecoder("8DA2C1B6E112B600000000760759"); + assertEquals("a2c1b6", tools.toHexString(msg.getIcao24())); + } + + @Test + public void testTypeCode28() throws Exception { + EmergencyOrPriorityStatusMsg msg = (EmergencyOrPriorityStatusMsg) Decoder.genericDecoder("8DA2C1B6E112B600000000760759"); + assertEquals(28, msg.getFormatTypeCode()); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/IdentificationMsgTest.java b/src/test/java/org/opensky/libadsb/msgs/IdentificationMsgTest.java new file mode 100644 index 0000000..08aabd5 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/IdentificationMsgTest.java @@ -0,0 +1,55 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.tools; +import org.opensky.libadsb.exceptions.BadFormatException; + +import static org.junit.Assert.*; + +/** + * Tests for ADS-B identification messages (TC 1-4). + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + * Note: trailing '_' in pyModeS output represents space ' ' in actual callsign. + */ +public class IdentificationMsgTest { + + @Test + public void testCallsignEZY85MH() throws Exception { + IdentificationMsg msg = (IdentificationMsg) Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + // pyModeS: "EZY85MH_" (_ = space), lib1090: "EZY85MH " + assertEquals("EZY85MH ", new String(msg.getIdentity())); + } + + @Test + public void testCallsignKLM1023() throws Exception { + IdentificationMsg msg = (IdentificationMsg) Decoder.genericDecoder("8D4840D6202CC371C32CE0576098"); + // pyModeS: "KLM1023_" (_ = space), lib1090: "KLM1023 " + assertEquals("KLM1023 ", new String(msg.getIdentity())); + } + + @Test + public void testCategoryEZY85MH() throws Exception { + IdentificationMsg msg = (IdentificationMsg) Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + // All decoders agree: category=0 + assertEquals(0, msg.getEmitterCategory()); + } + + @Test + public void testCategoryKLM1023() throws Exception { + IdentificationMsg msg = (IdentificationMsg) Decoder.genericDecoder("8D4840D6202CC371C32CE0576098"); + assertEquals(0, msg.getEmitterCategory()); + } + + @Test + public void testTypeCodeIdentification() throws Exception { + IdentificationMsg msg = (IdentificationMsg) Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + assertEquals(4, msg.getFormatTypeCode()); + } + + @Test + public void testIcaoIdentification() throws Exception { + IdentificationMsg msg = (IdentificationMsg) Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + assertEquals("406b90", tools.toHexString(msg.getIcao24())); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/ModeSReplyTest.java b/src/test/java/org/opensky/libadsb/msgs/ModeSReplyTest.java new file mode 100644 index 0000000..16f6d08 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/ModeSReplyTest.java @@ -0,0 +1,118 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.tools; +import org.opensky.libadsb.exceptions.BadFormatException; +import org.opensky.libadsb.exceptions.UnspecifiedFormatError; + +import static org.junit.Assert.*; + +/** + * Tests for CRC validation, ICAO extraction, and downlink format parsing. + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + */ +public class ModeSReplyTest { + + // Valid DF17 messages (CRC=0 in pyModeS) + private static final String[] VALID_MESSAGES = { + "8D406B902015A678D4D220AA4BDA", + "8D4840D6202CC371C32CE0576098", + "8D485020994409940838175B284F", + "8DA05F219B06B6AF189400CBC33F", + "8d8960ed58bf053cf11bc5932b7d", + "8d45cab390c39509496ca9a32912", + "8d74802958c904e6ef4ba0184d5c", + "8d4400cd9b0000b4f87000e71a10", + "8d4065de58a1054a7ef0218e226a", + }; + + @Test + public void testValidCRC() throws Exception { + for (String hex : VALID_MESSAGES) { + ModeSReply msg = Decoder.genericDecoder(hex); + assertTrue("CRC should be valid for " + hex, msg.checkParity()); + } + } + + @Test + public void testDownlinkFormat17() throws Exception { + ModeSReply msg = Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + assertEquals(17, msg.getDownlinkFormat()); + } + + @Test + public void testDownlinkFormat17WithCA5() throws Exception { + // 8D = 10001101, DF = 10001 = 17, CA = 101 = 5 + ModeSReply msg = Decoder.genericDecoder("8D485020994409940838175B284F"); + assertEquals(17, msg.getDownlinkFormat()); + } + + @Test + public void testDownlinkFormat17WithCA4() throws Exception { + // 8C = 10001100, DF = 10001 = 17, CA = 100 = 4 + ModeSReply msg = Decoder.genericDecoder("8c3c4dc6381c07331b029eb308de"); + assertEquals(17, msg.getDownlinkFormat()); + } + + @Test + public void testIcaoExtraction() throws Exception { + ModeSReply msg = Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + assertEquals("406b90", tools.toHexString(msg.getIcao24())); + } + + @Test + public void testIcaoExtractionMultiple() throws Exception { + String[][] cases = { + {"8D406B902015A678D4D220AA4BDA", "406b90"}, + {"8D4840D6202CC371C32CE0576098", "4840d6"}, + {"8D485020994409940838175B284F", "485020"}, + {"8DA05F219B06B6AF189400CBC33F", "a05f21"}, + {"8DA2C1B6E112B600000000760759", "a2c1b6"}, + {"8DA05629EA21485CBF3F8CADAEEB", "a05629"}, + }; + for (String[] c : cases) { + ModeSReply msg = Decoder.genericDecoder(c[0]); + assertEquals("ICAO for " + c[0], c[1], tools.toHexString(msg.getIcao24())); + } + } + + @Test + public void testFormatTypeCode() throws Exception { + // TC 4 - identification + ExtendedSquitter es = (ExtendedSquitter) Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA"); + assertEquals(4, es.getFormatTypeCode()); + + // TC 11 - airborne position + es = (ExtendedSquitter) Decoder.genericDecoder("8D40058B58C901375147EFD09357"); + assertEquals(11, es.getFormatTypeCode()); + + // TC 19 - velocity + es = (ExtendedSquitter) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertEquals(19, es.getFormatTypeCode()); + + // TC 28 - emergency + es = (ExtendedSquitter) Decoder.genericDecoder("8DA2C1B6E112B600000000760759"); + assertEquals(28, es.getFormatTypeCode()); + + // TC 29 - target state + es = (ExtendedSquitter) Decoder.genericDecoder("8DA05629EA21485CBF3F8CADAEEB"); + assertEquals(29, es.getFormatTypeCode()); + } + + @Test + public void testGenericDecoderReturnsCorrectType() throws Exception { + assertTrue(Decoder.genericDecoder("8D406B902015A678D4D220AA4BDA") instanceof IdentificationMsg); + assertTrue(Decoder.genericDecoder("8D40058B58C901375147EFD09357") instanceof AirbornePositionV0Msg); + assertTrue(Decoder.genericDecoder("8D485020994409940838175B284F") instanceof VelocityOverGroundMsg); + assertTrue(Decoder.genericDecoder("8DA05F219B06B6AF189400CBC33F") instanceof AirspeedHeadingMsg); + assertTrue(Decoder.genericDecoder("8DA2C1B6E112B600000000760759") instanceof EmergencyOrPriorityStatusMsg); + } + + @Test + public void testSurfaceMessageType() throws Exception { + // TC 7 surface position + ModeSReply msg = Decoder.genericDecoder("8c3c4dc6381c07331b029eb308de"); + assertTrue(msg instanceof SurfacePositionV0Msg); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/SurfacePositionV0MsgTest.java b/src/test/java/org/opensky/libadsb/msgs/SurfacePositionV0MsgTest.java new file mode 100644 index 0000000..3a86477 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/SurfacePositionV0MsgTest.java @@ -0,0 +1,130 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.Position; +import org.opensky.libadsb.tools; + +import static org.junit.Assert.*; + +/** + * Tests for surface position messages (TC 5-8). + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + * + * Bug #6: Southern hemisphere detection subtracts latitude from 90 + * instead of negating it (e.g. 90-43.49 = 46.51 instead of -43.49). + * + * Bug #7: Longitude wrap doesn't handle values < -180 after hemisphere adjustment. + */ +public class SurfacePositionV0MsgTest { + + // === Surface message basic field tests === + + // 8c3c4dc6381c07331b029eb308de: TC=7, heading=180, GS=0 + @Test + public void testSurfaceHeading_3C4DC6() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c3c4dc6381c07331b029eb308de"); + assertTrue(msg.hasValidHeading()); + assertEquals(180.0, msg.getHeading(), 0.001); + } + + @Test + public void testSurfaceGroundSpeedZero_3C4DC6() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c3c4dc6381c07331b029eb308de"); + assertTrue(msg.hasGroundSpeed()); + assertEquals(0.0, msg.getGroundSpeed(), 0.1); + } + + // 8c4841753aab238733c8cd4020b1: TC=7, GS=18, track=140.625 + @Test + public void testSurfaceGroundSpeed_484175() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c4841753aab238733c8cd4020b1"); + assertTrue(msg.hasGroundSpeed()); + assertEquals(18.0, msg.getGroundSpeed(), 1.0); + } + + @Test + public void testSurfaceTrack_484175() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c4841753aab238733c8cd4020b1"); + assertEquals(140.625, msg.getHeading(), 0.001); + } + + // 8FC8200A3AB8F5F893096B000000: TC=7, GS=19, track=42.1875 + @Test + public void testSurfaceGroundSpeed_C8200A() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8FC8200A3AB8F5F893096B000000"); + assertEquals(19.0, msg.getGroundSpeed(), 1.0); + } + + @Test + public void testSurfaceTrack_C8200A() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8FC8200A3AB8F5F893096B000000"); + assertEquals(42.1875, msg.getHeading(), 0.001); + } + + // === Surface CPR flag tests === + + @Test + public void testOddFlag_C8200A() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8FC8200A3AB8F5F893096B000000"); + assertTrue(msg.isOddFormat()); + } + + @Test + public void testEvenFlag_484175() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c4841753aab238733c8cd4020b1"); + assertFalse(msg.isOddFormat()); + } + + // === Local CPR decode with reference === + + @Test + public void testSurfaceLocalPosition_SouthernHemisphere() throws Exception { + // Bug #6: reference lat=-43.5, lon=172.5 + // pyModeS: ref_lat=-43.485644, ref_lon=172.539417 + // Correct: latitude should be negative (southern hemisphere) + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8FC8200A3AB8F5F893096B000000"); + Position ref = new Position(172.5, -43.5, null); + Position pos = msg.getLocalPosition(ref); + assertNotNull(pos); + // Latitude should be in the southern hemisphere + assertTrue("Latitude should be negative for southern hemisphere", + pos.getLatitude() < 0); + assertEquals(-43.486, pos.getLatitude(), 0.01); + assertEquals(172.539, pos.getLongitude(), 0.01); + } + + @Test + public void testSurfaceLocalPosition_NorthernHemisphere() throws Exception { + // reference lat=51.99, lon=4.375 + // pyModeS: ref_lat=52.32304, ref_lon=4.730473 + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c4841753aab238733c8cd4020b1"); + Position ref = new Position(4.375, 51.99, null); + Position pos = msg.getLocalPosition(ref); + assertNotNull(pos); + assertEquals(52.323, pos.getLatitude(), 0.01); + assertEquals(4.730, pos.getLongitude(), 0.01); + } + + // === Type code === + + @Test + public void testTypeCode7() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c3c4dc6381c07331b029eb308de"); + assertEquals(7, msg.getFormatTypeCode()); + } + + // === ICAO extraction === + + @Test + public void testIcao_3C4DC6() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c3c4dc6381c07331b029eb308de"); + assertEquals("3c4dc6", tools.toHexString(msg.getIcao24())); + } + + @Test + public void testIcao_484175() throws Exception { + SurfacePositionV0Msg msg = (SurfacePositionV0Msg) Decoder.genericDecoder("8c4841753aab238733c8cd4020b1"); + assertEquals("484175", tools.toHexString(msg.getIcao24())); + } +} diff --git a/src/test/java/org/opensky/libadsb/msgs/VelocityOverGroundMsgTest.java b/src/test/java/org/opensky/libadsb/msgs/VelocityOverGroundMsgTest.java new file mode 100644 index 0000000..3c52aa8 --- /dev/null +++ b/src/test/java/org/opensky/libadsb/msgs/VelocityOverGroundMsgTest.java @@ -0,0 +1,128 @@ +package org.opensky.libadsb.msgs; + +import org.junit.Test; +import org.opensky.libadsb.Decoder; +import org.opensky.libadsb.tools; + +import static org.junit.Assert.*; + +/** + * Tests for velocity over ground messages (TC 19, subtype 1-2). + * Expected values cross-validated with pyModeS 2.21.1 and lib1090. + * + * Tests marked "Bug #2" will FAIL against current code due to the vertical rate + * availability check bug (checks == -1 but raw=0 produces -64 after <<6). + */ +public class VelocityOverGroundMsgTest { + + // --- Message: 8D485020994409940838175B284F --- + // pyModeS: speed=159, heading=182.88, vrate=-832, GS, alt_diff=550 + // lib1090: speed=159.20, heading=182.88, vrate=-832, GS, alt_diff=550 + + @Test + public void testGroundSpeed_485020() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertTrue(msg.hasVelocityInfo()); + // pyModeS returns integer 159, lib1090 returns 159.201131 + assertEquals(159.0, msg.getVelocity(), 1.0); + } + + @Test + public void testHeading_485020() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertEquals(182.88, msg.getHeading(), 0.01); + } + + @Test + public void testVerticalRate_485020() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertTrue(msg.hasVerticalRateInfo()); + assertEquals(-832, msg.getVerticalRate().intValue()); + } + + @Test + public void testGeoMinusBaro_485020() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertTrue(msg.hasGeoMinusBaroInfo()); + assertEquals(550, msg.getGeoMinusBaro().intValue()); + } + + // --- Message: 8D45AC2D9904D910613F94BA81B5 --- + // pyModeS: speed=252, heading=301.04, vrate=4992, GS, alt_diff=-475 + @Test + public void testGroundSpeed_45AC2D() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d45ac2d9904d910613f94ba81b5"); + assertEquals(252.0, msg.getVelocity(), 1.0); + } + + @Test + public void testHeading_45AC2D() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d45ac2d9904d910613f94ba81b5"); + assertEquals(301.04, msg.getHeading(), 0.01); + } + + @Test + public void testVerticalRatePositive_45AC2D() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d45ac2d9904d910613f94ba81b5"); + assertEquals(4992, msg.getVerticalRate().intValue()); + } + + @Test + public void testGeoMinusBaroNegative_45AC2D() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d45ac2d9904d910613f94ba81b5"); + assertEquals(-475, msg.getGeoMinusBaro().intValue()); + } + + // --- Message: 8D451E8B99019699C00B0A81F36E --- + // pyModeS: speed=453, heading=116.85, vrate=64, alt_diff=225 + @Test + public void testSmallPositiveVerticalRate_451E8B() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D451E8B99019699C00B0A81F36E"); + assertTrue(msg.hasVerticalRateInfo()); + assertEquals(64, msg.getVerticalRate().intValue()); + } + + // --- Small vertical rate tests (from jet1090 test vectors) --- + // 8d3461cf9908388930080f948ea1: vrate=+64 + @Test + public void testVerticalRate64_3461cf_a() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d3461cf9908388930080f948ea1"); + assertEquals(64, msg.getVerticalRate().intValue()); + assertEquals(350, msg.getGeoMinusBaro().intValue()); + } + + // 8d3461cf9908558e100c1071eb67: vrate=+128 + @Test + public void testVerticalRate128_3461cf_b() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d3461cf9908558e100c1071eb67"); + assertEquals(128, msg.getVerticalRate().intValue()); + assertEquals(375, msg.getGeoMinusBaro().intValue()); + } + + // 8d3461cf99085a8f10400f80e6ac: vrate=+960 + @Test + public void testVerticalRate960_3461cf_c() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d3461cf99085a8f10400f80e6ac"); + assertEquals(960, msg.getVerticalRate().intValue()); + } + + // 8d394c0f990c4932780838866883: vrate=-64 + @Test + public void testVerticalRateNeg64_394c0f() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8d394c0f990c4932780838866883"); + assertEquals(-64, msg.getVerticalRate().intValue()); + assertEquals(1375, msg.getGeoMinusBaro().intValue()); + } + + @Test + public void testIcaoExtraction() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertEquals("485020", tools.toHexString(msg.getIcao24())); + } + + @Test + public void testSpeedType() throws Exception { + VelocityOverGroundMsg msg = (VelocityOverGroundMsg) Decoder.genericDecoder("8D485020994409940838175B284F"); + assertFalse("Ground speed messages should not be supersonic", msg.isSupersonic()); + } +}