欢迎阅读教程系列的第五篇文章,其中我正在构建一个基于遥控Arduino的车辆机器人。
这是我到目前为止发表的文章列表:
在上一篇文章中,我写了我的Michelino机器人固件的第一个版本,其中包括一个电机控制器驱动程序。今天我正在增加对距离传感器的支持。
距离传感器驱动器
正如我在上一篇文章中所示,只要为其编写了设备驱动程序,我的固件就可以处理任何电机控制器板。此上下文中的设备驱动程序只不过是一个实现我为此类设备定义的通用接口的类。
距离传感器没有什么不同,可以应用相同的想法。首先,我将定义所有距离传感器设备通用的接口。这将进入文件distance_sensor.h:
/** * @file distance_sensor.h * @brief distance sensor driver definition for the Michelino robot. * @author Miguel Grinberg */ namespace Michelino { class DistanceSensorDriver { public: /** * @brief Class constructor. * @param distance The maximum distance in centimeters that needs to be tracked. */ DistanceSensorDriver(unsigned int distance) : maxDistance(distance) {} /** * @brief Return the distance to the nearest obstacle in centimeters. * @return the distance to the closest object in centimeters * or maxDistance if no object was detected */ virtual unsigned int getDistance() = 0; protected: unsigned int maxDistance; }; };
我的距离传感器驱动器非常简单,驾驶员只需提供一种方法,以厘米为单位报告距离最近物体的距离。这就是我现在所需要的,我总是可以回来并在以后为驱动程序定义添加更多功能。
驱动程序的构造函数占用最大距离。这是在getDistance()没有检测到障碍物时将返回的值。
请注意,maxDistance成员变量位于protected块中,而不是private像我在其他类中使用的块。protected对于外部仍然是私有的变量,但子类可以访问它们。我希望这个驱动程序的实现可以访问这个值,这就是我创建它的原因protected。
正如我在上一篇文章中提到的,我发现我正在使用的HC-SR04距离传感器与arduino-new-ping开源库非常相配,所以我现在将为它实现上面的驱动程序接口档案newping_distance_sensor.h:
/** * @file newping_distance_sensor.h * @brief distance sensor driver for distance sensors supported by the NewPing library. * @author Miguel Grinberg */ #include "distance_sensor.h" namespace Michelino { class DistanceSensor : public DistanceSensorDriver { public: DistanceSensor(int triggerPin, int echoPin, int maxDistance) : DistanceSensorDriver(maxDistance), sensor(triggerPin, echoPin, maxDistance) { } virtual unsigned int getDistance() { int distance = sensor.ping_cm(); if (distance <= 0) return maxDistance; return distance; } private: NewPing sensor; }; };
请注意getDistance()实现如何maxDistance访问父类中定义的成员变量。如果我在private块中定义了此变量,则此代码将无法编译。
与固件集成
作为第一次集成测试,让机器人前进并在发现障碍物时停止。
我将从我在前一篇文章中编写的固件开始,并将替换initialize()和run()方法,它们是我面向Arduino setup()和loop()函数的面向对象。如果您不熟悉,可能需要返回该文章以查看完整草图的结构。
在草图的顶部,我使用与电机控制器相同的结构包括驱动器:
#define ENABLE_NEWPING_DISTANCE_SENSOR_DRIVER #ifdef ENABLE_NEWPING_DISTANCE_SENSOR_DRIVER #include <NewPing.h> #include "newping_distance_sensor.h" #define DISTANCE_SENSOR_INIT 14,15,MAX_DISTANCE #endif
在类的private部分中创建Robot一个DistanceSensor对象:
private: DistanceSensor distanceSensor;
在Robot类构造函数中初始化对象:
Robot() : leftMotor(LEFT_MOTOR_INIT), rightMotor(RIGHT_MOTOR_INIT), distanceSensor(DISTANCE_SENSOR_INIT) { initialize(); }
在initialize()方法中,我启动电机并将状态设置为运行:
void initialize() { leftMotor.setSpeed(255); rightMotor.setSpeed(255); state = stateRunning; }
并且在该run()方法中,当距离传感器发现近距离障碍时,我停止电机:
void run() { if (state == stateRunning) { if (distanceSensor.getDistance() <= TOO_CLOSE) { state = stateStopped; leftMotor.setSpeed(0); rightMotor.setSpeed(0); } } }
我在上面的所有更改中使用了两个常量,MAX_DISTANCE并且TOO_CLOSE。这些常量在草图的顶部定义,以便在需要修改时很容易找到它们:
#define TOO_CLOSE 10 #define MAX_DISTANCE (TOO_CLOSE * 20)
我将使用10厘米的距离来考虑障碍物太近。我需要跟踪的最大距离是该距离的20倍或2米。这些是我凭空掏出的数字,我真的不知道它们是否会起作用,但在我有机会测试之后我总能回来调整它们。
草图现在处于可用状态。以下是完整的michelino.ino草图源代码:
#define ENABLE_ADAFRUIT_MOTOR_DRIVER #define ENABLE_NEWPING_DISTANCE_SENSOR_DRIVER #define TOO_CLOSE 10 #define MAX_DISTANCE (TOO_CLOSE * 20) #ifdef ENABLE_ADAFRUIT_MOTOR_DRIVER #include <AFMotor.h> #include "adafruit_motor_driver.h" #define LEFT_MOTOR_INIT 1 #define RIGHT_MOTOR_INIT 3 #endif #ifdef ENABLE_NEWPING_DISTANCE_SENSOR_DRIVER #include <NewPing.h> #include "newping_distance_sensor.h" #define DISTANCE_SENSOR_INIT 14,15,MAX_DISTANCE #endif namespace Michelino { class Robot { public: /* * @brief Class constructor. */ Robot() : leftMotor(LEFT_MOTOR_INIT), rightMotor(RIGHT_MOTOR_INIT), distanceSensor(DISTANCE_SENSOR_INIT) { initialize(); } /* * @brief Initialize the robot state. */ void initialize() { leftMotor.setSpeed(255); rightMotor.setSpeed(255); state = stateRunning; } /* * @brief Update the state of the robot based on input from sensor and remote control. * Must be called repeatedly while the robot is in operation. */ void run() { if (state == stateRunning) { if (distanceSensor.getDistance() <= TOO_CLOSE) { state = stateStopped; leftMotor.setSpeed(0); rightMotor.setSpeed(0); } } } private: Motor leftMotor; Motor rightMotor; DistanceSensor distanceSensor; enum state_t { stateStopped, stateRunning }; state_t state; }; }; Michelino::Robot robot; void setup() { robot.initialize(); } void loop() { robot.run(); }
在这个草图的控制下,机器人将一直运行,直到距离墙壁或其他障碍物10厘米或更小,然后停止。
如果您正在构建一个像我一样的机器人并尝试草图,您可能会发现机器人大部分时间都按预期运行,但并非总是如此。偶尔它可能会停止,即使没有近距离障碍,或者有时它可能会不停地撞到墙上。继续阅读以了解我如何找出问题以及如何解决问题。
记录
运行上面的草图几次就可以清楚地发现意外情况正在发生,但我怎样才能知道它是什么?我真的需要看到草图正在接收的所有数据以及它如何对它做出反应以理解为什么事情不会像我预期的那样发展。换句话说,我需要调试我的草图。
调试这样的嵌入式系统非常有用的技术是将通过应用程序传递的信息写入可以查看的日志中。
为了能够从我的草图中记录,我将编写一个记录函数,该printf()函数与C标准库中的旧函数具有相同的参数,除了因为我的机器人没有屏幕,输出将转到串口,我可以从我的PC上运行的Arduino软件中捕获,或者通过蓝牙从我的手机中捕获。我打算叫这个功能log()。以下是一个示例用法:
unsigned int distance = distanceSensor.getDistance(); log("distance: %u\n", distance);
需要考虑的一个方面是,一旦我纠正了这个问题,我可能不想继续将记录数据发送到串行端口。这实际上是在正常操作期间不需要的操作,它仅对调试有用。但是,如果我找到问题的解决方案后,我将从草图中删除日志语句,我将不得不在下次调试时再次写入它们。我想要的是在草图中保持记录调用,但有选择地能够启用或禁用它们。我将使用C预处理器技巧来做到这一点。下面是我的日志记录功能的源代码,部分基于此Arduino游乐场页面中p函数的实现。我要把它放在一个名为的文件中:logging.h
#ifdef LOGGING // logging is enabled #include <stdarg.h> void log(char* format, ...) { char line[1024]; va_list args; va_start(args, format); vsnprintf(line, sizeof(line), format, args); va_end(args); Serial.print(line); } #else // logging is disabled #define log(...) #endif
此代码定义了该log()函数的两个版本。如果LOGGING定义了常量,则该log()函数执行将数据打印到串行端口的所有工作。但是如果LOGGING没有定义常量,那么log()函数实际上并没有被定义为函数,而是作为宏或常量使用#define,具有完全空的定义。这意味着如果LOGGING未在主草图中定义,则编译器会将对该log()函数的任何调用视为空行,因此不会生成任何代码。但是当我需要调试时,我可以LOGGING在草图的顶部定义,所有日志语句都会神奇地包含在固件中。
要在我的草图中启用日志记录,我在主michelino.ino文件的顶部附近添加了以下内容:
#define LOGGING #include "logging.h"
然后我改变了我的run()功能如下:
void run() { if (state == stateRunning) { unsigned int distance = distanceSensor.getDistance(); log("distance: %u\n", distance); if (distance <= TOO_CLOSE) { state = stateStopped; leftMotor.setSpeed(0); rightMotor.setSpeed(0); } } }
然后我用一根长USB线将机器人连接到我的电脑,上传草图并在串口监视器中观看输出。我不得不做几次运行来解决问题,但在大约二十分之二的时候机器人在没有近距离障碍时停了下来。
其中一个失败是在草图开始运行后立即发生的。以下是该会话的日志:
distance: 63 distance: 74 distance: 5
63和74厘米的数字大约是与墙壁的距离,但是5厘米的数字是从蓝色出来的,没有任何东西靠近机器人。
我捕获的另一个故障也很奇怪,机器人离墙几米远,所以它记录了最大距离值,但检查了某些地方发生了什么:
distance: 200 distance: 200 distance: 200 distance: 200 distance: 200 distance: 7
基于这两次失败,我能够制定一个解释我的问题的理论。
传感器通过发射超声波并等待声波从障碍物反弹并到达传感器来工作。到目前为止,这种测量技术并不精确,并且必然会不时产生误差。有时波可能会在地板上反弹并以某种方式使其返回到传感器,有时它可能会从吸收声音的表面反弹,因此反弹信号很弱而传感器会错过它。事实上,距离传感器的读数并不总是可靠的。
平均
在登录串行接口的帮助下,我发现传感器不是100%可靠。那我该怎么办?大大提高其准确性的一种方法是不对单个读数作出反应,最好采取多个读数并确保它们在引起机器人状态变化之前是一致的。
我将用来过滤掉错误或噪音的技术非常简单,我只需要读取一些读数并对其求平均值。
但是每次我需要检查障碍物时进行多次读数会使机器人响应变慢,所以理想情况下我想找到一种方法来获得平均多个读数的好处,而不必牺牲性能。该移动平均算法很好地解决了这个问题。
移动平均线逐渐增加。假设我决定平均使用三个读数。每次我需要从传感器获取数据时,我只需一次读数,但我不直接使用该结果。相反,我计算读数和前两个之间的平均值。下次我需要读取传感器时,我会得到另一个读数,就像之前一样。现在我删除上一次平均的三个读数中最老的一个并添加这个新读数。所以我总是添加新的阅读并从平均值中删除最旧的。
为了更清楚,让我们看一个例子:
传感器读数200200200200720020077五移动平均线,N = 3200200135135135135716
如果没有移动平均线,机器人就会在第一次移动时停下来7,就像我在捕获日志中遇到的一次失败一样。但是随着移动平均线的数量,TOO_CLOSE当三个连续读数很低时,数字只会降到下面,所以机器人首先7将其忽略为异常,但是在三个读数出现低之后,平均值最终反映了这一点。
这种技术非常有用,因此我不会将其硬编码到距离传感器驱动程序中,而是创建一个通用移动平均类,然后我可以将其应用于其他类型的样本。我将把这个类放在文件中moving_average.h:
/** * @file moving_average.h * @brief Helper class that maintains a moving average over a set of samples. * @author Miguel Grinberg */ template <typename V, int N> class MovingAverage { public: /* * @brief Class constructor. * @param def the default value to initialize the average. */ MovingAverage(V def = 0) : sum(0), p(0) { for (int i = 0; i < N; i ) { samples[i] = def; sum = samples[i]; } } /* * @brief Add a new sample. * @param new_sample the new sample to add to the moving average. * @return the updated average. */ V add(V new_sample) { sum = sum - samples[p] new_sample; samples[p ] = new_sample; if (p >= N) p = 0; return sum / N; } private: V samples[N]; V sum; V p; };
MovingAverage该类与我之前编写的类不同。这个类以一个template声明开头,它基本上使这个类成为一种模型,可以从中构造许多其他类。使其成为模板类的原因是可以在不同类型的样本上计算移动平均值。对于距离传感器,我将使用unsigned int值,但将来我可能需要对类型的值使用相同的算法float,double甚至是用户定义的类型。V在模板键盘处理之后立即声明的模板参数。在我使用V类型的类中的任何地方,编译器将使用在创建类的对象时提供的实际类型替换它。第二个模板参数N用于声明需要在内存中保留多少样本来进行平均。在我的例子中,我将使用三个,但我可能需要更多用于其他目的。因此,不是让类使用三个修复大小,而是调用大小N,编译器将再次使用每个实例的正确值替换它。如果我想为距离传感器创建实例,我会这样做:
MovingAverage<unsigned int, 3> average;
请注意,类构造函数采用默认为0的参数。这是默认样本值,在我看到三个样本之前将用于执行平均值的值。
该实现使用带有size 的循环缓冲区N,该缓冲区初始化为构造函数中的默认样本值。我还有一个sum成员变量,用于保存N内存中样本的总和,以及一个成员变量,p用于指示循环缓冲区中将存储下一个样本的条目。
当获得新样本时,通过该add()方法将其添加到移动平均值。实现将新样本存储在循环缓冲区中,并将指针移动p到下一个条目。在sum由最旧的样本,并增加了新的样值的值(这是获得替换所述一个)的值减小。最后,计算并返回新的平均值。
该类如何在机器人固件中使用?首先,我需要在主草图中包含头文件:
#include "moving_average.h"
然后我需要在类MovingAverage的private部分声明一个对象Robot:
private: MovingAverage<unsigned int, 3> distanceAverage;
然后我需要在Robot构造函数中初始化它:
Robot() : leftMotor(LEFT_MOTOR_INIT), rightMotor(RIGHT_MOTOR_INIT), distanceSensor(DISTANCE_SENSOR_INIT), distanceAverage(MAX_DISTANCE) { initialize(); }
请注意,我用我的MAX_DISTANCE常数初始化移动平均线,以确保我在开始时没有检测到障碍物。
该initialize()功能不会改变。该run()功能变化如下:
void run() { if (state == stateRunning) { int distance = distanceAverage.add(distanceSensor.getDistance()); log("distance: %u\n", distance); if (distance <= TOO_CLOSE) { state = stateStopped; leftMotor.setSpeed(0); rightMotor.setSpeed(0); } } }
通过这种简单的增强功能,距离传感器的读数更加可靠!
车削
找到障碍物时停下来并不是很有趣。真正的机器人不只是停在障碍物前面,他们找到了避开它们并继续前进的方法。
该机器人具有非常简单的视觉和运动系统,因此其避障算法也将是非常基本的。当机器人检测到前方障碍物时,我要做的是在原地旋转一段时间,直到没有检测到附近的障碍物然后向新的方向前进。
从本质上讲,这个算法应该让我的小机器人表现得像Roomba。我可以把刷子连接到它上面并刷它我的地板!
算法
我已经使用了a的概念,state让我的机器人知道它在任何给定时间正在做什么。现在我还要进行机器人转动,我需要定义不同的状态:
enum state_t { stateStopped, stateMoving, stateTurning };
“停止”状态将用于机器人根本不移动的状态。“移动”状态将适用于机器人向前移动的状态。最后,在机器人转动时将使用“转向”状态。
我将在run()轮到我启用固件的方法中使用的算法如下:
在上图中,菱形表示决策,框表示过程或状态变化。
机器人将在stateMoving状态下开始并将运行有限的时间,之后将更改为stateStopped状态。流程图中的前两个决定解决了逻辑的这一部分。
如果机器人在stateMoving其中,它将检查障碍物,如果找到一个,它将转动,这将状态改变为stateTurning。如果没有找到障碍物,那么它将不会改变其状态。
如果机器人在stateTurning其中,它将检查它是否完成转动,此时它将变回stateMoving。如果转弯没有完成,那么它将保持相同的状态。
很简单吧?
回想一下,这将在run()方法内部,因此只要该算法完成传递,该方法将再次被调用,因此该算法将重复运行。
由于我对编写可读代码感觉非常强烈,我将尝试尽可能直接将上图映射到代码中。这是第一次尝试编写我的run()方法:
void run() { if (stopped()) return; else if (doneRunning()) stop(); else if (moving()) { if (obstacleAhead()) turn(); } else if (turning()) { if (doneTurning()) move(); } }
你怎么看?这是非常易读的代码,对吗?如果我在离开项目很长一段时间后回到它,我认为我不会理解这段代码。
现在你可能会说我在作弊,我刚刚创建了算法的大纲,但实质上我所做的只是调用不存在的函数。
我不是在作弊。这被称为自上而下的设计,我从大件开始,然后转向较小的件。我将逐一实现上面引用的所有函数,在文章的最后,我将有一个功能齐全的草图,其run()方法看起来非常接近上述。
首先让我描述每个支持功能的作用。
实现转弯启用的机器人固件
我现在有九个run()我需要实现的方法使用的辅助函数。我将从三个非常简单的开始:
bool moving() { return (state == stateMoving); } bool turning() { return (state == stateTurning); } bool stopped() { return (state == stateStopped); }
我可以state直接在run()方法中检查变量,但这些使代码run()更具可读性。C 编译器非常智能,无论哪种方式都会生成相同的代码。
上面列表中还有两个函数非常容易实现:
void move() { leftMotor.setSpeed(255); rightMotor.setSpeed(255); state = stateMoving; } void stop() { leftMotor.setSpeed(0); rightMotor.setSpeed(0); state = stateStopped; }
五个功能完成,四个去!
我们来看看吧doneRunning()。
此功能需要true在30秒后返回或false以其他方式返回。我已经讨论过如何在上一篇文章中超时行动,所以这不会带来任何挑战。
我将首先在Robot类中添加一个成员变量来存储时间。我可以选择存储草图的开始时间,然后在此函数中检查当前时间是变量加上30秒,否则我可以存储结束时间,在这种情况下我只需要比较当前时间与变量。这最后一个选项似乎更有效,所以这就是我要做的。
我首先添加成员变量:
private: unsigned long endTime;
然后我在草图顶部定义一个常量,总运行时间以秒为单位:
#define RUN_TIME 30
在initialize()方法中,我设置endTime为正确的值,然后将机器人设置为stateMoving状态:
void initialize() { endTime = millis() RUN_TIME * 1000; move(); }
现在我可以轻松实现doneRunning():
bool doneRunning() { return (millis() >= endTime); }
但是这个实现让我感到困扰。该millis()电话不觉得我的权利。我知道我将需要在我尚未实现的其他一些功能中使用当前时间。例如,转动机器人也将在某种程度上基于当前时间。可以想象通过调用millis()函数获取当前时间必须有一些成本,因此多次调用函数并不是非常有效,因为知道所有这些调用将彼此非常接近并且将返回相同的值或者关闭。获得当前时间似乎更有效,然后让所有需要它的功能使用它。
人们会想到的直接解决方案是将另一个成员变量添加到Robot类中,然后在run()方法的顶部将其设置为当前时间,然后该类中的任何方法都可以访问此变量。
但这真的是一个很好的解决方案吗?想一想。成员变量用于保持对象中的状态。当前时间不能被认为是状态,它只对run()方法的执行有效,下次run()调用时必须再次获取。
将当前时间作为run()方法内的局部变量,然后作为参数传递给doneRunning():
void run() { unsigned long currentTime = millis(); if (stopped()) return; else if (doneRunning(currentTime)) stop(); // ... }
然后该doneRunning()方法变为:
bool doneRunning(unsigned long currentTime) { return (currentTime >= endTime); }
这感觉好多了。
现在我还有三个功能。我们obstacleAhead()接下来做。
要检查障碍物,我需要读取距离传感器。我可以在obstacleAhead()方法内部读取传感器,但是同样的原因使我选择获取当前时间一次并将其作为参数传递给函数适用于距离传感器。因此,我将在顶部获得单个(平均)距离读数,run()然后将其传递给任何需要它的辅助函数:
void run() { unsigned long currentTime = millis(); int distance = distanceAverage.add(distanceSensor.getDistance()); // ... }
obstacleAhead()然后该方法将距离读数作为参数:
bool obstacleAhead(unsigned int distance) { return (distance <= TOO_CLOSE); }
剩下的两个辅助功能是最复杂的,因为它们涉及在靠近障碍物时转动机器人。
这是一个基本问题。我应该让机器人向左或向右旋转吗?
为了避免让机器人始终以可预测的方式移动,我会让它选择离开一些时间,然后再选择其他时间。为了创造这种不可预测性,我将使用random()Arduino库中的函数。此函数将一系列数字作为输入,并随机返回其中一个数字,每次都是不同的数字。例如,random(2)有时会返回0,有时会返回1.然后我可以根据函数的结果向左或向右转。
另一个问题。机器人需要转动多长时间?
这个问题很难回答。通过使一个轮向前移动而另一个向后移动,我可以轻松地让机器人旋转到位,但是机器人无法知道它旋转的速度,或者当它完全转动时,这种反馈只能是从车载磁力计或指南针获得。
如果不添加更多硬件,我能做的最好就是使用更多随机数。我将实现的算法会产生一个随机数,用于确定机器人旋转的时间。一旦该时间过去,机器人将开始检查距离传感器,同时仍然旋转。只有当距离传感器没有找到障碍物时,旋转才会结束。
有了上述想法,我可以写turn()如下:
bool turn(unsigned long currentTime) { if (random(2) == 0) { leftMotor.setSpeed(-255); rightMotor.setSpeed(255); } else { leftMotor.setSpeed(255); rightMotor.setSpeed(-255); } state = stateTurning; endStateTime = currentTime random(500, 1000); }
第一个if语句决定根据结果向左或向右转random(2)。为了使机器人转动,机器人的轮子设置为以相反的方向运行。在将状态更新到stateTurningI 之后,我只需要让机器人在随机的时间内保持转动状态。这是另一个定时动作,比如我上面实现的30秒超时,所以我需要另一个成员变量来跟踪转弯完成的时间。我打算调用那个成员变量endStateTime。我们的想法是,任何需要定时操作的机器人状态都可以使用此成员变量来指示操作结束的时间:
private: unsigned long endStateTime;
我存储的值endStateTime是当前时间加上一半到一秒的随机值(回想一下所有时间都是毫秒单位)。我随意选择了随机范围,因为我知道如果这些数字效果不好我总能微调这些数字。
现在我还有一个功能要做。
我在上面说过,当满足两个条件时,转弯就结束了。首先,转弯的时间必须已经过去。其次,距离传感器一定不能看到前面的任何障碍物。基于以上描述,很明显doneTurning()需要两个参数,当前时间和传感器距离读数:
bool doneTurning(unsigned long currentTime, unsigned int distance) { if (currentTime >= endStateTime) return (distance > TOO_CLOSE); return false; }
为了完成任务,我将检查我的run()方法,并确保我向每个辅助函数发送正确的参数:
void run() { if (stopped()) return; unsigned long currentTime = millis(); int distance = distanceAverage.add(distanceSensor.getDistance()); log("state: %d, currentTime: %ul, distance: %u\n", state, currentTime, distance); if (doneRunning(currentTime)) stop(); else if (moving()) { if (obstacleAhead(distance)) turn(currentTime); } else if (turning()) { if (doneTurning(currentTime, distance)) move(); } }
由于该stopped()函数不需要任何参数,我将其移动到set currentTime和的行上方distance。
另请注意,我投入了一个记录语句,用于打印当前状态,时间和距离读数。由于我已经具备了登录功能,因此没有理由不使用它!
最后的话
您可能会觉得缺少某些东西,我没有做过任何非常复杂的事情,而且只需要一堆简短的功能,机器人就不会有足够的行为来移动和转动。
你期望在某个地方发生黑暗和复杂的事情,因为问题很复杂,但将问题分成小块的美妙之处在于每个部分本身并不像整个问题那么难。
当然,你不必接受我的话。代码是免费的开源代码,可在我的github页面上找到。您可以将其下载为zip文件:
下载michelino v0.2。
或者如果您愿意,可以直接从github克隆它。
对于那些没有和我一起构建机器人的人来说,这是我今天运行固件的机器人的简短视频:
在本系列的下一章中,我将为我的机器人添加远程控制支持,我将使用我的手机作为控制器。
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved