树莓派上的-Java-教程-二-

145 阅读1小时+

树莓派上的 Java 教程(二)

原文:Java on the Raspberry Pi

协议:CC BY-NC-SA 4.0

七、diozero 基本输入/输出 API

在这一章中,您将了解到更多关于 diozero base I/O API 的知识,您将在本书中使用它来创建 Java 库以支持您在树莓派上的设备。我们会掩护

  • diozero 提供的一些有用的实用程序

  • 设备和 Pi 之间的物理连接

  • 基于 Pi I/O 功能的一些背景知识

  • 精选 diozero 基本 I/O API 类的亮点

  • 开发使用 diozero base I/O API 的设备库和应用程序的指南

有关 diozero 基础 I/O API 的更多细节,您应该阅读 diozero Javadoc ( www.javadoc.io/doc/com.diozero/diozero-core/latest/index.html )。

diozero 公用事业

diozero 包括演示其许多特性的示例应用程序。您可能找不到所有这些工具的正式文档,但是您可以在 diozero GitHub ( https://github.com/mattjlewis/diozero/tree/main/diozero-sampleapps/src/main/java/com/diozero/sampleapps )上找到所有这些工具的源代码。

其中最有趣的例子是一些处理基本 I/O 功能的实用程序。我将在下面的相关章节中讨论具体的实用程序。你可以在 www.diozero.com/utilityapps.html 找到一些记录。要使用一个实用程序,您的类路径中必须有diozero-sampleapps-<version>.jarjansi-<version>.jar。您可以在 diozero 发行版的 ZIP 中找到这些罐子(参见第六章)。您可以使用命令在树莓派上运行一个实用程序

java -cp <classpath> com.diozero.sampleapps.<utility>

其中包括diozero-sampleapps-<version>.jarjansi-<version>.jar,是实用程序的类名。

将设备连接到树莓派

本书涵盖的设备通过其 USB 端口或其通用输入/输出(GPIO) 连接器连接到 Raspberry Pi,这里描述: www.raspberrypi.org/documentation/usage/gpio/ 。该参考中提出了一些极其重要的观点:

  • 连接器上的几个引脚提供 5V、3.3V(也称为 3V3)或接地。

  • 在给定时间,连接器中的其余引脚可以配置为提供简单的数字输入或简单的数字输出。一些可配置引脚也可用于其他目的,例如串行、I2C 或 SPI。

  • 任何特定的功能都需要连接器中有一个或多个单独的引脚(例如,I2C 需要两个引脚)。

  • 树莓派是一个 3.3V 系统。这意味着输出引脚产生最大 3.3V 和最小 0V(地)。这意味着输入引脚可以承受 3.3V 的电压;连接到产生 3.3V 以上电压的电源可能会损坏 I/O 芯片,甚至会损坏 Raspberry Pi!

一般来说,有两个 diozero 实用程序与基本 I/O 相关。第一个是GpioDetect,标识您的树莓派上的 GPIO 芯片。下面两行显示了我在 Pi3B+上运行GpioDetect的结果:

gpiochip0 [pinctrl-bcm2835] (54 lines)
gpiochip1 [raspberrypi-exp-gpio] (8 lines)

特别令人感兴趣的是 BCM 芯片;请注意报告中的“bcm2835”。因为 www.raspberrypi.org/documentation/hardware/raspberrypi/ 说 Pi3B+用的是 BCM2837B0,所以有点好奇。搜索该参考的各种链接(以及链接到链接的链接)显示,从 GPIO 的角度来看,Pi3B+具有 bcm2835 架构。因此,您可以使用 BCM2835 文档来查找有关基本 I/O 功能的详细信息。在零 W 上运行GpioDetect产生一条单线

gpiochip0 [pinctrl-bcm2835] (54 lines)

Zero W 确实使用了 BCM2835,因此该报告是有意义的(它没有第二个芯片),并确认了两个树莓派模型共享相同的基本 I/O 架构。很好!

第二个实用程序SystemInformation,描述了您的树莓派的引脚排列,以及其他系统信息。图 7-1 显示了在 Pi3B+上运行SystemInformation的结果。

img/506025_1_En_7_Fig1_HTML.jpg

图 7-1

Pi3B+的系统信息结果

Tip

网页 https://pinout.xyz 也为理解树莓派 GPIO 连接器引脚排列提供了非常有用的指导。

diozero 串行设备

本节通过SerialDevice类简要介绍 diozero 基本 I/O API 的串行功能。它支持与串行设备通信,这些设备可以连接到树莓派上的 USB 端口或 Pi GPIO 接头上的相关 UART 引脚。您可以在 www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/api/SerialDevice.html 找到更多文档。

###树莓派串行 I/O 的背景

在深入研究SerialDevice之前,我将在树莓派的上下文中提供一些关于串行 I/O 的有用背景。关于串口 I/O 一般很重要的一点是点对点;这意味着一旦串行设备连接到串行端口上的 Pi,Pi 只能通过该端口与该设备进行通信。 1 鉴于点对点通信的简单性,Pi 上的串行 I/O 比您想象的要复杂一些。网页 www.engineersgarage.com/microcontroller-projects/articles-raspberry-pi-serial-communication-uart-protocol-serial-linux-devices/ 涵盖了一般的串行 I/O,以及 Pi 的细节。网页 www.raspberrypi.org/documentation/configuration/uart.md 提供了关于 Pi 上基于 UART 的通信的更多细节。网页 www.raspberrypi.org/documentation/hardware/raspberrypi/usb/README.md 有一些关于 Pi 上基于 USB 的通信的细节。

参考文献中的一些重要亮点:

  • 所有 Pi 系列仅使用两种 UART 类型:PL001 和微型 UART。

  • 不同的 Pi 系列有不同数量的串行 UARTs。Pi 3 和 Pi Zero 系列都有两个 UARTs(每种类型一个)。

  • 所有系列指定一个 UART 作为主 UART。它驱动 GPIO 接头上的 RX/TX 引脚。

  • 所有系列都指定一个 UART 作为第二个;它驱动支持蓝牙的机型上的蓝牙控制器。Pi 3B+和 Pi Zero W 支持蓝牙。

  • 主 UART 被分配给树莓派操作系统控制台。如果您希望使用主 UART 与设备通信,您必须禁用控制台。章节 2 和 3 描述了如何做到这一点。

  • UARTs 在树莓派操作系统文件系统中有设备文件。对于 Pi 3 和 Pi Zero W,主 UART 设备文件是/dev/ttyS0,从 UART 设备文件是/dev/ttyAMA0。在两个系统中,设备文件/dev/serial0是到/dev/ttyS0的符号链接,设备文件/dev/serial1是到/dev/ttyAMA0的符号链接。

  • USB 设备可以有一个硬件软件控制器。

  • 带有硬件控制器的 USB 设备在树莓派 OS 文件系统中有一个格式为/dev/ttyACM<n>的设备文件,其中<n>是一个数字,例如/dev/ttyACM0

  • 带有软件控制器的 USB 设备在文件系统中有一个名为/dev/ttyUSB<n>的设备文件,其中<n>是一个数字,例如/dev/ttyUSB1

USB 设备文件命名值得详细说明。树莓派操作系统将设备文件编号动态分配给 USB 设备*。你不能假设操作系统总是给一个 USB 设备分配相同的设备文件号。例如,如果您在没有连接 USB 设备的情况下启动 Pi,插入 USB 设备 A,然后插入 USB 设备 B,设备 A 被分配/dev/ttyACM0,设备 B 被分配/dev/ttyACM1(假设两者都有硬件控制器)。如果你拔掉两个设备,然后插上 B,再插上 A, B 变成/dev/ttyACM0A 变成/dev/ttyACM1。这使得设备识别成为问题。*

*好消息是有办法适应这种动态行为。一种方法迫使操作系统在每次看到设备时分配相同的设备文件;参见 www.freva.com/2019/06/20/assign-fixed-usb-port-names-to-your-raspberry-pi/https://bnordgren.org/seismo/RPi/RPi_com_port_assignment.pdf 。我必须警告你,我没有尝试过这种方法,因为 diozero 提供了我认为更好的方法;我将在本章后面讨论它。

操作系统管理串行设备有一个重要的好处。一旦设备被应用程序打开,任何进一步尝试打开该设备都将失败。这很好,因为它可以防止其他应用程序干扰初始应用程序对设备的使用。操作系统无法阻止一个应用进程内的不同线程使用设备并相互干扰;这样做是由应用程序的开发人员来处理这样的并发问题。不幸的是,Java 并发一般来说是本书范围 之外的主题

构造器

SerialDevice有两个构造器。两个构造器都需要一个引用串行设备的设备文件的deviceFilename参数。前一小节讨论了操作系统如何为 USB 设备分配设备文件的动态特性。后面的小节描述了 diozero 对确定特定 USB 设备使用什么设备文件的支持。

最简单的构造器只需要deviceFilename参数。它将默认值用于其他可配置的串行特性。

第二个构造器允许定制所有可配置的串行特征:波特率(默认为 9600)、每个字的数据位(默认为 8)、停止位(默认为 1)和奇偶校验(默认为无)。diozero 提供了一组预定义的常量(通过SerialConstants)供构造器使用。有关详细信息,请参见 Javadoc。

SerialDevice和 API 中的其他几个关键类也提供了一个带有嵌套的Builder类的便利构造器。Builder实现了一个“构建器”设计模式 2 ,允许你只提供不同于默认值的特性。比如用SerialDeviceBuilder,你可以只指定波特率。很好!

读写方法

SerialDevice公开了三种读取方法:

  • read有两种形式:

    • 第一个读取单个字节,但返回一个int;如果读取成功,则读取的字节是int中的最低有效字节;如果读取失败,则int为负。

    • 第二个读取字节以填充字节数组参数;它返回实际读取的字节数。

  • readByte()读取单个字节并将其作为byte返回。

理解SerialDevice读取方法是阻塞,而没有超时是非常重要的。这意味着,如果你试图从一个串行设备中读取,而它没有发送你期望的字节数,read 方法将永远不会返回

SerialDevice还公开了一个bytesAvailable方法,该方法返回等待读取的字节数。您可以使用此方法创建非阻塞读取;你可以在第八章中找到一个粗略的例子(见清单 8-8 )。

SerialDevice公开了两个write方法:

  • writeByte写入单个字节。

  • 写入一个字节数组。

第 8 和 10 章使用SerialDevice

支持设备身份

前面的小节提到了由 USB 串行设备的设备文件的动态分配引起的问题。diozero 支持从独特的特定于设备的信息到一个SerialDevice构造器所需的设备文件的映射。

SerialDevice有一个静态方法getLocalSerialDevices,它返回一个DeviceInfo实例的列表。该列表包括所有有效串行设备,包括任何 USB 连接设备、蓝牙设备和 GPIO 头上的串行端口(如果控制台被禁用),无论是否有任何设备连接到 GPIO RX/TX 引脚。

一个DeviceInfo实例包含有用的身份关于一个 USB 3 串口设备的信息,通过操作系统从设备本身得到。可以通过 getter 方法访问各个字段。对我来说,最有用的字段是

  • deviceFile:串行设备的设备文件,例如/dev/ttyACM0。如前所述,在SerialDevice构造器中使用它。

  • usbVendorId:设备厂商厂商的唯一十六进制数,例如1ffb;仅限 USB 设备。

  • usbProductId:对于设备的一个唯一的(对于一个供应商/制造商)十六进制数;仅限 USB 设备。

  • manufacturer:识别设备销售商/制造商的人类可读文本;仅限 USB 设备。该字段有时在操作系统信息中不可用,这种情况下会复制usbVendorId的值。

  • description:识别设备的人类可读文本;仅 USB 设备;非 USB 设备产生“物理端口”

对于 USB 设备,组合{ usbVendorIdusbProductId } 是唯一的。因此,我将把这个组合称为 USB 设备标识。组合{ manufacturerdescription } 应该是唯一的,因此也可以被认为是 USB 设备标识。虽然特定设备的设备文件可以改变,但 USB 设备标识不会改变。这意味着一旦知道了设备的 USB 设备标识,就可以从相关的DeviceInfo实例中获得该设备的设备文件(deviceFile的值)。

来自getLocalSerialDevices的信息非常有用,我决定创建一个实用程序库来帮助识别 USB 设备。如何开始构建实用程序库?与任何基于 diozero 的新项目一样,您必须创建一个新的 NetBeans 项目、包和类。在您的树莓派上配置用于远程开发的项目;并将项目配置为使用 DIOZERO 库。总之,您必须

  1. 创建一个新的 NetBeans“Java with Ant”项目。你可以创建一个“Java 应用程序”或“Java 类库”;前者更好。

  2. 在项目中创建新包。

  3. 创建一个新的 Java 主类。

  4. 将项目属性中的运行时平台设置为您的 Raspberry Pi。

  5. 使用 diozero 库添加必需的 diozero jar 文件。

  6. 确保您为项目设置了构建打包复制依赖库属性。

有关步骤 1-4 的详细信息,请参见第五章(“测试 NetBeans 安装”一节),有关步骤 5 和 6 的详细信息,请参见第六章(“在 NetBeans 中配置 diozero”一节)。

我将调用我的项目工具,我的包org.gaf.util,我的类SerialUtil。列表 7-1 显示SerialUtil。方法printDeviceInfo顾名思义就是这样做的。

package org.gaf.util;

import com.diozero.api.SerialDevice;
import java.util.ArrayList;
import java.util.List;

public class SerialUtil {

    public static void printDeviceInfo() {
        List<SerialDevice.DeviceInfo> devs =
                SerialDevice.
                     getLocalSerialDevices();
        for (SerialDevice.DeviceInfo di : devs){
            System.out.println(
                    "device name = " +
                    di.getDeviceName() + " : " +
                    "device file = " +
                    di.getDeviceFile() + " : " +
                    "description = " +
                    di.getDescription() + " : "+
                    "manufacturer = " +
                    di.getManufacturer()+ " : "+
                    "driver name = " +
                    di.getDriverName() + " : " +
                    "vendor ID = " +
                    di.getUsbVendorId() + " : "+
                    "product = " +
                    di.getUsbProductId());
        }
    }

    public static void main(String[] args) {
        printDeviceInfo();
    }
}

Listing 7-1SerialUtil

Tip

你可以在本章中找到所有代码清单的实际源代码,也可以在本书的代码库中找到本书的其余部分(位置见前面的内容)。您可能会在代码清单中发现错误,但是源代码已经过全面测试。

运行SerialUtil,你会看到类似于清单 7-2 的东西,假设你连接了一些 USB 设备。当我运行该应用程序时,我有三个 USB 设备连接到一个树莓派 3B+,控制台被禁用。

device name = ttyACM1 : device file = /dev/ttyACM1 : description = USB Roboclaw 2x15A : manufacturer = 03eb : driver name = usb:cdc_acm : vendor ID = 03eb : product ID = 2404
device name = ttyACM0 : device file = /dev/ttyACM0 : description = Pololu A-Star 32U4 : manufacturer = Pololu Corporation : driver name = usb:cdc_acm : vendor ID = 1ffb : product ID = 2300
device name = ttyACM2 : device file = /dev/ttyACM2 : description = Pololu A-Star 32U4 : manufacturer = Pololu Corporation : driver name = usb:cdc_acm : vendor ID = 1ffb : product ID = 2300
device name = ttyS0 : device file = /dev/ttyS0 : description = Physical Port : manufacturer = null : driver name = bcm2835-aux-uart : vendor ID = null : product ID = null
device name = ttyAMA0 : device file = /dev/ttyAMA0 : description = Physical Port : manufacturer = null : driver name = uart-pl011 : vendor ID = null : product ID = null

Listing 7-2Output of SerialUtil.printDeviceInfo

表 7-1 显示了清单 7-2 中DeviceInfo的每个实例最有用的字段。我稍微重新排列了一下列表。

表 7-1

设备信息列表的结果

|

设备文件

|

制造商

|

描述

|

供应商 id

|

产品 id

| | --- | --- | --- | --- | --- | | /dev/ttyACM0 | 波洛卢公司 | 波洛卢 A 星 32U4 | 1ffb | Two thousand three hundred | | /dev/ttyACM1 | 03eb | USB Roboclaw 2x15A | 03eb | Two thousand four hundred and four | | /dev/ttyACM2 | 波洛卢公司 | 波洛卢 A 星 32U4 | 1ffb | Two thousand three hundred | | /dev/ttyS0 | 空 | 物理端口 | 空 | 空 | | /dev/ttyAMA0 | 空 | 物理端口 | 空 | 空 |

其中一个 USB 设备(/dev/ttyACM0)是附录 A1 中描述的 Arduino“命令服务器”。另一个(/dev/ttyACM2)是 Arduino 激光雷达单元(内置一个“命令服务器”),在附录 A2 中描述,并在第十章中使用。另一种(/dev/ttyACM1)是在第八章中使用的双 DC 电机控制器。/dev/ttyS0代表 GPIO 头上的串口(无附件)。/dev/ttyAMA0代表蓝牙控制器。

请注意,很容易将电机控制器与“命令服务器”和其他设备区分开来,因为 USB 设备标识{ usbVendorIdusbProductId }是唯一的。此外,注意电机控制器的manufacturer字段;它与usbVendorId相同,表明不能保证它是“人类可读的”

注意,两个“命令服务器”具有相同的 USB 设备标识。仅使用 USB 设备标识无法区分它们!在这种情况下,您必须使用设备本身的特性来区分一个实例和另一个。我断言,如果一个设备设计者预见到该设备在一个系统中存在多个实例,那么这样的特性就必须存在。实际上,这个设备必须有一个被认为是的设备实例 ID

这种情况意味着在解析 diozero SerialDevice构造器所需的设备文件时涉及两种类型的身份:

  • USB 设备标识:这是唯一的如果 USB 设备的单个实例连接到 Raspberry Pi。

  • 设备的设备实例 ID :对于共享相同 USB 设备标识的设备来说,这应该总是唯一的,并且只有在 USB 设备标识不唯一的情况下才需要。

这意味着可以有两个阶段来进行身份验证。首先,找到所有带有所需 USB 设备标识的设备文件;如果只有一个,那么这个设备文件可以在SerialDevice构造器中使用;不需要第二阶段。其次,对于任何匹配 USB 设备标识的设备文件,构造一个SerialDevice并确定设备实例 ID,然后将其与所需的设备实例 ID 进行比较。

身份验证非常重要,所以我决定在SerialUtil中添加对它的支持。清单 7-3 展示了findDeviceFiles方法。它基本上执行身份验证的第一阶段。具体来说,它检查所有串行设备,并返回与参数中给定的 USB 设备标识相匹配的设备文件列表。我将在第 8 和 10 章演示findDeviceFiles的用法。

public static List<String> findDeviceFiles(
        String vendorID, String productID) {
    ArrayList<String> deviceFiles =
            new ArrayList<>();

    List<SerialDevice.DeviceInfo> dis =
            SerialDevice.getLocalSerialDevices();
    for (SerialDevice.DeviceInfo di : dis) {
        if (vendorID.equals(di.getUsbVendorId())) {
            if (productID.equals(
                    di.getUsbProductId())) {
                deviceFiles.add(di.getDeviceFile());
            }
        }
    }
    return deviceFiles;
}

Listing 7-3findDeviceFiles

Tip

要找到设备的 USB 设备标识,您可以使用SerialUtil(参见清单 7-1 和 7-2 )。可以使用 diozero 实用程序SerialDeviceDetect(参见https://github.com/mattjlewis/diozero/blob/main/diozero-sampleapps/src/main/java/com/diozero/sampleapps/SerialDeviceDetect.java);它的输出类似于清单 7-2 (可悲的是,我在知道SerialDeviceDetect存在之前就写了SerialUtil)。您也可以使用 Linux 命令udevadm info --query=property --name=/dev/tty<.>,其中<.>表示系统中设备文件名的剩余部分;例子包括/dev/ttyACM0/dev/ttyUSB0

Diozero I2CDevice

本节通过I2CDevice类简要介绍了 diozero 基本 I/O API 的 I2C 功能。它支持与 I2C 设备的通信,这些设备可以通过 GPIO 头上的相关引脚连接到 Raspberry Pi。您可以在 www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/api/I2CDevice.html 找到更多文档。

I2C 覆盆子酱的背景

在深入研究I2CDevice之前,我将在树莓派的背景下提供一些关于 I2C I/O 的有用背景。与串行不同,I2C 是一条总线,你可以将多个 I2C 设备连接到一条 I2C 总线。I2C 标准允许多个设备启动交互,以及多个设备响应交互。Pi 可以是主设备,但是据我所知,Pi 不支持多主设备配置。更进一步,据我所知,圆周率不可能是奴隶(参见 www.raspberrypi.org/forums/viewtopic.php?t=235740 )。无论如何,I2CDevice作为一个 I2C 大师。

由于多个 I2C 从设备可以存在于一条 I2C 总线上,每个 I2C 设备必须有一个理论上唯一的地址。I2C 标准允许七位或十位地址。Pi 两者都支持。也就是说,绝大多数 I2C 设备使用 7 位地址,允许 128 个唯一的设备地址(设备数据表将显示地址的长度)。这可能会导致 I2C 设备地址冲突。一个来源是数百甚至数千个不同的 I2C 设备之间的冲突。另一个来源是当一个项目需要多个同一个 I2C 设备时。参见 https://learn.adafruit.com/i2c-addresses 了解一些可能的解决方案。

可能存在设备冲突的事实导致一些制造商在他们的设备上包括通常称为“我是谁”的注册。读取寄存器并检查值可以提供额外的器件验证层。

树莓派家庭拥有不同数量的 I2C 公交车。例如,本书中使用的 P3B+和 Pi Zero W 有两条 I2C 总线。这两条总线被命名为 i2c-0i2c-1 。只有 i2c-1 用于一般用途;i2c-0 用于访问 Pi“HATs”上的 EEPROM(参见 https://learn.sparkfun.com/tutorials/raspberry-pi-spi-and-i2c-tutorial/i2c-0-on-40-pin-pi-boards )。本书将只使用 i2c-1 总线。

Note

树莓派 4 有更多的 I2C 总线,但默认情况下只启用 i2c-1。您可以启用更多;见 www.raspberrypi.org/forums/viewtopic.php?t=271200 )。

diozero 包括一个帮助诊断 I2C 地址问题的实用程序。I2CDetect查找连接到 I2C 总线的所有 I2C 设备。图 7-2 显示了在带有两个连接到 i2c-1 的 I2C 分线板的零 W 上运行I2CDetect的结果。其中一块板包含两个 I2C 设备,因此总共有三个 I2C 设备。

img/506025_1_En_7_Fig2_HTML.jpg

图 7-2

运行 I2CDetect 的结果

至少与 SPI 相比,I2C 总线被认为是一种低速的 ?? 总线(见下一节)。I2C 标准( www.nxp.com/docs/en/user-guide/UM10204.pdf )称时钟速度可以从 0 Hz 到由类别定义的最大值,对于双向传输,可以是

  • 标准模式:100 kHz;大约 1982 年

  • 快速模式:400 kHz;大约 1992 年

  • 快速模式加:1 MHz;2007 年左右

  • 高速模式:3.4 MHz;大约 2000 年

我能在 Pi 上找到的关于 I2C 公交车速度的最接近官方的文件是 www.raspberrypi.org/documentation/hardware/raspberrypi/bcm2835/BCM2835-ARM-Peripherals.pdf 。该文献的第 28 页建议符合 2000 年 1 月的规范(2.1),这意味着符合标准、快速和高速模式。然而,该文件只提到快速模式(第 28 页)和“默认”速度 100 kHz,或标准模式(第 34 页)。第 34 页还描述了决定 I2C 总线速度的时钟分频寄存器(CDIV)。CDIV 可以设置为 2 到 65534 之间的任何偶数,从而产生大约 2289 Hz 到 75 MHz 之间的潜在速度。显然,任何超过 3.4 MHz 的频率都不符合规格。有可能超过 400 kHz 的都不行。

显然,所有 I2C 设备都支持标准模式。许多还支持快速模式。您可以找到支持高速模式和快速模式增强版的设备。无论如何,将 I2C 总线速度保持在默认的 100 kHz 是安全的,尽管可能效率较低(参见您设备的数据手册)。

如果你愿意,你可以改变 I2C 速度。为此,在你的树莓上,编辑/boot/config.txt(作为根)文件;找到写着

dtparam=i2c_arm=on

然后编辑该行,如下所示:

dtparam=i2c_arm=on,i2c_arm_baudrate=NNNNNN

其中NNNNNN代表以赫兹为单位的期望速度。保存文件,然后重新启动。有了正确的 Pi 模型、正确的操作系统版本和运气,您的 I2C 总线将会以期望的速度运行。

最后,理解 I2C 很重要,因为总线上的所有设备总是在寻找它们的地址,所以所有设备都以相同的速度计时。这意味着总线的最大速度受到总线上最慢设备的限制。

构造器

I2CDevice有几个构造器。所有构造器都需要以下两个参数:

  • controller:选择要使用的 I2C 公交车;根据之前关于树莓派的讨论,你必须识别 I2C-1;您可以使用常量I2CConstants.CONTROLLER_1来实现这一点。

  • address:这表示您的设备的 I2C 地址;您应该可以在器件的数据手册中找到它。

某些构造器上的附加参数允许您定义其他特性(有关正确值,请参考您设备的数据手册):

  • addressSize:表示交互过程中总线上传输的 I2C 设备地址的大小(以位为单位);根据前面的讨论,通常应该是 7;可以使用常量I2CConstants.AddressSize.SIZE_7(默认)。

  • byteOrder:表示总线上多字节值的字节顺序;这是由特定的 I2C 设备控制的;您可以使用java.nio.ByteOrder中两个值中的一个(默认的ByteOrder. BIG_ENDIAN对于大多数设备是正确的)。

幸运的是,默认值是大多数 I2C 设备的正确值。这意味着大多数时候,您可以使用只需要controlleraddress参数的最简单的构造器。

I2CDevice还提供了一个嵌套的Builder类,允许您只更改与默认值不同的参数。

读写方法

I2CDevice公开了许多从设备读取字节和向设备写入字节的方法。为了理解它们,我必须解释一下如何使用 I2C 设备。I2C 设备有寄存器的概念。你配置和控制寄存器,让设备以你想要的方式执行它的功能。您数据和状态寄存器中读取,以获得设备执行其功能的结果。在大多数 I2C 设备中,寄存器有一个地址;因此,在 I2C 操作中,你会发现设备 I2C 地址和寄存器地址。本书中的 I2C 设备都使用寄存器地址。一些 I2C 设备具有如此少的寄存器或如此简单的功能,它们使用寄存器地址进行 I2C 操作(例如,参见 https://datasheets.maximintegrated.com/en/ds/MAX11041.pdf ,一种具有一个控制寄存器和四个数据寄存器的逻辑等效的设备,这些寄存器总是作为一个块被读取)。

您会发现,对于块读取和块写入,I2C 设备可能支持也可能不支持自动递增。通过自动递增,您可以提供起始寄存器编号,然后执行读/写周期来读/写特定数量的连续寄存器。非常方便!一般而言,器件数据手册包含一个描述 I2C 功能的部分,包括任何自动递增功能。

I2CDevice提供支持这两种类型的 I2C 设备的方法。此外,它还提供了几种方便的方法来简化数据操作。使用寄存器地址的基本读/写方法有

  • readByteData从通过地址参数识别的寄存器中读取一个字节;它返回读取的字节。

  • readI2CBlockData从地址参数标识的寄存器开始读取连续的字节块,并尝试填充字节数组参数(最多 32 个字节);它返回读取的字节数。

  • writeByteData向地址参数所标识的寄存器写入一个字节。

  • writeI2CBlockData从地址参数标识的寄存器开始,写入字节数组参数(Java vararg)中的连续字节块(最多 32 字节)。

包装这些基本方法的便利方法支持读取位(boolean)、intshort、无符号字节(short)、无符号整数(long)、无符号短整型(int)和java.nio.ByteBuffer。他们也支持写一点和一个short

为了完整起见,不使用寄存器地址的基本读写方法有readBytereadByteswriteBytewriteBytes

第九章和第十一章使用I2CDevice

Note

如果您必须在一次交互中读取或写入超过 32 个字节,您可以使用readWrite方法来完成,尽管这有点困难。

diozero 设备

本节简要介绍 diozero 基本 I/O API,通过SpiDevice类实现 SPI 功能。它支持与 SPI 设备的通信,SPI 设备可以通过 GPIO 头上的相关引脚连接到 Raspberry Pi。您可以在 www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/api/SpiDevice.html 找到更多文档。

树莓派 SPI 的背景

在深入研究SpiDevice之前,我将在树莓派的背景下提供一些关于 SPI 的有用背景。像 I2C 一样,SPI 也是一种总线。SPI 规范支持一个单个主机和多个从机。树莓派只能充当 SPI 主机(见 www.raspberrypi.org/forums/viewtopic.php?t=230392 )。SpiDevice做 SPI 主。

树莓派 3 型号 B+和 Zero W 有两条 SPI 总线(见 www.raspberrypi.org/documentation/hardware/raspberrypi/spi/README.md )。一个 SPI 器件只能连接到一条总线。SPI 器件有一个芯片选择芯片使能引脚,指示总线上的流量指向该器件。Pi SPI 总线 0 有两个芯片使能引脚(名为 CE0 和 CE1),限制了该总线上只能有两个 SPI 器件;Pi SPI 总线 1 有三个芯片使能引脚(名为 CE0、CE1 和 C2),因此总线上只能有三个 SPI 器件。如果在一条总线上需要更多的设备,可以创建多路复用方案。 5

SPI 总线可以比 I2C 总线运行得更快(见前面的讨论),部分原因是它可以使用更快的时钟,部分原因是它可以运行全双工。这使得 SPI 非常适合某些器件。与 I2C 器件一样,SPI 器件通常表现为一组存储器位置或寄存器,每个位置或寄存器都有一个地址,您可以读写控制或配置寄存器,也可以读取数据和状态寄存器。

与 I2C 一样,PI 上 SPI 总线的可行速度似乎有点难以确认。答案似乎取决于产品系列和操作系统版本。也就是说,早期的参考文献表明“任何超过 50 兆赫兹的都不太可能工作。”特定的设备可能会进一步限制速度。

SPI 相对于 I2C 的另一个优势是,器件只有在被选中时才会查看总线。这意味着 SPI 总线速度可以针对每个器件设置*。因此,低速设备不会像 I2C 那样影响高速设备的性能。*

构造器

SpiDevice有三个构造器。所有的构造器都需要一个chipSelect参数。SpiDevice为参数定义了CE0CE1CE2CE3常量(记住 diozero 支持多个 SBC)。

最简单的构造器只需要chipSelect参数;构造器对附加的可配置特征使用默认值。额外的构造器使您能够定制SpiDevice实例的以下特征:

  • SPI 总线或controller号:该类为默认值DEFAULT_SPI_CONTROLLER提供了一个常量。

  • SPI 总线时钟频率模式:该类为默认频率DEFAULT_SPI_CLOCK_FREQUENCY (2 MHz)提供一个常量;一个关联的类SpiClockMode提供有效值,默认值为MODE_0

  • 位传输的顺序:该类为默认值提供了一个常量,DEFAULT_LSB_FIRST (false)。

SpiDevice还提供了一个Builder类。Builder允许您仅更改默认值中必要的特性。

读写方法

SpiDevice公开了向设备写入字节和从设备读取字节的三种方法。重要的是要认识到所有的方法都假定阻塞操作。这些方法是

  • write向设备写入一个字节块;该块包含寄存器地址和要写入的数据;细节取决于设备;write有两种形式:

    • 一个参数是字节数组(Java vararg ),将所有字节写入设备

    • 三个参数,一个字节数组、一个起始索引和一个长度,将字节写入设备,从索引开始,到写入长度字节结束

  • writeAndRead向设备写入一个字节块,一般是提供从中读取的地址,从设备中读取一个与写入长度相同的字节块(全双工);与write一样,为了能够读取所需的块,您必须写入的细节取决于设备。

您会发现 SPI 器件可能支持也可能不支持自动递增的读写。一般而言,器件数据手册包含一个描述 SPI 自动递增功能的部分。

您还会发现,由于 SPI 的全双工特性,使用 SPI 比使用 I2C 要复杂一些。我会在第十一章中比较SpiDeviceI2CDevice。第十二章也使用了SpiDevice

通用输入输出接口

本节简要介绍 diozero 对树莓派的“其余数字 I/O”功能的支持,即之前没有提到的“专用数字 I/O”(串行、I2C、SPI)。这包括通用功能,如“简单”数字输入和“简单”数字输出以及 PWM 输出。我将在后面的小节中描述“基本”类。

diozero 包含一个实用程序GpioReadAll,可以帮助诊断 GPIO 问题。它读取 GPIO 引脚的状态,并产生一份报告,尽可能包括 GPIO 引脚的模式(输入或输出)和状态(0[0V]或 1[3.3V])。图 7-3 显示了启动后立即捕获的报告。

img/506025_1_En_7_Fig3_HTML.jpg

图 7-3

GpioReadAll 报道

树莓派 GPIO 背景

在深入研究 GPIO 之前,我将在树莓派的背景下提供一些关于 GPIO 的额外背景。除了本章开头提到的亮点, www.raspberrypi.org/documentation/usage/gpio/www.raspberrypi.org/documentation/hardware/raspberrypi/gpio/README.md 为 GPIO 用法提供了额外的有用信息。

引脚编号

注意,GPIO 管脚号与物理连接器管脚号不对应。前者通常被称为 BCM 号,后者通常被称为板号。BCM 是树莓派基金会官方支持的 pin 编号方案。diozero 使用 BCM 数字。

上拉和下拉

将 GPIO 引脚配置为输入时,可以配置以下状态之一:

  • 上拉:引脚通过 50–65kω电阻连接到 3.3V 因此,该引脚将显示“高”或“开”,除非所连接的设备采取措施将该引脚拉至地。

  • 下拉:引脚通过 50–65kω电阻接地(0V);因此,除非所连接的器件采取措施将引脚拉至 3.3V,否则引脚将显示“低”或“关”

  • 浮动:引脚浮动;因此,引脚读“高”或“低”取决于连接的设备做什么;通常假设使用外部上拉或下拉电阻。

这些状态有两个例外。GPIO 2 和 3(连接器引脚 3 和 5)用于 I2C。因此,它们总是使用 1.8kω电阻上拉至 3.3V。

Tip

您的设备可能会迫使您了解 GPIO 引脚的初始状态。不幸的是,这个主题很复杂。上电时,所有引脚都是输入,GPIO 0–8 被上拉,其余被下拉。然而,这是可以改变的;详见 www.raspberrypi.org/documentation/configuration/pin-configuration.md 。前面提到的GpioReadAll实用程序可以帮助你随时了解你的 GPIO 的状态。

电流限制

虽然 GPIO 引脚的电压限值很容易理解(最小 0V,最大 3.3V),但电流限值却不容易理解。除了本节开头的链接, www.mosaic-industries.com/embedded-systems/microcontroller-projects/raspberry-pi/gpio-pin-electrical-specificationswww.raspberrypi.org/documentation/hardware/raspberrypi/gpio/gpio_pads_control.md 提供了一些血淋淋的细节和建议。一些亮点:

  • 从单个输出引脚吸电流或源电流不得超过 16 mA。

  • 单个输入引脚的吸电流和源电流不得超过 0.5 mA。

  • 任何时刻可从输出引脚获得的最大电流为 50 mA。

  • GPIO 引脚吸收的总电流没有限制。

  • 所有引脚的源/吸电流驱动强度可在 2 mA 至 16 mA 范围内编程。为安全起见,不应超过设定的限值。

最后一个亮点值得详述。您可以通过写入树莓派 SoC(片上系统)上的特定寄存器来设置驱动强度。我研究了 Python、C 和 Java 中的一些 GPIO API。我没有看到任何允许编程驱动强度。树莓派硬件在上电时将驱动强度设置为 8 mA,但它可以由操作系统内核更改。

这个故事的寓意是… 小心点!

diozero GPIO 类

在某些方面,diozero 提供的 GPIO 功能比前面讨论的串行、I2C 和 SPI 功能更有趣、更复杂。我将在下面的小节中描述“基本”类。您可以阅读 Javadoc 以获得关于 API 中附加类的信息。

有两个相关的概念对 GPIO 功能很重要。与 GPIO 管脚的物理方面相关;可以是 (3.3V)或 (0V)。状态与 GPIO 引脚的逻辑方面相关;可以是激活非激活。在构建过程中,您需要将逻辑映射到物理。

数字输入设备

顾名思义,DigitalInputDevice ( www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/api/DigitalInputDevice.html )允许您监控所连接设备的值和状态。您可以读取值和状态,等待特定的值或状态,在检测到特定状态时获取事件,或者在电平转换时获取事件。DigitalInputDevice的行为由以下特征控制:

  • GPIO 管脚:用于输入的管脚(BCM 号)(或其等效物PinInfo

  • 活动状态 : true =高(3.3V)或false =低(0V)

  • 上拉电阻类型:上升、下降或无(浮动)

  • 事件触发类型:上升沿、下降沿、两者或无

活动状态允许您将逻辑活动状态映射到物理状态。例如,许多器件产生的信号在激活时为高电平,在非激活时为低电平。另一方面,有些器件在工作时产生低电平信号,在不工作时产生高电平信号。由于DigitalInputDevice主要根据活动和非活动而不是高和低来工作,所以为物理设备定义正确的逻辑映射是很重要的。

构造器

DigitalInputDevice有几个构造器,除了一个之外,所有的构造器都使用内置的默认值(参见 Javadoc)来实现上述的一些或全部特征。它还有一个Builder嵌套类。 6 注意,diozero 为上拉电阻类型和事件触发类型定义了常量,用于构造器或与Builder一起使用。

方法

DigitalInputDevice有多种了解连接设备状态的关键方法。要读取值或状态,可以使用

  • getValue:返回 GPIO 管脚的当前true表示高(3.3V),false 表示低(0V)。

  • isActive:返回引脚是否有效;根据前面的讨论,活动是一种逻辑状态,可以表示高或低。

要等待特定值或状态(可能超时):

  • waitForActive:等待管脚激活。

  • waitForInactive:等待引脚变为无效。

  • waitForValue:等待 pin 达到所需值。

第十四章展示了一个waitForActive的例子。

要在检测到特定状态时获取事件,可以使用以下方法:

  • whenActivated:当设备处于活动状态时,调用“事件处理程序”。

  • whenDectivated:设备不活动时调用“事件处理程序”。

这两种方法实现了“中断”功能。您必须提供“事件处理程序”(要调用的方法)作为方法的参数。作为参数给出的类或方法属于类型LongConsumer。该类是一个函数接口,其函数方法是accept(long event)。在 diozero 中,传递给方法的long是以纳秒为单位的Linux CLOCK _ MONOTONIC timestamp。它表示从过去某个未指定的时间点开始的时间量(例如,系统启动时间)。我发现这种“带时间戳的中断”功能非常棒。我会在第 9 和 14 章给你看例子。

要获得电平转换的“中断”,必须使用方法addListener来识别“事件处理程序”参数类是另一个函数接口,它的函数方法是accept(DigitalInputEvent event)。事件触发特性控制哪些边沿(电平转换)产生事件。DigitalInputEvent有两个时间戳:Linux CLOCK_MONOTONIC 和 UNIX epoch time 。第十四章给出了一个例子。

数字输出设备

顾名思义,DigitalOutputDevice ( www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/api/DigitalOutputDevice.html )可以让你控制一个连接的设备。该类还区分状态和级别,并允许您设置映射。您可以简单地设置输出有效或无效,或高或低,或者您可以产生有趣的波形。DigitalOutputDevice的行为由以下特征控制:

  • GPIO 引脚:用于输出的引脚(BCM 号)(或其等效PinInfo)

  • 活动状态 : true =高(3.3V)或false =低(0V)

  • 初始值 : true =高(3.3V)或false =低(0V)

构造器

DigitalOutputDevice有几个构造器,其中一个使用内置默认值。最有用的构造器允许您设置前面提到的所有特征。DigitalOutputDevice也有一个Builder

方法

DigitalOutputDevice有以下主要方法(其中包括):

  • off:设置 GPIO 引脚无效;根据活动状态,可以是 0V 或 3.3V。

  • on:将引脚设为有效;根据活动状态,可以是 0V 或 3.3V。

  • setOn:设置引脚有效(真)或无效(假)。

  • setValue:将引脚设置为高电平(true)或低电平(false)。

  • toggle:切换设备状态/级别。

  • onOffLoop:自动切换多个周期的输出;完成后可以生成一个事件。

第十三章用途DigitalOutputDevice

PwmOutputDevice

顾名思义,PwmOutputDevice ( www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/api/PwmOutputDevice.html )代表由 PWM (脉宽调制)信号驱动的器件。您可以通过多种方式产生 PWM 信号。PwmOutputDevice的行为由以下特征控制:

  • GPIO 管脚:用于输出的管脚(BCM 号)

  • 频率:PWM 信号的频率,单位为赫兹

  • :相对于信号周期的脉冲宽度(0..1)

构造器

最有用的PwmOutputDevice构造器允许您设置前面提到的所有特征。PwmOutputDevice也有一个Builder

请注意,构造器以参数定义的频率和值启动 PWM 信号。信号以该频率和值继续,直到改变。

方法

PwmOutputDevice有以下“关键”方法(以及其他方法):

  • setValue:设定相对于信号周期的输出脉冲宽度(0..1);频率保持不变。

  • setPwmFrequency:设定信号的频率;该值保持不变。

这本书没有使用PwmOutputDevice,但是你可以在 diozero 文档和 diozero 预构建设备中找到例子。

如果你读过本章前面引用的参考资料,你就会知道树莓派在某些 GPIO 管脚上支持硬件 PWM 但是,这样做需要以 root 用户身份运行。为了避免这种情况,本书中使用的 diozero 内置提供程序实现了软件 PWM(使用后台线程)。如果您想使用硬件 PWM,您可以使用 diozero pigpio 提供程序,但是您必须以 root 用户身份运行您的应用程序。

Tip

正如在第六章中提到的,diozero 提供了一些基于基本 I/O API 类的设备(也称为设备库)。 www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/devices/package-summary.html。这些设备提供了使用 diozero 基本 I/O 类的其他示例。

设备库和应用程序结构

本节讨论了设计和开发的一些重要准则

  • 使用 diozero 基本 I/O API 设备类的设备库

  • 使用 diozero 基本 I/O API 设备类或基于它们的设备库类的应用程序

RuntimeIOException

diozero 有一个简单的规则:基础 I/O API 中的所有设备动作都可能抛出未选中的*RuntimeIOException。该规则适用于从设备读取或向设备写入的构造器和方法。*

*未检查的异常在 Java 开发人员中有点争议,因为捕捉它们是可选的,因此,实际上,它们可以被忽略,“因为你对它们无能为力。”更多信息见 https://docs.oracle.com/javase/tutorial/essential/exceptions/runtime.html

忽略未检查异常的能力可以允许更快的编码,并且可以产生更干净的代码,特别是在使用 Java lambda 表达式时。然而,如果没有被捕获,一个未检查的异常会沿着调用栈向下传播到main方法,并且您的程序会终止。因此,你可能有理由担心忽略一个RuntimeIOException的“不愉快”后果。考虑以下场景:

  • 你的程序创建了一个电机控制器类的实例Motors,来驱动移动机器人的电机。Motors使用SerialDevice与电机控制器交互。你叫Motors.forward打开马达(它们会一直开着,直到你叫Motors.stop)。

  • 你为另一个设备调用一个方法(在电机运行的情况下),它抛出一个RuntimeIOException。如果没有被捕获,你的程序终止,电机运行。机器人可能会崩溃!确实不愉快。

安全网

你如何防止这些不愉快的后果?Java 和 diozero 提供了一些“安全网”来解决这个和其他问题。

尝试资源安全网

Java 提供了一个安全网,可以通过java.lang.AutoCloseable接口和 try-with-resources 语句( https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html )来防止Motors场景中的崩溃。任何实现AutoCloseable 的类都必须有一个close方法,并且可以被认为是 try-with-resources 语句中的资源。try-with-resources 语句实例化语句中标识的资源,并确保在程序终止之前调用每个资源的close方法。

您可以按如下方式启用资源尝试安全网:

  1. 在您创建的任何使用 diozero 基本 I/O API 类与物理设备交互的设备库类中,您都要实现java.lang.AutoCloseable

  2. 在设备库类中的强制close方法中,您

    1. 终止正在进行的活动,尤其是那些如果不终止会有不愉快后果的活动

    2. 关闭您的类中使用的 diozero 基本 I/O API 类

  3. 在任何使用你的设备库类或 diozero base I/O API 中的任何类的应用程序/程序中,你实现一个 try-with-resources 语句,用所有这样的类在资源列表中。

让我们检查一下Motors场景,其中有资源尝试安全网:

  • Motors实现java.lang.AutoCloseable并使用SerialDevice与电机控制器交互。Motors.close停止任何正在进行的运动活动,然后在SerialDevice实例上调用close

  • 您的程序将其任务实现封装在资源列表中带有Motors的 try-with-resources 语句中;该语句创建了一个Motors的实例。在 try-with-resources 中,您调用Motors.forward来打开马达(它们会一直开着,直到您调用Motors.stop)。

  • 您为另一个设备调用一个方法(在电机运行的情况下),它会生成一个RuntimeIOException。程序终止前,Motors.close被调用并停止电机等。机器人停止时,程序终止。没有撞车!

尝试资源安全网处理你把RuntimeIOException理解为“你什么也做不了”并且想要一个优雅的终止的情况。我相信在某些情况下,您必须根据“我希望发生的事情没有发生”来考虑异常,并且捕获未检查的异常是合理的。您可以重试导致异常的操作,返回一些失败指示符,甚至抛出一个检查过的异常。只有你能做出这样的设计决策。关键是你要分析每一种情况再决定要不要抓。

Tip

diozero 包括一组实用程序类(见 www.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/util/package-summary.html ),可用于基本 I/O API 来开发设备库或创建 diozero 的扩展。最有用的一个类,SleepUtil,包装Thread.sleep,捕捉InterruptedException(这样你就不用捕捉或抛出它),抛出RuntimeIOException的一个子类。因此,当你需要睡眠时,你可以使用SleepUtil并利用你自己的方法来处理RuntimeIOException

diozero 关闭安全网

但是等等,还有呢!在阅读 GPIO 设备时,您可能想知道 diozero 是否在“幕后”产生线程。答案是肯定的(更多你不必做的工作)。但是 diozero 确保这样的线程在正常和异常情况下都会被终止,这样你的应用程序就可以正常退出,使用远程提供者时除外

为了解决潜在的线程问题,diozero 提供了一个实用程序类com.diozero.util.Diozero。它提供了静态的shutdown方法,强制终止所有 diozero 创建的线程,包括 pigpio 远程提供者的线程。为了启用安全网,您将方法调用放在与前一小节中提到的 try-with-resources 语句相关联的finally块中。

Java 关机安全网

但是等等,还有更多!Java 和 diozero 一起为您提供了一个额外的安全网。Java 提供了一种关闭机制(参见 www.baeldung.com/jvm-shutdown-hookswww.geeksforgeeks.org/jvm-shutdown-hook-java/ ),允许您在 JVM 终止之前释放资源并处理潜在的不愉快情况(例如,当 Ctrl-C 绕过 try-with-resources 时)。在应用程序启动时,diozero 注册它自己的关闭挂钩(Diozero实用程序类),它执行内部清理操作,比如停止它创建的任何线程。shutdown 挂钩有一个方法,允许应用程序注册附加的类实例(例如,设备)以包含在 diozero shutdown 活动中。您可以使用静态方法Diozero.registerForShutdown注册任何实现AutoCloseable的类的实例。当在 JVM 关闭期间被调用时,diozero shutdown 钩子首先在向它注册的所有类实例上调用close,然后执行内部清理操作。因此,注册的close方法在 JVM 终止之前被调用,可以防止不愉快。

有两种方法可以启用 Java 关机安全网。您可以注册您的

  • 应用程序中 try-with-resources 语句内的 Device 类实例

  • 应用类实例在main方法中创建实例后

你的设备类应该已经是AutoCloseable了,所以第一种方法很简单。要使用第二种方法,您必须创建您的应用程序类AutoCloseable并实现一个close方法,然后关闭应用程序中使用的相关设备。你可以选择你喜欢的方式。

如果您的程序在无限循环中运行,并且需要 Ctrl-c 或其他中断来终止,那么启用 Java 关闭安全网是非常重要的。反过来说,总是使用 Java 关闭安全网也没有坏处。

Caution

不要注册自己的 Java 关闭处理程序。JVM 不保证关闭处理程序的执行顺序,因此 diozero 关闭处理程序可能会首先运行,从而阻止您的关闭处理程序与它的任何设备进行通信。

自动安全网

但是等等,还有更多!diozero 在“幕后”实施了另一个安全网。diozero 基础 I/O API 中的所有设备(如SerialDeviceI2CDeviceDigitalInputDevice)实现AutoCloseable,diozero 自动为 Java 关机安全网注册实例。这确保了所有开放的 diozero base I/O 设备实例的close方法在程序终止前被调用。也就是说,显式设备关闭是首选(如前面讨论的Motors场景);这个“自动”安全网应该被认为是一个备份。

安全网准则

在 diozero 环境中,安全网讨论对器件库和应用有一些重要的影响:

  • 自动安全网(基于 Java 关机)总是启用的,但是你不应该依赖它;您应该显式关闭设备库中的 diozero API 类。认识到自动安全网不能关闭你的装置。

  • 应该在您编写的任何应用程序中启用 try-with-resources 安全网,将您的设备类和任何 diozero 设备类(基本 I/O 或其他)视为资源。

  • 应该启用 diozero 关机安全网(通过finally块中的Diozero.shutdown),即使是本书中主要使用的默认内置提供程序。当使用一个远程提供者来终止处理远程通信的线程时,你必须启用 diozero 关闭安全网。

  • 可能启用 Java 关闭安全网(通过Diozero.registerForShutdown),特别是如果你担心不愉快的后果。如果是这样,在正常的程序终止下,设备的close方法将被调用两次*,一次是在资源尝试结束时,另一次是在关闭时。您必须确保您的close方法被设计为处理多个调用。*

*如你所料,第八章到第十四章都利用了资源尝试和 diozero 安全网。第八章和第十四章利用 Java 关闭安全网。

Caution

坏消息:有些情况下,即使启用了安全网,也没有一个安全网被使用(参见关断挂钩参考)。好消息:你已经尽力了。

摘要

在本章中,您已经了解了

  • 树莓派的基本 I/O 功能

  • diozero 基本 I/O API 和一些有用的 diozero 实用程序的最重要方面

  • 开发利用 diozero base I/O API 的设备库和应用程序的指南

现在是时候使用 diozero base I/O API 来创建设备库了!

Footnotes 1

在第八章中,你会看到一个例外。

  2

参见 dzone。com/articles/design-patterns-the-builder-pattern

  3

对于非 USB 设备,DeviceInfo实例通常不包含任何关于身份的内容。

  4

可以使用简单的编辑器nano。参见第四章获取一些说明。

  5

一个硬件的例子是。

  6

我发现默认值,尤其是活动状态,有时不符合我的需要,所以我建议使用Builder来设置除 GPIO 引脚之外的所有特性,以便从DigitalInputDevice获得所需的行为。

 

***

八、DC 电机控制器

在这一章中,我们将根据您在前面章节中所学的知识开发一个基于 diozero 的设备库。我选择了一个机器人常用的设备,一个 DC 电机控制器,更确切地说,是一个通过串行 I/O 访问的设备。也就是说,本章涵盖了适用于使用任何形式的基本 I/O 为任何类型的设备开发设备库的主题,从而为后面的章节建立了重要的背景。所以,你至少应该浏览一下这一章,即使你的项目不针对机器人,不使用本章所考察的电机控制器,不使用电机控制器,或者不使用串行设备。

具体来说,在本章中,您将学习

  • 当存在多个候选设备时,如何选择要移植的设备库

  • 移植现有设备库的一些通用指南

  • 如何使用深度优先的方法逐步移植设备库

  • 如何使用 diozero 串行 I/O 支持

  • 帮助您了解设备和移植库的其他活动

选择设备

假设您的项目需要一个 DC 电机控制器。从简单的 H 桥到实现复杂 PID 电机控制算法的高端器件,您可以找到各种各样的控制器。一如既往,项目的最佳选择是满足项目功能和成本需求的选择。在这一章中,我们将看看我用来建造自主漫游车的 DC 电机控制器。 1 我选择了一个高端的控制器,来自 Basicmicro ( www.basicmicro.com/RoboClaw-2x15A-Motor-Controller_p_10.html )的 RoboClaw 2x15A 电机控制器。在本章中使用它有几个原因:

  • 它的功能卸载了树莓派的工作,将更多的 CPU 周期用于计算任务和任务协调。

  • 我发现它是一个优秀的运动控制器。

  • Basicmicro 的技术支持非常好。

  • 它支持 USB 接口,因此您可以体验 diozero 串行 I/O 支持。

  • 这个特定控制器的设备库应该适用于 RoboClaw 控制器系列。

  • 这是我在第一章提出的设备库匮乏的反例。

  • 这是一个复杂的设备,需要多管齐下的方法来移植设备库。

在本书中,我不会过多地描述 RoboClaw 或者如何使用它来帮助设计、实现和测试设备库。

了解设备

我强调过,你必须了解你的设备。当然,在为项目选择设备时,您必须在一定程度上了解设备的功能。您可能已经浏览了数据手册甚至用户手册来帮助做出选择,但可能更关注设备功能而不是如何使用这些功能。然而,即使你不必为它移植或创建库,你仍然必须很好地理解设备才能正确地使用它。我建议您在寻找现有器件库之前,尤其是在从头开始编写器件库之前,阅读并吸收所有数据手册、用户手册、应用笔记等。,你可以找到。这在复杂设备的情况下尤其重要,在这种情况下,你可能会跳过“无聊”但关键的细节,因为,正如一些智者所说,“魔鬼在细节中。”**

**RoboClaw 是这句格言的一个很好的例子。我声称机器人法律是一个复杂的装置。以下是一些证据:

  • 数据表长达 16 页。请参见产品网页上的下载选项卡(如前所述)。

  • 用户手册长达 101 页,描述了 130 多条命令。请参见产品网页上的下载选项卡。

用户手册中的几个章节有助于您识别一些对理解该设备非常重要的“魔鬼细节”:

  • USB 控制部分:您必须在“数据包串行”模式下运行设备。不需要担心找不到树莓派 OS 的底层设备驱动。你不需要设置波特率;USB 连接将尽可能快地运行。

  • 包序列部分:“基本命令结构由地址字节、命令字节、数据字节和 CRC16 16 位校验和组成。”对于 USB 连接,地址字节可以是 0x 80–0x 87,因为 USB 连接是唯一的(这消除了所谓的多单元包串行模式)。该部分包含有关数据包超时、只写命令的数据包确认、CRC 计算、2以及处理数据类型长度的附加信息。它还包含关于最基本命令的信息。

  • 高级分组串行、编码器、高级电机控制部分:这些包含命令的详细数据要求。您应该浏览这些部分,主要是为了了解一些现有的库代码的复杂性,尤其是围绕单个命令中的多种数据类型。

  • 高级电机控制:相关的命令描述解释了命令缓冲是如何操作的,这是 RoboClaw 的一个重要但未得到充分重视的功能。

充分掌握设备功能及其 I/O 要求将有助于您理解可能使用或移植的设备库,有助于您在必要时开发自己的库,并有助于您在任何情况下确定合适的设备库接口。

查找设备库

为了找到要使用或移植的设备库,我将遵循第六章中概述的过程。

搜索 Java 库

首先,看看 diozero 设备库。在撰写本文时,diozero 文档和 Javadoc 描述了一些支持 PWM 驱动的 DC 电机和伺服系统的接口和具体实现。跟机器人法律没什么关系。

接下来结合 RoboClaw 搜索其他树莓派串行 I/O Java 库。在撰写本文时,我一无所获。

接下来搜索 RoboClaw 和 Java。那次搜索产生了一些值得考虑的结果:

在这三个热门作品中,似乎只有 myrobotlab 值得考虑。唯一显著的缺点是拖着整个框架,这无疑包括不需要的功能。我个人不倾向于引入大量几乎肯定不会被使用的额外内容。因此,我至少会在检查完非 Java 库之后再做决定。

搜索非 Java 库

当你查看 RoboClaw 产品页面上的下载选项卡时(链接在本章的顶部),你会发现有五个不同编程语言的设备库(或驱动程序):

  • Python 用于树莓派操作系统和其他平台

  • Python 用于机器人操作系统(ROS)

  • C# 用于 Windows

  • 用于 Arduino 的 C++

  • 用于 LabVIEW 的*【G】*

您如何选择一个(或多个)来播种您的移植工作呢?如果你对一种语言的了解远胜于其他语言,那通常是最好的选择。如果不是这样,你必须评估所有的库来选择。这就是我要做的。在这样做的同时,我将提出一些具体的问题,您可以将这些问题推广到其他语言和其他设备。

我从简单的开始。据我所知,“G”LabVIEW 驱动程序下载只包含可执行文件,没有源代码;这使得它对移植毫无用处。ROS 的 Python 库似乎是由与 Basicmicro 无关的人编写的;至少四年没有更新;简单看一下它就知道它和树莓派 OS 的 Python 库没有太大区别,后者来自 Basicmicro 并受支持。因此,ROS Python 库不是一个好的候选。

看看 C#库

我有些懊恼地承认,我从未用过 C#。快速浏览一下我下载的源代码(见文件Roboclaw.cs)可以看出,C#展示了 C 血统,但具有 Java 的一些特征。可以理解,它有一些我不完全理解的“视窗主义”,但我认为可以安全地忽略,尽管它们确实使代码更难理解。

该文件包括一个定义命令常量的Commands类;它似乎很容易被复制到 Java 类中;很好,除了它包括明显用于其他 Basicmicro 设备的命令,所以要小心。该文件包含一个SerialPort类,该类定义了一些用于读写字节的低级方法。低级方法似乎与第七章中描述的 diozero SerialDevice的方法相似。

Roboclaw.cs文件当然包括了Roboclaw类,它扩展了SerialPort。现在事情变得有趣了。该类定义了三个中级方法ReadLenCmdReadCmdWrite_CRC,处理命令中的多种数据类型,进行 CRC 生成和验证,并使用低级方法与设备交互。最后,Roboclaw定义了与设备命令对应的接口级方法;使用设备库的应用程序调用接口级方法。绝大多数接口级方法使用中间层方法;一种是使用低级方法(接口级方法获得 RoboClaw 固件版本,机器人程序不太可能对此感兴趣)。

通过搜索Roboclaw源代码中的中级命令,可以确定只有四个接口级方法调用ReadLenCmd,而在 RoboClaw 用户手册中没有一个!这是因为Roboclaw类支持广泛的 Basicmicro 设备。从根本上说,这意味着你只需要担心如何移植ReadCmdWrite_CRC来支持 RoboClaw 2x15A。

综上所述,C# Roboclaw类展现了三层:

  • 用于基本串行通信的低级方法;这是你插入 diozero SerialDevice当量的地方。

  • 处理字节数组和多字节数据类型之间转换的中间层方法ReadCmdWrite_CRC;两者都需要移植。

  • 实现 RoboClaw 命令的接口级方法;您可以根据项目需要实现任意多的功能,并在以后添加更多功能。

所以,C#库看起来是一个很好的移植候选。然而,可怕的细节再次出现。ReadCmd方法的签名包含一个 C# ref。当像GetM1Encoder这样的方法调用ReadCmd时,前者必须将参数从原始形式(例如 Java int)转换为对象(例如 Java Integer,或者更类似的Object),并将它们添加到ArrayListReadCmd检查列表中每个参数的类型,以确定字节长度。虽然这是一个优雅的设计,实现起来也相当简单,但看起来开销很大,对性能有潜在的显著影响。对于 Windows 机器来说,这可能是一个合理的权衡,因为 Windows 机器的性能可能是树莓派的许多倍。

Note

我在 C#设备库下载中找不到使用示例。你可以通过更广泛的搜索找到一些。

看看 C++库

C++库(文件RoboClaw.cpp)当然包含一些 Arduino-ism。和 C#库一样,有三层。有类似 diozero SerialDevice提供的低级方法。有一些中级方法(write_nread_n)处理 CRC 生成和验证,并使用低级方法向设备发送或从设备接收字节数组。还有另外一些处理特定数据交换模式的中级方法(Read1Read2Read4Read4_1)。对应于 RoboClaw 命令的接口级方法使用中级方法。中级方法write_nread_n多次使用。Read1使用 1 次,Read2使用 8 次,Read4使用 12 次,Read4_1使用 6 次。

C++架构与 C#架构基本相同,但实现方式不同。中层表现出更多的专业化,因而方法也更多。但是 C++中间层把一些数据类型的工作推到了接口层;接口级方法可以负责转换数据类型,例如,将 32 位整数转换为 4 个字节的列表。

总之,C++ RoboClaw类还展示了三层:

  • 用于基本串行通信的低级方法,您可以插入 diozero SerialDevice等效物。

  • 中层方法write_nread_nRead1Read2Read4Read4_1,用于写入或读取一个字节数组,并用于处理特定的数据交换模式;除了Read1之外,所有都需要移植。

  • 实现 RoboClaw 命令的接口级方法;您可以根据项目需要实现任意多的功能,并在以后添加更多功能。

所以,C++库看起来也是一个很好的移植对象。整体设计不如 C#优雅。您可能需要编写比 C#更多的代码。不过我觉得,性能会更好,内存要求会更低(周围不会有一堆参数对象)。也就是说,串行通信相当慢的性能可能使这种差异可以忽略不计。

Note

C++下载包括几个例子,可以帮助你理解甚至实验 RoboClaw。当然,您必须将 RoboClaw 连接到 Arduino 才能使用这些示例。

Python 库一览

树莓派的 Python 下载包含 Python2 和 Python3 的代码。我只讨论后者。

Python 库(文件roboclaw_3.py)在架构上类似于 C#和 C++库,因为有三层*,低级、中级和接口级。接口级的实现与其他两个库的实现类似,因为接口级方法使用中间层方法。但是其他两个层次的相似性充其量也是微不足道的;由于与 Python 的数据处理有关的原因,下面两层的实现似乎由有符号数据和无符号数据主导。大致有九种低级方法来处理数据的字节长度和符号。中间层实现了 30 多种方法来处理不同的数据模式,以及数据是有符号的还是无符号的。*

*考虑到这种额外的复杂性似乎不适用于继承了 C 语言的语言,Python 库似乎不是移植的好选择。

Note

Python 下载包括几个例子,可以帮助您理解甚至试验 RoboClaw。尽管不建议移植,但您可以加载库并运行示例,因为您已经需要将 RoboClaw 连接到您的 Raspberry Pi。

答案是…

在我看来

  • 来自 myrobotlab 的 Java 库是一个可接受的移植候选库**。**

*** C#库是一个很好的移植候选。

***   C++库是一个很好的**移植候选。**

***   Python3 库是一个 ***坏*** 的移植候选。****** 

****Java 库拖了很多框架。除了很少的附加值(从我的角度来看),我担心框架引入的额外复杂性会导致性能问题。

C#和 C++库展示了相同的架构。我认为 C#实现更优雅,实现起来更简单。我认为 C++实现提供了更高的性能和更低的内存消耗,尽管它可能需要更多的编码。

虽然您可能会得出不同的结论,但基于性能和对 C++的进一步熟悉,我选择 C++库作为主要库,为 RoboClaw 的 Java 设备库的开发埋下种子。当然,您并不局限于只利用一个库,事实上,我还将使用 C#库的某些方面。

本章的其余部分描述了使用 diozero SerialDevice作为底层的移植过程。在继续之前,如有必要,您应该回顾一下第七章中的材料,其中涵盖了树莓派串行 I/O 功能和广泛的 diozero 串行设备支持。

移植问题

确定了要移植的设备库之后,您现在应该考虑一下从任何语言移植时出现的一些一般性问题,以及从 C/C++移植时出现的一些问题。

设备库接口

一个非常重要的问题是你的 Java 设备库公开的接口*。有三个相互关联的方面需要考虑:*

  • 来源指的是什么界面模型引导你的界面。

  • 范围指的是你的接口暴露了多少设备功能。一个设备可以做的比您的项目需要的更多。

  • 粒度指的是在对接口的单次调用中完成了多少功能或任务。

可能有许多来源来指导您的接口定义,但我认为最重要的是

  • 设备本身

  • 现有的设备库

  • 您的要求

我的经验表明,第一个和第二个来源通常是一致的。换句话说,设备库接口公开了设备本身公开的相同接口,至少在很大程度上是这样。因此,它们具有相同的范围(通常是一切)和相同的粒度。这是有意义的,因为现有设备库的开发人员非常积极地做尽可能少的工作,以尽可能多的灵活性公开尽可能多的功能,当然,开发人员不能知道您的需求。

对于第一个和第二个来源的广泛概括,有时也有例外。有时设备接口过于精细,例如,您必须写入多个寄存器才能完成一项任务。在这种情况下,设备库可能会隐藏设备本身的一些细节,并公开一个更大粒度的接口,从而减少库用户的“繁忙工作”。有时,现有设备库的开发人员变得“懒惰”,公开了设备功能的子集。

第三个来源做出了合理的假设,即您头脑中有某种接口来表示您需要的功能,而不依赖于提供这些功能的特定设备。我断言,在大多数情况下,你的理想接口将比设备或现有设备库的接口粒度更大。在某些情况下,甚至可能是大多数情况下,您的接口范围会更小。

那么,你是如何进行的呢?在我看来,如果你找到一个现有的设备库来移植,它应该是你的库接口的主要指南。显然,如果您只需要设备功能的一个子集(根据您的需求),您可能需要对其进行子集化。您可以做一些合理的小调整来降低粒度。当您发现缺少所需的功能时,您可能需要扩展接口。但是总的来说,模仿一个现有的接口并在背后实现它会导致更少的开发时间和更灵活的结果。如果您需要一个更大粒度的接口,您可以根据您的需求在移植的接口之上创建一个包装器。

如果您没有找到值得移植的现有库,适当的指导就不太清楚了。如果您只希望在一个项目中使用该设备,那么最好的来源可能就是您的需求。如果您希望在许多项目中使用该设备,灵活性可能是有益的,并且该设备本身可能是最佳来源。您的里程可能会有所不同!

对于 RoboClaw,存在合理的设备库,因此将它们用作模型是有意义的。对于 130 多个命令,似乎只需要设备功能的一个子集,所以新库将只实现现有库的一个子集。由于 RoboClaw 如此复杂,新接口的粒度将与现有的库相同。

一个相关的问题是是否将库接口与接口的实现分开。分离的主要动机是您是否计划拥有几个接口的“提供者”。通常答案是“不”,但你的项目可能不同。

设备实例

您的设备库应该只支持设备的一个实例还是多个实例?一个实例是只被一个线程使用还是被多个线程共享?正如在第七章中提到的,这个问题迫使我们考虑 Java 并发性,这个主题超出了本书的范围。

在机器人法律的背景下,四轮驱动机器人显然需要两个装置,六轮驱动机器人需要三个装置。因此,一个项目中可以存在该设备的多个实例。如果使用多单元模式,可以通过单个串行连接向多个设备发送命令(参见用户手册中的多单元包串行布线部分);这说明了 C++库的接口级方法中的地址参数。相反,C#库不允许在接口级方法中使用地址参数,所以它不支持多单元模式。在任何情况下,期望使用 RoboClaw USB 连接,它消除了多单元模式,导致每个设备一个库(类)实例。树莓派操作系统防止多个进程共享一个设备。我找不到一个合理的理由来允许多个线程使用一个库实例,所以并发应该是没有实际意义的。

第七章讨论了一个关于同一个 USB 设备的多个实例的有趣难题。仅使用 USB 设备标识,不可能区分一个机器人法律的多个实例;例如,您必须能够区分前轮控制器和后轮控制器。那么,如何区分两种不同的 RoboClaws 呢?用户手册指出,当通过 USB 连接时,RoboClaw 可以响应地址 0x 80–0x 87。使用 Basicmicro Motion Studio 配置工具设置机器人法律响应的地址(参见用户手册)。我通过测试确定,并得到 Basicmicro 技术支持部门的确认,当你通过 USB 向错误的地址发送命令时,RoboClaw 只是没有响应。因此,您必须小心尝试读取任何预期的响应,因为 diozero SerialDevice只实现无超时的阻塞读取。这很重要,因为设备库应该提供一种方法来验证连接到特定 USB 端口的 RoboClaw 的设备实例 ID ,实现第七章中提到的身份验证的第二阶段。

逐字与清洗端口

一个有趣的问题是你对移植的代码做了什么和多少自愿的修改。这个问题有几个方面。

考虑命名。你是否尽可能使用常量名、变量名、方法名等。,还是“净化”它们?清理可以像遵循 Java 命名约定一样简单。或者,它可能会更改名称,以便对您或您的设备库的其他用户更友好。

考虑设计变更。您是否尽可能地维护现有的设计,或者在可能的情况下增强它,也许是为了提高性能,或者更像 Java?

在 RoboClaw 的上下文中,由于需要生成一个 CRC,RoboClaw.cpp中的一些中级方法向 CRC 添加一个字节,然后写入该字节,然后对其他字节重复。该设计中显然存在可避免的开销。对于这种情况,我试图遵循很久以前给我的建议:“首先让它工作,然后让它快速工作。”

还有其他例子。注意,Roboclaw.csRoboClaw.cpp都没有引入“数据类”来处理参数或返回的数据,这种做法在 Java 中很常见。我相信原因是 C#和 C++都允许指针或引用原始类型,这在 Java 中是不可能的。例如,当RoboClaw.cpp中的一个方法需要返回多个原始类型的变量时,它可以使用指向这些变量的指针来完成。如果要返回的变量都是同一类型,在 Java 中你可以使用一个数组;如果它们不是同一类型,在 Java 中你有两个选择:你可以使用多个数组,或者你可以定义一个新的类。

RoboClaw.cpp中的大多数接口级方法和Roboclaw.cs中的所有方法都返回一个布尔值,指示设备读写操作的成功或失败。这当然消除了通过参数以外的方式返回数据的能力。在典型的 Java 库中,异常被用来代替状态返回,使得返回数据成为可能(尽管只是一个基本类型、一个数组或一个数据类)。

对于这个问题,很明显,除了基本需求之外,您所做的任何事情都会增加完成 Java 移植所需的工作和时间。但是,正如我前面所暗示的,有时“清理”或“增强”现有的库是有益的,尤其是如果您可以验证性能优势,期望其他人使用该库,或者如果您期望在长时间内在多个项目中使用该库。只有你能决定什么对你的项目是正确的。

移植方法

我想讨论一些设备库的开发理念(以及其他开发活动)。我认为有两种基本方法:广度优先深度优先。广度优先意味着一旦你完成了界面分析,你就“开始行动”应用于前面描述的 RoboClaw 库,首先实现构造器。然后实现底层方法(当 diozero 功能不匹配时,根据需要进行增强)。然后实现中级方法。然后实现必要的接口级方法。然后你开始调试整个事情。

深度优先意味着您对接口做更多的分析,以识别一两个“简单的”接口级方法以及它们需要的中级和低级方法。然后实现我称之为核心的东西,它指的是用深度优先的方法支持有意义测试的最少代码。这意味着构造器和所选接口级方法的“调用栈”。然后你开始调试。一旦核心工作,你可以选择更多的接口级方法并重复。

广度优先的优势是能够在任何层面上更加整体化;因此,你可能会减少设计上的调整。另一方面,如果你犯了一个错误,你可能直到你写了很多代码才发现它。深度优先的优势在于缩小了初始编码的范围,并且可以更快地找到工作代码。另一方面,您可能会发现,当您处理下一个“调用栈”时,您必须做比广度优先更多的调整。

我一般更喜欢深度优先,但移植时尤其喜欢深度优先。这种方法的渐进本质使得它更容易实现早期成功(一个巨大的心理提升),测试处理语言差异的技术,以及验证设计决策。在最初的成功之后,我有时会先做深度,有时会先做广度。

当然,您不必严格遵循这两种方法中的任何一种。变化是可行的,而且可能更好。

玩设备

我需要提到一个活动,无论是移植现有的设备库还是从头开始开发设备库,它都适用—用设备玩。玩,我的意思是忽略正式的开发步骤,只与设备交互,给你继续正式开发的信心。当使用不熟悉的基本 I/O 形式时,Playing 特别有用。

为了播放,该设备必须提供一些非常简单的功能,例如,读取一个已知值,无需大量配置等。,以启用它。您不需要为诸如命令、寄存器等工件定义形式常量。您不必担心类、变量等的好名字。您可能只在主类中编写了几行代码。

你可以在任何时候玩,只要你对这个设备有足够的了解。这通常意味着在您阅读了部分或全部相关文档之后。所以,你可以在寻找现有设备库之前,在做接口分析之前,或者在开始库开发之前玩。在某些情况下,甚至在您开始库开发之后,您可能还会玩,尽管这通常是为了玩复杂的东西而不是简单的东西。

如果你看看机器人法律的材料,你可能会意识到它并不适合玩。CRC 的存在消除了任何非常简单的交互。所以,很遗憾,我们将不得不开始正式开发。然而,我们将能够使用后面章节中用到的设备。

设备库开发

如何开始构建新的设备库?我建议首先选择接口级命令。研究用户手册以确定你需要什么命令来满足你的项目需求。

表 8-1 显示了我将执行的命令的名称和代码(均来自用户手册),以及RoboClaw.cpp中使用的相应方法名称。列出的命令是我在我的自主漫游车中使用的命令;如你所见,我只用了 130+条命令中的 12 条!

表 8-1

使用的 RoboClaw 命令

|

命令名称

|

密码

|

方法名

| | --- | --- | --- | | 设定速度 PID 常量 M1 | Twenty-eight | SetM1VelocityPID | | 设定速度 PID 常量 M2 | Twenty-nine | SetM2VelocityPID | | 读取电机 1 速度 PID 和 QPPS 设置 | Fifty-five | ReadM1VelocityPID | | 读取电机 2 速度 PID 和 QPPS 设置 | fifty-six | ReadM2VelocityPID | | 带符号速度、加速度和距离的缓冲驱动 M1 / M2 | Forty-six | SpeedAccelDistanceM1M2 | | 以标示的速度驾驶 M1 / M2 | Thirty-seven | SpeedM1M2 | | 以指定的速度和加速度驾驶 M1 / M2 | Forty | SpeedAccelM1M2 | | 带符号速度和距离的缓冲驱动 M1 / M2 | Forty-three | SpeedDistanceM1M2 | | 读取编码器计数/值 M1 | Sixteen | ReadEncM1 | | 读取编码器计数器 | seventy-eight | ReadEncoders | | 重置正交编码器计数器 | Twenty | ResetEncoders | | 读取主电池电压水平 | Twenty-four | ReadMainBatteryVoltage |

接下来,您应该确定由接口级方法调用的中级(和低级,如果适用的话)方法。表 8-2 显示了表 8-1 中所列命令的中级方法。表 8-2 中的结果相当有趣。这 12 个命令只需要 4 个中级方法!接受小的性能下降允许你通过使用ReadEncoders而不是ReadEncM1来消除Read4_1的使用。如果你愿意放弃知道主电池电压,你也可以取消使用Read_2

表 8-2

接口级方法(命令)调用的 RoboClaw.cpp 中级方法

|

中级方法

|

界面级方法

| | --- | --- | | write_n | SetM1VelocityPID, SetM2VelocityPID, SpeedAccelDistanceM1M2, SpeedM1M2, SpeedAccelM1M2, SpeedDistanceM1M2, ResetEncoders | | read_n | ReadM1VelocityPID, ReadM2VelocityPID, ReadEncoders | | Read4_1 | ReadEncM1 | | Read_2 | ReadMainBatteryVoltage |

在我看来,掉ReadEncM1是可以接受的。如果出现性能问题,您可以稍后实现它。然而,这里有一个微妙的权衡。编码器计数是 32 位无符号值。ReadEncM1除了计数之外还返回一个状态字节;状态表示计数的符号。ReadEncoders不返回任何状态,因此调用者必须了解运动方向(向前/向后)才能正确解释计数。

放弃ReadMainBatteryVoltage是一个非常不同的故事。由于过度消耗,我已经杀死了一些脂肪电池,所以我认为频繁检查主电池电压是谨慎的,支持命令是值得的额外工作。

接下来,您应该为核心选择一两个简单的接口级方法(在本例中是命令),这是深度优先方法所要求的。当然,最好是从您希望在项目中使用的方法/命令中进行选择。该准则的一个合理例外是,如果您需要的方法都不简单;那么选择一些简单的东西开始是一个好主意,即使你不打算以后使用它。

表格 8-2 可以帮助你选择机器人法律。该表显示了write_nread_n的实现提供了很多价值。四种方法使用write_n驱动电机;他们不是核心的好候选人。有两种方法写入速度 PID 值,因此它们是候选方法,但需要大量数据。ResetEncoders看起来是最佳人选;然而,由于马达不会运行,测试它是有问题的。

表 8-2 显示了使用read_n读取速度 PID 值的两种方法;这些都是候选,但需要大量的数据。ReadEncoders看起来是最佳人选;然而,和ResetEncoders一样,测试它是有问题的。读取主电压电池非常简单,所以它是一个候选,但不提供使用read_n实现一个方法的值。

查看用户手册RoboClaw.cpp,你会发现有设置单个编码器值的命令,它们使用write_n!因此,测试读取和写入数据的一对非常好的命令是表 8-1 中的命令 78,它使用read_n,以及命令 22("设置正交编码器 1 值",SetEncM1),它使用write_n

机器人法律课

现在你可以开始核心的编码了。与任何基于 diozero 的新项目一样,您必须创建一个新的 NetBeans 项目、包和类。然后在您的树莓派上配置项目进行远程开发;然后将项目配置为使用 diozero。步骤总结参见第七章,详细参见第 5 和 6 章。我将调用我的项目 RoboClaw ,我的包org.gaf.roboclaw,我的类RoboClaw

在你创建了RoboClaw之后,你应该确保它实现了AutoCloseable以遵循第七章中的安全网指导方针。由于机器人自动驱动马达,它很容易出现那一章中描述的那种不愉快的情况;因此,您应该在任何使用RoboClaw的应用程序中启用 Java 关闭安全网。

接下来,您应该处理 RoboClaw 命令代码。RoboClaw.h (C++下载)有一个enum定义命令代码。Roboclaw.cs (C#下载)有一个定义命令代码的内部类。我认为两者都遵循了将命令类型代码整合在一个地方的最佳实践,而不是将代码分散在接口级方法中。内部类(Commands)更加 Java 友好,代码名称与RoboClaw.cpp中的代码名称相同。因此,简单地复制内部类是非常方便的。我将只复制前面提到的 13 个命令的命令代码,只是为了使代码清单简短;你应该简单地注释掉那些你不需要的,以防你以后需要它们(就像我对代码 16 所做的那样)。认识到我使用了逐字逐句的方法;我留下了一个普通的类,而没有使用 Java enum,并且我使用了现有的命令代码名,尽管它们不符合 Java 中普遍接受的命名约定。从Roboclaw.cs复制内部类后,必须将Roboclaw.cs中找到的public const改为static final,不过那在 NetBeans 中真的很容易。

Note

并非用户手册中描述的所有命令都在RoboClaw.hRoboclaw.cs中定义了代码。我从 Basicmicro 技术支持部门了解到,大部分缺失的命令只供他们的 Motion Studio 应用程序使用。您极有可能不需要任何缺少的命令。

清单 8-1 显示了RoboClaw的初始代码,包括Commands内部类。

package org.gaf.roboclaw;

public class RoboClaw implements AutoCloseable {

    private class Commands {
//        static final int GETM1ENC = 16;
        static final int RESETENC = 20;
        static final int SETM1ENCCOUNT = 22;
        static final int GETMBATT = 24;
        static final int SETM1PID = 28;
        static final int SETM2PID = 29;
        static final int MIXEDSPEED = 37;
        static final int MIXEDSPEEDACCEL = 40;
        static final int MIXEDSPEEDDIST = 43;
        static final int MIXEDSPEEDACCELDIST = 46;
        static final int READM1PID = 55;
        static final int READM2PID = 56;
        static final int GETENCODERS = 78;
    }

Listing 8-1RoboClaw class

Note

源代码清单不包含 Javadoc 或注释(大部分)。这本书的代码库中的源代码包括这两者。您应该在移植或编写代码时包含这两者!

构造器分析和实现

通常有许多与设备库构造器的实现相关的考虑事项。本小节讨论一些与 RoboClaw 相关的 USB 串行设备。

身份

如前所述,一个项目中可能存在多个 RoboClaw 实例。此外,每个 RoboClaw 应该分配一个唯一的设备地址(0x 80–0x 87);设备地址实际上是第七章中提到的设备实例 ID。这意味着构造器应该有一个设备地址参数。在构造器上设置设备地址有一个重要的副作用:接口级命令不需要地址参数

根据前面的讨论,设备库应该支持设备实例 ID 的验证。身份验证应该在构造器内部进行还是在外部进行?我个人认为最好的选择是外在。在这一章的后面我会告诉你怎么做。

Caution

请记住,USB 连接不支持 RoboClaw 的多单元模式。这允许设计中每个单元有一个类实例,而接口级方法没有地址。对于多单元模式,正确的设计需要支持所有单元的单个类实例和每个接口级方法上的单元地址。

系列特征

一般来说,对于串行设备来说,至少在您可以控制连接两端的情况下,值得考虑可能需要定制的串行连接的几个特征。示例包括波特率、奇偶校验位和停止位。

根据 RoboClaw 用户手册,有了 USB 连接,就不需要在构造器上包含波特率,因为设备运行得尽可能快。此外,其他序列特征不会改变,因此在构造器中不需要它们。事实上,串行特征与SerialDevice的默认值相匹配,因此可以使用最简单的形式。

RoboClaw.cppRoboclaw.cs都规定了读取超时。由于SerialDevice不支持超时,所以构造器不需要有超时参数。

其他注意事项

清单 8-2 显示了由AutoCloseable授权的RoboClaw构造器和close方法。因为构造器必须创建一个SerialDevice的实例,所以您必须向RoboClaw构造器提供一个设备文件。因为我们想要支持多个设备,所以您也必须提供一个地址参数。注意RoboClaw.h为用于与 RoboClaw 通信的串行端口定义了一个变量。类似地,您需要一个类变量来引用您在构造器中创建的SerialDevice的实例。

SerialDevice构造器抛出未检查的RuntimeIOException。我决定抓住它,然后抛出选中的IOException,以确保明确失败的知识。你可能会决定做一些不同的事情。

import com.diozero.api.SerialDevice;
import com.diozero.util.RuntimeIOException;
import java.io.IOException;

private final SerialDevice device;
private final int address;

public RoboClaw(String deviceFile,
        int deviceAddress)
    throws IOException {
    try {
        this.device = new
            SerialDevice(deviceFile);
        this.address = deviceAddress;
    } catch (RuntimeIOException ex) {
        throw new IOException(ex.getMessage());
    }
}

@Override
public void close() {
    if (this.device != null) {
        // stop the motors, just in case
        speedM1M2(0, 0);
        // close SerialDevice
        this.device.close();
        this.device = null;
    }
}

Listing 8-2RoboClaw constructor and close method

作为一个自主运动控制器,RoboClaw 提供了一个潜在“不愉快后果”的好例子,在第七章中讨论,与未检查的RuntimeIOException或其他条件相关。出现状况时,马达可能正在运转。因此,RoboClaw.close必须确保电机停止。最简单的 RoboClaw 命令是表 8-1 中提到的“SpeedM1M2”,因此close方法使用了方法的 Java 实现speedM1M2。当然,该方法尚未实现,所以在实现该方法之前,您需要对语句进行注释。根据章节 7 , close也调用SerialDevice.close,防止被多次调用。

中级方法分析

现在你必须看一下write_nread_n的实现,以确定它们调用的是什么底层方法。write_n的实现非常简单。它写入代表地址、命令代码和命令参数的字节数组,同时计算 CRC,然后写入 CRC,然后读取返回状态以验证传输。实现需要 CRC 相关的方法,“写入字节”方法和“超时读取字节”方法。

read_n实现写入地址和命令字节,更新 CRC。然后,它一次一个字节地读取给定数量的四字节整数,同时更新每个字节的 CRC。它最后读取两个字节的 CRC 并检查它。实现需要 CRC 相关的方法,“写入字节”方法和“超时读取字节”方法。注意read_n也使用了一个flush方法;这种能力似乎是 Arduino 独有的。diozero SerialDevice不支持它,其他可用的 Java 库也不支持;我将忽略它。

这两种中级方法的低级需求碰巧是相同的。两者都需要与 CRC 相关的方法(清除、更新、获取)、“写入字节”方法和“超时读取字节”方法。

CRC 相关方法

我将首先处理与 CRC 相关的方法。所有的工作都在一个类变量上(RoboClaw.h中的crcRoboClaw.java)。你可以复制 CRC 相关的方法,然后对 Java 进行适当的修改,比如对数据类型;我还决定做一些清理,使用符合 Java 命名约定的方法和变量名。这些方法应该是私有的。清单 8-3 显示了支持 CRC 使用的代码。

private int crc = 0;

private void crcClear() {
    this.crc = 0;
}

private void crcUpdate(byte data) {
    this.crc = this.crc ^ (data << 8);
    for (int i = 0; i < 8; i++) {
        if ((this.crc & 0x8000) == 0x8000) {
            this.crc = (this.crc << 1) ^ 0x1021;
        } else {
            this.crc <<= 1;
        }
    }
}

private int crcGet() {
     return this.crc;
}

Listing 8-3RoboClaw CRC-related methods

在实施 CRC 方法时,有几件事值得注意。CRC 只有 16 位(2 字节)。Java 没有 16 位无符号数据类型;使用 Java int来保存 CRC 是最简单的。在整个库实现过程中,您必须认识到这种差异。这种差异的一个很好的例子是crcUpdate中的if语句,它必须使用与crc_update稍有不同的测试。

低级方法分析

“写入字节”方法(RoboClaw.cpp中的write())写入单个字节,并返回写入的字节数。在 Arduino 环境中,似乎没有失败的预期,因此没有报告失败的机制。因此,中级方法只是希望它能够工作。diozero SerialDevice.writeByte方法不返回任何内容;然而,如果出现错误,它可以抛出未检查的*RuntimeIOException,就像下面提到的读取方法一样。*

*“超时读取字节”方法(RoboClaw.cpp中的read(uint32_t timeout))需要一些思考。鉴于SerialDevice不支持超时,问题就变成了是实现超时功能还是忽略缺席。当然有可能实现类似于RoboClaw.cpp所做的事情,但是它会消耗大量的 CPU 周期。无论如何,我建议忽略缺席,除非这样做被证明是有问题的。

还要注意,read(uint32_t timeout)返回一个int而不是一个byte(读取的字节在int的最低有效字节中)。此外,如果读取因任何原因失败,该方法将返回-1。diozero SerialDevice.read方法的行为完全相同。

中级方法实现

在实现write_nread_n的等价物之前,您必须研究几个其他主题。这两种方法都允许在“超时读取”方法失败的情况下进行重试。因此,您需要一个设置重试次数的类常量。在RoboClaw.cpp中该值被设置为 2,所以我将使用它。前一小节显示了低级方法传播未检查的RuntimeIOException来指示写或读错误。因此,异常处理应该包含在重试机制中。

写 _n

write_n 使用 C++ 变量参数列表write_n的列表类型是字节。然而,检查接口级命令表明,在预处理过程中,宏被用来产生逗号分隔的字节串。Java 没有标准的预处理器。我认为最简单的方法是使用 Java varargs 功能,列出单个字节,或者在接口级方法中创建一个数组。

清单 8-4 显示了与write_n等价的 Java。如前所述,writeN方法使用 varargs(一个byte数组)作为参数。它捕捉未检查的异常,并在发生异常时重试整个操作。MAX_RETRIES就是前面讨论的常量。

private final int MAX_RETRIES = 2;

private boolean writeN(byte... data) {

    int trys = MAX_RETRIES;

    do { // retry per desired number
        crcClear();
        try {
            for (byte b : data) {
                crcUpdate(b);
                device.writeByte(b);
            }
            int crcLocal = crcGet();
            device.writeByte(
                (byte) (crcLocal >> 8));
            device.writeByte((byte) crcLocal);
            if (device.readByte() ==
                ((byte) 0xFF)) return true;
        } catch (RuntimeIOException ex) {
            // do nothing but retry
        }
    } while (trys-- != 0);
    return false;
}

Listing 8-4RoboClaw writeN method

writeN方法使用没有“超时保护”的SerialDevice阻塞读取这被认为是有风险的。然而,我发现,通常假设成功并适应失败更好。在这种情况下,首先假设设备总是响应,如果偶尔不响应,则实施“超时阻止读取”

请注意清单 8-4 中的内部循环,它用一个字节更新 CRC,然后写入该字节。一个潜在的性能改进是使用一个循环来更新 CRC,然后写入整个字节数组。根据之前的指导方针,这应该是次要活动。

阅读 _n

read_n还使用了 C++ 变量参数列表read_n的变量参数列表类型是uint32_t地址。Java 不支持地址,也不支持无符号 32 位整数。我推荐的方法是使用一个int数组,按照调用接口级方法的要求处理有符号和无符号问题。

清单 8-5 展示了read_n的 Java 等价物。readN方法使用一个int数组作为返回结果的参数。它捕捉未检查的异常,并在发生异常时重试整个操作。

private boolean readN(int commandCode,
    int[] response) {

    int trys = MAX_RETRIES;

    do { // retry per desired number
        crcClear();
        try {
            device.writeByte((byte) address);
            crcUpdate((byte) address);
            device.writeByte((byte) commandCode);
            crcUpdate((byte) commandCode);
            for (int i = 0; i < response.length;
                i++) {
                byte data = device.readByte();
                crcUpdate(data);
                int value =
                    Byte.toUnsignedInt(data) << 24;
                data = device.readByte();
                crcUpdate(data);
                value |=
                    Byte.toUnsignedInt(data) << 16;
                data = device.readByte();
                crcUpdate(data);
                value |=
                    Byte.toUnsignedInt(data) << 8;
                data = device.readByte();
                crcUpdate(data);
                value |= Byte.toUnsignedInt(data);
                response[i] = value;
            }
            dataI = device.read();
            int crcDevice = dataI << 8;
            dataI = device.read();
            crcDevice |= dataI;
            return ((crcGet() & 0x0000ffff) ==
                (crcDevice & 0x0000ffff));
        } catch (RuntimeIOException ex) {
            // do nothing but retry
        }
    } while (trys-- != 0);
    return false;
}

Listing 8-5RoboClaw readN method

清单 8-5 中的内部循环为性能改进提供了另一个选择。它也将被推迟。

完成核心

现在可以完成核心了。之前我得出结论,最好的测试是命令 22("设置正交编码器 1 值",SetEncM1)向电机 1 编码器写入一个值,命令 78("读取编码器计数器",ReadEncoders)读取两个电机编码器的值。

首先,我们将研究 Java 中的SetEncM1用户手册说,“正交编码器的范围是 0 到 4,294,967,295”(见命令 16 的描述)。由于编码器计数由 32 位值表示,因此必须将其视为无符号的。在 Java 中,这意味着使用一个long类型而不是一个int

因为writeN接受byte varargs,所以参数和命令代码必须插入到一个数组中。这对于地址参数和命令代码来说很容易。四字节编码器计数参数必须一次一个字节地插入到数组中。我决定创建一个私有的 helper 方法来完成这项工作,这样它就可以在其他方法中使用。

清单 8-6 显示了setEncoderM1方法,相当于具有 Java 友好名称的SetEncM1。该清单还包括 helper 方法。

public boolean setEncoderM1(long count){
    byte[] buffer = new byte[6];
    buffer[0] = (byte) address;
    buffer[1] = (byte) Commands.SETM1ENCCOUNT;
    insertIntInBuffer((int) count, buffer, 2);
    return writeN(buffer);
}

private void insertIntInBuffer(int value,
    byte[] buffer, int start) {
    buffer[start] = (byte) (value >>> 24);
    buffer[start + 1] = (byte) (value >>> 16);
    buffer[start + 2] = (byte) (value >>> 8);
    buffer[start + 3] = (byte) (value);
}

Listing 8-6RoboClaw setEncoderM1

现在我们将研究 Java 中的ReadEncoders。在ReadEncoders中,两个计数参数是地址。因为 Java 不处理地址,所以我将使用一个两元素数组。记住编码器计数必须是long类型。

清单 8-7 展示了getEncoders方法,相当于具有 Java 友好名称的ReadEncoders。注意long数组必须由调用者而不是方法创建。

public boolean getEncoders(long[] encoderCount) {
    int[] response = new int[2];
    boolean valid = readN(address,
            Commands.GETENCODERS, response);
    if (valid) {
        encoderCount[0] = Integer.toUnsignedLong(
                response[0]);
        encoderCount[1] = Integer.toUnsignedLong(
                response[1]);
    }
    return valid;
}

Listing 8-7RoboClaw getEncoders

测试核心

现在是考验核心的时候了!测试需要一个 main 方法。它应该位于哪里?有一些选项:

  1. RoboClaw本身:这意味着在运行时,当RoboClaw被加载时,测试实现也被加载。

  2. 在与RoboClaw相同的包中的一个类中:这意味着测试类包含在设备库的 jar 文件中。

  3. RoboClaw 项目中不同包的一个类中:这会产生与 2 相同的结果。

  4. RoboClaw 项目的测试包中的一个类中:正如在第五章中提到的,测试包中的类不会包含在 jar 文件中,这意味着它们不会被下载到树莓派中,因此不能支持远程开发。

  5. 在不同 NetBeans 项目的类中:这意味着测试类放在不同于设备库的 jar 文件中;这提供了库和测试类的清晰分离。

选项 5 是最佳选项。选项 4 不起作用。 3 选项 1 是可行的选项中最不可取的。我觉得选项 2 是“污染性的”并且容易出错,因为它将不必要的类放在同一个包中,并且可能无法捕获访问错误。选项 3 是次佳选项。我建议您在实际项目中使用选项 5。我会用选项 3,因为我懒;我还可以断言,有时用库下载一个测试类是有用的。

按照第五章中的说明,在 RoboClaw 中为新的主类创建一个新的源包,然后在这个包中创建一个新的主类。我将包命名为org.gaf.roboclaw.test,类命名为TestRoboClawCore

TestRoboClawCore该怎么办?显然它必须实例化一个RoboClaw。它应该练习两种方法,setEncoderM1getEncodersTestRoboClawCore还应该处理第七章中讨论的身份验证。

身份验证

身份验证的第一阶段由方法SerialUtil提供。findDeviceFiles在第七章中讨论。这意味着您必须将实用程序项目作为库添加到 RoboClaw 项目中。参见第五章了解如何添加库的说明。

你怎样才能完成机器人法律的第二阶段?正如在用户手册中所述以及前面所讨论的,RoboClaw 的所有命令都需要编写命令并读取响应。此外,每个命令都包含一个地址(将其视为设备实例 ID)。如果命令地址与 RoboClaw 中配置的地址相匹配,RoboClaw 会做出响应。如果不匹配,则没有响应,读取响应的尝试将失败。

第二阶段的一个简单方法是发出一个“无害的”命令,然后等待响应。不幸的是,可能没有响应的事实需要超时的阻塞读取,而SerialDevice不支持。此外,所有命令都实现了重试机制,如果第一次失败,就没有理由再次重试已知会失败的操作。

设计解决方案有几种方法。我选择了一个最小化代码重复的方法。首先,我查看了表 8-1 中的命令。就写入和读取的字节数而言,绝对最简单的是“复位正交编码器计数器”命令,该命令需要内核中的中级方法writeN(见表 8-2 )。这么好的运气!第二,您可以重新设计writeN来消除阻塞读取和必要时的重试。第三,您可以实现超时的阻塞读取。最后,您可以创建一个方法来实现整个第二阶段。

清单 8-8 显示了对RoboClaw的修改结果。第一个writeN反映了对清单 8-4 中原始writeN的更改,以消除重试和阻塞读取。第二个writeN保留了原writeN的签名,这样接口级方法就不需要使用更复杂的签名。

import com.diozero.util.SleepUtil;

private boolean writeN(int retries,
        boolean readResponse, byte ... data) {

    do { // retry per desired number
        crcClear();
        try {
            for (byte b : data) {
                crcUpdate(b);
                device.writeByte(b);
            }
            int crcLocal = crcGet();
            device.writeByte((byte)
                (crcLocal >> 8));
            device.writeByte((byte) crcLocal);
            if (readResponse) {
                if (device.readByte() ==
                        ((byte) 0xFF)) return true;
            }
        } catch (RuntimeIOException ex) {
            // do nothing but retry
        }
    } while (retries-- != 0);
    return false;
}

private boolean writeN(byte ... data) {
    return writeN(MAX_RETRIES, true, data);
}

private int readWithTimeout(int timeout)
        throws RuntimeIOException {
    int count = 0;
    while(device.bytesAvailable() < 1) {
        SleepUtil.sleepMillis(1);
        if (++count >= timeout) break;
    }
    if (count >= timeout) return -1;
    else return device.read();
}

public boolean verifyIdentity() throws IOException {
    try {
        writeN(0, false, (byte) address,
               (byte) Commands.RESETENC);
        return readWithTimeout(20) >= 0;
    } catch (RuntimeIOException ex) {
        throw new IOException(ex.getMessage());
    }
}

Listing 8-8Changes to RoboClaw to support identity verification

清单 8-8 中的readWithTimeout方法实现了一个简单的“超时读取”有两个方面值得注意:第一,它使用 diozero SleepUtil类来避免处理InterruptedException(见第七章);第二,如果找到预期的可用单字节,该方法读取该字节以保持串行通信同步。

清单 8-8 中的verifyIdentity方法首先使用新的writeN方法从RoboClaw构造器向设备地址发出“重置正交编码器计数器”命令。然后它调用readWithTimeout来获得任何响应。它做出了一个合理的假设,即任何响应都会验证身份。

为了形式化 RoboClaw 的两阶段身份验证,我决定创建一个新的实用方法来实现身份验证。清单 8-9 显示了包含static方法findDeviceFile的类RoboClawUtil。对于第一阶段,该方法利用SerialUtil.findDeviceFiles来产生匹配 USB 设备身份的设备文件列表。该方法遍历执行身份验证第二阶段的设备文件列表,使用RoboClaw.verifyIdentity检查 USB 设备的设备实例 ID。请注意,无论验证成功与否,该方法都会关闭设备。当成功时,它返回设备文件。这允许在创建实际使用的RoboClaw实例时使用 try-with-resources。

package org.gaf.roboclaw;

import java.io.IOException;
import java.util.List;
import org.gaf.util.SerialUtil;

public class RoboClawUtil {

    public static String findDeviceFile(
            String usbVendorId, String usbProductId,
            int instanceId) 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);
                RoboClaw claw =
                        new RoboClaw(
                             deviceFile,
                             instanceId);
                boolean verified =
                    claw.verifyIdentity();
                claw.close();
                if (verified) return deviceFile;
            }
        }
        return null;
    }
}

Listing 8-9RoboClawUtil

Note

findDeviceFile中使用 USB 设备标识的参数可能是多余的。USB 设备身份可以在该方法中被硬编码。

TestRoboClawCore 实现

列表 8-10 显示TestRoboClawCore。该类必须

  • 使用RoboClawUtil.findDeviceFile执行 USB 设备身份验证

  • 根据第七章,启用资源试运行和 diozero 关闭安全网

  • 根据章节 7 ,注册RoboClaw实例进行关机,启用 Java 关机安全网

  • 使用setEncoderM1设置编码器 M1 的值

  • 使用getEncoders读取两个编码器的值

RoboClaw USB 设备标识来自表 7-1 ,其中{usbVendorId,usbProductId} = {03eb,2404}。设备地址(或设备实例 ID)来自我在配置 RoboClaw 时使用的值(0x80)。

package org.gaf.roboclaw.test;

import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.roboclaw.RoboClaw;
import org.gaf.roboclaw.RoboClawUtil;

public class TestRoboClawCore {

    private final static int ADDRESS = 0x80;

    public static void main(String[] args)
        throws IOException {
        // identity verification
        String clawFile =
                RoboClawUtil.findDeviceFile(
                        "03eb", "2404", ADDRESS);
        if (clawFile == null) {
            throw new IOException(
                     "No matching device!");
        }

        try (RoboClaw claw = new RoboClaw(clawFile,
               ADDRESS)) {

            Diozero.
                registerForShutdown(claw);

            long[] encoders = new long[2];
            boolean ok = claw.setEncoderM1(123456l);
            if (!ok) {
                System.out.println("writeN failed!");
            }

            ok = claw.getEncoders(encoders);
            if (!ok) {
                System.out.println("readN failed");
            } else {
                System.out.println("Encoder M1:" +
                        encoders[0]);
            }
        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 8-10TestRoboClawCore

在运行TestRoboClawCore之前,您必须按照用户手册来:

  • 将主电池连接到 RoboClaw。

  • 将编码电机连接到 RoboClaw(尽管这可以在以后完成)。

  • 使用 Basicmicro Motion Studio 应用程序:

    • 更新 RoboClaw 固件(在设备状态屏幕上)。

    • 确保设备运行在数据包串行模式(在通用设置屏幕上)。

    • 分配所需的地址(0x 80–0x 87,在通用设置屏幕上);我用的是 0x80。

  • 通过 USB 电缆将您的树莓派连接到 RoboClaw。

Note

在撰写本文时,Motion Studio 只能在 Windows 上运行,这对非 Windows 用户来说是个问题。我用 macOS。幸运的是,我有 Parallels ( www.parallels.com )托管 Windows 8.2,所以我能够运行 Motion Studio。您可以为您的工作站创建类似的环境,或者使用廉价的 Windows 机器。

此时,您不需要连接电机,但如果连接了,也不会有任何损害。如第五章所述,使用 NetBeans 远程运行TestRoboClawCore。您应该会看到类似于清单 8-11 的结果(您的设备文件可能会有所不同)。核心作品!

/dev/ttyACM1
Encoder M1:123456

Listing 8-11Results of successful execution of TestRoboClawCore

现在来点“乐子”在TestRoboClawCore中,将ADDRESS字段更改为0x81。显然,身份验证的第二阶段应该会失败。再次运行TestRoboClawCore。您应该看到以下内容:

java.io.IOException: No matching device!

如果是这样,那就好!这意味着设备实例 ID 的验证在设备实例 ID 匹配和不匹配时都有效。

完成实施

成功是伟大的,但还有更多工作要做。你需要实现中间层方法Read2和使用它的接口层方法,加上表 8-2 中剩余的使用write_nread_n的接口层方法。

首先,我们来分析并实现Read2。它与read_n相似,既提供从设备读取的数据,也提供指示操作成功或失败的状态。然而,Read2返回一个包含数据而不是状态的双字节无符号整数,并在参数中提供状态而不是数据。

看看使用Read2的接口级方法,你会发现更多的不一致。有些方法,如期望的方法ReadMainBatteryVoltage,返回一个整数并在参数中提供状态,有些返回状态并提供整数或两个字节作为参数。为了满足我的好奇心,我查看了Roboclaw.cs,发现它始终返回状态,并使用一个或多个参数提供任何数据。

我非常想引入一致性,但是这也带来了一些问题。Java 不允许除了对象之外的引用。有几种方法可以处理这个问题:

  • 使用异常来指示状态并返回值。这可能是太多的 Java 主义,并且为了一致性,会影响已经完成的工作。

  • 使用子类来返回状态和值。这引入了一些 Java-ism,但不影响已经完成的工作。

  • 使用数组作为参数,即使是不必要的。这引入了一些 Java-ism,但不影响已经完成的工作。

我认为数组是最简单的方法。因此,接口级方法将直接返回状态,并采用参数来返回数据。

为了使Read2的 Java 实现与其他中级方法一致,它也应该返回 status。由于Read2调用者无论如何都必须考虑“双字节性”,我建议通过参数返回一个字节数组。这产生了一个与清单 8-12 中所示的read2方法的read_n(或readN)非常相似的设计。

private boolean read2(int commandCode,
    byte[] response) {

    int trys = MAX_RETRIES;

    do { // retry per desired number
        crcClear();
        try {
            device.writeByte((byte) address);
            crcUpdate((byte) address);
            device.writeByte((byte) commandCode);
            crcUpdate((byte) commandCode);

            byte data = device.readByte();
            crcUpdate(data);
            response[0] = data;
            data = device.readByte();
            crcUpdate(data);
            response[1] = data

            // check the CRC
            int crcDevice;
            int dataI;
            dataI = device.read();
            crcDevice = dataI << 8;
            dataI = device.read();
            crcDevice |= dataI;
            return ((crcGet() & 0x0000ffff) ==
                (crcDevice & 0x0000ffff));
        } catch (RuntimeIOException ex) {
            // do nothing but retry
        }
    } while (trys-- != 0);
    return false;
}

Listing 8-12RoboClaw read2

表 8-2 表明ReadMainBatteryVoltage是唯一使用read2的方法,所以我们接下来将实现那个接口级方法。清单 8-13 显示了具有 Java 友好名称getMainBatteryVoltage的实现。

public boolean getMainBatteryVoltage(int[] voltage) {
    byte[] response = new byte[2];
    boolean ok = read2(Commands.GETMBATT, response);

    if (ok) {
        int value =
            Byte.toUnsignedInt(response[0]) << 8;
        value |= Byte.toUnsignedInt(response[1]);
        voltage[0] = value;
    }
    return ok;
}

Listing 8-13RoboClaw getMainBatteryVoltage

为了测试read2getMainBatteryVoltage,将清单 8-12 和 8-13 中所示的代码添加到RoboClaw中。将清单 8-14 中的代码添加到TestRoboClawCore中;我把它放在 try-with-resources 结束之前。

int[] voltage = new int[1];
ok = claw.getMainBatteryVoltage(voltage);
if (!ok) {
    System.out.println("read2 failed");
} else {
    System.out.println("Main battery voltage: " +
        voltage[0]);
}

Listing 8-14Testing getMainBatteryVoltage and read2

当您运行TestRoboClawCore时,您应该会看到清单 8-10 中的结果以及类似如下的内容:

Main battery voltage: 120

由于报告的值以十分之一伏特为单位,因此电压为 12.0V,这对于我的 3 芯 LiPo 主电池来说是合理的。您几乎肯定会看到不同的电压值,这取决于电池的额定电压和充电水平。

剩余的接口级命令方法使用已经测试过的writeNreadN。清单 8-15 显示了获取电机 1 速度 PID 的getM1VelocityPID方法的实现。它必须提供三个浮点值和一个整数。一致性要求为状态返回一个boolean,并为四个“感兴趣的”值使用参数。唯一的两个选择是

  • 三个float数组和一个int数组

  • 具有三个float字段和一个int字段的内部类

两个选择都有点不愉快。然而,后者展示了一些新的东西,并且肯定更加 Java 友好,所以我将使用嵌套类。

清单 8-15 中getM1VelocityPID的实现反映了

  • 有另外一种相同的方法来获得电机 2 的 PID。

  • 该方法不是性能关键的。

因此,我将实现的公共部分放在它自己的方法中。这使得对电机 2 执行相同的命令变得非常容易,也减少了编码和测试。

public boolean getM1VelocityPID(
    VelocityPID velocityPID) {
    return getVelocityPID(Commands.READM1PID,
        velocityPID);
}

private boolean getVelocityPID(int commandCode,
    VelocityPID velocityPID) {

    int[] response = new int[4];
    boolean valid = readN(commandCode, response);
    if (valid) {
        velocityPID.kP =
            ((float) response[0]) / 65536f;
        velocityPID.kI =
            ((float) response[1]) / 65536f;
        velocityPID.kD =
            ((float) response[2]) / 65536f;
        velocityPID.qPPS = response[3];
    }
    return valid;
}

public static class VelocityPID {
    public float kP;
    public float kI;
    public float kD;
    public int qPPS;

    public VelocityPID() {
    }

    public VelocityPID(float kP, float kI,
            float kD, int qPPS) {
        this.kP = kP;
        this.kI = kI;
        this.kD = kD;
        this.qPPS = qPPS;
    }

    @Override
    public String toString() {
        return "Velocity PID kP: " + kP +
        "  kI: " + kI + "  kD: " + kD +
        "  qpps: " + qPPS;
    }
}

Listing 8-15RoboClaw getM1VelocityPID and VelocityPID inner class

清单 8-16 显示了设置电机 1 速度 PID 的setM1VelocityPID方法的实现。虽然有可能为三个float值和单个int值使用单独的参数,但是由于VelocityPID类已经存在,setM1VelocityPID将会使用它。

与获取 PID 一样,您可以将公共函数分解成不同的方法。同样,这减少了编码和测试。

public boolean setM1VelocityPID(
        VelocityPID velocityPID) {
    return setVelocityPID(Commands.SETM1PID,
            velocityPID);
}

private boolean setVelocityPID(int commandCode,
        VelocityPID velocityPID) {
    byte[] buffer = new byte[18];

    // calculate the integer values for device
    int kPi = (int) (velocityPID.kP * 65536);
    int kIi = (int) (velocityPID.kI * 65536);
    int kDi = (int) (velocityPID.kD * 65536);

    // insert parameters into buffer
    buffer[0] = (byte) address;
    buffer[1] = (byte) commandCode;
    insertIntInBuffer(kDi, buffer, 2);
    insertIntInBuffer(kPi, buffer, 6);
    insertIntInBuffer(kIi, buffer, 10);
    insertIntInBuffer(velocityPID.qPPS, buffer, 14);
    return writeN(buffer);
}

Listing 8-16RoboClaw setM1VelocityPID

是时候测试新方法了。将清单 8-16 中的代码添加到RoboClaw中。在 try-with-resources 语句的末尾之前添加清单 8-17 到TestRoboClawCore中的代码。

RoboClaw.VelocityPID m1PID =
    new RoboClaw.VelocityPID();
ok = claw.getM1VelocityPID(m1PID);
if (!ok) {
    System.out.println("readN failed");
} else {
    System.out.println("M1:" + m1PID);
}

m1PID = new RoboClaw.VelocityPID(8, 7, 6, 2000);
ok = claw.setM1VelocityPID(m1PID);
if (!ok) {
    System.out.println("writeN failed");
}

ok = claw.getM1VelocityPID(m1PID);
if (!ok) {
    System.out.println("readN failed");
} else {
    System.out.println("M1:" + m1PID);
}

Listing 8-17More in TestRoboClawCore

运行TestRoboClawCore,您应该会看到类似清单 8-18 中的输出。您的 M1 速度 PID 值可能会有所不同。同样,你的电压可能会有所不同。

/dev/ttyACM1
Encoder M1:123456
Main battery voltage:120
M1:Velocity PID kP: 10.167343  kI: 1.7274933  kD: 0.0  qpps: 2250
M1:Velocity PID kP: 8.0  kI: 7.0  kD: 6.0  qpps: 2000

Listing 8-18Output

成功运行测试后,将主电池从 RoboClaw 上断开,然后重新连接(我根据用户手册通过开关连接了我的电池)。这将恢复原始 PID 值。

现在是一些可怕的工作。我们将执行一个驱动马达的命令!清单 8-19 展示了speedAccelDistanceM1M2的实现。唯一有趣的方面是对一些参数使用了long;您可以猜到,这是因为 Java 不支持无符号 32 位整数。

public boolean speedAccelDistanceM1M2(
    long acceleration, int speedM1, long distanceM1,
    int speedM2, long distanceM2, boolean buffer) {
    byte[] buf = new byte[23];

    buf[0] = (byte) address;
    buf[1] = (byte) Commands.MIXEDSPEEDACCELDIST;
    insertIntInBuffer((int) acceleration, buf, 2);
    insertIntInBuffer(speedM1, buf, 6);
    insertIntInBuffer((int) distanceM1, buf, 10);
    insertIntInBuffer(speedM2, buf, 14);
    insertIntInBuffer((int) distanceM2, buf, 18);
    buf[22] = (buffer) ? (byte) 0 : 1;
    return writeN(buf);
}

Listing 8-19RoboClaw speedAccelDistanceM1M2

为了简化测试,我将创建一个新的主类TestClawMotor,如清单 8-20 所示。它和TestRoboClawCore有一些明显的相似之处,但它只叫speedAccelDistanceM1M2。当然,要运行这个测试,您必须将电池连接到 RoboClaw,并将编码电机连接到 RoboClaw。注意,该命令有两次调用。两个命令都被缓冲。第一个命令执行并使 RoboClaw 以 400 编码器每秒 2 的速度加速到 400 秒,并运行 2400 个脉冲的总距离。然后执行第二个命令,使机器人以 400 PPS 的速度减速 2 达到 0 PPS。

您可能对sleep语句感到好奇。speedAccelDistanceM1M2方法只是将命令发送给 RoboClaw 并返回;因此,该方法在命令启动的移动完成之前很久就返回。休眠只是在设备关闭之前给缓冲的命令时间来完成。阅读用户手册了解更多关于缓冲的细节。

package org.gaf.roboclaw.test;

import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.roboclaw.RoboClaw;
import org.gaf.roboclaw.RoboClawUtil;

public class TestClawMotor {

    private final static int ADDRESS = 0x80;

    public static void main(String[] args)
        throws IOException {
        // identity verification
        String clawFile =
                RoboClawUtil.findDeviceFile(
                        "03eb", "2404", ADDRESS);
        if (clawFile == null) {
            throw new IOException(
                    "No matching device!");
        }

        try (RoboClaw claw = new RoboClaw(clawFile,
               ADDRESS)) {

            Diozero.
                registerForShutdown(claw);

            boolean ok =
                    claw.speedAccelDistanceM1M2(
                    400, 400, 2400, 400, 2400,
                    true);
            ok = claw.speedAccelDistanceM1M2(
                    400, 0, 0, 0, 0, true);
            // wait for buffered commands to finish
            Thread.sleep(10000);
        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 8-20TestClawMotor

当您运行TestClawMotor时,您应该看到电机加速到额定速度,以额定速度运行,然后减速到零(停止),所有这些总共需要大约 7 秒的时间。如果没有发生这种情况,您可能有一些接线不正确。

恭喜你!您已经完成了将 C++库移植到 Java 的所有艰苦工作!我没有涵盖表 8-2 中的所有命令,但是那些没有实现的命令是那些已经实现的命令的简单变体。完整的实现包含在本书的代码库中。

Caution

不要忘记RoboClaw.close正常工作需要speedM1M2

摘要

在本章中,您已经学会了如何

  • 评估现有设备库以移植到 Java

  • 如果你有足够的时间,可以在多个图书馆中选择

  • 识别和评估移植问题,在如何移植现有库以及移植多少方面进行权衡

  • 解决将 C++库移植到 Java 的血淋淋的细节

  • 使用深度优先的开发方法,随着新需求的出现改进设计

  • 为复杂的串行设备创建一个全功能的 Java 库

干得好!

Footnotes 1

如果你想了解更多关于自主漫游车的信息,请访问。

  2

CRC(循环冗余校验)是一种检测传输数据流中错误的简单方法。你不需要理解这些原理,但是你必须在 RoboClaw 的上下文中处理 CRC 计算。

  3

使用 diozero 远程提供者进行测试,然后切换到所需的提供者进行生产,这可能是一个合理的选择。

 

********