{宇}柯南同款, 全自研高速电动滑板开源! STM32项目|宇的DIY#1

{宇}柯南同款, 全自研高速电动滑板开源! STM32项目|宇的DIY#1

开源文档原文https://shouchenyu.feishu.cn/wiki/PxnSwaRRRixkaokr8oBcpYotnyb

B站视频项目介绍链接https://www.bilibili.com/video/BV1cr2sBsEsF/

立创开源硬件平台:https://oshwhub.com/shouchenyu/conan-with-the-full-self-develop

现已开始更新电动滑板各个模块详细教学,移步守辰宇B站主页即可查看最新视频!

立创开源硬件平台官方公众号推文:https://mp.weixin.qq.com/s/RsQGZqsOtaHpAqjfm9CHLQ


项目简介

该项目制作了一个无线控制的电动滑板,载人工况下最大时速可达40km/h,续航25km左右。该项目开源资料中包括滑板的硬件电路设计、软件代码设计、机械结构工程。

整个系统主要分为两个部分:滑板、遥控器。滑板在使用时,通过遥控器控制滑板的动力,转向通过人在滑板上的重心转移实现,与普通滑板原理一样。遥控器上有显示屏可以查看当前滑板状态、电池电压、温度、速度、累计里程与运行时间。通过摇杆前后推移实现加速和刹车的控制,两个按钮靠近摇杆的一个可以切换动力模式,另一个预留。

滑板的主控板为电调板。电调板实现了滑板的全部控制,包括:无刷电机驱动、转速采集、遥控通信、电流电压温度采样等。在滑板中有一块分电板,用于便捷的更换保险丝、断电、充电。电调板支持24V-42V宽电压输入范围,仅需在代码中修改低电量判断参数即可,最大稳定驱动电流12A。

滑板和遥控器的软件功能较多。主要实现的了滑板与遥控之间的可靠双向通信,同时代码结构便于自行增加新功能。代码均未使用rtos,嵌入式软件基础较差的同学也能够理解代码逻辑。

滑板机械结构主要使用碳纤维板cnc加工后作为滑板主要框架,并作为防水防尘的外壳。内部电气设备使用3D打印件进行固定。遥控器使用3D打印外壳进行简单固定。

一、硬件

滑板的硬件分为3块PCB,电调板(滑板的主控板)、遥控板(用于控制滑板运行)、分电板(固定滑板电源总开关、充电接口、保险丝)。

硬件部分内容不在此处介绍,大家可以移步文章开头我的开源文档或立创开源硬件平台

二、软件

电调板与遥控板的软件功能如下,重点以电调板为例介绍功能,遥控板具有的功能基本均由电调板代码直接移植得到,基本不需要单独介绍。

两块板均使用常见的stm32f103c8t6芯片编写代码。没有使用freertos,通过手动的时间管理与DMA等硬件配置,实现多任务的执行不阻塞。

电机控制

电机控制的核心是使用三个半桥芯片控制六个mos管实现六步换向法控制三相无刷电机。其中当电机处于滑行状态时,需要使用弱场控制方法防止电机发电产生制动力。

六步换向法控制电机旋转

滑板使用有感三相无刷电机,需要逆变器输出三相交流电控制电机旋转。现在使用三个IR2104半桥芯片控制六个MOS管实现逆变。IR2104芯片只需要一路PWM输入即可自动控制上下桥臂导通与关断,并自动实现死区防止上下桥臂同时导通,方便软件控制。软件通过读取电机的霍尔信号判断当前转子角度,然后控制PWM输出实现电机旋转到下一个角度,并在此时可以控制旋转方向。

目前代码中没有写倒车代码,仅支持向前行驶不支持向后行驶。如需切换行驶方向,请自行增加该功能。

电机动力输入控制

电机的转速通过一个-100~100的变量控制。控制变量从心跳包中得到,然后在滤波器函数前映射到当前定时器的ARR范围。

// 一阶低通滤波器 // 用于速度控制 #define FILTER_ALPHA 0.0001f // 一阶低通滤波系数 float Motor_filter_prev = 0; uint8_t isCoasting = 0; // 当前正在滑行标志位 static float setSpeed_filter(float input) { input = input * ((MOTOR_ARR + 1) / 100); // 检测符号变化(正变负或负变正) static float last_input = 0.0f; int sign_change = 0; // 判断是否发生符号变化 if ((last_input >= 0.0f && input < 0.0f) || (last_input <= 0.0f && input > 0.0f)) { sign_change = 1; } last_input = input; // 如果检测到符号变化,重置滤波器状态 if (sign_change) { Motor_filter_prev = 0.0f; } float output = 0; // 如果收油门 立刻终止加速 if (!sign_change && input < Motor_filter_prev && output > input) { // 如果油门为正 当前输入小于上次一输出(杆量在下降) 本次输出大于当前杆量 则加大滤波系数使输出尽快达到杆量 output = (FILTER_ALPHA * 100) * input + (1 - (FILTER_ALPHA * 100)) * Motor_filter_prev; } else { output = FILTER_ALPHA * input + (1 - FILTER_ALPHA) * Motor_filter_prev; } // 如果当前正在滑行 则加大滤波系数使输出尽快达到杆量 防止滑行时增加杆量不跟手问题 if (isCoasting == 1) { output = (FILTER_ALPHA * 100) * input + (1 - (FILTER_ALPHA * 100)) * Motor_filter_prev; } // 当杆量为0时 杆量快速归零 用于滑行 if (input == 0) { output = (FILTER_ALPHA * 10) * input + (1 - (FILTER_ALPHA * 10)) * Motor_filter_prev; } Motor_filter_prev = output; return output; }

需要注意的是,低通滤波器在基础滤波功能的基础上,增加了一个当输入变量正负值改变时,重置filter_prev以达到在刹车和加速之间及时切换。收油门时,尽快停止加速,防止由于滤波器的存在,导致从加速到滑行的过程不跟手,当杆量减小时仍在加速。在滑行时,让杆量变化加快,以便在滑行切换到继续加速的过程更加跟手,此时使用弱场控制方法滑行。当杆量为0时,进入mos全关断滑行状态,节省能源。

在遥控板上,速度控制是一个0-200的数,小于100即减速,大于100加速。所以遥控板的速度值需要转换后再给到电调板使用,在电调板的解包函数中进行了转换。另外,如果需要限制动力,建议在遥控板上实现,这样可以方便的切换各个不同动力模式,无需再专门使用一个协议控制电调板。

弱场控制

当电机滑行时,由于电机发电,会有较大的刹车加速度,会导致当油门减小时,突然不平顺的减速。具体原因是由于当前mos管有输入较小的占空比,导致电机滑行发电时,电流会形成回路,从而在电机绕组中产生电流,形成制动力。为了解决这个问题,于是引入弱场控制。

需要注意,如果直接将所有mos管关断,确实能通过阻断发电电流从而解决滑行发电刹车的问题,但是在全关断与输出动力的切换时,电机输出动力不平顺且有异响。直接关断所有mos还有一个问题,当油门重新开始加大,但是又还没有到当前速度的油门大小时,也会有较大的发电刹车,所以必须使用弱场控制来实现电机滑行。

弱场控制是通过检测当前电机处于滑行状态时,主动施加动力,目的是输出电平去抵消滑行发电产生的电动势,从而解决滑行发电的制动力问题。

计算方法如下:

// 计算Ke 弱场控制 计算值为:0.147333 #define K_rpm_to_rads (2.0f * 3.1415926535f) / 60.0f; float Motor_calculate_Ke(uint8_t input_Duty) { float Duty_min_hold = (float) input_Duty / 100.0f; float V_bus = adc_calculate_battery_voltage(); float RPM_min_hold = Hall_rollSpeed * 60; float omega_mech = RPM_min_hold * K_rpm_to_rads; // 计算机械角速度(rad/s) float Ke = (V_bus * Duty_min_hold) / omega_mech; return Ke; // 单位 V·s/rad } // 计算最小滑行占空比 弱场控制 #define KE 0.130f #define Delta_V 0.1f uint8_t calculate_min_slip_duty(void) { float RPM = Hall_rollSpeed * 60; float V_bus = adc_calculate_battery_voltage(); float BEMF = KE * RPM * K_rpm_to_rads; float Duty_min_slip = (BEMF + Delta_V) / V_bus; // 限制范围 if (Duty_min_slip < 0.0f) Duty_min_slip = 0.0f; if (Duty_min_slip > 1.0f) Duty_min_slip = 1.0f; float ccr_value = Duty_min_slip * 100.0f; // 四舍五入并限制在0-99范围内 uint8_t ccr = (uint8_t)(ccr_value + 0.5f); // 四舍五入 if (ccr > 99) ccr = 99; return ccr; }

注意Ke和Delta_V需要根据实际情况调整。这两个函数的使用方法为:先通过串口获取Motor_calculate_Ke的返回值,然后当电机以不同速度空载匀速旋转时,记录Ke,然后取多个值进行平均计算。然后在滑行时,使用calculate_min_slip_duty这个函数得到的占空比,然后观察电机现象,如果当滑行时,电机继续加速,那么就需要减小Ke,反之增大。至于Delta_V,这个值较小,对实际效果的影响不太大,可以先从0.1开始缓慢增大观察现象。如果在滑行时发现电机越转越快,可以减小Delta_V。具体情况需要具体测试。

建议当杆量不为0的滑行状态使用弱场控制,当杆量为0时切换为mos全关断滑行。如果杆量不为0时也使用全关断滑行,可能会导致系统频繁在全关断与弱场控制之间切换,导致电机不平顺且有异响。当杆量为0时,基本代表长时间滑行,此时使用全关断可以节省能源,同时降低电机噪音。杆量在0和非0直接切换时,有滤波器进行平滑处理,不存在平凡在0和非0直接切换。

制动控制

实现刹车有两种方法:在电机旋转时施加反向的动力实现减速;利用滑行发电的制动力进行制动。我已经尝试了两种方法,利用滑行发电制动的效果较好,从加速到减速的过程较为平缓,且制动力完全足够,也能节省能源。

如果采用施加反向动力实现减速的方法,需要注意防止当电机成功停下后,由于继续输出反向动力,导致电机反转。需要判断如果电机已经停止或者旋转方向变化,需要切断刹车动力。

利用滑行发电的制动力进行制动效果较好且实现方法较简单,不需要考虑电机反向转动的问题。当进行刹车时,只需要将输出占空比从calculate_min_slip_duty开始减小即可。

目前只有正向行驶的刹车效果是正确的,如果滑板正在反向滑行,此时刹车力度会很大。因为刹车计算方式是按照正转进行计算的,在倒车时刹车力度计算输出超过安全范围,会被限制在最大值。所以倒车时刹车力度无法控制且刹车力度是最大值。在使用时需要小心。

霍尔信号处理

需要注意,霍尔信号线布线时应当尽量远离电机功率网络,防止信号被干扰产生大量杂波,然后使得软件中不断发起无意义的外部中断,速度计算出现错误。由于电机转速控制也需要根据速度信号得到,所以可能会导致电机速度控制不符合预期,可能会产生危险。

霍尔信号采集使用外部中断的方式,防止不断轮询导致时间浪费。该中断优先级应该尽量高,因为控制电机旋转必须霍尔信号,如果信号错误会导致电机运行错误,非常危险。

实测电机转动一圈发起60次霍尔中断,可以据此计算转速,也可以得到行驶里程。建议使用中断counter进行速度与里程计算,以确保里程的精度。在保存累计里程时,由于flash中存储的数据只包含整数不包含小数,但是如果每次都直接将小数丢弃,会导致误差不断累积,所以现在每次结算后,将多余的小数保存,累加到下一次结算里程中。

#define Wheel_diameter 90.0f //轮子直径 单位mm #define Speed_Time 0.1f //秒 // 转速计算 void Get_rollSpeed(void) { float Speed = 0; float count = Hall_Count; Speed = (count / 60.0f) / Speed_Time; //转一圈Hall_RotationCount增加60次。(圈/秒) if (Hall_Direction != 0) { Speed = Speed * Hall_Direction; } Hall_rollSpeed = RPM_filter(Speed); // 更新里程累计 static uint16_t mileage_count = 0; mileage_count += count; if (mileage_count >= 213) // 轮子周长 = 0.282743m, 一个count = 0.004712m, 212.2个count走1m { static float decimal = 0; // 累计每次计算留下的小数 在下次结算时加上 提高里程累计精度 float meter = 0.004712f * mileage_count + decimal; uint8_t add_meter = (uint8_t) meter; decimal = meter - add_meter; Store_Mileage(add_meter); mileage_count = 0; } }

遥控模块配置

目前的工程在上电时初始化遥控模块参数,但是可能由于遥控模块上电启动速度比MCU慢,导致模块参数初始化有可能失败,非常不稳定,故使用一个特殊固件专门用于配置模块参数。新的模块只需要将特殊固件下载后,复位MCU,等待代码运行10s左右即可,然后再下载正常运行固件即可正常使用。

在正常固件中,不执行遥控模块配置的操作,如果发现遥控模块无法通信,需要下载特殊固件重新配置遥控模块。遥控模块使用前需要配置信道与空中速率等,在上述提到的特殊固件代码中可以对照遥控模块使用手册详细查看具体配置信息,也可以使用厂家的上位机进行配置。收发功率可以尽可能大,空中速率可以在装机后实测信号大小,如果信号强度不够总是丢包,可以降低空中速率提高信号质量。

使用通信模块时需要注意信道占用问题。需要合理控制发包频率,如果一个模块不断高速发包,会导致信道长时间被占用,另一个模块就无法发送数据。

更详细的遥控模块使用手册请找淘宝商家获取。遥控模块型号:火蝠无线JC24B。

通信协议

通信协议数据包结构为:

typedef struct __attribute__((packed)){ //取消字节填充 解决字节对齐问题 uint8_t start_flag; //包头 uint32_t pack_number; //包序号 uint32_t timestamp; //发包时间 uint8_t cmd_id; //协议号 uint8_t data[]; // uint8_t end_flag; //包尾 // uint8_t crc8; //crc校验 } RC_pack_t;

在收到遥控模块传输的数据包后,将会进行解包操作,根据数据包中第一个0x5B作为包头,0x5D且其后第二位为0x00作为包尾,0x5D的后一位作为CRC8校验位。然后将对应协议号提取出来后,执行对应协议号的函数。

该解包与发包方法支持不定长数据,直接调用已有的函数进行发包即可,收到数据包后会自动开始解包,只需要注册对应协议号与执行函数即可。这种通信方式能够方便地管理多个命令,实现复杂的功能。同时不定长的数据包也可以节省遥控信道的带宽,防止由于空中速率不够导致丢包。

在数据包中,有包序号和发包时间这两个字段,可以通过这两个数据计算当前遥控通信的丢包率与延迟。计划在RC_unpack函数中进行计算,但是由于开发时间有限且丢包可以通过软件看门狗超时实现处理,所以目前并没有使用这两个字段。

解包函数与封包函数如下:

int8_t RC_unpack(const uint8_t *byteArray) { // 判断包头 if (byteArray[0] != 0x5B) { return -1; // 无效包或包头错误 } //查找包尾 size_t packet_size; uint8_t end_flag = 0; for (packet_size = 1; packet_size < SERIAL_3_RX_BUFFER_SIZE - 2; ++packet_size) { if (byteArray[packet_size] == 0x5D && byteArray[packet_size + 2] == 0x00) { packet_size += 2; end_flag = 1; break; } } if (!end_flag) { return -1; } // 提取数据包 uint8_t pack[SERIAL_3_RX_BUFFER_SIZE]; memcpy(pack, byteArray, packet_size); // crc校验 uint8_t crc8 = pack[packet_size - 1]; if (crc8 != Calculate_CRC8(pack, packet_size - 1)) { return -1; // CRC校验失败 } // 统计延迟与丢包率 // crc校验成功,进行解包操作 typedef union { //使用联合体提取数据包 避免动态分配内存 uint8_t raw[SERIAL_3_RX_BUFFER_SIZE]; RC_pack_t pack; } PackConverter; PackConverter converter; memcpy(converter.raw, pack, packet_size); // 判断当前NTP是否成功对时 uint32_t time = converter.pack.timestamp; // 误差小于100 判断为成功对时 if (((int64_t)NTP_time - (int64_t)time) < 100 && ((int64_t)NTP_time - (int64_t)time) > -100) { NTP_status = normal; } else { NTP_status = error; } switch (converter.pack.cmd_id) { case CMD_SPEED_CONTROL: { CMD_speed_control(converter.pack.data); return 0; } case CMD_SPEED_REPORT: { CMD_speed_report(); return 0; } case CMD_TIME_SYNCHRONOUS: { CMD_timeSynchronous(converter.pack.data); return 0; } case CMD_HEARTBEAT: { CMD_heartbeat(converter.pack.data); return 0; } default: return -2; } }
void RC_send(const uint8_t *data, uint8_t cmd_id, size_t data_size) { // 计算总包长度 = 包头(1) + pack_number(4) + timestamp(4) + cmd_id(1) + 数据(data_size) + 包尾(1) + CRC(1) const size_t header_size = sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint8_t); const size_t total_size = header_size + data_size + 2; // +2表示包尾和CRC // 创建发送缓冲区 uint8_t send_buf[SERIAL_3_TX_BUFFER_SIZE]; if(total_size > sizeof(send_buf)) { // 处理错误:数据过长 return; } // 填充包头部分 RC_pack_t *pack = (RC_pack_t *)send_buf; pack->start_flag = 0x5B; pack->pack_number = 0; // 需要实现包序号递增逻辑 pack->timestamp = APP_time; pack->cmd_id = cmd_id; // 填充数据部分 memcpy(send_buf + header_size, data, data_size); // 添加包尾 size_t end_pos = header_size + data_size; send_buf[end_pos] = 0x5D; // 计算CRC(包头+数据+包尾) uint8_t crc = Calculate_CRC8(send_buf, end_pos + 1); send_buf[end_pos + 1] = crc; // 发送完整数据包 Serial_3_SendArray(send_buf, total_size); }

在使用时虽然系统可以根据不同协议号执行对应操作,但是也需要确保如果执行指定协议的数据包丢包要如何进行保护。假设发送一个0x01协议的数据,但是该数据包被丢失,导致实际并没有执行该指令。那么这种情况的保护措施需要自行编写,在我的开源代码中没有实现该功能。

NTP时间同步

遥控与滑板首次连接或丢失连接再次连接时,会进行ntp时间同步,只有当时间已同步且延迟与丢包率正常时,才会开始正常控制功能,否则将一直执行时间同步操作直到信号良好。

注意NTP对时成功应当在解包前使用心跳包中的时间戳判断,因为如果使用一个专门协议判断,可能会导致该协议包丢包,导致系统错过该对时成功的信号。

NTP原理介绍

在NTP对时过程中,有4个时间戳:

T1: 客户端发送请求的时间(以客户端时钟为基准)。

T2: 服务器收到请求的时间(以服务器时钟为基准)。

T3: 服务器回复响应的时间(以服务器时钟为基准)。

T4: 客户端收到响应的时间(以客户端时钟为基准)。

这四个时间戳构成了计算的基础。

计算往返延迟: 延迟 = (T4 - T1) - (T3 - T2)

计算时间偏移(即客户端需要调整的量): 时间偏移 = [(T2 - T1) + (T3 - T4)] / 2

最终,客户端会将自己的时间调整 时间偏移 量,从而与服务器同步。

代码实现

// NTP时间同步 if (NTP_status == error) { //softWatchDog_feed(); Log_add(LOG_NTP_ERROR, "NTP_status error"); Motor_Control(0); while (NTP_status == error) { softWatchDog_loopCheck(); LED_Control(LED_ERROR, flicker); NTP_req = 1; if (Serial_3_RxFlag == 1) { Serial_3_RxFlag = 0; RC_unpack((uint8_t *)Serial_3_RxPacket); } if (NTP_req != 1) { // NTP对时 请求发送 NTPtime_t sendPack; sendPack.t1 = NTP_time; sendPack.t2 = 0; sendPack.t3 = 0; sendPack.t4 = 0; RC_send((uint8_t *)&sendPack, CMD_TIME_SYNCHRONOUS, sizeof(NTPtime_t)); NTP_req = 1; } writeFlash_loopcheck(); } LED_Control(LED_ERROR, off); uint8_t timestamp[4] = {0}; *((uint32_t*)timestamp) = NTP_time; RC_send(timestamp, CMD_TIME_MOTOR_RPO, sizeof(uint32_t)); Log_add(LOG_NTP_SUCCESS, "NTP_status success"); }
void CMD_timeSynchronous(uint8_t *pack) { if (NTP_status != normal) { NTPtime_t *NTPpack = (NTPtime_t*) pack; const int64_t t1 = (int64_t)NTPpack->t1; const int64_t t2 = (int64_t)NTPpack->t2; const int64_t t3 = (int64_t)NTPpack->t3; const int64_t t4 = (int64_t)NTP_time; const int32_t delay = (t4 - t1) - (t3 - t2); const int32_t offset = ((t2 - t1) + (t3 - t4)) / 2; NTPpack->t1 = NTP_time; RC_send((uint8_t *)NTPpack, CMD_TIME_SYNCHRONOUS, sizeof(NTPtime_t)); // 延迟在100ms以下才接受数据 if (delay < 150 && delay > 0) { static int64_t tempoffset[3]; static uint8_t tempcount = 0; if ((NTP_time + offset) > 0) { if (tempcount == 0 || offset != tempoffset[tempcount - 1]) { tempoffset[tempcount ++] = offset; } } if (tempcount >= 3) { // 找最大值和最小值 int64_t max_val = tempoffset[0]; int64_t min_val = tempoffset[0]; for (int i = 1; i < 3; ++i) { if (tempoffset[i] > max_val) max_val = tempoffset[i]; if (tempoffset[i] < min_val) min_val = tempoffset[i]; } // 判断差值是否小于50 满足则更新 NTP_time if ((max_val - min_val) < 50) { // 判断时间偏移量是否在合法范围 if (((tempoffset[2] + NTP_time) >= 0 || (uint32_t)(-tempoffset[2]) <= NTP_time) && NTP_status == error) { // 误差小于100 判断为成功对时 if ((((int64_t)NTP_time + tempoffset[2]) - (int64_t)NTPpack->t3) < 100 && (((int64_t)NTP_time + tempoffset[2]) - (int64_t)NTPpack->t3) > -50) { __disable_irq(); NTP_time += tempoffset[2]; __enable_irq(); NTP_status = normal; } } } tempcount = 0; memset(tempoffset, 0, sizeof(tempoffset)); } } } softWatchDog_feed(); }

系统时间

使用了一个1ms的定时器进行计时,实现系统时间的计算。很多需要定时执行的操作均在这里通过标志位的方式统一管理,实现定时器资源的节省,并且使用起来也更加方便。类似于hal库中的HAL_GetTick()函数管理时间以及执行频率的方法。

需要注意的是,在软件定时器中,必须使用标志位的方式执行相关内容,否则可能阻塞定时器导致软件时间错误,影响整个系统的功能与稳定。

注意区分APP_time与NTP_time之间的区别。APP_time自系统启动就开始走时,NTP_time是根据遥控的时间对时的联网时间。

软件看门狗

在系统时间中,实现了一个软件看门狗的功能。这个软件看门狗主要负责检查是否按时收到了心跳包,以便判断当前模块是否离线。

每次收到心跳包均会进行一次喂狗,收到其他包不喂狗,这是为了确保控制信号在被其他数据包阻塞后,可以正确判断为离线。

心跳包

遥控板与电调板均会以特定频率发送心跳包,当一段时间无法收到心跳则判断为离线,执行离线的操作,比如切断动力。滑板的动力控制以及采样数据回传均使用心跳包传输。

心跳包的结构如下:

typedef struct { float speed; // 速度 uint8_t status; // 状态 float battery; // 电量 float temperature; // 温度 } RC_heartbeat_t;

其中遥控下发的包中,speed为设定的速度。电调板上发的speed为当前电机的时速。

状态参数使用了一个uint8变量的每一位,表示各个系统的报错状态。具体每一位的作用可以看遥控板的心跳包解包函数与OLED更新函数中的内容。

ADC采样

在电调板中,使用了多个ADC通道,分别用于电池电压采样、NTC温度采样、电机电流采样、预留接口外接。其中电机电流采样数据并未使用。

ADC采用了DMA进行自动数据传输,无需浪费采样完成中断的时间。同时采样得到数据均会经过滤波器后使用,避免电机运行时电压波动以及信号串扰导致采样噪声过大。

Flash数据存储

使用flash存储模块运行数据。电调板存储了行驶里程数据、累计开机时间。遥控模块存储了累计开机时间。

当距离上次写入flash时间超过5min,系统进入准备写入flash模式,此时如果持续20s系统闲置,就会写入一次flash。系统闲置的判断方式为当前电机实际速度与遥控控制速度均为0并保持一段时间没有变化。

需要注意写入flash频率不应过高,因为flash寿命有限,而且在写入flash前需要擦除整页,而擦除整页花费的时间较多,所以会严重阻塞代码运行。基本上每次写入flash时遥控信号均会短暂断连,这也是一定要等待系统闲置后再写入flash的原因。

//Flash写入里程数据 使用数组区间:1-11 void Store_Mileage(uint16_t Plus_Meter) { //uint16_t最大65535,uint32_t最大4,294,967,295 uint16_t Meter = 0; uint16_t Kilometer = 0; uint16_t KKilometer = 0; // 存储 "Mi" -> 0x4D69 Store_Data[1] = ('M' << 8) | 'i'; // 存储 "le" -> 0x6C65 Store_Data[2] = ('l' << 8) | 'e'; // 存储 "ag" -> 0x6167 Store_Data[3] = ('a' << 8) | 'g'; // 存储 "e:" -> 0x653A Store_Data[4] = ('e' << 8) | ':'; Meter = Store_Data[5]; Store_Data[6] = ('m' << 8) | '\0'; Kilometer = Store_Data[7]; Store_Data[8] = ('k' << 8) | 'm'; KKilometer = Store_Data[9]; Store_Data[10] = ('k' << 8) | 'k'; Store_Data[11] = ('m' << 8) | '\0'; // --- 进位逻辑 --- uint32_t total_meters = (uint32_t)KKilometer * 1000000 + (uint32_t)Kilometer * 1000 + Meter; total_meters += Plus_Meter; // 重新计算并拆分 KKilometer = total_meters / 1000000; uint32_t remaining = total_meters % 1000000; Kilometer = remaining / 1000; Meter = remaining % 1000; Store_Data[5] = Meter; Store_Data[7] = Kilometer; Store_Data[9] = KKilometer; } void Store_Mileage_0(void) //清零总里程 { Store_Data[5] = 0; Store_Data[7] = 0; Store_Data[9] = 0; } //写入运行时间 使用数组区间:12-21 void Store_runTime(uint16_t Plus_Minutes) { Store_Data[12] = ('R' << 8) | 'u'; Store_Data[13] = ('n' << 8) | 'T'; Store_Data[14] = ('i' << 8) | 'm'; Store_Data[15] = ('e' << 8) | '\0'; // --- 读取旧的时间 --- uint16_t current_thousand_hours = Store_Data[16]; uint16_t current_hours = Store_Data[17]; Store_Data[18] = ('h' << 8) | '\0'; uint16_t current_minutes = Store_Data[19]; Store_Data[20] = ('m' << 8) | 'i'; Store_Data[21] = ('n' << 8) | '\0'; if (Plus_Minutes != 0) { // --- 将所有时间统一转换为“分钟”进行计算 --- // 1. 计算小时部分对应的总分钟数 uint32_t current_total_minutes = ((uint32_t)current_thousand_hours * 1000 + current_hours) * 60; // 2. 加上之前存储的分钟数 current_total_minutes += current_minutes; // --- 累加新的分钟数 --- uint32_t new_total_minutes = current_total_minutes + Plus_Minutes; // --- 将总分钟数转换回“千小时, 小时, 分钟”的格式 --- uint16_t new_thousand_hours = new_total_minutes / (1000 * 60); uint16_t remaining_minutes_after_thousand = new_total_minutes % (1000 * 60); uint16_t new_hours = remaining_minutes_after_thousand / 60; uint16_t new_minutes = remaining_minutes_after_thousand % 60; // --- 将新时间存回数组 --- Store_Data[16] = new_thousand_hours; Store_Data[17] = new_hours; Store_Data[19] = new_minutes; // 存储的是0-59的分钟数 } }

串口通信

在系统中使用了两个串口,且均使用DMA自动收发数据,可以极大减少程序阻塞的风险。因为电机转速较高,需要高频处理霍尔产生的外部中断并控制电机。如果在串口与遥控模块通信过程中消耗过多时间,会导致电机运行速度变高时出现丢步的现象。

串口的收发均使用了队列实现异步传输。防止由于在某一时间点突然大量串口发送请求导致程序阻塞的问题。接收队列不是必须的,但是发送队列必须实现。可以通过在.h文件中修改宏定义便捷的修改队列大小。由于我在代码中人为控制了串口发送频率,所以发送队列仅给了10位,且并未添加队列溢出标志位。如果在使用中发现串口数据丢失严重,可以尝试添加队列溢出标志位查看是否队列溢出导致的。

#ifndef __SERIAL_H_ #define __SERIAL_H_ #include <stdio.h> #include <stdarg.h> #define SERIAL_TX_BUFFER_SIZE 200 #define SERIAL_RX_BUFFER_SIZE 200 #define SERIAL_TX_QUEUE_SIZE 10 // 发送队列大小 最多缓存10个数据 extern char Serial_RxPacket[]; extern uint8_t Serial_RxFlag; void Serial_Init(void); void Serial_SendByte(uint8_t Byte); void Serial_SendArray(uint8_t *Array,uint16_t Length); void Serial_SendString(char*String); void Serial_SendNumber(uint32_t Number,uint8_t Length); void Serial_Printf(char *format, ...); #endif

另外遥控模块串口波特率不能设为最高的38400,只能设为19200,因为若波特率太高,DMA中断时,会将前几位数据丢失,数据包不完整,所以波特率不能过高。该问题理论上可以通过在中断标志位切换时增加us级delay解决,但是目前该问题并不会影响功能,故不解决。

需要注意的是,遥控模块有一个AUX引脚是用于判断模块是否空闲的,本来准备在串口发送时判断遥控模块是否空闲,若被占用,发送的数据也进入队列。但是经过实验,这样判断遥控模块是否空闲会使丢包更加严重,怀疑是遥控模块接收数据与发送数据时,均会在占用状态,但是其实模块接收数据时,是可以通过串口发送数据的,只是数据在遥控模块中会进入等待发送队列,这样就可以提高MCU串口传输效率,不需要在MCU的串口队列中等待,所以现在不使用AUX引脚判断遥控模块空闲,直接向模块发送数据。

连接电脑的串口功能可以自行修改,用于输出调试信息等。大家可以自行查看该串口的代码并自行修改。

log系统

在软件中实现了一个log报错的记录以便调试,该log系统最多可以记录100条数据,更多的数据将会覆盖最早数据。log包括添加记录的时间、错误码、错误信息。其中错误码是为了方便判断错误类型,错误信息最长50字节。

如果在调试过程中遇到难以复现的错误,可以使用该log系统在程序关键位置保存信息,然后使用串口在电脑上将信息打印出来查看故障原因。这样就不需要长时间连接电脑运行,便于调试。

static log_t Log[MAX_LOG_COUNT] = {0}; static uint16_t log_index = 0; // 当前日志索引 static uint16_t log_count = 0; // 当前日志数量 void Log_add(uint8_t error_number, const char *message) { // 确保message不为NULL if (message == NULL) {; } // 添加日志 Log[log_index].app_time = APP_time; Log[log_index].num = error_number; // 安全拷贝字符串,防止溢出 strncpy(Log[log_index].message, message, MAX_LOG_MESSAGE - 1); Log[log_index].message[MAX_LOG_MESSAGE - 1] = '\0'; // 确保字符串终止 // 更新索引和计数 log_index = (log_index + 1) % MAX_LOG_COUNT; if (log_count < MAX_LOG_COUNT) { log_count++; } } void Log_print(void) { uint16_t start_index; uint16_t i; if (log_count == 0) { Serial_Printf("No logs available.\r\n"); return; } // 计算起始索引(如果是循环缓冲区) if (log_count < MAX_LOG_COUNT) { start_index = 0; } else { start_index = log_index; } // 打印所有日志 for (i = 0; i < log_count; i++) { uint16_t idx = (start_index + i) % MAX_LOG_COUNT; Serial_Printf("[%u] Time: %u, Error: 0x%02X, Message: %s\r\n", i+1, Log[idx].app_time, Log[idx].num, Log[idx].message); Delay_ms(10); } } void Log_reset(void) { Serial_Printf("Logs reset. Current saved logs:\r\n"); Log_print(); memset(Log, 0, sizeof(Log)); log_index = 0; log_count = 0; } 

开机自检

当电调板上电时,会自动进行自检。通过ADC采样以及读取霍尔信号的方式,判断当前动力电池、电机霍尔信号、温度信号、电流信号是否正常,确保在系统启动正常。在自检通过后,会进行NTP对时,对时成功后才会进入正常控制模式。

// 开机自检 // 自检项目开关 #define SELFCHECK_TEMPERATURE 1 #define SELFCHECK_BATTERY 1 #define SELFCHECK_HALL 1 #define SELFCHECK_CURRENT 1 // return 0 = 自检成功 1 = 自检错误 uint8_t selfCheck(void) { uint8_t check_count = 0; if (SELFCHECK_TEMPERATURE) check_count++; if (SELFCHECK_BATTERY) check_count++; if (SELFCHECK_HALL) check_count++; if (SELFCHECK_CURRENT) check_count++; while (1) { LED_Control(LED_ERROR, on); LED_Control(LED_RED, on); Serial_Rx_loopCheck(); uint8_t success_count = 0; // 温度检测 if (SELFCHECK_TEMPERATURE) { uint8_t temperatureSuccess_count = 0; for (int8_t i = 10; i > 0; i--) { uint16_t temperature = AD_Value[3]; if (temperature > TEMPERATURE_HIGH && temperature < TEMPERATURE_ERROR) { temperatureSuccess_count ++; } else { temperatureSuccess_count = 0; } if (temperatureSuccess_count >= 5) { success_count ++; break; } } } // 电池电量 if (SELFCHECK_BATTERY) { uint8_t batterySuccess_count = 0; for (int8_t i = 10; i > 0; i--) { float battery = adc_calculate_battery_voltage(); if (battery > 30.0f && battery < 45.0f) { batterySuccess_count ++; } else { batterySuccess_count = 0; } if (batterySuccess_count >= 5) { success_count ++; break; } } } // 霍尔信号 if (SELFCHECK_HALL) { if (HallSensor.Position == 1 || HallSensor.Position == 2 || HallSensor.Position == 3 || HallSensor.Position == 4 || HallSensor.Position == 5 || HallSensor.Position == 6) { success_count ++; } } // 电流采样 if (SELFCHECK_CURRENT) { uint16_t current = AD_Value[0]; if (current != 0 && current < 5) { success_count ++; } } // 结算自检结果 if (success_count == check_count) { LED_Control(LED_RED, off); Log_add(LOG_NORMAL, "selfCheck Success."); return 0; } static uint8_t logwriteflag_selfcheck = 0; if (logwriteflag_selfcheck == 0) { logwriteflag_selfcheck = 1; Log_add(LOG_NORMAL, "selfCheck Error."); } } }

OLED

在遥控板上实现了OLED显示相关代码。该代码使用了江协科技的OLED模块代码,在此基础上进行了简单修改。在使用时需要注意严格控制OLED更新频率,因为该OLED代码的I2C通信没有使用硬件外设,导致该通信时序会严重占用CPU时间,可以通过降低OLED更新频率来降低通信时序对CPU的时间占用。


三、机械结构

由于本人并非机械设计相关专业,所以该滑板结构可能并非最优,建议在此基础上进行一定的改进。

在设计时,需要注意滑板在使用时长期震动,所以内部电气部分一定要牢固固定,防止颠簸导致内部线材或器件互相摩擦损坏,尤其动力电池如果损坏非常危险。电调板附近要留有开口,方便调试以及利于遥控信号接收,因为碳板有微弱导电性,会明显阻隔信号传播。

转向架

转向架安装使用淘宝购买的带有电机的滑板转向架。安装时需要注意转向架需要区分正反,否则转向会与实际相反。下方M5螺母可以使用一个套筒方便拧紧。

在淘宝购买的转向架电机接口与电调板接口不同,需要自行焊接mr30公头连接至电调板。

框架

目前设计使用全碳板与直角连接件配合组装。可以在当前设计的基础上,增加各板材之间的开槽,实现简单的榫卯配合,在安装直角连接件时更加方便。

若需要改进,面板和侧板的厚度不建议减小。实测使用全碳板强度足够,估计使用亚克力板强度也能够满足体重较轻的人使用。

在框架全部组装完成后,才能支持人站在上面,因为单一块面板无法承受一个人的重量,在我的设计中,使用了面板下方的两个侧板增加面板的强度。当侧板没有安装牢固时,人站在滑板上极有可能导致面板折断。

装配

在装配时,由于直角连接件的公差一般较大,所以所有螺丝必须先简单挂上后,再统一一起拧紧,否则可能出现螺丝孔无法对齐的问题。

先将下方的电器盒部分组装完毕,然后再将其固定到面板下,否则部分螺丝无法拧紧。

滑板内部的3D打印件设计有些许不合理,电机线需要在分电板安装之前先穿过预留孔位,否则会由于插头大小大于预留孔导致电机线无法穿入。建议用户自行重新设计滑板内部的各部件限位。

滑板所有装配螺丝必须使用防松螺母或者使用螺丝胶。因为滑板在使用时不可避免的会有长时间的高频振动,螺丝极易松脱。

内部活动器件用胶固定,线材可以包裹毛毡胶布防止震动异响。固定用胶水不建议使用热熔胶,建议使用704硅橡胶,因为其有一定缓震效果且不易老化。

电气与防水

电调板与分电板要尤其注意需要用螺丝孔将PCB固定并架空,因为这两块板上均有动力电池的42V供电,PCB背面的焊点如果接触到导体短路会产生危险。另外需要注意碳板并非完全绝缘,所以在使用时不能将碳板作为绝缘体而将PCB直接放置在碳板上通电。一定要使用螺丝孔将PCB悬空固定。

PCB板用螺丝固定的方法:打印件中预留孔位,然后使用滚花螺母热镶嵌进打印件,然后PCB就可以方便的使用螺丝固定了。注意热镶嵌的时候需要确保滚花螺母垂直于平面并位于预留孔位中心。具体设计尺寸可见工程文件。

动力电池需要注意不能直接放置在滑板内,需要用打印件固定防止其移动,因为滑板内有螺丝,在使用过程中极有可能刮坏电池,产生严重的危险。

先将所有螺丝装配完毕确保能够正确组装后,可以使用704硅橡胶将所有板材缝隙填满,实现简单的防水防尘。在长期使用前必须做好防水,一方面是防止液体进入导致电器短路,另一方面是防止灰尘进入影响电调板的工作。所以即使不在积水路面使用也请做好防水措施。

目前工程中电调板和开关开口的盖板并没有具体设计,仅简单封口通过打胶实现密封。建议自行设计电调板的盖板以及开关的盖板。


四、测试

电调板首次上电

首次上电建议使用可调电源供电,监控供电电流是否正常,在有问题时也可以立刻切断电源。电调板支持24V供电,无需专门寻找36V输出的电源。

首次上电后,正常状态应该是3.3V电源指示LED正常点亮,用万用表测了5V和12V供电接口电压正常。注意3.3V电源指示灯使用了36k的限流电阻,该LED在正常时亮度较低,该现象是正常的。

首次上电成功后,下载遥控特殊固件代码测试。

遥控模块

使用特殊固件初始化遥控模块参数(详见遥控模块配置部分)。在下载特殊固件后,进入debug查看串口接收数据,如果收到了5B开头的一长串hex数据,说明遥控模块配置成功。

然后下载正常固件,观察电调板与遥控板是否能够建立连接,如果PC13的红灯一直闪烁,说明无法进行NTP对时,即无法建立连接。如果红灯常亮,重启系统再次尝试。遥控就绪状态应当是没有LED闪烁,且遥控板的OLED能够显示电调板的信息,同时可以控制电机旋转。

注意测试时要将滑板电机架空,防止由于遥控板硬件存在问题,导致采样得到的摇杆数据错误,在遥控连接成功后电机疯转。

电机驱动

使用功率计测量实时电压电流。当电流小于1A时,板子发热大概在20-35度左右。如果温度过高说明硬件需要排查故障。检测PCB发热建议使用热成像,最少也需要使用万用表的温度探头,将探头紧贴MOS管粘贴测量。同时也可以测试NTC热敏电阻运行是否正常。测试完成后,建议在PCB背面粘贴散热块,能够显著增加爬坡时的散热能力。MOS管在长时间工作时,温度不能超过80度。

我使用了带风扇的M2固态硬盘散热器。自行选择散热块时注意尺寸即可。若尺寸较大,需要修改固定PCB的3D打印件结构,并考虑走线空间。


五、工程文件与BOM物料

工程文件部分内容不在此处介绍,大家可以移步文章开头我的开源文档链接或立创开源硬件平台

Read more

用playwright封装一个处理web网页的爬虫,并隐藏自动化特征,自动处理反爬

更多内容请见: 《爬虫和逆向教程》 - 专栏介绍和目录 文章目录 * 一、脚本概述 * 1.1 脚本对应反爬措施 * 1.2 注意事项 * 1.3 反爬细节说明 * 二、完整代码 * 2.1 安装依赖 * 2.2 封装代码 * 2.3 使用示例 下面是一个使用 Playwright 封装的、具备反爬对抗能力的网页爬虫Python函数,返回原始 HTML 内容,并重点隐藏自动化特征,避免被检测为 bot。 一、脚本概述 该封装已在多个中等反爬网站(如电商、新闻站)验证有效,能绕过大多数基于 navigator.webdriver、chrome 对象、permissions 等的检测。

By Ne0inhk
Flutter for OpenHarmony: Flutter 三方库 flutter_cors 应对鸿蒙 Web 与混合开发中的跨域挑战(网络兼容方案)

Flutter for OpenHarmony: Flutter 三方库 flutter_cors 应对鸿蒙 Web 与混合开发中的跨域挑战(网络兼容方案)

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net 前言 在进行 OpenHarmony 的跨平台开发时,我们不仅开发原生 HAP,有时也会涉及 Flutter Web 或是在鸿蒙端侧运行 Webview 混合应用。这时,一个经典的“拦路虎”就会出现:CORS (跨源资源共享) 限制。当你的 Web 端尝试访问一个未配置跨域头部的后端 API 时,请求会被浏览器拦截,报错信息极其晦涩。 虽然 CORS 主要是后端的工作,但 flutter_cors 提供了一种客户端视角的辅助工具。它通过工具化手段帮助开发者分析、绕过或生成跨域适配规则,是保证鸿蒙跨平台 Web 项目顺利运行的调试利器。 一、跨域访问逻辑模型 CORS 是一种浏览器的安全保护机制,它在请求发出前先进行“预检(Preflight)

By Ne0inhk

Flutter 三方库 dart_webrtc 的鸿蒙化适配指南 - 在鸿蒙系统上构建极致、透明、基于 WebRTC 标准的工业级实时音视频通讯与低延迟流媒体引擎

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net Flutter 三方库 dart_webrtc 的鸿蒙化适配指南 - 在鸿蒙系统上构建极致、透明、基于 WebRTC 标准的工业级实时音视频通讯与低延迟流媒体引擎 在鸿蒙(OpenHarmony)系统的跨端视频会议、分布式安防监控、直播连麦或者是需要实现“端到端(P2P)”低延迟数据传输的场景中,如何通过一套 Dart 代码调用底层浏览器级的 WebRTC 算力?dart_webrtc 为开发者提供了一套工业级的、针对 Web 平台(JS 接口)进行高度封装的 WebRTC 适配方案。本文将深入实战其在鸿蒙 Web 入口应用中的音视频能力扩展。 前言 什么是 Dart WebRTC?它不仅是一个简单的。管理过程。由于由接口包装。

By Ne0inhk

服务器无法访问WebUI?这几个排查步骤必看

服务器无法访问WebUI?这几个排查步骤必看 当你兴冲冲地执行完 bash start_app.sh,终端上也清晰地打印出: ============================================================ WebUI 服务地址: http://0.0.0.0:7860 ============================================================ 可一打开浏览器输入 http://你的服务器IP:7860,却只看到“无法访问此网站”“连接被拒绝”或“该网页无法正常运作”……别急,这绝不是模型本身出了问题,而是典型的服务可达性故障——它发生在模型启动之后、用户访问之前那个关键的“中间层”。 本文不讲OCR原理,不聊ResNet18结构,也不展开ONNX导出细节。我们聚焦一个最实际、最高频、最让人抓狂的问题:WebUI明明启动了,为什么就是打不开? 针对 cv_resnet18_ocr-detection OCR文字检测模型(构建by科哥) 这一镜像,我将带你按真实运维节奏,逐层穿透网络、系统、服

By Ne0inhk