树莓派上的 Java 教程(四)
十、激光雷达装置
在这一章中,我们将检查我在自主漫游车中使用的另一个设备——支持定位和导航的激光雷达单元。附录 A2 描述了围绕 USB 连接的 Arduino 构建的简单的定制激光雷达单元。激光雷达单元是将任务从树莓派卸载到 Arduino 的真实例子,特别是为了利用微控制器的“近实时”特性。
在这一章中,我将介绍
-
USB 串行设备设备库的实现
-
一个从头开始的设备库的设计与实现
-
识别和解决各种设计问题
-
设计的迭代
-
处理原始数据,使其更有用
在继续之前,如有必要,您应该回顾一下第七章中的材料,其中涵盖了树莓派串行 I/O 功能和 diozero 串行设备支持。
Note
有趣的事实:附录 A2 中描述的激光雷达单元中使用的激光雷达传感器被用于美国宇航局 2020 年火星任务的一部分“创新”直升机。 www.sparkfun.com/news/3791见。
了解设备
本章中的激光雷达装置由附录 A2 中的*激光雷达装置“数据表”*部分描述。查看数据手册,您会发现激光雷达设备所展示的接口的一些有趣方面:
-
该设备公开命令来执行或启动任务。
-
一个命令由一个单字节类型和一个可选的双字节整数参数组成。命令隐含的任务可以返回零个或多个双字节整数。
-
有些任务只需要在设备识别、测试或校准期间使用,而不需要在“生产”中使用
-
有些任务会立即完成;有些是长时间运行。
-
产生的距离数据可以包括指示不同误差的代码;低于 100 厘米的真实范围是非线性的。
查找设备库
作为自定义设备,不可能找到激光雷达单元的现有设备库,因此无需搜索。因此,好消息或坏消息,你有一个干净的石板。你是如何进行的?下一节将回答这个问题。
设备库设计
在“从头开始”的情况下,我认为你应该从定义你的库的接口方法开始。然后,您可以寻找可用于实现接口级方法的通用方法。与任何设备一样,还有其他考虑因素。
连接
根据第八章的讨论,新库接口的最佳指导来源是“我的需求”下一个最好的是设备接口。由于我设计了激光雷达单元,这两个是相同的!有点作弊,但事实就是如此。
激光雷达单元库界面的一个好的起点是在相当简单的界面中为每个命令提供一个方法。对于某些设备来说,接口可能更复杂,需要更多的抽象。例如,有时需要与设备进行多次交互,以在设备库接口中实现合理的抽象。此外,您会发现有时需要几次迭代才能识别正确的设备库接口和所有私有方法。
数据手册指出,有些命令不需要,甚至可能不应该在生产中使用。您有两种选择:忽略命令的差异,让用户自己去了解差异,或者加入访问限制,这会使设计变得复杂。我将采用后一种方法,因为我觉得它很有趣。有一些技术可以在 Java 中适当地限制访问;一些立即浮现在脑海中的东西:
-
具有公共访问“生产”方法和默认访问“其他”方法的单个设备类。这种方法将任何“其他”方法限制在与设备类相同的包中的主类。可能不是最好的解决方案,因为这些类被打包在库 jar 文件中。
-
具有公共访问“生产”方法和受保护访问“其他”方法的设备类。这种方法要求设备类的包之外的任何主类扩展设备类来访问“其他”方法。
-
一个只包含公共访问“生产”方法的设备“生产”类和一个扩展“生产”类并包含公共访问“其他”方法的“其他”子类。除了根据所使用的类,这种方法对包方法的访问没有任何限制。然而,“生产”类需要混合使用公共、默认和私有访问方法。
技术 2 和技术 3 之间没有很大的区别。我认为方法 2 是最有效的技术,我将在激光雷达单元设备库的实现中使用它。
查看数据表可以发现,一个只需要激光雷达设备提供的激光雷达扫描的“生产”项目只需要以下命令:
-
获取伺服参数
-
扫描
-
扫描检索
-
变暖
一些项目可能希望能够进行自定义扫描,或者进行简单的静态测距。如果是这样,“生产”项目还需要以下命令:
-
设置伺服位置
-
获取范围
幸运的是,使用技术 2,只需将访问级别从 protected 更改为 public,就可以非常容易地进行切换。在任一情况下,剩余的命令将是受保护访问的候选者。
常用方法
接下来呢?想想接口级方法做什么,希望找到一些可以封装成可重用私有方法的公共函数。通常情况下,你会发现中级方法,比如第八章中的那些方法,它们将使用第七章中描述的低级SerialDevice方法。
对于激光雷达单元,相关信息是发送到设备的命令的性质和返回信息的性质。基本上有两种通用命令形式:
-
类型和参数(如回声参数、预热),要求
-
从整数参数创建两个字节
-
写入命令类型和参数字节
-
如果需要,读取响应(两个字节);从字节中创建一个
short
-
-
仅类型(例如,获取 ID),这需要
-
写入命令类型(一个字节)
-
如果需要,读取响应(两个字节);从字节中创建一个
short
-
显然,“仅类型”表单中的步骤 b 和“类型和参数”表单中的步骤 c 是相同的。这暗示了实现这些步骤的“读取响应”方法。对于返回多个双字节整数的命令,我们可以直接循环读取它们。
虽然有一些方法可以处理这两个表单中需要的其他操作,但我将为“仅类型”表单定义一个“写命令类型”方法,为“类型和参数”表单定义一个“写命令类型和参数”方法。
其他考虑因素
第八章提到了移植现有设备库或从头开发设备库时适用的其他考虑事项。例如,是否应该将库接口与接口的实现分开?库实例应该是由许多用户共享的单例,还是应该有许多实例,每个用户一个?您必须根据您的项目需求和设备自行做出这些决定。
对于激光雷达单元
-
似乎不可能有另一个具有类似接口的激光雷达单元实现,因此没有理由将接口与实现分开。
-
我认为一个机器人不太可能需要多个激光雷达装置。因此,不需要库的多个实例。我将再次忽略第九章中提到的备选方案,并假设没有用户会试图创建多个实例。
第七章描述了 USB 设备身份的问题。在前面的文本中,我假设一个激光雷达单元,因此没有必要区分一个激光雷达单元与另一个。然而,由于激光雷达单元基于 Arduino,因此有可能找到多个具有相同 USB 设备身份的 Arduino(例如,参见表 7-1 )。因此,Lidar 单元必须提供一些唯一的设备实例 ID。幸运的是,激光雷达单元有一个 Get ID 命令,应该足够了。我将在本章的后面讨论身份验证的两个阶段的实现。
第八章还提到了一个有趣的问题,一个库实例是否在多个线程之间共享。激光雷达设备有一些需要很长时间才能完成的命令(例如,扫描)。这意味着整个项目设计应该考虑并发性。不幸的是,Java 并发性一般超出了本书的范围。因此,我将假设一个设备库实例和一个使用该实例的线程。你可能希望变得更复杂。
玩设备
快速看一下激光雷达单元数据表,它有两个命令(获取 ID 和回声参数)满足播放的“简单性”要求,所以我们将播放!
要开始游戏,您必须首先创建一个新的 NetBeans 项目、包和类。在您的树莓派上配置用于远程开发的项目;并将项目配置为使用 diozero(更多细节参见第七章)。我调用了我的项目 Lidar ,我的包org.gaf.lidar,我的设备类Lidar。但是,因为我们要做一些播放,你需要创建一个新的包;我会叫我的org.gaf.lidar.test。在那个包中,创建一个新的主类;我给我的取名PlayLidar。
列表 10-1 显示PlayLidar。我们首先需要创建一个SerialDevice的实例。激光雷达装置以 115,200 波特的速度运行。所有其他串行参数都是默认值。在我将激光雷达单元连接到树莓派并给单元加电后,它的设备文件是/dev/ttyACM0(参见第七章了解 USB 设备文件的信息)。您可以在main方法的第一条语句中看到这一点。
您可以在清单中找到两个代码块。第一次尝试按照前面描述的设计获取 ID。我们必须首先写入命令字节(10)。你可能会认为我们应该阅读回复。但是测试的时候发现,如果激光雷达传感器没电,读数永远不回,PlayLidar挂起!您必须按 Ctrl-C 来终止应用程序。因此,接下来的几条语句检查是否返回了响应。如果没有,我们抛出一个异常;如果是这样,那么我们进行块读取以获得两个字节的响应。接下来,我们必须操作这两个字节来创建一个short。最后,我们可以打印结果值。
package org.gaf.lidar.test;
import static com.diozero.api.SerialConstants.*;
import com.diozero.api.SerialDevice;
public class PlayLidar {
public static void main(String[] args)
throws InterruptedException {
SerialDevice device = new SerialDevice(
"/dev/ttyACM0", BAUD_115200,
DEFAULT_DATA_BITS,
DEFAULT_STOP_BITS,
DEFAULT_PARITY);
// get the ID
device.writeByte((byte) 10);
byte[] res = new byte[2];
// see if active
Thread.sleep(100);
if (!(device.bytesAvailable() > 1)) {
System.out.println("Lidar not powered!");
System.exit(-1);
}
// read the response byte array
device.read(res);
// construct response as short
short value = (short)(res[0] << 8);
value = (short) (value |
(short) Byte.toUnsignedInt(res[1]));
System.out.println("ID= " + value);
// echo a parameter
short parameter = 12345;
device.write((byte) 11,
(byte) (parameter >> 8),
(byte) parameter);
// read the response byte array
device.read(res);
// construct response as short
value = (short)(res[0] << 8);
value = (short) (value |
(short) Byte.toUnsignedInt(res[1]));
System.out.println("Parameter= " + value);
}
}
Listing 10-1PlayLidar
在进入第二组语句之前,可以运行PlayLidar 。你应该看到这个:ID= 600。
第二个代码块尝试回显一个参数,这也是由先前的设计决定的。首先,我们定义一个参数。然后,我们写入命令字节(11)和组成参数的两个字节。然后我们进行块读取(不怕失败)和操作以获得响应。最后,我们打印结果。
运行PlayLidar,您应该会看到以下内容:
ID= 600
Parameter= 12345
成功!我们去图书馆发展。
Tip
我认为使用激光雷达装置的体验强调了玩耍的好处;在玩游戏时发现传感器没有电源的“挂起”比在开发过程中发现它的破坏性要小得多。此外,经验建议测试各种条件(例如,信号或电源连接缺失)以了解影响并做出反应。
设备库开发
我们已经创建了必要的 NetBeans 项目、包和类,配置了用于远程开发的项目,并配置了使用 diozero 的项目。我们可以开始开发Lidar。
开发方法
在第八章中,我提到了两种基本的设备库开发方法:广度优先和深度优先。即使在开发一个“干净的”设备库时,这个问题也会出现。与移植一样,我更喜欢从头开始开发时先从深度开始。正如第八章所建议的,我们将首先开发“核心”
激光雷达核心
Lidar核心需要一个构造器,一些接口级方法,以及它们使用的中间层方法。A SerialDevice提供了底层方法。
接口级和中级方法
前面的分析从类似于 Get ID 和 Echo 参数的命令中导出了所需的中级方法。既然我们现在已经有了使用这些命令的经验,那么在内核中实现它们似乎是一个好主意。
前面的分析确定了三种中级方法:“写命令类型”、“写命令类型和参数”和“读响应”。要实现 Get ID 和 Echo 参数命令,核心必须包括所有这三个命令。
构造器
通常有许多与设备库构造器的实现相关的考虑事项。本小节讨论一些与激光雷达单元相关的 USB 串行设备。
身份
如前所述,激光雷达单元提出了 USB 设备身份挑战。正如第八章中的机器人法律一样,我认为验证应该在外部完成。在这一章的后面我会告诉你怎么做。
系列特征
虽然从技术上来说,可以控制激光雷达单元连接的两端,但我会假设情况并非如此,即数据手册中定义的串行特性不能改变。因此,我将假设激光雷达单元固定在 115,200 波特,其他串行特征不需要构造器上的参数。
履行
清单 10-2 展示了基于前面讨论的设计的Lidar的核心实现。同样,我省略了大部分注释和所有 Javadoc。你不应该!
关于实施的一些要点:
-
import陈述反映了不同 diozero 等级的要求。 -
请注意唯一标识激光雷达单元的
LIDAR_ID常量(设备实例 ID)。 -
构造器使用
SerialDevice.Builder来创建一个SerialDevice实例,因为所有其他序列特征都与 diozero 默认值相匹配。 -
该类实现了
AutoCloseable,根据第七章中的讨论,你会在Lidar实现中找到一个close方法。 -
中级方法必须能够访问在
Lidar实例中使用的SerialDevice实例,因此您必须创建一个实例变量,在构造器中填充它,并在中级方法中使用它。 -
根据前面的访问讨论,
getID和echoParameter方法被标记为protected。 -
getID和echoParameter方法以及writeCmdType、writeCmdTypeParm和readShort方法都传播未检查的RuntimeIOException。更多信息见第七章。 -
根据前面的身份讨论,方法
verifyIdentity验证设备实例 ID。 -
嵌套类
CommandTypes模拟了合并命令类型代码的最佳实践。
package org.gaf.lidar;
import static com.diozero.api.SerialConstants.
BAUD_115200;
import com.diozero.api.SerialDevice;
import com.diozero.api.RuntimeIOException;
import com.diozero.util.SleepUtil;
import java.io.IOException;
public class Lidar implements AutoCloseable {
public static final int LIDAR_ID = 600;
private SerialDevice device;
public Lidar(String deviceFile)
throws IOException {
try {
device =
SerialDevice.builder(deviceFile).
setBaud(BAUD_115200).build();
} catch (RuntimeIOException ex) {
throw new IOException(ex.getMessage());
}
}
public void close() throws IOException {
if (device != null) {
device.close();
device = null;
}
}
public boolean verifyIdentity()
throws RuntimeIOException {
return LIDAR_ID == getID();
}
protected short getID()
throws RuntimeIOException, IOException{
writeCmdType(CommandTypes.ID.code);
SleepUtil.sleepMillis(100);
if (!(device.bytesAvailable() > 1)) {
throw new IOException(
"Lidar not powered!");
}
return readShort();
}
protected short echoParameter(short parm)
throws RuntimeIOException {
writeCmdTypeParm(CommandTypes.ECHO.code,
parm);
return readShort();
}
private void writeCmdType(int type)
throws RuntimeIOException {
device.writeByte((byte) type);
}
private void writeCmdTypeParm(int type,
int parm) throws RuntimeIOException {
device.write((byte) type,
(byte) (parm >> 8), (byte) parm);
}
private short readShort()
throws RuntimeIOException {
byte[] res = new byte[2];
device.read(res);
short value = (short)(res[0] << 8);
value = (short) (value |
(short) Byte.toUnsignedInt(res[1]));
return value;
}
private enum CommandTypes {
ID(10),
ECHO(11),
SERVO_POS(30),
SERVO_PARMS(32),
MULTIPLE(50),
SCAN(52),
SCAN_RETRIEVE(54),
WARMUP(60);
public final int code;
CommandTypes(int code) {
this.code = code;
}
}
}
Listing 10-2Lidar core
清单 10-2 中的readShort和getID方法展示了一个有趣的设计选择。正如在PlayLidar中所讨论的,如果传感器没有通电,读取会失败,程序会挂起。为了检测这种情况,我们可以使用非阻塞读取。我们可以在readShort中实现非阻塞读取。但是,在实际使用中,getID应该总是被首先调用(作为身份验证的一部分);此外,根据该单元的经验,如果第一次读取有效,则所有后续读取都有效。所以,我决定让readShort变得“纯粹”,并在getID中做一个非阻塞读取的额外工作。该设计选择遵循第八章的建议;在这种情况下,我不会一直使用非阻塞读取,因为我并不总是需要它。
测试核心
您现在可以测试清单 10-2 中的Lidar核心实现。根据第八章的讨论,您应该在包org.gaf.lidar.test中创建一个主类;我将给这个班级取名为TestLidarCore。
TestLidarCore该怎么办?根据前面的身份讨论,它必须找到正确的 USB 设备文件;也就是说,做 USB 设备身份验证。显然它必须实例化一个Lidar。它应该练习两种方法,getID和echoParameter。记住既然getID用于身份验证,TestLidarCore真的只需要锻炼echoParameter。
身份验证
第八章描述了一种为 RoboClaw 电机控制器提供两阶段身份验证的实用方法(见清单 8-9 )。清单 10-3 显示了LidarUtil类(与Lidar在同一个包中)的实现,它包含一个静态方法findDeviceFile,该方法为激光雷达单元执行身份验证。实用程序方法之间的唯一区别是 RoboClaw 版本需要设备实例 ID 的参数,而 Lidar 单元不需要。为了支持身份验证,您必须将实用程序项目添加到激光雷达项目库属性中(有关如何操作的详细信息,请参见第五章)。
package org.gaf.lidar;
import java.io.IOException;
import java.util.List;
import org.gaf.util.SerialUtil;
public class LidarUtil {
public static String findDeviceFile(
String usbVendorId, String usbProductId)
throws IOException {
// identity verification - phase 1
List<String> deviceFles =
SerialUtil.findDeviceFiles(
usbVendorId, usbProductId);
// identity verification - phase 2
if (!deviceFles.isEmpty()) {
for (String deviceFile : deviceFles) {
System.out.println(deviceFile);
Lidar lidar = new Lidar(deviceFile);
boolean verified =
lidar.verifyIdentity();
lidar.close();
if (verified) return deviceFile;
}
}
return null;
}
}
Listing 10-3LidarUtil class
请注意,我在验证的第二阶段留下了一个println语句。它只是为了帮助测试。它在生产中是不需要的。
TestLidarCore 实现
清单 10-4 显示了测试程序TestLidarCore。TestLidarCore表示任何打算在Lidar中使用保护的方法的主类的一般形式。该类必须
-
扩展被测试的类,在这个例子中,
Lidar -
用适当的参数定义自己的构造器,在本例中是
fileName
TestLidarCore中的main方法必须
-
为激光雷达设备找到正确的设备文件(这样做可以验证设备身份)
-
使用已经验证了 USB 设备标识和设备实例 ID 的设备文件实例化该类
-
调用
echoParameter方法
激光雷达装置 USB 设备标识来自表 7-1 ,其中{ usbVendorId,usbProductId } = {1ffb,2300}用于 Pololu A-Star 32U4。设备实例 ID 在Lidar类(LIDAR_ID)内部。
遵循第七章中的指南,TestLidarCore启用资源试运行和 diozero 停堆安全网。由于激光雷达单元在非正常终止的情况下不会导致任何问题,所以我选择不使用 Java 关机安全网。
package org.gaf.lidar.test;
import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.lidar.Lidar;
import org.gaf.lidar.LidarUtil;
public class TestLidarCore extends Lidar {
public TestLidarCore(String fileName)
throws IOException {
super(fileName);
}
public static void main(String arg[])
throws IOException {
final short parm = 1298;
// identity verification
String deviceFile =
LidarUtil.findDeviceFile("1ffb", "2300");
if (deviceFile == null) {
throw new
IOException("No matching device!");
}
try (TestLidarCore tester =
new TestLidarCore(deviceFile)) {
// issue and check echo command
short echo = tester.echoParameter(parm);
if (echo == parm)
System.out.println("Echo GOOD");
else
System.out.println("Echo BAD");
} finally {
Diozero.shutdown();
}
}
}
Listing 10-4TestLidarCore
为了进行测试,我将本章中的激光雷达单元和另一个具有不同设备实例 ID(见附录 A1)的 Pololu A-Star 32U4 连接到 Raspberry Pi,然后给 Pi 加电。正如所料,结果是两个 USB 设备,/dev/ttyACM0和/dev/ttyACM1。当我运行TestLidarCore时,我得到的结果如清单 10-5 所示。
/dev/ttyACM1
/dev/ttyACM0
ID GOOD!
Echo GOOD
Listing 10-5Output from TestLidarCore execution
如你所见,原来激光雷达装置是设备/dev/ttyACM0。而且,你可以看到测试成功了!
其他方法
清单 10-6 显示了第一次通过时Lidar的附加方法。有几个方面值得阐述:
-
如前所述,一些接口级方法拥有
protected访问权,以表明它们不应该在“生产”中使用 -
有些方法不是从激光雷达单元命令集派生的。我将在下面讨论这些。
protected int setServoPosition(int positionHalfDeg)
throws RuntimeIOException {
writeCmdTypeParm(CommandTypes.SERVO_POS.code,
positionHalfDeg);
return (int) readShort();
}
public short[] getServoParms()
throws RuntimeIOException {
writeCmdType(CommandTypes.SERVO_PARMS.code);
return readNShort(3);
}
protected short[] getRanges(int number)
throws RuntimeIOException {
writeCmdTypeParm(CommandTypes.MULTIPLE.code,
number);
return readNShort(number);
}
public void scanStart(int delay)
throws RuntimeIOException {
writeCmdTypeParm(CommandTypes.SCAN.code, delay);
}
public boolean isTaskDone(boolean wait)
throws RuntimeIOException {
if (device.bytesAvailable() > 1) {
readShort(); // to keep sync
return true;
} else {
if (!wait) {
return false;
} else { // wait
while (device.bytesAvailable() < 2) {
SleepUtil.sleepMillis(1000);
}
readShort(); // to keep sync
return true;
}
}
}
public short[] scanRetrieve()
throws RuntimeIOException , IOException {
writeCmdType(CommandTypes.SCAN_RETRIEVE.code);
if (readShort() == -1 )
throw new IOException("No scan
to retrieve");
short[] ranges = readNShort(361);
return ranges;
}
public void warmupStart(int period)
throws RuntimeIOException {
writeCmdTypeParm(CommandTypes.WARMUP.code,
period);
}
private short[] readNShort(int number)
throws RuntimeIOException {
short[] values = new short[number];
for (int i = 0; i < number; i++) {
values [i] = readShort();
}
return values;
}
Listing 10-6Lidar additional methods
私有方法readNShort来源于接口级方法getServoParms(执行命令 Get Servo Parameters)getRanges(执行命令 Get Multiple)和scanRetrieve(执行命令 Scan Retrieve)读取多个short值。因此,创建一个共享的方法来这样做似乎是谨慎的。
公共方法isTaskDone的起源要有趣得多。方法scanStart和warmupStart都启动“长期运行”的任务在默认伺服延迟下,扫描开始后大约 30 秒左右,扫描结束并发送其完成代码。在最小预热期间,预热完成并在启动后几秒钟发送其完成代码;在最大预热期间,预热完成并在启动后几分钟发送其完成代码。因此,为了实现高效的多任务处理,两种方法都向激光雷达单元发送命令来启动任务,但不会等待发送完成代码。等待是isTaskDone的工作。也就是说,因为必须读取两个任务的完成代码以保持同步通信,所以必须在扫描或预热已经开始之后的某个点和调用另一个命令之前的某个点调用isTaskDone 以读取完成代码。**
正如您在清单 10-6 中看到的,isTaskDone有一个参数,它决定是等待长时间运行的任务完成,还是简单地检查当前状态并返回。为了支持多任务,在等待的时候,方法尽可能的休眠;我选择了相对较长的睡眠期;它可以更短,甚至可以通过另一个参数来定制。实现的一个重要部分是在任务完成后读取完成代码,以保持通信同步。
测试其他方法
我创建了一个新程序来测试完整的实现。TestLidarAll(见清单 10-7 )允许你输入命令和可选参数。然后它调用Lidar中适当的方法。重要的是要记住,由于TestLidarAll接受键盘输入,您不能成功地从 NetBeans 运行它。您必须将项目发行版(jar 和库)推送到 Raspberry Pi,并使用安全 shell 运行它;详见第五章。
请注意,在大多数情况下,switch 语句中的情况由 Lidar 单元命令代码标识。一个例外是调用isTaskDone的情况。我只是选择了一个未使用的命令代码,55。
package org.gaf.lidar.test;
import com.diozero.util.Diozero;
import java.io.IOException;
import java.util.Scanner;
import org.gaf.lidar.Lidar;
import org.gaf.lidar.LidarUtil;
public class TestLidarAll extends Lidar {
public TestLidarAll(String fileName)
throws IOException {
super(fileName);
}
public static void main(String arg[])
throws IOException, InterruptedException {
// identity verification
String deviceFile =
LidarUtil.findDeviceFile("1ffb", "2300");
if (deviceFile == null) {
throw new IOException(
"No matching device!");
}
try (TestLidarAll tester =
new TestLidarAll(deviceFile)) {
// enable keyboard input
Scanner input = new Scanner(System.in);
String command = "";
while (true) {
System.out.print(
"Command (type,parm; 'q' is
quit): ");
command = input.next();
System.out.println();
// parse
String delims = "[,]";
String[] tokens =
command.split(delims);
if (tokens[0].equalsIgnoreCase("q"))
{
tester.close();
System.exit(0);
}
int type =
Integer.parseInt(tokens[0]);
int parm = 0;
if (tokens.length > 1) {
parm =
Integer.parseInt(tokens[1]);
}
System.out.println("type: " + type +
" parm: " + parm);
switch (type) {
case 10:
int id = tester.getID();
System.out.println(
"ID=" + id);
break;
case 11:
short echo =
tester.echoParameter(
(short)parm);
System.out.println("Echo= " +
echo);
break;
case 30:
int rc =
tester.setServoPosition(
parm);
System.out.println("rc= " +
rc);
break;
case 32:
short[] p =
tester.getServoParms();
for (short pv : p) {
System.out.println(
"pv= " + pv);
}
break;
case 50:
short[] ranges =
tester.getRanges(parm);
for (short r : ranges) {
System.out.println(
"r= " + r);
}
break;
case 52:
tester.scanStart(parm);
break;
case 54:
ranges =
tester.scanRetrieve();
for (short r : ranges) {
System.out.println(
"r= " + r);
}
break;
case 55: // fake code
boolean wait;
if (parm == 0) wait = false;
else wait = true;
boolean status =
tester.isTaskDone(wait);
System.out.println(
"status= " + status);
break;
case 60:
tester.warmupStart(parm);
break;
default:
System.out.println(
"BAD Command!");
}
}
} finally {
Diozero.shutdown();
}
}
}
Listing 10-7TestLidarAll
我运行了TestLidarAll并测试了所有案例。一切正常!
开始扫描或预热后,您必须小心。这些命令在启动任务后会立即返回,您会得到另一个命令提示符。唯一有效的命令是 55,它调用isTaskDone;否则,树莓派和 Arduino 会失去通信同步。不好!
其他想法
在Lidar的早期实现和用于测试它的类中,出现了一些额外的设计想法/问题。以下小节将对它们进行描述。
长期运行的任务
清单 10-6 (扫描和预热)中的长期运行任务实际上有一个“启动”方法和一个“等待”方法。必须调用“start”和“wait”来确保树莓派和 Lidar 单元之间的同步。Lidar的另一个设计可以有一个“开始和等待”便利方法以及“开始”和“等待”方法。当线程不是问题时,可以使用便利方法。
便利方法的实现非常简单。它们如清单 10-8 所示。注意,方法名称的含义是它们完成任务,而不仅仅是开始任务。
public void scan(int delay)
throws RuntimeIOException,
InterruptedException {
writeCmdTypeParm(CommandTypes.SCAN.code, delay);
isTaskDone(true);
}
public void warmup(int period)
throws RuntimeIOException,
InterruptedException {
writeCmdTypeParm(CommandTypes.WARMUP.code,
period);
isTaskDone(true);
}
Listing 10-8Convenience methods in Lidar
为了测试方便的方法,我将清单 10-9 中显示的额外案例添加到TestLidarAll中的switch语句中。
case 53:
tester.scan(parm);
break;
case 61:
tester.warmup(parm);
break;
Listing 10-9Additional cases in TestLidarAll
我测试了方便的方法,它们像预期的那样工作。然而,在实际项目中,由于这些方法的阻塞性质,必须小心使用。
读取性能
readNShort方法使用readShort方法,因此通过操作分散读取两个字节来产生一个短路。从激光雷达单元读取所有字节,然后进行操作以产生所有的short值,可能会提高性能。
我实现了一个在操作前读取所有字节的测试。我的测试显示,清单 10-6 中所示的readNShort的实现与理论上性能更好的方法之间基本上没有区别。这表明整个处理时间是由串行通信决定的,而不是由操作或上下文切换决定的。这个经历是我很久以前得到的一些建议的一个很好的例子:“如果它没有坏,就不要修理它。”
原始范围
由激光雷达单元产生并通过scanRetrieve提供给应用的距离阵列是 原始 :
-
如数据表所示,它可能包含错误代码 (1 和 5),可能会妨碍进一步处理。
-
根据数据表,传感器中的范围< 100 suffers from 非线性,如果此类范围对项目很重要,则应进行补偿。
-
范围就是极坐标中的径向坐标;激光雷达信息的大部分处理受益于笛卡尔坐标系统。
-
可以从范围数组索引中导出极坐标的角度坐标。简单地除以 2 会以 0.5°的增量产生角度坐标。唉,这需要一个浮点数,消除了对坐标对使用简单的整数数组。
-
由于激光雷达单元中的伺服和伺服控制器的限制,角坐标不精确。虽然误差的最大绝对值约为 0.015,但这在某些项目中可能会有所不同。
有几种方法可以处理这种情况。对于设备库,最简单的方法是忽略它。也就是说,项目中的某个人将不得不在以后处理它。我将向您展示一个处理大部分工作的实现。有两种主要的设计方法。一种是将必要的处理注入到Lidar本身;第二是创建一个辅助类。我为我的漫游者选择了第一个,但是我将在下面展示第二个。
辅助类是LidarPoint,如清单 10-10 所示。从纯数据类的角度来看,它根据其在扫描阵列中的位置、极坐标和笛卡尔坐标来描述范围读数。
package org.gaf.lidar;
import java.text.DecimalFormat;
public class LidarPoint {
public int index;
public float rho;
public float theta;
public float x;
public float y;
public LidarPoint(int index, float rho) {
this.index = index;
this.rho = rho;
}
@Override
public String toString() {
DecimalFormat df =
new DecimalFormat("###.00");
String out = String.format("index = %3d : "
+ "(\u03c1,\u03b8)=(%2$6s,%3$6s) : "
+ "(x,y)= (%4$7s,%5$6s)",
index,
df.format(rho), df.format(theta),
df.format(x), df.format(y));
return out;
}
private static float servoStepsIn1;
private static boolean configured = false;
public static void setServoParms(short[] parms) {
configured = true;
float servoStepsIn180 =
(parms[2] - parms[0]) * 4;
servoStepsIn1 = servoStepsIn180 / 180;
}
public static LidarPoint[] processScan(
short[] scan) throws RuntimeIOException {
if (!configured) throw new RuntimeIOException(
"Servo parameters unset.");
LidarPoint[] lp =
new LidarPoint[scan.length];
for (int i = 0; i < scan.length; i++) {
// create the point
lp[i] = new LidarPoint(i, scan[i]);
// indicate invalid information
lp[i].rho = (lp[i].rho <= 5) ?
-1 : lp[i].rho;
// calculate ideal theta (degrees)
lp[i].theta = (float)i / 2;
// calculate exact angle (degrees)
lp[i].theta = ((int) (lp[i].theta *
servoStepsIn1 + 0.5)) /
servoStepsIn1;
// convert angle to radians
lp[i].theta = (float)
Math.toRadians((float) lp[i].theta);
// calculate Cartesian coordinates
if (lp[i].rho != -1) {
lp[i].x = (float)
Math.cos(lp[i].theta) * lp[i].rho;
lp[i].y = (float)
Math.sin(lp[i].theta) * lp[i].rho;
}
}
return lp;
}
}
Listing 10-10LidarPoint
LidarPoint有两个静态方法。processScan处理扫描阵列中的每个原始距离读数,产生一个LidarPoint实例。该方法解决了前面提到的“原始”问题,非线性除外。它根据数据表计算量程读数的精确角度。注意,角度坐标最终用弧度表示,因为 Java(和许多其他语言)使用弧度执行三角函数。setServoParms通过计算伺服控制器“第 1 步”的重要数字,启用processScan的操作。
Note
processScan方法是解决非线性的合适方法。然而,这样做的实际手段高度依赖于特定的激光雷达传感器。在我的机器人中,经过大量实验,我使用了两种不同的线性方程,一种用于 58 厘米以下的范围,另一种用于 58 到 100 厘米之间的范围。
为了测试LidarPoint,我对TestLidarAll做了一些小小的修改,如清单 10-11 所示。我在Lidar.getServoParms的案例陈述中添加了对LidarPoint.setServoParms的呼叫。我用一个未使用的命令代码(57)创建了一个新案例来检索扫描,并调用LidarPoint.processScan来产生扫描的笛卡尔坐标。
import org.gaf.lidar.LidarPoint;
case 32:
short[] p = tester.getServoParms();
for (short pv : p) {
System.out.println("pv= " + pv);
}
LidarPoint.setServoParms(p);
break;
case 57:
ranges = tester.scanRetrieve();
LidarPoint[] lps =
LidarPoint.processScan(ranges);
for (LidarPoint pt : lps) {
System.out.println(pt);
}
Listing 10-11TestLidarAll changes
为了进行测试,使用TestLidarAll,我
-
运行命令 32 从激光雷达装置获取伺服参数,并在
LidarPoint中设置伺服参数 -
运行命令 53 进行扫描
-
运行命令 57 以检索和处理扫描
清单 10-12 显示了一些结果。省略号表示为简洁起见而删除行。
index = 0 : (ρ,θ)=( 82.00, .00) : (x,y)= ( 82.00, .00)
index = 1 : (ρ,θ)=( 82.00, .01) : (x,y)= ( 82.00, .69)
index = 2 : (ρ,θ)=( 81.00, .02) : (x,y)= ( 80.99, 1.42)
index = 3 : (ρ,θ)=( 81.00, .03) : (x,y)= ( 80.97, 2.10)
index = 4 : (ρ,θ)=( 83.00, .04) : (x,y)= ( 82.95, 2.91)
...
index = 356 : (ρ,θ)=(212.00, 3.11) : (x,y)= (-211.87, 7.43)
index = 357 : (ρ,θ)=(230.00, 3.12) : (x,y)= (-229.92, 5.97)
index = 358 : (ρ,θ)=(242.00, 3.12) : (x,y)= (-241.96, 4.24)
index = 359 : (ρ,θ)=(262.00, 3.13) : (x,y)= (-261.99, 2.22)
index = 360 : (ρ,θ)=(288.00, 3.14) : (x,y)= (-288.00, -.00)
Listing 10-12Results of scan processing
图 10-1 显示了激光雷达装置提供的“乐趣”。我在一个电子表格程序中创建了这个图,该程序使用了通过扫描(不是清单 10-12 中显示的扫描)产生的笛卡尔坐标。网格轴以厘米为单位进行缩放。网格的原点指示扫描期间激光雷达传感器的位置。在可能的范围内,我将激光雷达装置放置在垂直于表面 0 度、90 度和 180 度的位置。
图 10-1
处理后的激光雷达单元扫描图
扫描的区域非常简单,所以不会发生太多事情。在最右边,大约 x=85,你可以看到一个垂直的表面,它是涂漆木门、涂漆木条和涂漆石膏板的组合;一些不规则性是由于表面特征,一些是由于非线性,因为范围有时小于 100 厘米(它们被补偿)。在中间,大约 y=130,你可以看到一个几乎水平的表面,这是一个油漆木门,油漆木材装饰,油漆石膏板的组合。在左边,大约 x=-255,你可以看到另一个垂直表面被涂上了石膏板。在左上方,大约 y=225,你可以看到另一个水平表面,它是油漆过的木质边饰和油漆过的石膏板的组合。(-230,20)和(-240,200)周围的孤立点分别代表桌腿和落地灯。该图展示了激光雷达设备能够表现其“所见”的保真度
下一步是什么?
我们现在已经从激光雷达单元产生了有用的信息。接下来呢?我相信你已经听腻了,但接下来该怎么做取决于你。就像第九章中的 PIMU 一样,还有很多工作要做。我们所实现的实际上是最容易的部分!例如,传感器产生受非线性、噪声、扫描表面特性和传感器与表面之间的角度影响的范围;几乎可以肯定的是,您必须进行试验,以发现环境中的影响,并决定如何应对这种影响。要实际使用这些信息,几乎可以肯定的是,您必须利用一篇或多篇关于使用范围数据来促进定位和导航的研究论文。
摘要
在这一章中,你经历了
-
使用可扩展的客户端/服务器模式将任务从树莓派卸载到 Arduino
-
分析一个适度复杂的“设备数据表”并设计一个合适的 Java 设备库,使该设备可用于您项目中的程序
-
使用 diozero 实现串行 I/O 的设备库
-
考虑设备库中方法的访问问题,即哪些方法应该是公共的、私有的或受保护的
-
探索有关使用 diozero 支持以及设备特定特征来确定设备身份的更多信息
-
考虑到长时间运行的操作引起的并发问题
-
处理原始数据以产生更有意义的信息
-
认识到你经常需要重复设计
非常有趣!
十一、环境传感器
在本章中,我们将为常用的物联网设备(环境传感器)创建一个设备库。对于这本书,我选择了博世 BME280,它可以测量湿度、压力和温度。BME280 是一款非常受欢迎的传感器,您可以找到许多使用该传感器构建的分线板,使其非常容易包含在您的项目中。
在这一章中,我将讨论
-
发现 diozero 支持您的设备的喜悦!
-
使用 I2C 和 SPI 读取和写入器件的一些区别
-
用你的设备玩的好处,即使你已经有了一个库
了解设备
博世 BME280 数据表( www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf )显示该设备相对复杂,具有不同的操作模式和配置选项。以下是一些有趣的亮点:
-
该设备不能承受而不是 5V 电压!这不是问题,因为你将连接到一个树莓派,但你必须用 3.3V,而不是 5V 的设备供电。
-
该器件支持 I2C 和 SPI 接口。您必须特别注意 SPI 交互的细节。
-
I2C 接口支持标准、快速和高速模式(参见第七章)。最大 SPI 时钟频率为 10 MHz。
-
功耗以μA 为单位,这意味着从 Pi 为器件供电很容易。
-
有三种模式,睡眠、强制、正常。强制模式允许用户驱动采样;正常模式连续采样。在睡眠模式下,不进行采样。
-
所有三种环境条件的测量都是可选的。所有三种条件都可以过采样以降低噪声。压力和温度测量值也可以进行低通滤波。
-
温度用于补偿压力和湿度,因此实际上,为了获得精确的测量值,必须一直测量温度。
-
使用突发读取来确保数据完整性非常重要。
-
必须使用存储在设备上的参数来补偿传感器读数。
-
三个寄存器控制器件的工作特性。
-
“启动时间”,即上电后第一次通信的时间,可能长达 2 毫秒(见数据手册的表 1)。“启动时间”的一部分是将补偿参数复制到补偿寄存器中(参见数据手册的第 5.4.4 节)。
-
软复位会导致与上电相同的行为(参见数据手册的第 5.4.2 节)。
-
称为 BME280 API 的设备库可从 Bosch (
https://github.com/BoschSensortec/BME280_driver)获得。
查看数据表并思考您的需求,可以让您了解需要如何配置 BME280 来满足您的需求。这是找到满足您需求的库的关键。
Caution
BME280 工作在 3.3V】不能容忍 5V 。一些分线板提供电源调节器和电平转换,因此您可以连接到 5V 设备;大多数人不知道。幸运的是,树莓派是一个 3.3V 的设备。BME280 支持 I2C 和 SPI。一些分线板暴露两个接口;大多数只揭露 I2C;如果您想通过 SPI 连接,您必须获得一个支持 SPI 的分线板。
查找设备库
为了找到要使用或移植的设备库,我将遵循第六章中概述的步骤。第一步是查看 diozero 设备库列表——有一个BME280类可用!尽职调查要求您检查库以确保它满足您的需求。不赘述太多细节,看看BME280 ( https://github.com/mattjlewis/diozero/blob/master/diozero-core/src/main/java/com/diozero/devices/BME280.java )的实现,你就明白了
-
它可以使用 I2C 或 SPI 与器件通信。
-
它支持 I2C 默认设备地址或不同的地址。
-
它支持设置所有器件配置选项,即工作模式、过采样和低通滤波。
-
它支持软复位。它读取状态寄存器以确定补偿数据的复制何时完成;它在状态读取之间使用两毫秒的延迟。
-
它使用板载补偿系数进行补偿。
-
它支持读取状态寄存器。因此,您可以确定数据何时可用。
总之,如果你想使用 I2C 或 SPI,diozero BME280类几乎肯定支持你可能需要用设备做的一切,你可以照原样使用它。最坏的情况是,它为您自己的库提供了一个非常好的起点。例如,BME280总是对所有三个传感器(湿度、压力和温度)进行采样、读取和补偿;如果您只需要测量一两个条件,您可以通过修改您自己的库来只做您需要的事情,从而提高性能。
Note
虽然这可能难以置信,但在我知道 diozero 存在之前,我选择了 BME280 用于书中。虽然我认为 diozero 对该设备的支持主要是因为该设备的流行,但有时你只是运气好!此外,本着完全公开的精神,我必须补充一点,当我开始与 diozero 合作时,BME280只支持 I2C。我添加了 SPI 支持。
所以,命运向你微笑,你在迪奥西诺为 BME280 找到了一个图书馆。在一个真实的项目中,你会继续前进。在本书的上下文中,为了完整起见,我将考虑如果情况不是这样会怎样。原来因为设备的普及,你可以找到多个设备库:
-
之前我提到过博世 BME280 API 。它支持 I2C 和 SPI。它是用 C 语言编写的,在 BSD 和 Linux 系统上都有实现。后者可能会在树莓派上运行。它特定于 BME280,不依赖于任何其他库。
-
Adafruit 提供了一个针对 Arduino 的 C++库。它同时支持 I2C 和 SPI (
https://github.com/adafruit/Adafruit_BME280_Library)。它依赖于 Adafruit 传感器库,但这对于移植来说不是一个大问题。我觉得奇怪的一点是,在软复位后,库在状态读取之间使用了 10 毫秒的延迟。除了产生补偿数据之外,它还有一些额外的功能,例如,从压力中产生高度。 -
Adafruit 提供了一个针对兼容微控制器(
https://github.com/adafruit/Adafruit_CircuitPython_BME280)的 CircuitPython 库。它也有一些似乎容易被忽略的依赖关系。您可以发现与 Adafruit C++库有相当多的相似之处。 -
一个简单的搜索会产生几个 C 库、几个 Python 库、一个 Rust 库等等。确实是一个受欢迎的设备!
-
ControlEverythingCommunity 提供了一个 Java 测试程序 (
https://github.com/ControlEverythingCommunity/BME280/blob/master/Java/BME280.java)。它展示了通过 I2C 与设备交互的基础,但不能被视为一个库。
如果您需要对设备操作有更多的了解,想要衍生功能,或者需要一些不寻常的配置,这些库可能会很有用。
使用 diozero BME280
演示一下BME280会很有用。幸运的是,该类包含在您在第六章中创建的 NetBeans DIOZERO 库中的 diozero-core jar 文件中。对于 I2C 和 SPI,您需要将树莓派 3.3V(例如,接头引脚 1)连接到 BME280 VIN,并将 Pi 地(例如,接头引脚 9)连接到 BME280 GND。我建议你关闭所有的连接。
如果你希望使用 I2C,你应该使用 I2C 公共汽车 1 树莓皮。您还必须将 Pi SDA(接头针脚 3)连接到 BME280 SDA/SDI,并将 Pi SCL(接头针脚 5)连接到 BME 280 SCL/SCK;见图 11-1 。 1
图 11-1
I2C 的树莓派至 BME280 连接
如果希望使用 SPI,应使用 Pi SPI 总线 0。还必须将 Pi MOSI(接头针脚 19)连接到 BME280 MOSI/SDA/SDI,Pi MISO(接头针脚 21)连接到 BME280 MISO/SDO,Pi SCLK(接头针脚 23)连接到 BME280 SCLK/SCL/SCK,Pi CE0(接头针脚 24)连接到 BME 280 CE/CS/CSB;见图 11-2 。
图 11-2
用于 SPI 的树莓派至 BME280 连接
要测试BME280,您必须首先创建一个新的 NetBeans 项目、包和类。在您的树莓派上配置用于远程开发的项目;并将项目配置为使用 diozero(更多细节参见第七章)。我将我的项目命名为 BME280 ,我的包命名为org.gaf.bme280.test,我的主类命名为TestBME280。
清单 11-1 显示了TestBME280,这是在 diozero 示例应用( https://github.com/mattjlewis/diozero/tree/master/diozero-sampleapps/src/main/java/com/diozero/sampleapps )中找到的 BME280 测试程序的一个修改版本。TestBME280使用 I2C 或 SPI 以每秒一次的速度读取 BME280 给定的次数。它接受零个、一个或两个参数。第一个参数指示接口;“I”是指 I2C,其他任何东西都是指 SPI。第二个参数表示读取次数。默认为 I2C 和三倍。
遵循第七章中的指南,TestBME280使用资源试运行和 diozero 停堆安全网。由于 BME280 在异常终止的情况下不会导致任何问题,所以我选择不使用 Java 关机安全网。
package org.gaf.bme280.test;
import com.diozero.api.SpiConstants;
import com.diozero.devices.BME280;
import com.diozero.util.Diozero;
import com.diozero.util.SleepUtil;
import java.io.IOException;
public class TestBME280 {
public static void main(String[] args) throws
InterruptedException, IOException {
boolean useI2C = true;
int number = 3;
switch (args.length) {
case 2: // set device type AND iterations
number = Integer.parseInt(args[1]);
case 1: // set device type
if (!args[0].toLowerCase().
equals("i"))
useI2C = false;
break;
default: // use defaults
}
BME280 bme280;
if (useI2C)
bme280 = new BME280();
else
bme280 = new BME280(SpiConstants.CE0);
try (bme280) {
for (int i = 0; i < number; i++) {
bme280.waitDataAvailable(10, 5);
float[] tph = bme280.getValues();
float tF = tph[0] * (9f/5f) + 32f;
float pHg = tph[1] * 0.02953f;
System.out.format(
"T=%.1f\u00B0C or %.1f\u00B0F "
+ " P=%.1f hPa or %.1f inHg "
+ " RH=%.1f%% %n",
tph[0], tF, tph[1], pHg, tph[2]);
SleepUtil.sleepSeconds(1);
}
} finally {
Diozero.shutdown();
}
}
}
Listing 11-1TestBME280 – application to test the diozero BME280 class
关于TestBME280有几件事值得详述:
-
它使用
BME280默认配置参数。这意味着无过采样、无滤波、正常工作(连续采样),采样间隔为 1 秒。 -
BME280以摄氏度为单位提供温度值,以百帕为单位提供压力值,以百分比为单位提供相对湿度值。对于我们这些生活在一个挑战公制的国家的人来说,我把摄氏温度转换成了华氏温度,把百帕转换成了英寸汞柱。 -
正如我在第五章中提到的,NetBeans 中的“远程运行”功能不能处理“花哨的格式”因此,我不得不 ssh 到树莓派来运行并获得正确的格式。
为了好玩,也为了验证正确性,我将TestBME280下载到了树莓派 3 Model B+和树莓派 Zero W。我将 3B+放在了我的实验室,Zero 放在了我的阳台上;我运行程序 120 秒,在 3B+上使用 SPI,在零点上使用 I2C。以下几行显示了该期间结束时产生的结果:
-
3B+:温度=23.1 摄氏度或 73.6 华氏度,压力=993.9 百帕或 29.4 英寸汞柱,相对湿度=44.9%
-
ZW:温度=18.5 摄氏度或 65.3 华氏度压力=998.5 百帕或 29.5 英寸汞柱相对湿度=52.5%
结果与预期一致。显然,外面更凉爽、更潮湿,气压几乎相同。
鉴于这一成功,您现在可以使用 Zero W 和 BME280 创建一个室外气象站。你也可以使用第八章中的 RoboClaw】、第九章中的 PIMU、第十章中的激光雷达和 BME280 来创建一个机器人漫游车,以监控你家中可到达区域的环境条件。
与 I2C 和 SPI 一起玩
虽然 diozero BME280类的存在在现实世界中对你有好处,但对本书的虚拟世界来说就没那么好了。你被骗了
-
与
I2CDevice的额外经历(第九章在本书中提供了你的第一次经历) -
如何使用
SpiDevice -
或许更重要的是,I2C 和 SPI 在相同背景下的差异
BME280 为这三者提供了一个绝佳的机会。
正如在第八章中所讨论的,简单地让用你的设备玩会很有教育意义。例如,如果您正在使用一种不熟悉的 I/O 功能,您对数据手册的内容有所怀疑(这种情况并不少见),或者您迫不及待地想使用您闪亮的新设备,Play 可能会特别有用。
第八章中的机器人法律由于复杂性而没有参赛资格。对于第十章中的设备,特别是第九章中的设备,我们有太多的工作要做,但是我们专注于库的开发,从一个有用的核心实现开始。BME280 绝对适合玩。因此,在本章的剩余部分,我们将使用I2CDevice和SpiDevice在 BME280 上玩。虽然这不是真正必要的,但我决定为我们将要使用的主要类创建一个新的包;我称之为org.gaf.io.test。
到数据表
承认冗余,我再次声明,器件数据手册(或用户手册)是理解如何与之交互的关键。因此,这是玩它的关键。博世 BME280 数据表的第五部分描述了存储器或寄存器映射,并描述了与通信类型无关的寄存器用途。数据手册的第六部分讨论了通过 I2C 和 SPI 的通信。第五部分是开始的地方。你可以看到
-
两块校准数据寄存器;这些是只读的。
-
一组传感器数据寄存器;这些也是只读的。
-
一个 ID 寄存器;只读。
-
一个复位寄存器;只写。
-
三个控制寄存器;读/写。
-
一个状态寄存器;只读。
-
I2C 控制器自动增加块读取次数;它不自动递增块写入。
-
SPI 控制器自动递增块读取数;它不自动递增块写入。
根据这些信息,您可以确定要使用 BME280,您需要以下操作:
-
读取单个寄存器。
-
读取寄存器块。
-
写单个寄存器。
I2C 设备
第七章确定了I2CDevice支持的基本读/写方法。表 11-1 显示了通过 I2C 使用 BME280 所需操作的映射。
表 11-1
BME280 操作的 I2C 设备方法
|BME280 操作
|
I2C 设备方法
|
| --- | --- |
| 读取单个寄存器 | readByteData |
| 读取寄存器块 | readI2CBlockData |
| 写单个寄存器 | writeByteData |
如果你想变得更复杂,你可以使用一些方便的方法。关于ByteBuffer的用法,可以查看BME280中的私有方法readCoefficients和公共方法getValues。
为了玩,我试着从尽可能简单的开始,然后在需要的时候变得更复杂。清单 11-2 显示了PlayI2C,一个测试使用 I2C 访问 BME280 的简单程序。它在 I2C 总线 1 上用默认的 BME280 地址实例化了一个I2CDevice实例。然后,它读取并打印一个寄存器,在本例中是 ID(或“我是谁”)寄存器。
package org.gaf.io.test;
import com.diozero.api.I2CConstants;
import com.diozero.api.I2CDevice;
public class PlayI2C {
private static I2CDevice device = null;
public static void main(String[] args) {
device = new I2CDevice(
I2CConstants.CONTROLLER_1, 0x76);
// 1: test read a register
byte reg = device.readByteData(0xd0);
System.out.format("ID=0x%02X%n", reg);
// close
device.close();
}
}
==========================================================
Output:
ID=0x60
Listing 11-2PlayI2C initial snippet; read a register
运行PlayI2C会产生清单 11-2 底部显示的输出。读取的值是好消息,因为它是预期值(参见数据手册)。
清单 11-3 显示了测试块读取的PlayI2C的附加片段。它读取一组七个校准寄存器,然后读取两个单独的寄存器作为检查。
// 2: test read register block
byte[] ret = new byte[7];
device.readI2CBlockData(0xe1, ret);
System.out.print("cal");
for (int i = 0; i < 7; i++) {
System.out.format(" %d=0x%02X ", i, ret[i]);
}
System.out.println();
reg = device.readByteData(0xe1);
System.out.format("cal 0=0x%02X%n", reg);
reg = device.readByteData(0xe7);
System.out.format("cal 6=0x%02X%n", reg);
==========================================================
Output:
cal 0=0x87 1=0x01 2=0x00 3=0x0F 4=0x2F 5=0x03 6=0x1E
cal 0=0x87
cal 6=0x1E
Listing 11-3PlayI2C snippet; read a register block
运行PlayI2C产生用于读取清单 11-3 底部所示块的输出。作为块读取的值也是一个好消息,因为两个寄存器分别读取来确认块值。
清单 11-4 显示了测试写寄存器的PlayI2C的最后一个片段。它读取一个配置寄存器,写入该寄存器,然后再次读取该寄存器以确认更改。最后,既然我们已经确认写入寄存器工作正常,它就会复位器件,为以后的测试创建一个已知状态。
// 3: test write a register
reg = device.readByteData(0xf4);
System.out.format("reg before=0x%02X%n", reg);
device.writeByteData(0xf4, (byte)0x55);
reg = device.readByteData(0xf4);
System.out.format("reg after=0x%02X%n", reg);
// reset
device.writeByteData(0xe0, (byte)0xb6);
==========================================================
Output:
reg before=0x00
reg after=0x55
Listing 11-4PlayI2C snippet; write a register
运行PlayI2C产生写寄存器的输出,如清单 11-4 底部所示。读取的最终值表明写入寄存器有效。
SpiDevice
第七章确定了SpiDevice支持的基本读/写方法。表 11-2 显示了通过 SPI 使用 BME280 所需操作的映射。
表 11-2
BME280 操作的 SpiDevice 方法
|BME280 操作
|
SpiDevice 方法
|
| --- | --- |
| 读取单个寄存器 | writeAndRead |
| 读取寄存器块 | writeAndRead |
| 写单个寄存器 | write |
与I2CDevice方法不同,SPI 方法都没有寄存器地址参数。这意味着必须研究数据手册的第六部分,以了解如何使用 SPI 控制 BME280。
让我们看看如何读取单个寄存器。它需要两个 SPI 帧。在第一个 SPI 帧中,必须写入一个“控制字节”,它是一个 8 位寄存器地址,最高有效位(MSB)设为“1”(表示读取操作)。注意,寄存器的内容出现在第二帧中(记住 SPI 是全双工的)。不太明显的是,你必须通过写一个第二字节来确保第二帧发生。写入的第二个字节的内容没有意义。由于我们写入两个帧(字节),设备返回两个帧(字节)。返回的双字节数组中的第一个字节是垃圾,第二个字节是所需寄存器的内容。
读取寄存器块与读取单个字节没有太大区别;它需要 N+1 帧,其中 N 是您希望读取的字节数。您必须在第一帧中写入第一个寄存器地址*(MSB 设置为“1”),然后写入 N 个额外的帧以读取所需的字节。由于器件在读取时会自动递增,因此后续写入的字节内容没有意义。返回的字节数组中的第一个字节是垃圾,其余的是所需寄存器的内容。*
写入单个寄存器也需要两个 SPI 帧。在第一个 SPI 帧中,再次写入一个“控制字节”,它是一个 8 位寄存器地址,MSB 设为“0”(表示写操作)。在第二帧中,您写入寄存器的所需内容。
清单 11-5 显示了PlaySPI,一个测试使用 SPI 访问 BME280 的简单程序;它执行与清单 11-2 中的PlayI2C相同的测试。它使用CEO作为设备使能引脚来实例化一个SpiDevice实例。它读取一个寄存器,同样是“我是谁”寄存器。我把PlaySPI和PlayI2C放在同一个包里。
私有方法readByte实现了之前对如何读取单个寄存器的描述。这使得该方法类似于I2CDevice.readByteData方法。
import com.diozero.api.SpiDevice;
import com.diozero.api.SpiConstants;
public class PlaySPI {
private static SpiDevice device = null;
public static void main(String[] args) {
device = new SpiDevice(SpiConstants.CE0);
// 1: test read a register
byte reg = readByte(0xd0);
System.out.format("ID=0x%02X%n", reg);
// close
device.close();
}
private static byte readByte(int address) {
byte[] tx = {(byte) (address | 0x80), 0};
byte[] rx = device.writeAndRead(tx);
return rx[1];
}
}
==========================================================
Output:
ID=0x60
Listing 11-5PlaySPI initial snippet; read a register
运行PlaySPI会产生清单 11-5 底部显示的输出。结果显示读取成功。
清单 11-6 显示了测试读取寄存器块的PlaySPI的附加片段。第一个代码片段在device.close()语句前的main方法中插入了几行代码。第二段代码是添加到类中的私有方法。readByteBlock类似于I2CDevice.readByteBlock方法。它实现了之前对如何读取寄存器块的描述。
// 2: test read register block [gos in main method]
byte[] ret = readByteBlock(0xe1, 7);
System.out.print("cal");
for (int i = 0; i < 7; i++) {
System.out.format(" %d=0x%02X ", i, ret[i]);
}
System.out.println();
reg = readByte(0xe1);
System.out.format("cal 0=0x%02X%n", reg);
reg = readByte(0xe7);
System.out.format("cal 6=0x%02X%n", reg);
private static byte[] readByteBlock(int address,
int length) {
byte[] tx = new byte[length + 1];
tx[0] = (byte) (address | 0x80);
/* NOTE: array initialized to 0 */
byte[] rx = device.writeAndRead(tx);
byte[] data = new byte[length];
System.arraycopy(rx, 1, data, 0, length);
return data;
}
==========================================================
Output:
cal 0=0x76 1=0x01 2=0x00 3=0x12 4=0x22 5=0x03 6=0x1E
cal 0=0x76
cal 6=0x1E
Listing 11-6PlaySPI snippets; read a register block
运行PlaySPI产生用于读取清单 11-6 底部所示寄存器块的输出。结果显示成功的块读取。请注意,一些校准寄存器值与 I2C 测试不同,因为我使用了不同的 BME280 分线板来测试 I2C。
清单 11-7 显示了测试写寄存器的PlaySPI的最后片段。同样,有两个片段。第一个代码片段在device.close()语句前的main方法中插入了几行代码。第二段代码是添加到类中的私有方法。writeByte类似于I2CDevice.writeByteData方法。它实现了之前对如何写单个寄存器的描述。
// 3: test write a register [goes in main method]
reg = readByte(0xf4);
System.out.format("reg before=0x%02X%n", reg);
writeByte(0xf4, (byte)0x55);
reg = readByte(0xf4);
System.out.format("reg after=0x%02X%n", reg);
// reset
writeByte(0xe0, (byte)0xb6);
private static void writeByte(int address,
byte value) {
byte[] tx = new byte[2];
tx[0] = (byte) (address & 0x7f); // msb must be 0
tx[1] = value;
device.write(tx);
}
==========================================================
Output:
reg before=0x00
reg after=0x55
Listing 11-7PlaySPI snippets; write a register
运行PlaySPI产生写寄存器的输出,如清单 11-7 底部所示。结果显示写入成功。
超越玩耍的一步
有时你读到一些关于一种设备的东西,让你想知道它对你的图书馆有什么影响。如果您之前阅读过 BME280 数据手册的要点,您会注意到上电复位涉及将补偿参数从 NVM 复制到寄存器,并且整个上电序列最多需要两毫秒。如果您检查现有的库,您会发现在软复位后,它们会等待 NVM 数据复制完成后再继续。我想知道它确实需要多长时间。
另一个有趣的消息是,BME280 允许 SPI 时钟频率高达 10 MHz。我想了解一下更高频率对性能的影响。
清单 11-8 显示了包org.gaf.io.test中的程序PlayReal,它让我们调查这两个主题。PlayReal
-
复制清单 11-7 中
PlaySPI的私有方法来读写字节。 -
使用
SpiDevice.Builder内部类来简化 SPI 时钟频率的设置;如清单 11-8 所示,频率初始设置为 1 MHz。 -
重置设备。
-
读取状态寄存器以检测启动何时完成;它递增计数器以跟踪状态读取的次数。
-
打印相关信息。
package org.gaf.io.test;
import com.diozero.api.SpiConstants;
import com.diozero.api.SpiDevice;
public class PlayReal {
private static SpiDevice device = null;
public static void main(String[] args) {
device = SpiDevice.builder(
SpiConstants.CE0).
setFrequency(1_000_000).build();
// reset
writeByte(0xe0, (byte)0xb6);
long tStart = System.nanoTime();
int cnt = 1;
while (readByte(0xf3) == 0x01) {
cnt++;
}
long tEnd = System.nanoTime();
long deltaT = (tEnd - tStart) / 1000;
System.out.println("Startup time = " +
deltaT + "micros." );
System.out.println(
"Status read iterations = " + cnt +
". Iteration duration = " +
(deltaT/cnt) + "micros.");
// close
device.close(); }
private static byte readByte(int address) {
byte[] tx = {(byte) (address | 0x80), 0};
byte[] rx = device.writeAndRead(tx);
return rx[1];
}
private static void writeByte(int address,
byte value) {
byte[] tx = new byte[2];
tx[0] = (byte) (address & 0x7f);
tx[1] = value;
device.write(tx);
}
}
==========================================================
Output:
Startup time = 1553 micros
Status read iterations = 38; Iteration duration = 40 micros
Listing 11-8PlayReal
以 1 MHz 的 SPI 频率运行PlayReal,您应该会看到类似于清单 11-8 底部的结果。在所示示例中,启动花费了 1553μs,读取状态的平均时间为 40μs。在几次执行中,启动时间从 1513 到 1633μs,平均读取时间从 40 到 44μs 不等。
现在将 SPI 时钟频率更改为 10 MHz 并运行PlayReal。您应该会看到类似于清单 11-9 的结果。
Output:
Startup time = 1552 micros
Status read iterations = 63; Iteration duration = 24 micros
Listing 11-9PlayReal results at 10 MHz
在 10 MHz 的几次执行中,启动时间从 1544 到 1561μs,平均读取时间从 24 到 27μs。从这些数据中,我们可以确定
-
启动时间肯定总是少于两毫秒,但确实有所不同。
-
10 MHz 的读取性能比 1 MHz 快,但不到 2 倍。块读取的性能提升可能会更好。我会把测试留给你!
摘要
在本章中,您学习了
-
利用 diozero 设备库的好处。没有工作!
-
分析使用设备所需的 I/O 操作的基础知识。
-
使用 diozero
SpiDevice方法执行设备所需的 I/O 操作的基本知识。 -
读取或写入同一器件时,I2C 和 SPI 的区别。
-
用你的设备玩是有教育意义的。
人物 11-1 和 11-2 是用熔块( https://fritzing.org )制作的。
十二、模拟转换器
与一些竞争对手不同,树莓派不提供真正的模拟 I/O。模拟输入特别有趣,因为物联网项目经常需要监控产生模拟信号的东西。在本章中,我们将为模拟转换器(ADC)制作一个器件库。对于本书,我选择了 Microchip MCP3008,它是该公司制造的 ADC 大家族中的一员,原因如下:
-
它既便宜又容易获得。
-
它很容易使用(一旦你理解它)。
-
它使用 SPI 的方式与许多 SPI 器件不同。
在这一章中,我将介绍
-
发现 diozero 支持您的设备的乐趣!
-
用你的设备玩的好处,即使你已经有了一个库
了解设备
一如既往,你必须了解你的设备。您可以在 https://ww1.microchip.com/downloads/en/DeviceDoc/21295d.pdf 找到 Microchip MCP30008 数据手册。这表明该装置使用起来相对简单。以下是一些有趣的亮点:
-
它支持 8 个单端通道或 4 个伪差分对。
-
每次 SPI 交互只能转换一个通道。
-
它提供 10 位分辨率的值。所报告的值实际上是输入电压相对于参考电压的百分比。
-
最大 SPI 时钟频率取决于电源电压,范围从 5V 时的 3.6 MHz 到 2.7V 时的 1.35 MHz,假设为线性,对于 3.3V 电源,最大频率约为 1.9 MHz。
-
最大采样速率为 SPI 时钟频率除以 18。
-
您可以使用不同的方法从设备中读取值;参见数据手册的第 5.0 节和第 6.1 节。
查找设备库
为了找到要使用或移植的设备库,我将遵循第六章中概述的步骤。第一步是查看 diozero 设备库列表,但您可能找不到它。也就是说,搜索 diozero 文档,你会发现一个扩展板部分,其中有一个子部分微芯片模拟转换器。该小节提到了相应的类(又名设备库)com.diozero.devices.McpAdc。该库支持 MCP3xxx 系列的几个成员,包括 MCP3008!
尽职调查要求您检查库以确保它满足您的需求。不赘述,单看McpAdc ( https://github.com/mattjlewis/diozero/blob/master/diozero-core/src/main/java/com/diozero/devices/McpAdc.java )的实现,暴露的只是单端采集。如果这对您的项目来说足够了,您可以立即使用它。如果需要使用差分采集或定制 SPI 时钟频率(默认为电源电压为 2.7V 时的最大频率),您必须创建自己的实施方案,当然,要从当前的实施方案开始。我应该指出,在内部,McpAdc确实支持差异获取。
Note
再一次,在我意识到 diozero 支持它之前,我选择了 MCP3008 在书中使用。同样,我认为 diozero 对该设备的支持主要归功于该设备家族的流行。
正如在第十一章中讨论的 BME280 一样,在一个真实项目的背景下,你会继续前进。在本书的上下文中,为了完整起见,我将考虑如果我没有在 diozero 中找到支持会怎么样。事实证明,由于设备的普及,您可以找到多个设备库,这有助于您创建自己的设备库:
-
Adafruit 提供了一个针对树莓派(
https://github.com/adafruit/Adafruit_CircuitPython_MCP3xxx)的 CircuitPython 库。 -
在 GitHub 上搜索会产生几个 Python 库,至少一个 JavaScript 库,一些 Android 库。确实是一个受欢迎的设备!
-
一个 Pi4J“测试程序”显示了如何使用该设备(参见
https://nealvs.wordpress.com/2016/02/19/pi4j-adc-mcp3008-spi-sensor-reader-example/)。
即使有所有可用的可能性,如果 diozero 不提供支持,MCP3008(及其家族中的许多产品)是如此简单,您最好从头开始。
使用 diozero McpAdc
演示一下McpAdc将会很有启发性。diozero 文档显示了一个例子,包括一个带有所有连接的图。我创建了一个更简单的测试环境。图 12-1 显示了 1kω电阻的级联和 MCP3008 通道 0–4 的测量点。
图 12-1
MCP3008 测试电阻级联
当然,您还必须将树莓派连接到 MCP3008。见图 12-2 。 1 首先,将 Pi +3.3V(例如,接头引脚 1)连接到 MCP3008 V DD 和 V REF 并将 Pi 地(例如,接头引脚 6)连接到 MCP3008 AGND 和 DGND。你应该用 Pi SPI 总线 0;将 Pi MOSI(插头插脚 19)连接到 MCP 3008D 中的,Pi MISO(插头插脚 21)连接到 MCP3008 D 中的,Pi SCLK(插头插脚 23)连接到 MCP3008 CLK,Pi CE1(插头插脚 26)连接到 MCP3008 CS。您还必须将 MCP3008 通道连接到电阻级联,如图 12-1 所示。
图 12-2
树莓派与 MCP3008 的连接
要开始使用测试应用程序,请创建一个新的 NetBeans 项目、包和类。在您的树莓派上配置用于远程开发的项目;并将项目配置为使用 diozero(更多细节参见第七章)。我将我的项目命名为 MCP3008 ,我的包命名为org.gaf.mcp.test,我的类命名为TestMcpAdc,它是从 diozero 示例应用程序( https://github.com/mattjlewis/diozero/tree/master/diozero-sampleapps/src/main/java/com/diozero/sampleapps )中找到的 diozero McpAdcTest派生而来的。
清单 12-1 显示了类别TestMcpAdc。该实现为 MCP3008 构建了一个McpAdc,指示使用 CE1 来选择器件,并将参考电压设置为 3.3V,然后读取通道 0–4 并打印结果。请注意,该应用遵循第七章中的安全网指南。
package org.gaf.mcp.test;
import static com.diozero.api.SpiConstants.CE1;
import com.diozero.devices.McpAdc;
import com.diozero.devices.McpAdc.Type;
import com.diozero.util.Diozero;
public class TestMcpAdc {
public static void main(String[] args) {
try (McpAdc adc = new McpAdc(Type.MCP3008,
CE1, 3.3f)) {
for (int i = 0; i < 5; i++) {
System.out.format("V%1d = %.2f FS%n",
i , adc.getValue(i));
}
} finally {
Diozero.shutdown();
}
}
}
Listing 12-1TestMcpAdc
运行TestMcpAdc产生清单 12-2 中所示的输出。考虑到电阻值的测量误差和精度,这是您应该预料到的。
V0 = 0.00 FS
V1 = 0.25 FS
V2 = 0.50 FS
V3 = 0.75 FS
V4 = 1.00 FS
Listing 12-2Results from running TestMcpAdc
Caution
对于实际应用,有关模拟输入的缓冲和滤波,请参见数据手册的第 6.3 节。
玩 SPI
正如我前面提到的,MCP3008 提供了一个 SPI 与许多其它器件不同用法的例子。没有要写入的寄存器;只有 SPI 帧写入器件时产生的数据需要读取。这提供了另一个玩耍的机会!
仔细查看数据手册的第 5 和第六部分,会发现器件在起始位之后的第七个时钟开始返回有效数据位。有了这些信息,您就可以将起始位置于一组 SPI 帧中,以便根据您的需要优化有效数据的位置。在本节中,我们将研究从设备中检索数据的两种不同方法:
-
操作方式
McpAdc(参见数据表第五部分,尤其是图 5.1)。 -
数据表第 6.1 节中描述的方法,特别是图 6.1。
清单 12-3 显示了类别TestMCP(在包装org.gaf.mcp.test)中)。假设通道输入如图 12-1 所示。一、看方法getValueD;它实现了数据手册第五部分中的样本读取方法。第一条语句创建一个代码字节,其中通道号位于 3 个最低有效位,第 3 位为“1”以指示单端读取,第 4 位为起始位(a“1”)。下一条语句创建一个三字节数组,用于产生一个三帧 SPI 事务;第一个字节包含代码字节,第二个和第三个字节没有意义,但对于创建第二个和第三个 SPI 帧是必要的。来自SpiDevice.writeAndRead方法的响应包含三个字节。起始位的定位意味着第一个字节是垃圾,第二个字节包含 10 位样本值的八个最高有效位,第三个字节的两个最高有效位包含 10 位值的两个最低有效位。最后几行操作第二个和第三个字节来创建返回的 10 位值。
package org.gaf.mcp.test;
import com.diozero.api.SpiConstants;
import com.diozero.api.SpiDevice;
public class TestMCP {
private static SpiDevice device = null;
public static void main(String[] args) {
// use CE1; frequency = 1.35MHz
device = SpiDevice.builder(SpiConstants.CE1).
setFrequency(1_350_000).build();
int[] value = new int[5];
for (int i = 0; i < 5; i++) {
value[i] = getValueD(i);
}
for (int i = 0; i < 5; i++) {
System.out.format(
"C%1d = %4d, %.2f FS, %.2fV %n",
i, value[i], getFS(value[i]),
getVoltage(value[i], 3.3f));
}
device.close();
}
private static int getValueD(int channel) {
// create start bit & channel code;
// assume single-ended
byte code = (byte) ((channel | 0x18));
// first byte: start bit, single ended,
// channel
// second and third bytes create total
// of 3 frames
byte[] tx = {code, 0, 0};
byte[] rx = device.writeAndRead(tx);
int lsb = rx[2] & 0xf0;
int msb = rx[1] << 8;
int value = ((msb | lsb) >>> 4) & 0x3ff;
return value;
}
private static int getValueM(int channel) {
// create channel code; assume single-ended
byte code = (byte) ((channel << 4) | 0x80);
// first byte has start bit
// second byte says single-ended, channel
// third byte for creating third frame
byte[] tx = {(byte)0x01, code, 0};
byte[] rx = device.writeAndRead(tx);
int lsb = rx[2] & 0xff;
int msb = rx[1] & 0x03;
int value = (msb << 8) | lsb;
return value;
}
private static float getFS(int value) {
float fs = ((float)value / 1024f);
return fs;
}
private static float getVoltage(int value,
float vRef) {
float voltage =
((float)value / 1024f) * vRef;
return voltage;
}
}
Listing 12-3TestMCP
接下来看方法getValueM;它实现了数据手册第 6.1 节中的样本读取方法。第一条语句创建一个代码字节,第 7 位为“1”以指示一个单端读取,第 4、5 和 6 位为通道号。下一条语句创建一个三字节数组,其中第一个字节包含最低有效位中的起始位,第二个字节包含代码字节,第三个字节无意义,但对于创建第三个 SPI 帧是必需的。来自SpiDevice.writeAndRead方法的响应包含三个字节。起始位的定位意味着第一个字节是垃圾,第二个字节的两个最低有效位包含 10 位样本值的两个最高有效位,第三个字节包含 10 位样本值的八个最低有效位。最后几行操作第二个和第三个字节来创建返回的 10 位值。
清单 12-3 显示TestMCP构建了一个SpiDevice实例,该实例使用 CE1 进行器件选择,并将 SPI 时钟频率设置为 1.35 MHz(以确保其低于 3.3V 的最大频率)。注意,使用SpiDevice.Builder允许我们接受 SPI 控制器(0)和位顺序(MSB 优先)的所需默认值。然后使用getValueD读取通道 0–4。最后,它打印出原始值、满量程值(用于与TestMcpAdc比较)以及使用基准电压计算的电压。
运行TestMCP产生清单 12-4 中所示的输出。满刻度结果看起来与运行TestMcpAdc的结果相同。这证明了getValueD的正确实施。
C0 = 0, 0.00 FS, 0.00V
C1 = 254, 0.25 FS, 0.82V
C2 = 512, 0.50 FS, 1.65V
C3 = 767, 0.75 FS, 2.47V
C4 = 1023, 1.00 FS, 3.30V
Listing 12-4Results from TestMCP
为了好玩,在TestMCP中,将对getValueD的调用替换为对getValueM的调用,并再次运行TestMCP。您应该会看到与清单 12-4 非常相似的结果。这很好,并且证实了对 MCP3008 使用 SPI 的方式的正确理解(并且有不止一种方法来剥一只猫的皮)。
Tip
在测试 MCP3008 期间,我最初使用 10kΩ电阻。当我移动传输字节中的起始位位置时,我收到了一个通道的不同值。这促使我们再次检查 MCP3008 数据手册。在第四部分中,我发现了以下陈述:“较大的源阻抗会增加转换的失调、增益和积分线性误差。”我换成 1kω电阻;我开始通过一系列起始位位置获得一致的值。可惜,有时候,你一定要注意细节!
把玩耍变成现实
如果你想想清单 12-3 中的TestMCP,你会意识到它基本上做了真实设备库会做的一切,只是非常非正式。因此,该剧超越了前几章中的核心实现。尽管没有必要,为什么不干脆创建一个 MCP3008 库呢?
当然,首先,我们需要在现有的 MCP3008 项目中创建一个包和类。我将调用包org.gaf.mcp3008和类MCP3008。
列表 12-5 显示MCP3008。如你所料,这个类实现了AutoCloseable,因此有了一个close方法(参见第七章)。该类有两个构造器来模仿McpAdc。与McpAdc不同,它有三种方法来获取一个频道的信息:
-
getRaw为通道提供未处理的值。注意,它只是清单 12-3 中TestMCP的getValueM的重命名副本。 -
getFSFraction提供一个通道的值,作为满量程的一部分。 -
getVoltage为一个通道提供电压。
package org.gaf.mcp3008;
import com.diozero.api.RuntimeIOException;
import static com.diozero.api.SpiConstants.
DEFAULT_SPI_CONTROLLER;
import com.diozero.api.SpiDevice;
import java.io.IOException;
public class MCP3008 implements AutoCloseable {
private SpiDevice device = null;
private final float vRef;
public MCP3008(int chipSelect, float vRef)
throws IOException {
this(DEFAULT_SPI_CONTROLLER,
chipSelect, vRef);
}
public MCP3008(int controller, int chipSelect,
float vRef) throws IOException {
try {
device = SpiDevice.
builder(chipSelect).
setController(controller).
setFrequency(1_350_000).build();
this.vRef = vRef;
} catch (RuntimeIOException ex) {
throw new IOException(ex.getMessage());
}
}
@Override
public void close() {
if (device != null) {
device.close();
device = null;
}
}
public int getRaw(int channel)
throws RuntimeIOException {
// create channel code; assume single-ended
byte code = (byte) ((channel << 4) | 0x80);
// first byte has start bit
// second byte says single-ended, channel
// third byte for creating third frame
byte[] tx = {(byte)0x01, code, 0};
byte[] rx = device.writeAndRead(tx);
int lsb = rx[2] & 0xff;
int msb = rx[1] & 0x03;
int value = (msb << 8) | lsb;
return value;
}
public float getFSFraction(int channel)
throws RuntimeIOException {
int raw = getRaw(channel);
float value = raw / (float) 1024;
return value;
}
public float getVoltage(int channel)
throws RuntimeIOException {
return (getFSFraction(channel) * vRef);
}
}
Listing 12-5MCP3008
为了测试,显然我们需要一个新的主类。我会调用我的TestMCP3008放在现有的包org.gaf.mcp.test里。清单 12-6 显示了新的主类。它的基本结构是清单 12-1 中TestMcpAdc的一个副本,但是格式化的输出使用了MCP3008中的所有三种数据访问方法;调用每个方法是低效的,因为设备被读取三次;但这不是真实的世界!
package org.gaf.mcp.test;
import static com.diozero.api.SpiConstants.CE1;
import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.mcp3008.MCP3008;
public class TestMCP3008 {
public static void main(String[] args)
throws IOException {
try (MCP3008 adc = new MCP3008(CE1, 3.3f)) {
for (int i = 0; i < 5; i++) {
System.out.format("C%1d = %4d, %.2f FS,
%.2fV %n", i, adc.getRaw(i),
adc.getRelative(i),
adc.getVoltage(i));
}
} finally {
Diozero.shutdown();
}
}
}
Listing 12-6TestMCP3008
运行TestMCP3008,你应该会看到一些现在熟悉的结果,如清单 12-7 所示。成功!
C0 = 2, 0.00 FS, 0.01V
C1 = 256, 0.25 FS, 0.82V
C2 = 512, 0.50 FS, 1.65V
C3 = 769, 0.75 FS, 2.48V
C4 = 1022, 1.00 FS, 3.30V
Listing 12-7Results from TestMCP3008
我不能声称MCP3008可以取代McpAdc,如果没有其他原因,它只适用于 MCP3008。前者也没有后者的复杂;例如,它没有继承 diozero 框架的大部分内容,也不支持伪差分采样。也就是说,如果McpAdc不存在,MCP3008将为许多项目服务。
摘要
在本章中,您学习了
-
你应该在 diozero 中彻底搜索你的设备的支持;可能是“躲”。
-
同样,有时您可以在 diozero 中找到一个现有的设备库,几乎不用做任何工作。
-
SPI 器件使用 SPI 的方式有很大不同。
-
有时候玩耍可以接近真正的代码。
-
魔鬼可以藏在细节里。
图 12-2 是用熔块( https://fritzing.org )制作的。
十三、步进电机驱动器
在这一章中,我们将为步进电机驱动器建立一个设备库。步进电机主要用于机器人项目,但也可以用于 IOT 项目。
驱动步进电机的方式有很多种,包括简单的驱动器,如分立晶体管和 H 桥,它们迫使你完成大部分工作,也包括复杂的驱动器为你完成大部分工作。在本书中,我们将看看 Watterott SilentStepStick(https://learn.watterott.com/silentstepstick/)。这是我在一些项目中使用的驱动程序;它的主要吸引力在于无声的操作。我认为它处于“复杂范围”的中间,但是它仍然非常容易使用。
在这一章中,我将讨论
-
使用多个 diozero 基本 I/O 设备,特别是 GPIO 输出设备,来构建单个逻辑设备
-
查找并忽略现有的设备库
-
探索 diozero 的选项和限制
了解设备
SilentStepStick 分线板( https://github.com/watterott/SilentStepStick/blob/master/hardware/SilentStepStick-TMC2100_v10.pdf )利用 Trinamic TMC2100 芯片( www.trinamic.com/fileadmin/assets/Products/ICs_Documents/TMC2100_datasheet_Rev1.11.pdf )。这意味着你要阅读并理解两张数据表。幸运的你(当然还有我)。我建议浏览 TMC2100 数据手册,然后仔细阅读 SilentStepStick 数据手册,然后仔细阅读 TMC2100 数据手册。以下是 TMC2100 最显著的特性:
-
以每线圈高达 2A 的速度驱动双极电机,电压从 4.75V 到 46V。
-
每步可插入高达 256 微步的步长。
-
StealthChop 模式支持“极其安静”的操作。
-
启动、方向和步进信号控制运动。
-
七个配置引脚(CFG 0–CFG 6)控制操作;其中之一 CFG6 是使能信号。
-
最大电机电流可以内部或外部控制。
-
逻辑电压可以是 3.3V 或 5V。
以下是 SilentStepStick 的显著特征:
-
CFG0、CFG4 和 CFG5 控制“斩波”操作。这三个都默认为“推荐的、最普遍的选择”CFG4 和 CFG5 有跳线,允许改变默认设置。
-
CFG1 和 CFG2 控制驱动器的模式和微步分辨率。详情参见 TMC2100 数据手册第 9 页的表格或 SilentStepStick 数据手册第三部分的表格。
-
CFG3 配置设置最大电机电流的方式。对于外部控制,它默认为“浮动”。它也有一个跳线,允许从默认的变化。
-
分接头上的电位计调节最大电机电流;两个数据手册都提供了如何调节电流的说明。
实际上,SilentStepStick 建立了一个合理的默认配置,如果您真的需要的话,可以对其进行更改。因此,在大多数情况下,您只需要担心 CFG1 和 CFG2。
当然,只有在你安装了步进电机的情况下,SilentStepStick 才有意思。步进电机是迷人的野兽。参见 https://learn.adafruit.com/all-about-stepper-motors/what-is-a-stepper-motor 获取有用的介绍。步进电机有许多不同的尺寸,需要不同的电压和电流,表现出不同的步长,不同的扭矩,等等。当然,它们有许多不同的用途。
这意味着不可能为设备库或通用配置确定一组真正通用的要求。因此,我将简单地根据我过去的 stepper 项目确定一组库和配置需求。
我有一个双极步进电机,规格为 12V、0.4A 和 200 整步/转(你会遇到的大多数步进电机是 200 步/转)。此外,我会要求无声操作,但越快越好。
我还会做一些简化但合理的假设。首先,CFG0、CFG3、CFG4 和 CFG5 的默认值是可以接受的。第二,库没有设置 CFG1 和 CFG2 的配置;相反,必须告诉它配置产生的每步微步数。这些假设节省了 GPIO 引脚,但可能并不适用于所有项目。
查找设备库
为了找到要使用或移植的设备库,我将遵循第六章中概述的过程。查看 diozero 设备库,没有步进电机驱动程序。
搜索 Java 库没有为 TMC2100 找到任何东西。我确实找到了它更复杂的表亲的库的线索。
搜索非 Java 库
搜索 Python 库没有为 TMC2100 找到任何内容。我又一次找到了它更复杂的表亲的库的线索。
SilentStepStick 产品页面链接到“Arduino 库和示例”、“通用软件库”和“Arduino 示例”前两个不支持 TMC2100,因此没有帮助。最后一个包含了一个非常琐碎的例子,也没有多大帮助。实际上,很令人惊讶。
我在 https://electropeak.com/learn/interfacing-tmc2100-stepper-motor-driver-with-arduino/ 找到了一张 Arduino 草图。该页面包含 SilentStepStick 和 TMC2100 数据手册的有趣摘要以及有用的提示。我期望识别出更多基于 Arduino 的候选者。
你可能已经注意到 SilentStepStick 产品页面上说它与另外两种步进电机控制器兼容,Watterott StepStick 和 Pololu A4988 ( www.pololu.com/product/1182 )。我认为 A4988 部分兼容*。它只有三个配置引脚,控制每步的微步。幸运的是,可用的分辨率与 SilentStepStick 相匹配。另外,幸运的是,Pololu 为 A4988 提供了一个 Arduino 库( https://github.com/laurb9/StepperDriver/blob/master/src/A4988.cpp )。该设计实际上相当复杂,因为它允许“速度曲线”,因此电机从停止加速到额定速度,以额定速度运行,然后减速到停止。*
*### 答案是…
不管是好是坏,由于预期的简单性,我将把这视为“从头开始”的情况。我将使用 A4988 库作为指导,但忽略其复杂的方面,原因有二。首先,我的期望是低速和低扭矩的要求。第二,我必须给你留点事做!在这一章的结尾会有更多关于这个问题的内容。
设备库设计
我将再次使用自顶向下的方法。我们必须从需求开始。概括地说,步进电机用于需要精确位置控制和/或精确速度控制的场合。例如,3D 打印机需要这两者。然而,我过去的步进电机项目只需要速度控制,我将用它们作为驱动需求的模型。根据我过去的项目,我将总结这些要求:
-
想控制旋转的方向和速度。
-
想要开始和停止旋转。
-
预计只有低速。
-
想要启用和禁用驱动程序。值得注意的是,启用时,驱动器为电机供电,因此电机即使在停止时也会产生扭矩。禁用时,驱动器不给电机供电,所以不产生扭矩;因此,轴和任何附在轴上的东西都可以自由移动。
-
不想控制微步配置。反而将的配置告诉了。
如前所述,您必须使用 diozero GPIO 数字输出设备来控制 SilentStepStick。启用和方向控制是静态的,应使用DigitalOutputDevice。速度由步进控制决定;它可以使用一个DigitalOutputDevice或一个PwmOutputDevice。关于这些 diozero 设备的更多信息,请参见第七章。
理论上,一个项目中可以有多个 SilentStepSticks,因此可以有多个库实例;在我上一个踏步机项目中,我实际上使用了三个无声踏步机。这意味着我们必须考虑到多个实例。
Caution
树莓派 OS 不是实时操作系统,Java 也不是实时语言。因此,你不能指望用 SilentStepStick 产生真正精确的步进电机速度控制,因为 Pi 产生的步进信号受操作系统和 Java 的变化影响。也就是说,你可以产生真正精确的步进电机位置控制,因为位置只取决于步数,Pi 可以精确控制。
接口设计
根据前面的要求和对 A4988 库的检查,接口需要提供以下功能的方法
-
启用或禁用驱动程序
-
设置方向,顺时针或逆时针
-
设置旋转速度
-
运行或停止
构造器需要以下参数:
-
使能、方向和步进引脚的 GPIO 引脚
-
每步的微步数,由 SilentStepStick 配置引脚 CFG1 和 CFG2 决定
设备库开发
与任何基于 diozero 的新项目一样,您必须创建一个新的 NetBeans 项目、包和类。在您的树莓派上配置用于远程开发的项目;并将项目配置为使用 diozero。参见第七章了解步骤总结。我将创建一个名为 SSS 的项目(因为 SilentStepStick 太长),一个包org.gaf.sss,一个类SilentStepStick。然而,在创建库之前,你应该认识到无声棒提供了一个完美的游戏机会。这就是我们要做的。
玩设备
当然,在演奏之前,你必须为无声手杖构建适当的电路。这意味着连接电机、电机电源和逻辑电源(来自树莓派的 3.3V)。SilentStepStick 数据表的第 3 页包含一个不错的电路图,您可以将其用作指南;第 6 页包含一些我认为对正确连接电机有用的图片。您还必须调整最大电机电流(参见 SilentStepStick 数据手册第 4 页和 TMC2100 数据手册第 24 页)。
由于配置引脚(包括 enable 引脚)默认为某个合理的值,并且方向无关紧要,因此您可以仅使用 Pi 驱动的 step 引脚来驱动电机。非常好!
一个有趣的问题是如何驱动阶梯销。之前我是用DigitalOutputDevice或者PwmOutputDevice假设的。前者的onOffLoop方法支持给定数量的循环(步骤)或在选定频率下的无限数量的循环;很好!后者只支持无限数量的周期,尽管您可以改变频率;也不错!最后,如果你仔细阅读文档,你会发现对于PwmOutputDevice,期望的频率必须是一个整数;相比之下,使用DigitalOutputDevice,你在浮点中设置开和关周期,因此,有效地,频率在浮点中。因此,尽管这两个类都可以工作,DigitalOutputDevice提供了更多的灵活性,所以我将使用它。
一个重要的问题是驱动电机的 PWM 信号使用什么频率。你不想走得太快或太慢。马达每转 200 步。1 RPM = 1/60 转/秒(RPS),因此要产生 1 RPM,必须以 200/60 = ~3.333 Hz 驱动电机。如果您选择的驱动程序配置使用微步,您必须将该结果乘以每步的微步数。例如,如果您的配置为每步 4 微步,要产生 1 RPM,您必须以(200/60) * 4 = ~13.333 Hz 驱动电机。
由于我是在静默操作之后,所以我将 CFG1 设置为 3.3V,CFG2 设置为 open,这样可以以 4 微步/步的速度打开 StealthChop。现在,1 RPM 是相当慢的,所以假设速度应该是 4 RPM。使用早期的公式,这意味着频率为 4 * (200/60) * 4 = ~53.333 Hz,产生 18.75 毫秒的周期和 9.375 毫秒的半周期。
现在,我们将创建一个简单的程序来运行步进电机。清单 13-1 显示了包org.gaf.sss.test中的程序Step。这个程序非常简单;它有三个有趣的陈述。第一个创建了一个驱动 GPIO 引脚 17 的DigitalOutputDevice实例,该引脚连接到 SilentStepStick step 引脚;第二个在 GPIO 引脚 17 上产生 53.333 Hz 阶跃信号;第三个在 5 秒后停止步进信号。
注意Step启用 diozero 安全网。这是因为DigitalOutputDevice使用不同的螺纹来驱动阶梯销;该线程必须在关闭时终止。参见第七章。
package org.gaf.sss.test;
import com.diozero.api.DigitalOutputDevice;
import com.diozero.util.Diozero;
public class Step {
public static void main(String[] args)
throws InterruptedException {
try (DigitalOutputDevice pwm =
new DigitalOutputDevice(17, true,
false)) {
pwm.onOffLoop(0.009375f, 0.009375f,
DigitalOutputDevice.
INFINITE_ITERATIONS,
true, null);
System.out.println("Waiting ...");
Thread.sleep(5000);
pwm.stopOnOffLoop();
System.out.println("Done");
} finally {
Diozero.shutdown();
}
}
}
Listing 13-1Step
当您运行Step时,如果一切连接正确,步进电机轴以大约 4 RPM 的速度旋转 5 秒钟。您可以在电机轴上贴一片胶带,以便更容易检测到旋转。
我们已经确认初始硬件和软件配置正常。现在,您可以开始尝试不同的配置和不同的 PWM 频率,找到适合您项目的组合。还可以为方向销的不同状态确定电机方向。
无声的步骤实现
现在,我们将开发SilentStepStick。 1 在前面的章节中,我们首先开发了一个核心。然而在SilentStepStick的情况下,核心和全库差别不大!
清单 13-2 显示了最初的实现。我们从前面的界面讨论中知道,我们需要将旋转方向设置为顺时针或逆时针;枚举提供了适当的常量。我们还需要根据每步的微步来设置配置;Resolution枚举提供了适当的常量。根据章节 7 ,类实现java.io.AutoCloseable;因此,它也有一个close方法,我们将在后面完成。
package org.gaf.sss;
public class SilentStepStick implements
AutoCloseable {
@Override
public void close(){
}
public enum Direction {
CW,
CCW;
}
public enum Resolution {
Full(1),
Half(2),
Quarter(4),
Eighth(8),
Sixteenth(16);
public final int resolution;
Resolution(int resolution) {
this.resolution = resolution;
}
}
}
Listing 13-2SilentStepStick constants and close method
构造器实现
清单 13-3 显示了SilentStepStick构造器。它实现了前面讨论的需求。之前唯一没有提到的参数是stepsPerRev;它指定了被驱动的步进电机每转的步数,这是计算步进信号频率所必需的。
构造器创建一个DigitalOutputDevice驱动 enable 引脚(初始化禁用),第二个驱动 direction 引脚(顺时针初始化),第三个驱动 step 引脚;step 引脚配置为高电平有效,初始设为低电平,因此不会发生步进。该构造器还计算每转的无声步进微步数,稍后用于计算步进信号的频率,以实现所需的速度。
import com.diozero.api.DigitalOutputDevice;
import com.diozero.api.RuntimeIOException;
import com.diozero.util.SleepUtil;
import java.io.IOException;
private DigitalOutputDevice dir;
private DigitalOutputDevice enable;
private DigitalOutputDevice step;
private final float microstepsPerRev;
private boolean running = false;
public SilentStepStick(int enablePin,
int directionPin, int stepPin,
int stepsPerRev, Resolution resolution)
throws IOException {
try {
// set up GPIO
enable = new DigitalOutputDevice(
enablePin, false, false);
dir = new DigitalOutputDevice(
directionPin, true, false);
step = new DigitalOutputDevice(
stepPin, true, false);
// set configuration
microstepsPerRev = (float)
(stepsPerRev * resolution.resolution);
} catch (RuntimeIOException ex) {
throw new IOException(ex.getMessage());
}
}
@Override
public void close() {
// disable
if (enable != null) {
enable.off();
enable.close();
enable = null;
}
// stop
if (step != null) {
// turn it off
step.stopOnOffLoop();
step.close();
step = null;
}
if (dir != null) {
dir.close();
dir = null;
}
}
Listing 13-3SilentStepStick constructor and close method
清单 13-3 也显示了完整的close方法。它确保驱动器被禁用,并且步进信号关闭,以便电机停止。该方法还关闭所有 diozero 设备实例。
清单 13-4 显示了先前描述的操作方法的实施。与setDirection方法一样,enable方法是不言自明的。
在进一步思考了前面的接口讨论之后,似乎应该提供一个单个方法来设置方向、速度和打开步进信号。因此,run方法有方向和旋转速度的参数。它使用参数来设置方向并确定阶跃信号的频率。该方法通过启动DigitalOutputDevice无限开/关循环,以所需频率打开阶跃信号。步进信号一直运行,直到调用stop方法将其关闭。
请注意,我决定在后台运行无限开/关循环。此外,我决定忽略循环停止时得到通知的能力,因为循环必须显式停止。这些选择在我看来是合理的。你可能会做出不同的选择。
public void enable(boolean enableIt)
throws RuntimeIOException {
if (enableIt) {
enable.on();
}
else {
enable.off();
}
}
private void setDirection(Direction direction)
throws RuntimeIOException {
if (direction == Direction.CW) dir.off();
else dir.on();
}
public void run(Direction direction, float speedRPM)
throws RuntimeIOException {
if (running) step.stopOnOffLoop();
// let motor rest (see p.9 of datasheet)
SleepUtil.sleepMillis(100);
setDirection(direction);
float halfPeriod = getHalfPeriod(speedRPM);
step.onOffLoop(halfPeriod, halfPeriod,
DigitalOutputDevice.INFINITE_ITERATIONS,
true, null);
running = true;
}
public void stop() throws RuntimeIOException {
step.stopOnOffLoop();
running = false;
}
private float getHalfPeriod(float speedRPM) {
float speedRPS = speedRPM/60f;
float frequency = speedRPS * microstepsPerRev;
float halfPeriod = 0.5f / frequency;
return halfPeriod;
}
public int getStepCount() {
return step.getCycleCount();
}
Listing 13-4SilentStepStick operative methods
run方法调用getHalfPeriod方法。后者执行前面解释的计算,从速度参数(单位为 RPM)中产生阶跃信号频率。然后,它计算出run用来建立DigitalOutputDevice开/关循环的半周期。
最后,注意清单 13-4 中的getStepCount方法。它不在前面提到的需求或接口中。在玩了Step(清单 13-1 )并思考了清单 13-4 中的run方法的含义后,我意识到类似于getStepCount的东西对于理解“?? 然后是stop场景中的步进电机定位非常有用。我请求 diozero 的开发者在DigitalOutputDevice中插入必要的逻辑。
silentstepstick 测试
现在,我们将测试SilentStepStick。一个好的第一个测试是重现清单 13-1 中Step程序的效果。清单 13-5 显示TestSSS1就是这样做的。
本章中的“应用”当然涉及资源试运行和 diozero 停堆安全网。它们不与 Java 停机安全网接合,因为步进电机轴上没有连接任何东西,因此不当端接不会造成损坏。
package org.gaf.sss.test;
import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.sss.SilentStepStick;
public class TestSSS1 {
public static void main(String[] args)
throws IOException, InterruptedException {
try (SilentStepStick stepper =
new SilentStepStick(4, 27, 17, 200,
SilentStepStick.Resolution.Quarter)) {
stepper.enable(true);
System.out.println("Run CW");
stepper.run(SilentStepStick.Direction.CW, 4f);
Thread.sleep(5000);
System.out.println("Stopping");
stepper.stop();
System.out.println("Count = " +
stepper.getStepCount());
System.out.println("Disabling");
stepper.enable(false);
System.out.println("Closing");
} finally {
Diozero.shutdown();
}
}
}
Listing 13-5TestSSS1
运行TestSSS1,你会看到与运行Step时相同的运动行为;您还应该看到清单 13-6 中显示的结果。请特别注意微步的计数。根据电机规格、微步配置和要求的速度,无声步进杆的步进信号频率应为 53.333Hz;因此,5 秒钟的运行周期应该产生大约 267 的计数;275 的计数有点令人失望,但并非不合理。显然,循环运行得有点快。
Run CW
Stopping
Count = 275
Disabling
Closing
Listing 13-6Results of running TestSSS1
为了更有趣一点,我们现在可以让马达先顺时针转动一会儿,然后再逆时针转动。清单 13-7 显示了TestSSS2,它就是这么做的。
package org.gaf.sss.test;
import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.sss.SilentStepStick;
public class TestSSS2 {
public static void main(String[] args)
throws IOException, InterruptedException {
try (SilentStepStick stepper =
new SilentStepStick(4, 27, 17, 200,
SilentStepStick.Resolution.Quarter)) {
stepper.enable(true);
System.out.println("Run CW");
stepper.run(
SilentStepStick.Direction.CW, 4f);
Thread.sleep(5000);
System.out.println("Stopping");
stepper.stop();
System.out.println("Count = " +
stepper.getStepCount());
System.out.println("Run CCW");
stepper.run(
SilentStepStick.Direction.CCW, 2f);
Thread.sleep(5000);
System.out.println("Stopping");
stepper.stop();
System.out.println("Count = " +
stepper.getStepCount());
stepper.enable(false);
System.out.println("Closing");
} finally {
Diozero.shutdown();
}
}
}
Listing 13-7TestSSS2
运行TestSSS2,如果一切接线正确,你应该看到电机以 4 RPM 顺时针旋转 5 秒,然后以 2 RPM 逆时针旋转 5 秒。成功!
清单 13-8 显示了运行TestSSS2的控制台结果。你可以再次看到顺时针方向是 275。您还可以看到逆时针计数是 138,大约是 275 的一半,所以这个计数似乎也是合理的,如果也比预期的高一些的话。
Run CW
Stopping
Count = 275
Run CCW
Stopping
Count = 138
Disabling
Closing
Listing 13-8Result of running TestSSS2
下一步是什么?
SilentStepStick的实现实现了步进电机的一个好处——速度控制。2DigitalOutputDevice的巧妙选择让我们也能提供精确的位置控制!原因是使用步进电机,精确的位置控制转化为移动精确的步数,而DigitalOutputDevice可以做到这一点。
清单 13-9 显示了执行位置控制的SilentStepStick的stepCount方法。它比run方法更复杂(列表 13-4 ):
-
它不也不允许终止当前运行的任何步进。虽然这个决定有些武断,但它确实有助于保持准确的定位。
-
它展示了
DigitalOutputDevice在前台或后台运行开/关循环的能力。步数可能足够小,以至于在前台运行是有意义的。 -
它公开了
DigitalOutputDevice在开/关循环结束时调用调用者的Action的能力。在大多数背景情况下,这是一个好主意。 -
它必须拦截
DigitalOutputDevice对Action的调用,以维持内部状态。
这些设计决策对我来说似乎是合理和谨慎的,但是您可能会决定做一些不同的事情。
public boolean stepCount(int count,
Direction direction, float speedRPM,
boolean background, Action stopAction)
throws RuntimeIOException {
if (running) {
return false;
} else {
// let motor rest (see p.9 of datasheet)
SleepUtil.sleepMillis(100);
// set up an intercept
Action intercept = () -> {
System.out.println("intercept");
running = false;
};
setDirection(direction);
running = true;
float halfPeriod = getHalfPeriod(speedRPM);
if (stopAction != null) {
step.onOffLoop(halfPeriod, halfPeriod,
count, background,
intercept.andThen(stopAction));
} else {
step.onOffLoop(halfPeriod, halfPeriod,
count, background, intercept);
}
return true;
}
}
Listing 13-9SilentStepStick stepCount method
解释一下stepCount是如何工作的可能会有帮助。首先,我将详细说明Action机制。stepCount总是定义一个内部“拦截”Action,并在对onOffLoop方法的调用中提供。因此,当开/关循环终止时,DigitalOutputDevice总是调用 intercept,因此它可以执行任何内部管理。如果调用者提供了一个非空的stopAction,那么这个Action将在内部Action之后被调用。
接下来,我将解决前景/背景选项。假设调用者选择在前台运行。在调用onOffLoop方法之前,running标志被设置为true。onOffLoop方法
-
运行,直到计数完成
-
调用设置了
running标志false的内部Action(然后调用调用者的Action,如果它存在的话) -
返回到
stepCount方法
然后,stepCount方法用running标志false返回给调用者。
现在假设呼叫者选择在后台运行。在调用onOffLoop方法之前,running标志被设置为true。onOffLoop方法产生一个后台线程来运行开/关循环,并返回到stepCount方法,该方法又返回给带有running标志的调用者true。当后台线程运行开/关循环时,调用者可以执行其他任务。背景线程
-
运行,直到计数完成
-
调用设置了
running标志false的内部Action(然后调用调用者的Action,如果它存在的话)
此时,SilentStepStick实例有了running标志false,可以启动另一个步进器活动。
现在我们可以测试stepCount方法了。清单 13-10 显示了这样做的程序TestSSS3。AtomicBoolean是一个 Java 并发结构,支持两个线程之间的同步通信;TestSSS3用它来知道步数何时结束。从清单 13-10 中可以看出,TestSSS3类似于TestSSS2,除了它要求固定的步数而不是无限的步数。此外,TestSSS3标识计数完成时采用的Action(方法whenDone);它只是通过AtomicBoolean指示计数完成。
package org.gaf.sss.test;
import com.diozero.util.Diozero;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.gaf.sss.SilentStepStick;
public class TestSSS3 {
private static AtomicBoolean done;
public static void main(String[] args)
throws IOException, InterruptedException {
try (SilentStepStick stepper =
new SilentStepStick(4, 27, 17, 200,
SilentStepStick.Resolution.Quarter)) {
done = new AtomicBoolean(false);
stepper.enable(true);
System.out.println("Run CW");
done.set(false);
boolean status = stepper.stepCount(100,
SilentStepStick.Direction.CW, 4f,
true, TestSSS3:: whenDone);
while (!done.get()) {
Thread.sleep(100);
}
System.out.println("DONE");
System.out.println("Count = " +
stepper.getStepCount());
System.out.println("Run CCW");
done.set(false);
status = stepper.stepCount(100,
SilentStepStick.Direction.CCW,
2f, true, TestSSS3:: whenDone);
while (!done.get()) {
Thread.sleep(100);
}
System.out.println("DONE");
System.out.println("Count = " +
stepper.getStepCount());
System.out.println("Disabling");
stepper.enable(false);
System.out.println("Closing");
} finally {
Diozero.shutdown();
}
}
private static void whenDone () {
System.out.println("Device done");
done.set(true);
}
}
Listing 13-10TestSSS3
运行TestSSS3,您应该会看到清单 13-11 中的结果。令人欣慰的是,两个旋转方向的微步计数与要求的计数一致。
Run CW
intercept
Device done
DONE
Count = 100
Run CCW
intercept
Device done
DONE
Count = 200
Disabling
Closing
Listing 13-11Results of running TestSSS3
速度曲线
我在关于库的部分提到了 Pololu A4988 库中实现的“速度配置”的概念。速度曲线在一些步进电机应用中非常重要,尤其是在涉及高速或高扭矩的情况下。论文 www.ti.com/lit/an/slyt482/slyt482.pdf?ts=1615587700571&ref_url=https%253A%252F%252Fwww.google.com%252F 解释了概念和问题。
基本上,速度曲线的目标是将电机从停止加速到目标速度,以目标速度运行一段时间,然后减速到停止。一个很好的问题是,是否有可能使用 diozero base I/O API 实现一个速度配置文件。答案有点复杂:
-
在撰写本文时,答案是否。我基于对
DigitalOutputDevice实现的检查。 -
也就是说,通过前面提到的 Arduino 库建议的一些改变,答案变成了是的,但是。“但是”有几个方面:
-
变化的一种形式可能会以不适合某些项目的方式影响性能。
-
变化的第二种形式是在
DigitalOutputDevice的界面中强制一个潜在的不愉快的变化。 -
使用修改后的
DigitalOutputDevice将需要在前台完成斜坡,或者使用 Java 并发结构或者 diozero 线程结构在后台完成。
-
我认为在现实中,最好的选择是产生一个专门针对步进电机速度曲线的对等体或子类。遗憾的是,这两者都超出了本书的范围。也就是说,如果你真的需要速度配置文件,并且你不想创建一个“速度配置文件”类,你可以找到一个更复杂的步进电机驱动程序来实现配置文件,就像 DC 汽车公司的 RoboClaw 控制器一样(见第八章)。
摘要
在这一章中,你经历了
-
查找现有的设备库,并大多忽略它们
-
大部分从零开始创建设备库
-
用几个 diozero 数字输入输出设备构成一个逻辑设备
-
在实现库之前使用设备
-
意识到 diozero 不能做所有的事情
都是好东西!
Footnotes 1同样,我没有包括 Javadoc 或大多数注释,但是您应该这样做。
2
由于系统的非实时性,其准确性是有问题的。
*