JavaScript ESP32 和 ESP8266 物联网开发教程(三)
五、文件和数据
几乎每个产品都有一些数据需要确保在设备重启时可用,即使断电也是如此。在微控制器上,闪存通常用于这种非易失性存储(NVS)存储器。保存应用程序代码的同一闪存也存储应用程序使用的数据和它创建的数据。以下是您的应用程序可能存储的一些数据类型:
-
只读数据,例如构成产品用户界面的图像或包含由嵌入式 web 服务器提供的静态网页的文件
-
读写的小块数据—例如,用户偏好和其他长期状态
-
产品监控操作时创建的大量数据,例如,从传感器收集数据时
在计算机和移动设备上,通常使用文件系统来满足大多数(如果不是全部)数据存储需求。然而,由于嵌入式系统的限制——代码大小限制、高度受限的 RAM 和严格的性能约束——固件通常甚至不包括文件系统。
本章解释了在嵌入式系统上处理存储数据的三种不同方式:文件、首选项和资源。最后一节介绍对闪存的直接访问,这是一种提供最大灵活性的高级技术。
构建产品时,选择最符合您需求的数据存储方法。在假设文件是正确的选择之前,请考虑首选项和资源,它们是处理存储数据的轻量级方式。
安装文件和数据主机
你可以按照第一章中描述的模式运行本章提到的所有示例:使用mcconfig在你的设备上安装主机,然后使用mcrun安装示例应用。
主机在$EXAMPLES/ch5-files/host目录中。从命令行导航到这个目录,用mcconfig安装它。
文件
ESP32 和 ESP8266 使用 SPIFFS(串行外设接口闪存文件系统)作为其闪存中的文件系统。SPIFFS 专门设计用于许多微控制器使用的 NOR(非或)闪存。虽然 SPIFFS 的功能远不如计算机上的文件系统全面,但它提供了您需要的所有基本功能。
在嵌入式设备上使用文件时,一定要记住文件系统实现的这些限制:
-
SPIFFS 是一个平面文件系统,这意味着没有真正的目录。所有文件都在 SPIFFS 根目录下。
-
文件名限制为 32 个字节。
-
没有文件权限或锁定。所有文件都可以被读取、写入和删除。
-
写操作的时间长度是不可预测的。它通常很快,但是当文件系统需要整合数据块时,它可能会阻塞一段时间。
本节重点介绍如何使用 SPIFFS 访问文件,SPIFFS 不需要添加任何硬件,代码量相对较小。在 ESP32 上,这些相同的 API 也可用于访问使用 FAT32 文件系统格式化的 SD 存储卡。
文件类别
对文件系统的所有访问都是使用file模块中的类来完成的:
import {File, Iterator, System} from "file";
file模块导出这三个类,下面几节将详细解释:
-
File类对单个文件执行操作,包括读、写、删除和重命名。 -
Iterator类返回目录的内容。在 SPIFFS 这样的平面文件系统上,Iterator只对根目录可用。 -
System类提供关于文件系统存储的信息,包括存储总量和可用空间。
文件路径
文件路径是用来标识文件和目录的字符串。file模块使用斜杠字符(/)来分隔路径的各个部分,就像在/spiffs/data.txt中一样。
虽然 SPIFFS 是一个没有子目录的平面文件系统,但它是通过根/spiffs/而不是/来访问的,以支持具有多个文件系统的嵌入式设备——例如,内置的闪存文件系统和外部 SD 卡。
在桌面模拟器上,根因主机平台而异。比如在 macOS 上,默认的文件系统根目录是/Users/Shared/。当您编写想要在多个环境中工作的代码时,您可以使用mc/config模块中的预定义值来查找您的主机平台的根。
import config from "mc/config";
const root = config.file.root;
因为可能有多个文件系统,这个根目录只是一个方便的文件默认位置,不一定是唯一可用的文件系统。
每个文件系统对文件名或目录名的长度可能有不同的限制。使用System.config静态方法检索指定根中名称的最大长度。
const spiffsConfig = System.config("/spiffs/");
let name = "this is a very long file name.txt";
if (name.length > spiffsConfig.maxPathLength)
throw new Error("file name too long");
文件操作
本节描述对文件执行操作的方法,包括删除、创建和打开。没有方法来读取或写入文件的全部内容,因为这通常会由于内存限制而失败;后面的章节介绍了阅读和写作的技巧。
确定文件是否存在
使用File类的静态exists方法来确定文件是否存在:
if (File.exists(root + "test.txt"))
trace("file exists\n");
else
trace("files does not exist\n");
删除文件
要删除一个文件,使用File类的静态delete方法:
File.delete(root + "goaway.txt");
如果成功,delete方法返回true,否则返回false。如果文件不存在,delete会返回true而不是抛出一个错误,所以不需要用try / catch块包围它的调用。如果删除操作失败,方法确实会引发错误,但这种情况只在极少数情况下发生,例如当闪存磨损或文件系统数据结构损坏时。
重命名文件
要重命名文件,使用File类的静态rename方法。第一个参数是要重命名的文件的完整路径,而第二个参数只是新名称。
File.rename(root + "oldname.txt", "newname.txt");
Note
rename方法仅用于重命名文件。在支持子目录的文件系统上,rename不能用于将文件从一个目录移动到另一个目录。
打开文件
要打开一个文件,创建一个File类的实例。File构造函数的第一个参数是要打开的文件的完整路径。可选的第二个参数是以写模式打开的true(如果文件不存在,则创建文件),或者不存在,或者是以读模式打开的false。以下是以读取模式打开文件的示例:
let file = new File(root + "test.txt");
以下示例以写模式打开一个文件,如果该文件不存在,则创建该文件:
let file = new File(root + "test.txt", true);
如果打开文件时出现错误,比如试图以读取模式打开一个不存在的文件,那么File构造函数会抛出一个错误。
访问完文件后,关闭文件实例以释放它正在使用的系统资源:
file.close();
写入文件
本节介绍将数据写入文件的技术。您可以使用File类来写入文本和二进制数据。文件必须以写模式打开,否则写操作将引发错误。要以写模式打开,将true作为第二个参数传递给File构造函数。
当您写入的数据超过当前大小时,文件系统会自动增大文件。不支持截断文件。要减小文件的大小,请创建另一个文件,并将所需数据从原始文件复制到其中。
书写文本
File类的write方法根据您传递给调用的 JavaScript 对象的类型来确定您想要写入的数据类型。若要写入文本,请传递一个字符串。以下来自$EXAMPLES/ch5-files/files示例的代码将单个字符串写入文件:
let file = new File(root + "test.txt", true);
file.write("this is a test");
file.close();
字符串总是被写成 UTF 8 数据。
写入二进制数据
要将二进制数据写入文件,请将一个ArrayBuffer传递给write。以下来自$EXAMPLES/ch5-files/files示例的代码将五个 32 位无符号整数写入一个文件。这些值在一个Uint32Array中,它使用一个ArrayBuffer进行存储。对write的调用从bytes数组的buffer属性中获取ArrayBuffer。
let bytes = Uint32Array.of(0, 1, 2, 3, 4);
let file = new File(root + "test.bin", true);
file.write(bytes.buffer);
file.close();
要写字节(8 位无符号值),传递一个整数值作为参数(参见清单 5-1 )。
let file = new File(root + "test.bin", true);
file.write(1);
file.write(2);
file.write(3);
file.close();
Listing 5-1.
获取文件大小
要确定文件的字节大小,首先要打开文件,然后检查它的length属性:
let file = new File(root + "test.txt");
let length = file.length;
trace(`test.txt is ${length} bytes\n`);
file.close();
length属性是只读的。不能设置它来更改文件的大小。
编写混合类型
write方法允许您传递多个参数,以便在一次调用中写入几段数据。这样执行起来会快一点,代码也会小一点。以下示例在对write的一次调用中写入一个ArrayBuffer、四个字节和一个字符串:
let bytes = Uint32Array.of(0x01020304, 0x05060708);
let file = new File(root + "test.bin", true);
file.write(bytes.buffer, 9, 10, 11 12, "ONE TWO!");
file.close();
写入后文件的十六进制转储如下所示:
04 03 02 01 08 07 06 05 .... ....
09 0A 0B 0C 79 78 69 32 .... ONE
84 87 79 33 TWO!
您可能希望前四个字节是01 02 03 04,但是请记住,包含Uint32Array的TypedArray的实例是按照主机平台的字节顺序存储的,而 ESP32 和 ESP8266 微控制器都是小端设备。
从文件中读取
本节介绍从文件中检索数据的技术。您可以使用File类来读取文本和二进制数据。大多数文件都是二进制或文本数据,尽管这不是必需的。
File类支持分段读取文件,这使您能够控制从文件读取时使用的最大内存。
阅读文本
有时,将文件的全部内容作为一个文本字符串进行检索是很有用的。您可以通过调用带有单个参数String的read方法来实现这一点,该方法告诉文件实例从当前位置读取到文件末尾,并将结果放入一个字符串中。下面来自$EXAMPLES/ch5-files/files示例的代码从前面创建的test.txt文件中读取内容:
let file = new File(root + "test.txt");
let string = file.read(String);
trace(string + "\n");
file.close();
read方法总是从当前位置开始读取。在这种情况下,由于文件刚刚被打开,所以当前位置是 0,即文件的开头。
分段阅读文本
您还可以使用read方法来检索文件的一部分,以最大限度地减少峰值内存的使用。read的可选第二个参数表示要读取的最大字节数。这是读取的字节数,但有一个例外:如果读取请求的字节数会超过文件的结尾,则从当前位置到文件结尾的文本被读取。
清单 5-2 中的例子读取一个十字节的文件并跟踪它们到控制台。它将position属性与length属性进行比较,以确定何时从文件中读取了所有数据。
let file = new File(root + "test.txt");
while (file.position < file.length) {
let string = file.read(String, 10);
trace(string + "\n");
}
file.close();
Listing 5-2.
在计算机上,你可以对文件进行内存映射以简化对数据的访问;然而,这种方法在微控制器上通常不可用,因为它们通常缺少 MMU(内存管理单元)来执行映射。如果你想对只读数据进行内存映射,资源是一个很好的选择,这将在本章后面解释。
读取二进制数据
要将整个文件作为二进制数据读取,请使用单个参数ArrayBuffer调用read。下面来自$EXAMPLES/ch5-files/files示例的代码从前面创建的test.bin文件中读取内容:
let file = new File(root + "test.bin");
let buffer = file.read(ArrayBuffer);
file.close();
与读取文本时一样,二进制读取从当前位置(文件打开时为 0)开始,一直持续到文件的末尾。数据在ArrayBuffer中返回。以下示例将返回的缓冲区包装在一个Uint8Array中,并在控制台上显示十六进制字节值:
let bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++)
trace(bytes[i].toString(16).padStart(2, "0"), "\n");
分段读取二进制数据
read方法也可以用来从文件中的任意位置获取二进制数据。清单 5-3 中的例子读取文件的最后四个字节,并将结果显示为一个 32 位无符号整数。读取位置是通过将position属性设置为距离文件末尾四个字节来指定的。
let file = new File(root + "test.bin");
file.position = file.length - 4;
let buffer = file.read(ArrayBuffer, 4);
file.close();
let value = (new Uint32(buffer))[0];
Listing 5-3.
目录
SPIFFS 文件系统只实现一个目录,即根目录。其他文件系统,比如 FAT32,支持任意数量的子目录。在所有情况下,都使用file模块的Iterator类来列出目录中包含的文件和子目录。
遍历目录
检索目录中的项目列表是一个两步过程。首先,为要迭代的目录创建一个Iterator类的实例;然后调用迭代器的next方法来检索每一项。当所有条目都被返回时,迭代器返回undefined。来自$EXAMPLES/ch5-files/files示例的清单 5-4 跟踪根目录中包含的文件和目录到控制台。
let iterator = new Iterator(root);
let item;
while (item = iterator.next()) {
if (undefined === item.length)
trace(`${item.name.padEnd(32)} directory\n`);
else
trace(`${item.name.padEnd(32)} file ${item.length}` +
"bytes\n");
}
Listing 5-4.
next方法返回一个具有描述该项目的属性的对象。name属性总是存在。length属性只存在于文件中,表示文件中的字节数。没有单独的属性来指示该项是文件还是目录,因为有了length属性就足够了。
迭代器实例有一个close方法,可以调用它来释放迭代器使用的系统资源。然而,这通常是不必要的,因为迭代器实现在到达项目末尾时会自动释放所有系统资源。
Iterator类一次返回一个项目,而不是所有项目的列表,以保持内存使用最小化。项目返回的顺序取决于底层文件系统的实现。例如,在一般情况下,您不能假定项目是按字母顺序返回的,或者目录是在文件之前返回的。
用 JavaScript 迭代器迭代
JavaScript 语言提供了迭代器特性,使得编写使用迭代器的代码更加容易。例如,您可以使用for - of循环语法来遍历条目。这个语言特性适用于任何实现了迭代器协议的实例,而file模块的Iterator类就是这样做的。这种方法对您来说更简洁,但代价是使用更多的内存和 CPU 时间。清单 5-5 改编清单 5-4 以使用 JavaScript 迭代器。
for (let item of new Iterator(root)) {
if (undefined === item.length)
trace(`${item.name.padEnd(32)} directory\n`);
else
trace(`${item.name.padEnd(32)} file ${item.length}` +
"bytes\n");
}
Listing 5-5.
迭代器真正出彩的地方是作为操作迭代器的函数的输入。例如,如果您需要一个包含目录中所有项目的数组,您可以简单地将迭代器实例传递给Array.from。
let items = Array.from(new Iterator(root));
获取文件系统信息
file模块的System对象包含一个info方法来提供关于每个文件系统根的信息。您可以使用此方法来确定可用存储的总字节数和当前使用的字节数。
let info = System.info(root);
trace(`Used ${info.used} of ${info.total}\n`);
偏好;喜好;优先;参数选择
首选项是在物联网产品的微控制器上存储数据的另一个工具。它们比文件更有效,但也更有限。文件非常适合存储大量信息,而首选项只存储少量信息。通常在你的产品中,你只需要记录少量的用户设置,在这些情况下你只需要偏好;您甚至可以将文件系统从您的产品中完全排除。
使用首选项的另一个优点是它们的可靠性。ESP32 和 ESP8266 首选项的实现采取措施确保首选项数据不会被破坏,即使在更新首选项时断电也是如此。在文件系统中更难达到这种级别的可靠性,因为数据结构更复杂。
Preference类
preference模块提供对首选项的访问。要在代码中使用首选项,请从preference模块中导入Preference类。
import Preference from "preference";
本章介绍的 JavaScript 首选项 API 在微控制器之间是相同的;然而,底层实现是不同的。例如,在 ESP32 上,首选项是使用 ESP32 IDF SDK 中的 NVS 库实现的,而在 ESP8266 上,首选项是由可修改的 SDK 实现的,因为没有系统提供的等效项。因为实现不同,所以行为也不同。以下部分指出了您需要记住的差异。
首选项名称
每个首选项由两个值标识,一个域和一个名称。这些类似于一个简单的文件系统路径:域就像目录名,名称就像文件名。例如,考虑一个 Wi-Fi 灯,您想要保存用户设置以便在打开电源时恢复。你可以使用一个light域作为所有灯光状态的首选项,用on、brightness和color作为名称。灯可以将统计数据(例如灯被打开的次数)保存在另一个域中,例如stats。
首选项的域名和名称值始终是字符串。ESP32 上的名称限制为 15 个字节,ESP8266 上的名称限制为 31 个字节。
偏好数据
首选项不是用来替换文件系统的;试图那样使用它们是一个常见的错误。因为每个单个首选项的大小是有限的,所有首选项可用的总存储空间也是有限的,所以它们远不如文件系统通用。
每个首选项都有一个数据类型:布尔值、32 位有符号整数、字符串或ArrayBuffer。不支持浮点数值。字符串类型通常是最方便使用的类型,但也是存储空间使用效率最低的类型。如果您需要在一个首选项中组合几个值,可以考虑使用一个ArrayBuffer。
当您写入一个值时,该值的类型是基于所提供的数据建立的。要更改类型,请再次写入值。当您读取一个值时,返回的值与写入的值具有相同的类型。
请注意 ESP32 和 ESP8266 上的偏好数据之间的差异:
-
在 ESP32 上,首选项数据空间是可配置的,在本书使用的主机中设置为 16 KB。在 ESP8266 上,偏好数据的空间为 4 KB。
-
在 ESP32 上,每个首选项最多可以有 4,000 字节的数据;在 ESP8266 上,该值限制为 64 字节。如果您正在编写希望在几种不同的微控制器平台上运行的代码,您需要为 64 字节数据大小设计您的首选值。
阅读和写作偏好
因为首选项只是具有某种类型的小块数据,所以它们比文件更容易读取和写入。来自$EXAMPLES/ch5-files/preferences示例的清单 5-6 将四个首选项写入example域。每个值的类型用作首选项名称。set实现基于第三个参数中传递的值来确定首选项的类型。
Preference.set("example", "boolean", true);
Preference.set("example", "integer", 1);
Preference.set("example", "string", "my value");
Preference.set("example", "arraybuffer",
Uint8Array.of(1, 2, 3).buffer);
Listing 5-6.
使用静态的get调用来检索偏好值,如清单 5-7 所示。返回值的类型与set调用中使用的值的类型相匹配。
let a = Preference.get("example", "boolean"); // true
let b = Preference.get("example", "integer"); // 1
let c = Preference.get("example", "string"); // "my value"
let d = Preference.get("example", "arraybuffer");
// ArrayBuffer of [1, 2, 3]
Listing 5-7.
如果没有找到具有指定域名和名称的首选项,get调用返回undefined:
let on = Preference.get("light", "on");
if (undefined === on)
on = false;
删除首选项
使用delete方法删除首选项:
Preference.delete("example", "integer");
如果找不到具有指定域名和名称的首选项,则不会引发错误。如果在更新闪存以删除首选项时出现错误,delete会抛出一个错误。
不要用 JSON
当用 JavaScript 为 web 或计算机构建产品时,通常使用 JSON 存储参数——这是一种非常容易编码且非常灵活的方法。当使用 JavaScript 创建一个嵌入式产品时,很容易做同样的事情;然而,尽管它在某些产品中有效,但并不推荐使用,因为它更有可能在开发过程的后期导致失败。请考虑以下几点:
-
将首选项存储在 JSON 文件中要求您的项目包含一个文件系统——大量代码会占用您的闪存中有限的空间。
-
JSON 对象必须一次加载到内存中,这意味着访问一个参数值需要足够的内存来保存所有参数值。
-
从文件中加载 JSON 字符串数据,然后将它解析成 JavaScript 对象,这比只从首选项中加载一个值要花费更多的时间。
-
文件系统对电源故障的容错能力通常不如首选项。因此,用户设置丢失的可能性更大。
使用 JSON 似乎也是在单个首选项中存储多个值的好方法。这确实有效,但是它有两个限制,这使得它在许多情况下不是一个明智的选择。首先,因为在某些设备上,首选项数据被限制为只有 64 个字节,所以不能以这种方式组合很多值。其次,JSON 格式的开销几乎肯定意味着偏好数据比其他方法使用更多的存储空间。例如,以下代码使用 24 字节的存储空间将三个小整数值存储为 JSON:
Preference.set("example", "json",
JSON.stringify({a: 1, b: 2, c: 3}));
相比之下,这个例子通过使用Uint8Array只需要三个字节:
Preference.set("example", "bytes",
Uint8Array.of(1, 2, 3).buffer);
从 JSON 版本中读取值更容易:
let pref = JSON.parse(Preference.get("example", "json"));
从存储效率更高的版本中读取值需要一行额外的代码:
let pref = new Uint8Array(Preference.get("example", "bytes"));
pref = {a: pref[0], b: pref[1], c: pref[2]};
安全
preference模块不保证偏好数据的安全性。域、名称和值可以“明文”存储,无需任何加密或混淆。与文件中的用户数据一样,您应该在产品中采取适当的步骤来确保用户数据得到充分的保护。物联网产品中通常存储的敏感用户数据包括 Wi-Fi 密码和云服务帐户标识符。至少,您应该考虑对这些值应用某种形式的加密,以便扫描设备闪存的攻击者无法读取它们。
一些主机确实为偏好数据提供加密存储。例如,通过额外的配置,这在 ESP32 上是可用的。
资源
资源是处理只读数据的工具。它们是在项目中嵌入大量数据的最有效方式。资源通常在存储它们的闪存中就地访问,因此无论资源数据有多大,都不使用 RAM。可修改的 SDK 将资源用于许多不同的目的,包括 TLS 证书、图像和音频,但是对可以存储在资源中的数据种类没有限制。
$EXAMPLES/ch5-files/resources示例托管一个由mydata.dat定义的简单 web 页面,它作为一个资源包含在内。运行该示例后,打开 web 浏览器并输入设备的 IP 地址,您将看到一个显示“Hello,world”的网页。
向项目添加资源
在项目中包含资源需要两个步骤:
-
将包含资源数据的文件添加到项目中。资源文件通常放在子目录中,比如
assets、data或者resources,但是你可以把它们放在你喜欢的任何地方。 -
您将文件添加到清单的
resources部分,告诉构建工具将文件的数据复制到资源中。
清单 5-8 来自resources示例的清单。它只包括一个资源mydata.dat,来自包含清单的目录。
"resources": {
"*": [
"./mydata"
],
},
Listing 5-8.
数据文件必须有一个.dat扩展名。但是,清单中的文件名不得包含扩展名;构建工具会自动定位扩展名为.dat的文件。重要的是,不要包含几个同名但扩展名不同的文件(例如,mydata.dat和mydata.bin),因为工具可能无法首先找到您期望的文件。
本章描述了直接从输入文件复制到输出二进制文件的资源数据,没有任何改变。构建工具还能够对数据进行转换,例如将图像转换为针对目标微控制器优化的格式;第八章解释了如何使用资源转换。
访问资源
要访问资源,从resource模块导入Resource类:
import Resource from "resource";
使用清单中的资源路径调用Resource类构造函数。注意,在这种情况下,路径总是包含文件扩展名— .dat。
let data = new Resource("mydata.dat");
如果Resource构造函数找不到请求的资源,就会抛出一个错误。如果您想在调用构造函数之前检查资源是否存在,请使用静态的exists方法:
if (Resource.exists("mydata.dat")) {
let data = new Resource("mydata.dat");
...
}
使用资源
Resource构造函数将二进制数据作为HostBuffer返回。HostBuffer类似于ArrayBuffer,但与ArrayBuffer不同的是,HostBuffer的数据可能是只读的,因此可能位于闪存中。
要获得资源中的字节数,使用byteLength属性,就像使用ArrayBuffer一样:
let r1 = new Resource("mydata.dat");
let length = r1.byteLength;
与ArrayBuffer一样,您不能直接访问HostBuffer的数据,而必须将其包装在类型化数组或数据视图中。以下示例将资源包装在一个Uint8Array中,并将值跟踪到控制台:
let r1 = new Resource("mydata.dat");
let bytes = new Uint8Array(r1);
for (let i = 0; i < bytes.length; i++)
trace(bytes[i], "\n");
此示例将资源包装在一个DataView对象中,以大端 32 位无符号整数的形式访问其内容:
let r1 = new Resource("mydata.dat");
let view = new DataView(r1);
for (let i = 0; i < view.byteLength; i += 4)
trace(view.getUint32(i, false), "\n");
有时您想要修改资源中的数据。因为数据是只读的,你需要做一个拷贝。由Resource构造函数返回的HostBuffer有一个slice方法,可以用来复制资源数据,与ArrayBuffer实例上的slice方法相同。例如,您可以将整个资源复制到 RAM 中的ArrayBuffer中,如下所示:
let r1 = new Resource("mydata.dat");
let clone = r1.slice(0);
slice的第一个参数是要复制的数据的起始偏移量。可选的第二个参数是要复制的结束偏移量;如果省略,数据将复制到资源的末尾。以下示例从字节 20 开始提取 10 个字节的资源数据:
let r1 = new Resource("mydata.dat");
let fragment = r1.slice(20, 30);
slice方法支持可选的第三个参数,ArrayBuffer没有提供这个参数。该参数控制是否将数据复制到 RAM 中。如果它被设置为false,那么slice返回一个HostBuffer,引用资源数据的一个片段,这在您想要将资源的一部分与一个对象相关联而不将其数据复制到 RAM 中时非常有用。例如,如果在资源的偏移量 32 处有一个包含五个无符号 16 位数据的数组,您可以创建一个引用它的Uint16Array,如下所示:
let r1 = new Resource("mydata.dat");
let values = new Uint16Array(r1.slice(32, 10, false));
您可以通过使用Uint16Array构造函数的可选参数byteOffset和length获得类似的结果:
let r1 = new Resource("mydata.dat");
let values = new Uint16Array(r1, 32, 10);
使用slice的优点是它确保了不受信任的代码无法使用全部资源来访问values数组。在前面两个例子的第一个中,values.buffer可以访问整个资源,而在第二个例子中,它只能用来访问Uint16Array中的五个值。
直接访问闪存
本章中描述的所有用于存储和检索数据的模块— files、preferences和resources—都使用连接到控制器的闪存来存储数据。每种处理闪存中数据的方法都有自己的优点和局限性。在大多数情况下,这些方法中的一种非常适合您的产品需求;在某些情况下,更专业的方法可能更有效。flash模块可让您直接访问闪存。用好它需要更多的工作,但在某些情况下这是值得的。
Warning
这是一个高级话题。直接访问闪存是危险的。您可能会使设备崩溃或损坏您的数据。您甚至可能损坏闪存,使您的设备无法使用。小心行事!
闪存硬件基础
为了能够使用由flash模块提供的 API,理解闪存硬件的基础很重要。
ESP32 和 ESP8266 微控制器使用的闪存通过 SPI(串行外设接口)总线连接。虽然访问速度相当快,但仍然比访问 RAM 中的数据慢很多倍。
闪存被组织成块(也称为“扇区”)。块的大小取决于所使用的闪存组件。常见的值是 4,096 字节。当你读写闪存时,你通常不需要知道块的大小。然而,在初始化闪存时,块的大小很重要。
闪存使用 NOR 技术来存储数据。这有一个奇怪的含义,即闪存的一个擦除字节的所有位都设置为 1,而通常认为擦除存储器设置为 0。您可能认为可以简单地将新擦除的字节设置为全零,但正如您将看到的,这对于 NOR 闪存来说并不是一个好主意。
当写入 NOR 闪存时,您只写入 0 位。因为闪存被擦除为全 1 位,所以这在第一次写入时无关紧要。考虑两个字节(16 位)的闪存。它们开始时被擦除为全 1 位。
11111111 11111111
向其中写入两个字节,1 和 2,结果很简单:
00000001 00000010
接下来就是结果出乎意料的地方了。下面是将两个字节 2 和 1 写入同一位置时的情况:
00000000 00000000
结果是两个字节都被设置为 0。为什么呢?请记住,对于 NOR 闪存,写操作仅设置 0 位。flash 存储器中已经设置为 0 的任何位都不能通过写操作变回 1。
-
闪 0。写入 0 = >闪存 0。
-
闪 0。写入 1 = >闪存 0。
-
闪电侠 1。写入 0 = >闪存 0。
-
闪电侠 1。写入 1 = >闪存 1。
如果写操作只能将位从 1 更改为 0,位如何从 0 更改为 1?你用 flash erase的方法做到这一点。与read和write可以直接访问闪存中的任何字节不同,erase是一个批量操作,将闪存块中的所有位设置为 1。擦除与块大小边界对齐的块,这意味着字节 0 到 4,095 或字节 4,096 到 8,191,而不是 1 到 4,096,因为它们没有与块的开始对齐,也不是字节 1 到 2,因为它们不是完整的块。
如果你想改变一个位,你可以将整个块读入 RAM,擦除块,改变 RAM 中的位,然后将块写回。这可行,但是很慢,因为erase是一个相对较慢的操作——比read和write慢很多倍。这种方法还需要足够的 RAM 来容纳一个完整的模块,而在资源受限的微控制器上并不总是有那么多内存。然而,最大的问题是闪存会磨损。每个块仅可被擦除特定次数,此后该块不再可靠地存储数据;为了保护设备,您需要尽量减少擦除每个块的次数。
好消息是,您的 ESP32 或 ESP8266 中的闪存支持成千上万的擦除操作。preference和file模块实施了解 NOR 闪存的限制和特性,并采取措施尽量减少擦除。如果你在一个打算使用多年的产品中直接访问闪存,你也需要这样做。
一种常用的策略是增量写入。在这种方法中,当前值被置零,新值被写入块中的零之后。这使得单个值可以多次更新,而无需擦除。此方法由preference模块使用。本节后面频繁更新的整数示例探究了增量写入的详细信息。
另一种常见策略是损耗均衡。这种方法试图在产品的生命周期内以相同的次数擦除每个闪存存储块,以确保没有任何块(例如,第一个块)会因为更频繁的访问而比其他块磨损得更快。模块底层的 SPIFFS 文件系统使用了这种技术。
访问闪存分区
使用flash模块中的Flash类访问微控制器可用的闪存:
import Flash from "flash";
闪存分为称为*分区的段。*例如,一个分区包含您的项目代码,另一个包含偏好数据,另一个存储 SPIFFS 文件系统。每个分区由一个名称标识。
要访问分区中的字节,用分区名实例化Flash类。当您使用第一章中介绍的mcrun安装示例应用程序时,应用程序的字节码存储在xs分区中。下面一行实例化了Flash类来访问它:
let xsPartition = new Flash("xs");
代码可用的分区根据微控制器和主机实现而有所不同。包含用mcrun安装的应用程序的xs分区总是可用的。用于 SPIFFS 文件系统的区域名为storage,通常也总是可用的;如果您的项目中没有使用 SPIFFS 文件系统,您可以将它用于其他目的。尽管这些分区都存在,但它们的大小因设备而异。
在 ESP32 上,来自 Espressif 的 ESP32 IDF 定义了分区。IDF 提供了一种灵活的分区机制,使您可以定义自己的分区。在 ESP8266 上,可修改的 SDK 定义了分区,并且它们不容易重新配置。
在 ESP32 上,Flash构造函数搜索 IDF 分区图以匹配所请求的分区名。因此,您可以访问包含 ESP32 首选项(在 NVS 库中实现)的分区,其名称为nvs,如分区图中所声明的(IDF 项目中的partitions.csv文件)。
let nvsPartition = new Flash("nvs");
获取分区信息
Flash类的一个实例有两个只读属性,提供关于分区的重要信息:blockSize和byteLength。
blockSize属性表示闪存硬件的单个块中的字节数。这个值通常是 4,096,但是为了保证健壮性,您应该使用blockSize属性,而不是在代码中硬编码一个常量值。这样,您的代码可以在包含不同 flash 硬件组件的硬件上不加更改地工作。
let storagePartition = new Flash("storage");
let blockSize = storagePartition.blockSize;
blockSize属性很重要,因为它告诉您分区上擦除操作的对齐方式和大小。
byteLength属性提供分区中可用的字节总数。以下示例计算分区中的块数:
let blocks = storagePartition.byteLength / blockSize;
byteLength属性的值总是blockSize属性的值的整数倍,所以块数总是整数。
从闪存分区读取
使用read方法从闪存分区获取字节。read方法有两个参数:分区中的偏移量和要读取的字节数。read调用的结果是一个ArrayBuffer。以下是摘自$EXAMPLES/ch5-files/flash-readwrite的例子:
let buffer = partition.read(0, 10);
let bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++)
trace(bytes[i] + "\n");
这段代码从分区中检索前十个字节。它将返回的ArrayBuffer包装在一个Uint8Array中,以跟踪到控制台的字节值。
除了需要在分区内之外,对偏移量和要读取的字节数没有限制。具体来说,对read的单次调用可能会跨越块边界。
read调用将请求的数据从分区复制到新的ArrayBuffer。因此,您应该以小片段读取闪存,以尽可能少地使用 RAM。
擦除闪存分区
使用erase方法将闪存分区中的所有位重置为 1。该方法采用一个参数,即要重置的块数。这一行擦除分区的第一个块:
partition.erase(0);
下面的代码重置整个分区。擦除操作相对较慢;对于大型分区,例如 ESP8266 上的存储分区,此操作需要几秒钟的时间。
let blocks = partition.byteLength / partition.blockSize;
for (let block = 0; block < blocks; block++)
partition.erase(block);
写入闪存分区
使用write方法改变闪存分区中存储的值。这个方法有三个参数:将数据写入分区的偏移量、要写入的字节数和包含数据的ArrayBuffer。当要写入的字节数小于ArrayBuffer的大小时,只写入该数量的字节。以下示例将分区的前十个字节设置为 1 到 10 之间的整数:
let buffer = Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).buffer;
partition.write(0, 10, buffer);
请记住,写操作只会设置 0 位,如“闪存硬件基础”部分所述。因此,可能有必要在调用write之前执行擦除。
映射闪存分区
在 ESP32 上,您可以选择内存映射分区,这允许您使用类型化数组或数据视图构造函数对分区的内容进行只读访问。要对一个分区进行内存映射,调用map方法。以下代码摘自$EXAMPLES/ch5-files/flash-map示例:
let partition = new Flash("storage");
let buffer = partition.map();
let bytes = new Uint8Array(buffer);
map属性返回一个HostBuffer,它可以被传递给类型化数组或数据视图构造函数来访问数据。在某些情况下,内存映射分区是比read调用更方便的数据访问方式。此外,因为分区中的数据不是通过map方法复制到 RAM 的,所以 RAM 的使用被最小化。
由于硬件限制,ES8266 上的map方法不可用,硬件限制只允许闪存第一兆字节的内存映射,这是为存储固件而保留的区域。
示例:频繁更新的整数
本节给出了一个直接访问 flash 存储器的例子,与使用文件或首选项相比,它可以更有效地维护 32 位值。该示例适用于您的产品需要频繁更新闪存存储中的值,以确保在产品重启后仍能可靠地维护该值的情况。
该示例使用单块闪存。这通常是 4,096 字节,比存储的 32 位(4 字节)值大 1,024 倍。该示例利用附加存储来减少擦除操作的次数,从而延长闪存的寿命。为了方便起见,使用的块是storage分区的第一个块,这使得这个例子不能用于 SPIFFS 文件系统。
完整的频繁更新整数示例可在$EXAMPLES/ch5-files/flash-frequentupdate获得。
正在初始化块
第一步是打开存储分区:
let partition = new Flash("storage");
如清单 5-9 所示,下一步是检查该块是否已经初始化。这是通过在块的开始处寻找唯一的签名来完成的。如果找不到签名,则擦除该块并写入签名。
const SIGNATURE = 0xa82aa82a;
let signature = partition.read(0, 4);
signature = (new Uint32Array(signature))[0];
if (signature !== SIGNATURE)
initialize(partition);
function initialize(partition) {
let signature = Uint32Array.of(SIGNATURE);
partition.erase(0);
partition.write(0, 4, signature.buffer);
}
Listing 5-9.
更新值
签名后,该块有空间存储计数器的 1023 个副本。清单 5-10 显示了一个更新计数器值的write函数。它在块中搜索第一个未使用的 32 位整数,并将值写入其中。回想一下,当一个块被擦除时,所有的位都被设置为 1。这意味着任何未使用的条目都包含值0xFFFFFFFF(一个所有位都设置为 1 的 32 位整数)。如果该块已满,它会重新初始化该块,并将值写入第一个空闲位置。
function write(partition, newValue) {
for (let i = 1; i < 1024; i++) {
let currentValue = partition.read(i * 4, 4);
currentValue = (new Uint32Array(currentValue))[0];
if (0xFFFFFFFF === currentValue) {
partition.write(i * 4, 4, Uint32Array.of(newValue).buffer);
return;
}
}
initialize(partition);
partition.write(4, 4, Uint32Array.of(newValue).buffer);
}
Listing 5-10.
读取数值
最后一部分是read函数,如清单 5-11 所示。像write函数一样,它搜索第一个自由条目。找到后,read返回前一个条目的值。如果搜索到达了块的末尾,则返回块中的最后一个值。
function read(partition) {
let i;
for (i = 1; i < 1024; i++) {
let currentValue = partition.read(i * 4, 4);
currentValue = (new Uint32Array(currentValue))[0];
if (0xFFFFFFFF === currentValue)
break;
}
let result = partition.read((i - 1) * 4, 4);
return (new Uint32Array(result))[0];
}
Listing 5-11.
益处和未来工作
这个例子有效地将一个整数值存储在闪存中。在该块需要被擦除之前,该值可以被更新 1023 次。为了理解这种影响,考虑一个每分钟更新一次该值的产品。这相当于每年 514 次擦除操作(60 * 24 * 365,即每年 525,600 分钟,除以每次擦除的 1,023 次更新,结果为 514 次)。使用支持 10,000 次擦除操作(保守估计)的闪存芯片,该产品的寿命约为 19.5 年。如果每次写入操作都需要擦除,则同一产品仅用 7 天就会耗尽(60 * 24 * 7 等于每周 10,080 次写入)。
细心的读者注意到了这个例子的两个局限性:如果在擦除之后和写入之前在write函数中断电,那么当前值将会丢失;并且该值不能被设置为0xffffffff,因为该值用于标识块中未使用的条目。这些缺点的解决方案是可能的,留给读者作为练习。
结论
在本章中,您学习了在嵌入式产品中存储信息的几种不同方法。文件、首选项和资源是存储数据的三种主要方式,每种方式都针对不同的存储用途进行了优化。您可以在您的产品中使用这些方法的任意组合。设计产品时,请考虑您的存储需求,以确定使用哪种方法来最佳利用可用存储。有些情况非常特殊,没有一种标准存储技术是最佳的;为了解决这些情况,本章展示了闪存如何工作,以便您可以创建自己的存储方法。
六、硬件
传感器和执行器是几乎所有物联网产品不可或缺的组成部分。传感器从环境中收集数据,如温度、湿度和光照水平,并将其转化为微控制器或其他系统可以做出反应的电信号。致动器的作用正好相反:它们接收电信号,并将其转化为物理动作,比如启动马达、打开灯或播放声音。
正如有不同的网络协议定义如何在网络上共享数据一样,也有不同的硬件协议定义传感器和执行器如何与其连接的微控制器通信。可修改的 SDK 包括用于各种硬件协议的 JavaScript APIs,包括数字、模拟、PWM、伺服和 I 2 C。这些 API 使您能够从 ESP32 或 ESP8266 与现成的硬件或您自己的电路进行交互。
在本章中,您将学习如何开始编写自己的 JavaScript 代码来与硬件交互。本章包括许多例子,只需要几个简单,广泛可用,廉价的传感器和执行器。
本章中的代码使用不同的硬件协议直接与硬件通信。一旦您学会了如何使用一些常见的硬件协议,您就拥有了将使用这些协议的许多硬件组件整合到您自己的项目中所需的知识。当给计算机安装新硬件时,通常需要安装软件驱动程序,即知道如何通过底层硬件协议与硬件交互的软件;实际上,本章教你如何为各种硬件组件编写软件驱动程序。以这种方式直接控制硬件的物联网产品有很多好处,包括更精确的控制、更小的代码和更低的延迟。当然,许多组件也有软件驱动程序;在可修改的 SDK 中,您可以在modules/drivers中找到它们。
安装硬件主机
本章中的示例使用第一章中描述的模式进行安装:您使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
主机在$EXAMPLES/ch6-hardware/host目录中。从命令行导航到这个目录,用mcconfig安装它。
接线注意事项
与本书中的其他章节不同,这一章要求你在运行大多数示例之前对你的设备进行额外的设置:你需要将各种传感器和执行器连接到你的设备上。如果你是新手,开始时可能会感到困惑。如果您以前这样做过,您会知道很容易出错,并且故障排除有时需要时间。本节提供了在运行示例之前应该了解的关于连接的重要信息。
遵循接线说明
本章提供了示例中使用的大多数传感器和致动器的接线表和接线图。布线图显示了 NodeMCU 板的布线,因此也显示了 NodeMCU 管脚号,如 D6 或 D7。这些标签不一定与代码中使用的 GPIO 号匹配。如果您使用的是不同的开发板,请确保查看接线表,该表提供了 GPIO 号,例如 GPIO12 或 GPIO13,以及括号中的 NodeMCU 引脚号。所有开发板的引脚标签都不同,因此您必须相应地映射引脚。可修改开发板标有“GP ”,后面是代码中使用的 GPIO 号,例如 GP12 或 GP13,因此,如果接线表显示某个引脚应连接到 GPIO12,请将其插入可修改板上标有 GP12 的引脚。
布线问题故障排除
仔细遵循接线说明很重要。如果您在布线中出错,可能会发生一些事情:
-
会引发一个错误。这很常见,通常也是最容易解决的问题。例如,如果您交换 I 2 C 传感器的 SDA 和 SCL 引脚,您将在读取和写入时出错。利用
xsbug并使用错误消息来诊断您的问题。有时候你只需要修理你的电线;其他时候,您可能有一个有故障的传感器或执行器。 -
该应用程序可以工作,但会产生意想不到的结果。这也很常见,但是很难捕捉到。例如,如果您将传感器的数字引脚插入开发板上的错误引脚,应用程序会从没有连接任何东西的引脚读取数据;它不会抛出错误,但会给出意外的结果。如果你按下一个按钮,应用程序没有像预期的那样响应,或者你正在写一个三色 LED 的引脚,颜色没有更新,请仔细检查你的接线。
-
你破坏了你的传感器或执行器。与前两个问题相比,这种情况不太常见,但也有可能发生。例如,当传感器设计为接受 3.3V 电压时,用 5V 电压供电会损坏传感器上的电子元件。
LED 闪烁
您可以使用 ESP32 或 ESP8266 创建的最简单的物理输出是打开和关闭板上 LED(图 6-1 )。ESP32 和 ESP8266 NodeMCU 板都有一个板载 LED 连接到引脚 2。
图 6-1
ESP8266(顶部)和 ESP32(底部)上的板载 LED
Digital类提供对设备上 GPIO 引脚的访问:
import Digital from "pins/digital";
您可以为输入或输出配置数字引脚。配置完成后,引脚的值可以是 1,表示电压为高电平,也可以是 0,表示电压为低电平。$EXAMPLES/ch6-hardware/blink示例使用Digital类和一个定时器来闪烁板载 LED。如清单 6-1 所示,该示例使用了Digital类的静态write方法,该方法将引脚(由第一个参数指定)设置为Digital.Output模式,并将其值设置为 0 或 1(第二个参数)。
let blink = 1;
Timer.repeat(() => {
blink = blink ^ 1;
Digital.write(2, blink);
}, 200);
Listing 6-1
或者,您可以构造一个Digital类的实例,并调用该实例的write方法。使用构造函数允许完整配置 pin。当您调用构造函数时,您传入一个带有pin和mode属性的字典。以下模式值可用于数字输出引脚:
Digital.Output
Digital.OutputOpenDrain
清单 6-2 展示了编写blink例子的另一种方式,使用Digital构造函数。
let led = new Digital({
pin: 2,
mode: Digital.Output
});
let blink = 1;
Timer.repeat(() => {
blink = blink ^ 1;
led.write(blink);
}, 200);
Listing 6-2.
使用Digital的实例比使用静态Digital.write方法更有效;构造函数初始化 pin 一次,而Digital.write必须在每次写操作时初始化它。Digital.write对于不频繁的写入很方便,但是如果您的项目频繁地写入数字输出,则创建一次实例并写入它。
阅读按钮
按钮是向项目添加物理输入的一种简单方式。ESP32 和 ESP8266 NodeMCU 模块内置了两个按钮。其中一个按钮连接到数字引脚 0,可以在您的项目中用作数字输入;根据您使用的模块,此按钮标记为 FLASH、BOOT 或 IO0。
$EXAMPLES/ch6-hardware/button示例使用了Digital类和一个计时器来读取板上按钮。如清单 6-3 所示,该示例使用了Digital类的静态read方法,该方法将引脚(由第一个参数指定)设置为Digital.Input模式并读取其值,返回 0 或 1。每次按下按钮时,该示例都会跟踪到调试控制台。它还维护按钮按下次数的计数,并将其包含在输出中。
let previous = 1;
let count = 0;
Timer.repeat(id => {
let value = Digital.read(0);
if (value !== previous) {
if (value)
trace(`button pressed: ${++count}\n`);
previous = value;
}
}, 100);
Listing 6-3
或者,您可以构造一个Digital类的实例,并调用该实例的read方法。使用构造函数允许完整配置 pin。当您调用构造函数时,您传入一个带有pin和mode属性的字典。以下模式值可用于数字输入引脚:
Digital.Input
Digital.InputPullUp
Digital.InputPullDown
Digital.InputPullUpDown
清单 6-4 展示了如何重写button示例以使用Digital构造函数。
let button = new Digital({
pin: 0,
mode: Digital.Input
});
let previous = 1;
let count = 0;
Timer.repeat(id => {
let value = button.read();
if (value !== previous) {
if (value)
trace(`button pressed: ${++count}\n`);
previous = value;
}
}, 100);
Listing 6-4.
其他数字输入模式
模式Digital.InputPullUp、Digital.InputPullDown和Digital.InputPullUpDown用于使能上拉和下拉电阻,这些电阻内置于 ESP32 和 ESP8266 的一些 GPIO 引脚中。这并不总是必要的,但对于如图 6-2 所示的按钮很有用,这需要一个下拉电阻来防止它在未按下的状态下接收随机噪声。你可以从 SparkFun(产品 ID COM-10302)和 Adafruit(产品 ID 1009)获得类似这样的按钮。
图 6-2
触觉按钮
$EXAMPLES/ch6-hardware/external-button示例与button示例具有相同的功能,但它使用的是类似图 6-2 中的按钮,而不是内置按钮。如果您想要运行此示例,首先按照此处给出的接线说明将其连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 6-1 和图 6-3 显示了如何将按钮连接到 ESP32。
图 6-3
将按钮连接到 ESP32 的接线图
表 6-1
将按钮连接到 ESP32 的接线
|纽扣
|
ESP32
| | --- | --- | | 压水反应堆 | 3V3 | | 联邦德国工业标准 | GPIO16 (RX2) |
ESP8266 接线说明
表 6-2 和图 6-4 显示了如何将按钮连接到 ESP8266。
图 6-4
将按钮连接到 ESP8266 的接线图
表 6-2
将按钮连接到 ESP8266 的接线
|纽扣
|
ESP8266
| | --- | --- | | 压水反应堆 | 3V3 | | 联邦德国工业标准 | GPIO16 (D0) |
理解external-button代码
external-button示例使用了Digital构造函数,如下面的代码所示。它用模式Digital.InputPullDown配置引脚 16,使能引脚 16 上的内置下拉电阻。
let button = new Digital({
pin: 16,
mode: Digital.InputPullDown
});
代码的其余部分与重写的button示例(列表 6-4 )非常相似,除了一些小的改动以说明下拉电阻的使用。
关于上拉和下拉电阻的更多信息
ESP32 和 ESP8266 都在引脚 16 上有一个内置下拉电阻,这就是为什么external-button示例可以在任一引脚上运行,而无需对代码进行任何更改。也就是说,您可以修改它,使其使用内置下拉电阻的任何引脚,并且您构建的其他应用程序也可以使用其他引脚。ESP32 在除引脚 34–39 之外的所有 GPIO 引脚上都有内置下拉电阻,而 ESP8266 仅在引脚 16 上有内置下拉电阻。
其他传感器可能需要上拉电阻。除了引脚 34–39,ESP32 在所有 GPIO 引脚上都有内置上拉电阻;ESP8266 在 GPIO 引脚 1–15 上有内置上拉电阻。
除了使用内置电阻,您还可以将上拉或下拉电阻直接添加到传感器。如果这样做,您可以使用任何 GPIO 引脚,而不仅仅是带有内置电阻的引脚。还要注意,如果你这样做,你应该总是使用模式Digital.Input。换句话说,如果向传感器本身添加下拉电阻,不要使能内置下拉电阻;同样,如果向传感器本身添加上拉电阻,也不要使能内置上拉电阻。
监控变更
通过使用 digital Monitor类,可以更有效地检测数字输入值的变化。它使用微控制器的功能来监控变化,而不是定期轮询。Monitor的实例被配置为触发从 0 到 1 的变化(即上升沿)和/或从 1 到 0 的变化(下降沿)。当硬件检测到一个触发事件时,Monitor类调用一个回调函数。
清单 6-5 展示了button示例如何使用Monitor类。请注意,这个版本要小得多。
let monitor = new Monitor({
pin: 0,
mode: Digital.Input,
edge: Monitor.Rising
});
let count = 0;
monitor.onChanged = function() {
trace(`button pressed: ${++count}\n`);
}
Listing 6-5.
最初的button示例使用了value和previous变量来跟踪按钮的状态;使用Monitor类大大简化了代码,因为该类跟踪按钮本身的状态,仅在状态改变时通知应用程序。
像Digital构造函数一样,Monitor构造函数接受一个带有pin和mode属性的字典。它还包括一个edge属性,指定触发onChanged回调的事件;edge可能是Monitor.Rising、Monitor.Falling,也可能是Monitor.Rising | Monitor.Falling。应用程序必须在实例上安装一个onChanged回调,在指定的边缘事件发生时被调用。
使用Monitor类代替轮询的好处不仅仅是简化了代码。因为该类使用微控制器的内置硬件来检测变化,所以不需要运行任何代码来监视变化,从而为其他工作释放 CPU 周期。此外,监视器会立即检测到更改,而轮询方法每隔 100 毫秒才检查一次更改。当然,您可以更频繁地轮询,但是这需要更多的 CPU 周期。此外,轮询方法会错过两次读取之间发生的快速按键,而监视器总是处于活动状态,因此不会错过任何按键。
控制三色 LED
与前面的blink示例中的基本开/关单色 led 不同,三色 LED(也称为 RGB LED)将三种 LED(红色、绿色和蓝色)结合到一个封装中,使您能够精确控制颜色和亮度。控制三色 LED 的三种颜色需要四个引脚:一个引脚控制三个 LED 中的每一个,加上一个由所有颜色共享的电源引脚。
本节中的示例假设您使用的是普通阳极 LED,如图 6-5 所示,可从 SparkFun(产品 ID COM-10821)和 Adafruit(产品 ID 159)获得。
图 6-5
三色 LED
在运行示例之前,请按照说明设置三色 LED,并按照接线说明将其连接到 ESP32 或 ESP8266。
LED 设置
如图 6-6 所示,LED 要求在除电源引脚之外的所有引脚上增加限流电阻,以防止它们消耗过多的电流。使用 330 欧姆的电阻。
图 6-6
带限流电阻的三色 LED
ESP32 接线说明
表 6-3 和图 6-7 显示了如何将 LED 连接到 ESP32。
图 6-7
将 LED 连接到 ESP32 的接线图
表 6-3
将 LED 连接到 ESP32 的接线
|发光二极管
|
ESP32
| | --- | --- | | 压水反应堆 | 3V3 | | 稀有 | GPIO12 (D12) | | G | GPIO13 (D13) | | B | GPIO14 (D14) |
ESP8266 接线说明
表 6-4 和图 6-8 显示了如何将三色 LED 连接到 ESP8266。
图 6-8
将 LED 连接到 ESP8266 的接线图
表 6-4
将 LED 连接到 ESP8266 的接线
|发光二极管
|
ESP8266
| | --- | --- | | 压水反应堆 | 3V3 | | 稀有 | GPIO12 (D6) | | G | GPIO13 (D7) | | B | GPIO14 (D5) |
使用带三色 LED 的Digital
三色 LED 的红色、绿色和蓝色引脚连接到数字输出。在$EXAMPLES/ch6-hardware/tricolor-led-digital示例中,可以使用与在blink示例中用于控制简单单色 led 的相同的Digital类来单独控制它们。如清单 6-6 所示,区别在于你可以通过混合三原色来显示八种不同的颜色。
let r = new Digital(12, Digital.Output);
let g = new Digital(13, Digital.Output);
let b = new Digital(14, Digital.Output);
Timer.repeat(() => {
// black (all off)
r.write(1);
g.write(1);
b.write(1);
Timer.delay(100);
// red (red on)
r.write(0);
Timer.delay(100);
// magenta (red and blue on)
b.write(0);
Timer.delay(100);
// white (all on)
g.write(0);
Timer.delay(100);
}, 1);
Listing 6-6.
三色 LED 不仅可以显示黑色、白色、红色、绿色、蓝色、品红色、青色和黄色的原色和二次色。为了实现这一点,你需要更多的控制,而不仅仅是简单地打开和关闭红色、绿色和蓝色发光二极管;您需要能够将它们设置为 on 和 off 之间的值,即 0 和 1 之间的值。数字输出不能做到这一点,因为它的输出总是 0 或 1。在下一节中,您将学习如何克服这个限制。
使用带三色 LED 的PWM
为了显示更大范围的颜色和亮度,三色 led 可以改为使用脉宽调制或 PWM 来控制,这是一种常用于电机和 LED(包括三色 LED)的特殊类型的数字信号。PWM 大致相当于模拟输出,但使用数字信号产生。更具体地说,数字引脚输出具有不同高低值宽度的方波。随着时间的推移,取这些高和低脉冲的平均值,产生与脉冲宽度成比例的高和低值之间的功率水平。结果是,您可以输出介于 0 和 1 之间的任何值,而不是仅限于 0 和 1 作为输出值。
PWM类提供对 PWM 输出引脚的访问。$EXAMPLES/ch6-hardware/tricolor-led-pwm的例子使用了PWM和一个定时器来循环显示不同的颜色。
import PWM from "pins/pwm";
该示例需要三个PWM类的实例,三色 LED 上的每根电线一个,用于控制一种颜色的亮度。PWM构造函数获取一个指定 pin 号的字典。
let r = new PWM({pin: 12});
let g = new PWM({pin: 13});
let b = new PWM({pin: 14});
write方法设置引脚的当前值。您传递的值是一个从 0 到 1023 的数字,要合成的模拟值。较低的值对应较高的亮度。当应用程序运行时,它使 LED 变成绿色。PWM 值为 0 相当于数字输出设为 0,PWM 值为 1023 相当于数字输出设为 1。以下代码通过将绿色 LED 设置为全亮度并将红色和蓝色 LED 设置为关闭,将三色 LED 设置为绿色:
r.write(1023);
g.write(0);
b.write(1023);
如清单 6-7 所示,代码通过调整单个管脚的值来循环显示颜色。首先,它通过降低蓝色引脚的值将颜色从绿色变为青色。在对write的调用之间,Timer类的delay方法用于将执行延迟 50 毫秒。
while (bVal >= 21) {
bVal -= 20;
b.write(bVal);
Timer.delay(50);
}
b.write(1);
Listing 6-7.
从绿色渐变为青色后,LED 从青色渐变为蓝色,蓝色渐变为洋红色,最后洋红色渐变为红色(列表 6-8 )。
while (gVal <= 1003) {
gVal += 20;
g.write(gVal);
Timer.delay(50);
}
g.write(1023);
while (rVal >= 21) {
rVal -= 20;
r.write(rVal);
Timer.delay(50);
}
r.write(0);
while (bVal <= 1003) {
bVal += 20;
b.write(bVal);
Timer.delay(50);
}
b.write(1023);
Listing 6-8.
旋转伺服系统
伺服电机是控制旋转输出的电机。输出可以精确地转向一个弧内的特定位置,通常为 180 度。伺服系统通常用于机器人学中,以控制机器人的运动,并用于旋转物体,如照相机的镜头,以控制聚焦和变焦。图 6-9 显示了 Adafruit 提供的微型伺服系统(产品 ID 169)。像这样的微型伺服系统可以使用 ESP32 或 ESP8266 供电。也有更大,更强大的伺服移动更大的物体;这些伺服系统需要比微控制器所能提供的更多的功率来运行,因此需要外部电源。
图 6-9
Adafruit 的微伺服系统
伺服系统配置有Servo类,它使用数字引脚来控制伺服电机。
$EXAMPLES/ch6-hardware/servo示例将伺服从 0 度旋转到 180 度,每次 2.5 度。在运行该示例之前,请按照这里给出的接线说明将其连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 6-5 和图 6-10 显示了如何将伺服连接到 ESP32。
图 6-10
将伺服连接到 ESP32 的接线图
表 6-5
将伺服机构连接到 ESP32 的接线
|伺服系统
|
ESP32
| | --- | --- | | 压水反应堆 | 3V3 | | 地线 | 地线 | | 伺服(DOUT) | GPIO14 (D14) |
ESP8266 接线说明
表 6-6 和图 6-11 显示了如何将伺服连接到 ESP8266。
图 6-11
将伺服连接到 ESP8266 的接线图
表 6-6
将伺服机构连接到 ESP8266 的接线
|伺服系统
|
ESP8266
| | --- | --- | | 压水反应堆 | 3V3 | | 地线 | 地线 | | 伺服(DOUT) | GPIO14 (D5) |
理解servo代码
Servo类使用数字引脚来控制伺服电机:
import Servo from "pins/servo";
servo示例创建了一个Servo类的实例,并通过调用实例的write方法定期改变位置。如清单 6-9 所示,write方法的参数是旋转到的角度;请注意,这可能是一个分数。
let servo = new Servo({pin: 14});
let angle = 0;
Timer.repeat(() => {
angle += 2.5;
if (angle > 180)
angle -= 180;
servo.write(angle);
}, 250);
Listing 6-9.
伺服旋转到新位置需要时间;时间的长短取决于你使用的伺服。根据伺服机构的不同,使用较短的时间间隔可能不会使伺服机构旋转得更快,而是可能会导致混乱的行为,因为伺服机构会尽最大努力跟上变化,而变化的速度会超过其运行速度。
Servo类也有一个writeMicroseconds方法,通过让您提供信号脉冲的微秒数(而不是度数),它允许更高的精度。可接受值的范围因伺服机构而异;将脉冲长度设置得太低或太高都会损坏伺服系统,所以一定要检查伺服系统的数据表。
获取温度
测量温度是物联网产品的一项常见任务,传感器制造商已经创建了许多不同的温度传感器。这些传感器使用各种硬件协议与微控制器通信。本节解释了两种易于使用且广泛可用的方法:
图 6-12
TMP36 传感器
- TMP36(图 6-12 )使用模拟值来传达温度。这是两个传感器中较为简单的一个,它只有一个输出,即连接到微控制器模拟输入的模拟输出,并且没有配置选项。它可以从 SparkFun(产品 ID SEN-10988)和 Adafruit(产品 ID 165)获得。
图 6-13
TMP102 传感器
- TMP102(图 6-13 )使用 I 2 C 总线进行温度通信。它使用 I 2 C 硬件协议进行连接,这比模拟输入复杂得多,但使传感器能够提供额外的功能和配置选项。它可以从 SparkFun 获得(产品 ID SEN-13314)。
本节还说明了如何使用传感器的数据手册来理解传感器提供的数据,并将其转换为人类可读的格式。
TMP36
$EXAMPLES/ch6-hardware/tmp36示例从 TMP36 传感器读取温度,并将以摄氏度为单位的值跟踪到调试控制台。在运行该示例之前,请按照这里给出的接线说明将 TMP36 连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 6-7 和图 6-14 显示了如何将 TMP36 连接到 ESP32。
图 6-14
连接 TMP36 和 ESP32 的接线图
表 6-7
连接 TMP36 和 ESP32 的接线
|TMP36
|
ESP32
| | --- | --- | | 压水反应堆 | 3V3 | | 模拟的 | NodeMCU 板上的 ADC0 (VP)可修改 2 上的 ADC7 (GP35) | | 地线 | 地线 |
ESP8266 接线说明
表 6-8 和图 6-15 显示了如何将 TMP36 连接到 ESP8266。
图 6-15
连接 TMP36 和 ESP8266 的接线图
表 6-8
连接 TMP36 和 ESP8266 的接线
|TMP36
|
ESP8266
| | --- | --- | | 压水反应堆 | 3V3 | | 模拟的 | ADC0 (A0) | | 地线 | 地线 |
理解tmp36代码
TMP36 上的模拟引脚输出与温度成比例的电压。Analog类提供对设备上模拟输入的访问:
import Analog from "pins/analog";
与其他硬件协议类不同,Analog类从不被实例化。它只提供了一个静态方法:一个对指定管脚的值进行采样的read方法,返回值从 0 到 1023。tmp36示例调用read方法,并将返回的电压转换为温度。
TMP36 ( learn.adafruit.com/tmp36-temperature-sensor/overview )的优秀 Adafruit 教程提供了以下将电压转换为温度的公式:
温度单位为摄氏度= [(Vout 单位为毫伏)–500]/10
tmp36的例子就是基于这个公式,正如你在这里看到的:
let value = (Analog.read(0) / 1023) * 330 - 50;
trace(`Celsius temperature: ${value}\n`);
Note
如果你运行在 Moddable Two 上,由于接线不同,你需要将 pin 号从 0 改为 7。
TMP36 设计用于精确测量–40°C 至+125°C 之间的温度,对于超出此范围的温度,它会返回读数,但精度较低。模拟输入具有 10 位分辨率,读数精度约为 0.25°c,这一精度足以满足多种用途,但并非所有用途;如下所述,TMP102温度传感器为温度测量提供了更高的分辨率。
TMP102
$EXAMPLES/ch6-hardware/tmp102示例从 TMP102 传感器读取温度,并将以摄氏度为单位的值跟踪到调试控制台。在运行该示例之前,请按照这里给出的接线说明将 TMP102 连接到您的 ESP32 或 ESP8266。
本节参考 TMP102 原理图和 TMP102 数据手册,二者均可在 SparkFun 网站的 TMP102 产品页面上找到: sparkfun.com/products/13314 。
ESP32 接线说明
表 6-9 和图 6-16 显示了如何将 TMP102 连接到 ESP32。
图 6-16
连接 TMP102 和 ESP32 的接线图
表 6-9
连接 TMP102 和 ESP32 的接线
|TMP102
|
ESP32
| | --- | --- | | 地线 | 地线 | | VCC | 3V3 | | 国家药品监督管理局 | GPIO21 (D21) | | 圣地亚哥 | GPIO22 (D22) |
ESP8266 接线说明
表 6-10 和图 6-17 显示了如何将 TMP102 连接到 ESP8266。
图 6-17
连接 TMP102 和 ESP8266 的接线图
表 6-10
连接 TMP102 和 ESP8266 的接线
|TMP102
|
ESP8266
| | --- | --- | | 地线 | 地线 | | VCC | 3V3 | | 国家药品监督管理局 | GPIO5 (D1) | | 圣地亚哥 | GPIO4 (D2) |
理解tmp102代码
tmp102示例从 TMP102 检索温度数据,并将其转换为摄氏度,以输出到调试控制台。这个特殊的例子值得仔细研究,因为它引入了在大量传感器中使用的I2C硬件协议。I 2 C 是一种串行协议,用于将多个设备连接到单条双线总线。
一旦你学会了在 JavaScript 中使用 I 2 C 的基本原理,你就可以根据硬件数据表或示例实现(如 Arduino 草图)快速编写代码与新的传感器进行通信。使用 I 2 C 的 SMBus 子集的另一种方法将在下一节讨论。了解如何使用 I 2 C 和 SMBus 将使你能够探索大量可用传感器的许多选项。
请注意,本章介绍了 TMP102 的许多功能,但并非全部。数据手册是了解任何传感器性能的最佳途径。对 TMP102 的进一步研究表明,它包括了用于恒温器等的特性。
I 2 C 受欢迎的一个特点是,因为它是一条总线,它允许几个不同的传感器连接到相同的两个微控制器引脚。每个传感器都有一个唯一的地址,可以独立访问。将总线用于这种硬件协议减少了连接多个传感器所需的引脚总数,这是很有价值的,因为可用引脚的数量通常是有限的。
I2C类提供对连接到一对管脚的 I 2 C 总线的访问:
import I2C from "pins/i2c";
tmp102示例创建了一个I2C类的实例。传递给构造函数的字典包含目标设备的 I 2 C 地址。您可以通过指定sda和scl属性在字典中包含 pin 号;此示例使用目标设备的默认管脚,因此不在字典中指定管脚号。ESP32 或 ESP8266 的默认引脚号与上图中的接线相匹配。电路板的地址0x48在 TMP102 原理图中给出。
let sensor = new I2C({address: 0x48});
这个I2C类的实例现在可以访问 I 2 C 总线上地址为0x48的传感器。TMP102 有四个 16 位寄存器,通过 I 2 C 使用读写来访问。寄存器如表 6-11 所示。(另请参见 TMP102 数据手册的表 1 和表 2。)
表 6-11
TMP102 寄存器
|注册号码
|
寄存器名
|
目的
| | --- | --- | --- | | Zero | 温度 | 读取最近的温度 | | one | 配置 | 读取或设置温度转换率、电源管理等选项 | | Two | T 低电平 | 使用内置比较器时,读取或设置低温 | | three | T 高电平 | 使用内置比较器时,读取或设置高温 |
要读取或写入寄存器,首先要将目标寄存器编号写入器件。一旦完成,你就可以读取或写入寄存器值。该示例读取温度(寄存器 0),因此它首先将值 0 写入传感器,然后读取两个字节。
const TEMPERATURE_REG = 0;
sensor.write(TEMPERATURE_REG);
let value = sensor.read(2);
read方法返回名为value的Uint8Array实例中的字节。它可以从目标设备读取多达 40 个字节,尽管大多数 I 2 C 读取的只是几个字节。
在读取的两个字节中,第一个字节是最高有效字节。第二个字节(最低有效位)的低 4 位设为 0,分辨率为 12 位。以下代码行将value数组中的两个字节组合成一个 12 位整数值:
value = (value[0] << 4) | (value[1] >> 4);
该值的格式如 TMP102 数据手册表 5 所示。负值以二进制补码格式表示。如果value的第一位为 1,则温度低于 0 ℃,需要额外的计算(列表 6-10 )来生成正确的负值。
if (value & 0x800) {
value -= 1;
value = ~value & 0xFFF;
value = -value;
}
Listing 6-10.
最后一步是将该值转换为摄氏度,并跟踪到调试控制台。由于总分辨率为 12 位,其中 4 位用于数值的小数部分,因此 TMP102 提供的温度值精确到 0.0625°C 以内,温度读数的精确范围为–55°C 至+128°C。
value /= 16;
trace(`Celsius temperature: ${value}\n`);
使用 SMBus
系统管理总线或 SMBus 协议建立在 I 2 C 之上。它使用 I 2 C 定义的方法子集来形式化许多基于寄存器的 I 2 C 设备使用的约定,包括 TMP102。如前所述,TMP102 使用四个寄存器在传感器和微控制器之间读写值。要读取或写入寄存器,首先要发送寄存器号,然后发送读或写命令。
您可以像前面一样使用 I 2 C 与 SMBus 设备通信,但是由于 SMBus 设备非常常见,为了方便起见,Moddable SDK 包含了一个SMBus类:
import SMBus from "pins/smbus";
SMBus类是I2C类的子类,它的构造函数接受相同的字典参数。SMBus增加了对I2C的额外调用,以直接读写寄存器。在tmp102的例子中,使用I2C直接读取寄存器需要两次调用:一次写操作将寄存器设置为读取,然后是实际读取。SMBus将这两个调用合并成一个单独的readWord调用。
let sensor = new SMBus({address: 0x48});
let value = sensor.readWord(TEMPERATURE_REG, true) >> 4;
readWord方法有两个参数:首先是要读取的寄存器,然后是true(如果两个字节是大端顺序)或false(如果是小端顺序)(默认)。因为这里返回的第一个字节是最重要的字节,值是 big-endian,所以第二个参数是true。由于这两个字节已经组合成一个整数,剩下的就是右移 4 位来生成 12 位值。
SMBus类提供readByte来读取单个字节,提供readBlock来读取指定数量的字节。它还提供了相应的写入方法writeByte、writeWord和writeBlock。
配置 TMP102
TMP102 能够支持各种配置选项,因为它通过 I 2 C 进行通信,这是一种灵活且可扩展的硬件协议。本节讨论四个这样的选项。
注意,为了简化代码,本节中的例子使用了I2C的SMBus子类,而不是直接使用I2C。
使用扩展模式读取更高的温度
TMP102 可以测量最高 150°C 的温度,但要做到这一点,需要将分辨率从默认的 12 位提高到 13 位,这可以通过使能*扩展模式来实现。*与 TMP102 的大多数选项一样,该模式由 16 位配置寄存器(寄存器 1)控制。要使能扩展模式,需要将配置寄存器中的 EM 位设为 1。
因为配置寄存器控制许多选项,为了避免无意中更改选项,代码(清单 6-11 )首先读取配置寄存器的当前值,然后设置 EM 位,最后写回该值。
const CONFIGURATION_REG = 1;
const EM_MASK = 0b0000_0000_0001_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG, configuration | EM_MASK,
true);
Listing 6-11.
在您自己的物联网产品中,您可能已经知道配置寄存器的值,而不需要读取它。在这种情况下,您可以直接设置它,无需初始读取。
使能扩展模式时,温度寄存器返回 13 位值,而不是 12 位值,这需要在摄氏度的计算中稍作调整。在 SMBus 版本中,右移位值从 4 变为 3,负数的计算也发生变化。清单 6-12 显示了修改后的代码。
let sensor = new SMBus({address: 0x48});
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG, configuration | EM_MASK,
true);
let value = sensor.readWord(TEMPERATURE_REG, true) >> 3;
if (value & 0x1000) {
value -= 1;
value = ~value & 0x1FFF;
value = -value;
}
value /= 16;
trace(`Celsius temperature: ${value}\n`);
Listing 6-12.
设置转换率
转换速率是指 TMP102 每秒完成一次温度测量并更新温度寄存器中的值的次数。TMP102 完成一次温度测量大约需要 26 毫秒。默认情况下,转化率为每秒四次。从读取数据到开始下一次读取数据之间的 224 毫秒内,TMP102 进入低功耗模式,功耗降低约 94%,从 40 μA 降至 2.2 μA。
了解转换率对您的应用很重要。如果从传感器读取温度的频率高于温度更新的频率,您会收到相同的值,不必要地使用有限的 CPU 周期。另一方面,如果传感器执行温度读数的频率高于您的应用要求,那么它使用的能量会超过需要,因为它会产生未使用的读数。
转换速率由配置寄存器中的 2 位控制,因此有四种可能的值,如下所示(以及数据手册的表 8):
-
00–每 4 秒一次(0.25 赫兹)
-
01–每秒一次(1 赫兹)
-
10–每秒四次(4 Hz,默认)
-
11–每秒八次(8 赫兹)
清单 6-13 中的代码将转换率设置为每秒 8 次。
const CONVERSION_RATE_SHIFT = 6;
const CONVERSION_RATE_MASK = 0b0000_0000_1100_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
configuration &= ~CONVERSION_RATE_MASK;
sensor.writeWord(CONFIGURATION_REG,
configuration | (0b11 << CONVERSION_RATE_SHIFT), true);
Listing 6-13.
关机模式节能
降低温度转换的频率可以节省能源。然而,最低的频率仍然是每 4 秒转换一次,这可能比您的物联网产品要求的频率更高。TMP102 提供*关断模式,*完全禁用温度转换硬件,将功耗降至 0.5 μA,您的应用可以在读数间隔期间进入关断模式,然后重新使能转换。
清单 6-14 中的代码通过设置配置寄存器中的关机模式位进入关机模式。
const SHUTDOWN_MODE_MASK = 0b0000_0001_0000_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG,
configuration | SHUTDOWN_MODE_MASK, true);
Listing 6-14.
退出关断模式类似于进入关断模式,但会清除关断模式位,而不是将其置位:
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG,
configuration & ~SHUTDOWN_MODE_MASK, true);
退出关断模式时需要记住一个重要细节,因为转换大约需要 26 毫秒,所以退出关断模式后立即读取温度寄存器会返回一个陈旧值。要在不阻止执行的情况下等待新的温度读数完成,请使用一个计时器,如清单 6-15 所示。
Timer.set(() => {
let value = sensor.readWord(TEMPERATURE_REG);
// Perform conversion to Celsius as before
...
}, 27);
Listing 6-15.
获取一次性温度读数
至此,您已经将 TMP102 传感器配置为定期连续获取温度读数。TMP102 还支持*单次触发模式,*只读取一个读数(参见清单 6-16 )。单次触发功能仅在器件处于关断模式时可用,读取完成后,TMP102 返回关断状态。这使得它成为最节能的方式来获取不频繁的读数,例如,如果您的产品每小时获取一次读数,或者只响应用户按下的按钮。
const ONESHOT_MODE_MASK = 0b1000_0000_0000_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG,
configuration | ONESHOT_MODE_MASK, true);
Listing 6-16.
启用单次触发模式后,需要等待读数准备就绪,大约需要 26 毫秒。然而,您可以使用一次性模式的一个特殊功能,让您知道何时可以读取,而不是等待一个固定的时间间隔。这一点很重要,因为读取读数所需的实际时间因当前温度而异。在配置寄存器中将单触发位设置为 1 后,轮询该位以了解新读数何时准备好;当温度读数正在进行时,TMP102 返回 0,当读数可用时,返回 1。清单 6-17 显示了等待读数准备就绪的代码。
while (true) {
let configuration = sensor.readWord(CONFIGURATION_REG, true);
if (configuration & ONESHOT_MODE_MASK)
break;
}
// new temperature reading now available
Listing 6-17.
前面的代码在等待温度读数时阻止执行。这对于某些产品来说是可以接受的,但对于其他产品来说却不是。要执行非阻塞轮询,请使用计时器(清单 6-18 )。
Timer.repeat(id => {
let configuration = sensor.readWord(CONFIGURATION_REG, true);
if (!(configuration & ONESHOT_MODE_MASK))
return;
Timer.clear(id);
// new temperature reading now available
}, 1);
Listing 6-18.
一次性模式还有另一个有趣的用途。由于读取温度需要大约 26 毫秒,理论上每秒可以读取大约 38 个读数。但是,请记住,配置寄存器支持的最大转换速率是每秒 8 次。使用连续、连续的单次读数,可以在硬件支持的情况下尽可能快地获取温度读数,这对于想要精确捕捉温度随时间变化的情况非常有用。
结论
既然您已经了解了一些硬件协议的基础知识,并且知道如何与一些传感器和执行器交互,那么您可以做很多事情来使提供的简单示例更加有趣。例如,你可以让执行器响应来自传感器的输入,或者利用你在第三章中学到的关于与云服务通信的知识,用它将数据从你的传感器传输到云端。在第 8 、 9 和 10 章中,你将学习如何使用触摸屏,这对于显示传感器数据和构建与硬件配合使用的用户界面非常有用。
在网上和电子商店里可以买到不计其数的其他传感器和执行器。本章使用了来自 SparkFun 和 Adafruit 的一些内容,这两个网站都是电子初学者的优秀资源。除了提供许多传感器和致动器及其数据表,他们还提供了许多产品的教程,这是编写自己的 JavaScript 模块与它们进行交互的有用起点。
七、声音
声音是向设备用户传达信息的一种很好的方式。您可以使用声音为用户操作(如点击按钮)提供反馈,在后台任务(如计时器或下载)完成时提醒用户,等等。
ESP32 和 ESP8266 都支持音频回放。包括 M5Stack FIRE 在内的一些开发板都内置了扬声器。如果您的主板没有扬声器,您可以自己安装一个。在本章中,您将学习如何使用便宜的扬声器播放声音,这种扬声器很容易直接连接到 ESP32 或 ESP8266。您还将了解如何使用外部 I 2 S 音频驱动器实现更高质量的音频回放,以及如何为您的项目选择最佳音频格式,平衡质量和存储空间。
扬声器选项
如果您没有使用带有内置扬声器的开发板,您需要在运行示例之前将扬声器连接到您的设备。
图 7-1 显示了 Adafruit(产品 ID 1890)生产的迷你金属扬声器,可与 ESP32 和 ESP8266 配合使用。这是一个简单的模拟扬声器,阻抗为 8 欧姆,功耗为 0.5W。你可以找到许多类似的不同阻抗和功耗的产品。8 欧姆、0.5W 扬声器是一个很好的起点,因为它可以与 ESP32 和 ESP8266 使用相同的电源,而更大的扬声器需要外部电源。
图 7-1
Adafruit 的迷你金属扬声器
迷你金属扬声器可以直接连接到您的设备上,这是一种快速入门的简单方法。但是,你可以通过增加一个 I 2 S 芯片来获得更好的音质。图 7-2 显示了一个来自 Adafruit 的 I 2 S 芯片(产品 ID 3006)。这个芯片也能放大声音。
图 7-2
我从阿达果公司买了 2 块芯片
I 2 S 芯片本身不播放声音;你还得给它装一个扬声器。迷你金属扬声器与 I 2 S 芯片一起工作;然而,廉价的扬声器会影响质量。对于更高质量的音频,使用更高质量的扬声器,例如图 7-3 所示的 Adafruit 公司生产的单声道封闭式扬声器(产品 ID 3351)。
图 7-3
Adafruit 的单声道封闭式扬声器
一个 I 2 S 芯片增加了额外的成本,但如果你的产品需要高质量的声音,这可能是必要的。此外,使用 I 2 S 芯片在 ESP8266 上的 CPU 开销更低,这也可能使其物有所值。你可以决定哪个选项最适合你。
如果您只是想尝试一下可修改的 SDK 的音频播放功能,最快的方法是从模拟扬声器开始。如果你后来决定想要更高质量的音频,你可以随时切换到使用 I 2 S 芯片和单声道封闭式扬声器。无论您选择哪种设置,播放音频的 JavaScript APIs 都是相同的,因此您不必更改应用程序代码。但是,您必须为每个选项配置不同的音频设置。本章的主持人负责他们的manifest.json文件中的音频配置。他们假设你正在使用图 7-1 所示的扬声器或者图 7-2 和 7-3 所示的 I 2 S 芯片和扬声器。
添加模拟扬声器
本节说明如何将模拟扬声器连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 7-1 和图 7-4 显示了如何将扬声器连接到 ESP32。
表 7-1
将扬声器连接到 ESP32 的接线
|扬声器
|
ESP32
| | --- | --- | | 电线 1 | GPIO25 (D25) | | 电线 2 | 地线 |
扬声器的哪根线连接到 GPIO25,哪根线连接到 ESP32 上的 GND,都无关紧要。
图 7-4
将扬声器连接到 ESP32 的接线图
ESP8266 接线说明
表 7-2 和图 7-5 显示了如何将扬声器连接到 ESP8266。
图 7-5
将扬声器连接到 ESP8266 的接线图
表 7-2
将扬声器连接到 ESP8266 的接线
|扬声器
|
ESP8266
| | --- | --- | | 电线 1 | GPIO3 (RX) | | 电线 2 | 地线 |
请注意,ESP8266 上的 GPIO3 用于与您的计算机进行串行通信,用于安装和调试。这意味着您不能使用xsbug来调试音频示例,并且安装音频示例需要一些额外的步骤:
-
断开扬声器与 GPIO3 的连接。
-
照常安装一个例子。
-
将扬声器重新连接到 GPIO3。
-
重置 ESP8266 以运行该示例。
如果你使用的是 Moddable One,GPIO3 在你连接编程适配器的小连接器上。安装音频示例后,断开编程适配器,连接扬声器,并使用 USB 电缆为可修改的音频示例供电。
在 ESP8266 上,扬声器的哪根线连接到 GPIO3,哪根线连接到 GND 都无关紧要。
增加一个 I 2 S 芯片和数字扬声器
本节介绍如何将 I 2 S 芯片连接到您的 ESP32 或 ESP8266,以及如何将数字扬声器连接到 I 2 S 芯片。
ESP32 接线说明
表 7-3 显示了如何将 I 2 S 芯片连接到 ESP32。
表 7-3
将 I 2 S 芯片连接到 ESP32 的接线
|我的芯片
|
ESP32
| | --- | --- | | 法律改革委员会(Law Reform Commission) | GPIO12 (D12) | | BCLK 公司 | GPIO13 (D13) | | 联邦德国工业标准 | GPIO14 (D14) | | 地线 | 地线 | | 酒 | 3V3 |
表 7-4 显示了如何将扬声器连接到 I 2 S 芯片。
表 7-4
将扬声器连接到 I 2 S 芯片的接线
|扬声器
|
我的芯片
| | --- | --- | | 黑线 | – | | 红线 | + |
图 7-6 显示了完整设置的接线图。
图 7-6
扬声器、I 2 S 芯片和 ESP32 的接线图
ESP8266 接线说明
表 7-5 显示了如何将 I 2 S 芯片连接到 ESP8266。请注意,GPIO2 和 GPIO15 在可修改的设备上不可用,因此您不能在可修改的设备上使用 I 2 S。
表 7-5
将 I 2 S 芯片连接到 ESP8266 的接线
|我的芯片
|
ESP8266
| | --- | --- | | 法律改革委员会(Law Reform Commission) | GPIO2 (D4) | | BCLK 公司 | GPIO15 (D8) | | 联邦德国工业标准 | GPIO3 (RX) | | 地线 | 地线 | | 酒 | 3V3 |
表 7-6 显示了如何将扬声器连接到 I 2 S 芯片。
表 7-6
将扬声器连接到 I 2 S 芯片的接线
|扬声器
|
我的芯片
| | --- | --- | | 黑线 | – | | 红线 | + |
图 7-7 显示了完整设置的接线图。
图 7-7
扬声器、I 2 S 芯片和 ESP8266 的接线图
安装音频主机
本章中的示例使用第一章中描述的模式进行安装:您使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
在$EXAMPLES/ch7-audio/host-pdm和$EXAMPLES/ch7-audio/host-i2s目录中有两个可用的主机应用程序。两者的区别在于它们如何配置音频设置。如果您使用 I 2 S 芯片和扬声器组合,请使用host-i2s,如果您仅使用扬声器,请使用host-pdm。从命令行导航到目录,用mcconfig安装。
AudioOut类
使用AudioOut类将声音传送到扬声器:
import AudioOut from "pins/audioout";
AudioOut类支持以每样本 8 或 16 位回放未压缩的单声道或立体声音频,以及回放使用 IMA ADPCM 算法压缩的单声道音频。内置混音器可以组合多达四个声道的音频进行同步播放。它可以在音频回放期间的指定点生成回调,例如,将屏幕绘图与音频回放同步。AudioOut产生 8 位或 16 位音频输出,并将其发送至伪模拟输出或数字 I 2 S 数模转换器。
有了这么多的功能,使用音频需要了解可用选项的权衡,以帮助您决定在产品中播放音频的最佳方式。
AudioOut配置
本节描述了音频硬件协议、数据格式和AudioOut类的其他配置选项。对于本章中的示例,设置是在主机的清单中配置的。
音频硬件协议
如本节所述,AudioOut类支持两种不同的硬件协议,PDM 和 I 2 。
脉冲密度调制(PDM)
脉冲密度调制或 PDM ,是 PWM 的一种变体,它快速切换数字输出引脚,以创建与所需输出信号相对应的能量水平。这种播放音频的方式有时被称为模拟音频输出,因为 PDM 转换会合成一个信号,该信号经过一段时间的平均后,与模拟信号输出的能量水平相匹配。
PDM 的优势在于它只与微控制器的内置数字输出硬件一起工作。PDM 的一个缺点是音频质量较低;因此,PDM audio 主要用于用户界面或游戏中的音效,而不是音乐或口语。
ESP32 具有内置硬件,可将音频数据转换为 PDM,因此使用该协议时没有 CPU 开销。然而,ESP8266 没有 PDM 转换硬件;转换在软件中进行,因此会占用一些 CPU 周期。
清单的defines部分配置 PDM 输出。对于 ESP32,它看起来像清单 7-1 。
"defines": {
"audioOut": {
"i2s": {
"DAC": 1
}
}
}
Listing 7-1.
当设置为 1 时,DAC属性告诉AudioOut实现使用 PDM 输出。没有指定输出引脚,因为只有 ESP32 上的数字引脚 25 支持 PDM 输出。
对于 ESP8266,清单部分略有不同(清单 7-2 )。
"defines": {
"audioOut": {
"i2s": {
"pdm": 32
}
}
}
Listing 7-2.
非零值的pdm属性表示使用 PDM 输出。该值必须是 32、64 或 128。值 32 指定在转换中不应执行过采样;这需要较少的时间和内存,但会导致较低质量的输出。值越大,质量越好。
一秒钟
AudioOut类支持的另一个硬件协议是 I 2 *S,*一个专为连接数字音频设备而设计的协议。I 2 S 通过数字连接将未修改的音频样本从微控制器传输到专用音频输出组件,该组件使用专门的算法和硬件执行数模转换,以生成高质量的结果。ESP32 和 ESP8266 都有内置的硬件支持,可使用 I 2 传输音频数据,因此微控制器上播放音频的 CPU 开销非常小。
使用 I 2 S 需要一个外部元件,这是一个额外的成本,并且使用至少两个,通常是三个数字引脚,而 PDM 输出仅使用一个数字引脚。另一方面,I 2 S 音频硬件产生了非常高质量的音频输出,因此质量的限制因素变成了用于输出的扬声器,而不是数字样本转换成模拟信号的方式。
I 2 S 零件千差万别。一些没有配置选项,而另一些包括 I 2 C 连接来配置器件,并且在配置完成之前不能正常工作。本节假设您正在使用不需要配置或已经配置的 I 2 S 部件。
清单的defines部分配置 I 2 的输出。对于 ESP32,这看起来像清单 7-3 。
"defines": {
"audioOut": {
"i2s": {
"bck_pin": 13,
"lr_pin": 12,
"dataout_pin": 14,
"bitsPerSample": 16
}
}
}
Listing 7-3.
bck_pin、lr_pin、dataout_pin属性对应于 I 2 S 硬件协议的三个管脚。默认值分别为 26、25 和 22。bitsPerSample属性表示通过 I 2 S 连接传输的样本大小(以位为单位)。对于许多 I 2 S 组件,这是 16,默认值,但对于其他组件,需要 32 位。
对于 ESP8266,清单部分要简单得多,如清单 7-4 所示,因为 I 2 S 引脚是在硬件中定义的,不能更改。将pdm属性设置为 0 会禁用 PDM 输出,而使用 I 2 S 硬件协议。I 2 S 引脚为 15 ( bck_pin)、2 ( lr_pin)和 3 ( dataout_pin)。
"defines": {
"audioOut": {
"i2s": {
"pdm": 0
}
}
}
Listing 7-4.
ESP8266 实现只支持 16 位样本输出,所以没有bitsPerSample属性。
音频数据格式
您的应用程序播放的音频数据必须以与AudioOut类和连接到微控制器的音频输出硬件兼容的格式存储。为了最大化效率和简单性,AudioOut使用自定义数据格式来存储数字音频;这种格式被称为 MAUD,是 Moddable Audio 的缩写。它由一个简单的标题和音频样本组成。您用来构建应用程序的工具知道如何将包含未压缩音频的标准 WAVE 音频文件(文件扩展名为.wav的文件)转换为 MAUD 资源,无需您自己创建 MAUD 文件。转换工具名为wav2maud,由mcconfig和mcrun自动调用。如果您的音频以另一种格式存储,例如 MP3,您必须先将其转换为 WAVE 文件;免费的 Audacity 应用程序是完成这项任务的好工具。
为简单起见,AudioOut类要求所有播放的音频样本具有与音频输出相同的每样本位数、通道数和采样率。这消除了在微控制器上用软件执行格式转换的需要。这些AudioOut属性在清单中配置,如清单 7-5 所示。
"defines": {
"audioOut": {
"bitsPerSample": 16,
"numChannels": 1,
"sampleRate": 11025
}
}
Listing 7-5.
属性可以是 8 或 16,尽管 16 更常见。同样的,numChannels属性可能是 1(单声道)或者 2(立体声);然而,很少在微控制器上为用户界面交互播放立体声,因此该值通常为 1。
要在应用程序中包含音频数据,需要将它们作为资源添加到清单中,如清单 7-6 所示。
"resources": {
"*": [
"./bflatmajor"
]
},
Listing 7-6.
当mcconfig和mcrun处理清单时,它们调用wav2maud将文件bflatmajor.wav转换成 MAUD 格式的资源。音频被转换,使得 MAUD 资源中音频的每样本比特数、通道数和采样率与清单的audioOut部分中定义的相匹配。根据前面的示例,音频样本是 16 位单声道,采样速率为 11,025 Hz。
音频压缩
音频数据会占用大量的存储空间。10 秒钟 8 KHz 的 16 位单声道音频使用 160,000 字节的存储空间,约为 ESP8266 的 1 MB 闪存地址空间的 15%,仍仅相当于模拟电话呼叫的质量。音频压缩通常用于减小存储在数字设备上并通过互联网传输的音频的大小。那里使用的算法,包括 MP3、AAC 和 Ogg,几乎不能在大多数微控制器上运行,所以它们在这里不实用。IMA ADPCM(自适应差分脉码调制)是一种更简单的音频压缩格式,可对 16 位音频样本进行 4:1 压缩,复杂度远低于 MP3、AAC 或 Ogg,适合在 ESP32 和 ESP8266 上实时使用。
要使用 IMA ADPCM,将format属性添加到您的manifest.json文件的audioOut部分:
"audioOut": {
... // other audioOut configuration
"format": "ima"
}
在构建过程中,您的音频会自动压缩。前面提到的 10 秒 16 位单声道 8 KHz 音频从 160,000 字节缩减到 40,000 字节。质量有所下降,但对于许多目的来说,例如,用户界面声音效果,这种差异可能是不明显的。
设置音频队列长度
音频队列的长度在构建时是固定的,通过在修改队列时消除对内存分配的需求来提高音频回放的运行时效率。默认的队列长度是八个条目,这对于大多数目的来说已经足够了,包括本章中的所有例子。如果需要更多的队列条目,可以通过在清单的audioOut部分定义queueLength属性来更改队列长度。
"audioOut": {
... // other audioOut configuration
"queueLength": 20
}
每个队列条目都使用一些内存(在撰写本文时为 24 个字节),所以您不应该分配超过您需要的内存。如果您的项目简单地使用音频,您可以降低默认值来恢复该内存。
使用AudioOut播放音频
AudioOut类提供了各种不同的音频回放功能,帮助您将音频反馈整合到项目的用户体验中。回放引擎能够无缝地回放样本序列。它提供了一种回调机制来同步音频和用户体验的其他部分。它甚至支持多通道音频的实时混合,这种功能在微控制器上很少见。本节将介绍这些功能以及许多其他功能。
实例化AudioOut
AudioOut构造函数接受一个字典来配置音频输出。$EXAMPLES/ch7-audio/sound示例如下配置AudioOut实例:
let speaker = new AudioOut({streams: 1});
流的数量表示可以同时播放的声音的数量,最多四个。因为每个流都使用一些额外的内存,所以最好只根据需要配置AudioOut实例。基本的sound示例播放单个声音,因此它只需要一个流。
采样率、每个样本的位数和通道数是在清单中定义的,所以它们不会作为属性在字典中传递来配置AudioOut实例。音频资源以相同的格式存储,因为mcconfig、mcrun和wav2maud执行任何需要的格式转换。
播放单个声音
要播放声音,首先使用enqueue方法将一个音频样本加入到AudioOut实例的流中。$EXAMPLES/ch7-audio/sound示例将音频资源bflatmajor.maud排入流 0,如下所示:
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"));
要开始播放排队的音频样本,请调用start方法:
speaker.start();
要停止AudioOut实例上的所有音频回放,调用stop方法:
speaker.stop();
重复一个声音
如果您想不止一次地播放一个声音,您可以向enqueue方法传递一个可选的repeat参数。以下是如何播放一个声音四次:
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"), 4);
要无限重复声音,为repeat值传递Infinity:
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"), Infinity);
使用回调来同步音频
enqueue方法不仅可以用于声音入队;例如,您可以将回调排队,以便在流回放的特定点调用。回调入队对于触发其他事件以响应声音的完成非常有用,就像在屏幕动画中一样。在清单 7-7 中,回调只是追踪到调试控制台,并在声音播放结束时闪烁一次板上 LED。
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"));
speaker.callback = function() {
trace("Sound finished\n");
Digital.write(2, 1);
Timer.delay(500);
Digital.write(2, 0);
};
speaker.enqueue(0, AudioOut.Callback, 0);
Listing 7-7.
使用命令改变音量
您还可以将命令加入队列,以调整单个声音的音量。该命令会更改其后排队的样本量;它不会改变已经排队的样本量。清单 7-8 中的代码连续播放声音三次:一次以最低音量(1),一次以中等音量(128),一次以最大音量(256)。
let bFlatMajor = new Resource("bflatmajor.maud");
speaker.enqueue(0, AudioOut.Volume, 1);
speaker.enqueue(0, AudioOut.Samples, bFlatMajor);
speaker.enqueue(0, AudioOut.Volume, 128);
speaker.enqueue(0, AudioOut.Samples, bFlatMajor);
speaker.enqueue(0, AudioOut.Volume, 256);
speaker.enqueue(0, AudioOut.Samples, bFlatMajor);
Listing 7-8.
播放一系列声音
$EXAMPLES/ch7-audio/sound-sequence示例播放一系列声音。因为它一次只播放一个声音,所以它只需要一个流,所以用与基本的sound示例中的AudioOut实例相同的设置进行配置。
let speaker = new AudioOut({streams: 1});
然后使用AudioOut实例的enqueue方法将每个声音排队。如清单 7-9 所示,sound-sequence示例中的所有声音都在同一个流中排队,使得它们按照排队的顺序依次播放。
speaker.callback = function() {
speaker.enqueue(0, AudioOut.Samples,
new Resource("ding.maud"));
speaker.enqueue(0, AudioOut.Samples,
new Resource("tick-tock.maud"));
speaker.enqueue(0, AudioOut.Samples,
new Resource("tada.maud"));
speaker.enqueue(0, AudioOut.Callback, 0);
}
speaker.callback();
speaker.start();
Listing 7-9.
回调在样本之后排队;一旦所有样本都播放完毕,回调就会被调用,并再次将样本排入队列,从而导致序列重复播放。
同时播放声音
$EXAMPLES/ch7-audio/ sound-simultaneous示例同时播放两个声音,因此AudioOut实例需要两个流。
let speaker = new AudioOut({streams: 2});
调用一次AudioOut实例的enqueue方法,将滴答声排入流 0。这种声音不断重复。
speaker.enqueue(0, AudioOut.Samples,
new Resource("tick.maud"), Infinity);
speaker.start();
然后,该示例设置一个重复计时器,其回调将一个丁丁声排入流 1。因为声音和滴答声在不同的流中排队,所以两种声音同时播放。
Timer.repeat(() => {
speaker.enqueue(1, AudioOut.Samples,
new Resource("ding.maud"));
}, 5000);
播放声音的一部分
这个例子演示了如何播放一段声音。使用与基本的sound示例相同的设置来配置AudioOut实例。
let speaker = new AudioOut({streams: 1});
tick-tock音频文件是一个时钟的录音。首先播放完整的声音。
let tickTock = new Resource("tick-tock.maud");
speaker.enqueue(0, AudioOut.Samples, tickTock);
然后前半秒打两遍。要播放部分声音,您需要指定enqueue方法的可选repeat、offset和count参数。在下面一行中,repeat是 2,所以声音播放两次;offset为 0,所以从音的开头开始;而count是 11,025/2,所以半秒播放:
speaker.enqueue(0, AudioOut.Samples, tickTock, 2, 0, 11025 / 2);
刷新音频队列
在某些情况下,您想要停止播放一个频道上的音频,而继续播放其他频道上的音频。方法是刷新您想要停止的流的音频队列。
speaker.enqueue(0, AudioOut.Flush);
一种有用的情况是,当一个通道使用通道 0 无限重复播放背景声音效果,而使用通道 1 播放交互式音频声音效果。您可以通过刷新频道 0 来停止背景声音效果,这将允许频道 1 继续播放而不会中断。这与在AudioOut实例上调用stop形成对比,后者会立即结束所有通道上的回放。
结论
现在您已经了解了如何配置音频设置并使用AudioOut类的许多功能来播放音频,您已经准备好开始向您的项目添加声音了。本章中的信息在与其他章节的信息结合使用时最为有用:
-
在第五章中,您学习了如何与传感器和执行器交互。现在,您可以触发声音效果来响应传感器读数或指示执行器何时执行某个操作。
-
在接下来的几章中,您将学习如何使用触摸屏。您可以为屏幕上的用户操作提供音频反馈,或者添加提醒声音以将用户的注意力吸引回显示屏。音频反馈和视觉反馈的结合提供了更完整的用户体验。