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

树莓派上的 Java 教程(全)

原文:Java on the Raspberry Pi

协议:CC BY-NC-SA 4.0

一、动机

这本书主要关注在机器人项目和物联网项目中使用树莓派和 Java。第二个重点是使用 Apache NetBeans 进行远程代码开发。我发现 Raspberry Pi、Java 和 NetBeans 的组合是开发机器人项目的非常强大和高效的方法。我相信这种组合也适用于物联网项目。在本章中,我将讨论机器人和物联网项目背景下的动机

  • 使用树莓派作为计算资源的主要提供者

  • 使用 Java 作为主要的编程语言

  • 使用 Apache NetBeans 远程开发运行在树莓派上的 Java 程序

在深入讨论动机之前,我想强调一些非常重要的事情。这本书的大部分内容都在探索为机器人和物联网项目中使用的设备寻找或创建支持。因此,即使您不进行远程开发、不使用 NetBeans、不使用 Java 或不使用 Raspberry Pi,许多与设备相关的材料也适用。

也就是说,说到动机!

为什么是树莓派?

要理解为什么树莓派非常适合机器人和物联网项目,我们必须探索这些项目的需求。我不会深究术语机器人的定义,因为它可以有一个或宽或窄的范围,这取决于上下文,并且它是不断发展的。举个例子,对我的孙子来说,因为一个微控制器驱动两个伺服系统而挥动翅膀的玩具鸟就是机器人。对我来说,除非一个系统感知它的环境并对环境做出反应自主,否则它不是一个机器人。对一些人来说,可能是现在,当然也可能是几年内,除非一个系统能够根据自己的经验感知环境并做出适应性反应(即,除非它包括人工智能和机器学习),否则它不可能是机器人。

出于本书的目的,机器人技术总是意味着

  • 传感器以各种方式对环境进行采样

  • 能够对环境做出反应的致动器

  • 智能解释传感器的输出并驱动执行机构以实现预期目标

物联网的定义也很松散,并且仍在不断发展。从根本上说,物联网是关于通过互联网进行通信的事物(例如,门锁、恒温器或冰箱)。事物感知它们的环境,并发送数据进行分析。数据被分析以通知事物,零个或多个事物做出反应,尽管不一定是事物做出感应。与机器人技术一样,人工智能和机器学习正在成为物联网的一个重要方面,但通常是在中,而不是在中。

就本书而言, IoT 意味着

  • 传感器以各种方式对环境进行采样,总是

  • 通信通过“互联网”进行通信,总是

  • 致动器允许对环境做出反应,有时

显然,这两个学科之间有重叠之处。事实上,一个机器人可能是一个东西。即使不是一件的事情,对于一些机器人来说,通信与物联网项目一样重要。例如,一个机器人可以与其他机器人合作,或者利用云服务,如图像处理甚至人工智能。

机器人和物联网还有其他共同需求。许多机器人系统和一些物联网系统是移动的,因此无线通信和小尺寸和低重量往往是考虑因素。这种系统必须由电池供电。一些物联网系统是固定的,但部署在偏远地区;这些也需要无线通信和电池供电。本书假设项目需要无线通信和电池供电。

那么,是什么让树莓派成为机器人和物联网的好选择呢?

  • 所有型号都支持多种类型的基本 I/O ,用于与传感器和执行器(设备)交互。相关的基本 I/O 类型有数字输入和输出(也称为 GPIO)、UART 协议(串行端口或只是串行),以及更复杂的串行协议,即内部集成电路总线(I2C 1 )和串行外设接口总线(SPI)。

  • 大多数型号提供现代无线通信技术,即 Wi-Fi、蓝牙和蓝牙低能耗(BLE)。

  • 所有型号都支持多种操作系统和多种编程语言。

  • 各种型号提供了内存和处理能力以及物理大小的选择,以支持从相对简单的控制器到桌面计算的广泛需求。

  • 所有型号的功耗都相当低。

  • 所有机型都有相当不错的性价比。

  • 用户社区是巨大的,并且是支持的。

公平地说,有些竞争产品拥有更快的处理器、更大的内存、更好的 I/O 能力或更低的价格。然而,据我所知,没有任何产品能接近树莓派社区的规模(截至 2021 年 5 月,已售出超过 4000 万个)。当你遇到一个问题时,你几乎总会发现有人在某个地方遇到了这个问题并解决了它。

因此,对于您的项目,您可能会找到一个似乎更适合的竞争产品。然而,与选择树莓派相比,你可能会做更多的工作,获得更少的支持。

机器人领域的“最佳”树莓派

如果您的项目侧重于机器人,突出的特征是低功耗、基本 I/O 能力、通信能力和尽可能多的计算能力,以便 Pi 可以提供项目的“大脑”。所有现代的树莓派系列都具有相同的基本 I/O 功能,但在其他方面有所不同。在我看来,在撰写本文时,树莓派 3 系列的三个型号之一是低电力消耗和高计算能力之间的最佳妥协。

你可以在树莓基金会的网站上找到这些模型的完整描述(见 www.raspberrypi.org/products/ )。表 1-1 显示了各型号显著特征的对比。该表没有列出等效的功能,如基本 I/O、音频、摄像头和显示器支持。

表 1-1

树莓派 3 模型的比较

|

特征

|

3B

|

3B+

|

3A+

|
| --- | --- | --- | --- |
| CPU 速度 | 1.2 千兆赫 | 1.4 千兆赫 | 1.4 千兆赫 |
| 随机存取存储 | 1 GB | 1 GB | 512 兆字节 |
| 无线网络 | 2.4ghz | 2.4 GHz 和 5 GHz | 2.4 GHz 和 5 GHz |
| 蓝牙 | brotherhood of locomotive engineers 火车司机兄弟会 | 蓝牙 4.2 和 BLE | 蓝牙 4.2 和 BLE |
| 以太网 | 100 碱基 | 基于 USB 2.0 的千兆以太网 | 钠 |
| USB 2.0 端口 | four | four | one |
| 大小 | 56 毫米 x 85 毫米 | 56 毫米 x 85 毫米 | 56 毫米 x 65 毫米 |
| 成本(美元) | Thirty-five | Thirty-five | Twenty-five |

如您所见,B+和 A+型比 B 型具有更高的 CPU 速度和更好的无线通信功能。但是,如果您已经有了 B 型,它很可能是可以接受的。如果您必须购买 Pi,B+或 A+型号将是更好的选择。B+和 A+之间的选择取决于项目所需的 RAM 数量、物理大小和连接性,以及项目的成本敏感性。

出于本书的目的,这三个模型是等价的。我会用一个树莓派 3 型号 B+ (Pi3B+)。第二章告诉你如何设置。

Note

你可能会奇怪,为什么我不推荐树莓派 model 4 家族的一员。在撰写本文时,与 Pi3B+相比,任何 Pi4 都成本更高,需要更大的功率(因此需要更大的电池),并且需要散热器甚至风扇。Pi4 价格下降,但其他差异没有改变。如果您希望使用 Pi4,本书中关于 Pi3B+的大部分内容也适用于 Pi4。

物联网的“最佳”树莓派

如果您的项目侧重于物联网,突出的特征是低功耗、基本 I/O 能力、无线通信能力和适度的计算能力。在我看来,在本文写作之时,只有一款机型,树莓派 Zero W (Zero W)是候选机型。它的尺寸大约是 Pi3B+的 40% (30 毫米 x 65 毫米),重量只有 Pi3B+的 20%。

你确实有一个选择。您可以购买带(14 美元)或不带(10 美元)带引脚的 GPIO 接头的 Zero W。如果您打算只连接 I2C 或 SPI 器件,或者只使用几个 GPIO 引脚,最好使用零 W,只焊接您需要的引脚。如果你需要大量的 GPIO 管脚或者讨厌焊接,就用预焊的插头管脚来获得零 WH。第三章告诉你如何设置一个零 w。

为什么是 Java?

我承认我很难对 Java 保持客观(没有双关语的意思)。当 Java 在 1995 年推出时,我开始用它编程。我还在用 Java 编程。我最雄心勃勃的机器人项目大多是用 Java 编写的,有时混合了一点 C/C++(对于 Arduino)和 Python。也就是说,在下面的讨论中,我将尽量保持客观。

树莓派支持广泛的编程语言。事实上,树莓派的主要目标是让所有年龄段的人都能学习如何编程。我不会试图将 Java 与该范围进行比较,而是将讨论限制在我称为专业级编程语言的范围内,这些语言面向独立程序,支持多任务处理,支持机器人和物联网设备访问,支持网络访问,由专业级开发工具支持,等等。我认为这将编程语言的选择局限于 Java、Python 或 C/C++。

选择的标准是什么?有几个;我将使用的,主要是按重要性排序:

  • 程序员生产率

  • 表演

  • 行业接受度

程序员生产率

程序员的生产力是多方面的,很难精确定义,并且测量起来有些主观。我将讨论我认为最有说服力的方面。

面向对象编程

OOP 是不言自明的。虽然与高性能的目标有些矛盾,但我认为 OOP 的好处是值得权衡的。这些好处包括改进的模块化、可维护性、质量、可重用性和灵活性——基本上是对程序员生产率的巨大提升。

Java 从一开始就被设计来促进 OOP,事实上也强制要求 OOP。Python 支持 OOP,但在我看来,并不强调它。因此,虽然存在于 Python 中,但 OOP 在 Java 中得到更好的支持。

当然 c 根本不是面向对象的。C++先于 Java 出现(尽管它直到 1998 年才标准化),看起来像是后来才出现的,实际上是 C 语言的面向对象“包装器”。因此,我的观点是,虽然 OOP 出现在 C++中,但它在 Java 中得到更好的支持。

安全

安全性是指在程序中引入难以诊断甚至危险的错误的可能性。更安全的语言的好处意味着调试时间更少,软件甚至硬件系统崩溃的风险更小——实际上是更高的编程效率。

Python 有一些我(主观上)不喜欢的特性,因为我觉得它们可能会引入 bug。Python 的松散或动态类型化就是最好的例子;我更喜欢 Java 的静态类型。即使 Python 看似令人满意地使用空白作为其语法的一部分,也可能引入逻辑错误,因为这使得开发工具在运行之前更难发现错误;Java 公认的更加冗长的语法消除了这个问题。因此,我认为 Java 比 Python 更安全。

c,在我看来,基本上是一种非常接近系统硬件和操作系统的“高级机器语言”。你几乎可以做任何事情。这意味着你只是一个模糊的错误(例如,糟糕的指针算法,一个错误的memcpy,或者一个相应的malloc丢失了free)就有可能使程序甚至整个系统崩溃(我已经做到了!).C++在消除 C 的危险方面做得很少,如果有的话。Java 防止这种危险,所以 Java 比 C/C++安全得多。

一次编写,随处运行

Java 以“一次编写,随处运行”的承诺而闻名。在本书的上下文中,这意味着您可以在任何平台上编写、构建、编码并运行可执行文件——您的 Raspberry Pi、macOS 工作站、Windows 10 工作站或某些 Linux 机器。这一承诺得到了兑现——除非涉及到平台细节。显然,在将机器人和物联网设备连接到树莓派时,您必须使用特定于平台的基本 I/O。然而,我在机器人领域的经验是,可能 10–20%的项目与硬件一起工作,因此多达 90%的项目可以在高性能工作站上编写和测试,而不是在树莓派上。这可以极大地提高程序员的工作效率。然而,我认为对于物联网项目来说,增幅不会那么大。

Python 作为一种解释型语言,在这方面享有和 Java 一样的优势。因此,Java 和 Python 大致相当。

C/C++作为编译语言,在这方面比不上 Java。虽然在高性能工作站上开发和测试平台无关的代码是可能的,但是要使它在树莓派上运行,必须将代码复制到 Pi 上并在 Pi 上编译。可以在工作站上交叉编译 Pi,然后复制到 Pi。在这两种情况下,谨慎的做法是在 Pi 上再次测试。这对程序员的生产力是一个不愉快的打击。

图书馆

虽然在机器人和物联网的背景下可能没有前面的生产力方面重要,但 Java 广泛的标准库集合是 C/C++或 Python 无可匹敌的。凡是你能想到的,Java 很可能都有它的标准库;仅举几个例子,网络、数据库、安全、加密、并发和集合。c 强制你滚动自己的支持或者找第三方库。C++和 Python 没有 Java 得到广泛的支持。在这方面,Java 丰富的函数库无疑给了它在程序员生产率方面的优势。

限制

使用 Java 时,程序员的生产率有限制吗?答案是肯定的。为了解释这些局限性,我们需要检查这本书为机器人和物联网项目假设的理想化软件架构;如图 1-1 所示。 树莓派 OS 层代表操作系统及其内核。该层为树莓派的基本 I/O 功能(GPIO、串行、I2C、SPI)提供了一个低级 C API 2。当然,该层对具体的设备一无所知。

img/506025_1_En_1_Fig1_HTML.jpg

图 1-1

理想化的软件架构

应用层代表你为你的项目编写的 Java 应用程序。一个应用程序想要使用一个 API 来呈现一个设备的抽象;也就是说,应用程序只关心设备做什么,而不关心设备如何做。一些例子:

  • 对于通过 GPIO 连接的 LED,应用需要“打开”或“关闭”,而不是“将 GPIO 引脚 8 设为高电平”或“将 GPIO 引脚 8 设为低电平”。

  • 对于通过 I2C 连接的温度传感器,应用想要“读取温度”,而不是“从寄存器 0x0E 开始,从 I2C 总线 1 上地址 0x42 的器件读取 2 个字节,从这 2 个字节组装一个 14 位值,应用补偿因子以产生温度。”

基本 I/O 层代表一个基本 I/O 库,“神奇”代码 3 提供了一个 Java API,用于通过树莓派 OS 层的 C API 提供的树莓派基本 I/O 功能。从根本上说,该层将 Java 程序员从可怕且难以使用的 OS C API 中抽象出来。像树莓派 OS 层一样,基本 I/O 层对特定设备一无所知;它只是通过单个基本 I/O 库或多个基本 I/O 库为 GPIO、串行、I2C 和 SPI 提供了一个 Java API。

设备层代表一个设备库。设备库知道关于一个设备的一切,包括它做什么和如何做。设备库为应用程序提供了一个高级的 Java API,对设备的功能进行了抽象。设备库利用其特定于设备的知识,使用基本 I/O 层提供的基本 I/O API 来实现 how 。因此,从根本上来说,设备库允许应用程序员关注设备做什么,而不是如何做。

这时你可能会问,为什么在应用层和树莓派 OS 层之间有两层,设备和基本 I/O,而不是一层。一个原因:重用!从 Java 访问基本 I/O C API 并不简单,使用该 API 也不简单。对每个设备重复这项工作将会适得其反。将这些工作封装在基于 Java 的 I/O 库中允许在所有设备上重用,节省了大量的工作和时间,有效地提高了程序员的生产率。

那么,有哪些局限性呢?

  1. Java 没有标准的基本 I/O 库来提供对树莓派 OS 基本 I/O C 接口的访问。鉴于 Java 的“随处运行”口号,这并不奇怪,但它抑制了设备库的开发。

  2. 我还没有找到为他们的设备提供 Java 设备库的制造商或供应商。我觉得是需求低的问题。大多数设备都瞄准了广阔的市场,远不止树莓派。此外,Java 社区是整个 Pi 社区的一小部分。

幸运的是,这些限制并非不可克服。“树莓派的 Java”社区惊人的庞大、熟练、专注和活跃。我已经找到了,你也可以找到,一个支持你的项目需求的基本 I/O 库。事实上,你可以找到多种选择。

一旦您选择了一个基本 I/O 库或一组适用于您的设备的基本 I/O 库,有时就有可能从第三方找到一个您可以使用或改编的 Java 设备库,特别是对于流行的设备。如果您找不到 Java 库,您几乎总是可以找到一个非 Java 设备库,您可以在基本 I/O 库或库中将它移植到 Java,工作量几乎总是可以接受的。我会在第六章深入讨论这个话题。

结论是 Java 提供了最好的程序员生产率,其次是 Python,然后是 C/C++。

表演

业绩是不言自明的,讨论业绩很容易做到客观。对于任何任务,性能必须足以及时完成任务。然而,我的经验是,性能需求通常很难准确预测。因此,通常应该选择性能最好的语言,除非这种选择与其他标准相矛盾。

与 Python 相比,Java 是一个明确的选择。比较基准( https://benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/python3-java.html )表明 Java 几乎总是比 Python 3 快,事实上可以快几百倍,这取决于基准。

c 比 Java 有明显的性能优势。将 C 语言的基准测试( https://benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/c.html )与 Java 语言的基准测试( https://benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/java.html )进行比较,C 语言的运行速度是 Java 的六倍。这些基准测试表明 C++比 C 慢,但通常还是比 Java 快。

基本上,如果你想在性能上达到极致,那么 C 甚至 C++是最好的选择。Java 是比 Python 好得多的选择。

行业接受度

行业认可度指的是这种语言在专业程序员中的广泛使用程度。一个必然的结果是,这种语言的经历会让你对潜在雇主产生多大的吸引力。

在很长一段时间里,Java 是全世界职业程序员中排名第一的编程语言。随着大量新语言的出现和不断变化的需求,排名也发生了变化。在 2020 年 10 月和 2020 年 12 月的民意调查中,C 排名第一,Java 第二,Python 第三(C++第四)。2020 年 11 月的一项民意调查将 C 排在第一,Python 第二,Java 第三。在未来的几年里,Java 将是一种重要的、受到良好支持的语言。因此,我认为行业接受度竞赛是一场平局,至少在未来几年内是如此。

判决

Java 提供了比 Python 好得多的程序员生产率。Java 比 Python 快多了。Java 和 Python 一样受欢迎(在有经验的程序员中)。总的来说,Java 胜于 Python。

Java 提供了比 C/C++好得多的程序员生产力。Java 比 C/C++慢。Java 和 C 一样受欢迎(在有经验的程序员中),而且比 C++更受欢迎。总的来说,Java 胜于 C/C++。

结论:对于树莓派上的复杂机器人和物联网项目,Java 是最好的初级编程语言。

你可能想知道为什么我在结论中包括形容词“主要的”。Java 和树莓派操作系统都不能保证“实时”行为。在机器人学中,发现需要“实时”或需要并行处理的情况是很常见的。在这种情况下,您可以将任务委托给像 Arduino 这样的微控制器,它可以独立于树莓派执行任务,并更好地实现“实时”行为。使用 Arduino 意味着使用 C/C++作为第二编程语言。

你可能也想知道为什么我要用“复杂”这个形容词我不得不说实话。有些物联网项目复杂(例如,只需要一两个传感器,并且只需要很少的数据处理)。在这种情况下,您不会编写或运行大量代码。程序员的生产力、性能和受欢迎程度并不重要。在这种情况下,Python 可能是更好的选择,除非您的 Java 环境已经就绪。

为什么使用 NetBeans 进行远程开发?

你能在树莓派上开发 Java 程序吗?简短的回答是“是的”更好的答案是“是的,但是……”考虑以下因素:

  • 你的程序的复杂性

  • 运行程序的树莓派的计算能力和内存大小

假设你的程序是一个类,只有几十行。然后,即使有一个计算能力适中、只有 512 MB 内存的 Zero W,你也可以在 Pi 上做所有的事情,而手动完成。这意味着

  • 在使用简单的文本编辑器编辑类和使用javac命令编译类之间循环,找到简单的语法错误以及更严重的错误,直到您有了可以运行的东西。

  • 使用java命令运行该类。

不幸的是,任何一个有趣的项目只有一门课几乎肯定是不现实的。

假设复杂性增加了一点,现在你有多个类或者必须使用 Java 运行时环境(JRE)中没有的库(对于机器人和物联网,你需要库)。你仍然可以手动完成 Pi 上的所有操作。这意味着

  • 对于多个 中的每一个,在文本编辑器和javac 之间迭代。

  • 使用多个类,或者一个或多个库,或者两者都用jar命令构建可执行文件。

  • 使用java命令运行可执行文件。

这变得痛苦而无益。即使对于一些物联网项目,这也可能是不可接受的。

远在 Java 出现之前,为哪怕是中等复杂程度的项目开发代码的严酷现实促使各种编程语言的集成开发环境(ide)的出现。Modern,professional gradeIDE4实际上是在您键入时进行编译,以便您可以立即看到语法和其他错误,提供代码完成功能,自动完成构建过程,并允许您在 IDE 中运行和调试程序。这种 IDE 极大地提高了程序员的工作效率,现在绝大多数开发人员都在使用 IDE,无论是编程语言还是项目复杂性。

所以,答案是在树莓派上运行 IDE!可惜,没有!考虑一些更残酷的现实。所有现代的 ide 都需要图形用户界面(GUI)。GUI 需要一个窗口系统(通常称为桌面),它支持像 IDE 这样的基于 GUI 的应用程序。支持一个桌面需要大量的 CPU 能力和内存。你会在第二章中看到 Pi3B+为桌面提供了合理的支持;零 w 就不一样了。

更糟糕的是,专业级 ide 需要的计算资源(CPU 和内存)至少和台式机一样多。比如运行在 macOS 上的 NetBeans 12(见第五章)最高消耗 4 GB 内存; 5 我相信其他专业级别的 ide 也会差不多。我认为公平地说,即使是拥有 1 GB 物理内存的 Pi3B+也难以提供足够的用户体验。在零 W 上,算了吧。

但是,假设您可以合理地在树莓派上运行桌面和 IDE(并且您很可能在 8 GB 的 Pi 4 上运行)。您的项目在运行时会与他们争夺计算资源。因此,您永远无法进行真实的性能测试,甚至可能会因为运行 IDE 时多任务处理导致的延迟而遇到随机错误。这意味着要进行真实的测试,你至少需要关闭 IDE,很可能还要关闭桌面。如果你需要修复一个错误,一切都必须重新启动。痛苦!

解决方案:远程开发。这是什么意思?从广义上讲,远程开发意味着

  • 所有代码的编写、编译和可执行文件的构建都发生在一个 IDE 中,该 IDE 运行在一个工作站上,该工作站拥有足够的资源来提供一个高效和愉快的用户体验。

  • 可执行文件(必要时包括所有库依赖项)从工作站被推送到目标系统。

  • 可执行文件在目标系统上运行,没有任何外来程序在目标系统上争用资源。

在本书中,我使用了远程开发的更狭义的定义,其中

  • IDE 在工作站上运行。

  • IDE 自动将可执行文件推送到远程目标系统。

  • IDE 自动在目标系统上运行可执行文件,并连接到正在运行的程序,以控制和监控执行和调试。

因此,通过远程开发,开发人员获得了两个世界的最佳效果——在一个功能强大的工作站上运行的专业 IDE 和在真实环境中在树莓派上运行的项目可执行文件。

为什么选择 NetBeans?

在撰写本文时,根据做评估的组织或个人,排名前三的专业级跨平台 Java IDEs 分别是 Eclipse ( www.eclipse.org/eclipseide )、NetBeans ( https://netbeans.org )和 IntelliJ ( www.jetbrains.com/idea )。Eclipse 几乎普遍是第一,NetBeans 或 IntelliJ 是第二,另一个是第三。本着完全公开的精神,我在 2001 年 Eclipse 首次发布时就开始使用它进行 Java 开发;2014 年,我开始使用 NetBeans 以及 Eclipse2017 年,我独家转投 NetBeans,完全是基于它对树莓派上远程开发的支持。我必须承认我从未使用过 IntelliJ。

从根本上说,在这三个 ide 中,只有 NetBeans 支持我对远程开发“开箱即用”的狭义定义使用 NetBeans 进行远程开发与本地开发没有什么不同。您可以在多个 NetBeans 项目中创建多个类,并且需要多个外部 jar 文件(库)。只需单击一个按钮,NetBeans 就会编译所有的类,构建可执行文件,将可执行文件(以及任何所需的相关库)下载到 Raspberry Pi,并运行甚至调试可执行文件。也就是说,还是有局限性的。然而,我发现这些限制只对某些测试是个问题。

底线是远程开发极大地提高了程序员的生产力。此外,只有 NetBeans 支持“开箱即用”的非常有效的远程开发形式,从而进一步提高了程序员的工作效率。我将在第五章向你展示如何设置和使用 NetBeans。

Note

如果您比我更熟练,您可以在 Eclipse 和 IntelliJ 中创建类似 NetBeans 的行为。这样做需要对 IDE 和您选择的构建工具有相当深入的了解,还要对工作站操作系统的脚本构建有一定的了解。我没有追求这一点,因为 NetBeans 使它没有必要。也就是说,参见附录 A3 中关于如何实现的示例。

摘要

在本章中,我讨论了机器人和物联网项目几个方面的建议动机:

  • 使用树莓派进行总体控制,这是因为此类项目的需求与 Pi 的大规模支持网络非常匹配

  • 使用 Java 作为主要的编程语言,这是由于它的程序员生产率和性能

  • 进行远程开发,因为程序员的工作效率提高了;使用 NetBeans,因为它对远程开发的一流支持,进一步提高了程序员的工作效率

本书的其余部分假设您想使用 Raspberry Pi,使用 Java,并使用 NetBeans 进行远程开发。内容预览:

  • 第二章向您展示如何设置 Pi3B+。

  • 第三章告诉你如何设置一个零 w。

  • 第四章讨论了远程计算技术,其中大部分只与 Pi3B+相关。

  • 第五章向你展示如何设置 NetBeans。

  • 第六章探讨了 Java 基本 I/O 支持的选项。

  • 第七章讨论了本书对 Java 基本 I/O 支持的选择,并提供了关于树莓派基本 I/O 功能的有用细节。

  • 第 8 到 14 章着眼于对机器人和物联网中使用的特定设备的支持。

  • 附录 A1 和 A2 讨论将任务卸载到 Arduino。

  • 附录 A3 检查了 Maven 作为 NetBeans 中的构建工具的使用。

好好享受!

二、树莓派 3B+ 设置

我假设您正在阅读这一章,因为您对使用树莓派 3 Model B+(我将在本章的剩余部分使用 Pi3 )和 Java 构建机器人项目感兴趣。在本章中,您将学习如何

  • 为 Pi3 选择“最佳”操作系统

  • 安装树莓派操作系统

  • 为远程开发配置树莓派操作系统

  • 在树莓派操作系统上安装 Java

设置注意事项

当然,每个树莓派项目都需要“基础知识”:

  • 覆盆子馅饼

  • 用于文件系统存储的 microSD 卡

  • 一种能源

在设置和一些项目开发过程中,您可以从电源插座(通过合适的电源)或电池供电。在本章我将介绍的设置方法中,你需要连接一个 HDMI 兼容的显示器或电视、一个 USB 键盘和一个 USB 鼠标。在项目开发过程中,你不需要这些。

选择操作系统

你应该使用什么操作系统?树莓派运行许多操作系统。然而,默认操作系统,以前叫做 Raspbian,现在叫做 树莓派 OS ,吸引了绝大多数用户和绝大多数在线支持。我建议使用它,除非你有一个非常好的理由使用另一个。本书中的所有材料都假设了树莓派操作系统,并且没有在任何其他操作系统上测试过。

树莓派操作系统有三个版本:

  • 完整的包括核心操作系统、桌面图形用户界面,以及许多有用的工具和应用程序。默认情况下,它会引导到桌面。

  • 推荐的(一个有点不正式的术语)包括核心操作系统、桌面,以及少数有用的工具和应用程序。默认情况下,它会引导到桌面。

  • Lite 只包含核心操作系统。它会引导至命令行界面(CLI)。你必须安装任何你需要的非操作系统工具或应用程序。

本书的基础是远程开发,其中所有的重型工具都运行在一个健壮的工作站上,为您的项目留下 Pi CPU 周期和存储空间。因此,事实上, Lite 几乎肯定是大多数项目的正确选择。然而

  • 我发现使用推荐完全中的桌面,第一次启动后的初始配置要容易得多。我将向您展示如何关闭桌面,以便当您不再需要它时,它不会消耗内存或 CPU 周期。

  • 我发现利用推荐的中包含的工具有时很有用。

出于这些原因,对于 Pi3,我建议使用推荐的,并将向您展示如何安装和配置推荐的。注意,如果你喜欢那个版本,同样的说明也适用于 Full

Caution

以下操作系统说明适用于所有型号的 Raspberry Pi。我只在一辆 3B(约 2015 年设计)、一辆 3B+(约 2017 年设计)和一辆 1 型(约 2011 年设计)上进行了测试。然而,最新版本的 Java 不能安装在像 model 1 这样的老系统上。

在 microSD 卡上加载树莓派操作系统

我将首先描述获得树莓派操作系统的一些选项。然后,我将描述我认为的最佳方法。

获取树莓派操作系统

有几种方法可以获得树莓派操作系统:

  • 你可以买一个预装 NOOBS 的 microSD 卡(新的开箱即用软件;见 www.raspberrypi.org/documentation/installation/noobs.md )。这提供了几个操作系统,你可以安装你的选择。

  • 你可以买一张空的 microSD 卡,下载 NOOBS 并将其写入工作站上的 microSD 卡。同样,你可以安装你选择的操作系统。

  • 你可以购买一张空的 microSD 卡,然后使用 树莓派 Imager 工具将树莓派操作系统写入工作站上的卡中。我觉得这是最简单的方法,我会在下面描述。

图像树莓派操作系统

树莓派 Imager ( www.raspberrypi.org/downloads )是一个很棒的工具,可以下载树莓派操作系统映像并将其写入 microSD 卡。您可以从 www.raspberrypi.org/blog/raspberry-pi-imager-imaging-utility 获得关于该工具的更多信息。

您可以下载适用于 Windows、macOS 或 Ubuntu 的工具。我使用 macOS,但我怀疑下面的描述对于其他操作系统来说会非常相似。

在 macOS 上,你下载一个.dmg文件。它以类似于imager_1.4.dmg的名称出现在Downloads文件夹中。双击弹出安装对话框,如图 2-1 所示。

img/506025_1_En_2_Fig1_HTML.jpg

图 2-1

树莓派成像仪安装程序对话框

树莓派成像仪图标拖放到对话框中的Applications文件夹,安装程序。安装只需几秒钟。

要启动树莓派成像仪,您可以

  • 转到Finder窗口,并导航到Applications文件夹。找到树莓派成像仪图标。双击该图标。

  • 按 Command-space 调出聚光灯搜索。在“树莓派 Imager”中键入足够的字符,以便 Spotlight 找到工具,然后按下 Enter 键。

您将会看到一个对话框,询问您是否真的想要打开该应用程序(仅第一次)。如果是,点击打开。图 2-2 显示成像仪主对话框。

img/506025_1_En_2_Fig2_HTML.jpg

图 2-2

树莓派成像仪主对话框

点击选择 OS 图 2-3 显示了允许你选择你想要的操作系统的对话框。

img/506025_1_En_2_Fig3_HTML.jpg

图 2-3

树莓派成像仪操作系统选择对话框

出于本书的目的,选择第一选择, 树莓派 OS (32 位);这就是我上一节所说的推荐的(你可以在描述中看到那个术语)。这将调出主对话框,显示所选择的操作系统,如图 2-4 所示。此时,如果您还没有这样做,您必须使 microSD 卡可用于您的工作站。

Tip

除非你预计你的项目需要大量的文件存储,否则我推荐 16 GB 或 32 GB 容量的 microSD 卡。大于 32 GB 将强制您重新格式化 microSD 卡,然后才能使用它。幸运的是,树莓派成像仪可以重新格式化它。更多信息见 www.raspberrypi.org/documentation/installation/sdxc_formatting.md

img/506025_1_En_2_Fig4_HTML.jpg

图 2-4

树莓派成像仪主对话框;选择的操作系统

现在,您必须选择成像仪写入所选操作系统映像的 SD 卡。点击选择 SD 卡查看可用的卡。图 2-5 显示了一个类似于你将看到的 microSD 选择对话框。在这种情况下,只有一张卡可用。如果您安装了多张 microSD 卡,请单击您想要使用的那张。

img/506025_1_En_2_Fig5_HTML.jpg

图 2-5

树莓派成像仪 SD 卡选择对话框

现在,您将再次看到主对话框,显示选择的操作系统、选择的 SD 卡和一个活动的写入按钮。见图 2-6 。

img/506025_1_En_2_Fig6_HTML.jpg

图 2-6

树莓派成像仪主对话框;选择的操作系统和 SD 卡

点击写入开始下载写入。你会看到一个警告对话框,提示当前卡上的所有内容都将被删除。只需点击。接下来会提示您输入用户密码。输入密码,点击确定。你最终会再次看到带有进度条的主对话框,如图 2-7 所示。

img/506025_1_En_2_Fig7_HTML.jpg

图 2-7

树莓派成像仪写入对话框

树莓派操作系统下载和 microSD 卡写入操作需要几分钟时间。实际时间取决于操作系统的大小、互联网连接速度和 microSD 卡的速度。注意取消写入按钮,您可以在紧急情况下使用。写入完成后,该工具会再花几分钟进行验证。验证后,你应该会看到成功的指示,如图 2-8 所示。

img/506025_1_En_2_Fig8_HTML.jpg

图 2-8

树莓派成像仪成功对话框

点击继续返回主对话框(见图 2-5 )。从那里,你可以重新开始整个过程来写另一个 microSD 卡。或者您可以移除 microSD 卡,然后开始引导和配置 Pi3。

Tip

树莓派成像仪帮了你一个忙…也许吧。该工具将下载的操作系统映像缓存在您的工作站文件系统中。因此,如果您想将相同的操作系统写入另一个 SD 卡,您不必再次下载映像,因此对 microSD 进行映像的整个任务应该会运行得更快。太好了。除非你不想再写同样的 OS;如果没有,您已经丢失了大约 2 GB 的文件系统。不幸的是,工具开发人员没有记录操作系统在哪里被缓存。然而,在大量的调查之后,我发现在 macOS 中,你可以在~/Library/Caches/Raspberry Pi/Imager下找到缓存的图片。在 Windows 机器上,它应该在c:\users\your-username\AppData\Local中,在 Ubuntu 上,它应该在~/.cache中。找到它,如果你愿意,你可以删除它。

引导和配置树莓派操作系统

在给 Pi3 通电之前,您必须将 microSD 卡插入 microSD 插槽。接下来,将 HDMI 电缆插入 Pi,然后插入 HDMI 兼容显示器或电视。接下来,将 USB 键盘和鼠标插入 Pi3。

最后,对于电源,您可以选择插入提供 5V 和 2.5A 电源的墙上适配器,或者使用类似规格的电源组。虽然初始启动和配置可能需要一段时间,但电池应该没问题。我经常用一个 10,000 mAh 的电池几个小时。

Note

不使用显示器、键盘和鼠标也可以设置 Raspberry Pi。它更复杂。参见第三章了解操作方法。

初始配置

一旦你给 Pi3 通电,奇迹就开始发生了。初始启动可能需要几十秒到几分钟。最终你会看到桌面和欢迎水花,如图 2-9 所示。

img/506025_1_En_2_Fig9_HTML.png

图 2-9

树莓派操作系统桌面和欢迎飞溅

Welcome splash 表示出现了一个非常好的配置工具。它以一种我发现比使用其他配置工具更容易的方式帮助设置所有的要素。在欢迎画面中点击下一个开始配置。

您看到的第一个对话框允许您设置适当的地区、时区和键盘。图 2-10 显示,和以往一样,英国的情况是默认的。

img/506025_1_En_2_Fig10_HTML.jpg

图 2-10

用于设置区域设置、时区和键盘的对话框

由于我生活在美国,假装读写美式英语,所以我使用了图 2-11 所示的设置。一旦你为自己做了适当的设置,点击下一步

img/506025_1_En_2_Fig11_HTML.jpg

图 2-11

用于设置区域设置、时区和键盘的示例设置

接下来,你会看到一个对话框,如图 2-12 所示,它让你调整桌面以全屏显示。只需按照说明操作,然后点击下一步

img/506025_1_En_2_Fig12_HTML.jpg

图 2-12

设置屏幕对话框

现在您有机会更改默认用户 ID“pi”的密码,如图 2-13 中的对话框所示。

img/506025_1_En_2_Fig13_HTML.jpg

图 2-13

更改密码对话框

如果您不想更改“raspberry”的密码,请将这些字段留空,然后单击下一个的。如果您想更改密码,我建议您首先取消选择隐藏字符,然后在两个文本字段中输入新密码,并点击下一步。如果您决定更改密码,您必须使用至少八个字符的密码,以便将来的设置步骤能够正常工作。 不要忘记密码!

接下来,你有机会连接到 Wi-Fi 网络,如图 2-14 所示。如果您通过以太网连接到网络,可以跳过这一步。既然这本书假设无线通信,不如现在就做。

img/506025_1_En_2_Fig14_HTML.jpg

图 2-14

无线网络选择对话框

滚动对话框中显示的列表找到您的网络,选择它,然后单击下一个 在下一个对话框中,如图 2-15 所示,您可以输入网络密码。

img/506025_1_En_2_Fig15_HTML.jpg

图 2-15

网络密码对话框

与前面的对话框一样,您可以跳过连接到 Wi-Fi 网络,但您应该连接。一旦你输入了正确的密码(取消选择隐藏字符使得输入正确的密码更容易),点击下一步

现在你将有机会确保树莓派操作系统是最新的,如图 2-16 所示。请注意,如果没有网络连接,操作系统甚至无法检查必要的更新,更不用说更新了。更新总是一个好主意,我强烈建议你这样做!点击下一个

img/506025_1_En_2_Fig16_HTML.jpg

图 2-16

更新软件对话框

现在,当操作系统读取更新列表、获取更新、下载更新、安装更新并最终完成时,您将看到一系列状态消息。图 2-17 所示为该系列。

img/506025_1_En_2_Fig17_HTML.jpg

图 2-17

树莓派操作系统更新消息

点击确定继续。你应该得到设置完成的确认,如图 2-18 所示。你可以选择现在重启操作系统或者做一些其他的事情。我个人觉得在做其他事情之前重启更安全。点击重启重启。

img/506025_1_En_2_Fig18_HTML.jpg

图 2-18

安装完成对话框

恭喜你!您已经完成了初始配置,并且运行了最新的树莓派操作系统!

配置远程计算

你现在有了一个可以作为台式电脑使用的树莓派系统,尽管你可能会发现它对任何重要的活动都令人失望。我将向您展示如何配置 Pi3 进行无头操作,即不连接显示器/电视、键盘或鼠标。然而,你将完全控制这个系统。这种配置非常适合机器人或物联网系统以及远程开发。

我假设此时您已经完成了上一节中描述的初始配置,已经重新启动,并且可以使用桌面来完成附加配置。通过菜单【树莓】偏好设置树莓派配置 调出桌面的树莓派配置工具。您应该会看到如图 2-19 所示的对话框。

img/506025_1_En_2_Fig19_HTML.jpg

图 2-19

树莓派配置对话框

配置工具在系统选项卡上打开。您需要更改一些默认值。如果你希望有多个树莓派连接到你的网络,我强烈建议你更改主机名

Boot 行控制引导时加载的用户界面的类型。如你所见,默认情况下,操作系统启动桌面。它很现代,也很容易使用。但是,正如您可以想象的那样,它占用了 CPU 周期和内存方面的资源。另一个选项是 CLI(命令行界面)。它更难使用,但需要的资源少得多。因为您希望将尽可能多的 Pi3 资源用于您的项目,所以您希望引导到 CLI。选择 CLI先不要点击确定

配置接口功能

现在是时候让 Pi3 通过其各种基本 I/O 接口与机器人和物联网设备进行交互了。默认情况下,并非所有接口都启用。在配置工具中,点击接口选项卡。你会看到类似图 2-20 的东西。该对话框显示所有接口和当前启用状态。

img/506025_1_En_2_Fig20_HTML.jpg

图 2-20

“接口”对话框的默认设置

感兴趣的接口有

  • 摄像头:与 Pi3 上的树莓派摄像头插槽相关。本书中的项目不利用相机,所以我将禁用它。如果您计划在项目中使用摄像机,请启用它。

  • SSH :通过安全外壳远程访问 Pi3。你需要这个;这将是与 Pi 交互的主要方式!你必须启用它。

  • VNC :与虚拟网络计算有关。您将使用它,但具有讽刺意味的是,您希望禁用它!这是因为 VNC 在运行时也会窃取资源。更多关于 VNC 的信息,请参见第四章。

  • SPI :与串行外设接口协议相关。本书中的一些设备需要它,所以启用它。

  • I2C :与集成电路间协议相关。本书中的一些设备需要它,所以启用它。

  • 串口:与树莓派 GPIO 头上可用的串口相关(RX & TX 引脚)。保持启用状态。

  • 串口控制台:与串口相关;如果启用,操作系统将串行端口用于控制台输出。如果您计划将串行端口用于设备,则必须禁用串行控制台。本书中没有一个项目会用到它,但出于说明的目的,我还是会禁用它;参见第七章。

其余设置都可以接受。根据前面的建议,本书的接口配置如图 2-21 所示。

img/506025_1_En_2_Fig21_HTML.jpg

图 2-21

启用或禁用相关界面的界面对话框

一旦您完成了项目所需的启用和禁用任务,单击 OK 确认配置。如果愿意,您可以关闭配置工具。

在树莓派操作系统上安装 Java

是时候在 Pi3 上安装 Java 了。要开始安装,请在桌面上启动一个终端。只需点击桌面左上方启动栏中的终端图标。

安装什么 Java?

在安装之前,最好确定要安装哪个版本的 Java。在撰写本文时,最新的 Java 是版本 14,但是版本 8 到 14 已经存在。你可以在 Pi3 上安装它们中的任何一个(或者实际上是全部),但是你可能会发现一些程序无法运行或者无法优化。最安全的 Java 版本是树莓派 OS 存储库中可用的版本。在撰写本文时,存储库中可用的最新版本是 Java version 11。

可以安装 JRE (Java 运行时环境),它允许您运行以前构建的程序,或者安装 JDK (Java 开发工具包),它允许您构建和运行程序。对于大多数项目,考虑到远程开发的预期,JRE 就足够了。然而,出于本书的目的,我将向您展示如何安装与 Pi3 兼容的 JDK。

检查以前的 Java 安装

在撰写本文时,关于 Java 是否包含在最新的树莓派操作系统中,似乎还有点混乱。我的经验说不包括在内;但是,检查是谨慎的。为此,在终端中,输入java -version命令来查找 Java 的当前默认版本。

有三种可能的反应:

  1. -bash: java: command not found

  2. openjdk version "11.x.x" 202x-xx-xx

  3. 不同的东西

如果得到第一个响应,就要装 Java 了。跳到下一小节。

如果您得到第二个响应,那么 Java 11 已经安装好了,您可以跳过下一小节。

如果你得到第三个回应,你还有更多工作要做。“有些不同”意味着操作系统将不同版本的 Java 设置为默认版本。除非默认值已更改,否则操作系统默认为安装的最新版本。因此,要么没有安装 Java 11,要么安装了 Java 11,并将其他一些 Java 版本设置为默认版本。

您可以使用以下命令来诊断情况并更改默认版本:

sudo update-alternatives --config java

响应列出了安装的 Java 版本。默认版本标有“*”。该命令还允许您将不同的版本指定为默认版本。如果你看到

/lib/jvm/java-11-openjdk-armhf/bin/java

在列表中,在字符串左侧键入数字,然后按 Enter 键。如果您在列表中没有看到该路径,您必须安装 Java 11,在这种情况下,您必须阅读下面的小节。

在你安装了 Java 11 之后,如果一个更高版本的 Java 是默认的,你必须使用update-alternatives命令来使 Java 11 成为默认的。

Note

你可以从 https://phoenixnap.com/kb/install-java-raspberry-pi 中获得关于默认 Java 版本以及如何操作它的更多细节。

安装 JDK 11

我发现安装 Java 最简单的方法是通过命令行。为此,在终端中输入以下命令(这是一个好主意,尽管系统应该是最新的):

sudo apt update

您最终应该会看到以下响应:

All packages are up to date.

现在,对于好的东西,输入以下命令:

sudo apt install default-jdk

几秒钟后,系统会询问您是否希望继续。输入“Y”。几分钟后,安装完成,没有任何大张旗鼓,只是返回命令行提示符。

现在,再次输入命令java -version。shell 现在应该会返回类似下面的内容:

openjdk version "11.0.8" 2020-07-14

如果你得到了这样的回应(或类似的东西),你可以继续下一小节。如果没有,您应该从上一小节重新开始。

完成 Java 安装

为了设置 NetBeans,在第五章中,你需要知道 Java 11 的安装路径。输入命令

sudo update-alternatives --config java

您应该会在响应中看到如下内容:

/usr/lib/jvm/java-11-openjdk-armhf/bin/java

你可以 记住这个路径 或者稍后运行这个命令。

完成 Pi3 设置

现在您需要找到新配置和连接的 Pi3 的 IP 地址。将鼠标指针悬停在桌面菜单栏右侧的 Wi-Fi 图标上。您应该会看到一个类似于图 2-22 所示的弹出窗口。

img/506025_1_En_2_Fig22_HTML.jpg

图 2-22

网络弹出窗口

第一行确认不存在以太网连接。第二行显示 Pi 所连接的 Wi-Fi 路由器的名称。第三行显示 IP 地址。 你需要记住 IP 地址

现在你应该关闭 Pi。新配置的某些方面在下次重新启动之前不会生效。例如,桌面仍将运行,直到您重新启动。要关机,使用菜单(树莓)注销。你会看到关机选项对话框。点击关机。你可能会看到一个认证对话框;如果是,输入“pi”的密码并点击 OK 。几秒钟后,绿色 LED 应该停止闪烁;此时,Pi 已经关闭。

一旦系统关闭,就可以安全地切断电源。你应该这样做。然后断开显示器/电视、键盘和鼠标的连接。从这一点上来说,除了远程计算什么都没有!

Tip

我发现最新版本的树莓派操作系统包括一个屏幕捕捉工具。它叫做斯克罗特。要捕捉整个屏幕,只需按下键盘上的 PrtScr 键。默认情况下,Scrot 存储。它在用户的主目录中生成的 png 文件。我用 Scrot 截取了本节使用的所有截图。你可以从 https://magpi.raspberrypi.org/articles/take-screenshots-raspberry-pi 了解更多。

摘要

在本章中,您已经

  • 了解如何为机器人选择合适的树莓派操作系统

  • 安装和配置操作系统,以生产基于 Pi 的功能性计算机

  • 进一步配置操作系统,以支持所需的接口和远程计算

  • 安装了正确版本的 Java

恭喜你!您的树莓派 3 模型 B+设置已完成!在第四章中,您将学习如何从您的工作站控制 Pi3。在第五章中,你将学习如何在 Pi3 上设置远程开发的 NetBeans。

二、树莓派 0W 设置

我假设如果你正在阅读这一章,你对使用树莓派 Zero W(我将在本章的其余部分使用 Zero)和 Java 构建物联网项目感兴趣。在本章中,您将学习如何

  • 为 Pi 选择“最佳”操作系统

  • 安装树莓派操作系统

  • 为远程开发配置树莓派操作系统

  • 在树莓派操作系统上安装 Java

设置注意事项

零涉及的步骤与树莓派 3 型号 B+相同;但是,有些执行方式不同。同样,Pi、microSD 卡和电源的“基础”也是一样的。然而,Zero 使得连接 HDMI 显示器或电视、USB 键盘和 USB 鼠标有些困难,除非你有合适的连接器。因此,在这一章中,我将向您展示如何进行“无头”设置,这在任何情况下理解起来都不是一件坏事。

选择操作系统

考虑事项与树莓派 3 模型 B+类似。树莓派 OS 是操作系统(OS)的最佳选择。有趣的决定是在第二章描述的精简版推荐版完整版之间做出的。可以做一个安装推荐的案例,但是我推荐 Lite 。因此,我将向您展示如何安装和配置 Lite

在 microSD 卡上加载树莓派操作系统

我在第二章描述了获得树莓派操作系统的选项。在这一章,我将跳转到使用树莓派成像仪。如果您的工作站上没有安装,请参见第二章。

写树莓派 OS

启动树莓派成像仪(详见第二章)。您将会看到一个对话框,询问您是否真的想要打开该应用程序(仅第一次)。如果是,点击打开。图 3-1 显示成像仪主对话框。

img/506025_1_En_3_Fig1_HTML.jpg

图 3-1

树莓派成像仪主对话框

点击选择 OS 。图 3-2 显示了允许你选择你想要的操作系统的对话框。

img/506025_1_En_3_Fig2_HTML.jpg

图 3-2

树莓派成像仪操作系统选择对话框

就本书而言,选择第二个选择 树莓派 OS(其他)。这将弹出如图 3-3 所示的窗口。

img/506025_1_En_3_Fig3_HTML.jpg

图 3-3

操作系统选择

你会看到树莓派操作系统 LiteFull 的选择。出于本书的目的,请单击 树莓派 OS Lite (32 位)。你会再次看到显示所选操作系统的主对话框,如图 3-4 所示。此时,如果您还没有这样做,您必须使 microSD 卡可用于您的工作站。

img/506025_1_En_3_Fig4_HTML.jpg

图 3-4

树莓派成像仪主对话框;选择的操作系统

现在,您必须选择成像仪写入所选操作系统映像的 SD 卡。点击选择 SD 卡查看选项。图 3-5 显示了一个类似于你将看到的 microSD 选择对话框。在这种情况下,只有一张卡可用。如果您安装了多张 microSD 卡,请单击您想要使用的那张。

img/506025_1_En_3_Fig5_HTML.jpg

图 3-5

树莓派成像仪 SD 卡选择对话框

现在,您将再次看到主对话框,显示选择的操作系统、选择的 SD 卡和一个活动的写入按钮。见图 3-6 。

img/506025_1_En_3_Fig6_HTML.jpg

图 3-6

树莓派成像仪主对话框;选择的操作系统和 SD 卡

点击写入按钮后,树莓派成像仪下载操作系统,将其写入 microSD 卡,并验证写入。在看到指示成功的对话框之前,不要取出 microSD 卡。更多细节请参见第二章。

Tip

参见第二章中的提示关于树莓派成像仪对操作系统映像的缓存。

完成可启动的 microSD 卡

由于我们正在进行“无头”设置,Zero 没有显示器,也没有键盘,你不能简单地将刚写好的 microSD 卡插入 Zero 并启动。您必须首先采取一些额外的重要措施:

  • 支持通过安全外壳 (SSH)访问设备。

  • 配置 Wi-Fi 网络。

Caution

Zero W 支持 5 GHz Wi-Fi 网络。

确保 microSD 卡仍可由您的工作站访问。要启用 SSH,只需在 microSD 卡的根文件夹中创建一个名为ssh的空文件。请注意,文件名没有文件扩展名或类型,这一点很重要。

Tip

要在 macOS 上创建没有扩展名的空文件,请打开终端,导航到您想要文件的文件夹,然后输入命令touch <fileName>。要在 Windows 上这样做,打开记事本,创建一个新文件,然后点击另存为,导航到你想要文件的文件夹,输入“<文件名>”;引号消除了扩展名。

要配置 Zero 使其可以连接到您的 Wi-Fi 网络,请在 microSD 卡的根文件夹中创建一个名为wpa_supplicant.conf的文件。然后用您选择的文本编辑器编辑该文件,使其内容如清单 3-1 所示。

country=<2 letter ISO 3166-1 country code>
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1

network={
  scan_ssid=1
  ssid="<WIFI LAN name>"
  psk="<WIFI LAN password>"
}

Listing 3-1wpa_supplicant.conf

< 2 个字母的 ISO 3166-1 国家代码> 标识您的国家(参见 www.iso.org/obp/ui/#search )。对美国来说,应该是"US";对于英国,应该是"UK";等等。 < WIFI 局域网名称> 是您的 Wi-Fi 网络的名称, < WIFI 局域网密码> 是该网络的密码。注意清单 3-1 中的第二行和第三行实际上应该是一行。

现在您已经准备好引导和配置 Zero。

引导和配置树莓派操作系统

在给 Zero 加电之前,必须将 microSD 卡插入 microSD 插槽。电源方面,可以使用供电 5V 和 1.2A 以上的壁式适配器;您也可以使用相同规格的电池/电源组。确保将电源插入标有“PWR IN”的微型 USB 连接器

接通电源后,初次启动大约需要 90 秒。如果一切顺利,绿色 LED 最终停止闪烁并保持点亮。这表示 Zero 已经完成启动。

Caution

我注意到,当连接到一个空闲的零点时,至少有一些电源组决定关闭。我相信这是因为零在空闲时的功耗低于 150 mA。在开发过程中,您可能希望使用壁式电源为设备供电。

找到 IP 地址

你必须做的下一件事是找到零的 IP 地址。最简单的方法是通过一个终端模拟器。在 macOS 上,终端模拟器叫终端;在 Windows 10 上,等效物也称为终端。我将用通用词终端来指代这两者。打开工作站上的终端,输入命令ping raspberrypi.local。响应包含 IP 地址。如果 Zero 是您的 Wi-Fi 网络上唯一一个主机名为raspberrypi的设备,这种方法就有效。

如果 Zero 不是您的 Wi-Fi 网络上唯一具有主机名raspberrypi的设备,您可以使用无线路由器的配置工具来查找 Zero 的 IP 地址。基本上,您必须将浏览器定向到 192.168.1.1 或 192.168.1.254 或类似的地址(请参阅您的无线路由器文档)。四处寻找,直到找到连接到网络的设备列表。然后,您应该能够识别您的零点并确定 IP 地址。

接零

连接到 Zero 的最简单和最常用的方法是从工作站的终端发出的安全外壳命令(ssh)。打开终端并输入命令

ssh <IP address> -l pi

其中<IP address>表示您之前确定的零的 IP 地址。输入命令后,终端可能会警告您“主机的真实性”;如果是这样,只需输入yes继续。将提示您输入密码;用户pi的默认密码是raspberry。一旦你输入了密码,你应该会看到零响应,如图 3-7 所示。

img/506025_1_En_3_Fig7_HTML.jpg

图 3-7

初始树莓派操作系统 ssh 响应

更新和升级树莓派操作系统

我建议连接到 Zero 后做的第一件事是更新和升级操作系统。为此,您可以在终端中输入以下两个命令:

sudo apt-get update -y
sudo apt-get upgrade -y

完成update命令需要相当短的时间。它打印了几行反馈。完成后,您将在下一个命令提示符前看到下面一行:

Reading package lists... Done

upgrade命令可能需要很长时间。Zero 不是一个快速的设备,而且总的时间会受到网络速度和 microSD 速度的影响。该命令打印数百行反馈。您看到的可能有所不同,但是当我升级时,下一个命令提示符前的最后一行如下所示:

Processing triggers for libc-bin (2.28-10+rpi1)

您必须重新启动以确保一切生效。输入命令sudo reboot重启。当然,您必须在终端中再次输入ssh命令,以便在终端完成重启后重新连接到 Zero。

附加配置

现在,谨慎的做法是进行额外的配置。要启动树莓派配置工具,请输入命令sudo raspi-config。您将看到如图 3-8 所示的配置工具主对话框(细节可能有所不同)。您可以使用键盘上的 tab 键和箭头键在工具对话框中导航以突出显示选项。当您突出显示了所需的操作后,只需按回车键。

img/506025_1_En_3_Fig8_HTML.jpg

图 3-8

树莓派操作系统配置工具主对话框

注意图 3-7 中为用户pi修改密码的建议。假设您在一个完全本地的网络上工作,我认为这是可选的。如果您希望这样做,那么在配置工具启动时,它已经被高亮显示了。要更改用户pi的密码,只需点击 Enter。您将看到一个对话框,确认您将要更改用户pi的密码。按回车键。您将离开工具对话框并返回到 shell 来输入,然后重新输入密码。完成后,您将返回到工具,确认密码更改成功;按回车键。您将再次看到图 3-8 中的主对话框。

另一个可选配置是更改主机名。我建议这样做。要更改主机名,在主对话框中导航到网络选项(使用向下箭头),然后点击 Enter。你会看到网络选项对话框,如图 3-9 所示。

img/506025_1_En_3_Fig9_HTML.jpg

图 3-9

树莓派操作系统配置工具网络选项对话框

您将看到主机名已经突出显示,因此只需点击 Enter。您将看到一个对话框,描述主机名中什么是合法的,什么是不合法的;只需按回车键。现在您将看到一个对话框,允许您键入新的主机名。这样做;然后按 tab 键或向下箭头键,使 < OK > 高亮显示,然后按 Enter 键。你会再次看到如图 3-8 所示的主对话框。

接下来,您将配置本地化选项(在美国也称为本地化选项)。导航到该选项,然后按 Enter 键。您将看到图 3-10 中的对话框。

img/506025_1_En_3_Fig10_HTML.jpg

图 3-10

树莓派操作系统配置工具本地化选项对话框

您应该更改区域设置;该选项已经被选中,所以只需按 Enter 键。您将看到如图 3-11 所示的区域设置选择对话框。

img/506025_1_En_3_Fig11_HTML.jpg

图 3-11

树莓派操作系统配置工具区域设置对话框

选择框包含一个区域列表。您可以使用上下箭头键滚动列表。您可以通过按空格键来选择或取消选择区域设置。你会发现。默认选择 UTF-8 UTF-8 。由于我在美国,我取消了它,然后选择了 en_US。UTF-8 UTF-8 (如图 3-11 )。一旦选择了所有需要的语言环境,按 tab 键使 < OK > 高亮显示,然后按 Enter 键。现在您将看到另一个区域设置对话框,让您选择系统的默认区域设置。参见图 3-12 。

img/506025_1_En_3_Fig12_HTML.jpg

图 3-12

树莓派操作系统配置工具区域设置默认对话框

虽然不会有太大的不同,但最好的选择是 en_US。UTF-8 。使用向下箭头键选中该选项,然后输入。零点会处理一段时间,当它完成时,你会再次看到主对话框(图 3-8 )。

现在,您将更改时区,以便 Zero 知道您所在位置的正确时间。再次选择定位选项,将会看到如图 3-10 所示的对话框。现在突出显示并选择更改时区。您将看到如图 3-13 所示的对话框。

img/506025_1_En_3_Fig13_HTML.jpg

图 3-13

树莓派操作系统配置工具时区对话框

你需要为自己选择合适的地理区域。我选择了美国选项。按回车键继续。接下来,您将看到与该地理区域相关的时区列表,在我的例子中,是与美国相关的时区。见图 3-14 。

img/506025_1_En_3_Fig14_HTML.jpg

图 3-14

树莓派操作系统配置工具时区对话框

如您所见,我选择了中部时区。当你为自己选择了合适的选项后,按回车键。你将返回到主对话框(见图 3-8 )。

配置接口功能

现在是时候让 Zero 通过各种接口与外部设备进行交互了。在组态工具主对话框中,选择接口选项。你会看到类似图 3-15 的东西。该对话框显示所有界面。

img/506025_1_En_3_Fig15_HTML.jpg

图 3-15

树莓派操作系统配置工具界面对话框

感兴趣的接口与第二章中讨论的相同。界面的初始配置如图 2-20 所示,除了一个例外;您已经启用了 SSH。本书中的一些物联网设备需要 SPI 或 I2C,因此同时启用两者。串行端口没有被使用,所以你不需要做任何事情;如果您计划使用串行端口,您必须禁用串行控制台。

要启用 SPI,突出显示 SPI 并按 Enter 键。系统会询问您是否要启用它。确保 Yes 高亮显示,并按下回车键。您将收到一条消息,表明 SPI 已启用;只需按回车键。你将返回到如图 3-8 所示的主对话框。

要启用 I2C,在主对话框中选择接口选项,高亮显示 I2C ,并点击回车。系统会询问您是否要启用它。确保高亮显示,并点击确认。您将收到一条消息,表明 I2C 已启用;只需按回车键。你将返回到如图 3-8 所示的主对话框。

现在是重新启动的好时机,这样您的新配置就会生效。在主对话框中,用 tab 键高亮显示 <完成> ,然后回车。将询问您是否希望重新启动。由于 <是> 已经高亮显示,您可以点击回车。

在树莓派操作系统上安装 Java

是时候从零开始安装 Java 了。要开始安装,在工作站终端再次使用ssh命令访问零点。要登录,如果您更改了默认的“raspberry”,您当然需要使用新密码

安装什么 Java?

为了确保本书中机器人技术(参见第二章)和物联网项目的一致性,你要安装 Java 版本 11。可悲的是,树莓派 OS repository 实现的 Java 11 在 Zero 上不起作用!Java 11 的存储库实现需要比 Zero 中使用的更现代的处理器架构。Java 8 的存储库实现应该可以工作,但是为了兼容在第二章配置的树莓派 3 Model B+,你需要 Java 11。

幸运的是,Azul ( www.azul.com/ )提供了一个 JRE (Java Runtime Environment,Java 运行时环境),允许你运行之前构建的程序,还提供了一个 JDK (Java Development Kit,Java 开发工具包),允许你在几个处理器架构上为几个版本的 Java 构建和运行程序。更幸运的是,它们可以免费用于非商业用途。

你可以去 Azul 下载页面( www.azul.com/downloads/zulu-community/?architecture=x86-64-bit&package=jdk )确定树莓派 Zero 有哪些 JRE 和 JDK 版本。在撰写本文时,该页面包含一个过滤功能,可以缩小选择范围;见图 3-16 。

img/506025_1_En_3_Fig16_HTML.png

图 3-16

阿苏尔 JDK/JRE 下载过滤器

我用图 3-16 所示的滤镜组合达到了最好的效果。请特别注意架构滤波器。如图所示,我为树莓派设置了适当的值;请注意,“HF”标志表示硬件浮点能力。在撰写本文时,过滤器生成的结果表只包含 JDK 8、JDK 11 和 JDK 13 的包。显然,你会想要 JDK 11 号。

检查以前的 Java 安装

在撰写本文时,我的经验表明 Java 不包含在最新的树莓派操作系统中。原因可能是默认的 Java 版本不能在所有的树莓派模型上运行。无论如何,检查是谨慎的。有关详细信息,请参考第二章“检查先前的 Java 安装”小节。

安装 JDK 11

即使系统应该是最新的,检查操作系统是否需要更新也是一个好主意。为此,请在终端中输入以下命令:

sudo apt update

您最终应该会看到以下响应:

All packages are up to date.

现在,您必须为 JDK 创建一个文件夹,然后导航到该文件夹。输入以下命令:

sudo mkdir /opt/jdk
cd /opt/jdk

现在,在您的工作站浏览器中,转到 Azul 下载页面,并在表格中找到与 JDK 11 相对应的行。见图 3-17 。

img/506025_1_En_3_Fig17_HTML.png

图 3-17

阿苏尔 JDK/JRE JDK 11 下载行

将鼠标光标悬停在该行右侧的下载图标(右边有一个带文本“ .tar.gz ”的小云)上。右键单击(在 macOS 上,即按住 Ctrl 键单击或用两个手指单击)图标,弹出一个操作弹出窗口。点击复制链接

现在在终端中,输入命令sudo wget,然后粘贴你复制的链接。例如,我使用的命令(在终端中,该命令应该是一行)是

sudo wget https://cdn.azul.com/zulu-embedded/
bin/zulu11.41.75-ca-jdk11.0.8-linux_aarch32hf.
tar.gz

下载 JDK 需要几分钟时间。有一个进度条让你知道命令正在工作。

下载完成后,您需要找到下载文件的名称。使用ls命令来完成。复制文件的名称。然后输入命令sudo tar -zxvf,将文件名粘贴到终端中。例如,我使用的命令(同样,都在一行中)是

sudo tar -zxvf zulu11.41.75-ca-jdk11.0.8-
linux_aarch32hf.tar.gz

该命令将需要几分钟的时间来提取所有文件。没有进度条,有时它似乎停滞不前。耐心点。

当命令完成时(您将看到一个新的命令提示符),做一些清理是一个好主意。tar.gz文件很大,应该删除。为此,您可以使用以下命令:

sudo rm *.tar.gz

现在您需要为javajavac命令创建符号链接(javac是可选的,因为它不太可能被使用)。首先,找到tar命令创建的目录的名称。使用ls命令来完成。复制目录的名称。使用以下命令(同样是单行命令)创建符号链接:

sudo update-alternatives --install
     /usr/bin/java java <name of the directory you
     copied>/bin/java 1

sudo update-alternatives --install
/usr/bin/javac javac <name of the directory
you copied>/bin/javac 1

同样,您应该能够将目录名粘贴到终端中。例如,我使用的命令是

sudo update-alternatives --install
/usr/bin/java java /opt/jdk/zulu11.41.75-
ca-jdk11.0.8-linux_aarch32hf/bin/java 1

sudo update-alternatives --install
/usr/bin/javac javac /opt/jdk/zulu11.41.75
     -ca-jdk11.0.8-linux_aarch32hf/bin/javac 1

现在输入命令

java -version

shell 现在应该会返回类似下面的内容:

openjdk version "11.0.8" 2020-07-14 LTS

为了设置 NetBeans,在第五章中,你需要知道 Java 11 的安装路径。输入命令

sudo update-alternatives --config java

您应该会在响应中看到如下内容:

/usr/bin/java

您可以记住该路径,也可以稍后运行该命令。

摘要

在本章中,您已经

  • 了解如何为物联网选择合适的树莓派操作系统

  • 安装和配置操作系统以生产基于树莓派 Zero W 的无头计算机

  • 进一步配置操作系统以支持所需的接口

  • 安装了正确版本的 Java

恭喜你!您的树莓派 Zero W 设置已完成!第四章主要与树莓派 3 型号 B+相关,因此请随意继续第五章。在第五章中,你将学习如何设置远程开发的 NetBeans。

四、设置工作站

在前两章中,您配置了一个树莓派来支持用 Java 编写的机器人和物联网项目,并支持远程计算。在本章中,我将向您展示如何为远程计算设置工作站,以及如何将工作站连接到 Pi。您将设置两种形式的远程计算:

  • 用于树莓派 3 模型 B+和树莓派 Zero W 的命令行控制的安全外壳(SSH)

  • 用于树莓派 3 模型 B+的桌面控制的虚拟网络计算(VNC)

使用 SSH 的远程计算

连接到树莓派的最简单和最常用的方法是从终端仿真器发出的安全 shell ( ssh)命令。如果你看第三章,这一节有些多余,可以跳过;如果没有,继续读。在 macOS 上,终端模拟器叫终端;在 Windows 10 上,等效物也称为终端。我就用通用词终端来指代两者。

要从工作站连接到 Raspberry Pi,请打开终端。以两种形式之一输入ssh命令:

ssh <IP address> -l <username>
ssh <hostname> -l <username>

“IP 地址”是您希望控制的 Pi 的地址;这种形式总是有效的,因为 IP 地址在网络域中必须是唯一的。类似地,“主机名”是您希望控制的 Pi 的主机名;只有当您的 Pi 在网络域中有唯一的主机名时,此表单才有效。该命令实际上是让您使用“用户名”登录系统系统会提示输入“用户名”的密码;您可以在树莓派操作系统配置期间设置密码。图 4-1 显示了使用带有 IP 地址的ssh访问第二章中配置的树莓派 3 Model B+ (Pi3)的结果。

img/506025_1_En_4_Fig1_HTML.jpg

图 4-1

突出显示 ssh 命令的终端 shell

成功!您可以在图 4-1 的最后一行看到来自 Pi3 的提示。现在,您可以输入操作系统中所有可用的命令。坐在您的工作站上,您实际上可以完全控制 Pi3。

Note

在旧版本的 Windows 和 Windows 10 上,您可以使用 PUTTY 来代替终端。PUTTY 是一个用于 Windows 的免费开源 ssh 客户端。

VNC 远程计算

虚拟网络计算(VNC)为远程计算提供了客户端/服务器架构,允许您与树莓派操作系统桌面进行交互。VNC 服务器运行在树莓派上,VNC 客户端运行在您的工作站上。VNC 服务器已经安装在 Pi3 上,因为它是推荐的操作系统安装的一部分。因为您将 VNC 配置为禁用,所以当 Pi3 启动时,VNC 服务器不会启动。没有烦恼;您可以在需要时启动和停止它。

启动 VNC 服务器

在您之前打开的终端中,输入命令vncserver。您将得到一个冗长的响应,表明服务器正在运行,如图 4-2 所示。请特别注意回复的最后一行。它显示了您需要用于 VNC 客户端的完整地址。在这种情况下,它是 192.168.1.70:1。

img/506025_1_En_4_Fig2_HTML.jpg

图 4-2

确认 VNC 服务器正在运行

获取并启动 VNC 浏览器

Pi3 上的 VNC 服务器来自 RealVNC 公司。您将需要为您的工作站获得一个兼容的客户端,该兼容的客户端也来自 RealVNC 可以从 www.realvnc.com/en/connect/download/viewer 下载。在 macOS 上,安装很简单。下载的是一个.dmg文件。只要双击它,你就会得到如图 4-3 所示的安装程序。将 VNC 浏览器图标拖放到应用程序文件夹图标开始安装;可能会提示您输入密码。

img/506025_1_En_4_Fig3_HTML.jpg

图 4-3

VNC 浏览器安装

现在你可以在 macOS 上启动 vnc 浏览器,方法是点击它在应用程序文件夹中的图标,或者使用字符“VNC”进行 Spotlight 搜索工具第一次启动时,会问你(至少在 macOS 上)是否真的要打开;你知道你做了,所以点击打开按钮。图 4-4 显示了第一次运行时的查看器。

img/506025_1_En_4_Fig4_HTML.jpg

图 4-4

VNC 客户初始开放

在文本字段中键入 Pi3 的 IP 地址,后跟分号和数字(如 192.168.1.70:1),然后按 Enter 键。系统会提示您输入用户 ID 和密码,如图 4-5 所示。输入正确的凭证(对于您在第二章中创建的配置,用户 ID 为“pi”,密码为“raspberry”,除非您更改了它)。您可能需要选中“记住密码”框。点击确定

img/506025_1_En_4_Fig5_HTML.jpg

图 4-5

VNC 客户端用户 ID/密码提示

验证后,服务器会提示您“访问本地电脑的辅助功能,将播放和音量等媒体键发送到远程电脑”我说拒绝访问,但你的需求可能不同。现在,您应该会看到一些与您在初始 Pi3 配置中看到的桌面非常相似的东西,如图 4-6 所示。

img/506025_1_En_4_Fig6_HTML.png

图 4-6

通过 VNC 浏览器的树莓派操作系统桌面

现在,您可以在 VNC 浏览器中通过显示器、键盘和鼠标直接使用 Pi3 进行任何操作。探索。尝试一些事情。玩得开心。

调整 VNC 服务器

您可以做一些事情来定制 VNC 服务器的外观,以适合您的偏好。我将讨论其中的两个:光标的外观和桌面的大小。

改变光标

当你在 VNC 服务器版本的桌面上玩的时候,你可能已经注意到光标是一个“X”而不是一个箭头。如果你喜欢,那好;忽略本小节的其余部分。我更喜欢箭头作为光标,我将向您展示如何使光标成为箭头。

首先,您必须终止查看器和服务器之间的连接。将光标移动到客户端窗口的中上方。一个菜单将会下拉。将光标移动到 X 处并点击。您将会看到一个弹出窗口,询问您是否真的要关闭。确认。

现在回到连接到 Pi 的终端。输入以下命令终止 VNC 服务器:

vncserver -kill :1

“:1”指的是您之前启动的特定服务器(数字可能不同)。您不会得到任何确认,但服务器不再运行。

要将服务器光标更改为箭头,首先需要将 VNC 服务器配置文件从其系统位置复制到用户 pi 的主文件夹,然后修改副本中的光标形状。在终端中,使用命令cd ~/.vnc导航到.vnc文件夹。现在,使用下面的命令(以“.”结尾)将配置文件复制到该文件夹中很重要,因为它指示当前文件夹是复制的目标;xstartup和“.”之间的空格也很重要):

cp /etc/vnc/xstartup .

现在你要编辑xstartup的副本。您可以使用任何可用的编辑器,但是我更喜欢使用nano,因为它很简单。要编辑文件,输入命令nano xstartup。您现在应该在编辑器中看到xstartup文件。它看起来有点像图 4-7 。

img/506025_1_En_4_Fig7_HTML.jpg

图 4-7

用编辑器nano打开xstartup的结果

现在你需要找到这样一行

xsetroot -solid grey

在 nano 中,您可以通过按 Ctrl-w 并在提示符下键入“xsetroot”(不带引号)来完成此操作。按回车键开始搜索。现在输入附加文本,使该行如下所示:

xsetroot -solid grey -cursor_name left_ptr

通过按 Ctrl-o 保存编辑过的文件;在提示符下,只需按回车键;您应该会看到一个文件被写入的确认。按 Ctrl-x 退出 nano。

现在,使用vncserver命令,再次启动服务器。返回 VNC 浏览器窗口。它的外观应该是不同的,如图 4-8 所示,因为查看者记得你之前建立的连接。

img/506025_1_En_4_Fig8_HTML.jpg

图 4-8

显示先前配置的连接的 VNC 浏览器

要重新建立连接,您只需双击地址为 192.168.1.70:1 的 Pi3 图标。请注意,查看者会记住多个连接。当桌面出现时,您应该会看到一个光标箭头。

更改桌面大小

假设你不喜欢你工作站上 VNC 桌面的尺寸。幸运的是,您可以在启动 VNC 服务器时更改大小。如果你想改变大小,你必须先杀死服务器,如前所述。然后,您可以使用类似下面的命令启动 VNC 服务器:

vncserver -randr=640x480

该命令在您的工作站上产生 640 像素宽、480 像素高的 Pi 桌面窗口。你可以使用不同的尺寸,比如 1024x768,但是我没有做过足够的实验来知道极限。

Tip

我的经验表明,vncserver 会记住输入的大小。所以,如果你对某个特定的尺寸很满意,你就不用再输入了。

摘要

你现在

  • 学习如何从终端模拟器使用 ssh

  • 安装了 VNC 浏览器

  • 学习了如何在树莓派上启动和停止 VNC 服务器

  • 已了解如何将 VNC 浏览器连接到 VNC 服务器

  • 了解如何定制 VNC 服务器以满足您的需求

基本上,您现在有两种使用树莓派进行远程计算的方式(假设安装了桌面)。太棒了!继续第五章来设置远程开发的 NetBeans。

五、将 NetBeans 用于远程 Java 开发

在本章中,我将向您展示使用 NetBeans 进行远程 Java 开发的基础知识。我将教您如何安装 NetBeans 并为远程开发进行配置。该配置适用于第二章中设置的树莓派 3 型号 B+和第三章中设置的树莓派 Zero W。我将讨论如何

  • 确定哪个 NetBeans 是“最好的”

  • 确定哪个 Java 版本是“最好的”

  • 安装 Java

  • 安装 NetBeans

  • 在 NetBeans 中配置远程开发

  • 远程运行和调试

选择 NetBeans 和 Java 版本

就本书而言,最好的 NetBeans 是最新的“长期支持”(LTS)版本。在撰写本文时,那是 NetBeans 12.0。

NetBeans 不仅支持 Java 开发,它本身也是一个 Java 应用程序。NetBeans 12.0 可以在 Java 版本 11 到 14 上运行。要运行 NetBeans 12.0,您需要在工作站上安装其中一个版本。

前几章向您展示了如何在树莓派上安装 Java 11。为了优化远程开发,您的树莓派和工作站上必须安装相同版本的 Java。这意味着你需要在你的工作站上安装 Java 11。

安装 Java 11

您必须首先从甲骨文下载页面( www.oracle.com/java/technologies/javase-jdk11-downloads.html )下载适用于您工作站操作系统的 JDK 11。重要的是要明白,在当前的许可下,下载对于个人或开发使用是免费的。也就是说,您可能需要在下载前创建一个帐户。

Note

您下载的 JDK 11 的次要和安全版本号可能与本章中显示的和本书中使用的不同(11.0.8)。只要您的树莓派版本和工作站具有相同的编号,一切都应该正常工作。

对于 macOS 和 Windows 10,你可以下载一个安装程序,它会帮你完成大部分工作。我将展示 macOS 使用安装程序的过程;对于 Windows 来说,应该是非常类似的。

要执行安装程序下载,请在下载页面的表格中查找“macOS 安装程序”行。单击该行右侧的链接。在这一点上,你可能会被要求创建一个帐户或注册,如果是这样,就这样做。执行下载需要一些时间。macOS .dmg文件下载完成后,在 macOS 上,转到Downloads文件夹,双击该文件。您将看到如图 5-1 所示的窗口。

img/506025_1_En_5_Fig1_HTML.jpg

图 5-1

JDK 安装程序软件包

双击.pkg图标开始安装。首先你看到介绍面板如图 5-2 所示。

img/506025_1_En_5_Fig2_HTML.jpg

图 5-2

JDK 安装对话框

点击继续。你会看到安装类型对话框,但是除了退出安装,回到安装面板,或者继续之外,你什么也做不了。点击安装。系统将提示您输入管理 ID 和密码来启用安装。输入这些凭证并点击安装软件。一旦安装完成,您将在图 5-3 中看到确认。

img/506025_1_En_5_Fig3_HTML.jpg

图 5-3

JDK 安装确认

点击关闭结束安装程序。它会方便地询问您是否希望删除安装程序。这样做会释放文件系统上的空间。现在,您可以继续安装 NetBeans 了。

安装 NetBeans 12.0

首先,您必须从 Apache NetBeans 下载页面( https://netbeans.apache.org/download/nb120/nb120.html )下载 NetBeans 12.0。您可以找到适用于 macOS、Windows 和 Linux 的安装程序。单击适当的链接开始下载您需要的软件。下载完成后,在 macOS 上,双击下载文件夹中的.dmg文件。接下来你会看到如图 5-4 所示的安装包。双击.pkg图标开始安装。

img/506025_1_En_5_Fig4_HTML.jpg

图 5-4

NetBeans 安装程序包

现在安装程序问你是否真的要安装程序;见图 5-5 。点击继续

img/506025_1_En_5_Fig5_HTML.jpg

图 5-5

安装程序请求权限

您现在将看到 NetBeans 的简介面板。这与 JDK 非常相似(见图 5-2 )。点击继续。接下来是一个牌照面板。点击继续。接下来,您将被要求同意您几乎肯定没有阅读的许可。点击同意。接下来你会看到安装类型面板,如图 5-6 所示。

img/506025_1_En_5_Fig6_HTML.jpg

图 5-6

NetBeans 安装程序安装类型面板

NetBeans 12.0 允许您使用多种编程语言进行开发。默认情况下,除了基本 IDE 之外,安装还包括 Java SE、Java EE、HTML5/JavaScript 和 PHP。你显然需要 Java SE。点击定制可以选择安装什么。不包括其他的可以节省很多存储空间,但是我选择全部安装;谁知道下一个项目会需要什么?我推荐使用默认的安装位置,但是你可以通过点击改变安装位置来改变它。想了这么多之后,点击安装。系统将提示您输入管理员 ID 和密码。输入凭证,点击安装软件。一旦安装完成,您会看到图 5-7 中的确认。点击关闭结束安装。请注意,该安装程序不会删除.pkg.img文件。如果您希望释放文件系统上的空间,您必须手动执行此操作。

img/506025_1_En_5_Fig7_HTML.jpg

图 5-7

NetBeans 12.0 安装成功确认

测试 NetBeans 安装

现在,您可以在 macOS 上打开 NetBeans 12.0,方法是打开 Spotlight search,在字符串“Apache NetBeans 12.0”中键入字符,直到它出现,然后按 Enter 键。NetBeans 对自身进行初始化,然后显示图 5-8 中的窗口。

img/506025_1_En_5_Fig8_HTML.png

图 5-8

NetBeans 12 初始窗口外观

现在您可以创建一个测试。总的来说,我不会尝试给出关于 NetBeans 开发或 Java 的教程,但是我会讲述一些基本概念。

在 NetBeans 工具栏中,单击新建项目图标,这是一个看起来像文件夹的图标(工具栏中左起第二个)。你会看到如图 5-9 所示的新建项目对话框。

img/506025_1_En_5_Fig9_HTML.jpg

图 5-9

NetBeans 新建项目对话框

从图 5-9 中注意到,在 NetBeans 12 中,默认的项目管理/构建工具是 Maven 。Maven 不支持我对远程开发“开箱即用”的定义蚂蚁工具支持。由于本书的一个重点是简单而有效的远程开发,所以本书使用了 Ant,在这一章中,我将向您展示如何使用 Ant 进行开发。

Tip

Maven 在自动化其他开发任务方面做得更好。通过一些工作,Maven 可以近似“蚂蚁风格”的远程开发。详见附录 A3。在阅读附录 A3 之前,我建议您至少浏览本章的其余部分以及第 6 、 7 ,甚至 8 章,以建立足够的上下文。

要上手 Ant,在新建项目对话框(图 5-9 ,点击 Java with Ant ,然后点击 Java 应用,再点击下一个。您将看到名称和位置对话框,如图 5-10 所示,该对话框允许您输入项目的名称(本例中为“FirstTest”)和应用程序(或主类),管理文件的位置(我建议不要更改默认位置,除非您确实需要这样做),决定是否创建一个主类(在本例中为 yes),并输入包名(对于本测试,默认是可以的)。当您满意地输入完信息后,点击完成

img/506025_1_En_5_Fig10_HTML.jpg

图 5-10

NetBeans“新建项目”对话框:输入项目名(和主类名)、位置和包名

NetBeans 创建项目并打开主类的类编辑器,在我的例子中是FirstTest。你会看到类似图 5-11 的东西。

img/506025_1_En_5_Fig11_HTML.png

图 5-11

NetBeans 类编辑器

现在,您可以输入一个简单的 print 语句来创建经典的“Hello World”应用程序。清单 5-1 显示了我使用的代码(我省略了 NetBeans 注入到类文件中的样板文件)。

public class FirstTest {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Listing 5-1The “Hello World!” application code

点击 Run 图标(工具栏中间的绿色向右箭头)运行代码。假设一切正常,您应该在编辑器下方的 NetBeans 输出窗格中看到以下内容:

run:
Hello World!
BUILD SUCCESSFUL (total time: 1 second)

成功!您已经安装并测试了 NetBeans。

将树莓派配置为远程平台

现在,您将配置 NetBeans,以将树莓派识别为远程开发平台。在 NetBeans 菜单栏中,选择工具Java 平台。您将看到如图 5-12 所示的 Java 平台管理器弹出窗口。

img/506025_1_En_5_Fig12_HTML.jpg

图 5-12

NetBeans Java 平台管理器

请注意,当前唯一的平台是工作站上的 JDK 11 本地版。点击添加平台,这样你就可以添加一个树莓派了。图 5-13 显示了添加平台对话框。默认情况下,您将添加另一个本地 JDK。在这种情况下,你想添加一个 Pi,那么选择远程 Java 标准版选项,然后点击下一步

img/506025_1_En_5_Fig13_HTML.jpg

图 5-13

NetBeans 添加 Java 平台对话框

现在,您将看到图 5-14 所示的设置远程平台对话框。您必须输入一个平台名称。应该是比较独特的东西。我有时会在三个远程系统上同时开发项目组件,在这种情况下,惟一的名称是必须的。您必须输入远程 Pi 的主机 IP 地址或主机名;您应该使用第二章或第三章中确定的地址或名称,除非您正在处理另一个 Raspberry Pi。您必须输入一个用户 ID;出于本书的目的,请使用“圆周率”最简单的认证类型是密码,因此确保选择使用密码认证并输入您在第二章或第三章中创建的密码。

img/506025_1_En_5_Fig14_HTML.jpg

图 5-14

完整的设置远程平台对话框

对话框中一个非常关键的字段是远程 JRE 路径。你应该使用在第 2 或第三章中发现的路径。如果您不记得了,找到该路径的最佳方法是到一个与 Pi 有ssh会话的终端,输入以下命令:

sudo update-alternatives --config java

响应应该类似于以下内容:

/usr/lib/jvm/java-11-openjdk-armhf/bin/java

远程 JRE 路径字段中输入字符串,减去“/bin/java”。你可以改变远程的工作目录,但是我发现默认就可以了。当你完成这个对话框时,它看起来应该类似于图 5-14 。点击完成创建远程平台。NetBeans 可能需要几秒钟才能完成。完成后,您将再次看到 Java 平台管理器,其中显示了新创建的远程平台信息。参见图 5-15 。

img/506025_1_En_5_Fig15_HTML.png

图 5-15

具有远程平台的 NetBeans Java 平台管理器

注意 Java 平台管理器中的测试平台按钮。如果您愿意,您可以尝试一下,但是在创建新的远程平台之后,这是不必要的。但是,对于您已经有一段时间没有使用的远程平台或者您已经对其进行了可能会影响配置的更改的远程平台,它可能会很有用。现在你可以点击关闭来关闭 Java 平台管理器

测试远程开发

现在您将运行之前在树莓派上创建的FirstTest程序。在 NetBeans 项目窗格中,右键单击 FirstTest 项目,弹出项目属性对话框。在类别下,点击运行查看项目的运行时属性,如图 5-16 所示。点击配置字段右侧的新建。在出现的创建新配置弹出窗口中,输入配置名称并点击确定。该名称可以是任何名称。点击运行时平台字段右侧的下拉图标。从弹出的列表中,选择您之前创建的远程平台(本例中为 PiForBook )。点击确定保存属性。

img/506025_1_En_5_Fig16_HTML.jpg

图 5-16

NetBeans 项目属性(运行)

现在再次运行该项目。这次在 NetBeans 输出窗格中,您将看到类似于图 5-17 的内容。注意与 Pi 的通信,创建一个文件夹来存储 Pi 上的结果 jar 文件,下载 jar 文件,运行 jar 文件,并检索要显示的输出。成功了。恭喜你,你正在远程开发!

img/506025_1_En_5_Fig17_HTML.png

图 5-17

远程平台的 NetBeans 输出窗格

远程调试

NetBeans 也支持远程调试。可以如下测试。在FirstTest编辑器中,在println语句前添加以下行:

int i = 5;

现在点击println语句的行号。整条线以红色高亮显示。点击主菜单栏中的调试图标;它是紧挨着运行图标右边的图标。遗憾的是,调试尝试可能会失败!至少它会失败,除非 NetBeans 开发人员已经修复了可疑的错误。如果有效,很好;您可以跳过下一小节。如果失败了,不要害怕;继续读。

修复远程调试

我最初认为这是一个常见问题,有一个容易发现的解决方案。我错了。然而,我四处搜寻,拼凑出了一个解决方案。来解决这个问题(至少在撰写本文时这是有效的)

  1. 点击项目标签右侧的文件标签。

  2. 展开 FirstTest 项目。

  3. 展开build.xml

  4. 向下滚动,直到找到debug-remote元素。

  5. 双击debug-remote;它的编辑器会在编辑器面板中打开。

  6. 搜索字符串“debug-args-line”;你会发现两个事件;重要的一个是在名为“-debug-remote-passwd”的元素中。

  7. 在该元素的子元素<remote:runwithpasswd/>中,删除字符串${debug-args-line}

  8. 保存文件并关闭run-remote编辑器。

现在再次点击调试。应该能行。不幸的是,有一个警告。我还没有发现如何在系统范围内解决这个问题。因此,在您希望远程调试的每个新 NetBeans 项目中,您都必须应用本小节中描述的修复程序。

成功调试

当 debug 运行时,您应该看到println语句变成绿色,表明调试器在执行它之前已经停止在那一行。见图 5-18 。

img/506025_1_En_5_Fig18_HTML.png

图 5-18

NetBeans 调试

现在您可以调试运行在树莓派上的代码了!

调试时检查变量

调试时,在输出面板中,您现在可以看到子面板、正常输出和调试器控制台。调试时,一般需要看变量。为此,单击 NetBeans 窗口左下角的变量(见图 5-18);出现变量弹出窗口;它覆盖了输出窗格。你现在可以看看变量;特别是,您可以看到变量i的值为 5。

您可能不希望变量弹出窗口覆盖输出窗格。要使变量成为输出窗格中的另一个选项卡,请单击图 5-18 所示 NetBeans 窗口左下角的“双窗口”图标。现在输出面板如图 5-19 所示(我对断点做了和对变量一样的事情)。

img/506025_1_En_5_Fig19_HTML.png

图 5-19

NetBeans 输出窗格,带有“输出”、“断点”和“变量”选项卡

调试器菜单

调试值得多讨论一下。首先,图 5-18 只显示了调试菜单的一部分,这是由于我截屏时 NetBeans 窗口的大小。图 5-20 显示了最有用的调试器控件的图标。

img/506025_1_En_5_Fig20_HTML.jpg

图 5-20

NetBeans 调试器控件

从左到右,控件是

  • 完成:终止调试会话

  • 暂停:暂停调试会话

  • 继续:运行到下一个断点

  • 跳过:运行一条语句

  • 单步执行表达式:运行单个表达式

  • 进入:进入一种方法

  • 步出:方法返回后运行到语句

在之前启动的调试会话中,如果您没有做任何其他事情,println语句应该仍然是绿色的。点击跳过,该语句将再次变为红色(表示是断点),其后包含“}”的行将变为绿色,表示下一步将退出该方法。您可以查看输出选项卡,查看println语句是否有效。点击继续结束,因为不再有断点存在。

在没有 NetBeans 的情况下在树莓派上运行

现在,您可以使用运行在桌面上的 NetBeans 来开发 Java 程序,然后在树莓派上运行和调试该程序,这真是太棒了。但是,在某些时候,您可能希望在 Pi 上运行程序,而不涉及 NetBeans。我遇到的原因不多:

  • 当您的项目处于“生产”阶段,甚至是进行一些测试时,您不需要或不想让 NetBeans“碍事”。

  • 不幸的是,虽然这并不奇怪,但是运行在 Pi 上的远程程序不能接收键盘输入。

  • 我发现高度格式化的控制台输出(例如由System.out.format产生的)不能正确呈现。

在这一节中,我将描述一些在没有 NetBeans 参与的情况下在 Pi 上运行程序的方法。后面的部分将描述其他的方法。

当您使用 NetBeans 在树莓派上运行或调试程序时,NetBeans 会将程序下载到 Pi。以前,当您将 Pi 配置为远程平台时,您必须标识 NetBeans 以 jar 文件的形式保存下载程序的文件夹(或 NetBeans 术语中的目录)(参见图 5-14 )。用户 pi 的默认文件夹是/home/pi/NetBeansProjects。在该基本文件夹中,NetBeans 会创建一个与项目同名的特定子文件夹。NetBeans 将 jar 放在名为dist的子文件夹中。对于前面的示例项目,完整路径是/home/pi/NetBeansProjects/FirstTest/dist

要在 Pi 上运行程序,首先从工作站终端 ssh 到 Pi。然后输入以下命令(在一行中):

java -jar /home/pi/NetBeansProjects/
FirstTest/dist/FirstTest.jar

您将在终端中看到执行的结果。见图 5-21

img/506025_1_En_5_Fig21_HTML.png

图 5-21

不使用 NetBeans 执行程序(第一种方式)

您也可以使用稍微不同的方法来完成同样的事情。首先,在终端中,将工作目录(通过cd命令)更改为 jar 文件的位置。然后输入命令

java -jar FirstTest.jar

你会得到完全相同的行为。见图 5-22

img/506025_1_En_5_Fig22_HTML.png

图 5-22

在没有 NetBeans 的情况下执行程序(第二种方式)

这些技术之所以有效,是因为 jar 文件中的信息标识了要执行 jar 中的哪个主类。该信息来自 NetBeans 项目的运行的属性名为的主类。您可以在项目属性对话框中查看并设置感兴趣项目的值;NetBeans 在创建包含主类FirstTestFirstTest 项目时会自动设置该值(参见图 5-16 )。

在复杂项目中利用 NetBeans

在开发复杂的系统项目时,我遵循一个经典的开发实践,将大系统划分成更小的子系统。NetBeans 为这种实践提供了很大的支持,我总是在开发过程中创建多个 NetBeans 项目。例如,我可能有一个 NetBeans 项目提供“设备库”,第二个项目提供“数据处理库”,第三个项目包含使用“设备库”和“数据处理库”的应用程序

Note

库促进代码重用。虽然一个库通常包含多个类,它们都以某种方式相关联,但是一个库只包含一个单个类也是完全可以接受的。事实上,在下面的例子中,库只包含一个类。本书中开发的设备库有时包含单个类,有时包含多个类,这取决于设备的特性。

创建和测试库

为了探究其中的含义,您将为一个简单的库创建一个新的 NetBeans 项目并测试该库:

img/506025_1_En_5_Fig23_HTML.jpg

图 5-23

新库类的 NetBeans 窗口

  1. 正如您之前对 FirstTest 项目所做的那样,在 NetBeans 中,单击菜单栏中的新项目图标。

  2. 新项目对话框中点击 Java with Ant

  3. 新建项目对话框中点击 Java 类库

  4. 新建项目对话框中点击下一个

  5. 新建 Java 类库对话框中,输入类库的名称;在这个例子中,我将使用“LibraryTest”。

  6. 新建 Java 类库对话框中点击完成。您应该会看到新项目出现在 NetBeans 项目窗格中。

  7. 右键 LibraryTest ,悬停在 New 上,点击 Java 包。你会看到新 Java 包对话框。

  8. 输入所需的包名;在本例中为“org.addlib”。

  9. 新 Java 包对话框中点击完成。您应该会在 LibraryTest 项目下看到这个新包。

  10. 右击 org.addlib ,悬停在 New 上,点击 Java 类。您应该会看到新 Java 类对话框。

  11. 新 Java 类对话框中输入所需的类名,在本例中为“AddThem”,点击完成。您将看到AddThem类的编辑器。NetBeans 窗口应该类似于图 5-23 所示。

新类需要一个带参数的构造器和一个带参数的方法。该方法会将其参数添加到构造器的参数中,并返回结果。输入清单 5-2 中的代码完成AddThem

public class AddThem {
    int base;
    public AddThem(int base) {
        this.base = base;
    }

    public int addIt(int it) {
        return base + it;
    }
}

Listing 5-2The AddThem class

将代码添加到AddThem后,请注意“项目”面板中的变化。由于该项目被指定为一个库,NetBeans 不希望在源包中找到主类。预计您会想要测试库类,因此 NetBeans 友好地为测试类添加了一个测试包文件夹,并为只与测试相关的类添加了测试库。见图 5-24 。

img/506025_1_En_5_Fig24_HTML.jpg

图 5-24

“项目”面板中的测试包和库

这些工件旨在支持 JUnit 测试程序(超出了本书的范围),但是您可以创建自己的简单测试用例。但是,需要理解的是,当 NetBeans 为库 构建 jar 文件时,它不包括任何测试类或测试库 !这是一件非常好的事情,因为您不希望测试用例塞满一个库 jar 文件。然而,这也意味着测试用例永远不会被下载到 Raspberry Pi,因此,举例来说,您不能使用 Pi 上的测试包中的测试用例来测试设备库。

现在您将创建一个简单的主类来测试库中的单个类。展开测试包;你会看到 <默认包> 。出于本书的目的,这是放置测试库的主类的好地方。右击 <默认包> ,悬停在新建上,点击主类。在出现的新 Java 主类对话框中,在类名字段中输入“TestAdd”,然后点击完成。你会看到课程的编辑。完成该类,使其看起来如清单 5-3 所示。

public class TestAdd {
    public static void main(String[] args) {
        AddThem at = new AddThem(6);
        System.out.println("The result: " + at.AddIt(5));
    }
}

Listing 5-3The TestAdd class

您将在以“AddThem at”开头的行上看到一个错误指示。这是因为 NetBeans 不知道AddThem的包名。点击行上的红色球,然后点击高亮显示的选项为 org.addlib.AddThem 添加导入。NetBeans 会为您添加导入,并且错误会消失。啊,美丽的 IDE!

现在点击运行图标。您将看到一个弹出的错误,指出该项目没有主类集。如果你检查项目运行属性,你会发现你不能设置TestAdd主类;你甚至在对话框里都找不到!这与前面的 jar 文件讨论有关;因为测试包从未出现在 jar 文件中,所以它们不可能是可运行的类。

但是可以跑TestAdd!在项目窗格中,右键单击 TestAdd 。在弹出的窗口中,点击运行文件。在输出窗格中,您会看到

The result: 11

有效!你不仅可以运行测试程序,还可以调试它。在提到的弹出窗口中,点击调试文件而不是运行文件

该库已经编写并经过测试。是时候使用它了。然而,在使用它之前,要认识到TestAdd是在您的工作站上运行的,而不是在树莓派上。这是极其重要的。您可以在您的工作站上对独立于硬件的代码进行大量测试。Java 的“编写一次,在任何地方运行”的特性对于这种情况确实很有价值。

Caution

我们在后面章节中开发的设备库而不是硬件独立的,并且必须运行在树莓派上。因此,您必须使用不同的测试方法。详见第八章。

使用库

我现在将向您展示如何使用之前创建的库。为了说明一些额外的要点,您将在已经配置为在树莓派上运行的 FirstTest 项目中创建一个新程序(主类)。

  1. 右键单击首测下的源包

  2. 将鼠标悬停在上,然后点击 Java 包

  3. 新建 Java 包对话框中,在包名字段中输入包名;对于图书,输入“org.lib.user”,然后点击完成

  4. 右击 org.lib.user ,悬停在弹出的 New 上,点击 Java 主类

  5. 在出现的 New Java Main Class 对话框中,在 Class Name 字段中输入类名;对于书籍,输入“LibTest”,然后点击完成。与其他类一样,NetBeans 会创建一个框架,并在编辑器窗格中打开该类。

  6. 完成该类,使其看起来像清单 5-4 。

public class LibTest {

    public static void main(String[] args) {
        AddThem at = new AddThem(6);
        System.out.println("The result: " + at.AddIt(5));
    }
}

Listing 5-4The LibTest class

与前面提到的TestAdd一样,您将在以“AddThem at”开始的那一行得到一个错误指示。点击线上的红色球。你会发现选项为 org.addlib.AddThem 添加导入没有出现!这是因为 NetBeans 不知道在哪里可以找到包含正确的AddThem的库(可能不止一个)。

您必须告诉 NetBeans 哪个库包含适当的AddThem类。为此,右键单击项目 FirstTest ,然后单击 Properties 。在出现的项目属性对话框中,在类别下,点击。您将看到如图 5-25 所示的库属性。

img/506025_1_En_5_Fig25_HTML.jpg

图 5-25

库的 NetBeans 项目属性

现在点击类路径右边的 + 。在弹出的小窗口中,点击添加项目。你现在会看到如图 5-26 所示的添加项目对话框。点击库测试,然后点击添加项目 JAR 文件。您将返回到项目属性对话框。点击确定

img/506025_1_En_5_Fig26_HTML.jpg

图 5-26

NetBeans 添加项目对话框

返回到TestAdd编辑器。点击红色的球。现在在弹出窗口中,您将看到所需的选项为 org.addlib.AddThem 添加导入。单击该选项,NetBeans 将为您添加从库中导入的内容,错误随即消失!

现在点击运行图标。您会看到主类FirstTest运行在树莓派上。这显然不是你想要的。运行LibTest有两种方式。如果您打算运行一两次,只需在项目窗格中右键单击 LibTest ,然后单击运行文件。注意,该弹出窗口还包含一个调试文件选项,因此您可以用同样的方式进行调试。第二种方式更有趣,我将在下一节介绍它。

选择要从 NetBeans 运行的程序

想一遍遍跑LibTest怎么办?然后你想使用运行图标。我将向您展示如何配置 NetBeans 来实现这一点。

首先右击 FirstTest ,选择项目属性。在类别下,点击运行。参见图 5-16 。查看主类旁边字段中的条目。您会看到 NetBeans 被配置为运行FirstTest。要选择运行另一个主类,单击主类右侧的浏览。您将看到一个弹出窗口,其中列出了项目中的主要类,并选择了firsttest.FirstTest。见图 5-27 。

img/506025_1_En_5_Fig27_HTML.jpg

图 5-27

NetBeans 浏览主类对话框

在对话框中点击org.lib.user.LibTest,然后点击选择主类。在项目属性对话框中,点击确定。现在当你点击运行图标,LibTest运行!

在没有 NetBeans 的情况下在树莓派上运行选择的程序

前一节向您展示了如何使用java -jar命令来运行在先前下载的项目 jar 文件中标识的主类。如果您想在一个 jar 文件中运行多个主程序,您必须使用不同形式的java命令。

在上一节中,您创建了一个包含两个主要类的项目,FirstTestLibTest。你配置 NetBeans 运行LibTest;因此,在远程终端中,当您输入以下命令时

java -jar FirstTest.jar

你发现LibTest跑了。如果你想运行FirstTest,你使用下面的命令:

java -cp FirstTest.jar firsttest.FirstTest

图 5-28 显示了运行各种命令的结果。

img/506025_1_En_5_Fig28_HTML.png

图 5-28

具有各种java命令结果的远程终端

通常,您应该使用前面描述的第二种形式:

java -cp <jar>.jar <package>.<main class>

在哪里

  • <jar>是指 NetBeans 创建并下载的 jar 文件(注意,这个名称与 NetBeans 项目名称相同)。

  • <package>指您想要运行的主类的包名。

  • <main class>指你要运行的主类的名字。

用 NetBeans 下载就行了

在上面几节中,您了解了 NetBeans 可以将项目下载到 Raspberry Pi,然后运行或调试 jar 文件中指定为主类的程序。随后,您可以在没有 NetBeans 的 Pi 上运行同一个项目中的许多程序(主类)之一。

您还了解了如果您想要运行的主类不是 jar 文件中指定要运行的主类,您可以解决这个问题。但是,如果您变得懒惰(像我一样),只想使用 NetBeans 下载一个更新的项目,然后在 Pi 上运行您想要的程序,该怎么办呢?

很简单。在这个项目中,您创建了一个“哑”主类,它除了指示下载成功之外什么也不做(有点多余)。当您单击 Run 图标时,您将它指定为主类来运行。对于示例项目首测

  1. 右键单击首测下的源包

  2. 将鼠标悬停在上,点击 Java 主类;你会看到新的 Java 主类对话框。

  3. 类名字段中,键入一个主类名,例如“Dummy”;在字段中,输入一个包名,例如“dummy”,点击完成;此时,如果你想添加代码在Dummy运行时打印一些东西,可以这样做,但不是必须的。

  4. 调出 FirstTest 项目属性对话框。

  5. 类别下,点击运行

  6. 主类行右侧,点击浏览;您将看到浏览主类对话框(参见图 5-27 中的示例)。

  7. 你应该在主类列表中看到dummy.Dummy;点击它并点击选择主类,你将返回到项目属性(运行)对话框。

  8. 现在点击确定

现在点击运行图标。在输出窗格中,你看不到FirstTestLibTest的输出,因为两者都没有运行;Dummy跑了,也确实没什么。您将在输出窗格中看到的内容如下:

run-remote:
BUILD SUCCESSFUL (total time: 3 seconds)

当然,如果你把一个println语句注入Dummy,你会看到它打印出来的东西。当然,你仍然可以使用一个ssh终端从命令行运行树莓派上的FirstTestLibTest

摘要

在本章中,您已经

  • 工作站上安装的 Java

  • 在工作站上安装了 NetBeans

  • 在 NetBeans 中将您的树莓派配置为远程平台

  • 学习如何使用 NetBeans 从工作站和树莓派以多种方式构建、运行和调试复杂的 Java 程序

现在,您已经有了使用 Java 和树莓派开发机器人和物联网项目的工具。在第六章中,您将了解为机器人和物联网中使用的设备开发设备库的注意事项。

六、Java 中的设备支持

如果没有 没有奋斗,就没有进步。

—弗雷德里克·道格拉斯,1857 年

在第一章,我提到了在树莓派上使用 Java 时为你的设备寻找或创建设备库的挑战。这一挑战存在于两个层面:

  1. 为各种形式的基本 I/O 寻找基本 I/O 库,即数字 I/O(也称为 GPIO)、串行、I2C 和 SPI,在这些基础上可以构建设备库

  2. 为您的设备寻找或创建设备库,这需要一种或多种形式的基本 I/O

在这一章中,我将讨论

  • 查找基本 I/O 库

  • 为您的项目和本书选择基本 I/O 库

  • 将基本 I/O 库引入 NetBeans

  • 查找设备库

  • 将非 Java 设备库移植到 Java

查找 Java 的基本 I/O 库

对于任何给定的项目,这组器件可能需要 GPIO、串行、I2C 和 SPI 的某种组合。遗憾的是,没有一个集中的来源提供关于树莓派上 Java 基本 I/O 的信息。或者,也许更准确地说,我找不到它,如果它存在的话。所以,要找到信息,你必须使用搜索引擎。很难立即产生有用的结果。例如,如果您想找到对串行 I/O 的支持,您可以使用搜索术语“串行”、“Java”和“Raspberry Pi”你毫无疑问会找到几个候选人,比如, Pi4JRxTx (又名 RXTXjserialcomusb4javajRXTX ,以及 jSSC 。有时候,在“第一顺序”搜索之后,你必须在找到所有候选人之前跟踪一系列链接。例如:

  • 沿着 Pi4J 走下去,你会发现它是一个封装了 wiringPi 库(用 C 编写)的包装器。

  • 沿着 jRXTX 走下去,你会发现它是围绕 RxTx 的一个“更容易使用”的包装器。

但是你还没有完成。我发现,有时,出于不清楚的原因,您必须搜索其他基本 I/O 类型才能获得完整的图片。因此,举例来说,即使你对 I2C 不感兴趣,你可能也应该搜索一下,然后沿着一条新的链接线索,看看你是否能找到其他东西。这样做,你会找到很多关于 Pi4J 的参考资料。你还会找到对设备 I/O (又名 DIO)和 diozero 的引用。Pi4J、DIO 和 diozero 都支持一切 : GPIO、串行、I2C 和 SPI。你可能还需要在 C 中寻找 I2C 支持,并找到支持一切的 pigpio。深入研究 pigpio,你会发现有一个 pigpio 的 Java“包装器”叫做 pigpioj 。我想你能明白我的观点;一般来说,你必须做一个相当广泛,详尽的搜索,以获得一个完整的图片。

选择最佳的基本 I/O 库

当您为一种类型的基本 I/O 找到多个选项时,有几个选择“最佳”的标准我建议不一定要按优先顺序

  • 功能覆盖范围

  • 表演

  • 支持

  • 易用性

功能覆盖范围

功能覆盖提出了一个问题“它做了我需要做的所有事情了吗?如果没有,需要什么样的妥协?”一些例子:

  • 树莓派有两种形式的串行 I/O:通过 USB 端口的 USB 串行和通过 GPIO 头上的 RX/TX 引脚的 TTY 串行。usb4java 只支持 usb 端口,而前面提到的其他一些支持两种形式。

  • 一些 GPIO 库为数字 I/O 提供上拉和下拉电阻支持;有些没有。

通常,您可以从文档中确定功能覆盖范围。然而,我发现测试有时是必要的。例如,在 2017 年,测试显示 Pi4J I2C 支持只能从寄存器地址 0 开始进行块读取,在某些情况下强制使用底层 wiringPi。

表演

性能其实是双管齐下的。第一个问题是“这个选项可靠吗?”例如,2017 年测试 jRXTX 表明它可以工作,但表现不稳定;我从未确定原因。显然不是最佳选择。

第二个问题是“选项见效快吗?”你肯定不想浪费时间或资源。如果一个选项执行任务的速度比其他选项快得多,那么它可能是最佳选择。比如 2017 年测试 Pi4J serial 和 jserialcom,结果显示 jserialcom 比 Pi4J 快很多(或者说我无法正确配置 Pi4J)。

您应该从这个讨论中了解到,您不仅要准备好测试一个库是否能够可靠地工作,还要准备好测试它在您的项目中是否具有足够的性能。

支持

支持是一个有点模糊的总称。有几件事需要考虑。您找到的任何库都是由个人、团队或组织创建和支持的。通常,创建和支持实体越大越好。

支持的现状和性质至关重要。您可能应该对使用几年没有更新或没有可见支持渠道的库保持警惕。例如,截至 2020 年 10 月

  • jSSC 似乎自 2013 年以来没有更新过。

  • 我发现自 2017 年以来没有更新,也没有提到 RxTx。

  • wiringPi 的作者不赞成使用它,这实际上是反对 Pi4J 的当前版本(1.2)。

  • usb4java 直到 2018 年才被积极支持。

  • jRXTX 似乎有积极的支持和更新。

  • jSerialComm 似乎得到了积极的支持和更新。

  • DIO 似乎很少使用。

  • diozero 得到了积极的支持和更新。

  • pigpio 拥有积极的支持和更新,pigpioj 包装器也是如此。

  • Pi4J 2.0 正在构建中。

所以,公平的警告,当你读这本书的时候,我为这本书所发现和推荐的可能会改变。你应该自己做研究。

另一个考虑因素是用户社区的规模。越大越好,虽然这可能很难确定(搜索引擎可以帮助)。一个大的用户社区提供了更多的机会来寻找使用实例,并获得关于不寻常的用例或异常行为的帮助。然而,庞大的用户群体并不能保证“最好”。在所有基本 I/O 库中,Pi4J 似乎拥有最大的用户群体,但是请记住,当前版本应该被认为是过时的(不仅底层 wiringPi 库过时了,Pi4J 1.2 的某些部分在 Java 版本 9 或更高版本上也不能工作)。

易用性

易用性是主观和模糊的,可以包括许多方面。例如,理想情况下,要使用这个库,您可以获得一个单独的 jar 文件,这样就完成了。一些图书馆做到了这一点。另一方面,有些可能要求您在树莓派上构建 C 代码,在 Pi 上安装其他代码,弄乱 Java 权限文件,或者其他消耗您在项目上花费的时间的活动。

另一个方面在前面已经提到。有些库支持多种类型的 I/O。如果您需要的所有类型都由一个库满足,那么这个选项可能是“最佳的”,除非有其他标准禁止。只有一个图书馆,生活更简单;例如,您有单一的文档来源和单一的支持问题联系人。

其他方面包括文档的存在和质量、代码示例的存在以及 API 的复杂性。所有这些都会影响您使用库的速度。

这本书的基本 I/O 选择

不管是好是坏,这本书主要是一组独立的以设备为中心的项目,而不是一个单一的项目。在某种程度上,这种方法是由于机器人项目和物联网项目之间的差异,前者通常需要几个设备,后者通常只需要一两个设备。在某种程度上,这是因为想要展示树莓派的所有形式的基本 I/O。因此,出于本书的目的,我将寻找一个支持树莓派上可用的所有基本 I/O 功能的库,即 GPIO、串行、I2C 和 SPI。

在撰写本文时(2020 年末),没有太多繁琐的细节,根据功能覆盖、相对性能、支持和易用性,对支持所有类型基本 I/O 的可用“一体化”库进行了筛选,结果只产生了两个可行的候选库:

正是在这一点上,最终的决定变得非常有趣。图 1-1 显示了适用于本书的理想化软件架构,相关文本描述了该架构四层的本质。我们将使用该架构来比较候选人。我断言,当比较 pigpioj 和 diozero 时,没有必要考虑应用层,因为你将总是必须在应用层编写程序。

看看皮格皮奥伊

图 6-1 显示了使用 pigpioj 时,图 1-1 的软件架构,减去了应用层。对于 pigpioj,基本 I/O 层由两个子层组成。 pigpio 子层代表 pigpio C 库,C 中公开一个基本 I/O API;pigpio 使用更原始的树莓派 OS C API 作为基本 I/o。pigpioj子层是 pigpio 的 pig pioj“包装器”。pigpioj 子层公开了用于创建设备库的 Java 基本 I/O API;它使用 pigpio C API。

img/506025_1_En_6_Fig1_HTML.jpg

图 6-1

pigpioj 软件架构

设备层表示您找到或编写的设备库。它们向应用程序公开特定于设备的 API。他们使用pigpioj base I/O API。

pigpio 和 pigpioj 的结合提供了一个有趣的特性——远程 I/O 。这意味着您可以在不同的计算平台上运行您的应用程序,但是在目标树莓派上执行 I/O。这当然是用 NetBeans 进行远程开发的一种替代方法。详情参见 pigpioj 文档。

看一看迪奥西诺

图 6-2 显示了使用 diozero 时,图 1-1 的软件架构,减去了应用层。将图 6-2 与图 6-1 进行比较,您会发现相似之处,但也有显著差异。虽然在图 6-2 中并不明显,但与 pigpioj 的相对简单性相比,diozero 是一个提供了一系列有趣特性的扩展框架。

Note

本书中所有关于 diozero 的陈述在 1.3.0 版本中都是正确的;然而,在 2021 年夏天,diozero 正在积极开发中,新的和改进的功能频繁发布。因此,以后的版本可能会有所不同。此外,以下章节中的所有代码都是针对 1.3.0 进行测试的;以后的版本可能需要或受益于代码更新。

在图 6-2 中,首先注意到单板计算机 OS 层。该层表示几个单板计算机(SBC)中的一个,包括 Raspberry Pi。没错;diozero 在树莓派上工作,也在类似的 SBC 上工作,如 Odroid C2 和 BeagleBone Black。您可以在 diozero 文档中找到更多关于受支持的 SBC 的信息。

img/506025_1_En_6_Fig2_HTML.jpg

图 6-2

使用 diozero 的软件架构

使用 diozero,基本 I/O 层由 diozero 提供者组成。dio zero 提供商尽可能地调整底层 SBC 基本 I/O 功能,以提供由基本 I/O 层公开的基本 I/O API。提供者公开了用于创建设备库的 Java 基本 I/O API。它使用更原始的 SBC OS C API 进行基本 I/O

提供者的概念可能代表了 diozero 最有趣的特性。提供者实现了众所周知的服务提供者模式(参见 www.javapedia.net/Design-Patterns/1593 )。这意味着可以使用不同的提供商,而不改变更高层。因此,您可以编写一个设备库,它可以跨多个提供者和多个 SBC 工作(在 SBC 的基本 I/O 能力范围内)。diozero 支持通用和工作于任何* SBC 的提供者和 SBC 专用的提供者。在 diozero 版本 1.3.0 中,树莓派有四个提供者:*

** 内置:在 Pi 基本 I/O 功能的范围内提供最佳的功能和性能选择。内置提供程序是通用的。

  • pigpio :使用 pigpio C 库。pigpio 提供程序是 Pi 特定的。

  • diozero remote :提供一种远程计算形式,应用程序运行在主机平台上,I/O 操作运行在 Pi 上。diozero-remote 提供程序是通用的。diozero-remote 提供程序是远程开发的一个替代方案。

  • pigpio remote :提供一种远程计算形式,应用程序运行在主机平台上,I/O 操作运行在 Pi 上。pigpio-remote 提供程序是 Pi 特定的。pigpio-remote 提供程序也是远程开发的一种选择。

您可以在 diozero 文档中找到关于各种 SBC 的提供者的更多信息。

在图 6-2 中,设备层用其暴露的设备 API 表示设备库;他们使用基本 I/O 层(也称为提供者)API。diozero 的另一个很大的特点是它包括了一个广泛的设备库,你不必去写!当然,如果 diozero 不支持您的设备,您可以使用基本 I/O 编程接口来创建一个设备库。两全其美!您可以在 diozero 文档中找到受支持设备的列表。

Note

本小节仅描述 diozero 与树莓派和类似 SBC 相关的方面。diozero 还支持其他计算平台,例如 Arduino 和粒子光子。更多细节见 diozero 文档。

评估选择

现在我将使用前面提到的四个标准来评估 pigpioj 和 diozero,以便在它们之间进行选择。

功能覆盖范围

pigpioj 为您提供了一个健壮的基础 I/O 层,用于在树莓派上创建设备库。就这样。

diozero 给你

  • 用于创建设备库的强大的基础 I/O

  • 多个提供者为基础 I/O 层提供不同的特性

  • 一组现有的设备库,用于 LED、按钮、环境传感器、电机控制器甚至 IMU 等设备

  • 支持额外的计算平台,例如,Odroid C2 和 BeagleBone Black

显然,diozero 具有更优越的功能覆盖面。

表演

如前所述,pigpioj 是 pigpio 上的一个薄薄的包装。因此,在 Java 中,pigpio 的性能已经是最好的了。

diozero 提供的提供商之一是 pigpio。diozero 内置提供程序提供的 GPIO 性能大约是 pigpio 提供程序的七倍,两个提供程序的 I2C、SPI 和串行性能大致相当。因此,声称 diozero 提供卓越的性能当然是合理的。

支持

如果您仔细阅读 pigpioj 和 diozero 的文档,您会注意到这些库是由同一个人创建的。因此,支持很可能是一个平局。另一方面,diozero 是一项更加全面的工作,因此,及时提供支持可能更加困难。

易用性

至于易用性,pigpioj 还是蛮不错的。在尝试使用 pigpioj 之前,您必须在您的树莓派上安装 pigpioj 然而,这样做很简单。您必须在您的工作站上获取 pigpioj jar 文件。要构建和运行应用程序,必须将 jar 文件添加到 NetBeans 项目中。

然而,使用 pigpioj 要小心。底层 pigpio 库必须以 root 权限运行。因此,您必须通过sudo命令以超级用户身份运行您的 Java 应用程序。因此,您的应用程序可能会无意中造成混乱。虽然可能性不大,但还是有可能的。

diozero 的易用性也非常好。根据您使用的提供者,您必须获得三个或四个 jar 文件。要构建和运行应用程序,必须将 jar 文件添加到 NetBeans 项目中。正如您将在下面看到的,NetBeans 使这变得很容易。

pigpioj 和 diozero 都支持其基本 IO 层中的所有基本 I/O,即 GPIO、串行 I/O、I2C 和 SPI。diozero 的文档更好一些,自然也更广泛。虽然两个基本的 I/O 编程接口都不难使用,但我发现 diozero API 更容易使用,而且同样有效。diozero 还提供了一些 pigpioj 中没有的有用的便利方法和函数。

总的来说,这两个标准大致相同,尽管我觉得 diozero 有一点点优势。

最终选择——dio zero

就支持和易用性而言,pigpioj 和 diozero 之间没有明显的区别。但是 diozero 在 GPIO 上可以大大胜过 pigpioj。diozero 的功能覆盖面及其众多引人注目的特性远远超过 pigpioj。因此,本书中开发的设备库使用 diozero。当然,你可以做出不同的选择。

请记住,除非您坚持开放基本 I/O 选项,否则您只需要支持项目设备所需的一个或多个库。虽然 diozero 因其广泛的框架和令人钦佩的性能而成为本书的最佳选择,但这一选择的一个关键因素是支持所有的基本 I/O。如果您只需要一种特定形式的基本 I/O,例如串行,diozero 很可能至少是一个不错的候选,但您可能需要针对其他候选进行自己的评估。

Caution

我想强调的是,diozero 并不针对机器人或物联网。它是一个通用框架,支持任何类型设备的基本 I/O 功能。

在 NetBeans 中配置 diozero

现在 diozero 被确定为这本书的基本 I/O 库;我将向您展示如何使 diozero 对 NetBeans 可用,以便对于您的项目,您可以使用 diozero 中现有的设备库,或者使用 diozero 开发设备库。

我在第五章中提到,我发现在 NetBeans 中只有 Ant 项目管理/构建工具支持“开箱即用”的高效远程计算因此,我将向您展示如何设置基于 Ant 的项目。 1

首先,您需要下载所需的 diozero jar 文件。最简单的方法是获取 diozero“发行版 ZIP”文件,其中包含 diozero 依赖项的所有dio zero JAR 文件和所有* JAR 文件,以及依赖项的依赖项,等等。*

在下载之前,你得考虑好你要用什么版本的 diozero。除非你有很好的理由不这样做,否则你应该使用最新的版本(最高的版本号)。我找到了三种简单的方法来下载 diozero 发布版本的发行版 ZIP 文件。首先,调出 diozero 文档( https://www.diozero.com )。如果你想要最新的 diozero 版本,在左边的导航面板中选择创建你自己的应用。单击名为 diozero-distribution ZIP 文件的链接(就在 XML 清单下面)立即下载最新的发行版 ZIP 文件。如果你认为你可能想要一个更早的 diozero 版本,在你调出文档后,向下滚动直到你看到部分 Maven 依赖/下载链接。本节描述了查找和下载 diozero 所有发布版本的发行版 ZIP 的两种方法。

使用前面提到的方法之一下载您选择的版本的发行版 ZIP。您将下载一个名为diozero-distribution-i.j.k-bin.zip的文件,其中i.j.k代表版本号,例如 1.3.0。您可能需要解压缩文件;在 macOS 上,它被自动解压缩,所有包含的 JAR 文件都放在文件夹Downloads / diozero-distribution-i.j.k中。如果您使用的是 Windows,下载和解压缩(如果需要)应该会产生一个装满 JAR 文件的子文件夹(同名)。

发行版 ZIP 包含的 JAR 文件比您实际需要的要多得多。在本书中,您将只使用内置的提供程序。 2 这意味着下载的几十个 JAR 文件中,你只需要三个:

  • diozero-core-i.j.k.jar(包含内置提供程序;要看后面两个)

  • tinylog-api-m.n.o.jar 和 tinylog-impl-m.n.o.jar(这两个版本号是一样的;该编号可能不同于 diozero 核心的版本号)

现在您创建一个 Ant 库用于 diozero 项目。在 NetBeans 中,点击菜单栏中的工具。点击弹出窗口中的;你会看到如图 6-3 所示的蚂蚁库管理器对话框。

img/506025_1_En_6_Fig3_HTML.jpg

图 6-3

NetBeans Ant 库管理器对话框

点击新建库按钮;你看到新库对话框(图 6-4 )允许你给新库命名;你可以看到我使用的是“DIOZERO”,但是你可以使用任何独特的东西。输入姓名后,点击确定

img/506025_1_En_6_Fig4_HTML.jpg

图 6-4

新建库对话框

您将再次看到 Ant Library Manager 对话框,在这里您可以将适当的 JAR 文件添加到您正在创建的库中。点击添加 JAR/文件夹按钮;你会看到如图 6-5 所示的浏览 JAR/文件夹对话框(你可能需要水平展开它才能看到完整的文件/文件夹名称)。

img/506025_1_En_6_Fig5_HTML.jpg

图 6-5

浏览 JAR/文件夹对话框

导航到包含您刚刚下载的 JAR 文件的文件夹。选择前面提到的三个 JAR 文件,点击 Add JAR/Folder 。您将再次看到 Ant Library Manager 对话框,其中有您刚刚添加的用于创建库 DIOZERO 的 JAR 文件;见图 6-6 。

img/506025_1_En_6_Fig6_HTML.jpg

图 6-6

与 DIOZERO 库的 Ant 库管理器对话框

点击 OK 创建 DIOZERO 库。 Ant Library Manager 对话框关闭,库可以使用了。

现在我们来看看如何将 DIOZERO 库添加到项目中。这与添加一个项目库的过程非常相似,如第五章所述,所以我将只讨论不同之处。首先,打开项目属性,点击,你会看到如图 5-25 所示的对话框。现在点击类路径右边的 + 。在弹出的小窗口中,点击添加库。你现在会看到如图 6-7 所示的添加库对话框。

img/506025_1_En_6_Fig7_HTML.jpg

图 6-7

添加库对话框

点击 DIOZERO ,然后点击添加库。你将返回到如图 6-8 所示的项目属性对话框。

img/506025_1_En_6_Fig8_HTML.jpg

图 6-8

将 DIOZERO 库添加到项目的结果

您还需要为项目设置构建打包复制依赖库属性。点击确定。现在,您可以编写一个使用 diozero 中包含的一些设备库的应用程序,或者您可以使用 diozero base I/O API 编写自己的设备库,或者两者兼而有之!下一节将帮助您决定做什么和如何做。

查找(和移植)设备库

你已经为你的项目获得了一个闪亮的新设备。你如何找到对它的支持?记住搜索引擎是你的朋友。一个好的方法是

  • 查看您设备的 diozero 设备库列表(参见 https://www.diozero.comwww.javadoc.io/doc/com.diozero/diozero-core/latest/com/diozero/devices/package-summary.html )。

  • 在互联网上搜索使用 diozero base I/O 层的 Java 设备库。

  • 搜索使用其他基本 I/O 支持的树莓派提供程序的 Java 设备库,例如 Pi4J 或 pigpioj。

  • 更广泛地搜索 Java 设备库。这个搜索应该会得到其他计算平台以及树莓派的支持。

  • 从销售商、制造商或任何其他地方为您的设备搜索非 Java 库。

  • 从头开始为你的设备实现一个库。

你会发现自己处于几种情况中的一种(或多种)。以下各小节或多或少地按照可取性的顺序讨论了这些情况。

在分析情况之前,我必须提几点。第一点是重要的现实。即使在最好的情况下,也可能会有需求差异要求您完成工作。有时一个设备库表现出太少的设备能力;例如,IMU 库可能不支持此类设备中常见的 FIFO。有时候一个设备库做的太多;在我的一个项目中,我只使用了 9 轴 IMU 的 z 轴陀螺仪。有时候,可悲的是,你发现“懒惰编程”;在一个例子中,开发者在出错的情况下编码控制台输出,而不是更健壮的方法。不管在什么情况下,你必须总是准备好自己解决这样的需求差异,除非你的项目在他们存在的情况下运行良好。

第二点是一个重要的机会。在第一章中,我断言你应该认为自己很幸运能够在为某些设备找到一个库。如果您确实找到了一个库,甚至多个库,您可以选择移植,也可以选择从头开始。后者可能是更好的选择,这取决于设备的复杂性、编写现有设备库的语言、实际需要使用的现有库的大小以及其他因素。然而,有一件事你应该一直考虑,如果你有必要的资源,实际上是运行一个现有的库。您可以简单地检查设备是否真的按照您期望的方式工作。您可以在创建资源库时,将现有资源库的行为或结果与您的资源库进行比较。如果你有能力,通过一个 IDE,你可以调试让你更深入地了解现有的库。

记住这两点,我们现在来看看可能的情况。

Java 设备库和 diozero 基本 I/O

这种情况有两种情况:

  • 你可以在 diozero 中找到一个设备库。

  • 您会发现一个使用 diozero base I/O 的非 diozero 设备库。

在这些非常理想的情况下,你不需要做任何工作!除了解决前面提到的需求差异之外,没有其他工作。

如果你没有找到这两种情况,你应该检查 diozero 是否为类似你的设备提供了一个设备库。各类设备通常有一些共同的特征。根据 diozero 支持的设备和您的设备之间的差异,您可能能够修改该库(例如,更改 I2C 地址、寄存器地址、控制常量)。至少,你可以用它作为指导。

Tip

有一种方法可以确保你在这种偶然的情况下结束。在为您的项目选择设备之前,检查 diozero 支持的设备,并尽可能使用它们。没有工作!

Java 设备库和非 diozero 基本 I/O

在这种情况下,您会发现一个 Java 设备库使用了一个非 diozero 基本 I/O 库。设备库可能以树莓派或不同的 SBC 为目标。最有可能的基本 I/O 库是 Pi4J,因为它很受欢迎并且覆盖了所有的基本 I/O。其他可能的库包括 pigpioj 和 jSerialComm。

对于所有的可能性,将设备库移植到 diozero base I/O API 应该是相当简单的。所有的设计和大部分代码应该不加修改地移植。显然,您必须理解非 diozero 和 diozero 基本 I/O API 的语义和语法,以便在它们之间进行转换。移植有两种基本选择:

  • 您可以将对非 diozero 基本 I/O API 的调用替换为逻辑和对 diozero 基本 I/O API 的调用的适当组合。

  • 您可以创建适配器方法,在其接口中模拟非 diozero 基本 I/O API,并封装逻辑和对 diozero 基本 I/O API 的调用的适当组合。您甚至可以将这样的方法分组到一个类中。如果您需要使用相同的非 diozero 基本 I/O API 的多个设备库,这是可取的。

然而,我必须对这种情况提出警告。我发现的 Java 设备库倾向于嵌入到一个更大的框架中。有时很容易忽略更大的框架,而使用库的大部分;有时候不是。如果有其他选择,要小心所涉及的努力。

C/C++设备库

您很有可能找到使用基于 C/C++的 I/O 库的 C/C++设备库。我的经验表明,最常见的设备库形式是 Arduino C++库,因为 Arduino 非常流行。这些通常会在设备供应商网站、制造商网站或两者上引用。您有时可以找到使用 pigpio 或 wiringPi 的设备库。这些几乎总是来自与供应商或制造商没有联系的个人或小团队。

一般来说,C/C++的大部分设计和实现应该是可用的;几乎可以用复制/粘贴来移植(尤其是 C++)。另一方面,移植现在包括

  • 了解非 diozero base I/O API 以及 diozero base I/O API

  • 处理 C/C++和 Java 之间的语法差异(例如,类定义、地址和指针、malloc / free)

  • 处理差异是变量类型;例如,在 Java 中没有无符号类型,但是在 C/C++中有

这些项目都不是很费力,但是总的来说,这种情况显然比大多数纯 Java 情况需要更多的工作。所涉及的工作量取决于设备的复杂性、C/C++库开发人员的能力(例如代码中的注释)、您对 C/C++的了解以及其他因素。

Python 设备库

您可能会找到一个 Python(或某种变体)设备库,它使用某种基于 Python 的 I/O 库,这主要是因为 Python 是树莓派的默认编程语言。Python 有几个基本的 I/O 库;最受欢迎的似乎是 RPi。GPIO 和 gpiozero。C pigpio 库也有 Python 包装器;和 pigpioj 一样,Python pigpio 库(是的,它和 C 库的名字完全一样)完全公开了 pigpio 接口。

使用 Python,设计应该是可用的,但是显然您必须将 Python 语法和语义移植到 Java。您将面临移植 C/C++库的所有问题,痛苦可能被夸大了(想想松散类型与强类型)。除非您精通 Python,否则移植 Python 设备库将比移植 C/C++库需要更多的工作,因此比移植 Java 需要更多的工作。也就是说,移植 Python 库可能比从头开始花费更少的工作。

没有设备库

如果在所有的搜索之后你没有找到合适的库,你显然必须做所有的工作。在某些方面,这是一种解放。你有一个干净的石板,你可以设计和实施精确的能力,你需要从你的设备。您不需要担心理解非 diozero 基础 I/O API。你不用担心语言之间的翻译。

或许与其他情况的最大区别在于,你必须对你的器件非常了解(例如,至少阅读两遍数据手册),但这不一定是件坏事。您应该搜索该器件的应用笔记,以及任何可能有助于您理解该器件简明数据手册的内容。请记住,您可以通过查看类似设备的设备库来获得指导。

摘要

在这一章中,我讨论了树莓派对 Java 的设备支持,包括

  • 寻找基本 I/O 功能支持的搜索技术

  • 为您的项目选择最佳候选人的标准

  • 本书的基 I/O 选择为*,即 diozero*

** 使用 diozero 开发的 NetBeans 配置

*   查找设备库的搜索技术

*   将这些库移植到 Java(如果需要的话)以及使用您选择的基本 I/O 库(如果需要的话)所涉及的工作

*   找不到图书馆时的努力* 

*现在你掌握了一些技巧和指导方针,可以让你应对任何情况。在第七章中,我们将更详细地了解 diozero 基础 I/O API。之后,我们会找到或创建一些设备库!

**

七、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 来创建设备库了!

***

八、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 库

干得好!

********

九、惯性测量装置

在这一章中,我们将检查我在自主漫游车中使用的另一个设备——惯性测量单元(IMU)。IMU 出现在许多移动机器人项目中,因为它有助于确定机器人从一个点到另一个点的方向。器件为阿达果精密恩智浦 9 自由度分线板——fxos 8700+fxa 21002(参见 www.adafruit.com/product/3463 )。这个名字太长了,所以在本章中我将使用术语 PIMU

从逻辑上来说,你可以将 IMU 视为三个设备,因为它包括一个加速度计,该加速度计测量三维(三个自由度)中的线性加速度;一个陀螺仪,它在三维空间(三个自由度)中测量角速度;以及一个磁力计,测量磁场强度三维(三个 DOF)。把所有测得的自由度加起来,你就得到了 PIMU 真实名称中的九个自由度。物理上,PIMU 有两个设备,FXOS8700CQ 和 FXAS21002C。

在这一章中,我将介绍

  • 一个设备实际上是两个设备,或者三个设备,这取决于你的观点!

  • 使用 diozero 实现 I2C 设备的设备库

  • 仅实现设备功能的子集

  • 串联使用 C++库和 Java 库进行移植

  • 使用“数据分析”识别和解决各种设计问题

  • 利用 diozero 处理设备中断

了解设备

在 PIMU 分线板上,FXOS8700CQ ( www.nxp.com/docs/en/data-sheet/FXOS8700CQ.pdf )为加速度计和磁力计供电;fxa 21002 c(www.nxp.com/docs/en/data-sheet/FXAS21002.pdf)为陀螺仪供电。这意味着您必须处理两张数据表!这也使得一些设计决策变得更加困难。

查看 FXOS8700CQ 数据手册可以发现,这是一款相当复杂的器件。数据手册中一些有趣的亮点:

  • 数据手册有 116 页,参考了一些设计笔记,并包括一些示例代码。

  • 它支持 I2C 和 SPI(点对点)。PIMU 突破板只暴露了 I2C。

  • 您可以打开加速计和/或磁力计。

  • 加速度计满量程范围是可配置的。

  • 输出数据速率最高可配置为 800 Hz(运行两个传感器时为 400 Hz)。

  • 加速度计的分辨率为 14 位;对于磁力计,它是 16 位。

  • 它支持加速度计的 32 样本 FIFO。

  • 它可以配置为识别几种可能产生中断的加速度和磁场“事件”。你必须仔细阅读,以确定还有一个更一般的“数据就绪”中断。

  • 它有近 120 个寄存器,其中大部分用于各种功能和事件的配置。

  • 它提供一个 8 位温度传感器。

查看 FXAS21002C 数据手册可以发现,它也是一款相当复杂的器件。数据手册中一些有趣的亮点:

  • 数据手册有 58 页,提到了“各种参考手册、用户指南和应用笔记”。

  • 陀螺仪满量程范围是可配置的。

  • 输出数据速率最高可配置为 800 Hz。

  • 陀螺仪的分辨率为 16 位。

  • 它支持 I2C 和 SPI。同样,PIMU 突破板只暴露了 I2C。

  • 它支持 32 样本 FIFO。

  • 如果角加速度超过配置的阈值,它可以产生中断。同样,近距离读取暴露出它可以产生“数据就绪”中断。

  • 它提供一个 8 位温度传感器。

FXOS8700CQ 和 FXAS21002C 都支持 100 kHz 或 400 kHz 的 I2C 总线速度。两者都不支持 I2C 时钟拉伸(用于根据需要减慢时钟)。对于块读取和块写入,两者都自动递增寄存器地址。

通过查看数据手册并思考您的需求,您可以了解如何配置 PIMU 来满足您的需求。这意味着有必要明确 PIMU 的功能预期。出于本书的目的,我假设加速度计/磁力计必须支持确定前面提到的火星车的罗盘或绝对航向,陀螺仪必须支持确定火星车的旋转幅度,或相对航向。考虑到这些要求,从数据手册中可以清楚地看出,除了“数据就绪”之外,任何一个器件都不需要“事件”

查找设备库

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

Java 库搜索

在撰写本文时,diozero 文档不包含任何与 IMU 相关的内容,发行版 ZIP 文件也不包含任何相关的类。然而,在 diozero GitHub 代码库中有一个com.diozero.devices.imu包( https://github.com/mattjlewis/diozero/tree/main/diozero-imu-devices/src/main/java/com/diozero/devices/imu )包含了几个与 IMU 相关的类。该封装有两种 IMU 实现方案:MPU9150 内置一个加速度计、一个陀螺仪和一个磁力计,ADXL345 仅内置一个加速度计。也就是说,没有任何东西与 PIMU 直接相关。

com.diozero.devices.imu中的接口和抽象类很有趣,尤其是高层的ImuInterfaceImuData,因为它们抽象了 IMU 的数据采集。它们的水平如此之高,我认为您应该只在最初的 PIMU 库实现之后才考虑使用它们。

MPU9150 库的实现(类MPU9150DeviceMPU9150DriverAK8975Driver)表明它是一个相当复杂的设备,并且比我预期的 PIMU 更加不同。 1 就配置灵活性而言,有很多好的界面设计理念,尽管并非所有都适用于 PIMU,而且基于 PIMU 对我的漫游者的要求,可能有些矫枉过正。不幸的是,MPU9150 和 PIMU 之间的差异建议在尝试使用 MPU9150 库之前搜索 PIMU 特定的库。

在其他 Java 库中搜索 FXOS8700CQ 和 FXAS21002C,每个库只找到一个结果。这两个库来自同一个基于 Android 的项目(见 https://github.com/ekalyvio/CowBit/tree/master/SensorApp/src/main/java/com/kaliviotis/efthymios/cowsensor/sensorapp )。

检查表明它们至少是合理的候选者,有一些小问题,例如可能不完整,具有 Android 依赖性,具有框架依赖性,表现出对 Java 命名标准的违反,以及对 FIFO 和“事件”等功能配置的不必要支持。

与 RoboClaw 的设备库(第八章)相比,基本上只有两个级别的设备访问:

  • 包装安卓 I2C 访问方法的底层方法;diozero 方法将被替代。

  • 调用低级方法的接口级方法。

这些库采用最大列表的方法来配置,并允许你配置几乎所有可配置的东西。这些库包括更改设备电源状态和重置设备的能力。这些能力在你的项目中可能有用,也可能没用。

FXOS8700CQ 库的另一个令人失望的地方是,它只能在混合模式下工作,即它可以读取加速度计和磁力计;允许所有三种模式会引入一些配置依赖性。我在 FXAS21002C 库中找不到其他令人失望的地方。

搜索非 Java 库

通常,找到一个 Java 库后,您就可以继续了,但是我认为看看还有什么其他的库也是不错的。Adafruit 为这两种设备提供了 Arduino C++库。为了支持 CircuitPython 的发展,Adafruit 还提供了 CircuitPython 库。最后,单个器件的制造商恩智浦提供 Android C 库。

既然 Java 库已经存在,我看不出有什么理由去研究 CircuitPython 库。另一方面,我认为至少浏览一下其他的库是有益的。

看看 C++库

你可以在 https://github.com/adafruit/Adafruit_FXOS8700https://github.com/adafruit/Adafruit_FXAS21002C 找到 C++库。一项检查表明,他们至少是合理的候选人,有一些小问题,如对 Adafruit 传感器框架的依赖和单字节读取的使用。

像 Java 库一样,基本上只有两个级别的设备访问。与 Java 库不同,C++库对整体功能采取了极简的方法,例如,它们

  • 仅定义部分配置寄存器。

  • 仅允许配置加速度计(FXOS8700CQ)或陀螺仪(FXAS21002C)的满量程范围;所有其他配置都以某种方式默认。

  • 始终读取两个传感器(FXOS8700CQ,尽管接口允许返回加速度计、磁力计或两者的传感器读数)。

  • 不支持设备可能产生的“事件”。

  • 不支持使用 FIFO。

  • 不要提供检索状态的方法。

看一看 C 库

不幸的是,你必须下载 C 库来查看它们;你可以从www.nxp.com/webapp/sps/download/license.jsp?colCode=FXAS2100X-DRVR&location=nullwww.nxp.com/webapp/sps/download/license.jsp?colCode=FXOS8700-DRVR&location=null开始做。一项检查显示,这些库是可能的候选者,但有我不理解的“Linux for Android”方面,并显示了一个更大框架的证据(我不理解细节)。

像 Java 和 C++库一样,基本上只有两个级别的设备访问。这些库像 C++库一样采用极简的配置方法,但也像 Java 库一样支持“事件”。FXOS8700CQ 库有一个有趣的特点;它独立返回加速度计和磁力计的数据,从而在一次读取操作就足够的情况下,执行两次块读取操作。

答案是…

我认为将之前检查的设备库描述为极简主义者 (C++)、最大主义者 (Java),或者介于 (C)之间的是公平的。事实上,我认为 C 库的“Linux for Android”方面混淆了太多需要移植的内容,所以我不考虑它。然后在最简的(c++)和最简的(Java)之间做出选择。**

**考虑到这些设备库之间的差异,确定如何在项目中使用这些设备比平常更重要。前面的简短用法说明表明大多数事件都没有必要。倾向于极简主义。另一方面,配置一切的能力听起来相当有吸引力。也就是说(这可能会被认为是欺骗),使用两种不同 IMU 和其他复杂设备的经验表明,通常情况下,你会从一种看起来适合你的配置开始,进行一点试验,最终确定一种配置,不再改变。因此,以编程方式配置一切的能力听起来很棒,但在实践中是不必要的。这绝对是一种最低限度的偏见。

因此,尽管听起来很荒谬,考虑到我的项目的最低需求,我将使用 C++库作为库来启动移植并充分利用 Java 库的各个方面。

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

设备库端口

第八章指出了在开始开发代码之前必须考虑的 Java 设备库的一些方面。本节讨论与 PIMU 有关的方面。

PIMU 给界面设计带来了有趣的挑战。PIMU 是一种设备吗:(加速度计、陀螺仪、磁力计)?是两个器件:(加速度计,磁强计),陀螺仪?是三个器件:加速度计、陀螺仪、磁力计?

显然,所有检查过的现有库都将 PIMU 分线板视为两个设备,与板上的两个真实设备 FXOS8700CQ 和 FXAS21002C 对齐。我会选择那个设计点。如果您需要将 PIMU 视为一个单独的设备,您可以创建一个类来封装这两个真实的设备(有点像前面提到的MPU9150Device)。

根据前面的讨论,Java 设备库接口应该关注检索传感器读数的方法。它应该可能包括支持某些配置和读取状态的方法。

每个传感器测量三个自由度。飞行器需要三维空间。然而,被限制在平坦表面上行进的漫游车只需要来自陀螺仪的一个维度(Z 轴)和来自加速度计/磁力计的两个维度(X 和 Y 轴)。因此,支持一组额外的方法来仅获取所需的信息是有意义的。

我认为一个机器人需要多个皮姆是非常不太可能的。因此,不需要库的多个实例。你可以创建一个单独的类(参见 www.benchresources.net/singleton-design-pattern-restricting-all-4-ways-of-object-creation-in-java/ ),或者你可以让所有的方法都是静态的,或者你可以假设没有用户会试图创建一个以上的实例。因为我很懒,所以我会做这样的假设,显然现有库的设计者也是这样做的。

*因为我主要是从 C++移植过来的,所以我可能会做大量的清理工作。至少在大多数情况下,我将遵循深度优先的开发移植方法。最后,由于 FXAS21002C 比 FXOS8700CQ 简单一点,所以我先从前者说起。

玩设备(FXAS21002C)

根据第八章中的建议,我们将首先尝试使用该设备。幸运的是,FXAS21002C 支持一个简单的交互来验证成功,即读取“我是谁”寄存器。所以,我们要玩!

显然,在运行任何代码之前,您必须将 PIMU 连接到您的树莓派参见 https://learn.adafruit.com/assets/59489 了解正确的 I2C 连接。你应该关掉你的 Pi;连接 SDA、SCL、地和 3.3V 检查连接;再查;然后打开你的电源。然后,您还可以在 ssh 到您的 Pi 的终端中使用命令"i2cdetect -y 1"来确定是否一切正常;在报告中,您应该会看到两个 I2C 设备地址,0x1f 和 0x21(参见 www.raspberrypi-spy.co.uk/2014/11/enabling-the-i2c-interface-on-the-raspberry-pi/ )。

为了玩,你必须首先采取建造一个库所需要的相同步骤。您必须创建一个新的 NetBeans 项目、包和类。在您的树莓派上配置用于远程开发的项目;并将项目配置为使用 diozero。参见第七章了解步骤总结。我把我的项目叫做 PIMU ,我的包org.gaf.pimu,我的类FXAS21002C。但是,既然我们要做一些测试,那么你想要创建一个新的包;我会叫我的org.gaf.pimu.test。在那个包中,创建一个新的类;我给我的班级取名PlayFXAS

清单 9-1 显示了读取“我是谁”寄存器的实现。如你所见,这很简单:

  • 导入允许我们使用 diozero I2CDevice与 FXAS21002C 交互。

  • 因为我们可以使用 I2C 通信的所有默认值,所以我们使用I2CDevice.Builder来构建一个仅使用 FXAS21002C 地址 0x21 的实例。

  • 我们从“我是谁”寄存器 0x0C 中读取一个字节,并将其打印出来。

  • 我们关闭实例。

package org.gaf.pimu.test;

import com.diozero.api.I2CDevice;

public class PlayFXAS {

    public static void main(String[] args) {
        I2CDevice device =
               I2CDevice.builder(0x21).build();
        byte whoID = device.readByteData(0x0C);
        System.out.format("who am I: %2x%n", whoID);
        device.close();
    }
}

Listing 9-1PlayFXAS

在 Pi 上运行PlayFXAS,您应该看到以下内容:

who am I: 0xd7

如果是,成功!然后我们可以继续开发图书馆。如果没有,…嗯;检查所有内容、连接和代码,然后重试。

设备库开发(FXAS21002C)

我们已经完成了库开发的初始任务,所以我们可以开始构建核心。但是核心是什么呢?基于第八章的指引,我们应该试着去读一些数据。为此,我们需要

  • 建筑工人

  • 寄存器地址和其他常量的定义

  • 能够阅读有意义的内容的配置

  • 一种读取数据的方法

构造器分析和实现

现有的 Java 设备库构造器需要一个可操作的 I2C 设备;它设置默认设备配置(13 部分),但不写入设备;这是稍后在库的begin方法中完成的。现有的 C++设备库构造器需要更大框架的某种 ID;所有的“实际工作”都在库的begin方法中完成,包括检查“我是谁”注册。因此,实际上,两个构造器什么都不做,甚至没有创建一个 I2C 设备,也没有检查“我是谁”寄存器。我个人认为这没有意义,所以我会在FXAS21002C构造器中做更多的工作。

清单 9-2 显示了FXAS21002C构造器。构造器创建一个 I2C 设备并检查“我是谁”寄存器。我再次选择在失败的情况下抛出一个检查过的异常,但是您可以做其他事情。

注意FXAS21002C实现AutoCloseable,如第七章中所推荐的。因此,必须有一个close方法。我想不出 PIMU 会发生什么不愉快的事情,但是确保关闭任何资源是一个最佳实践。

Note

同样,我没有包括 Javadoc 或注释(大部分),但是你应该

package org.gaf.pimu;

import com.diozero.api.I2CDevice;
import com.diozero.api.RuntimeIOException;
import com.diozero.util.SleepUtil;
import java.io.IOException;

public class FXAS21002C implements AutoCloseable {

    private static final int FXAS21002C_ADDRESS =
           0x21;
    private static final byte FXAS21002C_ID =
           (byte) 0xD7;

    private I2CDevice device = null;

    public FXAS21002C() throws IOException {
        try {
            device =
                I2CDevice.builder(
                    FXAS21002C_ADDRESS).build();

            byte whoID = device.readByteData(
                Registers.WHO_AM_I.register);
            if (whoID != FXAS21002C_ID) {
                throw new IOException(
                    "FXAS21002C not found
                    at address " +
                    FXAS21002C_ADDRESS);
            }
        } catch (RuntimeIOException ex) {
            throw new IOException(ex.getMessage());
        }
    }

    @Override

    public void close() {
        if (device != null) device.close();
    }

    private enum Registers {
        STATUS(0x00),
        OUT_X_MSB(0x01),
        OUT_X_LSB(0x02),
        OUT_Y_MSB(0x03),
        OUT_Y_LSB(0x04),
        OUT_Z_MSB(0x05),
        OUT_Z_LSB(0x06),
        DR_STATUS(0x07),
        F_STATUS(0x08),
        F_SETUP(0x09),
        F_EVENT(0x0A),
        INT_SOURCE_FLAG(0x0B),
        WHO_AM_I(0x0C),
        CTRL_REG0(0x0D),
        CTRL_REG1(0x13),
        CTRL_REG2(0x14),
        CTRL_REG3(0x15);

        public final int register;

        Registers(int register) {
            this.register = register;
        }
    }

    public enum Range {
        DPS250(250, 3, 0.0078125F),
        DPS500(500, 2, 0.015625F),
        DPS1000(1000, 1, 0.03125F),
        DPS2000(2000, 0, 0.0625F);

        public final int range;

        public final int rangeCode;
        public final float sensitivity;

        Range(int range, int rangeCode,
                float sensitivity) {
            this.range = range;
            this.rangeCode = rangeCode;
            this.sensitivity = sensitivity;
        }
    }

    public enum ODR {
        ODR_800(800f, 0 << 2),
        ODR_400(400f, 1  << 2),
        ODR_200(200f, 2 << 2),
        ODR_100(100f, 3 << 2),
        ODR_50(50f, 4 << 2),
        ODR_25(25f, 5 << 2),
        ODR_12_5(12.5f, 6 << 2);

        public final float odr;
        public final int odrCode;

        ODR(float odr, int odrCode) {
            this.odr = odr;
            this.odrCode = odrCode;
        }
    }

    public enum LpfCutoff {
        Highest(0 << 6),
        Medium(1 << 6),
        Lowest(2 << 6);

        public final int level;

        LpfCutoff(int level) {
            this.level = level;
        }
    }

    public enum PowerState {
        StandBy(0),
        Ready(1),
        Active(2),
        Reset(0x40);

        public  final int state;

        PowerState(int state) {
            this.state = state;
        }
    }
}

Listing 9-2FXAS21002C constructor and constants

清单 9-2 还显示了该类常量的定义。现有的 Java 类有一些很好的代码,展示了一些可以用作模型的“最佳实践”。但是你可以看到我做了一点清理。我

  • 更改了名称,因为我觉得没有必要在名称中使用“enum”

  • 将访问权限从公共改为私有,我认为不需要在类外访问

  • 增强了一些枚举,使它们更有用

    • Range添加信息

    • 修改了ODR,使得该值可用于帮助定义控制寄存器的内容

    • PowerState增加了一个值,以支持复位

  • 增加了LpfCutoff以增加常量使用的一致性

  • 因为我不打算使用 FIFO,所以删除了FifoModeEnum

配置

现有的库支持界面中的各种级别的配置,最大列表 Java 库允许几乎所有的配置,最小化 C++库只允许全范围的配置。虽然一般的想法是创建一个极简的库,但是确定什么是可能的,当然,什么是需要的,这是一个好主意。假设没有 FIFO 和事件,重要的配置寄存器是

  • CTRL_REG0 ,控制

    • 低通滤波器带宽截止

    • 高通滤波器使能和带宽截止

    • 满量程范围

  • CTRL_REG1 ,控制

    • 输出数据速率

    • 电源模式

  • CTRL_REG3 ,控制

    • 满量程范围加倍

您可能需要的可能不同于现有库的设计者决定需要的,也不同于我最终决定需要的。漫游者的目标包括直线行驶和以 90°的增量旋转。因此,任何角度的变化都会非常小,或者发生在几秒钟内。这意味着角度变化根据器件的能力“缓慢”发生,表明器件应始终配置在其最灵敏的位置,无需配置满量程范围(或加倍)。此外,由于任何真正的角度变化都是“缓慢”发生的,我决定使用低通滤波器,并始终禁用高通数字,以消除漫游者机械特性中的“噪声”。这样就只剩下电源模式,它可以用来将设备置于就绪状态,而不是激活状态,以节省电能;我觉得这是不必要的,所以唯一需要的配置是

  • 低通滤波器带宽截止(在 CTRL_REG0 中)

  • 输出数据速率(在 CTRL_REG1 中)

坦率地说,即使是这两种配置选项也只是为了更容易找到漫游者的最佳配置。一旦我发现我觉得是最佳的,我就再也不会改变它们了。这个故事的寓意是,在你花大力气允许改变之前,好好想想你需要改变什么,多久需要改变一次。

Tip

根据我使用 PIMU 的经验,您可能必须尝试不同的满量程范围和过滤配置,以找到一个或多个为您的项目产生最佳结果的配置。相反,您项目的需求决定了输出数据速率,无需太多实验。

清单 9-3 显示的是FXAS21002Cbegin配置和激活设备的方法。该方法首先复位器件,然后根据数据手册将器件置于待机模式,以便建立正确的配置。根据数据表(和现有库),该方法在配置建立后休眠。

public void begin(LpfCutoff lpfCutoff, ODR odr)
        throws RuntimeIOException {
    // reset
    device.writeByteData(
            Registers.CTRL_REG1.register,
            PowerState.StandBy.state);
    try {
        device.writeByteData(
                Registers.CTRL_REG1.register,
                PowerState.Reset.state);
    } catch (RuntimeIOException ex) {
        // expected so do nothing
    }

    // go to standby state
    device.writeByteData(
            Registers.CTRL_REG1.register,
            PowerState.StandBy.state);

    // set the lpf value
    int cntl_reg0 = lpfCutoff.level;
    // set the default full scale range
    cntl_reg0 |= DEFAULT_FSR_250DPS;
    // write the FSR and LPF cutoff
    device.writeByteData(
            Registers.CTRL_REG0.register,
            (byte) cntl_reg0);

    // set the odr value
    int cntl_reg1 = odr.odr;
    // write ODR as requested and active state
    cntl_reg1 |= PowerState.Active.state;
    device.writeByteData(
             Registers.CTRL_REG1.register,
             (byte) cntl_reg1);
    SleepUtil.sleepMillis(100);
} 

Listing 9-3FXAS21002C begin method

begin中的一个微妙之处值得详述。当设备重置时,您会注意到该方法捕获了一个预期的 RuntimeIOException并且什么都不做,而 C++构造器不必这样做。这是因为树莓派和 Arduino I2C 操作的不同。由于设备在 I2C 交互期间复位,设备不会发送预期的确认。缺少的“ACK”不会困扰 Arduino,但会给 Pi 带来很多麻烦,导致 Java 出现异常。幸运的是,可以安全地忽略该异常。

请注意,根据第七章中的指导原则,任何意外的 RuntimeIOException都会被传播,包括来自SleepUtil的。《??》中的其他方法也是如此。

阅读样本

现在该做图书馆的主要工作了,就是读陀螺仪。然而,首先,有几个问题我要解决,有些微不足道,有些相当重要。

命名

现有的库称他们的数据读取方法为getEvent,我觉得这不合适,因为根据数据表,读取数据不一定是对某个事件的反应。在另一次清理中,我将使用一个我认为更合适的不同名称(readRaw)。

状态

FXAS21002C DR _ STATUS 寄存器指示新数据何时可用,旧数据何时被覆盖;状态寄存器镜像 DR_STATUS 寄存器,其配置在清单 9-2 中的begin方法中建立。

现有的 Java 库在读取数据时没有读取状态;它是否包括一种读取 DR_STATUS 的方法和几种评估其内容的方法。C++库在读取数据时读取状态,但不做任何事情;库没有包含读取状态的方法。

我认为现有的 Java 库方法是合适的。也就是说,阅读状态不一定属于核心。我会推迟到以后。

单位

返回数据的单元是目前最有趣的问题。该设备以最低有效位(LSB)为单位提供 原始 数据。为了有用,原始数据必须转换成每秒度数(dps)。数据手册显示,250 dps 的配置满量程范围可产生 7.8125 mdps/LSB 的灵敏度。两个现有的库都将从设备读取的原始数据(单位= LSB)乘以灵敏度,以产生 转换的 数据(单位= dps)。

**这听起来是个好主意,因为 dps 单位比 LSB 更有意义。我不认为这是个好主意。处理现实世界设备的一个不幸现实是不完美。考虑数据手册中的表 5。原始数据受到与温度、 2 相关的“噪声”、非线性、噪声密度和零速率偏移的影响。在完美的世界中,如果设备是静止的,并且你获得 Z 轴的一系列原始读数,你会期望序列 0,0,…,0。由于零速率偏移,你会得到,例如,20,20,…,20。不幸的是,在使用器件进行计算之前,您并不真正知道零速率失调是什么。但是各种其他来源的“噪声”也开始起作用,所以你真的得到一系列看似随机的数字!

由于这一现实,我们现在只产生原始数据。我们稍后将查看原始数据,以巩固本小节中的讨论。

履行

清单 9-4 显示了从设备读取数据的FXAS21002C.readRaw的实现。根据前面的讨论,该方法不读取状态并返回原始数据。实现非常简单。它执行块读取来获取所有三个轴的值(总共六个字节),然后创建整数返回给调用者。

public int[] readRaw() throws RuntimeIOException {
    // read the data from the device
    byte[] buffer = new byte[6];
    device.readI2CBlockData(
            Registers.OUT_X_MSB.register, buffer);

    // construct the response as an int[]
    int[] res = new int[3];
    res[0] = (int) (buffer[0] << 8);
    res[0] = res[0] | Byte.toUnsignedInt(buffer[1]);
    res[1] = (int) (buffer[2] << 8);
    res[1] = res[1] | Byte.toUnsignedInt(buffer[3]);
    res[2] = (int) (buffer[4] << 8);
    res[2] = res[2] | Byte.toUnsignedInt(buffer[5]);
    return res;
}

Listing 9-4Method to read raw data from the FXAS21002C

测试核心

您现在可以测试FXAS21002C核心实现了。基于第八章的讨论,你应该在不同的包中创建一个主类。因为我们玩的比较早,包org.gaf.pimu.test已经存在。我将主类命名为TestFXASCore

TestFXASCore该怎么办?显然它必须创建一个FXAS21002C(参见清单 9-2 )。它必须使用begin方法配置设备(参见清单 9-3 )。最后,它必须调用readRaw(参见清单 9-4 )来读取数据。

清单 9-5 显示了 TestFXASCore 。它将低通滤波器截止频率配置为最低频率,将输出数据速率配置为 50 Hz。它定义了私有方法readXYZ,该方法多次读取所有三个轴,并打印一个轴的值。TestFXASCore调用readXYZ两次,一次在激活后立即调用,一次在延迟后调用。变量axis决定readXYZ是否打印 X、Y 或 Z 轴(axis =分别为 0、1 或 2)。

请注意,TestFXASCore按照第七章中的指南启用资源尝试和 diozero 安全网,本章中的所有其他设备和应用也是如此。由于 PIMU 不会造成伤害,因此不需要 Java 关闭安全网。

package org.gaf.pimu.test;

import com.diozero.api.RuntimeIOException;
import com.diozero.util.Diozero;
import com.diozero.util.SleepUtil;
import java.io.IOException;
import org.gaf.pimu.FXAS21002C;

public class TestFXASCore {

    public static void main(String[] args) throws
            IOException, InterruptedException {
        try (FXAS21002C device = new FXAS21002C()) {
            device.begin(
                FXAS21002C.LpfCutoff.Lowest,
                FXAS21002C.ODR.ODR_50);

            int num = Integer.valueOf(args[0]);

            int axis = 2;

            readXYZ(device, num, axis);

            System.out.println("\n ... MORE ... \n");
            Thread.sleep(2000);

            readXYZ(device, num, axis);
        } finally {
            Diozero.shutdown();
        }
    }

    private static void readXYZ(FXAS21002C device,
             int num, int axis)
             throws RuntimeIOException {
        int[] xyz;
        for (int i = 0; i < num; i++) {
            xyz = device.readRaw();
            System.out.println(xyz[axis]);
            SleepUtil.sleepMillis.sleep(20);
        }
    }
}

Listing 9-5TestFXASCore

我运行TestFXASCore的参数= 200(使用项目 PIMU运行属性来设置参数),ODR = 50 赫兹,PIMU 完全静止。清单 9-6 显示了输出。

-256
-188
-125
-76
-47
...
 ... MORE ...
...
-33
-28
-25
-22
-21

Listing 9-6Output from TestFXASCore execution

我又跑了TestFXASCore。图 9-1 显示了在相同条件下,激活后立即开始的输出(200 个值或 4 秒)图。请注意两个重要特征:

img/506025_1_En_9_Fig1_HTML.jpg

图 9-1

激活后立即绘制 Z 轴原始值

  • 有一个从 0 秒到大约 0.1 秒的时间段,在该时间段内,器件在产生零速率偏移值附近的值之前似乎“预热”。

  • 即使在“预热”结束后,实际值仍然存在显著的随机性。

图 9-2 显示了在图 9-1 显示的数据之后的 200 个样本,用于相同的时期和配置。没有“预热”,但它也显示了值的随机性。平均值= -32.3,用粗水平线表示;平均值估计该时间段的零速率偏移(为了简洁起见,我此后将使用“零偏移”)。标准差= 7.9。图 9-2 中绘制的对角线显示了数值的趋势线;这表明零点偏移会随时间变化,甚至在 4 秒的时间内也会变化。不是好消息。

img/506025_1_En_9_Fig2_HTML.png

图 9-2

“预热”后很久的 Z 轴原始值图

清单 9-6 和图 9-1 和 9-2 中的结果清楚地表明,您不能将 Z 原始值或其转换后的对应值视为“真实值”在相信这些数据是“真实的”之前,你必须对它们做大量的工作。我将在本章的后面讨论这项工作。

正如您所料,X 轴和 Y 轴在零失调和噪声方面的表现与 Z 轴相同。因此,如果全部使用,就必须处理所有三个轴的零失调和噪声。

尽管如此,你有数据证明你有一个正常工作的陀螺仪!你只是有更多的工作要做。

Tip

我使用电子表格应用程序创建了数字 9-1 、 9-2 以及本章后面的其他数字。我发现它是使用传感器时帮助可视化传感器数据的一个有价值的工具。

其他想法

前面的讨论提到了核心之外的一些有趣的功能。本节详细阐述。

获取 Z 轴

清单 9-7 显示了读取 Z 轴数据的方法的实现,减少了读取的数据量。像readRaw(列表 9-4 )一样,方法readRawZ不读取状态并返回原始数据。

public int readRawZ() throws RuntimeIOException {
    // read only the Z axis
    byte[] buffer = new byte[2];
    device.readI2CBlockData(
            Registers.OUT_Z_MSB.register,
            buffer);

    // construct the int data
    int value = buffer[0] << 8;
    value = value |
            Byte.toUnsignedInt(buffer[1]);
    return value;
}

Listing 9-7Method to read Z-axis data from the FXAS21002C

状态

为了实现数据就绪和读取数据之间的同步,必须访问器件状态。因为可能需要所有三个轴的信息或者只需要 Z 轴的信息,所以我将创建单独的方法。清单 9-8 显示了状态方法。私有方法isDataReady读取 DR_STATUS 寄存器并检查适当的状态位;一个wait参数指示是否等待数据准备就绪。公共方法isXYZReadyisZReady分别使用所有三个轴或仅 Z 轴的适当状态位来调用isDataReady

public boolean isXYZReady(boolean wait)
        throws  RuntimeIOException {
    return isDataReady(0x08, wait);
}

public boolean isZReady(boolean wait)
        throws RuntimeIOException {
    return isDataReady(0x04, wait);
}

private boolean isDataReady(int type, boolean wait)
        throws RuntimeIOException {
    do {
        byte status =
               device.readByteData(
               Registers.DR_STATUS.register);
        if ((status & type) > 0) {
            return true;
        }
    } while (wait);
    return false;
}

Listing 9-8FXAS21002C data-ready status methods

Caution

isDataReady中的等待循环是而不是系统友好的。最初,我在寄存器读取之间编码了一个 250 微秒的休眠。但是,在我的 Pi3B+上,显然 Java 睡眠时间最少为一毫秒。我觉得太长了,放弃了睡觉。你可能想睡觉。在本章的后面,我将向您展示如何消除使用isDataReady的需要。

清单 9-9 显示了TestFXASCore_S,一个测试状态的主类。它以TestFXASCore为基础,但只做一组读数。此外,私有方法readZ使用适当的状态方法来门控读取 Z 轴数据,并计算样本之间的时间周期。

FXAS21002C 数据手册指出,读取适当的数据后,状态位会被清零。为了确保readZ中的循环不会立即看到就绪状态,该方法在进入循环之前进行初始读取。

package org.gaf.pimu.test;

import com.diozero.api.RuntimeIOException;
import com.diozero.util.Diozero;
import com.diozero.util.SleepUtil;
import java.io.IOException;
import org.gaf.pimu.FXAS21002C;

public class TestFXASCore_S {

    private static FXAS21002C device;

    public static void main(String[] args) throws
           IOException, InterruptedException {

        try (FXAS21002C device = new FXAS21002C()) {

            device.begin(
                FXAS21002C.LpfCutoff.Lowest,
                FXAS21002C.ODR.ODR_50);

            int num = Integer.valueOf(args[0]);

            readZ(device, num);
        } finally {
            Diozero.shutdown();
        }
    }

    private static void readZ(FXAS21002C device,
           int num)
           throws RuntimeIOException {
        long tCurrent, tLast, tDelta;
        tLast = System.nanoTime();
        device.readRawZ();
        for (int i = 0; i < num; i++) {
            device.isZReady(true);
            tCurrent = System.nanoTime();
            tDelta = (tCurrent - tLast) / 100000;
            tLast = tCurrent;
            int z = device.readRaw();
            System.out.println(z +
                    ", " + tDelta);

            SleepUtil.sleepMillis(15);
        }
    }
}

Listing 9-9TestFXASCore_S

TestFXASCoreTestFXASCore_S中的“读取”方法中还有另一个非常重要的差异。在两次读取之间,前者休眠 20 毫秒(ms)的采样周期,后者休眠 15 ms,然后等待就绪状态。为什么要睡(相对)长的时间?您不希望在数据不可能准备就绪时浪费 CPU 周期来读取状态。基本上,readZ方法休眠 15 毫秒,在此期间释放 CPU,然后花费大约 5 毫秒读取状态,等待数据就绪,然后读取数据。

我是怎么到达 15 毫秒的?我作弊了。在运行TestFXASCore_S时,我启动了示波器,监测发送到 PIMU 分线点的 I2C 时钟信号。图 9-3 显示了结果。在图中,顶部的信号是 I2C 时钟(以 100 kHz 运行)。所示的时间跨度(24 ms)包括略多于一个完整周期。在左侧,您可以看到时钟在运行,同时读取状态,然后读取数据。然后,您可以看到大约 15 ms 的延迟,然后是另一段读取状态的第一部分。因此,15 ms 似乎可以保证状态读取在数据准备就绪之前开始,这意味着数据读取之间的时间间隔始终为 20 ms。

img/506025_1_En_9_Fig3_HTML.jpg

图 9-3

TestFXASCore_S 期间的 I2C 时钟信号

然而,我最初在TestFXASCore_S中使用的是 18 ms 睡眠。我发现,有时候,多任务的树莓派操作系统中时间的不确定性会导致“失误”,以至于有时候数据读取得太晚,已经被覆盖了。虽然我没有广泛的实验,15 毫秒似乎足以避免失误。

无论如何,列表 9-10 显示了在 PIMU 静止的情况下运行TestFXASCore_S的最初几个结果。一行中的第一个数字是 Z 轴值,第二个数字是读数之间的时间间隔,以十分之一毫秒为单位。

-185, 62
-37, 911
-37, 166
-37, 166
-37, 166
-38, 191
-40, 200
-43, 200
-43, 200
-40, 200

Listing 9-10TestFXASCore_S results

您可以看到,在配置几个读数后,在 Z 值和读数之间的时间方面,立即发生了一些奇怪的事情。在图 9-1 中部分观察到了这种现象。我不能 100%确定原因;我将在下一小节中讨论潜在的原因。在任何情况下,它都建议要么在配置后延迟,在begin本身的额外延迟,或在认真使用设备之前扔掉几个读数。

毕竟事件…

对状态的讨论和图 9-3 中显示的结果让我对 FXAS21002C 产生的“数据就绪”事件和中断产生了疑问。当数据准备好时获得中断,而不是轮询,将节省大量 CPU 周期。

数据手册显示,配置“数据就绪”中断非常简单(参见 CTRL_REG2 描述)。清除中断很容易(见 INT_SOURCE_FLAG 描述),尽管你必须读取所有三个轴才能清除。分线板上有 FXAS21002C 的中断引脚(参见 https://learn.adafruit.com/nxp-precision-9dof-breakout/downloads )。

硬件能够产生“数据就绪”信号,该信号可以发送到树莓派 GPIO 引脚。软件能把它当作一个中断并调用一个“中断处理程序”吗?在第七章中简要讨论的 diozero DigitalInputDevice支持“中断”和“中断处理程序”(我使用这些术语有点不严谨)!本小节说明如何在 FXAS21002C 的环境中使用中断。

启用中断的适当位置是在begin方法中。显然,您希望启用“数据就绪”中断。我决定使用陀螺仪中断引脚 1(分线板上的引脚 GI1),使中断信号高电平有效,并使用推挽输出驱动器(无需外部上拉或下拉电阻)。这意味着将 0x0E 写入 CTRL_REG2。为此,我向FXAS21002C添加了以下常量:

private static final byte DATA_READY_INTERRUPT = 0x0e;

为了实际写入值,我在写入 CTRL_REG0 和 CTRL_REG1 之间向begin方法添加了以下语句:

device.writeByteData(
        Registers.CTRL_REG2.register,
        DATA_READY_INTERRUPT);

一旦 FXAS21002C 产生中断,我们需要使用一个DigitalInputDevice来捕捉它们并调用一个中断处理程序。我相信这暗示了一个使用了一个 ?? 和一个 ?? 的 ?? 复合 ?? 设备。我将调用新的类Gyro,并将其放入包org.gaf.pimu

要设计一个中断处理程序,我们必须考虑如何使用来自 FXAS21002C 的数据以及DigitalInputDevice如何工作。对于我的漫游者,我想等待 Z 轴的样本,并从该样本中获得相对的航向DigitalInputDevice引入了并发性,因为中断处理程序在一个单独的线程中运行。因此,我们需要在中断处理线程和应用程序线程之间共享数据。此外,可以想象的是,应用程序有时可能无法像标题产生时那样快速地消费它们,所以共享 FIFO 队列会很好。一个非常方便的方法是使用实现了java.util.concurrent.BlockingQueue的类。中断处理程序可以将标题排队,应用程序可以等待队列中的标题。

不幸的是,我们还没有准备好计算方向的所有东西,所以最初,中断处理程序将简单地将样本的 Z 轴原始值排队。

清单 9-11 显示了初始的Gyro类。构造器需要一个参数来指示用于检测中断的 GPIO 引脚,以及一个参数来标识实现BlockingQueue的对象。构造器创建一个FXAS21002C和一个DigitalInputDevice为中断信号适当配置(无上拉或下拉,上升沿触发)。你也可以在构造器后看到强制的close方法。

package org.gaf.pimu;

import com.diozero.api.DigitalInputDevice;
import com.diozero.api.GpioEventTrigger;
import com.diozero.api.GpioPullUpDown;
import com.diozero.api.RuntimeIOException;
import com.diozero.util.SleepUtil;
import java.io.IOException;
import java.util.concurrent.BlockingQueue;

public class Gyro implements AutoCloseable {

    private final BlockingQueue queue;
    private FXAS21002C fxas = null;
    private DigitalInputDevice catcher = null;
    private FXAS21002C.ODR odr;

    private long tsLast;
    private boolean active = false;

    public Gyro(int interruptPin,
            BlockingQueue queue) throws IOException {
        this.queue = queue;
        this.fxas = new FXAS21002C();
        try {
            catcher = new DigitalInputDevice(
                    interruptPin,
                    GpioPullUpDown.NONE,
                    GpioEventTrigger.RISING);
        } catch (RuntimeIOException ex) {
            throw new IOException(ex.getMessage());
        }
    }

    @Override
    public void close() {
        System.out.println("Gyro close");
        if (fxas != null) {
            fxas.close();
            fxas = null;
        }
        if (catcher != null) {
            catcher.close();
            catcher = null;
        }
    }

    public void begin(
            FXAS21002C.LpfCutoff lpfCutoff,
            FXAS21002C.ODR odr)
            throws RuntimeIOException {
        this.odr = odr;
        fxas.begin(lpfCutoff, odr);
    }

    public void activateIH()
            throws RuntimeIOException {
        fxas.readRaw();
        queue.clear();
        tsLast = 0;
        this.active = true;
    }

    public void activateRaw()
            throws RuntimeIOException {
        catcher.whenActivated(this::queueRaw);
        activateIH();
    }

    public void deactivate() {
        this.active = false;
    }

    private void queueRaw(long timestamp)
            throws RuntimeIOException {
        if (active) {
            int[] xyz = fxas.readRaw();
            long tsDelta = timestamp - tsLast;
            tsLast = timestamp;
            long[] sample = {xyz[2], tsDelta};

            // queue it if queue not full
            if (!queue.offer(sample))
                System.err.println("Queue Full!");
        }
    }
}

Listing 9-11Gyro

Gyro.begin类似于FXAS21002C.begin方法。实际上,前者使用后者,并简单地传递配置参数。

activateRawactivateIHdeactivate方法值得详细阐述一下。没有理由从 FXAS21002C 连续读取数据;只有当漫游车移动时才需要。这些方法允许打开和关闭数据收集。activateRaw方法支持简单的数据收集,deactivate禁用它。activateRaw注册简单中断处理程序(方法queueRaw)并调用activateIH,后者读取原始数据以清除任何中断,清空队列,清除上一次中断的时间,并将中断处理程序状态设置为活动。

queueRaw方法一个中断处理程序。在这个简单的实现中,它读取一个原始样本,找到这个样本和上一个样本之间的时间段,从这些数据片段中创建一个数组,并对该数组进行排队。我必须承认,我对排满队的反应有点迟钝;一个更合适的动作可能是抛出一个RuntimeIOException,但我认为这有点激烈;你应该分析你的项目,以确定适当的反应。

在这一点上你可以合理地问“为什么不像TestFXASCore_S那样打印queueGyro中的数据?”很高兴你问了。最初,我确实打印了,但是在运行TestFXASCore_S的结果讨论(列出 9-10 )中发现的异常出现在执行queueRaw的结果中。经过广泛的调查,我发现印刷是造成某些异常行为的原因!因此,我决定简单地将收集到的数据排队(就像在生产中所做的那样)并在以后打印出来。

清单 9-12 显示了用于测试Gyro的主类TestGyro。它首先创建一个队列;很明显,我选择了ArrayBlockingQueue,但是也有其他候选元素以先进先出的方式排序。TestGyro然后创建一个新的Gyro,配置它,激活中断处理程序,收集并打印 100 个数据样本。

在运行TestGyro之前,您必须将 PIMU GI1 中断引脚连接到树莓派 GPIO 引脚,以便使用DigitalInputDevice对其进行监控。我用的是 GPIO 18。同样,我建议在建立连接之前关闭 Pi。

package org.gaf.pimu.test;

import com.diozero.util.Diozero;
import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import org.gaf.pimu.FXAS21002C;
import org.gaf.pimu.Gyro;

public class TestGyro {

    public static void main(String[] args)
            throws IOException,
            InterruptedException {
        ArrayBlockingQueue queue =
            new ArrayBlockingQueue(10);

        try (Gyro gyro = new Gyro(18, queue)) {

            gyro.begin(FXAS21002C.LpfCutoff.Lowest,
                FXAS21002C.ODR.ODR_50);

            gyro.activateRaw();

            for (int cnt = 0; cnt < 100; cnt++) {
                long[] sample = (long[])
                    queue.take();
                System.out.println(sample[0] + ", " +
                    sample[1]/100000);
            }

            gyro.deactivate();
        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 9-12TestGyro

清单 9-13 显示了在 PIMU 静止的情况下运行TestGyro的输出片段。您可以看到设备激活后的前几个数据值(3 到 4)是假的;这与之前观察到的行为一致。时间也显示了一些异常的行为。第一次实际上只是时间戳,因为第一次读数没有时间参考。第二个和第三个读数关闭;我相信这是因为设备何时稳定以及所有 diozero 类何时加载的不确定性。随后的时间值看起来和预期的一样。附加测试证实,数据值异常仅在装置激活后立即出现。最终结果是激活后最初几个读数中的数据值和时间必须忽略

-122, 16167848657463
-122, 510
-75, 800
-47, 200
-37, 200
-37, 200
-34, 200
-31, 200
-28, 200
-25, 200
-25, 200
-28, 200
-33, 200
-35, 200
-38, 200

Listing 9-13Output from TestGyro

清单 9-13 中另一个明显的要点是,在异常时期之后,读数发生的规律性。读取之间的平均时间至少与睡眠和读取状态以等待数据就绪时一样好。

图 9-4 显示了运行TestGyro时捕获的示波器图像。顶部的信号是 I2C 钟。底部信号是来自设备的中断信号。请注意,中断以 20 ms 的间隔发生(在测量能力范围内),正如预期的那样(我的示波器测量值为 49.985 Hz)。此外,I2C 时钟脉冲的突发显示,读取数据仅发生在中断处理程序被调用以读取三个轴的数据之后。

img/506025_1_En_9_Fig4_HTML.jpg

图 9-4

运行测试陀螺仪时的中断信号和 I2C 时钟信号

与中断处理相关的最后一个主题如图 9-5 所示。中断信号和中断处理程序readPrintGyroRaw开始读取数据之间的时间间隔略小于 400 微秒。还不错!此外,您可以看到读取数据花费了 800 多微秒。还是那句话,还不错!

img/506025_1_En_9_Fig5_HTML.jpg

图 9-5

中断延迟和数据读取时间

Note

完成本章后,我决定按照第七章中的说明,尝试将 I2C 时钟频率从默认的 100 kHz 更改为 400 kHz。它对我的树莓派 3 B+有效。正如预期的那样,后来,它花了 200 多微秒来读取数据,而图 9-5 中花了 800 微秒。

解决零失调和噪声问题

我在本章前面说过,原始数据是不可靠的,因为有零偏置和噪声。在这一小节中,我将讨论处理这两个问题的一些步骤。

零偏移

在理想条件下,零失调补偿似乎非常简单。你多次取样并计算平均值,如图 9-2 所示。然后当你得到一个读数,你减去零偏移。事实上,我找到的一篇关于这个主题的文章(见 https://sensing.honeywell.com/auto-zero-calibration-technique-pressure-sensors-technical-note.pdf )正是这么做的。

然而,图 9-2 显示实际情况并不理想。首先,存在高频噪声,表现为平均值附近的差异很大。第二,有低频噪音,趋势线表示。尽管进行了大量的搜索,但除了取一个长时间的平均值之外,我找不到任何可以解释现实世界条件的东西。

图 9-6 显示了 20 秒内读取的 Z 轴原始数据。粗水平线代表 20 秒内的平均值(-33.3,标准偏差为 6.2)。在大约-30°和-37°之间弯曲的粗“信号”代表 2 秒钟的运行平均值(100 个读数)。

img/506025_1_En_9_Fig6_HTML.jpg

图 9-6

z 轴原始数据

我想知道需要多少读数才能很好地估计零点偏移。图 9-7 显示了不同时间段的运行平均值图。数字 1 显示两秒的时间,与图 9-6 相同。数字 2 显示 4 秒。数字 3 显示八秒。如图 9-7 ,粗水平线显示的是 20 秒平均值。

img/506025_1_En_9_Fig7_HTML.jpg

图 9-7

Z 轴的各种平均零点偏移

图 9-7 中的结果表明,没有确定零点偏移的完美时间段。即使 8 秒的平均周期产生的值也可能与长期平均值相差很大。当然,20 秒平均值可能会在接下来的 20 秒内发生变化。因此,我认为有两种方法可以找到零点偏移:

  • 当调出 FXAS21002C 时,读取一段“长”时间,并在可能持续数小时的试验期间,对设备使用的所有实例使用平均零偏移。不清楚“长”是多长。

  • 在试验期间每次使用设备之前,读取一段“合理的”时间,并仅在试验期间使用该设备时使用平均零点偏移。不清楚“合理”的时间有多长。

我不得不承认我不确定最好的方法。也就是说,对于我的漫游者,我使用的是第二种。

清单 9-14 显示了对Gyro的更改,以解决零偏移问题。您可以看到一些新的变量:大量原始读数的累积、读数的总和,以及最终计算出的零点偏移。特别注意常量BAD_DATA;它用于在计算零点偏移时跳过潜在的异常读数。

中断处理程序accumulateRaw累积大量原始数据样本;它在累积时跳过“坏数据”。方法calcZeroOffset初始化所需的变量,然后激活中断处理程序;它等待 3 一段指定时间的原始数据累积,然后计算零点偏移以备后用。请注意,我在calcZeroOffset中留下了两张调试图,以帮助理解结果。

private static final int BAD_DATA = 5;
private final long[] acc = new long[3];
private int total;
private final float[] zeroOffset = new float[3];

private void accumulateRaw()
        throws RuntimeIOException {
    if (active) {
        int[] xyz = device.readRaw();

        if (total >= BAD_DATA) {
            acc[0] += xyz[0];
            acc[1] += xyz[1];
            acc[2] += xyz[2];
        }
        total++;
    }
}

public void calcZeroOffset(int period)
        throws RuntimeIOException {

    acc[0] = 0;
    acc[1] = 0;
    acc[2] = 0;
    total = 0;

    catcher.whenActivated(this::accumulateRaw);
    activateIH();

    // sleep to gather raw data
    SleepUtil.sleepMillis(period);
    deactivate();

    // calculate the zero offsets
    float denom = (float) (total - BAD_DATA);
    zeroOffset[0] = (float) acc[0] / denom;
    zeroOffset [1] = (float) acc[1] / denom;
    zeroOffset [2] = (float) acc[2] / denom;
    System.out.println("Total = " + denom);
    System.out.format("Zero offsets: z=%f ",
        zeroOffset[2]);
}

public void activateZO() throws RuntimeIOException {
        catcher.whenActivated(this::queueO);
        activateIH();
}

public void queueO(long timestamp)
        throws RuntimeIOException {
    if (active) {
        int[] xyz = fxas.readRaw();

        long tsDelta = timestamp - tsLast;
        tsLast = timestamp;

        long z =  xyz[2] - (long) zeroOffset[2];
        long[] sample = {z, tsDelta};

        // queue it if queue not full
        if (!queue.offer(sample))
            System.err.println("Queue Full!");
    }
}

Listing 9-14Gyro changes for zero offset

方法activateZO激活一个应用零偏移的中断处理程序。中断处理器queueO将零偏移应用于原始读数;它将调整后的 Z 轴值排队。

清单 9-15 显示了测试Gyro变化的主类TestGyro_ZO。因为它源自TestGyro,所以我省略了相同的包和导入语句。TestGyro_ZO的操作与TestGyro非常相似,除了

  • 在处理原始数据之前,它用四秒钟时间累计原始数据来计算零偏置。

  • 它激活queueO,在对样本进行排队之前应用零点偏移。

public class TestGyro_ZO {

    public static void main(String[] args)
           throws IOException, InterruptedException {
        // set up queue
        ArrayBlockingQueue queue =
            new ArrayBlockingQueue(10);

        try ( Gyro gyro = new Gyro(18, queue)) {

            gyro.begin(FXAS21002C.LpfCutoff.Lowest,
                FXAS21002C.ODR.ODR_50);

            System.out.println(
                "\n... Calculating offset ...\n");
            gyro.calcZeroOffset(4000);

            gyro.activateZO();

            for (int cnt = 0; cnt < 100; cnt++) {
                long[] sample =
                    (long[]) queue.take();
                System.out.println(sample[0] + ", " +
                    sample[1] / 100000);
            }

            gyro.deactivate();
        } finally {
            Diozero.shutdown();
        }
    }
} 

Listing 9-15TestGyro_ZO

图 9-8 显示了在 PIMU 静止的情况下,绘制四秒钟零偏校正数据的结果。在这个四秒钟的图之前,Z 轴的零偏移计算值为-33.4。您可以看到,虽然绘制的数据以 0 为中心,但单个读数存在显著的可变性,实际上是 噪声 。下一小节讨论噪声。

img/506025_1_En_9_Fig8_HTML.jpg

图 9-8

零偏移校正的 Z 轴数据

噪音

如前所述,来自 FXAS21002C 的数据表示角度变化率,单位为/秒。在图 9-8 中,粗水平线显示数据的平均值= -0.85(标准偏差= 6.6)。请记住,数据是在 PIMU 静止时捕获的。但是,通过使用灵敏度系数(7.8125 mdps/LSB)和时间周期(4 秒)从平均值计算报告的角度,会导致结束角度为-0.026,而不是 0!

这听起来可能不是一个大错误,对于一些项目来说,这可能是可以接受的。然而,对于我的漫游者,我需要在长达 25 秒的时间内测量零点几度的角度。你可以看到噪音肯定是个问题。

我决定实施一种死区方法来限制噪音的影响。注意,图 9-8 中从 0 开始的最大变化大约是 16,或者大约 3 个标准偏差。所以,我决定把死亡地带定为 20。

列表 9-16 显示了对Gyro的补充,以说明死区。中断处理器queueOD确定零偏移校正值是否在死区内。如果是,则假定该值为零;如果不是,则假定它是真实的,并按原样传递。方法activateZODZ激活queueOD

这种简单的方法显然有局限性。例如,前面的讨论表明,零点偏移会随时间而变化。此外,即使是指示真实运动的值也会受到噪声的影响。欢迎来到现实世界。

private static final long DEAD_ZONE = 20;

public void activateZODZ() throws RuntimeIOException {
    catcher.whenActivated(this::queueOD);
    activateIH();
}

public void queueOD(long timestamp)
        throws RuntimeIOException {
    if (active) {
        int[] xyz = fxas.readRaw();

        long tsDelta = timestamp - tsLast;
        tsLast = timestamp;
        long z =  xyz[2] - (long) zeroOffset[2];

        if ((-DEAD_ZONE <= z) && (z <= DEAD_ZONE)) {
             z = 0;
        }

        long[] sample = {z, tsDelta};

        // queue it if queue not full
        if (!queue.offer(sample))
            System.err.println("Queue Full!");
    }
}

Listing 9-16Gyro dead-zone implementation

为了测试死区方法,在TestGyro_ZO中,我简单地用gyro.activateZODZ代替了gyro.activateZO。在 PIMU 静止状态下运行,在 4 秒钟的捕获周期之前产生了-34.8°的零偏。在整个测试过程中,显示的校正数据如预期的那样为 0。

Note

针对死区的一种可能的更好方法是,不仅计算零偏移,而且从相同的数据集计算标准偏差,并使死区大约为 3 个标准偏差。我懒得做那件事,但你可能不会。也可以考虑把死区的大小做成activateZODZ上的一个参数。

现实点吧

所有之前的工作对于理解 FXAS21002C 和“让它有所作为”都很有帮助。然而,这项工作没有产生可用于校正航向误差的相对航向角

使用积分从设备产生的数据计算当前航向角,这个缺点很容易解决。计算涉及设备的灵敏度和采样周期。

清单 9-17 显示了对Gyro的补充,以支持航向计算。有一些支持计算的新字段。中断处理程序queueHeading积分计算当前航向角,然后将结果放入队列。方法activateHeading激活queueHeading

private float angle;
private float sensitivity;
private float period;

public void activateHeading(FXAS21002C.Range range)
        throws RuntimeIOException {
    // initialize
    angle = 0;
    sensitivity = range.sensitivity;
    period = 1 / odr.odr;
    System.out.println("sensitivity = " +
        sensitivity + " period = " + period);

    // identify interrupt handler
    catcher.whenActivated(this::queueHeading);

    activateIH();
}

public void queueHeading(long timestamp)
        throws RuntimeIOException {
    if (active) {
        int[] xyz = fxas.readRaw();
        float z =  (float) xyz[2] –
            zeroOffset[2];
        if ((-DEAD_ZONE <= z) && (z <= DEAD_ZONE)) {
             z = 0;
        }
        // integrate
        angle += (z * sensitivity) * period;

        // put the angle in queue if queue not full
        if (!queue.offer(angle))
            System.err.println("Queue Full!");
    }
}

Listing 9-17Gyro heading implementation

为了测试航向计算,我创建了清单 9-18 中所示的TestGyro_Heading。因为它是从TestGyro_ZO派生的,所以我省略了相同的包和导入语句。TestGyro_Heading的操作与TestGyro_ZO非常相似,除了

  • 它用activateHeading代替activateZODZ

  • 它打印的是相对航向,而不是校正后的样本。

public class TestGyro_Heading {
    public static void main(String[] args)
           throws IOException, InterruptedException {
        ArrayBlockingQueue queue =
            new ArrayBlockingQueue(10);

        try ( Gyro gyro = new Gyro(18, queue)) {

            gyro.begin(FXAS21002C.LpfCutoff.Lowest,
                FXAS21002C.ODR.ODR_50);

            System.out.println(
                "\n... Calculating offset ...\n");
            gyro.calcZeroOffset(4000);

            gyro.activateHeading(
                FXAS21002C.Range.DPS250);

            for (int cnt = 0; cnt < 5000; cnt++) {
                float heading = (float) queue.take();
                System.out.println(heading);
            }

            gyro.deactivate();
        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 9-18TestGyro_Heading

为了测试航向计算,我将 PIMU 安装在试验板上运行TestGyro_Heading,这样我可以手动将组件逆时针旋转大约 90°,然后顺时针旋转大约 180°,最后逆时针旋转 90°(回到 0°)。图 9-9 显示了结果。当然,剧情从 0 开始;达到 90.8;它经过 0,到达-89.4,最后回到 0.2。考虑到旋转的手动性质,一点也不差!

img/506025_1_En_9_Fig9_HTML.jpg

图 9-9

手动旋转期间的航向角

Caution

如果你一直在仔细阅读,你可能会在Gyro.queueHeading中发现更多懒惰的编码。该方法假设采样间隔是理想值(输出数据速率的倒数)。你不能真的假设;最好实际测量样本之间的时间周期,并将其用于积分。好消息:列表 9-13 表明测量的时间周期至少接近理想值。但是为了最大限度的精确,你应该使用测量的时间周期。

接下来呢?

您现在已经看到了复杂和挑剔的设备典型的设备库的增量开发。您已经了解了如何

  • 使用FXAS21002C中的方法从设备中读取数据,并“猜测”何时读取数据——非常低效

  • 使用状态信息“指导”何时读取,从设备中读取数据—效率低下

  • 使用中断来“知道”何时读取,从而高效地从设备读取数据——非常高效

  • 校正零点偏移和噪声,并使用积分和排队来产生有用和及时的信息

综上所述,你可以正确地断言这个库是不完整的。以前的工作大多只涉及 Z 轴。大多数工作必须被认为是“学习”或“调试”的范畴为设备配置所做的选择可能不符合您的需要。

接下来的步骤取决于你想如何使用这个设备,显然,我无法知道。现在,我们将离开 FXAS21002C,研究 FXOS8700CQ。

设备库开发(FXOS8700CQ)

对于 FXOS8700CQ,我将在与FXAS21002C相同的项目和包中创建一个类FXOS8700CQ。我们将按照与FXAS21002C相同的方式开发FXOS8700CQ,根据需要进行不同的开发。

我们将跳过播放,因为设备是如此相似,并开始建立核心。我们将首先处理寄存器地址、常量等。,自有余为FXAS21002C。参见清单 9-19 。同样,现有的 Java 类有一些很好的代码,展示了一些可以用作模型的“最佳实践”。和FXAS21002C一样,我对常量定义做了一些清理。根据本章前面的讨论,我还从Registers枚举中删除了许多寄存器,以反映最小化方法。

package org.gaf.pimu;

import com.diozero.api.I2CDevice;
import com.diozero.api.RuntimeIOException;
import com.diozero.util.SleepUtil;
import java.io.IOException;

public class FXOS8700CQ implements AutoCloseable {

    private enum Registers {
        STATUS(0x00),
        OUT_X_MSB(0x01),
        OUT_X_LSB(0x02),
        OUT_Y_MSB(0x03),
        OUT_Y_LSB(0x04),
        OUT_Z_MSB(0x05),
        OUT_Z_LSB(0x06),

        WHO_AM_I(0x0D),
        XYZ_DATA_CFG(0x0E),

        CTRL_REG1(0x2A),
        CTRL_REG2(0x2B),
        CTRL_REG3(0x2C),
        CTRL_REG4(0x2D),
        CTRL_REG5(0x2E),
        OFF_X(0x2F),
        OFF_Y(0x30),
        OFF_Z(0x31),
        M_DR_STATUS(0x32),
        M_OUT_X_MSB(0x33),
        M_OUT_X_LSB(0x34),
        M_OUT_Y_MSB(0x35),
        M_OUT_Y_LSB(0x36),
        M_OUT_Z_MSB(0x37),
        M_OUT_Z_LSB(0x38),

        M_CTRL_REG1(0x5B),
        M_CTRL_REG2(0x5C),
        M_CTRL_REG3(0x5D);

        public final int register;

        Registers(int code) {
            this.register = code;
        }
    }

    public enum AccelRange
    {
        RANGE_2G(0x00),
        RANGE_4G(0x01),
        RANGE_8G(0x02);

        public final int rangeCode;

        AccelRange(int rangeCode) {
            this.range = rangeCode;
        }
    }

    public enum ODR {
        ODR_800(0),
        ODR_400(1),
        ODR_200(2),
        ODR_100(3),
        ODR_50(4),
        ODR_12_5(5),
        ODR_06_25(6),
        ODR_01_56(6);

        public final int odrCode;

        ODR(int odrCode) {
            this. odrCode  = odrCode;
        }
    }

    public enum ReadSpeed {
        Normal(0 << 1),
        Fast(1 << 1);

        public final int speedCode;

        private ReadSpeed(int speedCode) {
            this.speedCode = speedCode;
        }
    }

    public enum NoiseMode {
        Normal(0 << 2),
        Reduced(1 << 2);

        public final int noiseCode;

        private NoiseMode(int noiseCode) {
            this.noiseCode = noiseCode;
        }
    }

    public enum AccelOSR {
        Normal(0),
        LowNoiseLowPower(1),
        HighResolution(2),
        LowPower(3);

        public final int osrCode;

        AccelOSR(int osrCode) {
            this. osrCode = osrCode;
        }
    }

    public enum PowerState {
        StandBy(0),
        Active(1);

        public final int stateCode;

        PowerState(int stateCode) {
            this.state = stateCode;
        }
    }

    public enum OperatingMode {
        OnlyAccelerometer(0),
        OnlyMagnetometer(1),
        HybridMode(3);

        public final int mode;

        OperatingMode(int mode) { this.mode = mode; }
    }

    private static final int HYBRID_AUTO_INC = 0x20;
    private static final int RESET = 0x40;

    public enum MagOSR {
        R0(0 << 2),
        R1(1 << 2),
        R2(2 << 2),
        R3(3 << 2),
        R4(4 << 2),
        R5(5 << 2),
        R6(6 << 2),
        R7(7 << 2);

        public final int osrCode;

        MagOSR(int osrCode) {
            this.osrCode = osrCode;
        }
    }
}

Listing 9-19FXOS8700CQ

构造器分析和实现

FXOS8700CQ构造器的分析与对FXAS21002C的分析相同,因此,结果非常相似。清单 9-20 显示了FXOS8700CQ构造器和类中使用的一些额外的常量。既然类实现了AutoCloseable,那么还有一个close方法。

private static final int FXOS8700CQ_ADDRESS = 0x1F;
private static final byte FXOS8700CQ_ID = (byte) 0xC7;

private I2CDevice device = null;

public FXOS8700CQ() throws IOException {
    try {
        device = I2CDevice.builder(
            FXOS8700CQ_ADDRESS).build();
        byte whoID = device.readByteData(
                Registers.WHO_AM_I.register);
        if (whoID != FXOS8700CQ_ID) {
            throw new IOException(
                "FXOS8700CQ not found at address " +
                FXOS8700CQ_ADDRESS);
        }
    } catch (RuntimeIOException ex) {
        throw new IOException(ex.getMessage());
    }
}

@Override
public void close() {
    if (device != null) device.close();
}

Listing 9-20FXOS8700CQ constructor

配置

FXOS8700CQ 比 FXAS21002C 更复杂。因此,FXOS8700CQ 配置更加复杂。此外,我必须承认我对 FXOS8700CQ 没有什么实际经验。我从以前的机器人那里了解到,我家地板上丰富的钢筋会对任何磁力计(或模拟指南针)造成严重破坏,使它在我的环境中一文不值。我用加速度计做了实验,但唯一感兴趣的维度(X)产生的加速度足够低,它消失在噪声中。因此,除了现有的库和一些与FXAS21002C的协同之外,我没有什么可以指导我的配置建议。

maximalist Java 库支持几乎所有配置,极简 C++库仅支持加速度计满量程范围。同样,总的想法是创建一个极简主义的图书馆。由于缺乏经验,我将使不可配置。当然,您的需求可能会有所不同。值得看一看数据表,并对合理的默认配置做出一些断言:

  • 加速度计使用 14 位分辨率,磁力计使用 16 位分辨率。

  • 使用过采样来优化分辨率。

  • 使用混合模式(采样加速度计和磁力计)。

  • 使用降低的噪音。

  • 不要使用任何事件,除了数据就绪。

  • 务必使能数据就绪中断(最终)。

  • 将输出数据速率配置为与陀螺仪相同。

  • 使用最佳加速度计满量程。

清单 9-21 显示了配置和激活 FXOS8700CQ 的类FXOS8700CQbegin方法。该方法首先重置设备,然后将设备置于待机模式,以便可以建立正确的配置。

public void begin() throws RuntimeIOException {
    // reset
    device.writeByteData(
            Registers.CTRL_REG1.register,
            PowerState.StandBy.stateCode);
    try {
        device.writeByteData(
                Registers.CTRL_REG2.register, RESET);
    } catch (RuntimeIOException ex) {
            // expected so do nothing
    }
    SleepUtil.sleepMillis(10);

    // go to standby state
    device.writeByteData(
            Registers.CTRL_REG1.register,
            PowerState.StandBy.stateCode);

    // set up high res OSR for mag, and hybrid mode
    int m_ctrl_reg1 = MagOSR.R7.osrCode |
           OperatingMode.HybridMode.mode;
    device.writeByteData(
           Registers.M_CTRL_REG1.register,
           m_ctrl_reg1);

    // set address increment to read ALL registers
    device.writeByteData(
            Registers.M_CTRL_REG2.register,
            HYBRID_AUTO_INC);

    // set accel sensitivity and no HPF
    device.writeByteData(
            Registers.XYZ_DATA_CFG.register,
            AccelRange.RANGE_2G.rangeCode);

    // set up high res OSR for accelerometer
    device.writeByteData(
            Registers.CTRL_REG2.register,
            AccelOSR.HighResolution.osrCode);

    // set ODR, normal read speed, and device ACTIVE
    int cntl_reg1 = ODR.ODR_100.odrCode |
            NoiseMode.Reduced.noiseCode |
            ReadSpeed.Normal.speedCode |
            PowerState.Active.stateCode;
    device.writeByteData(
            Registers.CTRL_REG1.register,
            cntl_reg1);
}

Listing 9-21FXOS8700CQ begin method

请注意在begin方法结束时设置的输出数据速率。它被设置为 100 赫兹。由于该器件配置为在混合模式下工作,实际输出数据速率将为 50 Hz,就像 FXAS21002C 一样。

读取样本和状态

如前所述,对于 FXOS8700CQ,我们将创建一种方法来读取加速度计和磁力计的原始数据(混合模式)。我们还将创建一个方法来读取“数据就绪”状态。

目标是在单个模块中读取加速度计和磁力计。有两种方法。第一个在 C++库中使用,从加速度计寄存器开始,然后是磁力计寄存器。第二种在现有 Java 库中使用,从磁力计寄存器开始,一直到加速度计寄存器的副本(参见数据手册第 14.14.3 节)。第一种方法可行,但数据手册没有明确说明加速度计和磁力计的结果是时间对齐的。数据手册指出,第二种方式的结果是时间对齐的,但对过采样速率配置有一些限制(参见 https://community.nxp.com/t5/Sensors/FXOS8700CQ-Time-aligned-accelerometer-magnetometer-measurements/td-p/345298 )。

清单 9-21 中的begin方法中的配置设置适用于任何一种方式。清单 9-22 中的readRaw方法实现了时间对齐的第二种方式。它读取并返回所有三个磁力计轴和所有三个加速计轴的原始数据。注意加速度计数据的 18 位移位;需要传播 14 位数据的符号。

public int[] readRaw() throws RuntimeIOException {
    // read the data from the device
    byte[] buffer = new byte[12];
    device.readI2CBlockData(
            Registers.M_OUT_X_MSB.register,
            buffer);

    // construct the response as an int[]
    int[] res = new int[6];
    // magnetometer
    res[0] = (int) (buffer[0] << 8);
    res[0] = res[0] | Byte.toUnsignedInt(buffer[1]);
    res[1] = (int) (buffer[2] << 8);
    res[1] = res[1] | Byte.toUnsignedInt(buffer[3]);
    res[2] = (int) (buffer[4] << 8);
    res[2] = res[2] | Byte.toUnsignedInt(buffer[5]);
    // accelerometer
    res[3] = (int) (buffer[6] << 8);
    res[3] = ((res[3] |
            Byte.toUnsignedInt(buffer[7]))
            << 18) >> 18 ;
    res[4] = (int) (buffer[8] << 8);
    res[4] = ((res[4] |
            Byte.toUnsignedInt(buffer[9]))
            << 18) >> 18 ;
    res[5] = (int) (buffer[10] << 8);
    res[5] = ((res[5] |
            Byte.toUnsignedInt(buffer[11]))
            << 18) >> 18 ;
    return res;
}

public boolean isDataReady(boolean wait)
        throws RuntimeIOException {
    do {
        byte status = device.readByteData(
                Registers.M_DR_STATUS.register);
        if ((status & 0x08) > 0) {
            return true;
        }
    } while (wait);
    return false;
}

Listing 9-22FXOS8700CQ methods readRaw and isDataReady

isDataReady方法必须检查磁力计的“数据就绪”状态,因为它控制时序。

测试核心

我们现在可以测试FXOS8700CQ核心实现了。我将在包org.gaf.pimu.test中创建一个新的主类TestFXOSCore

TestFXOSCore该怎么办?显然它必须实例化一个FXOS8700CQ(清单 9-20 )。它必须使用begin方法配置设备(列表 9-21 )。最后,它必须调用readRawisDataReady(清单 9-22 )。

列表 9-23 显示TestFXOSCore。它定义了一个私有方法readAM来多次读取所有六个轴并打印一个值。变量axis决定readAM是否打印磁力计或加速计的 X、Y 或 Z 轴(轴分别为 0、1、2、3、4 或 5)。

package org.gaf.pimu.test;

import com.diozero.api.RuntimeIOException;
import com.diozero.util.Diozero;
import com.diozero.util.SleepUtil;
import java.io.IOException;
import org.gaf.pimu.FXOS8700CQ;

public class TestFXOSCore {

    public static void main(String[] args)
           throws IOException, InterruptedException {

        try (FXOS8700CQ device = new FXOS8700CQ()) {

            device.begin();

            int num = Integer.valueOf(args[0]);

            int axis = 3;

            readAM(num, axis);
        } finally {
            Diozero.shutdown();
        }
    }

    private static void readAM(FXOS8700CQ device,
             int num, int axis)
             throws RuntimeIOException {
        int[] am;
        long tCurrent, tLast, tDelta;
        tLast = System.nanoTime();
        device.readRaw();
        for (int i = 0; i < num; i++) {
            device.isDataReady(true);
            tCurrent = System.nanoTime();
            tDelta = (tCurrent - tLast) / 100000;
            tLast = tCurrent;
            am = device.readRaw();
            System.out.println(am[axis] +
                  ", " + tDelta);

            SleepUtil.sleepMillis(15);
        }
    }

}

Listing 9-23TestFXOSCore

我在axis =3(加速度计 X 轴)和 PIMU 静止的情况下跑了TestFXOSCore。我得到了清单 9-24 中所示的输出;第一个数字是 X 轴输出,第二个数字是以十分之一毫秒为单位的时间增量。

106, 204
94, 936
92, 172
102, 171
107, 171
110, 179
106, 200
98, 205
97, 205
95, 201

Listing 9-24Output from TestFXOSCore execution for accelerometer x axis

在最初的几个读数中,您可以清楚地看到一些异常的计时行为。这似乎与陀螺仪的行为非常相似。然而,样本值中似乎没有任何异常。也就是说,您可以在样本值中看到零偏移和噪声。

我在沿着 X 轴来回移动设备的同时,再次运行了 200 个样本的TestFXOSCore。图 9-10 显示了结果的曲线图。该图清楚地显示了大约 100 LSB 的零失调。你可以很容易地看到+X 和-X 两个方向的加速度。

img/506025_1_En_9_Fig10_HTML.jpg

图 9-10

在+x 和-x 方向移动设备时的加速度计 X 轴

我还运行了显示 Y 和 Z 轴的TestFXASCore。两者的行为方式与 X 轴相似,表现为零偏移(Y 轴约为-200,Z 轴约为 4220)、噪声以及正负加速度。

为了测试磁力计,在TestFXOSCore中,我用下面一行替换了打印由axis参数指示的轴的语句:

System.out.println(am[0] + ", " + am[1]);

该语句打印磁力计的 X 轴和 Y 轴。在设备静止的情况下,我得到了清单 9-25 中所示的结果。

439, 211
435, 219
436, 221
444, 219
436, 217
442, 217
430, 218
433, 219
436, 211
434, 219

Listing 9-25Magnetometer X and Y axes; stationary

显然,读数中存在零点偏移和一点明显的噪声。图 9-11 显示了装置逆时针旋转约 90 °,然后顺时针旋转 90°回到初始位置时 200 个读数的曲线图。上面的曲线是 X 轴,下面的曲线是 Y 轴。

img/506025_1_En_9_Fig11_HTML.jpg

图 9-11

磁力计 X 轴(顶部)和 Y 轴(底部);在运转中

再次事件

鉴于 FXAS21002C 中断的成功,将其用于 FSOX8700CQ 是有意义的。分线板上也有 FSOX8700CQ 的中断引脚。配置中断很容易(尽管您必须处理三个而不是一个寄存器)。清除中断很容易,只需读取数据即可。

清单 9-26 显示了添加到FSOX8700CQ中的代码,使用加速度计/磁力计中断 1(分线板上的引脚 AI1)配置数据就绪中断。列表 9-26 开头的常量显示了控制寄存器中的值。末尾的语句被添加到begin方法中,就在将设备置于待机状态的语句之后。

/* Configure interrupt : CNTL_REG3
-- active high
-- push-pull output driver
*/
private static final byte INTERRUPT_HIGH_PP = 0x02;
/* Configure interrupt : CNTL_REG4
-- on data ready
*/
private static final byte INTERRUPT_DATA_READY = 0x01;
/* Configure interrupt : CNTL_REG5
-- to pin 1
*/
private static final byte INTERRUPT_PIN1 = 0x01;

// configure a data ready interrupt
device.writeByteData(
       Registers.CTRL_REG3.register,
       INTERRUPT_HIGH_PP);
device.writeByteData(
        Registers.CTRL_REG4.register,
        INTERRUPT_DATA_READY);
device.writeByteData(
        Registers.CTRL_REG5.register,
        INTERRUPT_PIN1);

Listing 9-26Configuring interrupts in FSOX8700CQ

使用与 FXAS21002C 相同的论点,我建议创建一个复合设备。我将调用我的类AccelMag,如清单 9-27 所示。它的实现与清单 9-11 中Gyro的初始实现几乎相同。AccelMagGyro有以下不同:

  • 它使用FXOS8700CQ

  • FXOS8700CQ开始。begin没有配置参数,AccelMag.begin也没有。

  • 它对加速度计 X 轴进行排队。

package org.gaf.pimu;

import com.diozero.api.DigitalInputDevice;
import com.diozero.api.GpioEventTrigger;
import com.diozero.api.GpioPullUpDown;
import com.diozero.api.RuntimeIOException;
import java.io.IOException;
import java.util.concurrent.BlockingQueue;

public class AccelMag implements AutoCloseable {

    private final BlockingQueue queue;
    private FXOS8700CQ fxos = null;
    private DigitalInputDevice catcher = null;

    private long tsLast;
    private boolean active = false;

    public AccelMag(int interruptPin,
            BlockingQueue queue) throws IOException {
        this.queue = queue;
        this.fxos = new FXOS8700CQ();
        try {
            catcher = new DigitalInputDevice(
                    interruptPin,
                    GpioPullUpDown.NONE,
                    GpioEventTrigger.RISING);
        } catch (RuntimeIOException ex) {
            throw new IOException(ex.getMessage());
        }
    }

    @Override
    public void close() {
        if (fxos != null) {
            fxos.close();
            fxos = null;
        }
        if (catcher != null) {
            catcher.close();
            catcher = null;
        }
    }

    public void begin() throws RuntimeIOException {
        fxos.begin();
    }

    public void activateIH() throws
            RuntimeIOException {
        // read to clear interrupt status
        fxos.readRaw();

        // empty the queue
        queue.clear();

        // set active
        tsLast = 0;
        this.active = true;
    }

    public void activateRaw()
            throws RuntimeIOException {
        catcher.whenActivated(this::queueRaw);
        activateIH();
    }

    public void deactivate() {
        this.active = false;
    }

    private void queueRaw(long timestamp)
            throws RuntimeIOException {
        if (active) {
            int[] xyzxyz = fxos.readRaw();

            long tsDelta = timestamp - tsLast;
            tsLast = timestamp;
            long[] sample = {xyzxyz[3], tsDelta};

            // queue it if queue not full
            if (!queue.offer(sample))
                System.err.println("Queue Full!");
        }
    }
}

Listing 9-27AccelMag

清单 9-28 显示了测试AccelMag的主类TestAccelMag。毫不奇怪,它的实现与清单 9-12 中的TestGyro几乎相同。

在运行AccelMag之前,您必须将 PIMU AI1 中断引脚连接到树莓派 GPIO 引脚,以便使用DigitalInputDevice对其进行监控。我用的是 GPIO 18。同样,我建议在建立连接之前关闭 Pi。

Caution

我在TestGyroTestAccelMag中使用了相同的 GPIO 引脚。如果您同时使用GyroAccelMag,您必须使用两个不同的引脚。

package org.gaf.pimu.test;

import com.diozero.util.Diozero;
import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import org.gaf.pimu.AccelMag;

public class TestAccelMag {

    public static void main(String[] args)
           throws IOException, InterruptedException {
        ArrayBlockingQueue queue =
                new ArrayBlockingQueue(10);

        try (AccelMag am = new AccelMag(18, queue)) {

            am.begin();

            am.activateRaw();

            for (int cnt = 0; cnt < 100; cnt++) {
                long[] sample = (long[])
                    queue.take();
                System.out.println(sample[0] + ", " +
                   sample[1]/100000);
            }

            am.deactivate();
        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 9-28TestAccelMag

清单 9-29 显示了在 PIMU 静止的情况下运行TestAccelMag的输出片段。你可以看到最初的几个时间增量是异常的,但之后非常一致。在给定噪声的情况下,所有读数都在预期范围内。和 FXAS21002C 区别不大。好消息!

92, 16168669712057
92, 52
104, 1131
94, 202
84, 202
88, 202
94, 202
104, 202
...

Listing 9-29Output from TestAccelMag

与 FXAS21002C 一样,我使用示波器测量中断周期。我有点失望地发现,频率约为 49.3 赫兹,不像 FXAS21002C 那样接近 50 赫兹。我测量了中断延迟,结果是一样的(不奇怪)。我还测量了读取加速度计和磁力计数据的时间,大约为 1400 微秒(I2C 时钟为 100 kHz)。那太好了!

Caution

要使用加速度计,通常需要进行积分。注意在 FXAS21002C 中,样本之间的测量的时间段明显不同于根据输出数据速率计算的理想的时间段!您必须使用测量的时间周期进行积分。

下一步是什么?

现在,您已经开发了一个库,支持使用 FXOS8700CQ 的基础知识。也就是说,之前的工作表明,加速度计和磁力计都存在 FXAS21002C 陀螺仪提出的一些棘手问题:零失调和噪声(尽管噪声水平似乎低得多)。好消息是,您可以复制为 FXAS21002C 所做的工作,以“强化”来自 FXOS8700CQ 的数据。

坏消息是强化数据是容易的部分。有些问题你必须解决。这两款器件的哪些方面没有改变,比如配置灵活性、其他事件和 FIFO?FXOS8700CQ 未涉及的方面如何,例如磁力计的校准?这些都可能对你的项目很重要。

一个大问题是接口模型:您是否公开

  • 两个底层设备FXAS21002CFXOS8700CQ,还是一个底层设备PIMU,利用FXAS21002CFXOS8700CQ

  • 两个复合设备GyroAccelMag,还是一个复合设备PIMU,利用FXAS21002CFXOS8700CQ,或者GyroAccelMag

不管曝光什么,都是用前面说的 diozero IMU 相关类吗?

另一个大问题是,如何利用这些数据做一些有用的事情?我向您展示了如何使用陀螺仪 Z 轴来确定相对航向;它使用了一些琐碎的数学。您可以使用磁力计 X 轴和 Y 轴创建一个粗略的指南针,以提供绝对方向;这需要一些更复杂的数学以及一些校准工作。您可以使用加速度计、磁力计和陀螺仪来创建 AHRS(姿态和航向参考系统);这需要一些非常复杂的数学。后者因发现 FXAS21002C 和 FXOS8700CQ 的采样频率不完全相同而变得复杂。

从根本上来说,接下来你要做的是确定你对 PIMU 的需求,并据此进行。

摘要

在这一章中,我们已经讨论了很多内容。你不得不再次

  • 评估用于移植的现有设备库,有些是 Java 的!

  • 在多个库之间选择

  • 识别和评估移植问题并做出权衡

  • 随着新需求的出现,改进设计

您还学习了如何

  • 解决将 C++ Java 库移植到你的 Java 库的细节问题

  • 识别、调查和减轻设备或设备库的异常或特性,也就是魔鬼可以隐藏在细节中

  • 利用 diozero 的能力支持设备中断

  • 利用 Java 并发类处理简单的并发情况

我认为有必要强调一个关于开发 FXAS21002C 和 FXOS8700CQ 库的重要“经验教训”。很大一部分工作与从现有库到基于 diozero 的 Java 库的移植无关。这部分来自于对设备行为的理解和“驯服”,这样它们就能产生对项目有用的信息。当然,我指的是需要解决零失调、噪声和集成问题。但我也指的是需要及时有效地公开数据,这导致了中断和并发性的引入。这些方面在现有的库中都没有解决。记住这个教训,为下一个复杂的设备做准备。

*****

十、激光雷达装置

在这一章中,我们将检查我在自主漫游车中使用的另一个设备——支持定位和导航的激光雷达单元。附录 A2 描述了围绕 USB 连接的 Arduino 构建的简单的定制激光雷达单元。激光雷达单元是将任务从树莓派卸载到 Arduino 的真实例子,特别是为了利用微控制器的“近实时”特性。

在这一章中,我将介绍

  • USB 串行设备设备库的实现

  • 一个从头开始的设备库的设计与实现

  • 识别和解决各种设计问题

  • 设计的迭代

  • 处理原始数据,使其更有用

在继续之前,如有必要,您应该回顾一下第七章中的材料,其中涵盖了树莓派串行 I/O 功能和 diozero 串行设备支持。

Note

有趣的事实:附录 A2 中描述的激光雷达单元中使用的激光雷达传感器被用于美国宇航局 2020 年火星任务的一部分“创新”直升机。 www.sparkfun.com/news/3791

了解设备

本章中的激光雷达装置由附录 A2 中的激光雷达装置“数据表”部分描述。查看数据手册,您会发现激光雷达设备所展示的接口的一些有趣方面:

  • 该设备公开命令来执行或启动任务。

  • 一个命令由一个单字节类型和一个可选的双字节整数参数组成。命令隐含的任务可以返回零个或多个双字节整数。

  • 有些任务只需要在设备识别、测试或校准期间使用,而不需要在“生产”中使用

  • 有些任务会立即完成;有些是长时间运行

  • 产生的距离数据可以包括指示不同误差的代码;低于 100 厘米的真实范围是非线性的。

查找设备库

作为自定义设备,不可能找到激光雷达单元的现有设备库,因此无需搜索。因此,好消息或坏消息,你有一个干净的石板。你是如何进行的?下一节将回答这个问题。

设备库设计

在“从头开始”的情况下,我认为你应该从定义你的库的接口方法开始。然后,您可以寻找可用于实现接口级方法的通用方法。与任何设备一样,还有其他考虑因素。

连接

根据第八章的讨论,新库接口的最佳指导来源是“我的需求”下一个最好的是设备接口。由于我设计了激光雷达单元,这两个是相同的!有点作弊,但事实就是如此。

激光雷达单元库界面的一个好的起点是在相当简单的界面中为每个命令提供一个方法。对于某些设备来说,接口可能更复杂,需要更多的抽象。例如,有时需要与设备进行多次交互,以在设备库接口中实现合理的抽象。此外,您会发现有时需要几次迭代才能识别正确的设备库接口和所有私有方法。

数据手册指出,有些命令不需要,甚至可能不应该在生产中使用。您有两种选择:忽略命令的差异,让用户自己去了解差异,或者加入访问限制,这会使设计变得复杂。我将采用后一种方法,因为我觉得它很有趣。有一些技术可以在 Java 中适当地限制访问;一些立即浮现在脑海中的东西:

  1. 具有公共访问“生产”方法和默认访问“其他”方法的单个设备类。这种方法将任何“其他”方法限制在与设备类相同的中的主类。可能不是最好的解决方案,因为这些类被打包在库 jar 文件中。

  2. 具有公共访问“生产”方法和受保护访问“其他”方法的设备类。这种方法要求设备类的包之外的任何主类扩展设备类来访问“其他”方法。

  3. 一个只包含公共访问“生产”方法的设备“生产”类和一个扩展“生产”类并包含公共访问“其他”方法的“其他”子类。除了根据所使用的类,这种方法对包方法的访问没有任何限制。然而,“生产”类需要混合使用公共默认私有访问方法。

技术 2 和技术 3 之间没有很大的区别。我认为方法 2 是最有效的技术,我将在激光雷达单元设备库的实现中使用它。

查看数据表可以发现,一个只需要激光雷达设备提供的激光雷达扫描的“生产”项目只需要以下命令:

  • 获取伺服参数

  • 扫描

  • 扫描检索

  • 变暖

一些项目可能希望能够进行自定义扫描,或者进行简单的静态测距。如果是这样,“生产”项目还需要以下命令:

  • 设置伺服位置

  • 获取范围

幸运的是,使用技术 2,只需将访问级别从 protected 更改为 public,就可以非常容易地进行切换。在任一情况下,剩余的命令将是受保护访问的候选者。

常用方法

接下来呢?想想接口级方法做什么,希望找到一些可以封装成可重用私有方法的公共函数。通常情况下,你会发现中级方法,比如第八章中的那些方法,它们将使用第七章中描述的低级SerialDevice方法。

对于激光雷达单元,相关信息是发送到设备的命令的性质和返回信息的性质。基本上有两种通用命令形式:

  • 类型和参数(如回声参数、预热),要求

    1. 从整数参数创建两个字节

    2. 写入命令类型和参数字节

    3. 如果需要,读取响应(两个字节);从字节中创建一个short

  • 仅类型(例如,获取 ID),这需要

    1. 写入命令类型(一个字节)

    2. 如果需要,读取响应(两个字节);从字节中创建一个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实例,因此您必须创建一个实例变量,在构造器中填充它,并在中级方法中使用它。

  • 根据前面的访问讨论,getIDechoParameter方法被标记为protected

  • getIDechoParameter方法以及writeCmdTypewriteCmdTypeParmreadShort方法都传播未检查的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 中的readShortgetID方法展示了一个有趣的设计选择。正如在PlayLidar中所讨论的,如果传感器没有通电,读取会失败,程序会挂起。为了检测这种情况,我们可以使用非阻塞读取。我们可以在readShort中实现非阻塞读取。但是,在实际使用中,getID应该总是被首先调用(作为身份验证的一部分);此外,根据该单元的经验,如果第一次读取有效,则所有后续读取都有效。所以,我决定让readShort变得“纯粹”,并在getID中做一个非阻塞读取的额外工作。该设计选择遵循第八章的建议;在这种情况下,我不会一直使用非阻塞读取,因为我并不总是需要它。

测试核心

您现在可以测试清单 10-2 中的Lidar核心实现。根据第八章的讨论,您应该在包org.gaf.lidar.test中创建一个主类;我将给这个班级取名为TestLidarCore

TestLidarCore该怎么办?根据前面的身份讨论,它必须找到正确的 USB 设备文件;也就是说,做 USB 设备身份验证。显然它必须实例化一个Lidar。它应该练习两种方法,getIDechoParameter。记住既然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 显示了测试程序TestLidarCoreTestLidarCore表示任何打算在Lidar中使用保护的方法的主类的一般形式。该类必须

  • 扩展被测试的类,在这个例子中,Lidar

  • 用适当的参数定义自己的构造器,在本例中是fileName

TestLidarCore中的main方法必须

  • 为激光雷达设备找到正确的设备文件(这样做可以验证设备身份)

  • 使用已经验证了 USB 设备标识和设备实例 ID 的设备文件实例化该类

  • 调用echoParameter方法

激光雷达装置 USB 设备标识来自表 7-1 ,其中{ usbVendorIdusbProductId } = {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的起源要有趣得多。方法scanStartwarmupStart都启动“长期运行”的任务在默认伺服延迟下,扫描开始后大约 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,我

  1. 运行命令 32 从激光雷达装置获取伺服参数,并在LidarPoint中设置伺服参数

  2. 运行命令 53 进行扫描

  3. 运行命令 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 度的位置。

img/506025_1_En_10_Fig1_HTML.jpg

图 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

img/506025_1_En_11_Fig1_HTML.jpg

图 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 。

img/506025_1_En_11_Fig2_HTML.jpg

图 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 绝对适合玩。因此,在本章的剩余部分,我们将使用I2CDeviceSpiDevice在 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实例。它读取一个寄存器,同样是“我是谁”寄存器。我把PlaySPIPlayI2C放在同一个包里。

私有方法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 的区别。

  • 用你的设备玩是有教育意义的。

十二、模拟转换器

与一些竞争对手不同,树莓派不提供真正的模拟 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 中找到支持会怎么样。事实证明,由于设备的普及,您可以找到多个设备库,这有助于您创建自己的设备库:

即使有所有可用的可能性,如果 diozero 不提供支持,MCP3008(及其家族中的许多产品)是如此简单,您最好从头开始。

使用 diozero McpAdc

演示一下McpAdc将会很有启发性。diozero 文档显示了一个例子,包括一个带有所有连接的图。我创建了一个更简单的测试环境。图 12-1 显示了 1kω电阻的级联和 MCP3008 通道 0–4 的测量点。

img/506025_1_En_12_Fig1_HTML.jpg

图 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 所示。

img/506025_1_En_12_Fig2_HTML.jpg

图 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 中TestMCPgetValueM的重命名副本。

  • 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 的方式有很大不同。

  • 有时候玩耍可以接近真正的代码。

  • 魔鬼可以藏在细节里。

十三、步进电机驱动器

在这一章中,我们将为步进电机驱动器建立一个设备库。步进电机主要用于机器人项目,但也可以用于 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 频率,找到适合您项目的组合。还可以为方向销的不同状态确定电机方向。

无声的步骤实现

现在,我们将开发SilentStepStick1 在前面的章节中,我们首先开发了一个核心。然而在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 显示了执行位置控制的SilentStepStickstepCount方法。它比run方法更复杂(列表 13-4 ):

  • 它不也不允许终止当前运行的任何步进。虽然这个决定有些武断,但它确实有助于保持准确的定位。

  • 它展示了DigitalOutputDevice在前台或后台运行开/关循环的能力。步数可能足够小,以至于在前台运行是有意义的。

  • 它公开了DigitalOutputDevice在开/关循环结束时调用调用者的Action的能力。在大多数背景情况下,这是一个好主意。

  • 它必须拦截DigitalOutputDeviceAction的调用,以维持内部状态。

这些设计决策对我来说似乎是合理和谨慎的,但是您可能会决定做一些不同的事情。

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标志被设置为trueonOffLoop方法

  • 运行,直到计数完成

  • 调用设置了running标志false的内部Action(然后调用调用者的Action,如果它存在的话)

  • 返回到stepCount方法

然后,stepCount方法用running标志false返回给调用者。

现在假设呼叫者选择在后台运行。在调用onOffLoop方法之前,running标志被设置为trueonOffLoop方法产生一个后台线程来运行开/关循环,并返回到stepCount方法,该方法又返回给带有running标志的调用者true。当后台线程运行开/关循环时,调用者可以执行其他任务。背景线程

  • 运行,直到计数完成

  • 调用设置了running标志false的内部Action(然后调用调用者的Action,如果它存在的话)

此时,SilentStepStick实例有了running标志false,可以启动另一个步进器活动。

现在我们可以测试stepCount方法了。清单 13-10 显示了这样做的程序TestSSS3AtomicBoolean是一个 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 不能做所有的事情

都是好东西!

*

十四、一个项目

在这一章中,我们将研究一个需要多个设备项目,而不是关注一个单个设备。我选择的项目是你可能认为的“世界上最荒谬的节拍器”虽然它并不是非常雄心勃勃,但我认为它说明了完成一个项目必须采取的步骤,这个项目涉及大多数机器人项目和一些物联网项目中典型的机械、电子和软件组件。

在这一章中,我将介绍

  • 确定项目需求

  • 为项目选择设备

  • 用这些设备做实验

  • 把所有东西放在一起

节拍器

在您能够识别项目需求之前,您必须定义项目。节拍器是什么?用最简单的话来说,节拍器来回挥动“魔杖”。要实现它,您可以简单地在两个已知点之间波动,其中一个必须是手动建立的起点;但那有点无聊。一个更有趣的方法是让魔杖从任意位置开始,检测两个已知点,然后挥动。让我们用有趣的方法。

因此,我们现在必须围绕节拍器的基本元素做出一些设计决策。我们如何

  • 移动魔杖?

  • 检测已知点?

在这本书里,我们讨论了两种移动东西的装置:DC 汽车的 RoboClaw(第八章)和步进电机的 SilentStepStick(第十三章)。在这一点上,任何一个都可以满足节拍器。 1

我们在本书中没有讨论任何检测节拍器所需的位置或存在的设备。我首先想到的两个设备是光电探测器和限位开关,可能是因为我在过去的项目中使用过这两种设备。也就是说,我在写作时只有“崩溃”(又名极限)开关可用,所以我使用了它们(见 www.dfrobot.com/product-762.htmlwww.dfrobot.com/product-763.html )。

构造设计

现在我们知道,我们可以使用两种电机类型中的一种来来回移动棒,并且我们将使用限位开关来检测棒何时到达两个已知点。这导致了一些机械设计问题。我们如何

  • 将棒安装到马达上?

  • 对齐棒和开关,以便棒激活开关来建立已知点?

在 3D 打印时代,你可能会决定设计和 3D 打印定制零件,以构建适当的机械结构。然而,在写作的时候,我碰巧收集了一些以前项目中的机器人部件(见 www.servocity.com )。他们允许我用标准零件快速设计和建造一个机械结构。

基于我当时拥有的零件,我不得不使用步进电机,而不是 DC 电机。考虑到这个限制,我用 Fusion360 为节拍器做了一个初步的机械设计(见 www.autodesk.com/products/fusion-360 )。图 14-1 显示了机械设计的主要元素。

img/506025_1_En_14_Fig1_HTML.jpg

图 14-1

节拍器机械设计

本设计使用了以下动作机器人部件: 2 其中一些部件在图 14-1 中看不到:

  • 585444 (2x): 5 孔 U 型槽;用于基础结构支撑

  • 555156: NEMA 14 步进电机安装;将电机连接到 U 形槽

  • 634076: 2.75˝ x ˝ D 轴;杆的驱动轴

  • 535198 (2x):法兰滚珠轴承;轴支架

  • 625302 (2x):固定螺钉套环;将轴锁定到位

  • 633104 (2x):塑料垫片;减少轴旋转过程中的摩擦

  • 585412: 13 孔梁;魔杖

  • 545548:固定螺丝轮毂;将横梁连接到轴上

有两个重要部分是 ServoCity 没有的。首先是步进电机本身。第二个是一个灵活的连接器 3 以适应五毫米电机轴到˝机器人驱动轴。幸运的是,我有以前项目中的那些部分;你可以使用任何合理的步进电机和相应的耦合器。

还需要建筑

  • M3 螺丝(4x):将步进电机安装到支架上

  • ˝ 6-32 桁架头螺钉(4x):将底座固定到 u 形槽上

  • 5/16˝ 6-32 螺钉(4x):将 u 形槽连接到 u 形槽

  • 6-32 螺母(4x):将 U 形槽连接到 U 形槽上

如你所知,Actobotics 零件使用英制测量系统。开关使用公制,所以我不能使用 Actobotics 零件来安装限位开关。我碰巧有一些 M3 支架和螺丝,并能够使用它们将开关安装在可接受的位置。

图 14-2 显示节拍器的机械实现,包括安装的开关。你可能会注意到数字 14-1 和 14-2 之间的细微差别。我发现,为了确保棒接触开关,我必须把塑料垫片之间的棒和固定螺丝枢纽。

img/506025_1_En_14_Fig2_HTML.jpg

图 14-2

节拍器机械实现

本着诚实的精神,我承认图 14-2 中的结构并不理想,因为它容易受到机械损伤。这些开关的设计使得物体可以通过,关闭开关,并继续通过,而不会损坏开关。不幸的是,我没有零件来安装开关的位置,使该行为。如果你检查开关的位置,你会发现棒直接向开关移动,如果它继续移动,它会碰到开关体,有可能会打破什么东西!不幸的机械设计限制要求在软件设计和测试中格外小心。

电子设计

显然,我们将使用一个树莓派来为节拍器提供总体控制。选择步进电机来驱动节拍器意味着我们使用第十三章中的静音步进棒来驱动电机;这需要 Pi 上有三个 GPIO 引脚。我们有两个限位开关,每个都必须由 Pi 监控;每个都需要一个 GPIO 引脚。

我们可以从第章复制第十三章

  • 从步进电机到静音步进杆的连接

  • 从静音踏板到 Pi 的电源和信号连接

  • 静音踏板的电机电源连接

  • 静音步进微步配置

限位/碰撞开关比大多数开关稍微复杂一些(示意图见 http://image.dfrobot.com/image/data/SEN0138-L/Crash%20sensor%20Sch.pdf )。这些开关通常是打开的,有一个内置的上拉电阻和一个 LED,当开关关闭时,LED 会亮起。因此,每个开关都需要连接到电源(为了 Pi 安全,我使用了 3.3V 而不是 5V)、地和 GPIO 引脚,以检测开关何时闭合。

软件设计

这一章和前面几章有所不同。我们没有创建设备库;相反,我们正在创建一个完整的项目。因此,我们的主要目标是创建一个 Java 应用程序,而不是 Java 。到目前为止,您应该还记得使用 diozero 开发库的设置。开发应用程序略有不同(参见第五章)。在本例中,您创建了一个新项目(我将把我的项目称为 Metronome )、一个包(org.gaf.metronome)和一个新的主类(Metronome)。

我们在上一节中已经确定,我们将通过第十三章中的库来使用 SilentStepStick。但是开关呢?我们如何尽快检测到开关闭合,从而将崩溃的可能性降至最低?检查 diozero Javadoc,在撰写本文时唯一的候选是DigitalInputDevice。参见第章第 7 。

仔细观察DigitalInputDevice的功能,似乎有三种方法可以检测开关闭合:

  • waitForActive:等待直到 GPIO 引脚检测到活动状态,对于常开开关,该状态为

  • whenActivated:每当 GPIO 引脚检测到活动状态时,调用一个“中断处理程序”

  • addListener:每当 GPIO 引脚检测到所需边沿时,调用一个“中断处理程序”,对于常开开关,该边沿将是下降

在多设备项目中,通常需要单独测试每个设备,然后测试组合。实际上,我们在第十三章中对 SilentStepStick 和它的库做了很多测试。作为交换机测试的一部分,最好测试所有三种方法,以确定哪一种检测速度最快。这听起来像玩(和乐趣)给我!

项目组件测试

如前所述,我们已经测试了 SilentStepStick,但我们确实需要测试开关。为此,我将在 Metronome 中创建另一个名为org.gaf.metronome.test的包来包含测试程序。

测试 waitForActive

我们将首先使用程序TestWait测试waitForActive,如清单 14-1 所示。想法是使用waitForActive检测开关闭合,并使用DigitalOutputDevice产生一个脉冲。理论上,这允许我们测量开关闭合和能够对此采取措施之间的延迟。在第九章中,我们使用了whenActivated来捕捉中断,并在第一次和可能的第二次中断时经历了一些异常的时序。因此,TestWait多次练习waitForActive以查看是否出现相同的定时异常。

由于DigitalOutputDevice使用的线程,TestWait启用了 try-with-resources 和 diozero 安全网。参见第七章。

package org.gaf.metronome.test;

import com.diozero.api.DigitalInputDevice;
import com.diozero.api.DigitalOutputDevice;
import com.diozero.api.GpioPullUpDown;
import com.diozero.util.Diozero;

public class TestWait {

    public static void main(String[] args)
            throws InterruptedException {
        try (
            DigitalInputDevice did =
                DigitalInputDevice.Builder.
                builder(20).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).build();
            DigitalOutputDevice dod =
                new DigitalOutputDevice(21,
                true, false)) {

            int num = 3;
            for (int i = 0; i < num; i++) {

                System.out.println("Waiting ...");
                boolean status =
                    did.waitForActive(5000);
                dod.on();
                if (status) {
                    System.out.println("Got it");
                }
                Thread.sleep(5);
                dod.off();

                System.out.println("Killing time");
                if (i < (num - 1))
                    Thread.sleep(4000);
            }
            System.out.println("Done");
        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 14-1TestWait

正如你在清单 14-1 中看到的,TestWait循环。每次迭代等待开关闭合,然后产生一个脉冲。然后它“消磨一些时间”让你为新的开关闭合做准备。

图 14-3 显示了我的示波器捕捉到的三个开关闭合的第一个的结果。顶部走线显示监控开关的 GPIO 引脚。底部轨迹显示产生输出脉冲的 GPIO 引脚。可以看到,从开关信号的第一个可见边沿到脉冲前沿的时间约为 1.8 毫秒。

img/506025_1_En_14_Fig3_HTML.jpg

图 14-3

测试等待第一次中断

你也可以在图 14-3 中看到处理机械开关的可悲现实。开关触点会“跳动”一会儿,但最终会稳定下来。这是机械开关的一个经典问题——开关何时真正被激活?对于我使用的开关,反弹周期似乎在 500 微秒和几毫秒之间,取决于树莓派 GPIO 用来定义低电平和高电平的电压,以及电压必须稳定多长时间。也就是说,去抖周期的常见期望值是 50 毫秒。

Tip

如图 14-3 所示,机械开关“弹跳”你可以在网上找到很多关于这个主题的信息。下面的参考文献提供了一些关于为什么开关会反弹、其含义以及如何处理反弹的见解: www.labbookpages.co.uk/electronics/debounce.htmlwww.allaboutcircuits.com/technical-articles/switch-bounce-how-to-deal-with-it/www.eejournal.com/article/ultimate-guide-to-switch-debounce-part-9 。diozero 包括com.diozero.api.DebouncedDigitalDevice,它成功地去抖了我测试的一些开关,但没有其他的(例如,本章中的开关)。您应该准备好测试您的开关,甚至实现您自己的去抖方法。

图 14-4 显示了三个开关闭合的第三个的结果。可以看到,从开关信号的第一个可见边沿到脉冲的前沿的时间约为 800 微秒。这是一个明显的进步。我推测这种差异是由于 JVM 类加载和可能的 JITC 4 完成。

img/506025_1_En_14_Fig4_HTML.jpg

图 14-4

TestWait 第三次中断

激活时测试

现在我们将使用程序TestWhen测试whenActivated,如清单 14-2 所示。TestWhenTestWait截然不同。如第九章所述,我们必须创建一个“中断处理程序”(方法when)来捕捉开关产生的中断,当然也要使用whenActivated来识别中断处理程序。中断处理器产生脉冲,并记录开关闭合产生的事件数。

package org.gaf.metronome.test;

import com.diozero.api.DigitalInputDevice;
import com.diozero.api.DigitalOutputDevice;
import com.diozero.api.GpioPullUpDown;
import com.diozero.util.Diozero;

public class TestWhen {

    private static int cnt;
    private static DigitalOutputDevice dodP;

    public static void main(String[] args)
             throws InterruptedException {
        try (
            DigitalInputDevice did =
                DigitalInputDevice.
                Builder.builder(20).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).build();
            DigitalOutputDevice dod =
                new DigitalOutputDevice(21,
                true, false)) {

            did.whenActivated(TestWhen::when);

            dodP = dod;

            cnt = 0;

            System.out.println("Waiting ...");
            Thread.sleep(10000);

            System.out.println("Count = " + cnt);
        } finally {
            Diozero.shutdown();
        }
    }

    private static void when(long ts) {
        cnt++;
        dodP.on();
        dodP.off();
    }
}

Listing 14-2TestWhen

图 14-5 显示了运行TestWhen的结果。范围跟踪显示了一些有趣的方面:

img/506025_1_En_14_Fig5_HTML.jpg

图 14-5

测试结果

  • 开关闭合在左侧;由于示波器的水平刻度,跳动几乎检测不到。

  • 第一个脉冲出现在开关闭合后大约两毫秒。

  • 一个开关闭合产生四个脉冲;有一组三个,包括第一个,在开关闭合后约 2 毫秒开始,间隔约 2 毫秒;四个中的最后一个距离开关闭合大约 34 毫秒。

从根本上说,结果很好地证明了开关弹跳的影响。本着充分披露的精神,在测试期间,TestWhen打印的脉冲计数范围从 1 到 12,4 是最常见的。

底线:我断言whenActivated在开关闭合之间的延迟和采取行动的能力方面和waitForActive一样好。然而,whenActivated可能会更难使用,因为来自开关反弹的多个事件。

addListener 测试

现在我们将使用程序TestListen测试addListener,如清单 14-3 所示。TestListenTestWhen几乎一样,当然是用addListener代替whenActivated

package org.gaf.metronome.test;

import com.diozero.api.DigitalInputDevice;
import com.diozero.api.DigitalInputEvent;
import com.diozero.api.DigitalOutputDevice;
import com.diozero.api.GpioEventTrigger;
import com.diozero.api.GpioPullUpDown;
import com.diozero.util.Diozero;

public class TestListen {

    private static int cnt;
    private static DigitalOutputDevice dodP;

    public static void main(String[] args)
            throws InterruptedException {
        try (
            DigitalInputDevice did =
                DigitalInputDevice.Builder.
                builder(20).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).
                setTrigger(
                GpioEventTrigger.FALLING).build();
            DigitalOutputDevice dod =
                new DigitalOutputDevice(21,
                true, false)) {

            did.addListener(TestListen::listen);

            dodP = dod;

            cnt = 0;

            System.out.println("Waiting ...");
            Thread.sleep(10000);

            System.out.println("Count = " + cnt);
        } finally {
            Diozero.shutdown();
        }
    }

    private static void listen(
            DigitalInputEvent event) {
        cnt++;
        dodP.on();
        dodP.off();
    }
}

Listing 14-3TestListen

我运行了TestListen,观察到了与TestWhen完全不同的结果。产生的第一个输出脉冲总是在第一次开关闭合脉冲串之后 15 毫秒左右。然而,关闭次数大致相同。我断言addListener没有waitForActive好,因为有更长的延迟和来自开关反弹的多个事件。

看来waitForActivewhenActivated都可以很好地尽快检测开关闭合。waitForActive似乎更容易使用一点,所以我们就用它。现在我们可以随着Metronome的发展向前迈进了。

Caution

在第九章以及本章中,我们遇到了异常的 GPIO 时序。理解使用树莓派 OS 和 Java 构建的系统的局限性是很重要的。树莓派操作系统是一个多任务操作系统,而不是一个实时操作系统。这些特点限制了产生输出和对输入作出反应的及时性。Java 虚拟机的一些特性,如惰性类加载、垃圾收集和实时编译,会加剧树莓派操作系统的局限性。在大多数情况下,这些限制不会导致问题,但是您必须始终认识到这些限制的存在。在某些情况下,绕过它们很容易,就像第九章一样。在某些情况下,你可能不得不简单地接受它们,就像在本章中一样。在极端实时需求的情况下,您可能必须将一些任务卸载给能够更好地适应实时需求的子系统,如第八章和第十章。

节拍器发展

基于本章前面粗略定义的目标,我们知道我们需要SilentStepStick来驱动步进电机。要使它在 Metronome 项目中可用,您必须将 SSS 项目添加到 Metronome classpath 属性中;参见第五章。我们也知道我们需要一个DigitalInputDevice用于每个开关。上一节的结论是,我们应该使用waitForActive来检测开关闭合。

此外,我们可以从TestSSS1(清单 13-5 )和TestWait(清单 14-1 )中复制代码片段来播种Metronome开发。清单 14-4 显示了Metronome的初始框架。它使用第十三章中的参数创建了一个SilentStepStick的实例,并使用清单 14-1 中的参数创建了一个DigitalInputDevice的实例,用于当棒顺时针(swCW)和逆时针(swCCW)移动时遇到的开关。从清单中可以看出,这个框架除了启用 try-with-resources 和 diozero 安全网,以及 Java shutdown 安全网之外,不做任何其他事情;Metronome启用后者,因为步进器可能会继续运行,并在非正常终止的情况下将棒撞向开关。

package org.gaf.metronome;

import com.diozero.api.DigitalInputDevice;
import com.diozero.api.GpioPullUpDown;
import com.diozero.util.Diozero;
import java.io.IOException;
import org.gaf.sss.SilentStepStick;

public class Metronome {

    public static void main(String[] args)
            throws IOException {
        try (
            SilentStepStick stepper =
                new SilentStepStick(4, 27, 17, 200,
                SilentStepStick.Resolution.Quarter);
            DigitalInputDevice swCW =
                DigitalInputDevice.Builder.
                builder(20).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).build();
            DigitalInputDevice swCCW =
                DigitalInputDevice.Builder.
                builder(21).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).build()
            ) {

            // engage Java shutdown safety net
            Diozero.registerForShutdown(stepper);

        } finally {
            Diozero.shutdown();
        }
    }
}

Listing 14-4Metronome skeleton

我们现在将迭代开发Metronome,添加功能和测试,添加功能和测试,等等。首先要添加的是检测两个开关闭合的能力。展望未来,我们可能应该假设你必须在运行程序之前手动定位魔杖,这样它就不会在运行程序之前关闭开关。此外,等待一切准备就绪的信号可能是个好主意。

清单 14-5 显示了开关测试实现的代码片段;它被放在清单 14-4 中的finally语句之前。当您首先关闭顺时针开关,然后关闭逆时针开关时,运行这个版本的Metronome会在清单的底部产生输出。如果你看到了,那就成功了!

// wait for start
System.out.println(
    "Waiting to start .... Press CW switch.");
boolean status = swCW.waitForActive(10000);
if (status) {
    System.out.println("Starting");
} else {
    System.out.println("Failure to start!");
    System.exit(1);
}

// check for CCW
System.out.println(
    "Waiting for CCW .... Press CCW switch.");
status = swCCW.waitForActive(10000);
if (status) System.out.println("Got CCW");

Output ------------------------

Waiting to start .... Press CW switch.
Starting
Waiting for CCW .... Press CCW switch.
Got CCW

Listing 14-5Metronome switch test

接下来,我们可以尝试顺时针和逆时针慢慢运行步进电机,随着开关被按下而改变方向。清单 14-6 显示了修改后的Metronome。等待启动开关后,我们首先启用步进器,然后开始以 1 RPM 的速度顺时针移动。此时,我们等待顺时针开关关闭,并在它关闭时停止步进器。然后,我们开始以 1 RPM 的速度逆时针移动步进器,等待逆时针开关关闭,并在关闭时停止步进器。

// wait for start
System.out.println("Waiting to start .... Press CW switch.");
boolean status = swCW.waitForActive(10000);
if (status) {
    System.out.println("Starting");
} else {
    System.out.println("Failure to start!");
    System.exit(1);
}
// make sure switch not bouncing
Thread.sleep(100);

// run to CW switch
stepper.enable(true);
System.out.println("Run CW");
stepper.run(SilentStepStick.Direction.CW, 1f);
System.out.println("Waiting to hit switch ...");
status = swCW.waitForActive(20000);
stepper.stop();
if (status) {
    System.out.println("Got it");
} else {
    System.out.println("Motor not running");
    System.exit(1);
}

// run to CCW switch
System.out.println("Run CCW");
stepper.run(SilentStepStick.Direction.CCW, 1f);
System.out.println("Waiting to hit switch ...");
status = swCCW.waitForActive(20000);
stepper.stop();
if (status) {
    System.out.println("Got it");
} else {
    System.out.println("Motor not running");
    System.exit(1);
}

stepper.enable(false);

Listing 14-6Metronome motor test

为了测试Metronome,现在你显然必须将静音杆连接到树莓派、步进电机和电机电源(参见第十三章)。我建议第一次测试不带魔杖,以防止任何不愉快的意外;当然,你必须自己点击开关来模拟魔杖的运动。一旦你按下顺时针开关启动,电机应顺时针旋转,并应继续这样做,直到你按下顺时针开关。然后,电机应停止并开始逆时针旋转,并应继续这样做,直到你按下逆时针开关。那么马达应该停止。如果看到这种行为,宣告成功!

假设手动测试有效,连接棒并再次运行Metronome。一旦你按下启动开关,魔杖应该顺时针旋转,并应该继续这样做,直到它按下顺时针开关。然后,棒应该停止并开始逆时针旋转,并应该继续这样做,直到它碰到逆时针开关。然后魔杖应该停下来。如果看到这种行为,再次宣告成功!

超越最初的要求

现在,创建本章前面描述的节拍器行为的所有部分都已就绪。将驱动步进器和测试开关的代码段放在一个循环中,使棒在开关之间摆动,这是非常简单的。然而,我认为在没有 ?? 按下开关的情况下挥动 ?? 会有趣得多。这是可能的,因为SilentStepStick可以移动请求的步数,并且可以报告它已经走了多少步,无论它如何移动。

我们现在需要做更多的设计。首先看图 14-6 ,图中显示了当关闭顺时针开关时,棒的位置;该顺时针关闭位置(CWC)由穿过棒的实线标记。左侧的实线表示逆时针关闭位置(CCWC)。

img/506025_1_En_14_Fig6_HTML.jpg

图 14-6

开关闭合时的棒位置

在清单 14-6 的Metronome版本中,魔杖首先从任意起始位置移动到 CWC。我们不在乎要走多少步。然后魔杖从 CWC 移到了 CCWC。这需要一些步骤,在图 14-6 中标为 Sw2Sw。因此,Sw2Sw 是将魔杖从 CWC 移动到 CCWC,或者从开关闭合到开关闭合的步数。但是我们想在不关闭开关的情况下来回移动棒。我们应该能够通过移动更少的步骤来实现这一点,也就是说,在图中的点划线之间;那些虚线代表一个顺时针非闭合位置(CWN)和一个逆时针非闭合位置(CCWN)。基本上,我们需要在开关闭合位置(如 CWC)和开关非闭合位置(如 CWN)之间定义一个缓冲步数(图中的“B”)。有了这些概念,我们就可以确定从

  • CWC 到 CCWC = Sw2Sw

  • CCWC 到 CWN = Sw2Sw–B

  • CWN 到 CCWN(图 14-6 中的“标称”)= Sw2Sw-(2 x B)

  • CCWN 到 CWN = Sw2Sw-(2 x B)

  • CWN 到“中间”=(Sw2Sw/2)–B

清单 14-7 显示了对Metronome的更新,以实现新的目标,来回挥动而不触及开关,然后将魔杖位置留在 CCWN 和 CWN 之间的“中间”。所示代码必须插入清单 14-6 ( stepper.enable(false))的最后一条语句之前。

// get step count; calculate moves
int sw2sw = stepper.getStepCount();
System.out.println("Step Count = " + sw2sw);
int buffer = 15;
int first = sw2sw - buffer;
int nominal = sw2sw - (2 * buffer);
int middle = sw2sw/2 - buffer;

// move to CW
stepper.stepCount(first,
    SilentStepStick.Direction.CW, 4f, false, null);

// move back and forth
for (int i = 0; i < 4; i++) {
    // move to CCW
    stepper.stepCount(nominal,
            SilentStepStick.Direction.CCW, 4f,
            false, null);
    // move to CW
    stepper.stepCount(nominal,
            SilentStepStick.Direction.CW, 4f,
            false, null);
}

// move to middle
stepper.stepCount(middle,
         SilentStepStick.Direction.CCW, 4f,
         false, null);

Listing 14-7Metronome final snippet

关于更新的几个有趣的点:

  • 在搜索开关期间,摆动期间的所有步进运动以 4 RPM 运行,而不是以 1 RPM 运行。没有理由要谨慎,因为魔杖不会碰到开关!

  • 我选择了任意数量的波周期。你当然可以参数化它,或者让它无限,也许当你按下开关时终止。

  • 我觉得结束后把魔杖留在波动区域的中间很有趣,但是你可以在任何地方停止它。

如果您运行Metronome更新,您应该看到现在熟悉的顺时针缓慢旋转关闭 CW 开关,然后逆时针缓慢旋转关闭 CCW 开关。然后你应该看到快速移动顺时针移动,接着快速移动逆时针然后顺时针移动四个周期,最后逆时针快速移动到波区域的中间。恭喜你!

Note

如果你想看到节拍器的动作,你可以在书中的代码库中观看视频。

更接近真实的世界

除了最初的要求,还有另一种方法。在之前的实现中(清单 14-6 和 14-7 ),我们使用限位开关来校准魔杖的移动,因此魔杖在挥动时不会激活开关。在现实世界中,我们还会使用限位开关来防止由于各种原因可能发生的崩溃,例如,糟糕的编码或机械问题。

我们该怎么做?我们必须在控制步进机的同时监控开关。基于我们在本章前面所做的测试,我们不能在DigitalInputDevice上使用waitForActive方法,因为它阻止我们做任何其他事情。然而,这些测试表明,whenActivated将是在做其他事情的同时监控开关的好选择。开关激活时调用的相关中断处理程序可以停止步进器。但是你可以在清单 14-7 中看到有几个启动步进器的语句,所以我们必须防止在步进器停止后再次启动它。我们可以使用一个AtomicBoolean来同步一切(更多信息请参见清单 13-10)。

清单 14-8 显示了检测崩溃所需的修改(我省略了导入语句)。您可以看到第一个实现的以下不同之处:

  • 上市初期:声明的AtomicBoolean叫做emergency

  • 在清单的最后:中断处理程序limitHit,当一个开关被激活时,它停止步进器并设置emergency为真。

  • 在有意按下两个开关后顺时针移动:设置limitHit以便在任一开关被激活时调用。

  • 最后,挥动发生的地方:每个动作都受到emergency的检查。

public class Metronome {

    private static SilentStepStick eStop;
    private static final AtomicBoolean emergency =
           new AtomicBoolean(false);

    public static void main(String[] args)
            throws IOException,InterruptedException {
        try (
            SilentStepStick stepper =
                new SilentStepStick(4, 27, 17, 200,
                SilentStepStick.Resolution.Quarter);
            DigitalInputDevice swCW =
                DigitalInputDevice.Builder.
                builder(20).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).build();
            DigitalInputDevice swCCW =
                DigitalInputDevice.Builder.
                builder(21).
                setPullUpDown(GpioPullUpDown.NONE).
                setActiveHigh(false).build()
            ) {

            // set up for emergency stop
            eStop = stepper;

            // engage Java shutdown safety net
            Diozero.registerForShutdown(stepper);

            // wait for start
            System.out.println(

                "Waiting to start ....
                Press CW switch.");
            boolean status =
                swCW.waitForActive(10000);
            if (status) {
                System.out.println("Starting");
            } else {
                System.out.println(
                    "Failure to start!");
                System.exit(1);
            }
            // make sure switch not bouncing
            Thread.sleep(100);

            // run to CW switch
            stepper.enable(true);
            System.out.println("Run CW");
            stepper.run(SilentStepStick.Direction.
                CW, 1f);
            System.out.println(
                "Waiting to hit switch ...");
            status = swCW.waitForActive(20000);
            stepper.stop();
            if (status) {
                System.out.println("Got it");
            } else {
                System.out.println(
                    "Motor not running");
                System.exit(1);
            }

            // run to CCW switch
            System.out.println("Run CCW");
            stepper.run(
                SilentStepStick.Direction.CCW, 1f);
            System.out.println("Waiting to
                hit switch ...");
            status = swCCW.waitForActive(20000);
            stepper.stop();
            if (status) {
                System.out.println("Got it");
            } else {
                System.out.println(
                    "Motor not running");
                System.exit(1);
            }

            // get step count; calculate moves
            int sw2sw = stepper.getStepCount();
            System.out.println(
                "Step Count = " + sw2sw);
            int buffer = 15;
            int first = sw2sw - buffer;
            int nominal = sw2sw - (2 * buffer);
            int middle = sw2sw/2 - buffer;

            // move to CW
            stepper.stepCount(first,
                    SilentStepStick.Direction.CW, 4f,
                    false, null);

            // set up limit switches for emergency
            swCW.whenActivated(Metronome::limitHit);
            swCCW.whenActivated(Metronome::limitHit);

            // move back and forth
            for (int i = 0; i < 4; i++) {
                // move to CCW
                stepper.stepCount(nominal,
                    SilentStepStick.Direction.CCW,
                    4f, false, null);
                if (emergency.get()) break;
                // move to CW
                stepper.stepCount(nominal,
                    SilentStepStick.Direction.CW,
                    4f, false, null);
                if (emergency.get()) break;
            }

            // move to middle
            if (!emergency.get())
                stepper.stepCount(middle,
                   SilentStepStick.Direction.CCW, 4f,
                   false, null);

            stepper.enable(false);
        } finally {
            Diozero.shutdown();
        }
    }

    private static void limitHit(long ts) {
        emergency.set(true);
        eStop.stop();
    }
}

Listing 14-8Metronome with crash detection

如果运行更新后的Metronome,应该会看到之前版本的行为。但是如果你在魔杖挥动的时候按下任何一个开关,步进器就会停止,并且Metronome终止。再次恭喜!

摘要

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

  • 分析项目并确定需求

  • 确定实施项目的合适设备

  • 尝试设备以选择正确的用法

  • 发展项目需求以充分利用设备功能

我希望您喜欢这个“把所有东西放在一起”的练习,这个练习是使用 diozero 定义和实现一个完整的项目。

posted @ 2024-08-06 16:42  绝不原创的飞龙  阅读(55)  评论(0编辑  收藏  举报