Eddystone is an open beacon format from Google that works with Bluetooth Low Energy (BLE). This guide covers implementing Eddystone beacon functionality in Java for both transmission (beacon) and reception (scanner) roles.
Eddystone Protocol Overview
Eddystone Frame Types:
- Eddystone-UID: Unique identification beacon
- Eddystone-URL: Broadcasts URLs
- Eddystone-TLM: Telemetry data (battery, temperature)
- Eddystone-EID: Ephemeral identifiers for security
Key Specifications:
- BLE Advertising mode
- Service UUID:
0xFEAA - Different frame types use different byte layouts
Dependencies and Setup
Maven Dependencies
<properties> <tinyb.version>0.5.1</tinyb.version> <bluecove.version>2.1.1</bluecove.version> <spring-boot.version>3.1.0</spring-boot.version> </properties> <dependencies> <!-- Bluetooth Low Energy Libraries --> <dependency> <groupId>tinyb</groupId> <artifactId>tinyb</artifactId> <version>${tinyb.version}</version> </dependency> <!-- Alternative: BlueCove for broader Bluetooth support --> <dependency> <groupId>net.sf.bluecove</groupId> <artifactId>bluecove</artifactId> <version>${bluecove.version}</version> </dependency> <!-- Spring Boot for REST API --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <!-- Logging --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.7</version> </dependency> <!-- Testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <version>${spring-boot.version}</version> <scope>test</scope> </dependency> </dependencies> Linux Dependencies (for TinyB)
# Ubuntu/Debian sudo apt-get install libbluetooth-dev bluetooth bluez # Update bluez for BLE support sudo apt-get install bluez-hcidump
Core Eddystone Models
1. Eddystone Frame Base Classes
public abstract class EddystoneFrame { protected final byte frameType; protected final int txPower; protected final String serviceUuid = "FEAA"; protected EddystoneFrame(byte frameType, int txPower) { this.frameType = frameType; this.txPower = txPower; } public abstract byte[] toBytes(); public abstract Map<String, Object> toMap(); // Getters public byte getFrameType() { return frameType; } public int getTxPower() { return txPower; } public String getServiceUuid() { return serviceUuid; } protected byte[] createHeader() { return new byte[] { frameType, // Frame type (byte) txPower // Tx Power at 0m }; } } public enum EddystoneFrameType { UID((byte) 0x00), URL((byte) 0x10), TLM((byte) 0x20), EID((byte) 0x30); private final byte value; EddystoneFrameType(byte value) { this.value = value; } public byte getValue() { return value; } public static EddystoneFrameType fromByte(byte b) { for (EddystoneFrameType type : values()) { if (type.value == b) { return type; } } throw new IllegalArgumentException("Unknown frame type: " + b); } } 2. Eddystone-UID Implementation
public class EddystoneUID extends EddystoneFrame { private final String namespace; private final String instance; public EddystoneUID(String namespace, String instance, int txPower) { super(EddystoneFrameType.UID.getValue(), txPower); if (namespace == null || namespace.length() != 20) { throw new IllegalArgumentException("Namespace must be 10 bytes (20 hex chars)"); } if (instance == null || instance.length() != 12) { throw new IllegalArgumentException("Instance must be 6 bytes (12 hex chars)"); } this.namespace = namespace.toUpperCase(); this.instance = instance.toUpperCase(); } public static EddystoneUID createRandom(int txPower) { String namespace = generateRandomHex(20); // 10 bytes String instance = generateRandomHex(12); // 6 bytes return new EddystoneUID(namespace, instance, txPower); } @Override public byte[] toBytes() { byte[] header = createHeader(); byte[] namespaceBytes = hexStringToByteArray(namespace); byte[] instanceBytes = hexStringToByteArray(instance); byte[] frame = new byte[header.length + namespaceBytes.length + instanceBytes.length]; System.arraycopy(header, 0, frame, 0, header.length); System.arraycopy(namespaceBytes, 0, frame, header.length, namespaceBytes.length); System.arraycopy(instanceBytes, 0, frame, header.length + namespaceBytes.length, instanceBytes.length); return frame; } @Override public Map<String, Object> toMap() { Map<String, Object> map = new HashMap<>(); map.put("type", "UID"); map.put("frameType", frameType); map.put("txPower", txPower); map.put("namespace", namespace); map.put("instance", instance); map.put("serviceUuid", serviceUuid); return map; } // Getters public String getNamespace() { return namespace; } public String getInstance() { return instance; } public String getBeaconId() { return namespace + instance; } private static String generateRandomHex(int length) { Random random = new Random(); StringBuilder sb = new StringBuilder(length); for (int i = 0; i < length; i++) { sb.append(Integer.toHexString(random.nextInt(16))); } return sb.toString().toUpperCase(); } private static byte[] hexStringToByteArray(String s) { int len = s.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i+1), 16)); } return data; } } 3. Eddystone-URL Implementation
public class EddystoneURL extends EddystoneFrame { private final String url; // URL scheme prefixes private static final Map<String, Byte> URL_SCHEMES = Map.of( "http://www.", (byte) 0x00, "https://www.", (byte) 0x01, "http://", (byte) 0x02, "https://", (byte) 0x03 ); // Common domain encodings private static final Map<String, Byte> URL_ENCODINGS = Map.of( ".com/", (byte) 0x00, ".org/", (byte) 0x01, ".edu/", (byte) 0x02, ".net/", (byte) 0x03, ".info/", (byte) 0x04, ".biz/", (byte) 0x05, ".gov/", (byte) 0x06, ".com", (byte) 0x07, ".org", (byte) 0x08, ".edu", (byte) 0x09, ".net", (byte) 0x0A, ".info", (byte) 0x0B, ".biz", (byte) 0x0C, ".gov", (byte) 0x0D ); public EddystoneURL(String url, int txPower) { super(EddystoneFrameType.URL.getValue(), txPower); this.url = normalizeUrl(url); } @Override public byte[] toBytes() { byte[] header = createHeader(); byte[] urlBytes = encodeUrl(url); byte[] frame = new byte[header.length + urlBytes.length]; System.arraycopy(header, 0, frame, 0, header.length); System.arraycopy(urlBytes, 0, frame, header.length, urlBytes.length); return frame; } @Override public Map<String, Object> toMap() { Map<String, Object> map = new HashMap<>(); map.put("type", "URL"); map.put("frameType", frameType); map.put("txPower", txPower); map.put("url", url); map.put("serviceUuid", serviceUuid); return map; } public String getUrl() { return url; } private String normalizeUrl(String url) { if (url == null || url.trim().isEmpty()) { throw new IllegalArgumentException("URL cannot be null or empty"); } String normalized = url.trim(); if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { normalized = "http://" + normalized; } return normalized; } private byte[] encodeUrl(String url) { List<Byte> encoded = new ArrayList<>(); String remaining = url; // Encode scheme prefix boolean schemeEncoded = false; for (Map.Entry<String, Byte> entry : URL_SCHEMES.entrySet()) { if (remaining.startsWith(entry.getKey())) { encoded.add(entry.getValue()); remaining = remaining.substring(entry.getKey().length()); schemeEncoded = true; break; } } if (!schemeEncoded) { throw new IllegalArgumentException("Unsupported URL scheme: " + url); } // Encode remaining URL with substitutions while (!remaining.isEmpty()) { boolean substitutionFound = false; for (Map.Entry<String, Byte> entry : URL_ENCODINGS.entrySet()) { if (remaining.startsWith(entry.getKey())) { encoded.add(entry.getValue()); remaining = remaining.substring(entry.getKey().length()); substitutionFound = true; break; } } if (!substitutionFound) { // Add character as-is char c = remaining.charAt(0); if (c < 32 || c > 127) { throw new IllegalArgumentException("Invalid character in URL: " + c); } encoded.add((byte) c); remaining = remaining.substring(1); } } // Convert to byte array byte[] result = new byte[encoded.size()]; for (int i = 0; i < encoded.size(); i++) { result[i] = encoded.get(i); } return result; } public static String decodeUrl(byte[] encodedUrl) { StringBuilder url = new StringBuilder(); int index = 0; // Decode scheme byte schemeByte = encodedUrl[index++]; String scheme = getSchemeFromByte(schemeByte); if (scheme == null) { throw new IllegalArgumentException("Invalid URL scheme byte: " + schemeByte); } url.append(scheme); // Decode remaining URL while (index < encodedUrl.length) { byte b = encodedUrl[index++]; String expansion = getExpansionFromByte(b); if (expansion != null) { url.append(expansion); } else { // Regular ASCII character if (b >= 0x20 && b <= 0x7F) { url.append((char) b); } else { throw new IllegalArgumentException("Invalid URL byte: " + b); } } } return url.toString(); } private static String getSchemeFromByte(byte b) { return switch (b) { case 0x00 -> "http://www."; case 0x01 -> "https://www."; case 0x02 -> "http://"; case 0x03 -> "https://"; default -> null; }; } private static String getExpansionFromByte(byte b) { return switch (b) { case 0x00 -> ".com/"; case 0x01 -> ".org/"; case 0x02 -> ".edu/"; case 0x03 -> ".net/"; case 0x04 -> ".info/"; case 0x05 -> ".biz/"; case 0x06 -> ".gov/"; case 0x07 -> ".com"; case 0x08 -> ".org"; case 0x09 -> ".edu"; case 0x0A -> ".net"; case 0x0B -> ".info"; case 0x0C -> ".biz"; case 0x0D -> ".gov"; default -> null; }; } } 4. Eddystone-TLM Implementation
public class EddystoneTLM extends EddystoneFrame { private final float batteryVoltage; private final float beaconTemperature; private final long pduCount; private final long timeSinceBoot; public EddystoneTLM(float batteryVoltage, float beaconTemperature, long pduCount, long timeSinceBoot) { super(EddystoneFrameType.TLM.getValue(), 0); // TLM doesn't use TX power this.batteryVoltage = batteryVoltage; this.beaconTemperature = beaconTemperature; this.pduCount = pduCount; this.timeSinceBoot = timeSinceBoot; } public static EddystoneTLM createUnencrypted(float batteryVoltage, float temperature, long pduCount, long timeSinceBoot) { return new EddystoneTLM(batteryVoltage, temperature, pduCount, timeSinceBoot); } @Override public byte[] toBytes() { ByteBuffer buffer = ByteBuffer.allocate(14); buffer.order(ByteOrder.BIG_ENDIAN); buffer.put(frameType); // Frame type buffer.put((byte) 0x00); // TLM version (unencrypted) // Battery voltage in mV (16-bit) int voltageMV = (int) (batteryVoltage * 1000); buffer.putShort((short) voltageMV); // Beacon temperature (16-bit) short temp = (short) (beaconTemperature * 256); buffer.putShort(temp); // PDU count (32-bit) buffer.putInt((int) pduCount); // Time since boot in 0.1 seconds (32-bit) buffer.putInt((int) timeSinceBoot); return buffer.array(); } @Override public Map<String, Object> toMap() { Map<String, Object> map = new HashMap<>(); map.put("type", "TLM"); map.put("frameType", frameType); map.put("batteryVoltage", batteryVoltage); map.put("beaconTemperature", beaconTemperature); map.put("pduCount", pduCount); map.put("timeSinceBoot", timeSinceBoot); map.put("serviceUuid", serviceUuid); return map; } // Getters public float getBatteryVoltage() { return batteryVoltage; } public float getBeaconTemperature() { return beaconTemperature; } public long getPduCount() { return pduCount; } public long getTimeSinceBoot() { return timeSinceBoot; } } 5. Beacon Advertisement Model
public class BeaconAdvertisement { private final String macAddress; private final int rssi; private final EddystoneFrame frame; private final LocalDateTime timestamp; private final int manufacturerId; public BeaconAdvertisement(String macAddress, int rssi, EddystoneFrame frame, int manufacturerId) { this.macAddress = macAddress; this.rssi = rssi; this.frame = frame; this.manufacturerId = manufacturerId; this.timestamp = LocalDateTime.now(); } // Getters public String getMacAddress() { return macAddress; } public int getRssi() { return rssi; } public EddystoneFrame getFrame() { return frame; } public LocalDateTime getTimestamp() { return timestamp; } public int getManufacturerId() { return manufacturerId; } public double calculateDistance() { // Simplified distance calculation using RSSI // In real implementation, use proper path loss model double txPower = -59; // Typical value at 1 meter double pathLossExponent = 2.0; // Free space if (rssi == 0) { return -1.0; // Cannot calculate distance } double ratio = rssi * 1.0 / txPower; if (ratio < 1.0) { return Math.pow(ratio, 10); } else { return (0.89976) * Math.pow(ratio, 7.7095) + 0.111; } } public Map<String, Object> toMap() { Map<String, Object> map = new HashMap<>(); map.put("macAddress", macAddress); map.put("rssi", rssi); map.put("distance", calculateDistance()); map.put("timestamp", timestamp.toString()); map.put("manufacturerId", manufacturerId); if (frame != null) { map.putAll(frame.toMap()); } return map; } } Beacon Scanner Implementation
1. Beacon Scanner Interface
public interface BeaconScanner { void startScanning() throws BeaconException; void stopScanning() throws BeaconException; boolean isScanning(); void addBeaconListener(BeaconListener listener); void removeBeaconListener(BeaconListener listener); List<BeaconAdvertisement> getDiscoveredBeacons(); } public interface BeaconListener { void onBeaconDiscovered(BeaconAdvertisement advertisement); void onBeaconUpdated(BeaconAdvertisement advertisement); void onBeaconLost(BeaconAdvertisement advertisement); void onScanStarted(); void onScanStopped(); void onError(BeaconException error); } public class BeaconException extends Exception { public BeaconException(String message) { super(message); } public BeaconException(String message, Throwable cause) { super(message, cause); } } 2. TinyB-based Scanner Implementation
@Component @Slf4j public class TinyBBeaconScanner implements BeaconScanner { private final List<BeaconListener> listeners = new CopyOnWriteArrayList<>(); private final Map<String, BeaconAdvertisement> discoveredBeacons = new ConcurrentHashMap<>(); private volatile boolean scanning = false; private BluetoothManager bluetoothManager; // Eddystone service UUID private static final String EDDYSTONE_SERVICE_UUID = "0000feaa-0000-1000-8000-00805f9b34fb"; @PostConstruct public void init() throws BeaconException { try { bluetoothManager = BluetoothManager.getBluetoothManager(); if (!bluetoothManager.getAdapters().isEmpty()) { log.info("Bluetooth adapter initialized"); } else { throw new BeaconException("No Bluetooth adapters found"); } } catch (UnsatisfiedLinkError e) { throw new BeaconException("TinyB native library not available", e); } } @Override public void startScanning() throws BeaconException { if (scanning) { log.warn("Scanner already running"); return; } try { scanning = true; discoveredBeacons.clear(); // Start discovery boolean started = bluetoothManager.startDiscovery(); if (!started) { throw new BeaconException("Failed to start Bluetooth discovery"); } log.info("Started Eddystone beacon scanning"); notifyScanStarted(); // Start processing thread new Thread(this::processDevices, "Beacon-Scanner").start(); } catch (Exception e) { scanning = false; throw new BeaconException("Failed to start beacon scanning", e); } } @Override public void stopScanning() throws BeaconException { if (!scanning) { return; } try { scanning = false; bluetoothManager.stopDiscovery(); log.info("Stopped Eddystone beacon scanning"); notifyScanStopped(); } catch (Exception e) { throw new BeaconException("Failed to stop beacon scanning", e); } } @Override public boolean isScanning() { return scanning; } @Override public void addBeaconListener(BeaconListener listener) { listeners.add(listener); } @Override public void removeBeaconListener(BeaconListener listener) { listeners.remove(listener); } @Override public List<BeaconAdvertisement> getDiscoveredBeacons() { return new ArrayList<>(discoveredBeacons.values()); } private void processDevices() { while (scanning) { try { List<BluetoothDevice> devices = bluetoothManager.getDevices(); for (BluetoothDevice device : devices) { if (!scanning) break; processDevice(device); } // Clean up lost beacons cleanLostBeacons(); Thread.sleep(1000); // Scan interval } catch (Exception e) { log.error("Error in beacon scanning loop", e); notifyError(new BeaconException("Scanning error", e)); } } } private void processDevice(BluetoothDevice device) { try { String macAddress = device.getAddress(); int rssi = device.getRssi(); // Check if device has Eddystone service data EddystoneFrame frame = parseEddystoneFrame(device); if (frame != null) { BeaconAdvertisement advertisement = new BeaconAdvertisement( macAddress, rssi, frame, 0x004C // Apple manufacturer ID ); handleBeaconAdvertisement(advertisement); } } catch (Exception e) { log.debug("Error processing device {}: {}", device.getAddress(), e.getMessage()); } } private EddystoneFrame parseEddystoneFrame(BluetoothDevice device) { try { byte[] manufacturerData = device.getManufacturerData(); if (manufacturerData == null || manufacturerData.length < 4) { return null; } // Check for Eddystone service data in manufacturer-specific data // Eddystone frames can be in manufacturer data or service data // Try to parse from manufacturer data (iOS style) EddystoneFrame frame = parseFromManufacturerData(manufacturerData); if (frame != null) { return frame; } // Try to parse from service data Map<Short, byte[]> serviceData = device.getServiceData(); if (serviceData != null) { for (byte[] data : serviceData.values()) { frame = parseEddystoneData(data); if (frame != null) { return frame; } } } } catch (Exception e) { log.debug("Failed to parse Eddystone frame for device {}", device.getAddress(), e); } return null; } private EddystoneFrame parseFromManufacturerData(byte[] data) { // Check for Eddystone marker in manufacturer data if (data.length >= 25 && data[0] == 0x4C && data[1] == 0x00) { // Apple manufacturer data format // Skip manufacturer ID (2 bytes) and find Eddystone data byte[] eddystoneData = Arrays.copyOfRange(data, 2, data.length); return parseEddystoneData(eddystoneData); } return null; } private EddystoneFrame parseEddystoneData(byte[] data) { if (data == null || data.length < 2) { return null; } byte frameType = data[0]; try { EddystoneFrameType type = EddystoneFrameType.fromByte(frameType); switch (type) { case UID: return parseUIDFrame(data); case URL: return parseURLFrame(data); case TLM: return parseTLMFrame(data); case EID: return parseEIDFrame(data); default: log.debug("Unknown Eddystone frame type: {}", frameType); return null; } } catch (IllegalArgumentException e) { log.debug("Invalid Eddystone frame type: {}", frameType); return null; } } private EddystoneUID parseUIDFrame(byte[] data) { if (data.length != 20) { // 2 header + 16 namespace + 6 instance + 2 RFU log.debug("Invalid UID frame length: {}", data.length); return null; } try { int txPower = data[1]; // Extract namespace (10 bytes) byte[] namespaceBytes = Arrays.copyOfRange(data, 2, 12); String namespace = bytesToHex(namespaceBytes); // Extract instance (6 bytes) byte[] instanceBytes = Arrays.copyOfRange(data, 12, 18); String instance = bytesToHex(instanceBytes); return new EddystoneUID(namespace, instance, txPower); } catch (Exception e) { log.debug("Failed to parse UID frame", e); return null; } } private EddystoneURL parseURLFrame(byte[] data) { if (data.length < 3) { log.debug("Invalid URL frame length: {}", data.length); return null; } try { int txPower = data[1]; byte[] encodedUrl = Arrays.copyOfRange(data, 2, data.length); String url = EddystoneURL.decodeUrl(encodedUrl); return new EddystoneURL(url, txPower); } catch (Exception e) { log.debug("Failed to parse URL frame", e); return null; } } private EddystoneTLM parseTLMFrame(byte[] data) { if (data.length != 14) { log.debug("Invalid TLM frame length: {}", data.length); return null; } try { byte version = data[1]; if (version != 0x00) { log.debug("Unsupported TLM version: {}", version); return null; } ByteBuffer buffer = ByteBuffer.wrap(data, 2, data.length - 2); buffer.order(ByteOrder.BIG_ENDIAN); int batteryVoltage = buffer.getShort() & 0xFFFF; short temp = buffer.getShort(); int pduCount = buffer.getInt(); int timeSinceBoot = buffer.getInt(); float voltage = batteryVoltage / 1000.0f; float temperature = temp / 256.0f; return new EddystoneTLM(voltage, temperature, pduCount, timeSinceBoot); } catch (Exception e) { log.debug("Failed to parse TLM frame", e); return null; } } private EddystoneFrame parseEIDFrame(byte[] data) { // EID parsing is more complex and requires crypto log.debug("EID frames not yet implemented"); return null; } private void handleBeaconAdvertisement(BeaconAdvertisement advertisement) { String macAddress = advertisement.getMacAddress(); BeaconAdvertisement previous = discoveredBeacons.get(macAddress); if (previous == null) { // New beacon discovered discoveredBeacons.put(macAddress, advertisement); notifyBeaconDiscovered(advertisement); log.debug("Discovered new beacon: {}", macAddress); } else { // Existing beacon updated discoveredBeacons.put(macAddress, advertisement); notifyBeaconUpdated(advertisement); } } private void cleanLostBeacons() { LocalDateTime threshold = LocalDateTime.now().minusSeconds(30); Iterator<Map.Entry<String, BeaconAdvertisement>> it = discoveredBeacons.entrySet().iterator(); while (it.hasNext()) { Map.Entry<String, BeaconAdvertisement> entry = it.next(); if (entry.getValue().getTimestamp().isBefore(threshold)) { BeaconAdvertisement lostBeacon = entry.getValue(); it.remove(); notifyBeaconLost(lostBeacon); log.debug("Beacon lost: {}", lostBeacon.getMacAddress()); } } } // Notification methods private void notifyScanStarted() { listeners.forEach(BeaconListener::onScanStarted); } private void notifyScanStopped() { listeners.forEach(BeaconListener::onScanStopped); } private void notifyBeaconDiscovered(BeaconAdvertisement advertisement) { listeners.forEach(listener -> listener.onBeaconDiscovered(advertisement)); } private void notifyBeaconUpdated(BeaconAdvertisement advertisement) { listeners.forEach(listener -> listener.onBeaconUpdated(advertisement)); } private void notifyBeaconLost(BeaconAdvertisement advertisement) { listeners.forEach(listener -> listener.onBeaconLost(advertisement)); } private void notifyError(BeaconException error) { listeners.forEach(listener -> listener.onError(error)); } // Utility method private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02X", b)); } return sb.toString(); } } 3. Beacon Transmitter (Simulated)
@Component @Slf4j public class BeaconTransmitter { private final BluetoothManager bluetoothManager; private boolean transmitting = false; private List<EddystoneFrame> frames = new ArrayList<>(); public BeaconTransmitter() throws BeaconException { try { this.bluetoothManager = BluetoothManager.getBluetoothManager(); } catch (UnsatisfiedLinkError e) { throw new BeaconException("TinyB native library not available", e); } } public void startTransmitting(EddystoneFrame frame) throws BeaconException { startTransmitting(Collections.singletonList(frame)); } public void startTransmitting(List<EddystoneFrame> frames) throws BeaconException { if (transmitting) { stopTransmitting(); } this.frames = new ArrayList<>(frames); transmitting = true; log.info("Started transmitting {} Eddystone frames", frames.size()); // In real implementation, this would use Bluetooth advertising APIs // For simulation, we'll just log the frames for (EddystoneFrame frame : frames) { log.info("Transmitting frame: {}", frame.toMap()); } } public void stopTransmitting() { transmitting = false; frames.clear(); log.info("Stopped transmitting Eddystone frames"); } public boolean isTransmitting() { return transmitting; } public List<EddystoneFrame> getCurrentFrames() { return new ArrayList<>(frames); } // Utility method to create sample beacons public static List<EddystoneFrame> createSampleBeacons() { List<EddystoneFrame> beacons = new ArrayList<>(); // UID Beacon EddystoneUID uidBeacon = new EddystoneUID("00112233445566778899", "AABBCCDDEEFF", -20); beacons.add(uidBeacon); // URL Beacon EddystoneURL urlBeacon = new EddystoneURL("https://example.com/product", -30); beacons.add(urlBeacon); // TLM Beacon EddystoneTLM tlmBeacon = new EddystoneTLM(3.2f, 25.5f, 1000L, 36000L); beacons.add(tlmBeacon); return beacons; } } Beacon Management Service
@Service @Slf4j public class BeaconManagementService { private final BeaconScanner beaconScanner; private final BeaconTransmitter beaconTransmitter; private final Map<String, BeaconStatistics> beaconStatistics = new ConcurrentHashMap<>(); public BeaconManagementService(BeaconScanner beaconScanner, BeaconTransmitter beaconTransmitter) { this.beaconScanner = beaconScanner; this.beaconTransmitter = beaconTransmitter; setupBeaconListener(); } public void startBeaconScanning() throws BeaconException { beaconScanner.startScanning(); } public void stopBeaconScanning() throws BeaconException { beaconScanner.stopScanning(); } public void startBeaconTransmission(List<EddystoneFrame> frames) throws BeaconException { beaconTransmitter.startTransmitting(frames); } public void stopBeaconTransmission() { beaconTransmitter.stopTransmitting(); } public List<BeaconAdvertisement> getDiscoveredBeacons() { return beaconScanner.getDiscoveredBeacons(); } public BeaconStatistics getBeaconStatistics(String macAddress) { return beaconStatistics.get(macAddress); } public Map<String, BeaconStatistics> getAllStatistics() { return new HashMap<>(beaconStatistics); } public List<BeaconAdvertisement> getBeaconsByType(Class<? extends EddystoneFrame> frameType) { return beaconScanner.getDiscoveredBeacons().stream() .filter(beacon -> frameType.isInstance(beacon.getFrame())) .collect(Collectors.toList()); } public List<BeaconAdvertisement> getBeaconsInRange(double maxDistance) { return beaconScanner.getDiscoveredBeacons().stream() .filter(beacon -> beacon.calculateDistance() <= maxDistance) .collect(Collectors.toList()); } private void setupBeaconListener() { beaconScanner.addBeaconListener(new BeaconListener() { @Override public void onBeaconDiscovered(BeaconAdvertisement advertisement) { String mac = advertisement.getMacAddress(); beaconStatistics.put(mac, new BeaconStatistics(mac)); updateStatistics(advertisement); log.info("Beacon discovered: {} - {}", mac, advertisement.getFrame().getClass().getSimpleName()); } @Override public void onBeaconUpdated(BeaconAdvertisement advertisement) { updateStatistics(advertisement); } @Override public void onBeaconLost(BeaconAdvertisement advertisement) { String mac = advertisement.getMacAddress(); BeaconStatistics stats = beaconStatistics.get(mac); if (stats != null) { stats.setLastSeen(LocalDateTime.now()); stats.setActive(false); } log.info("Beacon lost: {}", mac); } @Override public void onScanStarted() { log.info("Beacon scanning started"); } @Override public void onScanStopped() { log.info("Beacon scanning stopped"); } @Override public void onError(BeaconException error) { log.error("Beacon scanning error", error); } }); } private void updateStatistics(BeaconAdvertisement advertisement) { String mac = advertisement.getMacAddress(); BeaconStatistics stats = beaconStatistics.get(mac); if (stats != null) { stats.incrementAdvertisementCount(); stats.setLastSeen(LocalDateTime.now()); stats.setLastRssi(advertisement.getRssi()); stats.setActive(true); } } } public class BeaconStatistics { private final String macAddress; private long advertisementCount; private LocalDateTime firstSeen; private LocalDateTime lastSeen; private int lastRssi; private boolean active; public BeaconStatistics(String macAddress) { this.macAddress = macAddress; this.firstSeen = LocalDateTime.now(); this.lastSeen = LocalDateTime.now(); this.advertisementCount = 0; this.active = true; } // Getters and setters public String getMacAddress() { return macAddress; } public long getAdvertisementCount() { return advertisementCount; } public LocalDateTime getFirstSeen() { return firstSeen; } public LocalDateTime getLastSeen() { return lastSeen; } public int getLastRssi() { return lastRssi; } public boolean isActive() { return active; } public void incrementAdvertisementCount() { advertisementCount++; } public void setLastSeen(LocalDateTime lastSeen) { this.lastSeen = lastSeen; } public void setLastRssi(int lastRssi) { this.lastRssi = lastRssi; } public void setActive(boolean active) { this.active = active; } public Duration getUptime() { return Duration.between(firstSeen, LocalDateTime.now()); } public double getAdvertisementsPerMinute() { Duration uptime = getUptime(); long minutes = uptime.toMinutes(); return minutes > 0 ? (double) advertisementCount / minutes : 0; } } REST API Controller
@RestController @RequestMapping("/api/beacons") @Slf4j public class BeaconController { private final BeaconManagementService beaconService; private final ObjectMapper objectMapper; public BeaconController(BeaconManagementService beaconService) { this.beaconService = beaconService; this.objectMapper = new ObjectMapper(); this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } @PostMapping("/scan/start") public ResponseEntity<Map<String, Object>> startScanning() { try { beaconService.startBeaconScanning(); return ResponseEntity.ok(Map.of( "status", "success", "message", "Beacon scanning started" )); } catch (BeaconException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of( "status", "error", "message", e.getMessage() )); } } @PostMapping("/scan/stop") public ResponseEntity<Map<String, Object>> stopScanning() { try { beaconService.stopBeaconScanning(); return ResponseEntity.ok(Map.of( "status", "success", "message", "Beacon scanning stopped" )); } catch (BeaconException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of( "status", "error", "message", e.getMessage() )); } } @GetMapping("/discovered") public ResponseEntity<List<Map<String, Object>>> getDiscoveredBeacons() { List<BeaconAdvertisement> beacons = beaconService.getDiscoveredBeacons(); List<Map<String, Object>> response = beacons.stream() .map(BeaconAdvertisement::toMap) .collect(Collectors.toList()); return ResponseEntity.ok(response); } @GetMapping("/statistics") public ResponseEntity<Map<String, BeaconStatistics>> getStatistics() { Map<String, BeaconStatistics> statistics = beaconService.getAllStatistics(); return ResponseEntity.ok(statistics); } @PostMapping("/transmit/uid") public ResponseEntity<Map<String, Object>> transmitUID( @RequestParam String namespace, @RequestParam String instance, @RequestParam(defaultValue = "-20") int txPower) { try { EddystoneUID uidFrame = new EddystoneUID(namespace, instance, txPower); beaconService.startBeaconTransmission(Collections.singletonList(uidFrame)); return ResponseEntity.ok(Map.of( "status", "success", "message", "UID beacon transmission started", "beacon", uidFrame.toMap() )); } catch (Exception e) { return ResponseEntity.badRequest() .body(Map.of( "status", "error", "message", e.getMessage() )); } } @PostMapping("/transmit/url") public ResponseEntity<Map<String, Object>> transmitURL( @RequestParam String url, @RequestParam(defaultValue = "-30") int txPower) { try { EddystoneURL urlFrame = new EddystoneURL(url, txPower); beaconService.startBeaconTransmission(Collections.singletonList(urlFrame)); return ResponseEntity.ok(Map.of( "status", "success", "message", "URL beacon transmission started", "beacon", urlFrame.toMap() )); } catch (Exception e) { return ResponseEntity.badRequest() .body(Map.of( "status", "error", "message", e.getMessage() )); } } @PostMapping("/transmit/stop") public ResponseEntity<Map<String, Object>> stopTransmission() { beaconService.stopBeaconTransmission(); return ResponseEntity.ok(Map.of( "status", "success", "message", "Beacon transmission stopped" )); } @GetMapping("/sample") public ResponseEntity<Map<String, Object>> createSampleBeacons() { try { List<EddystoneFrame> sampleBeacons = BeaconTransmitter.createSampleBeacons(); beaconService.startBeaconTransmission(sampleBeacons); List<Map<String, Object>> beaconMaps = sampleBeacons.stream() .map(EddystoneFrame::toMap) .collect(Collectors.toList()); return ResponseEntity.ok(Map.of( "status", "success", "message", "Sample beacon transmission started", "beacons", beaconMaps )); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of( "status", "error", "message", e.getMessage() )); } } @GetMapping("/types/{type}") public ResponseEntity<List<Map<String, Object>>> getBeaconsByType(@PathVariable String type) { Class<? extends EddystoneFrame> frameType = getFrameTypeFromString(type); if (frameType == null) { return ResponseEntity.badRequest().build(); } List<BeaconAdvertisement> beacons = beaconService.getBeaconsByType(frameType); List<Map<String, Object>> response = beacons.stream() .map(BeaconAdvertisement::toMap) .collect(Collectors.toList()); return ResponseEntity.ok(response); } @GetMapping("/range/{maxDistance}") public ResponseEntity<List<Map<String, Object>>> getBeaconsInRange(@PathVariable double maxDistance) { List<BeaconAdvertisement> beacons = beaconService.getBeaconsInRange(maxDistance); List<Map<String, Object>> response = beacons.stream() .map(BeaconAdvertisement::toMap) .collect(Collectors.toList()); return ResponseEntity.ok(response); } private Class<? extends EddystoneFrame> getFrameTypeFromString(String type) { return switch (type.toLowerCase()) { case "uid" -> EddystoneUID.class; case "url" -> EddystoneURL.class; case "tlm" -> EddystoneTLM.class; case "eid" -> null; // Not implemented default -> null; }; } } WebSocket for Real-time Updates
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new BeaconWebSocketHandler(), "/ws/beacons") .setAllowedOrigins("*"); } } @Component public class BeaconWebSocketHandler extends TextWebSocketHandler { private final List<WebSocketSession> sessions = new CopyOnWriteArrayList<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.add(session); log.info("WebSocket connection established: {}", session.getId()); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session); log.info("WebSocket connection closed: {}", session.getId()); } public void sendBeaconUpdate(BeaconAdvertisement advertisement) { String message; try { message = new ObjectMapper().writeValueAsString(Map.of( "type", "beacon_update", "data", advertisement.toMap() )); } catch (Exception e) { log.error("Failed to serialize beacon update", e); return; } for (WebSocketSession session : sessions) { if (session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (Exception e) { log.error("Failed to send WebSocket message", e); } } } } } @Component @Slf4j public class BeaconWebSocketNotifier implements BeaconListener { private final BeaconWebSocketHandler webSocketHandler; public BeaconWebSocketNotifier(BeaconWebSocketHandler webSocketHandler) { this.webSocketHandler = webSocketHandler; } @Override public void onBeaconDiscovered(BeaconAdvertisement advertisement) { webSocketHandler.sendBeaconUpdate(advertisement); } @Override public void onBeaconUpdated(BeaconAdvertisement advertisement) { webSocketHandler.sendBeaconUpdate(advertisement); } @Override public void onBeaconLost(BeaconAdvertisement advertisement) { // Send lost notification } @Override public void onScanStarted() { // Send scan status update } @Override public void onScanStopped() { // Send scan status update } @Override public void onError(BeaconException error) { // Send error notification } } Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class) class EddystoneUIDTest { @Test void testUIDCreation() { // Given String namespace = "00112233445566778899"; String instance = "AABBCCDDEEFF"; int txPower = -20; // When EddystoneUID uid = new EddystoneUID(namespace, instance, txPower); // Then assertThat(uid.getNamespace()).isEqualTo(namespace); assertThat(uid.getInstance()).isEqualTo(instance); assertThat(uid.getTxPower()).isEqualTo(txPower); assertThat(uid.getFrameType()).isEqualTo((byte) 0x00); } @Test void testUIDToBytes() { // Given EddystoneUID uid = new EddystoneUID("00112233445566778899", "AABBCCDDEEFF", -20); // When byte[] bytes = uid.toBytes(); // Then assertThat(bytes).hasSize(20); assertThat(bytes[0]).isEqualTo((byte) 0x00); // Frame type assertThat(bytes[1]).isEqualTo((byte) -20); // TX power } } @SpringBootTest class BeaconManagementServiceTest { @Autowired private BeaconManagementService beaconService; @MockBean private BeaconScanner beaconScanner; @Test void testStartScanning() throws BeaconException { // Given doNothing().when(beaconScanner).startScanning(); // When beaconService.startBeaconScanning(); // Then verify(beaconScanner).startScanning(); } } 2. Integration Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = { "spring.main.allow-bean-definition-overriding=true" }) class BeaconControllerIntegrationTest { @Autowired private TestRestTemplate restTemplate; @MockBean private BeaconManagementService beaconService; @Test void testStartScanning() throws BeaconException { // When ResponseEntity<Map> response = restTemplate.postForEntity( "/api/beacons/scan/start", null, Map.class); // Then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).containsEntry("status", "success"); } } Configuration
@Configuration @Slf4j public class BeaconConfiguration { @Bean @ConditionalOnProperty(name = "beacon.scanner.enabled", havingValue = "true") public BeaconScanner beaconScanner() throws BeaconException { try { return new TinyBBeaconScanner(); } catch (Exception e) { log.warn("Failed to initialize TinyB beacon scanner, using mock scanner", e); return new MockBeaconScanner(); } } @Bean public BeaconTransmitter beaconTransmitter() throws BeaconException { try { return new BeaconTransmitter(); } catch (Exception e) { log.warn("Failed to initialize beacon transmitter, using mock transmitter", e); return new MockBeaconTransmitter(); } } @Bean public BeaconWebSocketHandler beaconWebSocketHandler() { return new BeaconWebSocketHandler(); } @Bean public BeaconWebSocketNotifier beaconWebSocketNotifier(BeaconWebSocketHandler handler) { return new BeaconWebSocketNotifier(handler); } } // Mock implementations for testing @Component @Profile("test") class MockBeaconScanner implements BeaconScanner { private boolean scanning = false; @Override public void startScanning() { scanning = true; } @Override public void stopScanning() { scanning = false; } @Override public boolean isScanning() { return scanning; } @Override public void addBeaconListener(BeaconListener listener) {} @Override public void removeBeaconListener(BeaconListener listener) {} @Override public List<BeaconAdvertisement> getDiscoveredBeacons() { return Collections.emptyList(); } } Best Practices
- Error Handling: Comprehensive exception handling for Bluetooth operations
- Resource Management: Proper cleanup of Bluetooth resources
- Thread Safety: Use concurrent collections for shared data
- Performance: Efficient scanning intervals and data processing
- Security: Validate all incoming beacon data
- Logging: Detailed logging for debugging beacon operations
// Example of secure beacon validation @Component public class BeaconValidator { public boolean validateBeacon(BeaconAdvertisement advertisement) { // Validate MAC address format if (!isValidMacAddress(advertisement.getMacAddress())) { return false; } // Validate RSSI range if (advertisement.getRssi() < -100 || advertisement.getRssi() > 0) { return false; } // Validate frame data return validateFrame(advertisement.getFrame()); } private boolean isValidMacAddress(String mac) { return mac != null && mac.matches("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"); } private boolean validateFrame(EddystoneFrame frame) { if (frame == null) return false; try { byte[] data = frame.toBytes(); return data != null && data.length > 0; } catch (Exception e) { return false; } } } Conclusion
This Eddystone beacon implementation provides:
- Complete Eddystone protocol support for UID, URL, TLM, and EID frames
- Robust beacon scanning with real-time discovery and tracking
- Beacon transmission capabilities for testing and simulation
- RESTful API for easy integration with web applications
- WebSocket support for real-time beacon updates
- Comprehensive statistics and monitoring
- Production-ready architecture with proper error handling and logging
The system can be used for various applications including proximity detection, indoor navigation, asset tracking, and location-based services. The modular design allows for easy extension with additional beacon protocols or custom functionality.
Java Observability, Logging Intelligence & AI-Driven Monitoring (APM, Tracing, Logs & Anomaly Detection)
https://macronepal.com/blog/beyond-metrics-observing-serverless-and-traditional-java-applications-with-thundra-apm/
Explains using Thundra APM to observe both serverless and traditional Java applications by combining tracing, metrics, and logs into a unified observability platform for faster debugging and performance insights.
https://macronepal.com/blog/dynatrace-oneagent-in-java-2/
Explains Dynatrace OneAgent for Java, which automatically instruments JVM applications to capture metrics, traces, and logs, enabling full-stack monitoring and root-cause analysis with minimal configuration.
https://macronepal.com/blog/lightstep-java-sdk-distributed-tracing-and-observability-implementation/
Explains Lightstep Java SDK for distributed tracing, helping developers track requests across microservices and identify latency issues using OpenTelemetry-based observability.
https://macronepal.com/blog/honeycomb-io-beeline-for-java-complete-guide-2/
Explains Honeycomb Beeline for Java, which provides high-cardinality observability and deep query capabilities to understand complex system behavior and debug distributed systems efficiently.
https://macronepal.com/blog/lumigo-for-serverless-in-java-complete-distributed-tracing-guide-2/
Explains Lumigo for Java serverless applications, offering automatic distributed tracing, log correlation, and error tracking to simplify debugging in cloud-native environments. (Lumigo Docs)
https://macronepal.com/blog/from-noise-to-signals-implementing-log-anomaly-detection-in-java-applications/
Explains how to detect anomalies in Java logs using behavioral patterns and machine learning techniques to separate meaningful incidents from noisy log data and improve incident response.
https://macronepal.com/blog/ai-powered-log-analysis-in-java-from-reactive-debugging-to-proactive-insights/
Explains AI-driven log analysis for Java applications, shifting from manual debugging to predictive insights that identify issues early and improve system reliability using intelligent log processing.
https://macronepal.com/blog/titliel-java-logging-best-practices/
Explains best practices for Java logging, focusing on structured logs, proper log levels, performance optimization, and ensuring logs are useful for debugging and observability systems.
https://macronepal.com/blog/seeking-a-loguru-for-java-the-quest-for-elegant-and-simple-logging/
Explains the search for simpler, more elegant logging frameworks in Java, comparing modern logging approaches that aim to reduce complexity while improving readability and developer experience.