// File: eddystone.js

// This library scans for Eddystone beacons and translates their
// advertisements into user-friendly variables.
// The protocol specification is available at:
// https://github.com/google/eddystone

;(function() {

// prerequisites
evothings.loadScripts([
	'libs/evothings/easyble/easyble.js',
])

/**
 * @namespace
 * @description <p>Library for Eddystone beacons.</p>
 * <p>It is safe practise to call function {@link evothings.scriptsLoaded}
 * to ensure dependent libraries are loaded before calling functions
 * in this library.</p>
 */
evothings.eddystone = {};

// constants
var BLUETOOTH_BASE_UUID = '-0000-1000-8000-00805f9b34fb';

// false when scanning is off. true when on.
var isScanning = false;

/**
 * @description Starts scanning for Eddystone devices.
 * <p>Found devices and errors will be reported to the supplied callbacks.</p>
 * <p>Will keep scanning indefinitely until you call stopScan().</p>
 * <p>To conserve energy, call stopScan() as soon as you've found the device
 * you're looking for.</p>
 * <p>Calling startScan() while scanning is in progress will produce an error.</p>
 *
 * @param {evothings.eddystone.scanCallback} - Success function called
 * when a beacon is found.
 * @param {evothings.eddystone.failCallback} - Error callback: fail(error).
 *
 * @public
 *
 * @example
 *   evothings.eddystone.startScan(
 *     function(beacon)
 *     {
 *       console.log('Found beacon: ' + beacon.url);
 *     },
 *     function(error)
 *     {
 *       console.log('Scan error: ' + error);
 *     });
 */
evothings.eddystone.startScan = function(scanCallback, failCallback)
{
	// Internal callback variable names.
	var win = scanCallback;
	var fail = failCallback;

	// If scanning is already in progress, fail.
	if(isScanning)
	{
		fail("Scanning already in progress!");
		return;
	}

	isScanning = true;

	// The device object given in this callback is reused by easyble.
	// Therefore we can store data in it and expect to have the data still be there
	// on the next callback with the same device.
	evothings.easyble.startScan(
		// Scan for Eddystone Service UUID.
		// This enables background scanning on iOS (and Android).
		['0000FEAA-0000-1000-8000-00805F9B34FB'],
		function(device)
		{
			// A device might be an Eddystone if it has advertisementData...
			var ad = device.advertisementData;
			if(!ad) return;
			// With serviceData...
			var sd = ad.kCBAdvDataServiceData;
			if(!sd) return;
			// And the 0xFEAA service.
			var base64data = sd['0000feaa'+BLUETOOTH_BASE_UUID];
			if(!base64data) return;
			var byteArray = evothings.util.base64DecToArr(base64data);

			// If the data matches one of the Eddystone frame formats,
			// we can forward it to the user.
			if(parseFrameUID(device, byteArray, win, fail)) return;
			if(parseFrameURL(device, byteArray, win, fail)) return;
			if(parseFrameTLM(device, byteArray, win, fail)) return;
		},
		function(error)
		{
			fail(error);
		});
}

/**
 * @description This function is a parameter to startScan() and
 * is called when a beacons is discovered/updated.
 * @callback evothings.eddystone.scanCallback
 * @param {evothings.eddystone.EddystoneDevice} beacon - Beacon
 * found during scanning.
 */

/**
 * @description This function is called when an operation fails.
 * @callback evothings.eddystone.failCallback
 * @param {string} errorString - A human-readable string that
 * describes the error that occurred.
 */

/**
 * @description Object representing a BLE device. Inherits from
 * {@link evothings.easyble.EasyBLEDevice}.
 * Which properties are available depends on which packets types broadcasted
 * by the beacon. Properties may be undefined. Typically properties are populated
 * as scanning processes.
 * @typedef {Object} evothings.eddystone.EddystoneDevice
 * @property {string} url - An Internet URL.
 * @property {number} txPower - A signed integer, the signal strength in decibels,
 * factory-measured at a range of 0 meters.
 * @property {Uint8Array} nid - 10-byte namespace ID.
 * @property {Uint8Array} bid - 6-byte beacon ID.
 * @property {number} voltage - Device's battery voltage, in millivolts,
 * or 0 (zero) if device is not battery-powered.
 * @property {number} temperature - Device's ambient temperature in 256:ths of
 * degrees Celcius, or 0x8000 if device has no thermometer.
 * @property {number} adv_cnt - Count of advertisement frames sent since device's startup.
 * @property {number} dsec_cnt - Time since device's startup, in deci-seconds
 * (10 units equals 1 second).
*/

/**
 * @description Stop scanning for Eddystone devices.
 * @public
 * @example
 *   evothings.eddystone.stopScan();
 */
evothings.eddystone.stopScan = function()
{
	evothings.easyble.stopScan();
	isScanning = false;
}

/**
 * @description Calculate the accuracy (distance in meters) of the beacon.
 * <p>The beacon distance calculation uses txPower at 1 meters, but the
 * Eddystone protocol reports the value at 0 meters. 41dBm is the signal
 * loss that occurs over 1 meter, this value is subtracted by default
 * from the reported txPower. You can tune the calculation by adding
 * or subtracting to param txPower.<p>
 * <p>Note that the returned distance value is not accurate, and that
 * it fluctuates over time. Sampling/filtering over time is recommended
 * to obtain a stable value.<p>
 * @public
 * @param txPower The txPower of the beacon.
 * @param rssi The RSSI of the beacon, subtract or add to this value to
 * tune the dBm strength. 41dBm is subtracted from this value in the
 * distance algorithm used by calculateAccuracy.
 * @return Distance in meters, or null if unable to compute distance
 * (occurs for example when txPower or rssi is undefined).
 * @example
 *   // Note that beacon.txPower and beacon.rssi many be undefined,
 *   // in which case calculateAccuracy returns null. This happens
 *   // before txPower and rssi have been reported by the beacon.
 *   var distance = evothings.eddystone.calculateAccuracy(
 *       beacon.txPower, beacon.rssi);
 */
evothings.eddystone.calculateAccuracy = function(txPower, rssi)
{
	if (!rssi || rssi >= 0 || !txPower)
	{
		return null
	}

	// Algorithm
	// http://developer.radiusnetworks.com/2014/12/04/fundamentals-of-beacon-ranging.html
	// http://stackoverflow.com/questions/21338031/radius-networks-ibeacon-ranging-fluctuation

	// The beacon distance formula uses txPower at 1 meters, but the Eddystone
	// protocol reports the value at 0 meters. 41dBm is the signal loss that
	// occurs over 1 meter, so we subtract that from the reported txPower.
	var ratio = rssi * 1.0 / (txPower - 41)
	if (ratio < 1.0)
	{
		return Math.pow(ratio, 10)
	}
	else
	{
		var accuracy = (0.89976) * Math.pow(ratio, 7.7095) + 0.111
		return accuracy
	}
}

/**
 * Create a low-pass filter.
 * @param cutOff The filter cut off value.
 * @return Object with two functions: filter(value), value()
 * @example
 *   // Create filter with cut off 0.8
 *   var lowpass = evothings.eddystone.createLowPassFilter(0.8)
 *   // Filter value (returns current filter value)
 *   distance = lowpass.filter(distance)
 *   // Get current value
 *   distance = lowpass.value()
 */
evothings.eddystone.createLowPassFilter = function(cutOff, state)
{
	// Filter cut off.
	if (undefined === cutOff) { cutOff = 0.8 }

	// Current value of the filter.
	if (undefined === state) { state = 0.0 }

	// Return object with filter functions.
	return {
		// This function will filter the given value.
		// Returns the current value of the filter.
		filter: function(value)
		{
			state =
				(value * (1.0 - cutOff)) +
				(state * cutOff)
			return state
		},
		// This function returns the current value of the filter.
		value: function()
		{
			return state
		}
	}
}

// Return true on frame type recognition, false otherwise.
function parseFrameUID(device, data, win, fail)
{
	if(data[0] != 0x00) return false;

	// The UID frame has 18 bytes + 2 bytes reserved for future use
	// https://github.com/google/eddystone/tree/master/eddystone-uid
	// Check that we got at least 18 bytes.
	if(data.byteLength < 18)
	{
		fail("UID frame: invalid byteLength: "+data.byteLength);
		return true;
	}

	device.txPower = evothings.util.littleEndianToInt8(data, 1);
	device.nid = data.subarray(2, 12);  // Namespace ID.
	device.bid = data.subarray(12, 18); // Beacon ID.

	win(device);

	return true;
}

function parseFrameURL(device, data, win, fail)
{
	if(data[0] != 0x10) return false;

	if(data.byteLength < 4)
	{
		fail("URL frame: invalid byteLength: "+data.byteLength);
		return true;
	}

	device.txPower = evothings.util.littleEndianToInt8(data, 1);

	// URL scheme prefix
	var url;
	switch(data[2]) {
		case 0: url = 'http://www.'; break;
		case 1: url = 'https://www.'; break;
		case 2: url = 'http://'; break;
		case 3: url = 'https://'; break;
		default: fail("URL frame: invalid prefix: "+data[2]); return true;
	}

	// Process each byte in sequence.
	var i = 3;
	while(i < data.byteLength)
	{
		var c = data[i];
		// A byte is either a top-domain shortcut, or a printable ascii character.
		if(c < 14)
		{
			switch(c)
			{
				case 0: url += '.com/'; break;
				case 1: url += '.org/'; break;
				case 2: url += '.edu/'; break;
				case 3: url += '.net/'; break;
				case 4: url += '.info/'; break;
				case 5: url += '.biz/'; break;
				case 6: url += '.gov/'; break;
				case 7: url += '.com'; break;
				case 8: url += '.org'; break;
				case 9: url += '.edu'; break;
				case 10: url += '.net'; break;
				case 11: url += '.info'; break;
				case 12: url += '.biz'; break;
				case 13: url += '.gov'; break;
			}
		}
		else if(c < 32 || c >= 127)
		{
			// Unprintables are not allowed.
			fail("URL frame: invalid character: "+data[2]);
			return true;
		}
		else
		{
			url += String.fromCharCode(c);
		}

		i += 1;
	}

	// Set URL field of the device.
	device.url = url;

	win(device);

	return true;
}

function parseFrameTLM(device, data, win, fail)
{
	if(data[0] != 0x20) return false;

	if(data[1] != 0x00)
	{
		fail("TLM frame: unknown version: "+data[1]);

		return true;
	}

	if(data.byteLength != 14)
	{
		fail("TLM frame: invalid byteLength: "+data.byteLength);

		return true;
	}

	device.voltage = evothings.util.bigEndianToUint16(data, 2);

	var temp = evothings.util.bigEndianToUint16(data, 4);
	if(temp == 0x8000)
	{
		device.temperature = 0x8000;
	}
	else
	{
		device.temperature = evothings.util.bigEndianToInt16(data, 4) / 256.0;
	}

	device.adv_cnt = evothings.util.bigEndianToUint32(data, 6);
	device.dsec_cnt = evothings.util.bigEndianToUint32(data, 10);

	win(device);

	return true;
}

var isAdvertising = false;

function isValidHex(str, len) {
  return typeof str === "string" && str.length === len * 2 && /^[0-9A-F]+$/.test(str)
}

function txPowerLevel() {
  // ADVERTISE_TX_POWER_MEDIUM taken from
	// txeddystone_uid/MainActivity.java#L324-L340 (https://goo.gl/MqWqUu)
  return -26
}

// From [txeddystone_uid/MainActivity.java](https://goo.gl/VGtnN1)
function toByteArray(hexString) {
  var out = [];

  for (var i = 0; i < hexString.length; i += 2) {
    out.push((parseInt(hexString.charAt(i), 16) << 4)
        + parseInt(hexString.charAt(i + 1), 16));
  }

  return out;
}

// Variable that points to the Cordova Base64 module, loaded lazily.
var base64;

function buildServiceData(namespace, instance) {
  if (!base64) { base64 = cordova.require('cordova/base64'); }

  var data = [0, txPowerLevel()];

  Array.prototype.push.apply(data, toByteArray(namespace));
  Array.prototype.push.apply(data, toByteArray(instance));

  return base64.fromArrayBuffer(new Uint8Array(data));
}

/**
 * @description Start Advertising Eddystone Beacon.
 * <p>Namespace may only be 10 hex bytes</p>
 * <p>Instance may only be 6 hex bytes</p>
 * <p>Will keep advertising indefinitely until you call stopAdvertise().</p>
 * @public
 * @param {string} namespace - Hex namespace string (10 bytes, 20 chars)
 * @param {string} instance - Hex instance string (6 bytes, 12 chars)
 * @param {evothings.eddystone.advertiseCallback} - Success function called when advertising started
 * @param {evothings.eddystone.failCallback} - Error callback: fail(error).
 * @example
 *   evothings.eddystone.startAdvertise("0123456789ABCDEF0123", "0123456789AB");
 */
evothings.eddystone.startAdvertise = function(namespace, instance, advertiseCallback, failCallback) {
	// Internal callback variable names.
	var win = advertiseCallback;
	var fail = failCallback;

	if(isAdvertising) {
		fail("Advertising already in progress!");
		return;
	}

  if (!isValidHex(namespace, 10)) {
    fail("Invalid namespace, must be 10 hex bytes");
    return;
  }
  if (!isValidHex(instance, 6)) {
    fail("Invalid instance, must be 6 hex bytes");
    return;
  }

  var serviceData = buildServiceData(namespace, instance);

  var transmitData = {
    serviceUUIDs: ["0000FEAA-0000-1000-8000-00805F9B34FB"],
    serviceData: { "0000FEAA-0000-1000-8000-00805F9B34FB": serviceData }
  };

  var settings = {
    broadcastData: transmitData,
    scanResponseData: transmitData
  };

  evothings.ble.peripheral.startAdvertise(
  	settings, 
  	function() { 
  	  isAdvertising = true;
  	  win() }, 
  	function(error) { 
  	  isAdvertising = false;
  	  fail(error) });
}

/**
 * @description Stop Advertising Eddystone Beacon.
 * @public
 * @param {Function} onSuccess - Success function called when advertising stoppped
 * @param {evothings.eddystone.failCallback} - Error callback: fail(error).
 * @example
 *   evothings.eddystone.stopAdvertise();
 */
evothings.eddystone.stopAdvertise = function(onSuccess, failCallback) {
  isAdvertising = false;
	evothings.ble.peripheral.stopAdvertise(onSuccess, failCallback);
}

})();