技术研究之大疆无人机

标签: 无 分类: 未分类 创建时间:2023-06-20 09:45:26 更新时间:2025-09-26 04:44:39

1.前言

最近接到了一个项目,就是使用大疆的无人机进行自动巡检,所以就要研究相关的技术

(1)第三方云平台泛指网关设备可以直接访问的各个服务端,对于上云API来说,只要通信链路能够访问到第三方云平台服务,即可进行通信,这样对于第三方云平台的环境部署就没有做限制,无论是私有化部署还是公有云部署,只要能访问均可工作。

(2)第三方云平台需要先部署MQTT网关、HTTPS服务、Websocket服务、对象存储等基础服务,打通与网关设备的通信链路,即可进行功能集开发实现,上云API提供的功能集主要分2部分,一部分是基于DJI Pilot 2的,有飞手操作的场景,该场景下提供的功能集有:地图元素、态势感知、直播、媒体库、航线库、设备管理。另一部分是主要面向大疆机场的场景,功能集有:固件远程升级、设备异常告警、机场远程控制等等,当然,有些功能集是两种场景均有重叠的,例如视频直播,具体使用方式详见各个功能集方案及API说明章节。

(3)第三方云平台在打通与无人机的业务之后,即可通过自身私有协议,搭建前端web页面、APP应用、小程序等,深入业务场景例如安防、能源、环保、海事等,构建一个完整的场景业务工作流。

参考文章:
【1】.产品架构 从架构分层上可以看出,上云API是基于大疆行业版无人机的基础上对外提供的接口,整体思路采用与物联网类似的端边云架构分层。此处要特别注意,无人机并不能直接接入第三方云平台,它需要先连接网关设备(遥控器、机场),然后通过遥控器中的DJI Pilot 2或者机场间接上云,DJI Pilot 2、大疆机场在注册登录第三方云平台时,会同时把飞机和负载的能力一起上报。
【2】.SpringBoot获取大疆无人机的飞行数据 这是一个人开发的应用,可以联系查看
【3】.免费航线规划 这个网站可以自动规划航线,可以作为参考。

2.需要解决的问题

关于是否可以使用Web应用去操纵飞机,首先需要解决的几个问题。

(1) 上云API 可以在云端控制飞机吗?
如果要实现的是通过网页控制飞机的飞行,这个到底能不能做到呢? 在论坛里,说明:“Pilot2 为了保证安全性,目前上云API没有提供控制接口,云台控制接口也没有。”,但是我在查看机场物模型的定义的时候,发现通过机场,可以控制飞行器的状态。

参考文章:
1.上云API 可以在云端控制飞机吗? Pilot2 为了保证安全性,目前上云API没有提供控制接口,云台控制接口也没有。
2.上云API 可以控制飞机拍照、录像吗?
3.指令飞行

(2) 飞机的坐标系是什么?
标准的 WGS84 坐标系,不管APP的地图是高德还是Mapbox地图,与服务器交互的坐标系都是 WGS84。

参考文章:
1.Pilot 2地图使用的坐标系是什么?

(3) 飞机和遥控器断开后,上云API 开发的第三方云还能连接飞机吗?
不可以,断开后就获取不到飞机的数据。原因:第三方云不能直接连接飞机,中间需要通过遥控器。

参考文章:
1.飞机和遥控器断开后,上云API 开发的第三方云还能连接飞机吗?

(4) 无人机画面如何回传
这部分的内容主要就是通过视频直播的形式进行的,在官方的教程中,使用的是 声网的SDK。我以前使用过阿里云的地址,可以很方便的集成直播播放。

参考文章:
1.无人机视频直播如何设置?
2.直播功能 直播功能主要是把无人机相机负载和大疆机场的监控视频码流发给第三方云平台进行播放,用户可以方便的在远程web页面点击直播。直播功能支持直播的开始、停止、清晰度设置、镜头切换。

3.软件依赖

软件安装需要 redis、emqx、java、node

4.前端项目

前端项目启动需要注意以下几点

参考文章:
1.基于源码的部署

4.1.修改src别名

源代码里面的src别名,用了 “/@” 进行替代,需要修改为 “@”,否则启动不了。修改vite.config.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 将下面的代码
resolve: {
alias: [{
// https://github.com/vitejs/vite/issues/279#issuecomment-635646269
find: '/@',
replacement: path.resolve(__dirname, './src'),
}]
},

// 替换为
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
}
},

然后将所有文件中的 “/@” 全部替换为 “@”,注意是全部的用 /@ 引入的路径,都改为这个路径。

4.2.修改依赖项

因为我在执行安装的时候,出现了很多莫名奇妙的问题,比如 exports 不存在,模块无法加载等等。删除package.json中的 @vue/compiler-sfc,然后执行依赖安装

1
2
3
4
5
## 安装全部依赖
pnpm install
## 安装没有的依赖
pnpm add lodash
pnpm add moment

4.3.登陆

修改配置文件之后,就可以进行登陆了。默认的账户名:pilot,密码:pilot123

5.后端服务

后台服务依赖于redes、mysql 和 mqtt 服务,需要将相关的软件安装,并配置到 application 中,配置完成之后,就可以启动了,基本没有什么大的问题。

(1)数据库创建

1
mysql -uroot -pxxx  < cloud_sample.sql

参考文章:
1.MySQL执行SQL文件的几种方式
2.mysql执行sql脚本文件的方法
3.MySQL执行外部sql脚本文件的命令

6.上云

上云模块分为两种,一种是 Pilot 上云,一种是 机场上云。

6.1.Pilot上云

机场上云需要借助遥控器内安装的 DJI Pilot 2。
(1) 创建局域网
修改前端和后端代码,将前端和后端发不到局域网内,创建一个可以局域网访问的 http 连接。

(2) 然后打开遥控器的 DJI Pilot2 软件,点击 “Cloud Service”

(3) 登陆第三方平台
选择 开放云平台 输入 url:http://192.168.0.106/pilot-login ,我这里部署的内网地址是 192.168.0.106,然后输入用户名密码,就可以登陆了。

(4) 查看设备信息
登陆之后,点击左上角的返回按钮,就可以回到主页面了。

(5) 调试
打开mqtt调试工具,订阅 topic主题:sys/product/+/status 和 sys/product/+/status_reply,当设备上线的时候,就会发送信息到这个主题上,设备就算是上云了。

参考文章:
1.Pilot 上云

7.MQTT说明

Topic 定义中,这里有两个 SN 需要注意:

  • gateway_sn
    表示网关设备的 SN,网关设备分为 Pilot 也就是遥控器,和 机场 。Pilot 的SN 在设备的后面有一个二维码,二维码下面就是设备SN。

  • device_sn
    表示该物模型属性的所属设备的 SN,我理解为 飞机。

除此之外,还有就是一些content需要注意:

  • 主题:sys/product/{gateway_sn}/status

    其中的 device_secret 和 nonce 的获取,比如下面的内容,实现的是:网关设备+子设备上线

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    {
    "tid": "tid001",
    "bid": "bid001",
    "method": "update_topo",
    "timestamp": 1234567890123,
    "data": {
    "type": 98,
    "sub_type": 0,
    "device_secret": "secret",
    "nonce": "nonce",
    "version": 1,
    "sub_devices": [
    {
    "sn": "drone001",
    "type": 116,
    "sub_type": 0,
    "index": "A",
    "device_secret": "secret",
    "nonce": "nonce",
    "version": 1
    }
    ]
    }
    }

    官方给出的回答:“这个是在对接上云的流程后,网关设备向云端上报的。可以从消息中获取。”

    后来我重新设置之后,重新上线,果真得到了设备的信息。

参考文章:
1.如何查询设备信息
2.开始使用
3.设备拓扑更新
4.网关设备上线下线中的device_secret如何获取 我提了一个问题给社区,不知道会不会有回应。

8.视频直播

本部分主要就是将飞机相机和机场的视频实时画面进行传输。其实这部分我花费了很长的时间进行技术的探索,虽然大疆采用的是 “声网” 的推流地址,但是在 “声网” 中,我竟然很难找到简单的拉流操作,集成的 web sdk 也是什么 互动直播之类的东西,但是我根本不用互动直播啊,我只需要播放飞机的视频流就可以了。

(1) 登陆声网

(2) 创建项目
项目管理 里面配置项目,并获取 AppID,和临时的 Token。
临时token的生产,需要选择产品,并输入频道名,这个频道名,下面一步还要用的到。

(3) 修改前端配置
在前端源码目录的 src/api/http/config.ts 文件中,修改 agoraAPPID、agoraToken和agoraChannel,其中 agoraChannel 的名字可以随便取,注意和第二步生成的token时填入的一致就好了。当观察者和你取的 agoraChannel 同一的时候,就意味着你们进入了同一个房间,实现了直播。

1
2
3
4
// Agora
agoraAPPID: '你的 appid ',
agoraToken: '你的 token',
agoraChannel: '这里可以取一个自己的名字',

(4) 测试
打开 http://192.168.0.20:8080/livestream/living pc端管理页面,选择直播,并选择需要直播的镜头和画质等,就可以开启直播了。

但是这个直播也不是非常的稳定的,可能是因为我内网不稳定的原因吗?总是出现: RECV_VIDEO_DECODE_FAILED 问题。

参考文章:
1.第六章:推流直播

9.总结

根据一系列的操作,我终于算是摸清了整个大疆无人机Demo的逻辑:

(1) 前后端软件依赖安装

(2) 创建数据库,配置mqtt服务器,获取声网直播 appid、token等信息

(3) 修改前后端服务配置,mqtt、redis、agora配置等

(4) 遥控器登陆 第三方云平台,在 “/pilot-login” 页面实现设备上线和绑定

(5) 在PC端 ”livestream/living“ 测试声网直播功能

(6) 进行后续的开发
通过订阅 mqtt 服务,可以定时获取飞机和设备的状态和位置,还可以通过发送mqtt消息,操作设备和飞机的属性。

1
2
3
Topic: thing/product/{device_sn}/state 状态数据:设备在状态变化时上报

Topic: thing/product/{device_sn}/osd 定频数据:设备将以 0.5HZ 的频率定时上报

技术难点就是上云操作,配置mqtt服务,配置声网直播操作。大部分的控制操作,都是使用的mqtt进行操作,飞机和机场的事实画面回传,使用的就是声网的直播,或者是RTMP等服务。

参考文章:
1.实现音视频互动

10.API汇总

在了解了整个的流程之后,剩下的就是调用 API 进行开发了,

10.1.航线管理

11.问题汇总

(1) Error Msg: Device is not registered.. The device has not been registered, please call the ‘SDKManager.registerDevice()’ method to register the device first.
这个问题就是在云平台上,无人机没有注册进去,这个问题也非常的奇怪,就是说每过一段时间,无人机就会掉线。

参考文章:
1.大疆无人机注册问题 民航局规定的需要实名注册。

(2) Please check whether the live stream service is normal
这个就是检查三个参数,可是最后还是没有检查出来,到底是什么问题。

【尝试方案】
(1)我尝试在不同的网络下进行测试,结果没有用。
(2)我尝试用可行的 token 进行测试,结果出现的问题是 211001,
(3)另外的错误代码就是 513010,说是 ”直播失败,设备无法联网“,这个我在部署到另外的一台服务器上,确实是可以的啊,为什么到这里就不行了呢?
(4)我尝试去理解这个东西,我怀疑是多个mqtt订阅消息,导致消息错乱的问题,会不会是这个问题呢?我想不起来。
(5)我不断的去检查这个接口,发现返回的状态码和我本身定义的状态吗不一致。
(6)我尝试查看发布消息的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public <T> CommonTopicResponse<T> publishWithReply(Class<T> clazz, String topic, CommonTopicRequest request,
int retryCount, long timeout) {
AtomicInteger time = new AtomicInteger(0);
boolean hasBid = StringUtils.hasText(request.getBid());
request.setBid(hasBid ? request.getBid() : UUID.randomUUID().toString());
// Retry
while (time.getAndIncrement() <= retryCount) {
this.publish(topic, request);

// If the message is not received in 3 seconds then resend it again.
CommonTopicResponse<T> receiver = Chan.getInstance(request.getTid(), true).get(request.getTid(), timeout);
// Need to match tid and bid.
if (Objects.nonNull(receiver)
&& receiver.getTid().equals(request.getTid())
&& receiver.getBid().equals(request.getBid())) {
if (clazz.isAssignableFrom(receiver.getData().getClass())) {
return receiver;
}
throw new TypeMismatchException(receiver.getData(), clazz);
}
// It must be guaranteed that the tid and bid of each message are different.
if (!hasBid) {
request.setBid(UUID.randomUUID().toString());
}
request.setTid(UUID.randomUUID().toString());
}
throw new CloudSDKException(CloudSDKErrorEnum.MQTT_PUBLISH_ABNORMAL, "No message reply received.");
}

【解决方案】
最终我还是回归到了代码上面,我不晓得我以前写的代码为什么可以,但是这次我实在只能重新看原先的代码了。最新的大疆demo中的代码这么写的 liveStart 中的代码。当然其中的一些返回值和判断做了调整,我不晓得自己当时为什么这么写,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public Result liveStart(LiveTypeDTO liveParam) {
// Check if this lens is available live.
Result<DeviceDTO> responseResult = this.checkBeforeLive(liveParam.getVideoId());
if (CommonConstant.SC_OK_200 != responseResult.getCode() ) {
return responseResult;
}


ILivestreamUrl url = LiveStreamProperty.get(liveParam.getUrlType());
/**
* 解析 agora url
*/
if(liveParam.getUrlType()==UrlTypeEnum.AGORA){
LivestreamAgoraUrl agoraUrl=new LivestreamAgoraUrl();
Map<String,String> urlMap= HttpUtil.decodeParamMap(liveParam.getUrl(), Charset.forName("UTF-8"));
agoraUrl.setSn(urlMap.get("sn"));
agoraUrl.setChannel(urlMap.get("channel"));
agoraUrl.setToken(urlMap.get("token"));
agoraUrl.setUid(Integer.valueOf(urlMap.get("uid")));
url=agoraUrl;
}

url = setExt(liveParam.getUrlType(), url, liveParam.getVideoId());

TopicServicesResponse<ServicesReplyData<String>> response = abstractLivestreamService.liveStartPush(
SDKManager.getDeviceSDK(responseResult.getResult().getDeviceSn()),
new LiveStartPushRequest()
.setUrl(url)
.setUrlType(liveParam.getUrlType())
.setVideoId(liveParam.getVideoId())
.setVideoQuality(liveParam.getVideoQuality()));

if (!response.getData().getResult().isSuccess()) {
return Result.error(response.getData().getResult().getMessage());
}

LiveDTO live = new LiveDTO();

switch (liveParam.getUrlType()) {
case AGORA:
break;
case RTMP:
live.setUrl(url.toString().replace("rtmp", "webrtc"));
break;
case GB28181:
LivestreamGb28181Url gb28181 = (LivestreamGb28181Url) url;
live.setUrl(new StringBuilder()
.append("webrtc://")
.append(gb28181.getServerIP())
.append("/live/")
.append(gb28181.getAgentID())
.append("@")
.append(gb28181.getChannel())
.toString());
break;
case RTSP:
live.setUrl(response.getData().getOutput());
break;
case WHIP:
live.setUrl(url.toString().replace("whip", "whep"));
break;
default:
return Result.error(LiveErrorCodeEnum.URL_TYPE_NOT_SUPPORTED.getMessage());
}

return Result.ok(live);
}

后来我改成了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public Result liveStart(LiveTypeDTO liveParam) {
// Check if this lens is available live.
Result<DeviceDTO> responseResult = this.checkBeforeLive(liveParam.getVideoId());
if (CommonConstant.SC_OK_200 != responseResult.getCode()) {
return responseResult;
}

TopicServicesResponse<ServicesReplyData<String>> response = abstractLivestreamService.liveStartPush(
SDKManager.getDeviceSDK(responseResult.getResult().getDeviceSn()), new LiveStartPushRequest()
.setUrl(liveParam.getUrl())
.setUrlType(liveParam.getUrlType())
.setVideoId(liveParam.getVideoId())
.setVideoQuality(liveParam.getVideoQuality()));

if (!response.getData().getResult().isSuccess()) {
return Result.error(response.getData().getResult().getMessage());
}

LiveDTO live = new LiveDTO();

return Result.OK(live);
}

当然这个 LiveStartPushRequest 也做了相应的调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

/**
* @author sean
* @version 1.7
* @date 2023/5/23
*/
public class LiveStartPushRequest extends BaseModel {

@NotNull
private UrlTypeEnum urlType;

/**
* RTMP: (rtmp://xxxxxxx) Example: rtmp://192.168.1.1:8080/live
* RTSP:(uerName&password&port) Example: userName=dji-cloud-api&password=123456&port=8080
* GB28181:(serverIP&serverPort&serverID&agentID&agentPassword&localPort&channel)
* Example: serverIP=192.168.1.1&serverPort=8080&serverID=34000000000000000000&agentID=
* 300000000010000000000&agentPassword=0000000&localPort=7060&channel=340000000000000000000
* AGORA:(channel&sn&token&uid)
* Example: channel=1ZNDH360010162_39-0-7&sn=1ZNDH360010162&token=006dca67721582a48768ec4d8
* 17b7b25a86IAB4cw2JgN6iX8BpTPdc3e4S1Iendz94IFJ56aSXKvzAJei27MqF2zyCIgCLIIoBt41+YAQAAQC3jX
* 5gAgC3jX5gAwC3jX5gBAC3jX5g&uid=50000
* Notice: The token generated by Shengwang may have special characters such as '+' ' ',
* and need to do url encode, otherwise there will be a parsing error on the PILOT side
*/
@NotBlank
private String url;

/**
* The format is #{uav_sn}/#{camera_id}/#{video_index},
* drone serial number/payload and mounted location enumeration value/payload lens numbering
*/
@NotNull
private VideoId videoId;

@NotNull
private VideoQualityEnum videoQuality;

public LiveStartPushRequest() {
}

@Override
public String toString() {
return "LiveStartPushRequest{" +
"urlType=" + urlType +
", url='" + url + '\'' +
", videoId=" + videoId +
", videoQuality=" + videoQuality +
'}';
}

public UrlTypeEnum getUrlType() {
return urlType;
}

public LiveStartPushRequest setUrlType(UrlTypeEnum urlType) {
this.urlType = urlType;
return this;
}

public String getUrl() {
return url;
}

public LiveStartPushRequest setUrl(String url) {
this.url = url;
return this;
}

public VideoId getVideoId() {
return videoId;
}

public LiveStartPushRequest setVideoId(VideoId videoId) {
this.videoId = videoId;
return this;
}

public VideoQualityEnum getVideoQuality() {
return videoQuality;
}

public LiveStartPushRequest setVideoQuality(VideoQualityEnum videoQuality) {
this.videoQuality = videoQuality;
return this;
}
}

参考文章:
1.声网播放失败

(3) 无法获取飞机的 total_flight_sorties 总架次信息
根据使用说明文档,使用 thing/product/{device_sn}/osd 无法获取飞机的 total_flight_sorties 总架次信息,而且获取到的 total_flight_distance 和 total_flight_time 都是零。我该如何获取飞机的飞行总架次信息?

【解决方案】
目前是只有机场上云才能拿到这个信息,pilot上云暂时还不支持。pilot 不支持,至少应该在文档里面说明下啊。

参考文章:
1.如何获取飞机总架次信息
2.设备属性

12.起飞和返航

我发现只有一键起飞命令,没有一键返航命令啊,几经周折,还是找到了返航的任务了。
(1)一键起飞
一键起飞的接口是在大疆机场的 指令飞行 这一个章节里面,调用了 takeoff_to_point 命令实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 一键起飞
*/
@RequestMapping("/takeoffToPoint")
private HttpResultResponse<Boolean> takeoffToPoint(@RequestBody JSONObject param){
String sn=param.getString("sn");

// 设置飞行参数
TakeoffToPointParam takeParam=new TakeoffToPointParam();
takeParam.setTargetLongitude(param.getDouble("target_longitude"));
takeParam.setTargetLatitude(param.getDouble("target_latitude"));
takeParam.setTargetLatitude(param.getDouble("target_height"));
takeParam.setSecurityTakeoffHeight(param.getDouble("security_takeoff_height"));
takeParam.setRthAltitude(param.getDouble("rth_altitude"));
takeParam.setMaxSpeed(param.getDouble("max_speed"));
takeParam.setRcLostAction(RcLostActionEnum.RETURN_HOME);
takeParam.setExitWaylineWhenRcLost(ExitWaylineWhenRcLostEnum.EXECUTE_RC_LOST_ACTION);
takeParam.setRthMode(RthModeEnum.SMART_HEIGHT);
takeParam.setCommanderModeLostAction(CommanderModeLostActionEnum.EXECUTE_RC_LOST_ACTION);
takeParam.setCommanderFlightMode(CommanderFlightModeEnum.SMART_HEIGHT);
takeParam.setCommanderFlightHeight(param.getFloat("commander_flight_height"));

return controlService.takeoffToPoint(sn, takeParam);
}

(2)一键返航
我还是没有在官方的地方找到返航的设置,我问了论坛,问了以前的开发人员,最后都没有及时的得到回复,最后终于算是看源码找到了答案,这个一键返航的功能,是在 航线管理 里面的,一个在指令飞行里面,一个在航线管理里面,怎么能让人找到两着的关联呢?

1
2
3
4
5
6
7
8
/**
* 返航控制
*/

@RequestMapping("/returnHome")
private HttpResultResponse<Boolean> returnHome(@RequestParam(value = "sn")String sn){
return controlService.controlDockDebug(sn, RemoteDebugMethodEnum.RETURN_HOME,null);
}

参考文章:
1.指令飞行 这里有一键起飞命令。
2.无人机飞行数据java版本api大疆无人机SpringBoot 这是一个完整的例子,但是不一定会给你说答案。

13.飞行数据导出

参考文章:
【1】.飞控数据导出指引
【2】.航点飞行保存的航点如何导出到本地保存起来?

小额赞助
本人提供免费与付费咨询服务,感谢您的支持!赞助请发邮件(ititchuan@gmail.com)通知,方便公布您的善意!
**光 3.01 元
Sun 3.00 元
ititchuan 3.00 元
微信公众号
广告位
诚心邀请广大金主爸爸洽谈合作
每日一省
isNaN 和 Number.isNaN 函数的区别?

1.函数 isNaN 接收参数后,会尝试将这个参数转换为数值,任何不能被转换为数值的的值都会返回 true,因此非数字值传入也会返回 true ,会影响 NaN 的判断。

2.函数 Number.isNaN 会首先判断传入参数是否为数字,如果是数字再继续判断是否为 NaN ,不会进行数据类型的转换,这种方法对于 NaN 的判断更为准确。

每日二省
为什么0.1+0.2 ! == 0.3,如何让其相等?

一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2-52,在ES6中,提供了Number.EPSILON属性,而它的值就是2-52,只要判断0.1+0.2-0.3是否小于Number.EPSILON,如果小于,就可以判断为0.1+0.2 ===0.3。

每日三省
== 操作符的强制类型转换规则?

1.首先会判断两者类型是否**相同,**相同的话就比较两者的大小。

2.类型不相同的话,就会进行类型转换。

3.会先判断是否在对比 null 和 undefined,是的话就会返回 true。

4.判断两者类型是否为 string 和 number,是的话就会将字符串转换为 number。

5.判断其中一方是否为 boolean,是的话就会把 boolean 转为 number 再进行判断。

6.判断其中一方是否为 object 且另一方为 string、number 或者 symbol,是的话就会把 object 转为原始类型再进行判断。

每日英语
Happiness is time precipitation, smile is the lonely sad.
幸福是年华的沉淀,微笑是寂寞的悲伤。