Eddystone Beacons in Java: Complete Implementation Guide

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

  1. Error Handling: Comprehensive exception handling for Bluetooth operations
  2. Resource Management: Proper cleanup of Bluetooth resources
  3. Thread Safety: Use concurrent collections for shared data
  4. Performance: Efficient scanning intervals and data processing
  5. Security: Validate all incoming beacon data
  6. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper