项目名称
丸骑行,一款帮你管理电动车的轻便APP。
如今电动车\自行车保有量巨大,停车点混乱、拥堵现象导致用车找车困难,为了给人们更好的骑行体验,领航员1号团队基于OpenHarmony开发了丸骑行方案。用户可体验远程实时查看车辆电量、位置,远程开关锁、响铃找车、续航估算等功能。
- 作品标题:丸骑行
- 软件分类:生活类APP
- 应用领域:交通工具-电动车
- 开放源码许可证:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
- 文件说明
IotDevice:设备开发源码
app/: OPenHrmony 应用
bin/ 设备开发固件
hap/ 应用Hap包
涉及的OH技术特性:
ArkUI、服务卡片、应用内web、分布式KvStore数据持久化、socket网络通信。
1、运行条件
开发环境准备:
- 应用开发:
DevEco Studio版本:DevEco Studio 3.1.1 Release及以上版本。
OpenHarmony SDK版本:API 9, OpenHarmony 3.2 Release
- 设备开发:
- 主控芯片:RISC-V架构,Hi3861,适用于上海海思 HiSpark T1、润和 HiHope Pegasus、小熊派 BearPI Nano。
本文使用Hihope Pegasus、BearPi Nano派验证通过。 - OpenHarmony 版本: https://gitee.com/HiSpark/hi3861_hdu_iot_application。
- Windows环境搭建: hi3861_hdu_iot_application基于Hi3861V100和OpenHarmony. 开发指南文档地址: /doc/物联网设计及应用实验指导手册.pdf。
2、运行说明
- 操作一:准备前文所述应用开发环境,下载hap包到本地,正确编译后上传到DAYU800开发板;
- 操作二:给DAYU800开发板连接上可以访问互联网的热点
- 操作三:【仅连接硬件需要】使用Hiburn工具下载bin文件到Hi3861开发板,使用串口工具查看当前Hi3861的IP地址
- 操作四:刷新设备状态
- 运行APP后,右滑进入地图页面,等待几秒(看网络情况),地图刷新出来后页面自动刷新,看到页面获取到定位数据后,已同步保存到数据库。(若GPS未开启请点击控件开启定位功能。)
- 进入首页页面,查看对应的电量、位置等数据,因为数据写入是异步的,若未及时刷新可点击刷新数据控件获取最新数据。
- 操作五: 桌面服务卡片
- 在桌面长按应用图标,选择添加卡片。
- 在卡片上可查看数据,当前实现了点击对应控件或者定时刷新固定的数据,暂未与数据库同步。
3、测试说明
演示视频链接:领航员1号-智骑行
- 关于连接硬件:
- 运行APP,点击首页的临时车辆,输入Hi3861的IP地址,然后点击wifi按钮控件连接设备,若失败重启应用或者检查IP是否正确
- 点击开锁、响铃找车按钮,硬件会做出相应动作。【需要硬件配和,具体效果看演示视频】
- Hi3861设备默认连接的wifi信息如下
#define CONFIG_WIFI_SSID “r1” // 要连接的WiFi 热点账号
#define CONFIG_WIFI_PWD “88888889” // 要连接的WiFi 热点password
#define CONFIG_CLIENT_PORT 8888 // 要连接的服务器端口
- 关于获取定位信息
定位需要GPS模块,为了评委测试方便,保证评审期间硬件设备24h不关机,每次运行APP时,每1s至少可获取1次上报数据。由于地图开放平台限额地址逆编码5000次/日,故在H5中默认限制逆编码10次/运行,若不想频繁启动APP,可修改文件src/main/resources/rawfile/index.html如下四段的定义:
<script type="text/javascript">
var publish_topic="PilotWeb";
....
var getAddressCount = 4990 // 每次逆编码数值+1,到5000停止地址逆编码
....
</script>
4、技术架构
(1)APP功能框架
(2)UX/UI设计
从功能需求,设计应用交互。APP包含四个页面,其中三个主要交互页面在一个Tabs组件中,可点击底部的导航bar或者左右滑动切换展示的内容,通过点击TabContent(0)页面中定位控件触发Navigator导航到屏地图页面(WebPage.ets)。
APP主要页面布局(文中不展示具体页面布局代码,主要讲解UI信息与交互):
build() {
Column(){
Tabs({ barPosition: BarPosition.End, controller: this.controller }) {
TabContent() {...}
.tabBar(this.TabBuilder(0,'首页'))
TabContent() {...}
.tabBar(this.TabBuilder(1,'地图'))
TabContent() {...}
.tabBar(this.TabBuilder(2,'我的'))
}
.vertical(false)
.barHeight(100)
.onChange((index: number) => {
this.currentIndex = index
})
.width('100%')
.height('100%')
}
.height('100%')
.backgroundImage($r('app.media.background_lite'))
.backgroundImageSize({ width: '100%', height: '100%' })
}
为了便于区分当前的Tabs的TabContent,自定义一个Tab bar,选中时显示不同图标与文字效果。
@Builder TabBuilder(index: number ,name:string) {
Column() {
Image(this.currentIndex === index ? $r("app.media.bar_on") : $r("app.media.bar_off"))
.width(50)
.height(50)
.margin({ bottom: 8 })
.objectFit(ImageFit.Contain)
Text(name)
.fontColor(this.currentIndex === index ? this.selectedFontColor : this.fontColor)
.fontSize(this.my_font_size)
.lineHeight(this.my_font_size+2)
}.width('100%')
}
应用首页(Index.ets)
启动应用后进入TabContent(0)设备主页,这里展示用户常用的功能和信息。
- UI信息:显示实时电量(同步数据库)、开关锁、响铃找车等控件。
- UX交互:
- 开关锁控件,点击触发this.tcpSend()、this.webController.runJavaScript(‘RingOff()’)或者this.webController.runJavaScript(‘RingOn()’)函数(具体实现后文讲解),设备在线时可实现消息通信(实测见第三章-功能演示);
- 响铃找车控件,点击触发this.tcpSend()、this.webController.runJavaScript(‘RingOn()’)、this.webController.runJavaScript(‘RingOff()’)函数
- wifi连接控件,显示设备近场连接状态(socket),点击触发执行this.tcpConnect() 或者this.tcpSend()函数,实现近场通信。
- 位置信息控件,点击触发Navigator导航到大屏地图页面,查看可视化定位数据
- 累计骑行、预计续航控件展示里程数据,数据根据数据库中的电量来估算。
- 点击刷新数据控件,获取数据库中最新的数据,并展示到对应控件。
- 临时车辆控件,用于连接临时车辆,支持TCP Socket通信的车辆都可以连接。点击时触发执行this.dialogController.open(),打开自定义的对话框,设置目标IP,随连随用,IP不做持久化存储。
地图页面(Index.ets)
点击底部bar或者再右滑动到TabContent(1)可切换到车辆可视化定位页面,包含带标签的地图和定位开关。
- UI信息:可视化车辆位置、定位开关。
- UX交互:
- 点击开启定位按钮,触发执行订阅位置信息函数this.webController.runJavaScript(‘subscribeGPS()’)
- 点击关闭定位按钮,触发执行订阅位置信息函数this.webController.runJavaScript(‘unsubscribeGPS()’)
- 文本显示:车辆实时地址详情
用户个人页面(Index.ets)
点击底部bar或者再右滑动到TabContent(2)可切换到用户设置页面,可查看、设置车辆的基本信息。
- UI信息:车辆拥有者信息、车辆固定IP、序列号、固件版本号。
- UX交互:IP输入控件,可输入车辆IP并支持持久化保存;
大屏地图页面(WebPage.ets)
通过点击TabContent(0)页面中定位控件触发Navigator导航到大屏地图页面(WebPage.ets),该页面会加载本地web,完成地图加载、设备上报数据的获取。
- UI信息:车辆的地理位置,每1min自动刷新。
- UX交互:支持缩放地图;
服务卡片
- UI信息:车辆的地理位置,每1min自动刷新。
- UX交互:当前只实现了点击对应控件或者定时刷新固定的数据,暂未与数据库同步。
#星计划# 丸骑行-OpenHarmony骑行助手-鸿蒙开发者社区
(3)各功能实现
数据管理与通信连接
数据管理
为方便使用和管理数据,使用KvStore进行管理,在src/main/ets/model/KvStoreModel.ts创建了数据模板,提供KvStoreModel.createKvStore() KvStoreModel.get() KvStoreModel.put() 接口用于创建获取数据库数据。
例如在Index.ets中,页面加载时先初始化。
import common from '@ohos.app.ability.common'
import { KvStoreModel } from '../model/KvStoreModel'
let kvStoreModel: KvStoreModel = new KvStoreModel()
aboutToAppear() {
let context = getContext(this) as common.UIAbilityContext
// 获取数据库对象
kvStoreModel.createKvStore(globalThis.context,(value)=>{
console.info('KVStore:kvStoreModel.createKvStore Callback'+value)
})
...
}
后续根据业务需求进行存取,如在点击刷数据按钮时,获取传入的数据。
// 手动刷新数据(位置+电量+续航估算)
Column() {
...
Text("刷新数据")
.fontSize(this.my_font_size)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor("black")
.maxLines(this.MAX_LINES)
.height("40%")
}.width('48%').height("100%")
.backgroundColor("#FFFFFF")
.borderRadius(15)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.onClick(()=>
{
kvStoreModel.get(Const.PILOT_POWER_KEY,(value)=>
{
this.bike_power = value
})
kvStoreModel.get(Const.PILOT_LOCATION_KEY,(value)=>
{
this.bike_location = value
})
}
在src/main/ets/common/Constant.ts定义了常用的KEY。
// KvStore存放车辆电量
static readonly PILOT_POWER_KEY: string = 'PILOT_POWER';
// KvStore存放车辆位置
static readonly PILOT_LOCATION_KEY:string = 'PILOT_LOCATION';
// KvStore存放车辆固定IP
static readonly PILOT_IP_KEY:string= 'PILOT_IP';
// KvStore存放车辆满电续航,默认520+999 = 1519Km,用户可根据经验设定
static readonly PILOT_MAX_DURATION_KEY:string= 'PILOT_DURATION';
通信连接
智骑行APP支持TCP Socket、MQTT通信,用于与硬件交互数据。
Socket通信: 近场连接车辆,目前可获取车辆电量。
具体实现流程:
创建一个TCPSocket对象-->提供连接-发送-接收的接口-->根据设定的IP地址连接目标-->发送/接收数据
import socket from '@ohos.net.socket'
// 创建一个TCPSocket连接,返回一个TCPSocket对象。
let tcp = socket.constructTCPSocketInstance();
tcpInit() {
// 订阅TCPSocket相关的订阅事件
tcp.on('message', value => {
console.log("tcp on message")
let buffer = value.message
let dataView = new DataView(buffer)
let str = ""
for (let i = 0; i < dataView.byteLength; ++i) {
str += String.fromCharCode(dataView.getUint8(i))
}
//接收到车辆一帧数据
this.recv_rider_msg = str
//刷新电量
this.bike_power = this.recv_rider_msg.substring(19,21).toString()
console.log("tcp on connect received:" + str)
// 电量做持久化保存
kvStoreModel.put(Const.PILOT_POWER_KEY,this.bike_power)
});
}
tcpSend() {
tcp.getState().then((data) => {
if (data.isConnected) {
//发送消息
tcp.send(
{ data: this.message_send, }
).then(() => {
promptAction.showToast({message:"send message successful"})
}).catch((error) => {
promptAction.showToast({message:"send failed"})
})
} else {
promptAction.showToast({message:"tcp not connect"})
}
})
}
这里需要说明, 用户可在我的页面设定设备IP地址,可以持久化保存.若需要临时连接一台公共车辆或者调试时可以点击临时用车进行连接. 临时用车通过自定义的Dialog连接,IP地址通过Link变量获取到。
@CustomDialog
struct CustomDialogSetIP{
@State inputValue: string = ''
@Link InputIP: string // 获取的IP
controller: CustomDialogController
cancel: () => void
confirm: () => void
....
TextInput({ placeholder: '不做存储,随连随用192.168.43.164'}).width('85%').height('70%').fontSize(30)
.placeholderColor("rgb(0,0,225)")
.placeholderFont({ size: 16, weight: 100, family: 'cursive', style: FontStyle.Italic })
.onChange((value: string) => {
this.inputValue = value
})
....
}
**MQTT通信:**远程连接车辆,获取电量-位置信息。
实现流程:
①用户ArkUI页面,消息通信<-->②APP本地web页面,发布或者订阅消息<-->③云端服务器<-->④设备发布或者订阅消息; // 数据通道是双向的
用户ArkUI页面,消息通信<–>②APP本地web页面的消息通信。
首先,在启动app时,要在Index.ets的aboutToAppear()中创建一个和H5页面通信的消息通道,实现如下:
// 注册与H5通信的通道接口与回调
try {
// 1、创建两个消息端口。
this.ports = this.webController.createWebMessagePorts();
// 2、在应用侧的消息端口(如端口1)上注册回调事件。
this.ports[1].onMessageEvent((result: web_view.WebMessage) => {
let msg = 'Got msg from HTML:';
if (typeof(result) === 'string') {
console.info(`received string message from html5, string is: ${result}`);
msg = result;
} else if (typeof(result) === 'object') {
if (result instanceof ArrayBuffer) {
console.info(`received arraybuffer from html5, length is: ${result.byteLength}`);
msg = msg + 'lenght is ' + result.byteLength;
} else {
console.info('not support');
}
} else {
console.info('not support');
}
this.receivedFromHtml = msg;
console.info('Callback when the first button is clicked')
kvStoreModel.put('APP','Pilot')
kvStoreModel.put(Const.PILOT_POWER_KEY,this.receivedFromHtml.substring(0,2)) //电量
kvStoreModel.put(Const.PILOT_LOCATION_KEY,this.receivedFromHtml.substring(2,8)) //位置
this.bike_power = this.receivedFromHtml.substring(0,2)
this.bike_location = this.receivedFromHtml.substring(2,8)
kvStoreModel.get(Const.PILOT_POWER_KEY,(value)=>
{
this.bike_power = value
})
kvStoreModel.get(Const.PILOT_LOCATION_KEY,(value)=>
{
this.bike_location = value
})
})
// 3、将另一个消息端口(如端口0)发送到HTML侧,由HTML侧保存并使用。
this.webController.postMessage('__init_port__', [this.ports[0]], '*');
} catch (error) {
console.error(`ErrorCode: ${error.code}, Message: ${error.message}`);
}
其次,需要在本地H5 src/main/resources/rawfile/index.html 中创建一个用于接收的监听端口,具体实现如下:
// 页面
var h5Port;
var output = document.querySelector('.output');
window.addEventListener('message', function (event) {
if (event.data === '__init_port__') {
if (event.ports[0] !== null) {
h5Port = event.ports[0]; // 1. 保存从ets侧发送过来的端口
h5Port.onmessage = function (event) {
// 2. 接收ets侧发送过来的消息.
var msg = 'Got message from ets:';
var result = event.data;
if (typeof(result) === 'string') {
console.info(`received string message from html5, string is: ${result}`);
msg = result;
} else if (typeof(result) === 'object') {
if (result instanceof ArrayBuffer) {
console.info(`received arraybuffer from html5, length is: ${result.byteLength}`);
msg = msg + 'lenght is ' + result.byteLength;
} else {
console.info('not support');
}
} else {
console.info('not support');
}
// this.PositionName = msg.toString();
// document.getElementById("getMsg").innerText = msg;
send(msg.toString(),"PilotWeb"); //将收到的数据通过mqtt发送到设备。ets可直接调用H5函数,该接口备用
}
}
}
})
也可以直接调用H5的runJavaScript,通过H5中的函数接口发送数据到MQTT服务器. 如响铃找车按钮,使用this.webController.runJavaScript()即可调用H5中的RingOff()函数.比消息发送更便捷。
// 响铃找车
Column()
{
if(this.ring_icon_flag)
{
Image($r("app.media.ic_ring_on_filled"))
.onClick(() => {
...
// 调用H5函数,发送关闭响铃的mqtt数据到设备
this.webController.runJavaScript('RingOff()');
})
}
}
APP本地web页面,发布或者订阅消息<–>③云端服务器。
在本地H5中直接实现一个MQTT实例,实现数据的交互
const options={
connectTimeout:4000,
keepalice:20,
clean:true,
clientId:'mqttjsks',
username:'hellokun',
password:'123456',
}
const client=mqtt.connect('ws://MQTT服务器的ip地址:8083/mqtt',options)
client.on('reconnect', (error) => {
// document.getElementById("status").innerText = '正在重连';
console.log('正在重连:', error)
})
client.on('error',(error)=>{
// document.getElementById("status").innerText='Faild';
console.log('connect faild:',error)
})
发送数据到设备:
前面提到响铃找车按钮触发 this.webController.runJavaScript(‘RingOff()’);在H5中具体实现如下:
// 车辆关铃
function RingOff()
{
this.send('ring_off',"PilotWeb"); // 通过MQTT发送数据
}
接收来自设备端的数据:
通信方向与发送时相反,本地的H5可以通过与ets建立的消息通道,直接发送数据到用户页面,这个通道也可以用来接收H5发送回来的数据.
// 使用h5Port往ets侧发送消息.
function PostMsgToEts(data) {
console.info('H5 to Ets data:'+data);
if (h5Port) {
h5Port.postMessage(data);
} else {
console.error('h5Port is null, Please initialize first');
}
}
// 调用接口发送数据到ets用户页面,便于存储和展示
this.PostMsgToEts(PilotPower.toString()+PositionName); // 电量+位置
可视化定位
通过MQTT服务器获取到车辆的GPS坐标,接下来使用高德地图开放平台的JS API进行地图标点和逆编码,实现用户在地图上查看车辆的具体位置信息.
只需要使用Web组件,即可加载H5页面到用户页面中,
// Web component loading H5.
Web({ src: $rawfile('index.html'), controller: this.webController })
在地图页面中,开关/定位功能是直接调用MQTT的订阅/取消订阅接口。
//订阅话题
function subscribe() {
if (client.connected) {
client.subscribe(this.subscribe_topic);
// document.getElementById("status").innerText = '开始订阅';
}
}
//取消订阅话题
function unsubscribe() {
if (client.connected) {
client.unsubscribe(this.subscribe_topic, (error) => {
console.log(error || '取消订阅')
// document.getElementById("status").innerText = '取消订阅';
})
}
}
开关锁/响铃找车
通信连接一节讲解的通信接口可实现点击开关锁控件,触发x下列函数,设备在线时可实现消息通信(实测见演示视频)。
this.tcpSend()
this.webController.runJavaScript('RingOff()')
this.webController.runJavaScript('RingOn()')
this.webController.runJavaScript('Lock()')
this.webController.runJavaScript('UnLock()')
里程数据
里程数据根据电量和用户设定的满电续航数据计算.获取到电量数据后,自动计算,计算方式为:
剩余电量/总电量 = 预计续航/用户设定最大续航
this.max_duration = value
// 使用电量预估续航,公式: 剩余电量/总电量 = 预计续航/用户设定最大续航 数值取整
this.bike_duration = (parseInt(this.max_duration)*parseInt(this.bike_power)/100).toFixed(0)
// 累计骑行 = 最大续航-预计续航
this.bike_distance = (parseInt(this.max_duration) - parseInt(this.bike_duration)).toFixed(0)
桌面服务卡片
创建一张2*4尺寸的桌面服务卡片,目前可展示里程数据和电量信息.支持定时30min自动刷新或者用户触发控件刷新数据. 刷新的数据目前还未与数据库同步。
服务卡片刷新数据的方式有如图所示几种,智骑行APP中使用了message 和call刷新数据,使用router拉起应用。
卡片使用message与FormExtensionAbility交互介绍: 在服务卡片的获取定位控件中,添加点击事件,发起postCardAction message。
// 获取定位
Column() {
Image($r("app.media.ic_statusbar_gps"))
...
Text(this.location)
...
}
.onClick(() => {
console.info('KVStore postCardAction(this')
postCardAction(this, {
'action': 'message',
'params': {
'msgTest': 'messageEvent'
}
});
})
}
在FormExtensionAbility的onFormEvent生命周期中调用updateForm接口刷新卡片。
onUpdateForm(formId) {
// 每30min自动刷新一次
let formData = {
'power': 'power', // 和卡片布局中-电量对应
'location': 'location', // 和卡片布局中-位置对应
'distance': 'distance', // 和卡片布局中-里程对应
'duration': 'duration', // 和卡片布局中-预计续航对应
'beep': 'beep.', // 和卡片布局中-响铃找车对应
'lock': 'lock', // 和卡片布局中-开锁对应
};
let formInfo = formBindingData.createFormBindingData(formData)
formProvider.updateForm(formId, formInfo).then((data) => {
console.info('FormAbility updateForm success.' + JSON.stringify(data));
}).catch((error) => {
console.error('FormAbility updateForm failed: ' + JSON.stringify(error));
})
}
在卡片页面通过注册里程数据的onClick点击事件回调,并在回调中调用postCardAction接口触发router事件至EntryAbility。
// 里程数据
Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceAround }) {
Column() .width('47%').height("100%")
...
.onClick(() => {
....
postCardAction(this, {
'action': 'router',
'abilityName': 'EntryAbility', // 只能跳转到当前应用下的UIAbility
'params': {
'detail': 'RouterFromCard'
}
});
})
在卡片页面通过注册车辆图标的onClick点击事件回调,并在回调中调用postCardAction接口触发call事件至UIAbility。
// 卡片中车辆图标
.onClick(()=>
{
postCardAction(this, {
'action': 'call',
'bundleName': 'com.example.obike',
'abilityName': 'EntryAbility', // 只能拉起当前应用下的UIAbility
'params': {
'method': 'funA',
'formId': this.formId,
'detail': 'CallFromCard'
}
});
})
车辆硬件开发
基于OpenHarmony开发电动车的控制系统,主控芯片为Hi3861。近距离时可通过TCP连接APP,远程可通过连接GPS+4G模块实现通信。
具体实现:Hi3861通过串口发送指令到GPS+4G模块获取定位信息;通过ADC采集电池电量;通过4G模块发送到云服务器;结合前文所述通信连接,APP从应用内web端口获取数据。
Hi3861主要任务代码如下:
while (1) {
memset_s(recvbuf, sizeof(recvbuf), 0, sizeof(recvbuf));
if ((ret = recv(new_fd, recvbuf, sizeof(recvbuf), 0)) == -1) {
printf("recv error \r\n");
}
printf("recv :%s\r\n", recvbuf);
if(!strncmp(recvbuf,UNLOCK,6))
{
IoTGpioSetOutputVal(LockCtr_GPIO, 0);
sleep(TASK_DELAY_1S);
IoTGpioSetOutputVal(LockCtr_GPIO, 1);
}
if(!strncmp(recvbuf,LOCK,5))
{
IoTGpioSetOutputVal(LockCtr_GPIO, 1);
sleep(TASK_DELAY_1S);
IoTGpioSetOutputVal(LockCtr_GPIO, 0);
}
if(!strncmp(recvbuf,RING_ON,7))
{
IoTGpioSetOutputVal(LockCtr_GPIO, 1);
}
if(!strncmp(recvbuf,RING_OFF,8))
{
IoTGpioSetOutputVal(LockCtr_GPIO, 0);
}
// sleep(TASK_DELAY_1S);
osDelay(10); // 100ms
if ((ret = send(new_fd, buf, strlen(buf) + 1, 0)) == -1) {
perror("send : ");
}
// sleep(TASK_DELAY_1S);
}
5、展望
**作品商业价值:**万物互联时代,电动车智能化是趋势,团队基于OpenHarmony开发的智骑行方案,拥有服务卡片、定位、找车等功能,成本低易用性强。
作品进一步优化计划:
- B12版本:实现服务卡片数据库同步;实现BLE通信,无需网络,近场自动连接;实现"人离车锁,人来车开"功能;连接真实电动车;
- B241版本:实现导航功能、轨迹回放、截图分享、历史数据查看
- B242版本: 支持圆屏幕lite设备