不限距离4g/5g信号远程遥控小车

       

4g/5g不限距离遥控小车(1)

        

4g/5g不限距离遥控小车(2)

        最开始学习编程也是源于一个想法, 无线遥控小车和飞机操作范围都是在几十米, 远的几百米, 再远的几公里, 那能不能把手机放在小车或飞机上, 利用手机的4g/5g信号来接收指令, 这样只要有手机信号的地方, 就可以不限距离的操作, 当时我面临两个非常棘手的问题, 一个是视频图像传输的实时性, 因为我要坐在电脑前实时操作小车和飞机的路线, 那么对视频传输的延时就要求很高; 第二个是当时我并不知道安卓手机可以利用AOA协议来扩展外设, 当年我看的所有安卓视频教程中从来没有一个老师提起过, 导致我这个想法一直没能实现. 非常偶然的一个机会, 我在百度里才发现了安卓手机可以用AOA协议来扩展外设, 后来通过学习又解决了视频传输实时性的问题, 好了, 开始动手搞起来~        

        服务端源码已经上传到gitee了, 喜欢的可以去下载, 记得手抖点个赞哈~

        [email protected]:phoenix3k/client-a_client-b_-server.git

        前一阵修复了clientA和clientB的两处bug, 其一是更新到最新版本的百度地图sdk 7.4版本, 其二是socketio在高版本AndroidSDK中无法通信的bug (为此我将单独写一篇博客记录下来), 目前已经适配了从Android12到Android4.4的兼容.

        后续会开放ClientA和ClientB的源代码

        小车改装升级为四驱越野, 2百多的车还是有点心疼的, 同时还做了一个简单的充电器, 直接上图

        先上两张图, 小车已经可以正常跑起来了~

        

再来说说我都消耗了哪些东西:

        1. 两部旧的安卓手机, 一个是乐视2安卓6.0, 一个是小米5s安卓7.0

        2. x宝上淘的一块FT311D开发模块

        3. x宝上淘的两块4000mAh, 3.7v可充电锂电池

        4. x宝上淘的一块pwm电机模块, 用于驱动马达

        5. x宝上淘的一块移动电源主板升压充电模块, 锂电池3v 3.7v升5v 1A升压板

        6. x宝上淘的一块DC-DC升压模块, 2A升压板, 宽压输入2/24v升5/9/12/28v 可调

        7. 拆掉了儿子一辆无线遥控小车

        8. 一台阿里云服务器, 配置8g内存, 2核CPU, 5m带宽

        9. 一台笔记本电脑或台式机

需要做的工作和目前实现了哪些功能:

        1. 乐视2手机用于和FT311D版相连, 用于接收控制指令, 以及传输视频图像, 经纬度坐标, 手机电量信息等, 出于起名困难, 我称这个手机为clientB.

        2. 小米5s手机用于发出控制指令, 接收经纬度坐标, 并在百度地图中显示, 接收clientB电量信息, 发出打开或关闭clientB的摄像头, 接收clientB的连接状态, 出于起名困难, 我称这个手机为clientA.

        3. 服务器上部署了srs服务, 用于低延时视频传输, 这部分我已经单独发了一篇帖子, 感兴趣的小伙伴可以去看看, 同时部署了服务端程序, 这部分的代码我并没有用java, 而是用来nodejs来实现, 功能相对简单, 只是负责转发clientB和clientA的指令和状态信息, 控制指令和消息的转发安卓端和服务端都是用了socketIO.

        需要懂一些原生安卓开发的知识, 当然如果小伙伴不想用nodejs来写后台服务, 完全可以替换成java, 了解一些Linux的知识最好, 方便应用的部署, 懂一些srs服务的部署, 这个可以参考gitee的官方wiki, 需要去看FT311D的文档, 了解如何使用FT311D开发版以及嵌入到自己的安卓项目中, 这部分资源我也上传到了我的CSDN资源中, 喜欢的小伙伴可以下载.

        先上nodejs服务器端的代码, 比较简单, 只是做了自定义命令的转发.

        app.js

var app = require('express')();
var server = require('http').Server(app);
var io = require('socket.io')(server);

server.listen(6547); // 监听自己的服务端口

app.use('/api', require('./routers/api'));

io.on('connection', function(socket) {
	console.log("来了一个人  " + socket.id + "-" + socket.request.connection.remoteAddress);

	// 监听客户端离线事件
	socket.on('disconnect', function() {
		console.log("对方下线了");
		socket.broadcast.emit('cmd', '对方下线了');
	});

	// 监听clientA和clientB发送的cmd命令
	socket.on('cmd', function(msg) {
		if (msg == "B" || msg == "A") {
			socket.emit('cmd', "通信成功");
		}
		if (msg == "A:up") {
			console.log("up");
			socket.broadcast.emit('cmd', 'up');
		}
		if (msg == "A:back") {
			console.log("back");
			socket.broadcast.emit('cmd', 'back');
		}
		if (msg == "A:left") {
			console.log("left");
			socket.broadcast.emit('cmd', 'left');
		}
		if (msg == "A:right") {
			console.log("right");
			socket.broadcast.emit('cmd', 'right');
		}
		if (msg == "A:stop") {
			console.log("stop");
			socket.broadcast.emit('cmd', 'stop');
		}
		if (msg.indexOf("speed:") != -1) {
			socket.broadcast.emit('speed', msg.slice(6));
			console.log(msg.slice(6));
		}
		if (msg.indexOf("period:") != -1) {
			socket.broadcast.emit('period', msg.slice(7));
			console.log(msg.slice(7));
		}
		if (msg == "push") { // 发送推流命令到clientB
			socket.broadcast.emit('cmd', 'push');
			console.log('push');
		}
		if (msg == "close") { // 发送关闭推流命令到clientB
			socket.broadcast.emit('cmd', 'close');
			console.log('close');
		}
	});
	socket.on('location', function(msg) { // 转发clientB位置信息
		var LatLon = msg.split(",");
		socket.broadcast.emit('location', LatLon[0], LatLon[1]);
	});
	socket.on('elect', function(msg) { // 转发clientB电量信息
		socket.broadcast.emit('elect', msg);
		onsole.log(msg);
	});
});

        api.js 负责推流和拉流简单的鉴权验证

var express = require("express");
var app = express();
var router = express.Router();
var formidable = require("formidable");

// 推流鉴权
router.post('/publish', function(req, res, next) {
	console.log("--------推流鉴权--------");
	var form = new formidable.IncomingForm();
	form.encoding = 'utf-8';
	form.parse(req, function(err, fields, files) {
		if (fields.stream == '147') { // 这里的流名称请参考srs的wiki
			res.statusCode = 200;
			res.write("0");
			res.end();
		} else {
			res.write("1");
			res.end();
		}
		// console.log(fields);
	});
});

// 拉流鉴权
router.post('/play', function(req, res, next) {
	console.log("--------拉流鉴权--------");
	var form = new formidable.IncomingForm();
	form.encoding = 'utf-8';
	form.parse(req, function(err, fields, files) {
		if (fields.stream == '147') { // 这里的流名称请参考srs的wiki
			res.statusCode = 200;
			res.write("0");
			res.end();
		} else {
			res.write("1");
			res.end();
		}
		// console.log(fields);
	});
});

// 声网分发token
const Role = {

	// DEPRECATED. Role::ATTENDEE has the same privileges as Role.PUBLISHER.
	ATTENDEE: 0,

	// RECOMMENDED. Use this role for a voice/video call or a live broadcast, if your scenario does not require authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in).
	PUBLISHER: 1,

	/* Only use this role if your scenario require authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in).
	 * @note In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role.SUBSCRIBER still has the same privileges as Role.PUBLISHER.
	 */
	SUBSCRIBER: 2,

	// DEPRECATED. Role.ADMIN has the same privileges as Role.PUBLISHER.
	ADMIN: 101
}

class RtcTokenBuilder {
	static buildTokenWithUid(appID, appCertificate, channelName, uid, role, privilegeExpiredTs) {
		return this.buildTokenWithAccount(appID, appCertificate, channelName, uid, role, privilegeExpiredTs)
	}

	static buildTokenWithAccount(appID, appCertificate, channelName, account, role, privilegeExpiredTs) {
		this.key = new AccessToken(appID, appCertificate, channelName, account)
		this.key.addPriviledge(Priviledges.kJoinChannel, privilegeExpiredTs)
		if (role == Role.ATTENDEE ||
			role == Role.PUBLISHER ||
			role == Role.ADMIN) {
			this.key.addPriviledge(Priviledges.kPublishAudioStream, privilegeExpiredTs)
			this.key.addPriviledge(Priviledges.kPublishVideoStream, privilegeExpiredTs)
			this.key.addPriviledge(Priviledges.kPublishDataStream, privilegeExpiredTs)
		}
		return this.key.build();
	}
}


module.exports = router;
module.exports.Role = Role;
module.exports.RtcTokenBuilder = RtcTokenBuilder;

        最近用闲散时间改进了服务端和Android端的代码, 之前只能满足一个clientA和一个clientB进行通信, 无法实现多组clientA和clientB通信, 现在把Android端和nodejs的代码加入了"房间"的概念, 每个"房间"有且只有一组clientA和clientB, 而且只能通过clientA来创建房间, clientB来加入房间, 就可以有多个"房间"同时工作, 而各个房间里的clientA发出的指令只对该"房间"的clientB起作用, 而房间之间互不干扰, 先上改进后的服务端代码, 本次服务端代码只改了app.js.

const app = require('express')();
const server = require('http').Server(app);
const io = require('socket.io')(server);

const RtcTokenBuilder = require('./routers/api').RtcTokenBuilder;
const RtcRole = require('./routers/api').Role;

// 生成声网token所需参数
const appID = 'xxxx'; // 此处请填写自己在声网创建项目的appid
const appCertificate = 'xxxx'; // 此处请填写自己在声网创建项目的app证书
const uid = 0;
// const account = "2882341273";
const role = RtcRole.PUBLISHER;
const expirationTimeInSeconds = 3600;

app.use('/api', require('./routers/api'));

var rooms = []; // rooms数组中又存储每一个room对象
var tmp = [];
var channelName = '';
var token;

io.on("connection", (socket) => {
	console.log("来了一个人  " + socket.id + "-" + socket.request.connection.remoteAddress);

	// 接到一个客户端连接后, 判断客户端类型, 加入的房间号, 房间密码, 以及房间里的人数, 每个房间只容纳两个客户端, 一个clientA, 一个clientB
	socket.on("info", (roomNum, roomPwd, socketType) => {
		if (rooms.length == 0 && socketType == 'clientA') { // 只允许clientA创建房间
			var room = {};
			room.roomNum = roomNum;
			room.roomPwd = roomPwd;
			room.socketType = socketType;
			room.socketID = socket.id;
			rooms.push(room);
			socket.emit("message", 1);

			// 用户加入房间
			socket.join(roomNum);
		} else if (rooms.length == 0 && socketType == 'clientB') { // clientB不允许创建房间
			socket.emit("message", 0);
		} else {
			if (rooms.length != 0) {
				if (socketType == 'clientA') {
					for (let i = 0; i < rooms.length; i++) {
						if (rooms[i].roomNum == roomNum && rooms[i].socketType == 'clientA') {
							socket.emit("message", 0);
							break;
						}
						if (rooms[i].roomNum == roomNum && rooms[i].socketType == 'clientB' && i == (
								rooms.length - 1)) {
							var room = {};
							room.roomNum = roomNum;
							room.roomPwd = roomPwd;
							room.socketType = socketType;
							room.socketID = socket.id;
							rooms.push(room);

							// 用户加入房间
							socket.join(roomNum);
							socket.emit("message", 1);
							break;
						}
						if (rooms[i].roomNum != roomNum && i == (rooms.length - 1)) {
							var room = {};
							room.roomNum = roomNum;
							room.roomPwd = roomPwd;
							room.socketType = socketType;
							room.socketID = socket.id;
							rooms.push(room);

							// 用户加入房间
							socket.join(roomNum);
							socket.emit("message", 1);
							break;
						}
					}
				}
				if (socketType == 'clientB') {
					for (let i = 0; i < rooms.length; i++) {
						if (isContain(rooms, roomNum, 'clientA') && !
							isContain(rooms, roomNum, 'clientB') && rooms[i].roomPwd == roomPwd
						) {
							var room = {};
							room.roomNum = roomNum;
							room.roomPwd = roomPwd;
							room.socketType = socketType;
							room.socketID = socket.id;
							rooms.push(room);

							// 用户加入房间
							socket.join(roomNum);
							socket.emit("message", 1);
							break;
						}
						if (isContain(rooms, roomNum, 'clientA') && !
							isContain(rooms, roomNum, 'clientB') && rooms[i].roomPwd != roomPwd
						) {
							socket.emit("message", -1);
							break;
						}
						if (!isContain(rooms, roomNum, 'clientA') && !isContain(rooms, roomNum,
								'clientB')) {
							socket.emit("message", 0);
							break;
						}
					}
				}
			}
		}
	});

	// 监听客户端离线事件
	socket.on("disconnect", () => {
		for (let i = 0; i < rooms.length; i++) {
			if (rooms[i].socketID == socket.id) {
				socket.leave(rooms[i].roomNum);
				rooms.splice(i, 1);
			}
		}
		console.log("对方下线了" + socket.id);
	});

	// 监听clientA和clientB发送的cmd命令
	socket.on('cmd', function(msg) {
		if (msg == "B" || msg == "A") {
			socket.emit('cmd', "通信成功");
		}
		for (let i = 0; i < rooms.length; i++) {
			if (rooms[i].socketID == socket.id) {
				if (msg == "A:up") {
					console.log("up");
					socket.to(rooms[i].roomNum).emit('cmd', 'up');
				}
				if (msg == "A:back") {
					console.log("back");
					socket.to(rooms[i].roomNum).emit('cmd', 'back');
				}
				if (msg == "A:left") {
					console.log("left");
					socket.to(rooms[i].roomNum).emit('cmd', 'left');
				}
				if (msg == "A:right") {
					console.log("right");
					socket.to(rooms[i].roomNum).emit('cmd', 'right');
				}
				if (msg == "A:stop") {
					console.log("stop");
					socket.to(rooms[i].roomNum).emit('cmd', 'stop');
				}
				if (msg.indexOf("speed:") != -1) {
					socket.to(rooms[i].roomNum).emit('speed', msg.slice(6));
					console.log(msg.slice(6));
				}
				if (msg.indexOf("period:") != -1) {
					socket.to(rooms[i].roomNum).emit('period', msg.slice(7));
					console.log(msg.slice(7));
				}
				if (msg == "push") { // 发送推流命令到clientB
					socket.to(rooms[i].roomNum).emit('cmd', 'push');
					console.log('push');
				}
				if (msg == "close") { // 发送关闭推流命令到clientB
					socket.to(rooms[i].roomNum).emit('cmd', 'close');
					console.log('close');
				}
				if (msg == "token") { // 返回给客户端token
					channelName = rooms[i].roomNum;
					if (rooms[i].socketType == 'clientB') {
						io.to(socket.id).emit('token', token); // 把token返回给发送请求的socket端
					} else {
						let currentTimestamp = Math.floor(Date.now() / 1000);
						let privilegeExpiredTs = currentTimestamp + expirationTimeInSeconds;
						token = RtcTokenBuilder.buildTokenWithUid(appID, appCertificate, channelName,
							uid, role, privilegeExpiredTs);
						io.to(socket.id).emit('token', token); // 把token返回给发送请求的socket端
						// socket.emit('cmd', token);
					}
					console.log("Token is: " + token);
				}
			}
		}
	});
	socket.on('location', function(msg) {
		for (let i = 0; i < rooms.length; i++) {
			if (rooms[i].socketID == socket.id) {
				var LatLon = msg.split(",");
				socket.broadcast.emit('location', LatLon[0], LatLon[1]);
			}
		}
	});
	socket.on('elect', function(msg) {
		for (let i = 0; i < rooms.length; i++) {
			if (rooms[i].socketID == socket.id) {
				socket.broadcast.emit('elect', msg);
				console.log(msg);
			}
		}
	});
});

function isContain(rooms, roomNum, type) {
	for (let i = 0; i < rooms.length; i++) {
		if (rooms[i].roomNum == roomNum && rooms[i].socketType == type) {
			return true;
		} else {
			continue;
		}
	}
	return false;
}

server.listen(3764);

        顺便说一下, 服务器端的socketIO版本升级到了最新, 4.2.0, 同时Android端的版本升级到了2.0.1, 这两个版本一定是对应的, 相关版本兼容说明可直接参考官网说明, 否则一定扑街!!!

        这次的改进也在两个Android端加入了语音功能, 引入了第三方的平台---声网, 之所以选择它是因为能快速接入语音功能, 目前已实现了clientA和clientB实时对讲功能, clientB端我默认开启了免提功能, 这样clientA和clientB就能相互喊话啦! 有的小伙伴要问, 为什么你视频功能不接入第三方平台呢, 原因很简单, 传输时效达不到我的要求~

        本次修改优化了声网语音电话的token申请, token的生成需要自己写逻辑, token过期时间为24小时, 这个在声网官网中都有明确的说明, 直接上我服务器生成token的代码, 也是从声网搬砖来的, 声网的token生成有多个语言版本, 由于我的后台服务是nodejs, 所以我选择的是这个版本的代码.

        AccessToken.js 是token生成的核心代码, 直接搬砖到自己服务器上ok了~

var crypto = require('crypto');
var crc32 = require('crc-32');
var UINT32 = require('cuint').UINT32;
var version = "006";
var randomInt = Math.floor(Math.random() * 0xFFFFFFFF);
const VERSION_LENGTH = 3;
const APP_ID_LENGTH = 32;

var AccessToken = function(appID, appCertificate, channelName, uid) {
	let token = this;
	this.appID = appID;
	this.appCertificate = appCertificate;
	this.channelName = channelName;
	this.messages = {};
	this.salt = randomInt;
	this.ts = Math.floor(new Date() / 1000) + (24 * 3600);
	if (uid === 0) {
		this.uid = "";
	} else {
		this.uid = `${uid}`;
	}

	this.build = function() {
		var m = Message({
			salt: token.salt,
			ts: token.ts,
			messages: token.messages
		}).pack();

		var toSign = Buffer.concat(
			[Buffer.from(token.appID, 'utf8'),
				Buffer.from(token.channelName, 'utf8'),
				Buffer.from(token.uid, 'utf8'),
				m
			]);

		var signature = encodeHMac(token.appCertificate, toSign);
		var crc_channel = UINT32(crc32.str(token.channelName)).and(UINT32(0xffffffff)).toNumber();
		var crc_uid = UINT32(crc32.str(token.uid)).and(UINT32(0xffffffff)).toNumber();
		var content = AccessTokenContent({
			signature: signature,
			crc_channel: crc_channel,
			crc_uid: crc_uid,
			m: m
		}).pack();
		return (version + token.appID + content.toString('base64'));
	}

	this.addPriviledge = function(priviledge, expireTimestamp) {
		token.messages[priviledge] = expireTimestamp;
	};

	this.fromString = function(originToken) {
		try {
			originVersion = originToken.substr(0, VERSION_LENGTH);
			if (originVersion != version) {
				return false;
			}
			var originAppID = originToken.substr(VERSION_LENGTH, (VERSION_LENGTH + APP_ID_LENGTH));
			var originContent = originToken.substr((VERSION_LENGTH + APP_ID_LENGTH));
			var originContentDecodedBuf = Buffer.from(originContent, 'base64');

			var content = unPackContent(originContentDecodedBuf);
			this.signature = content.signature;
			this.crc_channel_name = content.crc_channel_name;
			this.crc_uid = content.crc_uid;
			this.m = content.m;

			var msgs = unPackMessages(this.m);
			this.salt = msgs.salt;
			this.ts = msgs.ts;
			this.messages = msgs.messages;

		} catch (err) {
			console.log(err);
			return false;
		}

		return true;
	};
};

module.exports.version = version;
module.exports.AccessToken = AccessToken;
module.exports.priviledges = {
	kJoinChannel: 1,
	kPublishAudioStream: 2,
	kPublishVideoStream: 3,
	kPublishDataStream: 4,
	kRtmLogin: 1000
};

var encodeHMac = function(key, message) {
	return crypto.createHmac('sha256', key).update(message).digest();
};

var ByteBuf = function() {
	var that = {
		buffer: Buffer.alloc(1024),
		position: 0
	};

	that.buffer.fill(0);

	that.pack = function() {
		var out = Buffer.alloc(that.position);
		that.buffer.copy(out, 0, 0, out.length);
		return out;
	};

	that.putUint16 = function(v) {
		that.buffer.writeUInt16LE(v, that.position);
		that.position += 2;
		return that;
	};

	that.putUint32 = function(v) {
		that.buffer.writeUInt32LE(v, that.position);
		that.position += 4;
		return that;
	};

	that.putBytes = function(bytes) {
		that.putUint16(bytes.length);
		bytes.copy(that.buffer, that.position);
		that.position += bytes.length;
		return that;
	};

	that.putString = function(str) {
		return that.putBytes(Buffer.from(str));
	};

	that.putTreeMap = function(map) {
		if (!map) {
			that.putUint16(0);
			return that;
		}

		that.putUint16(Object.keys(map).length);
		for (var key in map) {
			that.putUint16(key);
			that.putString(map[key]);
		}

		return that;
	};

	that.putTreeMapUInt32 = function(map) {
		if (!map) {
			that.putUint16(0);
			return that;
		}

		that.putUint16(Object.keys(map).length);
		for (var key in map) {
			that.putUint16(key);
			that.putUint32(map[key]);
		}

		return that;
	};

	return that;
}


var ReadByteBuf = function(bytes) {
	var that = {
		buffer: bytes,
		position: 0
	};

	that.getUint16 = function() {
		var ret = that.buffer.readUInt16LE(that.position);
		that.position += 2;
		return ret;
	};

	that.getUint32 = function() {
		var ret = that.buffer.readUInt32LE(that.position);
		that.position += 4;
		return ret;
	};

	that.getString = function() {
		var len = that.getUint16();

		var out = Buffer.alloc(len);
		that.buffer.copy(out, 0, that.position, (that.position + len));
		that.position += len;
		return out;
	};

	that.getTreeMapUInt32 = function() {
		var map = {};
		var len = that.getUint16();
		for (var i = 0; i < len; i++) {
			var key = that.getUint16();
			var value = that.getUint32();
			map[key] = value;
		}
		return map;
	};

	return that;
}
var AccessTokenContent = function(options) {
	options.pack = function() {
		var out = new ByteBuf();
		return out.putString(options.signature)
			.putUint32(options.crc_channel)
			.putUint32(options.crc_uid)
			.putString(options.m).pack();
	}

	return options;
}

var Message = function(options) {
	options.pack = function() {
		var out = new ByteBuf();
		var val = out
			.putUint32(options.salt)
			.putUint32(options.ts)
			.putTreeMapUInt32(options.messages).pack();
		return val;
	}

	return options;
}

var unPackContent = function(bytes) {
	var readbuf = new ReadByteBuf(bytes);
	return AccessTokenContent({
		signature: readbuf.getString(),
		crc_channel_name: readbuf.getUint32(),
		crc_uid: readbuf.getUint32(),
		m: readbuf.getString()
	});
}

var unPackMessages = function(bytes) {
	var readbuf = new ReadByteBuf(bytes);
	return Message({
		salt: readbuf.getUint32(),
		ts: readbuf.getUint32(),
		messages: readbuf.getTreeMapUInt32()
	});
}

猜你喜欢

转载自blog.csdn.net/phoenix3k/article/details/119594002
今日推荐