org.deepsymmetry.beatlink.DeviceFinder Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of beat-link Show documentation
Show all versions of beat-link Show documentation
A Java library for communicating with Pioneer DJ Link equipment.
package org.deepsymmetry.beatlink;
import java.io.IOException;
import java.net.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.*;
/**
* Watches for devices to report their presence by broadcasting announcement packets on port 50000,
* and keeps a list of the devices that have been seen, and the network address on which they were seen.
*
* @author James Elliott
*/
@SuppressWarnings("WeakerAccess")
public class DeviceFinder extends LifecycleParticipant {
private static final Logger logger = LoggerFactory.getLogger(DeviceFinder.class);
/**
* The port to which devices broadcast announcement messages to report their presence on the network.
*/
public static final int ANNOUNCEMENT_PORT = 50000;
/**
* The number of milliseconds after which we will consider a device to have disappeared if
* we have not received an announcement from it.
*/
public static final int MAXIMUM_AGE = 10000;
/**
* The socket used to listen for announcement packets while we are active.
*/
private final AtomicReference socket = new AtomicReference(null);
/**
* Track when we started listening for announcement packets.
*/
private static final AtomicLong startTime = new AtomicLong();
/**
* Track when we saw the first announcement packet, to help the {@link VirtualCdj} determine how long it needs
* to watch for devices in order to avoid conflicts when self-assigning a device number. Will be zero when none
* have yet been seen.
*/
private static final AtomicLong firstDeviceTime = new AtomicLong(0);
/**
* Check whether we are presently listening for device announcements.
*
* @return {@code true} if our socket is open and monitoring for DJ Link device announcements on the network
*/
public boolean isRunning() {
return socket.get() != null;
}
/**
* Get the timestamp of when we started listening for device announcements.
*
* @return the system millisecond timestamp when {@link #start()} was called.
* @throws IllegalStateException if we are not listening for announcements.
*/
public long getStartTime() {
ensureRunning();
return startTime.get();
}
/**
* Get the timestamp of when we saw the first announcement packet, to help the {@link VirtualCdj} determine how
* long it needs to watch for devices in order to avoid conflicts when self-assigning a device number.
*
* @return the system millisecond timestamp when the first device announcement was received.
* @throws IllegalStateException if we are not listening for announcements, or if none have been seen.
*/
public long getFirstDeviceTime() {
ensureRunning();
final long result = firstDeviceTime.get();
if (result == 0) {
throw new IllegalStateException("No device announcements have yet been seen");
}
return result;
}
/**
* Keep track of the announcements we have seen.
*/
private final Map devices = new ConcurrentHashMap();
/**
* Remove any device announcements that are so old that the device seems to have gone away.
*/
private void expireDevices() {
long now = System.currentTimeMillis();
// Make a copy so we don't have to worry about concurrent modification.
Map copy = new HashMap(devices);
for (Map.Entry entry : copy.entrySet()) {
if (now - entry.getValue().getTimestamp() > MAXIMUM_AGE) {
devices.remove(entry.getKey());
deliverLostAnnouncement(entry.getValue());
}
}
if (devices.isEmpty()) {
firstDeviceTime.set(0); // We have lost contact with the Pro DJ Link network, so start over with next device.
}
}
/**
* Record a device announcement in the devices map, so we know whe saw it.
*
* @param announcement the announcement to be recorded
*/
private void updateDevices(DeviceAnnouncement announcement) {
firstDeviceTime.compareAndSet(0, System.currentTimeMillis());
devices.put(announcement.getAddress(), announcement);
}
/**
* Check whether a device is already known, or if it is newly found.
*
* @param announcement the message from the device to be considered
*
* @return true if this is the first message from this device
*/
private boolean isDeviceNew(DeviceAnnouncement announcement) {
return !devices.containsKey(announcement.getAddress());
}
/**
* Maintain a set of addresses from which device announcements should be ignored. The {@link VirtualCdj} will add
* its socket to this set when it is active so that it does not show up in the set of devices found on the network.
*/
private final Set ignoredAddresses =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Start ignoring any device updates which are received from the specified address. Intended for use by the
* {@link VirtualCdj}, so that its updates do not cause it to appear as a device.
*
* @param address the address from which any device updates should be ignored.
*/
public void addIgnoredAddress(InetAddress address) {
ignoredAddresses.add(address);
}
/**
* Stop ignoring device updates which are received from the specified address. Intended for use by the
* {@link VirtualCdj}, so that when it shuts down, its socket stops being treated specially.
*
* @param address the address from which any device updates should be ignored.
*/
public void removeIgnoredAddress(InetAddress address) {
ignoredAddresses.remove(address);
}
/**
* Check whether an address is being ignored. (The {@link BeatFinder} will call this so it can filter out the
* {@link VirtualCdj}'s beat messages when it is broadcasting them, for example.
*
* @param address the address to be checked as a candidate to be ignored
*
* @return {@code true} if packets from the address should be ignored
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isAddressIgnored(InetAddress address) {
return ignoredAddresses.contains(address);
}
/**
* Start listening for device announcements and keeping track of the DJ Link devices visible on the network.
* If already listening, has no effect.
*
* @throws SocketException if the socket to listen on port 50000 cannot be created
*/
public synchronized void start() throws SocketException {
if (!isRunning()) {
socket.set(new DatagramSocket(ANNOUNCEMENT_PORT));
startTime.set(System.currentTimeMillis());
deliverLifecycleAnnouncement(logger, true);
final byte[] buffer = new byte[512];
final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
Thread receiver = new Thread(null, new Runnable() {
@Override
public void run() {
boolean received;
while (isRunning()) {
try {
if (getCurrentDevices().isEmpty()) {
socket.get().setSoTimeout(60000); // We have no devices to check for timeout; block for a whole minute to check for shutdown
} else {
socket.get().setSoTimeout(1000); // Check every second to see if a device has vanished
}
socket.get().receive(packet);
received = !ignoredAddresses.contains(packet.getAddress());
} catch (SocketTimeoutException ste) {
received = false;
} catch (IOException e) {
// Don't log a warning if the exception was due to the socket closing at shutdown.
if (isRunning()) {
// We did not expect to have a problem; log a warning and shut down.
logger.warn("Problem reading from DeviceAnnouncement socket, stopping", e);
stop();
}
received = false;
}
try {
if (received && (packet.getLength() == 54)) {
final Util.PacketType kind = Util.validateHeader(packet, ANNOUNCEMENT_PORT);
if (kind == Util.PacketType.DEVICE_KEEP_ALIVE) {
// Looks like the kind of packet we need
if (packet.getLength() < 54) {
logger.warn("Ignoring too-short " + kind.name + " packet; expected 54 bytes, but only got " +
packet.getLength() + ".");
} else {
if (packet.getLength() > 54) {
logger.warn("Processing too-long " + kind.name + " packet; expected 54 bytes, but got " +
packet.getLength() + ".");
}
DeviceAnnouncement announcement = new DeviceAnnouncement(packet);
final boolean foundNewDevice = isDeviceNew(announcement);
updateDevices(announcement);
if (foundNewDevice) {
deliverFoundAnnouncement(announcement);
}
}
}
}
expireDevices();
} catch (Throwable t) {
logger.warn("Problem processing DeviceAnnouncement packet", t);
}
}
}
}, "beat-link DeviceFinder receiver");
receiver.setDaemon(true);
receiver.start();
}
}
/**
* Stop listening for device announcements. Also discard any announcements which had been received, and
* notify any registered listeners that those devices have been lost.
*/
@SuppressWarnings("WeakerAccess")
public synchronized void stop() {
if (isRunning()) {
final Set lastDevices = getCurrentDevices();
socket.get().close();
socket.set(null);
devices.clear();
firstDeviceTime.set(0);
// Report the loss of all our devices, on the proper thread, outside our lock
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
for (DeviceAnnouncement announcement : lastDevices) {
deliverLostAnnouncement(announcement);
}
}
});
deliverLifecycleAnnouncement(logger, false);
}
}
/**
* Get the set of DJ Link devices which currently can be seen on the network. These can be passed to
* {@link VirtualCdj#getLatestStatusFor(DeviceUpdate)} to find the current detailed status for that device,
* as long as the Virtual CDJ is active.
*
* @return the devices which have been heard from recently enough to be considered present on the network
*
* @throws IllegalStateException if the {@code DeviceFinder} is not active
*/
public Set getCurrentDevices() {
if (!isRunning()) {
throw new IllegalStateException("DeviceFinder is not active");
}
expireDevices(); // Get rid of anything past its sell-by date.
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(devices.values()));
}
/**
* Find and return the device announcement that was most recently received from a device identifying itself
* with the specified device number, if any.
*
* @param deviceNumber the device number of interest
*
* @return the matching announcement or null if no such device has been heard from
*
* @throws IllegalStateException if the {@code DeviceFinder} is not active
*/
public DeviceAnnouncement getLatestAnnouncementFrom(int deviceNumber) {
ensureRunning();
for (DeviceAnnouncement announcement : getCurrentDevices()) {
if (announcement.getNumber() == deviceNumber) {
return announcement;
}
}
return null;
}
/**
* Keeps track of the registered device announcement listeners.
*/
private final Set deviceListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified device announcement listener to receive device announcements when DJ Link devices
* are found on or leave the network. If {@code listener} is {@code null} or already present in the list
* of registered listeners, no exception is thrown and no action is performed.
*
* Device announcements are delivered to listeners on the
* Event Dispatch thread,
* so it is fine to interact with user interface objects in listener methods. Any code in the listener method
* must finish quickly, or unhandled events will back up and the user interface will be come unresponsive.
*
* @param listener the device announcement listener to add
*/
public void addDeviceAnnouncementListener(DeviceAnnouncementListener listener) {
if (listener != null) {
deviceListeners.add(listener);
}
}
/**
* Removes the specified device announcement listener so that it no longer receives device announcements when
* DJ Link devices are found on or leave the network. If {@code listener} is {@code null} or not present
* in the list of registered listeners, no exception is thrown and no action is performed.
*
* @param listener the device announcement listener to remove
*/
public void removeDeviceAnnouncementListener(DeviceAnnouncementListener listener) {
if (listener != null) {
deviceListeners.remove(listener);
}
}
/**
* Get the set of device announcement listeners that are currently registered.
*
* @return the currently registered device announcement listeners
*/
@SuppressWarnings("WeakerAccess")
public Set getDeviceAnnouncementListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(deviceListeners));
}
/**
* Send a device found announcement to all registered listeners.
*
* @param announcement the message announcing the new device
*/
private void deliverFoundAnnouncement(final DeviceAnnouncement announcement) {
for (final DeviceAnnouncementListener listener : getDeviceAnnouncementListeners()) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
listener.deviceFound(announcement);
} catch (Throwable t) {
logger.warn("Problem delivering device found announcement to listener", t);
}
}
});
}
}
/**
* Send a device lost announcement to all registered listeners.
*
* @param announcement the last message received from the vanished device
*/
private void deliverLostAnnouncement(final DeviceAnnouncement announcement) {
for (final DeviceAnnouncementListener listener : getDeviceAnnouncementListeners()) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
listener.deviceLost(announcement);
} catch (Throwable t) {
logger.warn("Problem delivering device lost announcement to listener", t);
}
}
});
}
}
/**
* Holds the singleton instance of this class.
*/
private static final DeviceFinder ourInstance = new DeviceFinder();
/**
* Get the singleton instance of this class.
*
* @return the only instance of this class which exists.
*/
public static DeviceFinder getInstance() {
return ourInstance;
}
/**
* Prevent direct instantiation.
*/
private DeviceFinder() {
// Nothing to do.
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder() ;
sb.append("DeviceFinder[active:").append(isRunning());
if (isRunning()) {
sb.append(", startTime:").append(getStartTime()).append(", firstDeviceTime:").append(getFirstDeviceTime());
sb.append(", currentDevices:").append(getCurrentDevices());
}
return sb.append("]").toString();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy