diff --git a/src/main/java/org/deepsymmetry/beatlink/CdjStatus.java b/src/main/java/org/deepsymmetry/beatlink/CdjStatus.java index e55fe5e..3ea1bc1 100644 --- a/src/main/java/org/deepsymmetry/beatlink/CdjStatus.java +++ b/src/main/java/org/deepsymmetry/beatlink/CdjStatus.java @@ -76,6 +76,25 @@ public class CdjStatus extends DeviceUpdate { @API(status = API.Status.STABLE) public static final int PLAYING_FLAG = 0x40; + /** + * The byte which reports the state of the player’s CD slot, labeled dl in the + * Packet Analysis document. + */ + public static final int LOCAL_CD_STATE = 0x37; + + /** + * The byte which reports the state of the player’s USB slot, labeled Ul in the + * Packet Analysis document. + */ + @API(status = API.Status.STABLE) + public static final int LOCAL_USB_STATE = 0x6F; + + /** + * The byte which reports the state of the player’s SD slot, labeled Sl in the + * Packet Analysis document. + */ + public static final int LOCAL_SD_STATE = 0x73; + /** * The device number of the player from which the track was loaded, if any; labeled Dr in * the Packet Analysis document. @@ -513,7 +532,7 @@ public PlayState3 getPlayState3() { * @return the proper value */ private TrackSourceSlot findTrackSourceSlot() { - TrackSourceSlot result = TRACK_SOURCE_SLOT_MAP.get(packetBytes[41]); + TrackSourceSlot result = TRACK_SOURCE_SLOT_MAP.get(packetBytes[0x29]); if (result == null) { return TrackSourceSlot.UNKNOWN; } @@ -526,7 +545,7 @@ private TrackSourceSlot findTrackSourceSlot() { * @return the proper value */ private TrackType findTrackType() { - TrackType result = TRACK_TYPE_MAP.get(packetBytes[42]); + TrackType result = TRACK_TYPE_MAP.get(packetBytes[0x2a]); if (result == null) { return TrackType.UNKNOWN; } @@ -539,7 +558,7 @@ private TrackType findTrackType() { * @return the proper value */ private PlayState1 findPlayState1() { - PlayState1 result = PLAY_STATE_1_MAP.get(packetBytes[123]); + PlayState1 result = PLAY_STATE_1_MAP.get(packetBytes[0x7b]); if (result == null) { return PlayState1.UNKNOWN; } @@ -552,7 +571,7 @@ private PlayState1 findPlayState1() { * @return the proper value */ private PlayState2 findPlayState2() { - switch (packetBytes[139]) { + switch (packetBytes[0x8b]) { case 0x6a: case 0x7a: case (byte)0xfa: @@ -574,7 +593,7 @@ private PlayState2 findPlayState2() { * @return the proper value */ private PlayState3 findPlayState3() { - PlayState3 result = PLAY_STATE_3_MAP.get(packetBytes[157]); + PlayState3 result = PLAY_STATE_3_MAP.get(packetBytes[0x9d]); if (result == null) { return PlayState3.UNKNOWN; } @@ -605,6 +624,13 @@ private PlayState3 findPlayState3() { @API(status = API.Status.STABLE) public static final int MINIMUM_PACKET_SIZE = 0xcc; + /** + * Indicates whether the status flags in this instance had to be reused from the previous valid packet received + * from this device, because it is an Opus Quad which sent a zero value in the current packet. + */ + @API(status = API.Status.EXPERIMENTAL) + public final boolean statusFlagsWereReplayed; + /** * Constructor sets all the immutable interpreted fields based on the packet content. * @@ -612,8 +638,22 @@ private PlayState3 findPlayState3() { */ @API(status = API.Status.STABLE) public CdjStatus(DatagramPacket packet) { + this(packet, false); + } + + /** + * Constructor that records whether the status flag is a replay of a previous, valid status flag, because + * we are receiving corrupt data from an Opus Quad. + * + * @param packet the CDJ status packet that was received, except that a zero {@link #STATUS_FLAGS} byte + * may have been replaced by a valid one from the previous packet received for this device + * @param statusFlagsReplayed indicates whether the status flag replacement has occurred. + */ + CdjStatus(DatagramPacket packet, boolean statusFlagsReplayed) { super(packet, "CDJ status", packet.getLength()); + statusFlagsWereReplayed = statusFlagsReplayed; + if (packetBytes.length < MINIMUM_PACKET_SIZE) { throw new IllegalArgumentException("Unable to create a CdjStatus object, packet too short: we need " + MINIMUM_PACKET_SIZE + " bytes and were given only " + packetBytes.length); @@ -633,16 +673,16 @@ public CdjStatus(DatagramPacket packet) { logger.warn("Processing CDJ Status packets with unexpected lengths {}.", packetBytes.length); } trackType = findTrackType(); - rekordboxId = (int)Util.bytesToNumber(packetBytes, 44, 4); - pitch = (int)Util.bytesToNumber(packetBytes, 141, 3); - bpm = (int)Util.bytesToNumber(packetBytes, 146, 2); + rekordboxId = (int)Util.bytesToNumber(packetBytes, 0x2c, 4); + pitch = (int)Util.bytesToNumber(packetBytes, 0x8d, 3); + bpm = (int)Util.bytesToNumber(packetBytes, 0x92, 2); playState1 = findPlayState1(); playState2 = findPlayState2(); playState3 = findPlayState3(); - firmwareVersion = new String(packetBytes, 124, 4).trim(); + firmwareVersion = new String(packetBytes, 0x7c, 4).trim(); handingMasterToDevice = Util.unsign(packetBytes[MASTER_HAND_OFF]); - final byte trackSourceByte = packetBytes[40]; + final byte trackSourceByte = packetBytes[0x28]; if (isFromOpusQuad && (trackSourceByte < 16)) { int sourcePlayer = Util.translateOpusPlayerNumbers(trackSourceByte); if (sourcePlayer != 0) { @@ -654,7 +694,7 @@ public CdjStatus(DatagramPacket packet) { trackSourcePlayer = sourcePlayer; trackSourceSlot = TrackSourceSlot.USB_SLOT; // Indicate whether we have a metadata archive available for the USB slot: - packetBytes[111] = (byte) (OpusProvider.getInstance().findArchive(deviceNumber) == null? 4 : 0); + packetBytes[LOCAL_USB_STATE] = (byte) (OpusProvider.getInstance().findArchive(deviceNumber) == null? 4 : 0); } else { trackSourcePlayer = trackSourceByte; trackSourceSlot = findTrackSourceSlot(); @@ -732,7 +772,7 @@ public int getBpm() { */ @API(status = API.Status.STABLE) public int getBeatWithinBar() { - return packetBytes[166]; + return packetBytes[0xa6]; } /** @@ -773,23 +813,15 @@ public double getEffectiveTempo() { /** * Was the CDJ playing a track when this update was sent? Has special logic to try to accommodate the quirks of - * both pre-nexus players and the Opus Quad. + * pre-nexus players. * * @return true if the play flag was set, or, if this seems to be a non-nexus player, if P1 * and P2 have values corresponding to a playing state. */ @API(status = API.Status.STABLE) public boolean isPlaying() { - if (packetBytes.length >= 212) { - final boolean simpleResult = (packetBytes[STATUS_FLAGS] & PLAYING_FLAG) > 0; - if (!simpleResult && isFromOpusQuad) { - // Sometimes the Opus Quad lies and reports that it is not playing in this flag, even though it actually is. - // Try to recover from that. - return playState1 == PlayState1.PLAYING || playState1 == PlayState1.LOOPING || - (playState1 == PlayState1.SEARCHING && (playState2.protocolValue & 0x0f) == 0x0a); - } else { - return simpleResult; - } + if (packetBytes.length >= 0xd4) { + return (packetBytes[STATUS_FLAGS] & PLAYING_FLAG) > 0; } else { // Pre-nexus players don’t send this critical flag byte at all, so we always have to infer play state. return playState1 == PlayState1.PLAYING || playState1 == PlayState1.LOOPING || @@ -838,7 +870,7 @@ public boolean isOnAir() { */ @API(status = API.Status.STABLE) public boolean isLocalUsbLoaded() { - return (packetBytes[111] == 0); + return (packetBytes[LOCAL_USB_STATE] == 0); } /** @@ -848,7 +880,7 @@ public boolean isLocalUsbLoaded() { */ @API(status = API.Status.STABLE) public boolean isLocalUsbUnloading() { - return (packetBytes[111] == 2); + return (packetBytes[LOCAL_USB_STATE] == 2); } /** @@ -858,7 +890,7 @@ public boolean isLocalUsbUnloading() { */ @API(status = API.Status.STABLE) public boolean isLocalUsbEmpty() { - return (packetBytes[111] == 4); + return (packetBytes[LOCAL_USB_STATE] == 4); } /** @@ -868,7 +900,7 @@ public boolean isLocalUsbEmpty() { */ @API(status = API.Status.STABLE) public boolean isLocalSdLoaded() { - return (packetBytes[115] == 0); + return (packetBytes[LOCAL_SD_STATE] == 0); } /** @@ -878,7 +910,7 @@ public boolean isLocalSdLoaded() { */ @API(status = API.Status.STABLE) public boolean isLocalSdUnloading() { - return (packetBytes[115] == 2); + return (packetBytes[LOCAL_SD_STATE] == 2); } /** @@ -888,7 +920,7 @@ public boolean isLocalSdUnloading() { */ @API(status = API.Status.STABLE) public boolean isLocalSdEmpty() { - return (packetBytes[115] == 4); + return (packetBytes[LOCAL_SD_STATE] == 4); } /** @@ -901,7 +933,7 @@ public boolean isLocalSdEmpty() { */ @API(status = API.Status.STABLE) public boolean isDiscSlotEmpty() { - return (packetBytes[0x37] != 0x1e) && (packetBytes[0x37] != 0x11); + return (packetBytes[LOCAL_CD_STATE] != 0x1e) && (packetBytes[LOCAL_CD_STATE] != 0x11); } /** @@ -912,7 +944,7 @@ public boolean isDiscSlotEmpty() { */ @API(status = API.Status.STABLE) public boolean isDiscSlotAsleep() { - return (packetBytes[0x37] == 1); + return (packetBytes[LOCAL_CD_STATE] == 1); } /** @@ -1036,7 +1068,7 @@ public boolean isPlayingCdjMode() { */ @API(status = API.Status.STABLE) public boolean isLinkMediaAvailable() { - return (packetBytes[117] != 0); + return (packetBytes[0x75] != 0); } /** @@ -1046,7 +1078,7 @@ public boolean isLinkMediaAvailable() { */ @API(status = API.Status.STABLE) public boolean isBusy() { - return packetBytes[39] != 0; + return packetBytes[0x27] != 0; } /** @@ -1057,7 +1089,7 @@ public boolean isBusy() { */ @API(status = API.Status.STABLE) public int getTrackNumber() { - return (int)Util.bytesToNumber(packetBytes, 50, 2); + return (int)Util.bytesToNumber(packetBytes, 0x32, 2); } /** @@ -1083,7 +1115,7 @@ public int getSyncNumber() { */ @API(status = API.Status.STABLE) public int getBeatNumber() { - long result = Util.bytesToNumber(packetBytes, 160, 4); + long result = Util.bytesToNumber(packetBytes, 0xa0, 4); if (result != 0xffffffffL) { return (int) result; } return -1; @@ -1104,7 +1136,7 @@ public int getBeatNumber() { */ @API(status = API.Status.STABLE) public int getCueCountdown() { - return (int)Util.bytesToNumber(packetBytes, 164, 2); + return (int)Util.bytesToNumber(packetBytes, 0xa4, 2); } /** @@ -1151,7 +1183,7 @@ public String getFirmwareVersion() { */ @API(status = API.Status.STABLE) public long getPacketNumber() { - return Util.bytesToNumber(packetBytes, 200, 4); + return Util.bytesToNumber(packetBytes, 0xc8, 4); } /** @@ -1221,7 +1253,8 @@ public String toString() { ", isBeatWithinBarMeaningful? " + isBeatWithinBarMeaningful() + ", cue: " + formatCueCountdown() + ", Playing? " + isPlaying() + ", Master? " + isTempoMaster() + ", Synced? " + isSynced() + ", On-Air? " + isOnAir() + - ", handingMasterToDevice:" + handingMasterToDevice + "]"; + ", handingMasterToDevice: " + handingMasterToDevice + + ", statusFlagsWereReplayed: " + statusFlagsWereReplayed + "]"; } } diff --git a/src/main/java/org/deepsymmetry/beatlink/DeviceUpdate.java b/src/main/java/org/deepsymmetry/beatlink/DeviceUpdate.java index 6e19832..3bc3c11 100644 --- a/src/main/java/org/deepsymmetry/beatlink/DeviceUpdate.java +++ b/src/main/java/org/deepsymmetry/beatlink/DeviceUpdate.java @@ -14,6 +14,11 @@ @API(status = API.Status.STABLE) public abstract class DeviceUpdate { + /** + * The offset at which the device number that sent this packet can be found. + */ + public static final int DEVICE_NUMBER_OFFSET = 0x21; + /** * The address from which this device update was received. */ @@ -60,14 +65,14 @@ public DeviceUpdate(DatagramPacket packet, String name, int length) { address = packet.getAddress(); packetBytes = new byte[packet.getLength()]; System.arraycopy(packet.getData(), 0, packetBytes, 0, packet.getLength()); - deviceName = new String(packetBytes, 11, 20).trim(); + deviceName = new String(packetBytes, 0x0b, 20).trim(); isFromOpusQuad = deviceName.equals(OpusProvider.OPUS_NAME); preNexusCdj = deviceName.startsWith("CDJ") && (deviceName.endsWith("900") || deviceName.endsWith("2000")); if (isFromOpusQuad) { - deviceNumber = Util.translateOpusPlayerNumbers(packetBytes[40]); + deviceNumber = Util.translateOpusPlayerNumbers(packetBytes[DEVICE_NUMBER_OFFSET]); } else { - deviceNumber = Util.unsign(packetBytes[33]); + deviceNumber = Util.unsign(packetBytes[DEVICE_NUMBER_OFFSET]); } } diff --git a/src/main/java/org/deepsymmetry/beatlink/VirtualRekordbox.java b/src/main/java/org/deepsymmetry/beatlink/VirtualRekordbox.java index 6bbcf5c..a387fda 100644 --- a/src/main/java/org/deepsymmetry/beatlink/VirtualRekordbox.java +++ b/src/main/java/org/deepsymmetry/beatlink/VirtualRekordbox.java @@ -1,7 +1,6 @@ package org.deepsymmetry.beatlink; import org.apiguardian.api.API; -import org.deepsymmetry.beatlink.data.MetadataFinder; import org.deepsymmetry.beatlink.data.OpusProvider; import org.deepsymmetry.beatlink.data.SlotReference; import org.slf4j.Logger; @@ -209,6 +208,10 @@ public void setAnnounceInterval(int interval) { 0x02, 0x0b, 0x04, 0x01, 0x00, 0x00, 0x04, 0x08 }; + /** + * Used to construct the packet we send to request lighting information from the Opus Quad, which is how we + * can figure out what tracks it is playing. + */ private static final byte[] rekordboxLightingRequestStatusBytes = { 0x51, 0x73, 0x70, 0x74, 0x31, 0x57, 0x6d, 0x4a, 0x4f, 0x4c, 0x11, 0x72, 0x65, 0x6b, 0x6f, 0x72, 0x64, 0x62, 0x6f, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, @@ -328,7 +331,7 @@ public static String getDeviceName() { * This imitates the request RekordboxLighting sends to get PSSI data from Opus Quad device * (and maybe more devices in the future). * - * @throws IOException + * @throws IOException if there is a problem sending the request */ @API(status = API.Status.EXPERIMENTAL) public void requestPSSI() throws IOException{ @@ -356,11 +359,7 @@ public void requestPSSI() throws IOException{ * * @param usbSlotNumber the slot we have the archive loaded in */ - - /** - * - * @param usbSlotNumber - */ + @API(status = API.Status.EXPERIMENTAL) public void clearPlayerCaches(int usbSlotNumber){ playerSongStructures.remove(usbSlotNumber); playerTrackSourceSlots.remove(usbSlotNumber); @@ -378,6 +377,13 @@ SlotReference findMatchedTrackSourceSlotForPlayer(int player) { return playerTrackSourceSlots.get(player); } + + /** + * Keeps track of the most recent valid (non-zero) status flag byte we have received from each device number, + * so we can reuse it in cases where a corrupt (zero) value has been sent to us. + */ + private final Map lastValidStatusFlagBytes = new ConcurrentHashMap<>(); + /** * Given an update packet sent to us, create the appropriate object to describe it. * @@ -406,7 +412,24 @@ private DeviceUpdate buildUpdate(DatagramPacket packet) { case CDJ_STATUS: if (length >= CdjStatus.MINIMUM_PACKET_SIZE) { - CdjStatus status = new CdjStatus(packet); + // Try to recover from a malformed Opus Quad packet with a zero value in its status flags by reusing the last valid one we saw from the same device. + final byte reportedStatusFlags = packet.getData()[CdjStatus.STATUS_FLAGS]; + final boolean hadToRecoverStatusFlags = (reportedStatusFlags == 0); + final int rawDeviceNumber = packet.getData()[CdjStatus.DEVICE_NUMBER_OFFSET]; + if (hadToRecoverStatusFlags) { + final Byte recoveredStatusFlags = lastValidStatusFlagBytes.get(rawDeviceNumber); + if (recoveredStatusFlags != null) { + packet.getData()[CdjStatus.STATUS_FLAGS] = recoveredStatusFlags; + } else { + logger.warn("Unable to recover from malformed Opus Quad status packet because we have not yet received a valid packet from device {}.", + rawDeviceNumber); + return null; // Discard this packet. + } + } else { + lastValidStatusFlagBytes.put(rawDeviceNumber, reportedStatusFlags); // Record in case next packet for this device is malformed. + } + + CdjStatus status = new CdjStatus(packet, hadToRecoverStatusFlags); // If source player number is zero the deck does not have a song loaded, clear the PSSI and source slot we had for that player. if (status.getTrackSourcePlayer() == 0) { @@ -825,7 +848,7 @@ public void sendRekordboxAnnouncement() { } /** - * TODO top-level description needed. + * TODO: top-level description needed. * * @return true if we found DJ Link devices and were able to create the {@code VirtualRekordbox}. * @throws Exception if there is a problem opening a socket on the right network @@ -1036,6 +1059,7 @@ synchronized boolean start() throws Exception { * @return true if we found DJ Link devices and were able to create the {@code VirtualRekordbox}, or it was already running. * @throws SocketException if the socket to listen on port 50002 cannot be created */ + @API(status = API.Status.EXPERIMENTAL) synchronized boolean start(byte deviceNumber) throws Exception { // TODO I am not sure we actually need this method. If we do want to control the device number that is used, // we will need to add a mechanism do that from VirtualCdj. @@ -1049,6 +1073,7 @@ synchronized boolean start(byte deviceNumber) throws Exception { /** * Stop announcing ourselves and listening for status updates. */ + @API(status = API.Status.EXPERIMENTAL) synchronized void stop() { if (isRunning()) { DeviceFinder.getInstance().removeIgnoredAddress(socket.get().getLocalAddress()); @@ -1056,6 +1081,7 @@ synchronized void stop() { socket.set(null); broadcastAddress.set(null); updates.clear(); + lastValidStatusFlagBytes.clear(); setDeviceNumber((byte) 0); // Set up for self-assignment if restarted. deliverLifecycleAnnouncement(logger, false); }