蓝牙Beacon室内定位全栈

GPS是成熟很久的技术,智能手机发展起来之后GPS成了手机手机的必备模块之一,但室内没有GPS信号,使得室内定位到目前为止都是难点之一。但理论上技术倒真没难到什么程度,只要有可以在室内用的定位信号基站,手机端可以接收并解析就行了。但也有如下难点:

  • 室内距离短,电磁波传播速度太快,模仿GPS通过数据发送时间和接收时间的时间差来计算接收端与发送端之间的距离就不靠谱。
  • 室内遮挡多会导致信号衰减,因此通过信号衰减量来计算接收端与发送端之间的距离也不靠谱。
  • 便宜和兼容性,得方便移动设备用,高端的扫地机器人可以通过激光雷达给房间建模然后规划路线,这定位是精准了,但是让每个手机自带激光雷达明显不现实。超声波也可以用来定位,但这东西放手机上就没啥用。

Beacon的出现,这一切迎刃而解。

2012年,蓝牙4.0标准发布,低功耗蓝牙(Bluetooth Low Energy,BLE)技术成熟,一刻纽扣电池就可以让一个BLE设备工作很久。2013年苹果WWDC发布的设备支持iBeacon,标志着Beacon协议开始广泛用于个人移动设备。Beacon设备会每隔一定的时间广播一个数据包(BeaconName+UUID+Major+Minor+TX power)到周围,当手机接近时扫描到该设备,就能接收到其广播的数据包。Beacon设备用于室内定位有以下优势:

  • 几乎所有的手机都有蓝牙模块,不用担心兼容性问题。
  • 价格便宜,一百个Beacon设备也比不上一个激光雷达,可以大批量部署。
  • 传播距离近,遮挡等干扰少,信号衰减可以作为计算距离的参考。

接下来是我研究Beacon室内定位的整个流程,可能带有非常明显的小作坊的色彩,也还有不少部分需要完善,但也算是五脏齐全了。

数据生产

室内定位首先要有室内地图,或者室内的三维模型,理论上室内定位不需要理真实坐标,可以完全以建模或者室内地图的坐标系来,只要保证Beacon信标的坐标系与室内地图的坐标系一致即可。但这么做明显不利于后期可能存在的室内定位与室外定位相结合,也不利于标准化的生产和部署。所以我这里与真是坐标系相结合,建筑、楼层、信标的位置都直接保存成经纬度,根据我在博文《三维GIS建模不要用墨卡托投影》中的说明,我在三维建模时采用了CGS2000高斯-克吕格三度带投影坐标系。

在这里我首先说一下我的数据库设计,首先我假设了有多个需要室内定位的项目,每个项目可能有好几栋楼,每栋楼有若干层,每层部署若干Beacon信标(理论上8到10米,离地2米高左右的楼顶、墙壁或柱子上部署),楼栋和楼层都有自己的模型,数据库每张表的关系也这么设计。

然后就是我生产的目标数据是什么了,这跟我的技术路线有关,至少生产出来的三维模型能在我的管理后台和手机上用。技术选型有以下的考量:

  • 如果有多个项目,我的后台管理系统得统一管理,最好把多个项目的模型同时显示,方便查看。
  • 移动端最好跨平台,支持安卓和苹果,方便更广泛的使用。

管理后台其实很好确定了,要显示那么大范围的三维,Cesium基本上是唯一的选择。移动客户端就比较难受,想到跨平台三维,首先就能想到Unity这类游戏开发的,肯定是可以跨平台的。但是很遗憾,Unity不支持在线的三维模型资源,都得先下载到本地才能加载。最后移动端选择了直接做成WebApp然后通过Cordova打包,所以实际上移动端也能直接用Cesium的,但移动端通常只显示一栋楼或者一个项目的模型就行,还得显示个地球,有点多余,因此采用了微软的基于WebGL的三维引擎babylonjs

技术路线一确定,数据生产的目标也就确定了,生产成Cesium和babylonjs都可以很方便使用的gltf模型。

楼栋白模

这不是一个有真实需求的项目,因此我一丁点数据都没有,所有的数据都得自力更生,首先是楼栋白模。楼栋白模还是很方便制作的。

  1. 我从OpenStreetMap上下载了我需要做的楼栋的数据,下载下来的是WGS84地理坐标系OSM格式的数据。
  2. QGIS软件加载OSM数据,并把里面的房屋面提取出来成shp,假设名字叫fwm.shp,然后将数据重投影成CGS2000高斯克-吕格3度带投影坐标系,根据数据所在的经纬度来确定带号fwm_projection.shp。同时地理坐标系的数据也要保留着(因为WGS84地理坐标系和CGS2000地理坐标系非常相似,因此保留WGS84地理坐标系的数据也能当CGS2000地理坐标系的数据用)。
  3. 将fwm_projection.shp用QGIS打开,要素另存为AutoCAD DXF文件fwm_projection.dxf。
  4. 用AutoCAD打开fwm_projection.dxf文件,使用三维工具根据楼层的高度拉升成三维数据,保存成fwm_projection.dwg,这时候已经是一个三维模型了,接下来是将他转换成gltf(这里用glb,免得一个模型是多个文件)。
  5. 如果有FME2020以上的版本,可以直接将fwm_projection.dwg转换成fwm_projection.glb,如果没有也可以用Sketchup Pro导入dwg,然后导出成fbx或者obj模型,导出的时候注意勾选导出全部平面为三角形和切换YZ坐标,导入和导出时都要将单位设置成米,最后用Windows 10自带的3D查看器打开并保存成glb即可。我在博文《三维GIS建模不要用墨卡托投影》中有提到Cesium的三维笛卡尔坐标系跟建模软件的坐标系不一致的问题,因此这里的模型旋转问题需要处理,要就在建模的时候处理,要就在客户端去处理,反正就是要旋转一下。
  6. 接下来就是坐标问题了,这么创建的模型的坐标原点,对应的是平面图形的左下角,如果是比较规则的房屋面倒还好,特别是左下角恰好是房屋定点就比较方便,下面说通用的情况。在QGIS里面打开之前地理坐标系的房屋面,也就是fwm.shp,使用选择工具选中建模的那个房屋面,在工具箱中打开矢量地理对象>最小边界矢量图像,输入图层选择fwm.shp,几何图像类型选择边界框,勾选上仅选中的要素,就可以生成房屋面的边界举行,那这个矩形的左下角就对应了模型的坐标原点了,将地图放大到最大,鼠标放在矩形的左下角,鼠标所在位置的经纬度就是我们要记录下来的位置了。保存起来,以后房屋的模型就以这个坐标加载到场景。

楼层模型

楼层模型的生产与白模的生产大体上类似,不过因为我没有原数据,数据获取有点麻烦。

  • 首先我在网上搜了一下我要做的这栋楼的户型图,运气好,搜到了。
  • 然后我将户型图导入上面的fwm_projection.dxf,调整大小,方向和位置,让他能跟房屋面的外廓线匹配起来,之后再照着户型图用CAD画了一遍,就能保证位置和户型大体上差不多了。
  • 再之后就跟做白模差不多了,不过做楼层模型可以做个材质,这在Sketchup里很方便。还有一点要注意,楼层的插入经纬度跟楼栋不一定是一样的,比如有的楼下面10层是商场,上面40层是办公楼,或者有AB栋连在一起我只做B栋的楼层模型但是房屋白模是AB栋一体的。

管理后台展示模型

其实这部分倒是简单了,如上面所说,后台管理用的Cesium,总的来说就是选中项目之后定位到项目的几栋房屋所在的位置,点选中楼栋之后楼栋半透明,显示楼层列表,并默认选择第一楼层,选择哪个楼层就显示哪个楼层的模型。核心代码如下:

/************************
* 初始化事件
************************/
function initEvent() {
$('#project_select').on('change', function () {
const project = _projects[$('#project_select').val()]
const rectangle = Cesium.Rectangle.fromDegrees(project.minLongitude, project.minLatitude, project.maxLongitude, project.maxLatitude);
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = rectangle;
_viewer.camera.flyTo({
destination: rectangle
});
showBuilding(project);
})

var handler = new Cesium.ScreenSpaceEventHandler(_viewer.scene.canvas);
handler.setInputAction(function (movement) {
var pick = _viewer.scene.pick(movement.position);
if (Cesium.defined(pick) && Cesium.defined(pick.id)) {
const entity = pick.id;
if (entity.id.startsWith('building_')) {
entity.model.color = Cesium.Color.WHITE.withAlpha(0.3);
const buildingId = entity.id.replace('building_', '');
showFloors(buildingId);
}
}
else {
clearFloors();
}

}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}

/************************
* 加载所有项目数据
************************/
function loadProjects() {
$('#project_select').empty();
$.post(`${Config.BASE_URL}/api/project/list-all`, JSON.stringify({}), function (response) {
if (response.succeeded) {
_projects = response.data;
for (let index = 0; index < _projects.length; index++) {
const project = _projects[index];
$('#project_select').append(`<option value=${index}>${project.name}</option>`)
}
if (_projects.length > 0) {
const project = _projects[0];
const rectangle = Cesium.Rectangle.fromDegrees(project.minLongitude, project.minLatitude, project.maxLongitude, project.maxLatitude);
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = rectangle;
_viewer.camera.flyTo({
destination: rectangle
});
showBuildings(project);
}
}
})
}

/**
* 展示一个项目所有的建筑物
* @param {*} project
*/
function showBuildings(project) {
const buildings = project.buildings;
const midLat = (project.minLatitude + project.maxLatitude) / 2;
buildings.forEach(building => {
var entity = _viewer.entities.add({
name: building.name,
id: 'building_' + building.id,
position: new Cesium.Cartesian3.fromDegrees(building.x, building.y, building.height),
model: {
uri: `${Config.BASE_URL}/api/building/download-model/${building.model}`,
},
});
});
}

/**
* 展示一个建筑物的楼层
* @param {*} buildingId
*/
function showFloors(buildingId) {
$('#lg_floor').empty();
$.get(`${Config.BASE_URL}/api/building/${buildingId}`, function (response) {
if (response.succeeded) {
_selectedBuilding = response.data;
for (let index = 0; index < _selectedBuilding.floors.length; index++) {
const floor = _selectedBuilding.floors[index];
$('#lg_floor').append(`<a href="javascript: void(0);" class="list-group-item list-group-item-action">${floor.name}</a>`);
}
$('#lg_floor').fadeIn();
$('#lg_floor .list-group-item').on('click', function (e) {
const index = $(e.target).index();
showFloor(index);
})
if (_selectedBuilding.floors.length > 0) {
showFloor(0)
}
}
})
}

/**
* 展示单个楼层
* @param {*} index
*/
function showFloor(index) {
_viewer.entities.values.forEach(entity => {
if (entity.id.startsWith('floor_')) {
_viewer.entities.remove(entity);
}
});
$('#lg_floor .list-group-item').removeClass('active');
$(`#lg_floor .list-group-item:nth-child(${index + 1})`).addClass('active')
const floor = _selectedBuilding.floors[index];
var entity = _viewer.entities.add({
name: floor.name,
id: 'floor_' + floor.id,
position: new Cesium.Cartesian3.fromDegrees(floor.x, floor.y, floor.height),
model: {
uri: `${Config.BASE_URL}/api/floor/download-model/${floor.model}`,
},
});
//展示信标基站
showStations(floor);
}

/**
* 清除建筑物图层
*/
function clearFloors() {
$('#lg_floor').fadeOut();
_viewer.entities.values.forEach(entity => {
if (entity.id.startsWith('building_')) {
entity.model.color = Cesium.Color.WHITE;
}
else if (entity.id.startsWith('floor_')) {
_viewer.entities.remove(entity);
}
});
}

移动端展示模型

前面有说移动端展使用Cordova打包WebApp的方式,这中间的细节之后再说,所以实际上移动端的展示也是用的网页,这里用了babylonjs。这里也会有几个问题:

  • 我们保存的房屋模型、楼层模型、信标坐标都是经纬度,但是在做距离计算的时候一般是不用经纬度来算球面距离的。而且我们的模型是用高斯-克吕格投影坐标系做的,距离单位也是米,以经纬度为坐标插入笛卡尔坐标系的场景是乱套了。Cesium能直接以经纬度插入是因为Cesium本来就构建了一个地球,因此首先需要将经纬度转换成高斯克吕格投影坐标。
  • 转过过来的高斯-克吕格投影坐标系是一个非常大的数值,当然一直用这么大的数值也没啥问题,但总是不利于调试对比,因此我们可以以一个项目里所有房屋的最小插入点当做原点(0,0),给插入点的坐标都变成比较小的数值。
  • 第二个问题,上面有提到,我在其他博文里说过不同软件不同库笛卡尔坐标系不同的问题,babylonjs会自己做一定的处理,比如他在加载gltf的时候会默认沿Y轴旋转180°,会给Z坐标乘一个-1,以适配大多数的三维建模软件,具体看文档,但是出来到底符不符合,就得具体看了。

然后逻辑其实跟管理后台差不多了,下面主要贴高斯-克吕格投影坐标系和经纬度互转的代码。

/**
*
* @param {*} gcs_wkid 地理坐标系wkid
* @param {*} centerLongitude 中央经线经度
* @returns
*/
var Gauss_Krueger_Projection = function(gcs_wkid,centerLongitude)
{
var a ;//'椭球体长半轴
var b;// '椭球体短半轴
var k0 = 1;//'比例因子

switch (gcs_wkid) {
case 4214:
//GCS_Beijing_1954
a = 6378245;
b = 6356863.0188;
break;
case 4610:
//GCS_Xian_1980
a = 6378140;
b = 6356755.2882;
break;
case 4490:
//GCS_China_Geodetic_Coordinate_System_2000
a = 6378137.0;
b = 6356752.314140356;
break;
case 4326:
//GCS_WGS_1984
a = 6378137;
b = 6356752.3142;
default:
break;
}

var f = (a - b) / a;//扁率
var e = Math.sqrt(2*f - Math.pow(f ,2));//第一偏心率
var e1 = e / Math.sqrt(1 - Math.pow(e , 2));//'第二偏心率

/**
* 经纬度转平面坐标系
* @param {*} lon
* @param {*} lat
*/
function LngLat2XY(lng,lat)
{
var BR = lat * Math.PI / 180;//纬度弧长
var lo = (lng - centerLongitude)*Math.PI/180; //经差弧度
var N = a / Math.sqrt(1 - Math.pow((e * Math.sin(BR)) , 2)) //卯酉圈曲率半径

//求解参数s
var C = Math.pow(a , 2)/ b;
var B0 = 1 - 3 * Math.pow(e1 , 2) / 4 + 45 *Math.pow( e1 ,4) / 64 - 175 * Math.pow(e1 , 6) / 256 + 11025 * Math.pow(e1 , 8 )/ 16384;
var B2 = B0 - 1
var B4 = 15 / 32 * Math.pow(e1 , 4) - 175 / 384 * Math.pow(e1 , 6 )+ 3675 / 8192 *Math.pow( e1 , 8);
var B6 = 0 - 35 / 96 *Math.pow( e1 , 6) + 735 / 2048 * Math.pow(e1 , 8);
var B8 = 315 / 1024 * Math.pow(e1 , 8);
var s = C * (B0 * BR + Math.sin(BR) * (B2 * Math.cos(BR) + B4 * Math.pow((Math.cos(BR)) , 3) + B6 * Math.pow((Math.cos(BR)) , 5 )+ B8 * Math.pow((Math.cos(BR)) , 7)))

var t = Math.tan(BR);
var g = e1 * Math.cos(BR);

var XR= s + Math.pow(lo , 2) / 2 * N * Math.sin(BR) * Math.cos(BR) + Math.pow(lo , 4 )* N * Math.sin(BR) * Math.pow((Math.cos(BR)) , 3) / 24 * (5 -Math.pow( t , 2 )+ 9 * Math.pow(g , 2) + 4 *Math.pow( g , 4)) + Math.pow(lo , 6) * N * Math.sin(BR) * Math.pow((Math.cos(BR)) , 5) * (61 - 58 *Math.pow( t , 2) + Math.pow(t , 4)) / 720;
var YR= lo * N * Math.cos(BR) + Math.pow(lo , 3 )* N / 6 *Math.pow( (Math.cos(BR)) , 3) * (1 - Math.pow(t , 2) + Math.pow(g , 2)) + Math.pow(lo , 5) * N / 120 * Math.pow((Math.cos(BR)) , 5) * (5 - 18 * Math.pow(t , 2) + Math.pow(t , 4) + 14 * Math.pow(g , 2) - 58 * Math.pow(g , 2) * Math.pow(t , 2));
var x=YR;
var y=XR;
return [x,y];
}

/**
* 平面坐标系转经纬度
* @param {*} x
* @param {*} y
*/
function XY2LngLat(x,y)
{
var El1 = (1 - Math.sqrt(1 - Math.pow(e , 2))) / (1 + Math.sqrt(1 -Math.pow( e , 2)));

var Mf = y/ k0 ;//真实坐标值

var Q = Mf / (a * (1 - Math.pow(e , 2) / 4 - 3 * Math.pow(e , 4) / 64 - 5 *Math.pow( e , 6) / 256));//角度

var Bf = Q + (3 * El1 / 2 - 27 *Math.pow( El1 , 3) / 32) * Math.sin(2 * Q) + (21 *Math.pow( El1 , 2) / 16 - 55 *Math.pow( El1 , 4 )/ 32) * Math.sin(4 * Q) + (151 *Math.pow( El1 , 3 )/ 96) * Math.sin(6 * Q) + 1097 / 512 * Math.pow(El1 , 4) * Math.sin(8 * Q);
var Rf = a * (1 -Math.pow( e , 2)) / Math.sqrt(Math.pow((1 - Math.pow((e * Math.sin(Bf)) ,2)) , 3));
var Nf = a / Math.sqrt(1 - Math.pow((e * Math.sin(Bf)) , 2));//卯酉圈曲率半径
var Tf = Math.pow((Math.tan(Bf)) , 2);
var D =x/ (k0 * Nf);

var Cf =Math.pow( e1 , 2) * Math.pow((Math.cos(Bf)) , 2);

var B = Bf - Nf * Math.tan(Bf) / Rf * (Math.pow(D , 2) / 2 - (5 + 3 * Tf + 10 * Cf - 9 * Tf * Cf - 4 *Math.pow( Cf , 2) - 9 * Math.pow(e1 , 2)) *Math.pow( D , 4) / 24 + (61 + 90 * Tf + 45 * Math.pow(Tf , 2) - 256 * Math.pow(e1 , 2) - 3 * Math.pow(Cf , 2)) *Math.pow( D , 6) / 720);
var L = centerLongitude*Math.PI/180 + 1 / Math.cos(Bf) * (D - (1 + 2 * Tf + Cf) *Math.pow( D , 3) / 6 + (5 - 2 * Cf + 28 * Tf - 3 *Math.pow( Cf , 2) + 8 * Math.pow(e1 , 2) + 24 * Math.pow(Tf , 2)) * Math.pow(D , 5 )/ 120);

var Bangle = B * 180 / Math.PI;
var Langle = L * 180 / Math.PI;

return [Langle,Bangle];
}

return{
LngLat2XY:LngLat2XY,
XY2LngLat:XY2LngLat
}
}

Beacon设备的组织

上面有提到Beacon会周期性的向外界广播格式为BeaconName+UUID+Major+Minor+TX power的数据包,这些都是可以设置的。当然除了这些广播的数据,发送功率、时间间隔啥的也都是能设置的,我们这里主要说UUID,Major和Minor。

UUID是形式FDA50693-A4E2-4FB1-AFCF-C6EB07647824的一串字符串,占用16字节,通常同一批设备出厂的UUID会是一样的,我这里给他设置成了不同,前20位我设置成了一样的,标识这是我家的设备,移动端读的时候使用这一段关键字,也就能保证回来的只有我家设备的数据。最后12位设置成不同,成为设备的唯一码。

Major一般叫主值,占用两个字节,我这里每个字节存了不同的值,第一个字节存了项目标识,第二个字节存了楼栋标识。

Minor一般叫辅值,占用两个字节,跟主值一样我也设置了不同值,第一个字节存了楼层号,第二个字节存了信标标识。

我使用的是一个叫nRF Connect的安卓APP设置的。

Beacon数据获取(Android)

目前只做了安卓端。安卓获取Beacon数据使用了Android Beacon Library。我写了一个BeaconService的服务,代码如下,其中Rssi代表信号强度,也就是定位要用的数据:

package me.zxhm.plugin.beacon;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

import androidx.annotation.Nullable;

import org.altbeacon.beacon.Beacon;
import org.altbeacon.beacon.BeaconManager;
import org.altbeacon.beacon.BeaconParser;
import org.altbeacon.beacon.MonitorNotifier;
import org.altbeacon.beacon.RangeNotifier;
import org.altbeacon.beacon.Region;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

public class BeaconService extends Service {
public static final String TAG = "BeaconService";
private static final long DEFAULT_FOREGROUND_SCAN_PERIOD = 1000L;
private static final long DEFAULT_FOREGROUND_BETWEEN_SCAN_PERIOD = 1000L;
public static final String IBEACON_FORMAT = "m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24";

private static BeaconManager beaconManager;
public static Collection<BeaconDto> beaconList = new ArrayList<>();


public static final Region filterRegion = new Region("FDA50693-A4E2-4FB1", null, null, null);

@Override
public void onCreate() {
super.onCreate();
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (beaconManager == null)
{
beaconManager = BeaconManager.getInstanceForApplication(this);
beaconManager.setForegroundBetweenScanPeriod(DEFAULT_FOREGROUND_BETWEEN_SCAN_PERIOD);
beaconManager.setForegroundScanPeriod(DEFAULT_FOREGROUND_SCAN_PERIOD);
beaconManager.getBeaconParsers().add(new BeaconParser().setBeaconLayout(IBEACON_FORMAT));
}
beaconManager.addRangeNotifier(rangeNotifier);
beaconManager.startRangingBeacons(filterRegion);
return super.onStartCommand(intent, flags, startId);
}

@Override
public void onDestroy() {
beaconManager.stopRangingBeacons(filterRegion);
beaconManager.removeAllRangeNotifiers();
super.onDestroy();
}

@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}

RangeNotifier rangeNotifier = new RangeNotifier() {
@Override
public void didRangeBeaconsInRegion(Collection<Beacon> beacons, Region region) {
if (beacons.size() > 0) {
beaconList.clear();
Log.d(TAG, "didRangeBeaconsInRegion called with beacon count: "+beacons.size());
for (Beacon beacon: beacons) {
BeaconDto beaconDto = new BeaconDto();
beaconDto.setName(beacon.getBluetoothName());
beaconDto.setUuid(beacon.getIdentifier(0).toUuid().toString());
beaconDto.setMac(beacon.getBluetoothAddress());
beaconDto.setRssi(beacon.getRssi());
beaconDto.setAvgRssi(beacon.getRunningAverageRssi());
beaconDto.setMajor(beacon.getIdentifier(1).toByteArray());
beaconDto.setMinor(beacon.getIdentifier(2).toByteArray());
beaconList.add(beaconDto);
}
Beacon firstBeacon = beacons.iterator().next();
}
}

};
}

filterRegion实例化用到的字符串就是UUID前面一段不变的部分,最后数据保存成一个BeaconDto列表,方便其他地方使用。

这里需要注意权限问题和硬件使用问题,需要在AndroidManifest.xml文件mainfet节点下加入以下权限,如果是Android 6以后的设备还需要在代码中动态申请定位权限。

<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>

WebApp获取Beacon数据

本来有上面的内容,手机APP就已经获取Beacon数据了,但是我用了Cordova,实际上也就是用的一个WebView运行的网页程序,是没法直接获取硬件数据的,因此Cordova有了插件系统,用于原生和WebApp之间交互。其实Cordova的插件已经很丰富了,但是很可惜没有Beacon的,所以我自己写了一个。

怎么自定义和使用Cordova的插件这里就不说了,网上一搜就很多。我做的插件主要有三个功能,第一个是启用上面定义的获取数据的服务,第二个是停止服务,第三个是获取数据。代码如下

package me.zxhm.plugin.beacon;

import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.widget.Toast;

import org.altbeacon.beacon.BeaconConsumer;
import org.altbeacon.beacon.BeaconManager;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CallbackContext;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
* This class echoes a string called from JavaScript.
*/
public class Beacon extends CordovaPlugin {

protected static final String TAG = "BeaconPlugin";



@Override
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
if (action.equals("toast")) {
String message = args.getString(0);
this.toast(message, callbackContext);
return true;
}
else if (action.equals("getBeacons"))
{
this.getBeacons(callbackContext);
return true;
}
else if (action.equals("startService"))
{
this.startService(callbackContext);
return true;
}
else if (action.equals("stopService"))
{
this.stopService(callbackContext);
return true;
}
return false;
}

/**
* 开启服务
* @param callbackContext
*/
public void startService(CallbackContext callbackContext)
{
if (verifyBluetooth())
{
Intent intent = new Intent(cordova.getContext(), BeaconService.class);
cordova.getActivity().startService(intent);
callbackContext.success();
}
else
{
callbackContext.error("低功耗蓝牙不可用");
}
}

/**
* 停止服务
* @param callbackContext
*/
public void stopService(CallbackContext callbackContext)
{
Intent intent = new Intent(cordova.getContext(), BeaconService.class);
cordova.getActivity().stopService(intent);
callbackContext.success();
}

/**
* 获取Beacon设备
* @param callbackContext
*/
public void getBeacons(CallbackContext callbackContext)
{
try {
JSONArray value = new JSONArray();
for (BeaconDto beacon:BeaconService.beaconList) {
value.put(beacon.toJSON());
}
callbackContext.success(value);
}
catch (Exception e)
{
callbackContext.error(e.getMessage());
}
}

/**
* 确认蓝牙可用
*/
private boolean verifyBluetooth() {
boolean access = true;
try {
if (!BeaconManager.getInstanceForApplication(cordova.getActivity()).checkAvailability()) {
final AlertDialog.Builder builder = new AlertDialog.Builder(cordova.getContext());
builder.setTitle("蓝牙没有打开");
builder.setMessage("请在设置中打开蓝牙,然后重启APP");
builder.setPositiveButton(android.R.string.ok, null);
builder.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
cordova.getActivity().finishAffinity();
}
});
builder.show();
access = false;
}
}
catch (RuntimeException e) {
final AlertDialog.Builder builder = new AlertDialog.Builder(cordova.getContext());
builder.setTitle("低功耗蓝牙不可用");
builder.setMessage("您的设备不支持低功耗蓝牙,请更换设备");
builder.setPositiveButton(android.R.string.ok, null);
builder.setOnDismissListener(new DialogInterface.OnDismissListener() {

@Override
public void onDismiss(DialogInterface dialog) {
cordova.getActivity().finishAffinity();
}

});
builder.show();
access = false;
}
return access;
}
}

然后在WebApp里面不断获取数据就行了

/**
* 获取Beacon设备
*/
function getBeacons()
{
if (cordova && cordova.plugins && cordova.plugins.Beacon)
{
if (cordova.plugins.Beacon.getBeacons) {
cordova.plugins.Beacon.getBeacons(function(beacons){
var sortBeacons = beacons.sort(function(a,b){
return b.rssi - a.rssi;
})
if (sortBeacons.length >= 3) {
setHumanLocation(sortBeacons);
}
})
}
}
}

通过Beacon数据定位

在说这一步的原理之前,得先引入一个下面会用到的数据:MeasuredPower,是指在信标1m处测得的信号强度,这一点一般出厂的时候会有相应的参数,当然也可以自己去矫正。那么手机获取数据时得到的信号强度,减去这个MeasuredPower,就是信号衰减量了。理论上来说,信号衰减量是随着距离的增加而增加的,具体关系可以去查论文,我查出来的有说信号强度跟距离的平方成反比的,也有说距离=kln(信号强度/MeasuredPower)的,K是衰减系数。

其实这都不重要,一来因为这个信号强度他不是一个稳定值,而且变动还挺大,可能拿着手机的人一个转身,就会有剧烈波动;二来因为基站会8到10米布置一个,完全不考虑信号强度只选最强信号也可以让误差在10米以内了,再考虑信号强度,三点定位,只要计算中贯彻距离越远衰减越强的策略,这个定位精度就会提高不少。考虑到信号波动,就算真的精准计算也不会比简单策略高到哪里去。其实最主要的是精准算距离之后的三角定位算法写起来挺麻烦的,我懒得折腾。

我就假设了信号衰减跟距离是完全的正比关系,如图假设三个点的信号衰减是d1,d2,d3,那在他们的三边上就能找出三个点,与他们的信号衰减量是成比例的,然后用这三点构成一个三角形,这个三角形的质心到黑色三角形三个顶点的距离比,就跟接收到这三个基站的信号衰减量的比一致了,所以定位点就选择选择蓝色三角形的质心。

这个算法肯定是有问题的,比如如果人跑到黑色三角形的外面,他还是有可能接收到这三个基站的信号,这个时候人毫无疑问不可能在蓝色三角形的质心。使用这个算法只因为有两个条件,第一是基站部署的足够密集,第二是精度要求没有很高且蓝牙信号强度波动本就比较大。

示意图

代码如下,使用了turf进行距离、线上的点和三角形质心的计算。

/**
* 设置人的位置
* @param {*} beacons
* @returns
*/
function setHumanLocation(beacons) {
const triangle = _scene.getMeshByID('station_triangle');
if (triangle) {
_scene.removeMesh(triangle,true);
}

const beacon1 = beacons[0];
const beacon2 = beacons[1];
const beacon3 = beacons[2];
if (!_stationMap) {
return;
}
const station1 = _stationMap[beacon1.uuid.toUpperCase()];
const station2 = _stationMap[beacon2.uuid.toUpperCase()];
const station3 = _stationMap[beacon3.uuid.toUpperCase()];
if (!station1 || !station2 || !station3) {
console.log('基站信息未获取');
return;
}

const station1Postion = calRelativePosition(station1.position.x,station1.position.y);
const station2Postion = calRelativePosition(station2.position.x,station2.position.y);
const station3Postion = calRelativePosition(station3.position.x,station3.position.y);

let triangleHeight = 1;
if (_selectedFloor) {
triangleHeight = _selectedFloor.height + 1;
}
const positons = [
new BABYLON.Vector3(station1Postion[0], triangleHeight, station1Postion[1]),
new BABYLON.Vector3(station2Postion[0], triangleHeight, station2Postion[1]),
new BABYLON.Vector3(station3Postion[0], triangleHeight, station3Postion[1]),
new BABYLON.Vector3(station1Postion[0], triangleHeight, station1Postion[1]),
]
//显示信号最强的三个基站形成的三角形
const stationTriangle = BABYLON.MeshBuilder.CreateLines('station_triangle', {points: positons});

//信号衰减量
let decay1 = station1.measuredPower - beacon1.rssi;
let decay2 = station2.measuredPower - beacon2.rssi;
let decay3 = station3.measuredPower - beacon3.rssi;
decay1 = decay1>0?decay1:1;
decay2 = decay2>0?decay2:1;
decay3 = decay3>0?decay3:1;

const distanceOption = {units: 'kilometers'};
const length12 = turf.distance(turf.point(station1Postion),turf.point(station2Postion),distanceOption);
const length23 = turf.distance(turf.point(station2Postion),turf.point(station3Postion),distanceOption);
const length13 = turf.distance(turf.point(station1Postion),turf.point(station3Postion),distanceOption);

const line12 = turf.lineString([station1Postion,station2Postion]);
const line23 = turf.lineString([station2Postion,station3Postion]);
const line13 = turf.lineString([station1Postion,station3Postion]);

const point12 = turf.along(line12, length12 * decay1/(decay1 + decay2), distanceOption);
const point23 = turf.along(line23, length23 * decay2/(decay2 + decay3), distanceOption);
const point13 = turf.along(line13, length13 * decay1/(decay1 + decay3), distanceOption);

const features = turf.polygon([
[
point12.geometry.coordinates,
point23.geometry.coordinates,
point13.geometry.coordinates,
point12.geometry.coordinates]
]);
const center = turf.centerOfMass(features);
const centerX = center.geometry.coordinates[0];
const centerY = center.geometry.coordinates[1];

const mesh = _scene.getMeshByID('__human__');
if (mesh) {
let height = 0;
if (_selectedFloor) {
height = _selectedFloor.height + 0.24;
}
mesh.position = new BABYLON.Vector3(centerX, height, centerY);
}
}

效果图

效果图

加载评论框需要翻墙