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()
});
}