TinyGO-DIY-微控制器项目创建指南-全-
TinyGO DIY 微控制器项目创建指南(全)
原文:
zh.annas-archive.org/md5/b484a78bd7af455037b5eacd9dc45866译者:飞龙
前言
如果 JavaScript 或 C#可以在微控制器上运行,那么 Go 可以做得更好。虽然标准 Go 会产生巨大的二进制文件,但 TinyGo 产生的二进制文件可以适应最小的设备。为什么你应该选择 Go 进行微控制器和Wasm(即WebAssembly)编程?我最喜欢的理由是 Go 易于学习、易于阅读和易于编写。此外,Go 附带了一个功能强大的标准库,它松散耦合且具有强大的并发能力。
如果你喜欢 Go 这门语言,那么这本书适合你。完成这本书后,你将拥有构建你梦想中的所有微控制器项目所需的所有工具和知识。此外,作为额外的福利,你将能够使用 Wasm 构建仪表板和家庭控制应用,用于你的家庭自动化项目。所有这些都可以使用 TinyGo 实现。
如果你之前从未使用过微控制器,这里有几个你尝试它的理由:
-
如果你已经是一名程序员,看到代码影响现实世界的设备是一件很酷的事情。完成一个项目并最终看到电机转动、LED 闪烁、蜂鸣器响起等,这种感觉真的很棒。
-
随着你熟悉不同的总线系统、协议、硬件接口等,你将不断学习新事物,并更深入地理解计算机的一般工作原理。
-
当你玩微控制器时,可能性几乎是无限的。你不受市场上可用产品的限制,因为你完全可以自己构建一切。
-
你可以学习如何编写小型、高效的程序来告诉微控制器你想要它做什么。这也有助于你成为更优秀的开发者。
-
你可以参与酷炫的项目,并与志同道合的伟大社区取得联系。
这本书适合谁阅读
如果你是一名想要编程低功耗设备(如 Arduino UNO 和 Arduino Nano IoT 33)或硬件的 Go 开发者,或者如果你是一名想要在浏览器中用 Go 编程的同时扩展你对使用 WebAssembly 的知识点的 Go 开发者,那么这本书适合你。对通过 DIY 项目学习 TinyGo 感兴趣的 Go 爱好者程序员也会发现这本实用指南很有用。
这本书涵盖了什么内容
第一章,开始使用 TinyGo,将教你如何设置 TinyGo 并编译你的第一个程序!
第二章,构建交通灯控制系统,将教你构建一个包含行人信号灯和按钮的交通灯控制系统;你将学习如何在 TinyGo 中使用 Goroutines。
第三章,使用键盘构建安全锁,探讨了如何利用 4x4 键盘和伺服电机构建一个在输入正确密码时打开的锁。
第四章,构建植物浇水系统,解释了如何使用不同类型的传感器构建自动植物浇水系统,这样您就不再需要手动浇水植物了!
第五章,构建无接触式洗手计时器,探讨了使用四位七段显示器和超声波距离传感器来识别附近物体的移动,以启动一个计时器,告诉我们洗手是否足够长时间。
第六章,使用 I2C 和 SPI 接口构建用于通信的显示器,通过让您使用使用 I2C 和 SPI 总线的显示器来解释集成电路间(I2C)和串行外围接口(SPI)的概念。到本章结束时,您将了解如何在 TinyGo 中使用不同类型的显示器。
第七章,在 TinyGo Wasm 仪表板上显示天气警报,您将构建并托管一个 Wasm 应用程序,该应用程序显示从通过 Wi-Fi 发送的 Arduino Nano 33 IoT 传感器数据。
第八章,通过 TinyGo Wasm 仪表板自动化和监控您的家庭,解释了如何使用 Wasm 仪表板控制和监控您家中的设备。
第九章,附录–"Go"向前进,涵盖了之前章节中没有涉及的一些概念。
要充分利用本书
所有代码示例都已使用 Go 1.16.2 在 Ubuntu 上测试,但它们也将与 Go 的未来版本和其他操作系统兼容。本书中一直使用 Visual Studio Code 作为编辑器,但也可以使用任何其他编辑器。

如果您使用的是本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
我很乐意在社交媒体上看到您阅读本书后构建的项目。请自由地使用以下标签在 Twitter 上标记我:@Nooby_Games。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!
代码在行动
本书的相关操作视频可以在bit.ly/3cYZOh4查看。
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800560208_ColorImages.pdf (ColorImages.pdf)。
使用的约定
本书中使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“当我们收到命令的开始时,我们将所有后续字符追加到commandBuffer。”
代码块设置如下:
data, err := uart.ReadByte()
if err != nil {
println(err.Error())
}
当我们希望您注意代码块中的特定部分时,相关的行或项目会设置为粗体:
func main() {
blocker := make(chan bool, 1)
<-blocker
println("this gets never printed")
}
任何命令行输入或输出都按照以下方式编写:
tinygo flash –target=arduino-nano33 Chapter06/tinygame/main.go
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“值相当稳定,为37888。”
小贴士或重要提示
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 mailto:customercare@packtpub.com 与我们联系。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者可以查看他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packt.com。
第一章:第一章:TinyGo 入门
在我看来,Go 语言易于学习、易于阅读、易于编写。该语言没有过多的花哨特性,而是注重简洁。内置的并发性、快速的编译时间、高执行性能和丰富的标准库使它成为一款出色的语言。这就是为什么我想带您从非常基础的高级 Go 程序开始,深入到利用 TinyGo 全部功能的微控制器。
在本章中,我们将设置 TinyGo,并学习如何在 VS Code 和其他编辑器中实现代码补全。完成这些后,我们将查看 Arduino UNO 及其技术规格。我们将比较 TinyGo 与 Go,并讨论 TinyGo 相较于其他微控制器语言的特殊之处。在本章结束时,我们将编写、编译、部署和运行我们的第一个 TinyGo 程序在真实的微控制器上。涵盖所有这些主题后,您将学会如何在微控制器上编写、构建和运行程序。
在本章中,我们将涵盖以下主要内容:
-
理解 TinyGo 是什么
-
设置 TinyGo
-
理解 IDE 集成
-
Arduino UNO
-
查看事物的 Hello World
技术要求
为了继续,您需要具备以下条件:
-
必须安装 Go
-
必须设置 GOPATH
-
必须安装 Git
-
Arduino Uno,最好是 Rev3 版本,但您也可以使用其他 Arduino Uno 板
您可以在此 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter01
本章的“代码在行动”视频可以在此处找到:bit.ly/3mLFCCJ
理解 TinyGo 是什么
TinyGo 是一个独立编写的编译器,拥有其自己的运行时实现。它旨在用于微控制器编程、WebAssembly(WASM)和 CLI 工具。TinyGo 大量使用 LLVM 基础设施来优化和编译代码,使其成为微控制器可以理解的二进制文件。
TinyGo 的第一个版本(v0.1)于 2019 年 2 月 1 日在 GitHub 上发布。从那时起,该项目迅速实现了许多功能,并且从未停止为更多微控制器、传感器、显示屏和其他设备添加支持。
2020 年 2 月 2 日,TinyGo 宣布它现在正式成为谷歌赞助的项目。这对整个项目来说是一个巨大的进步。
TinyGo 的工作原理
TinyGo 编译器与其他语言相比,在将 Go 源代码转换为机器代码时使用不同的步骤。虽然我们不会深入细节,但让我们看一下编译器管道的概述:
-
我们编写 Go 源代码。
-
此源代码被转换为 Go SSA(静态单赋值)。
-
TinyGo 编译器包将 Go SSA 转换为 LLVM IR。
-
LLVRM IR 中的初始化代码由 TinyGo 的
interp包解释。这一步优化了全局变量、常量等。 -
结果随后通过一些 LLVM 优化过程(如
string到[]byte优化)进行优化。 -
结果随后再次由 LLVM 优化器优化。
-
然后,编译器包进行了一些修正。
-
最后一步,LLVM 创建机器代码。
如果现在听起来很复杂,请不要担心——我们不需要关心这个过程。TinyGo 会为我们完成所有这些。现在让我们看看与 Go 相比,TinyGo 有哪些特殊之处。
将 TinyGo 与 Go 比较
TinyGo 可以编译一些 Go 程序,但不是全部。让我们看看一个可以被两者编译的例子。让我们用 Go 编写一个简单的 Hello World 程序——构建它并检查其大小:
-
这是我目前能想到的最简单的 Hello World 程序:
package main func main() { print("Hello World\n") }它不需要像
fmt这样的外部包来打印行。 -
我将在 Ubuntu 20.01 操作系统上使用 Go 1.15.2。要检查您当前安装的 Go 版本,请使用
go version命令:$ go version go version go1.15.2 linux/amd64 -
我们使用
go build命令构建程序:$ go build ./ch1/hello-world/ -
现在我们使用
ls –l命令来检查大小:$ ls -l -rwxrwxr-x 1 tobias tobias 1231780 Okt 4 19:31 hello-world
因此,程序有 1,231,780 字节,即 1.23178 兆字节。对于一个只有 4 行代码的程序来说,这相当大。
注意
ls 命令并非在所有操作系统上都有。如果您想自己检查大小,您需要使用您操作系统上可用的工具。
二进制文件的大小在您尝试时可能会有所不同,因为 Go 团队继续优化编译器。
此外,为其他操作系统构建时,二进制文件的大小也可能不同。
现在让我们检查相同程序的大小,但这次使用 TinyGo 编译。由于 TinyGo 不支持为 Windows 构建二进制文件,我负责编译,所以我们只需比较大小即可:
-
我使用以下命令构建二进制文件:
tinygo build command has a syntax that is similar to the Go build command. -
然后,我使用
ls –l命令检查了大小,就像我们之前做的那样:$ ls -l -rwxrwxr-x 1 tobias tobias 1231780 Okt 4 19:31 hello-world -rwxrwxr-x 1 tobias tobias 21152 Okt 4 19:39 hello-world-tiny
我们看到,我们的 Hello World 程序的 TinyGo 版本大小仅为 Go 编译器生成的尺寸的一小部分。TinyGo 版本仅为 21,152 字节,大约是 0.021152 兆字节。与 Go 程序相比,TinyGo 程序小了 58 倍。这是一个巨大的差异。如果您仍然想亲自测试,您可以在设置 TinyGo 后进行。
我们现在已经了解到 TinyGo 可以编译一些 Go 程序,但不能编译所有。我们还了解到,用 TinyGo 编译的程序非常小。在下一节中,我们将了解为什么 TinyGo 不能编译所有 Go 程序,以及 TinyGo 提供了哪些 Go 没有的特性。
支持的语言特性
TinyGo 支持 Go 语言的一部分特性,但并不是所有特性都支持。Goroutines 和 channels 在大多数微控制器上工作。对于大多数类型,反射是支持的。虽然切片是支持的,但在处理映射时可能会遇到一些问题。只有某些类型的字符串、整数、指针以及包含前述类型的结构体或数组是支持的。所以,总的来说,TinyGo 支持了 Go 语言的大部分特性。
支持的标准包
标准库的大部分内容也在 TinyGo 中得到支持。然而,截至编写本文时,net和crypto包的大部分内容仍然无法编译。这意味着,如果你导入它们,你会得到编译错误。
你可以在这里查找当前支持的标准化包列表:tinygo.org/lang-support/stdlib/。
注意
支持表中的一项“是”并不意味着一个包中的每个函数在 TinyGo 中实际上都是可用的。一些函数仍然可能引起编译错误。
易失性操作
易失性操作可以用来读写内存映射寄存器。这些寄存器中的值在多次读取之间可能会改变,而编译器并不知道这一点。编译器对这些操作的效果一无所知,因此它们被称为易失性。
Go 没有易失性运算符,这就是为什么 TinyGo 提供了一个易失性包。在大多数情况下,我们不需要易失性操作,因为这些操作被机器包抽象掉了。
内联汇编
汇编语言(ASM)是一种专门为特定处理器架构设计的语言。这是因为汇编依赖于机器代码指令集。TinyGo 的设备特定包提供了汇编包。
这使得我们能够在 Go 程序中使用内联汇编代码,这在标准 Go 中是不可能的。
堆内存分配
堆是内存中动态分配和释放发生的地方。因此,当我们的应用程序想要保留内存的一部分时,它会与堆进行通信以保留内存。这部分内存将被标记为正在使用。由于微控制器上的空间相当有限,垃圾回收既昂贵又慢,TinyGo 试图优化堆内存分配。结果是,通常,对象可以被静态分配而不是动态分配。
垃圾回收
垃圾回收是释放内存的过程。因此,当你的应用程序不再需要之前请求的内存部分时,这部分内存会被标记为未使用(空闲)。
为了这个目的,TinyGo 实现了自己的垃圾回收变体。TinyGo 使用保守的标记/清除垃圾回收,这里的保守意味着垃圾回收器(GC)不知道什么是指针,什么不是指针。GC 过程分为两个部分:
-
Mark:在标记阶段,gc 将对象标记为可达。
-
Sweep:在清扫阶段,gc 通过将不可达对象的区域标记为空闲来释放内存。这些释放的区域可以随后被重新用于分配新对象。
我们现在已经知道了 TinyGo 是什么,以及 TinyGo 和 Go 之间存在的差异。我们还学习了 Heap、GC 和 volatile 包是什么。下一步的逻辑步骤是继续设置 TinyGo,我们将在下一节中完成。
设置 TinyGo
在以下链接中,您可以按照 Linux、macOS、Windows 和 Docker 的快速入门指南安装 TinyGo 及其所有依赖项:tinygo.org/getting-started/。
由于这些指南涵盖了重要部分,我将仅涵盖基于 x64 架构的快速入门部分,并且仅针对基于 Debian 的操作系统的 Linux,如 Ubuntu。
在开始设置之前,我们首先要检查 TinyGo 的最新版本。为此,请访问github.com/tinygo-org/tinygo/releases并检查最新发布版本。现在,请将此信息记录下来或记住它,因为我们稍后会用到。
在 Linux 上安装
以下步骤涵盖了在基于 Debian 的 Linux 发行版上安装 TinyGo:
-
我们使用以下命令从 GitHub 下载
deb包,并使用dpkg进行安装:wget https://github.com/tinygo-org/tinygo/releases/download/v0.15.0/tinygo_0.15.0_amd64.deb sudo dpkg -i tinygo_0.15.0_amd64.deb您可以将路径和文件名中的版本与您之前找到的最新发布版本进行交换。
-
现在我们必须将 TinyGo 添加到
GOPATH中。您可以使用以下命令:GOPATH by editing your .profile or .netrc file. -
下一步是验证安装。使用
tinygo version命令验证 TinyGo 是否已成功安装:$ tinygo version tinygo version 0.15.0 linux/amd64 (using go version go1.15.2 and LLVM version 10.0.1) -
AVR 依赖项:由于我们将在前几章中使用 Arduino UNO,我们需要安装一些额外的依赖项。我们通过以下命令来完成:
sudo apt-get install gcc-avr sudo apt-get install avr-libc sudo apt-get install avrdude
安装这些依赖项后,我们现在可以在基于 AVR 的板卡上编译,例如 Arduino UNO。
如果您使用 Fedora、Arch Linux 或其他发行版,请遵循以下安装指南:tinygo.org/getting-started/linux/。
在 Windows 上安装
在本节中,我们将学习如何在 Windows 上安装 TinyGo。在本节之后,我们还将学习如何安装依赖项,这些依赖项是烧录 Arduino UNO 所必需的。
非常重要的注意事项
您无法使用 TinyGo 创建 Windows 二进制程序。您仍然可以编译和烧录针对微控制器和 WebAssembly 目标的程序。
您可能希望直接在Windows Subsystem for Linux(WSL)中安装并使用 TinyGo。WSL 是我推荐给 Windows 用户的方案。
要在不使用 WSL 的情况下在 Windows 上安装 TinyGo,我推荐使用 Scoop,这是一个 Windows 的命令行安装程序。请确保您已安装 PowerShell 5(或更高版本)和.NET Framework 4.5(或更高版本)。为此,请按照以下步骤操作:
-
使用以下命令为您的当前用户账户启用 PowerShell:
Set-ExecutionPolicy RemoteSigned -scope CurrentUser -
现在运行以下命令以下载 Scoop:
iwr -useb get.scoop.sh | iex -
您可以使用以下命令安装 TinyGo:
scoop install tinygo -
现在要验证安装是否成功,请使用以下命令:
tinygo version输出应该看起来像以下这样:
tinygo version 0.15.0 windows/amd64 (using go version go1.15.3 and LLVM version 10.0.1)实际的 TinyGo 和 Go 版本可能不同。
-
AVR 依赖项:为了能够编译和刷写 Arduino UNO 等基于 AVR 的微控制器的程序,我们需要安装一个 AVR 8 位工具链。您可以在以下位置找到下载链接:
www.microchip.com/mplab/avr-support/avr-and-arm-toolchains-c-compilers。扩展您的
%PATH%并确保bin文件夹被包含在内: -
接下来,下载并安装 Windows 的 GNU Make。您可以在以下位置找到 GNU Make:
gnuwin32.sourceforge.net/packages/make.htm。 -
作为最后一步,您需要下载并安装
avrdude。avrdudeEXE 也必须位于您的%PATH%中。您可以在以下位置下载 AVR Dude:download.savannah.gnu.org/releases/avrdude/。您要找的文件名为avrdude-6.3-mingw32.zip。
如果您遇到有关 avr 设置的问题或不知道如何配置环境变量,您可能想查看以下指南:fab.cba.mit.edu/classes/863.16/doc/projects/ftsmin/windows_avr.html。
WSL 安装
也可以直接在 Windows Subsystem for Linux(WSL)上安装 TinyGo。只需遵循 Linux 部分,即可完成此操作。
macOS 安装
macOS 安装
-
我们将使用 Homebrew 来
安装 tinygo。只需使用以下两个命令:brew tap tinygo-org/tools brew install tinygo -
简单地运行
tinygo version命令以验证安装:$ tinygo version tinygo version 0.15.0 darwin/amd64 (using go version go1.15 and LLVM version 10.0.1) -
让我们快速查看以下步骤,以安装编译 AVR 基于微控制器程序(如我们在本书的前几章中将使用的 Arduino UNO)所需的附加要求:
brew tap osx-cross/avr brew install avr-gcc brew install avrdude
Docker 安装
可以直接使用 Docker 镜像来编译我们的程序。然而,使用 Docker 镜像无法刷写程序。
简单地使用以下命令下载镜像:
docker pull tinygo/tinygo:0.15.0
注意
实际的 TinyGo 版本可能不同。使用我们在开始本节时进行的检查中获取的最新 TinyGo 版本。
这里是一个构建程序的示例调用:
docker run -v $GOPATH:/go -e "GOPATH=/go" tinygo/tinygo:0.15.0 tinygo build -o /go/src/github.com/myuser/myrepo/wasm.wasm -target wasm --no-debug /go/src/github.com/myuser/myrepo/wasm-main.go
我们现在已经成功设置了 TinyGo,并安装了所有编译和刷写到 Arduino UNO 微控制器所需的附加要求。此外,我们需要的所有 WebAssembly 东西也已设置好。下一步是在我们开始编写微控制器的第一个程序之前设置 IDE 集成。
理解 IDE 集成
拥有一个设置得当的 IDE 真是件好事,因为我们能从中受益于代码补全、功能性代码审查等功能。这样,我们就不必调查每个想要调用的函数的源代码或文档。
在本节中,我们将探讨将 TinyGo 集成到 VS Code、Goland 和其他编辑器中的过程。这使得我们可以选择我们偏好的任何编辑器来使用。
VS Code 集成
VS Code 提供了一个扩展系统,这使得将 Go 和 TinyGo 工具集集成到 IDE 中变得容易。我们将安装提供 Go 编程语言支持的 Go 扩展。之后,我们将安装提供 TinyGo 支持的 TinyGo 扩展。
Go 扩展
我们将使用以下步骤通过 扩展 视图安装 Go 扩展:
-
通过点击 扩展 图标或按 Ctrl + Shift + X 来打开 扩展 视图。
-
搜索 Go。
-
选择列表中的第一个条目,它被称为 Go,由 Google 的 Go 团队提供。
-
点击以下截图中的 安装 按钮:
![图 1.1 – 从扩展视图安装]()
图 1.1 – 从扩展视图安装
-
首次安装扩展后,可能会提示您安装更多依赖项。通过点击 安装 按钮进行安装。如果没有提示,您也可以通过按 Ctrl + Shift + P 并输入以下命令来安装所有依赖项:
Go: install -
选择 Go: 安装/更新工具 命令,然后按 Enter:
![图 1.2 – 执行安装/更新工具命令]()
图 1.2 – 执行安装/更新工具命令
-
现在通过在左侧勾选复选框来选择所有依赖项,然后点击 确定:
![图 1.3 – 选择所有依赖项]()
图 1.3 – 选择所有依赖项
-
VS Code 现在将安装所有依赖项,完成后应会打印以下消息:
All tools successfully installed. You are ready to Go :).
接下来,我们将看到 VS Code 中的 TinyGo 集成。
TinyGo 扩展
TinyGo 在 VS Code 中的集成非常简单,因为有一个 TinyGo 扩展,我们只需要安装它。让我们快速浏览以下步骤:
-
通过点击 扩展 图标或按 Ctrl + Shift + X 来打开 扩展 视图。
-
搜索 TinyGo。
-
选择列表中的第一个条目,它被称为 TinyGo,由 TinyGo 团队提供。
-
点击以下截图中的 安装 按钮:
![图 1.4 – 显示 TinyGo 扩展的扩展视图]()
图 1.4 – 显示 TinyGo 扩展的扩展视图
-
我们还没有完成扩展的安装。我们需要使用另一个命令来配置我们想要构建的目标。按 Ctrl + Shift + P,输入
TinyGo target,然后按 Enter。 -
现在,搜索
arduino并按Enter键,正如我们在以下截图中所见:![图 1.5 – 目标选择弹出窗口]()
图 1.5 – 目标选择弹出窗口
-
VS Code 将打开一个弹出窗口,告诉您它需要重新加载窗口。通过点击重新加载来完成此操作:

图 1.6 – 弹出窗口请求重新加载窗口
好的,我们现在已经安装了扩展,并选择了一个目标。但是,它内部是如何工作的?这个扩展的唯一功能是在当前项目的vs code settings.json中设置go.toolsEnvVars变量。
这可能看起来像以下示例:
{
"go.toolsEnvVars": {
"GOROOT": "/home/user/.cache/tinygo/goroot-go1.14-f930d5b
5f36579e8cbf1f-syscall",
"GOFLAGS": "-tags=cortexm,baremetal,linux,arm,nrf51822,
nrf51,nrf,microbit,tinygo,gc.conservative,scheduler.
tasks"
}
}
有时会出现类似于以下截图的弹出窗口。不要点击更新工具;只需关闭它。

图 1.7 – 弹出窗口请求更新工具
如果您正在使用 VS Code,恭喜您,您已完成设置,准备开始使用!接下来的几节将解释如何在其他编辑器中设置 IDE 集成。
通用 IDE 集成
您可能会想知道,IDE 集成是如何与 TinyGo 一起工作的?嗯,我们只需要配置标准的 Go 工具,特别是gopls 语言服务器。
TinyGo 有自己的标准库实现,并提供额外的库,例如机器包。gopls 语言服务器需要知道在哪里查找这些包。这就是为什么我们需要为这个项目设置一个GOROOT。
TinyGo 大量使用编译器标志。这些标志在编译时用于确定哪些文件必须包含在构建中,正如我们在以下截图中所见:

图 1.8 – 来自 TinyGo 源代码的 board_arduino.go 文件显示构建标志
因此,基本上,我们通过在本地设置这些环境变量将 TinyGo 集成到 IDE 中。
我们不必猜测GOROOT和GOFLAGS的正确值。TinyGo 提供了一个用于此目的的命令。假设我们想要为 Arduino 设置正确的标志,我们可以通过以下命令来查找:
tinygo info arduino
这将打印以下结果:
LLVM triple: avr-unknown-unknown
GOOS: linux
GOARCH: arm
build tags: avr baremetal linux arm atmega328p atmega avr5 arduino tinygo gc.conservative scheduler.none
garbage collector: conservative
scheduler: none
cached GOROOT: /home/tobias/.cache/tinygo/goroot-go1.15.2-bb8dfc1625dfff39df9d5a78a474eb93c273cccfe3243ee4e33bafef0dcd97fe-syscall
输出的重要部分是build标签和缓存的 GOROOT。
既然我们已经知道了所需信息的来源,我们就可以配置我们想要使用的任何 IDE。
设置 Goland
我们现在已经了解到我们必须设置GOROOT和build标签,我们也可以在 Goland 中配置集成。
从以下截图中的tinygo info命令设置GOROOT:

图 1.9 – Goland 中的 GOROOT 配置
下一步是设置build标签。您可以在构建标签和供应商下找到它们。将这些标签添加到自定义标签字段中:

图 1.10 – Goland 中的自定义标签配置
注意
每次您想要为另一个微控制器编程时,都必须手动更改自定义标签。
集成任何编辑器
如果您已安装标准 Go 工具,您可以使用任何其他编辑器,例如 Vim 或 Nano,以便您获得 IDE 支持。由于其他编辑器可能缺少配置文件,我们可以通过在启动时传递它们环境变量来解决这个问题。
在以下示例中,我们首先设置环境变量,然后启动 VS Code 实例:
export GOFLAGS=-tags=avr,baremetal,linux,arm,atmega328p,atmega,avr5,arduino,tinygo,gc.conservative,scheduler.none; code
您可以将代码调用更改为任何其他程序,例如 vim 或 nano。在 Windows 系统上,调用可能略有不同。
既然我们已经知道如何为 TinyGo 配置任何 IDE,我们将继续学习关于 Arduino UNO 的内容。
Arduino UNO
Arduino UNO 是最受欢迎的板之一。它由一个 8 位 ATmega328P 微控制器供电,截至本书编写时,有大量源自原始 Arduino UNO 板的衍生产品。让我们在以下小节中更好地了解它。
了解技术规格
如您在以下表中看到的,ATmega328P 只有 16 MHz 和 32 KB 闪存。标准 Go 生成大约 1.2 MB 的 Hello World 程序,甚至无法适应这个微控制器。因此,我们在非常有限的硬件上工作,但您将看到这足以构建惊人的项目。
这里简要地看看 Arduino UNO 的技术规格:

表 1.1 – 技术规格
注意
考虑每个 I/O 引脚的直流电流上限为 20 mA。您不应超过此限制,以防止损坏您的微控制器。
让我们看看引脚图。
探索引脚图
引脚图基本上是引脚的映射。我们将使用我们在 Arduino UNO 上构建的所有项目的这些引脚的描述。我们需要它来正确布线我们的组件。

图 1.11 – Arduino UNO REV3 引脚图
既然我们已经了解了一些关于 Arduino UNO 的基本信息,让我们继续编写我们的第一个程序。
检查事物的 Hello World
Hello World 程序是开始学习一门新编程语言的典型方式。与普通 Hello World 程序相比,微控制器上的 Hello World 程序看起来略有不同。我们将编写一个 Hello World 程序来使内置 LED 闪烁。让我们开始吧!
准备要求
要开始使用我们的程序,我们需要以下内容:
-
Arduino UNO
-
一根 USB 线连接到您的电脑
准备项目
严格按照以下步骤进行您的项目:
-
在您的项目根目录下创建一个名为
ch1的新文件夹。 -
在文件夹内,我们需要创建一个名为
hello-world-of-things的文件夹,并在其中创建一个新的main.go文件。 -
你的结构现在应该如下所示:

图 1.12 – 项目结构
现在我们已经准备好了我们的项目,我们可以继续编写我们的第一个程序。
编程微控制器
我们将让板载 LED 闪烁。这是我们最容易开始的例子。我们使用的例子是受 TinyGo 源代码中的 Blinky 示例 启发,该示例也被用作事物世界问候的展示。让我们仔细地过一遍每个步骤:
-
声明
main包:package main -
导入
machine和time包:import ( "machine" "time" ) -
添加一个
main函数:func main() { -
初始化一个名为
led的变量,其值为machine.LED:led := machine.LED -
将
led引脚配置为输出引脚:led.Configure(machine.PinConfig{Mode: machine. PinOutput}) -
声明一个无限循环:
for { -
将
led设置为Low以确保不向 LED 提供电压:led.Low() -
将
sleep设置为300毫秒,即 LED 关闭的时间:time.Sleep(time.Millisecond * 300) -
将
led设置为High以给 LED 提供电压,使其发光:led.High() -
将
Sleep设置为300毫秒,这是 LED 亮的时间:time.Sleep(time.Millisecond * 300) -
关闭
for循环:} -
main函数的闭合花括号:}
machine 包提供了引脚映射的常量,并提供了一些与所使用的微控制器直接相关的函数。
我们必须在给 LED 提供电压和再次断电之间等待一定的时间,这样我们才能看到闪烁。
将引脚配置为输出意味着我们告诉微控制器我们只将通过此引脚发送信号。我们也可以将引脚配置为输入,这样我们就可以从引脚读取状态。
闪存程序
如果你在 Linux、macOS 或使用 Windows WSL,闪存程序是一个简单的命令。
简单地将你的 Arduino UNO 连接到任何 USB 端口并执行以下命令:
tinygo flash --target=arduino ch1/hello-world-of-things/main.go
tinygo flash 命令至少需要以下参数:
-
--target,该命令将微控制器设置为闪存: -
main.go文件的路径
你的输出应该如下所示:
avrdude: AVR device initialized and ready to accept instructions
Reading | ################################################## | 100% 0.00s
avrdude: Device signature = 0x1e950f (probably m328p)
avrdude: NOTE: "flash" memory has been specified, an erase cycle will be performed
To disable this feature, specify the -D option.
avrdude: erasing chip
avrdude: reading input file "/tmp/tinygo208327574/main.hex"
avrdude: writing flash (558 bytes):
Writing | ################################################## | 100% 0.10s
avrdude: 558 bytes of flash written
avrdude: verifying flash memory against /tmp/tinygo208327574/main.hex:
avrdude: load data flash data from input file /tmp/tinygo208327574/main.hex:
avrdude: input file /tmp/tinygo208327574/main.hex contains 558 bytes
avrdude: reading on-chip flash data:
Reading | ################################################## | 100% 0.08s
avrdude: verifying ...
avrdude: 558 bytes of flash verified
avrdude done. Thank you.
如你所见,在我的例子中,闪存到 Arduino UNO 的代码只使用了 558 字节的内存。
恭喜你,你已经成功使用 TinyGo 在 Arduino UNO 上编写、构建和闪存了你的第一个程序。
使用 TinyGo Playground
你现在没有 Arduino UNO?你可以使用 TinyGo Playground 测试代码。TinyGo Playground 利用 WebAssembly 模拟少量板的行为,例如 Arduino Nano IoT 33 和 Arduino UNO。它还可以为 Arduino Nano IoT 33 编译程序。但请记住,TinyGo Playground 中的行为可能与真实硬件不同。
你可以在 play.tinygo.org/ 找到 TinyGo Playground。
摘要
我们已经学习了 TinyGo 实际上是什么,它与标准 Go 有何不同,我们获得了关于 Arduino UNO 本身的基本知识,如何设置 TinyGo,如何设置 IDE 集成,最后,我们将我们的第一个程序写入并烧录到真实硬件上,并用我们的代码使 LED 闪烁。这不是一个有趣的开始吗?
我们将在下一章构建一个交通灯控制器系统。
问题
-
哪个命令可以用来找出用于 IDE 集成的所需环境变量值?
-
哪个命令可以用来将程序烧录到微控制器上?
-
为什么在给 LED 供电或从 LED 取电时,我们必须让电路休眠一段时间?
-
你会如何让 LED 以摩尔斯电码闪烁 S-O-S?
第二章:第二章:构建交通灯控制系统
在上一章中,我们设置了 TinyGo 和我们的 IDE,并且我们现在知道如何构建和将我们的程序烧录到 Arduino UNO 上。现在我们将利用这些知识更进一步。
在本章中,我们将构建一个交通灯控制系统。我们将把项目分解成小步骤,其中我们构建并测试每个组件。最后,我们将把所有东西组合在一起。我们将使用多个 LED、面包板、GPIO 端口和按钮来中断正常流程,将人行横道灯切换为绿色。到本章结束时,你将知道如何控制外部 LED、读取按钮的状态、使用 GPIO 端口、如何区分电阻以及如何在 TinyGo 中利用 Goroutines。
在本章中,我们将介绍以下主题:
-
点亮外部 LED
-
当按下按钮时点亮单个 LED
-
建立交通灯
-
建立带人行横道灯的交通灯
技术要求
要构建交通灯控制系统,我们需要一些组件。为了构建完整的项目,我们需要以下内容:
-
Arduino UNO
-
面板
-
五个 LED
-
多条跳线
-
多个 220 欧姆电阻
-
一个按钮
-
一个 10K 欧姆电阻
你可以在此 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter02
本章的“代码在行动”视频可以在这里找到:bit.ly/2RpvF2a
点亮外部 LED
在我们开始构建更复杂的电路之前,让我们先点亮一个外部 LED。一旦这个工作正常,我们将逐步扩展电路。我们从一个红色 LED 开始。点亮外部 LED 与点亮板载 LED 略有不同。我们需要一个可以放置 LED 的东西,我们还需要一些电线以及基本的电阻知识,这将帮助我们防止 LED 损坏。这就是为什么我们要逐个检查每个组件。
使用面包板
面包板用于原型设计,因为它们不需要你直接焊接组件。我们将使用面包板构建所有项目。
面板通常由两部分组成:
-
电源总线
-
水平行
面板每一侧都有一个电源总线。电源总线提供+(正)车道和-(地)车道。正车道是红色的,地车道是蓝色的。单个插槽在电源总线内部相连。
单行槽位之间也是相连的。一个槽位中的信号也可以在下一个槽位中找到。除非我们放入一根电缆来创建连接,否则不同的水平行之间是不相连的。下面是面包板的外观:

图 2.1 – 面包板 – 图片来自 Fritzing
理解 LED 基础
Arduino UNO 的工作电压为 5V,这对大多数 LED 来说太高了。因此,我们需要将电压降低到 LED 可以处理的水平。为此,我们将使用 220 欧姆电阻从线路中抽取电流,以保护 LED 免受损坏。如果您没有 220 欧姆电阻,也可以使用 470 欧姆;220 欧姆到 1K 欧姆(1K = 1,000)之间的任何值都可以。
如果您想确保电阻符合 LED 的需求,也可以按照以下方式计算电阻值:
R = (V**s – Vled) / I**led*
其中:
-
R 是电阻值。
-
Vs 是源电压。
-
Vled 是 LED 上的电压降。
-
Iled 是通过 LED 的电流。
注意
LED 有 阳极(+)和 阴极(-)引脚。阳极引脚较长。
不同的颜色需要不同的电压。当使用相同电阻和电压为不同颜色的 LED 时,您会发现某些颜色比其他颜色更亮。
使用 GPIO 端口
GPIO 代表 通用输入输出。这意味着我们可以将这些引脚用于数字信号的输入和输出。我们可以将 GPIO 引脚设置为 高 或 低,或者从端口读取 高 或 低 值。
注意
我们从不应该从单个 GPIO 端口中抽取超过最大 40.0 mA(毫安)的电流。否则,我们可能会永久损坏硬件。
搭建电路
现在让我们在面包板上搭建我们的第一个电路:
-
在水平行的 G 列中放入一个红色 LED。将阴极放入 G12,将阳极放入 G13。
-
将 F12 连接到电源总线上的地线。
-
使用 220 欧姆电阻将 F13 和 E13 连接起来。(220 欧姆到 1,000 欧姆之间的任何值都可以。)
-
将 GPIO 端口的 Pin 13 连接到 A13。
-
将 GND 端口连接到电源总线上的地线。
注意
您面包板上的描述可能与我使用的不同。如果是这样,您需要根据下一张图来构建电路。
电路现在应该看起来像以下这样:

图 2.2 – 电路图片 – 图片来自 Fritzing
编写代码
我们首先在我们的项目工作区中创建一个名为 Chapter02 的新文件夹。这个文件夹将用于本章的所有部分。在 Chapter02 文件夹内,我们创建一个 blinky-external 文件夹,并在其中创建一个新的 main.go 文件。
结构应该看起来像以下这样:

图 2.3 - 编写代码的项目结构
我们导入machine和time包,并将以下代码放入main函数中:
-
声明并初始化一个名为
outputConfig的变量,使用新的PinConfig以输出模式:outputConfig := machine.PinConfig{Mode: machine. PinOutput} -
声明并初始化一个名为
greenLED的变量,其值为machine.D13:greenLED := machine.D13 -
使用我们之前创建的
outputConfig实例配置 LED,通过将其作为参数传递给Configure函数:redLED.Configure(outputConfig) -
然后我们无限循环:
for { -
将
redLED设置为Low(关闭):redLED.Low() -
睡眠半秒钟。如果不睡眠,LED 将以极高的速率开启和关闭,因此每次状态改变后我们都会睡眠:
time.Sleep(500 * time.Millisecond) -
将
redLED设置为High(开启):redLED.High() -
睡眠半秒钟:
time.Sleep(500 * time.Millisecond) } -
现在使用以下命令使用
tinygo flash命令闪烁程序:tinygo flash –target=arduino Chapter02/blinky-external/main.go
当闪烁进度完成并且 Arduino 重新启动时,红色 LED 应该以 500 毫秒的间隔闪烁。
恭喜你,你刚刚构建了你的第一个电路并编写了第一个控制外部硬件的程序!既然我们现在知道如何在面包板上连接和控制外部 LED,我们可以继续构建一个更复杂的电路。让我们在下一节中这样做。
当按钮被按下时点亮 LED
到目前为止,我们只使用了代码来直接控制硬件组件。现在让我们尝试读取按钮的状态以控制 LED。我们需要以下组件:
-
至少 6 根跳线
-
一个 LED(颜色不重要)
-
一个 220 欧姆电阻
-
一个 4 针按钮(按下按钮)
-
一个 10K 欧姆电阻
现在让我们继续构建电路。
构建电路
以下电路扩展了我们之前构建的电路。所以,如果你仍然组装了之前的电路,你只需要添加按钮部分。下一个电路由两个组件组组成。第一个组用于控制 LED,第二个组用于读取按钮状态。
添加 LED 组件
我们从 LED 电路开始:
-
将一个 LED 的阴极放在 G12,阳极放在 G13\。
-
使用 220 欧姆电阻将F13与D13连接。
-
使用跳线将 GPIO 端口中的D13端口与A13连接。
-
使用跳线将F12与电源总线的地线连接。
添加按钮组件
现在我们将添加一个按钮:
-
使用跳线将
A31与电源总线的正线连接。 -
使用 10K 欧姆电阻将电源总线的地线与
B29连接。 -
将
D29与端口D2连接。 -
将按钮的一个引脚放在
E29,一个放在E31,一个放在F29,最后一个引脚放在F31。
我们现在的电路应该看起来类似于以下这样:

图 2.4 – 电路 – 从 Fritzing 获取的图像
注意
在我们开始为这个电路编写代码之前,我们需要了解这些按钮是如何工作的。
由于按钮如果放置不正确在面包板上将无法工作,让我们再次看看按钮。
按钮上的 4 个引脚分为两组,每组两个引脚。因此,两个引脚相互连接。查看按钮的背面,我们应该能够看到两个相对的引脚相互连接。因此,当你将按钮旋转 90°放置时,按钮可能不会按预期工作。
编程逻辑
在深入代码之前,我们将在Chapter02文件夹内创建一个名为light-button的新文件夹,并在其中创建一个main.go文件,包含一个空的main函数,使用以下命令:


图 2.5 – 逻辑的文件夹结构
现在我们来看看main函数和上拉电阻。
主函数
我们希望在按钮被按下时点亮 LED。为了实现这一点,我们需要从引脚读取并使用以下步骤检查其状态:
-
使用
PinConfig在PinOutput模式下初始化outPutConfig变量。这个配置将被用来控制 LED 引脚:outputConfig := machine.PinConfig{Mode: machine. PinOutput} -
使用
PinConfig在PinInput模式下初始化inputConfig变量。这个配置正在用于读取按钮状态的引脚,因此它需要是一个输入:inputConfig := machine.PinConfig{Mode: machine.PinInput} -
使用
machine.D13的值初始化led变量,这是我们连接到led的引脚:led := machine.D13 -
通过传递
outputConfig作为参数配置led为输出,这是连接到按钮的引脚:led.Configure(outputConfig) -
使用
machine.D2的值初始化buttonInput变量:buttonInput := machine.D2 -
将
buttonInput配置为输入,通过传递inputConfig作为参数:buttonInput.Configure(inputConfig) -
由于我们不希望程序在检查按钮状态一次后就终止,我们使用一个无限循环来重复并永远检查:
for { -
检查按钮的当前状态。如果按钮被按下,它将为真:
if buttonInput.Get() { -
如果按钮被按下,我们将点亮 LED:
led.High() -
我们在这里调用
continue,所以我们不会执行led.Low()调用:continue } -
如果按钮没有被按下,我们将 LED 关闭:
led.Low() }注意
不要忘记导入
machine包,否则代码将无法编译。
现在使用tinygo flash命令烧录程序:
tinygo flash –target=arduino Chapter02/light-button/main.go
在成功烧录后,当你按下按钮时,LED 应该会点亮。
上拉电阻
你可能想知道为什么按钮电路中需要一个 10K 欧姆的电阻。10K 欧姆电阻用于防止信号/引脚悬空。悬空引脚是坏的,因为一个处于悬空状态的输入引脚是不确定的。当我们尝试从一个引脚读取值时,我们期望得到一个数字值 - 1 或 0,或真或假。悬空意味着值可以在 1 和 0 之间快速变化,这发生在没有上拉或下拉电阻的情况下。以下是一些关于悬空引脚的进一步阅读:www.mouser.com/blog/dont-leave-your-pins-floating。
作为 10K 欧姆外部电阻的替代,可以使用内部电阻。
配置输入引脚使用内部电阻的方法如下:
inputConfig := machine.PinConfig{
Mode: machine.PinInputPullup
}
我们现在已经学会了如何使用输入信号控制 LED,这个信号是由按钮提供的。下一步是构建交通信号灯流程来控制三个 LED。
构建交通信号灯
我们知道如何点亮一个 LED,也知道如何使用按钮输入点亮一个 LED。下一步是构建一个使用三个 LED 的电路,并编写代码以正确顺序点亮它们。
构建电路
构建电路需要以下组件:
-
三个 LED(最好是红、黄、绿)
-
三个 220 欧姆电阻
-
七根跳线
我们首先通过以下步骤设置组件:
-
将 Arduino 的GND连接到电源总线的任何地端口。
-
将第一个(红色)LED 的阴极放置在G12,阳极放置在G13。
-
将第二个(黄色)LED 的阴极放置在G15,阳极放置在G16。
-
将第三个(绿色)LED 的阴极放置在G18,阳极放置在G19。
-
使用 220 欧姆电阻将F13与D13连接。
-
使用 220 欧姆电阻将F16与D16连接。
-
使用 220 欧姆电阻将F19与D19连接。
-
使用跳线将F13连接到电源总线上的Ground。
-
使用跳线将F16连接到电源总线上的Ground。
-
使用跳线将F19连接到电源总线上的Ground。
-
使用跳线将端口D13连接到A12。
-
使用跳线将端口D16连接到A12。
-
使用跳线将端口D19连接到A12。
您的电路现在应该类似于以下图示:
![图 2.6 – 交通信号灯电路 – 图片来自 Fritzing
![img/Figure_2.6_B16555.jpg]
图 2.6 – 交通信号灯电路 – 图片来自 Fritzing
我们现在已经成功设置了电路。现在我们可以继续编写一些代码来控制 LED。
创建文件夹结构
我们首先在Chapter02文件夹内的main.go文件中创建一个名为traffic-lights-simple的新文件夹,并从空的main函数开始。现在,您的项目结构应该如下所示:
![图 2.7 - 电路的文件夹结构
![img/Figure_2.7_B16555.jpg]
图 2.7 - 电路的文件夹结构
编写逻辑
我们已经成功设置了项目结构以继续。我们将实现以下流程:
红灯 -> 黄红灯 -> 绿灯 -> 黄灯 -> 红灯
这是一种典型的有三个灯泡的交通信号灯流程。
我们将配置三个引脚为输出,之后我们想要无限循环并按照此流程点亮 LED。
在main函数内部,我们编写以下代码:
-
使用
PinOutPut模式初始化一个名为outputConfig的新变量为PinConfig:outputConfig := machine.PinConfig{Mode: machine. PinOutput} -
初始化一个名为
redLED的新变量,其值为machine.D13,并配置为输出:redLED := machine.D13 redLED.Configure(outputConfig) -
初始化一个名为
yellowLED的新变量,其值为machine.D12,并配置为输出:yellowLED := machine.D12 yellowLED.Configure(outputConfig) -
初始化一个名为
greenLED的新变量,其值为machine.D11,并配置为输出:greenLED := machine.D11 greenLED.Configure(outputConfig)
我们现在已经初始化了变量作为输出引脚。下一步是按正确顺序点亮 LED。我们基本上有四个阶段,只需要按顺序重复即可模拟真实的交通灯。让我们逐一来看:
-
我们将无限循环处理各个阶段:
for { -
对于红色阶段,打开红色 LED 并等待一秒钟:
redLED.High() time.Sleep(time.Second) -
对于红黄阶段,打开黄色 LED 并等待一秒钟:
yellowLED.High() time.Sleep(time.Second) -
对于绿色阶段,关闭黄色和红色 LED,打开绿色 LED 并等待一秒钟:
redLED.Low() yellowLED.Low() greenLED.High() time.Sleep(time.Second) -
对于黄色阶段,关闭绿色 LED 并打开黄色 LED,然后等待一秒钟,再次关闭黄色 LED,这样我们就可以干净地再次开始红色阶段:
greenLED.Low() yellowLED.High() time.Sleep(time.Second) yellowLED.Low() }
函数的完整内容可在以下 URL 找到:
注意
不要忘记导入time和machine包。
我们现在已经组装并编程了一个完整的交通灯流程。下一步是将我们构建的一切结合起来完成我们的项目。
建立带人行道灯的交通灯
我们现在将结合本章所学和所做的一切,创建一个更加逼真的交通灯系统。我们将通过组装一个包含前一步骤中的三个灯泡交通灯的电路,并添加由按钮控制的两个灯泡的人行道灯来实现这一点。
组装电路
对于本章的最终项目,我们需要以下内容:
-
五个 LED:最好有两个红色,一个黄色和两个绿色
-
五个 220 欧姆电阻,每个 LED 一个
-
一个 10K 欧姆电阻作为按钮的上拉电阻
-
一个四针按钮
-
14 根跳线
我们首先按照以下步骤设置三个灯泡的交通灯:
-
将第一个 LED(红色)的阴极连接到G12,阳极连接到G13。
-
将第二个 LED(黄色)的阴极连接到G15,阳极连接到G16。
-
将第三个 LED(绿色)的阴极连接到G18,阳极连接到G19。
-
使用 220 欧姆电阻将F13与D13连接。
-
使用 220 欧姆电阻将F16与D16连接。
-
使用 220 欧姆电阻将F19与D19连接。
-
使用跳线将引脚D13与A13连接。
-
使用跳线将引脚D12与A16连接。
-
使用跳线将引脚D11与A10连接。
-
使用跳线将F12与电源总线上的地线连接。
-
使用跳线将F15与电源总线上的地线连接。
-
使用跳线将F18与电源总线上的地线连接。
现在按照以下步骤组装人行道灯:
-
将第四个 LED(红色)的阴极连接到G22,阳极连接到G23。
-
将第五个 LED(绿色)的阴极连接到G25,阳极连接到G26。
-
使用 220 欧姆电阻将F23与D23连接。
-
使用 220 欧姆电阻将F26与D26连接。
-
使用跳线将引脚D5与A23连接。
-
使用跳线将引脚D4与A26连接。
-
使用跳线将F22与电源总线上的地连接。
-
使用跳线将F24与电源总线上的地连接。
现在我们只需要组装按钮并连接电源总线:
-
将一个按钮放置在E29和F29的左引脚上,右引脚在E31和F31上。
-
使用 10K 欧姆电阻将电源总线上的地与B29连接。
-
使用跳线将引脚D2与C29连接。
-
使用跳线将A31与电源总线上的正极连接。
-
使用跳线将电源总线上的正极连接到 Arduino UNO 的 5V 端口。
-
使用跳线将电源总线上的地连接到 Arduino UNO 的地端口。
当你完成组装后,你的电路应该看起来像这样:
![图 2.8 – 由按钮控制的交通灯和行人灯的电路 – 图片来自 Fritzing
一个按钮 – 图片来自 Fritzing
![img/Figure_2.8_B16555.jpg]
图 2.8 – 由按钮控制的交通灯和行人灯的电路 – 图片来自 Fritzing
太好了,我们现在已经完全组装了本章的最终项目。我们现在可以编写一些代码来使这个项目变得生动起来。
设置项目结构
我们首先在Chapter02文件夹内创建一个名为traffic-lights-pedestrian的新文件夹。在新文件夹内,我们创建一个名为main.go的新文件,并在其中创建一个空的main函数。
我们的项目结构现在应该看起来像以下这样:
![图 2.9 - 项目的结构
![img/Figure_2.9_B16555.jpg]
图 2.9 - 项目的结构
编写逻辑
我们将把程序分成三个部分:
-
初始化逻辑
-
主逻辑
-
交通灯逻辑
初始化逻辑
我们需要初始化一个stopTraffic变量,并按照以下步骤配置 LED 引脚为输出引脚:
-
我们首先在包级别声明一个名为
stopTraffic的bool变量。这个变量将用作我们两个逻辑部分之间的通信通道:var stopTraffic bool -
在
main方法中,我们首先将stopTraffic的值设置为false:stopTraffic = false -
我们在
PinOutput模式下声明并初始化一个名为outputConfig的新变量,并将其传递给所有 LED 引脚:outputConfig := machine.PinConfig{Mode: machine. PinOutput} -
我们初始化一些新变量:
greenLED的值为machine.D11,yellowLED的值为machine.D12,redLED的值为machine.D13。然后,我们将每个 LED 变量配置为输出引脚:greenLED := machine.D11 greenLED.Configure(outputConfig) yellowLED := machine.D12 yellowLED.Configure(outputConfig) redLED := machine.D13 redLED.Configure(outputConfig) -
我们初始化一些新变量:
pedestrianGreen的值为machine.D4,pedestrianRed的值为machine.D5。然后,我们将每个 LED 变量配置为输出引脚:pedestrianGreen := machine.D4 pedestrianGreen.Configure(outputConfig) pedestrianRed := machine.D5 pedestrianRed.Configure(outputConfig) -
我们声明并初始化一个名为
inputConfig的新变量,使用PinConfig在PinInput模式中。然后,我们声明并初始化一个名为buttonInput的新变量,其值为machine.D2,并将buttonInput配置为输入引脚:inputConfig := machine.PinConfig{Mode: machine.PinInput} buttonInput := machine.D2 buttonInput.Configure(inputConfig)
初始化到此为止。我们已经设置了所有引脚和一个布尔变量在包级别。
注意
引脚常量,如machine.D13,是machine.Pin类型。
编写 trafficLights 逻辑
我们现在将编写完整的逻辑来控制电路中的所有 LED。这将是我们第一次需要将代码的一些部分移动到其他函数中。
为了做到这一点,我们首先编写一个名为trafficLights的新函数,该函数接受所有五个 LED 引脚作为参数,并且没有返回值。在函数内部,我们从一个空的、无休止的循环开始。我们的函数现在应该看起来像以下这样:
func trafficLights(redLED, greenLED, yellowLED, pedestrianRED,
pedestrianGreen machine.Pin) {
for {
}
}
所有逻辑都将放置在for循环中。循环中的实际逻辑包括两部分:
-
处理按钮信号以停止交通和控制行人灯
-
控制正常交通灯的流量
我们从处理按钮的信号开始。为了做到这一点,我们在if中检查stopTraffic,并且还有一个空的else分支。它看起来像以下这样:
if stopTraffic {
} else {
}
因此,当stopTraffic为true时,我们希望将我们的交通灯相位设置为红色。我们还希望将行人灯相位设置为绿色3 秒钟,然后回到红色,之后将stopTraffic设置为false,因为我们已经处理了一次信号。
让我们使用以下步骤来实现这个逻辑:
-
将交通灯相位设置为红色:
redLED.High() yellowLED.Low() greenLED.Low() -
将行人灯相位设置为绿色 3 秒钟:
pedestrianGreen.High() pedestrianRED.Low() time.Sleep(3 * time.Second) -
将行人灯相位设置为红色:
pedestrianGreen.Low() pedestrianRED.High() -
将
stopTraffic设置为false,因为我们已经处理了信号:stopTraffic = false -
在
else块中,我们只需将行人灯状态重置为红色:pedestrianGreen.Low() pedestrianRED.High()
好的,这是响应stopTraffic信号的部分。在if-else块下面,我们将实现控制交通灯流量的实际逻辑,这与之前所做的相同。所以我们从红色相位开始,过渡到红色-黄色相位,然后到绿色,然后到黄色,然后重置黄色以便能够干净地再次开始,如下所示:
redLED.High()
time.Sleep(time.Second)
yellowLED.High()
time.Sleep(time.Second)
redLED.Low()
yellowLED.Low()
greenLED.High()
time.Sleep(time.Second)
greenLED.Low()
yellowLED.High()
time.Sleep(time.Second)
yellowLED.Low()
在trafficLights函数中,我们只需做这些。
实现主要逻辑
现在我们只需要同时运行trafficLights函数和处理按钮输入。这就是goroutines发挥作用的地方。由于微控制器只有一个处理器核心,它使用单个线程工作,所以我们不能真正并行执行任务。由于我们在 Arduino UNO 上使用 goroutines,我们需要一些额外的构建参数。我们将在程序烧录时学习这些参数。在我们的例子中,我们希望在按钮上有一个监听器,同时仍然能够逐步处理交通灯过程。逻辑包括三个步骤:
-
使用
red相位初始化行人灯。 -
在 goroutine 中运行
trafficLights函数。 -
处理按钮输入。
对于第一部分,我们只需要将pedestrianRED LED 设置为高,将pedestrianGreen LED 设置为低:
pedestrianRed.High()
pedestrianGreen.Low()
现在我们只需调用trafficLights,并通过 goroutine 传递所有必要的参数:
go trafficLights(redLED, greenLED, yellowLED, pedestrianRed, pedestrianGreen)
对于最后一步,我们需要一个无限循环来检查buttonInput,并在按钮被按下时将stopTraffic设置为true。我们还需要它在之后睡眠 50 毫秒:
for {
if buttonInput.Get() {
stopTraffic = true
}
time.Sleep(50 * time.Millisecond)
}
注意
在处理按钮输入的循环中添加睡眠时间是必要的,因为调度器需要时间来运行 goroutine。goroutine 在主函数睡眠时被处理。此外,其他阻塞函数,如从通道读取,也可以用来给调度器时间来处理其他任务。
既然我们已经完成了逻辑,现在是时候将程序烧录到控制器上了。由于我们在项目中使用了 goroutine,我们需要向tinygo flash命令传递额外的参数:
tinygo flash -scheduler tasks -target=arduino Chapter02/traffic-lights-pedestrian/main.go
由于 ATmega328p 资源非常有限,因此默认情况下在使用此微控制器的板上禁用调度器。Arduino UNO 就是这样一块板。当使用其他微控制器时,我们通常不需要通过设置此参数来覆盖默认调度器。
我们现在已经成功地将程序烧录到了 Arduino Uno。交通灯应该开始循环所有相位,行人灯应保持在红灯相位。当点击按钮时,交通灯应该结束循环,然后行人灯应切换到绿灯相位,而交通灯保持在红灯相位 3 秒钟。
注意
由于 Arduino Uno 上的内存非常有限,使用 goroutine 可能只在不太复杂的项目中有效,例如这个项目。
摘要
我们学习了如何构建一个完全功能的交通灯系统,行人灯由按钮控制。我们通过分别构建项目的每个部分并在最后组装它们来实现这一点。
我们学习了如何使用面包板,电阻上的颜色代码如何工作,为什么在控制 LED 时使用电阻,以及外部 LED 的组装方法。我们还学习了如何使用按钮,如何使用上拉电阻防止信号浮空,以及如何在 TinyGo 中利用 goroutine。
在下一章中,我们将学习如何从 4x4 键盘读取输入以及如何控制伺服电机。我们将利用这些知识来构建一个当输入正确密码时开启的安全锁。
问题
-
为什么我们在 LED 阳极和 GPIO 端口之间放置一个电阻?
-
我们如何阻止信号浮空?
-
为什么在检查按钮状态后我们会进入睡眠状态?
-
你会如何修改代码以实现以下行为?
a. 当按钮被按下时,关闭交通灯的红灯和绿灯,并让黄灯闪烁。
b. 当按钮再次被按下时:返回正常相位旋转。
进一步阅读
-
电阻色环转换计算器:
www.digikey.com/en/resources/conversion-calculators/conversion-calculator-resistor-color-code -
TinyGo 中的 Goroutines:
aykevl.nl/2019/02/tinygo-goroutines
第三章:第三章:使用键盘构建安全锁
在上一章中,我们学习了如何使用 LED、GPIO 端口和电阻,以及如何处理输入和输出。在本章中,我们将使用键盘构建一个安全锁。我们可以在键盘上输入一个密码,触发伺服电机解锁锁。这将通过将项目分解成单独的步骤并在本章末尾将其全部组合来实现。
在完成本章内容后,我们将了解如何将信息写入串行端口以及如何监控这些信息。这是一种轻松调试应用程序的好方法。然后,我们将编写自己的 4x4 键盘驱动程序,在我们的情况下,它可以作为密码输入使用。这个 4x4 键盘也可以用作控制器输入,或者作为启动程序不同部分的输入。在完成这些后,我们将编写控制伺服电机的逻辑。伺服电机可以用作锁机制,也常用于遥控飞机。最后,我们将有一个项目,可以设置密码,输入密码,如果输入正确,则触发伺服电机。
在本章中,我们将涵盖以下主要主题:
-
向串行端口写入
-
监控串行端口
-
监控键盘输入
-
编写驱动程序
-
寻找 TinyGo 的驱动程序
-
控制伺服电机
-
使用键盘构建安全锁
技术要求
为了在微控制器上调试程序,我们需要以下组件:
-
一块 Arduino Uno
-
一个 4x4 膜式键盘
-
一个 SG90 伺服电机
-
一个红色 LED
-
一个绿色 LED
-
14 根跳线
-
两个 220 欧姆电阻
-
一块面包板
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter03
本章的“代码在行动”视频可以在以下链接找到:bit.ly/3uN9OAf
向串行端口写入
在微控制器上调试程序的一个简单方法是向串行端口写入消息。您可以使用此技术来调试程序,例如打印当前步骤或传感器值。
让我们编写一个小程序来看看如何向串行端口写入。我们首先在项目目录中创建一个名为Chapter03的新文件夹,然后在这个新目录内创建另一个名为writing-to-serial的新目录。现在我们需要创建一个新的main.go文件并插入一个空的main()函数。文件夹结构现在应该如下所示:

图 3.1 – 向串行端口写入的文件夹结构
现在,请按照以下步骤操作:
-
我们打印单词
starting后跟一个空格,然后打印单词program后跟一个\n:print("starting ") print("program\n") -
我们无限循环,打印
Hello World并暂停一秒钟:for { println("Hello World") time.Sleep(1 * time.Second) } -
现在,使用以下命令将程序烧录到您的微控制器中:
print just writes the text to the serial port and does not insert a character for a newline.`println` adds a character for a newline.
好的,我们现在在控制器上有一个程序,它会将文本打印到串行端口。我们已经了解了一种非常方便的方法,可以将调试日志插入到我们的程序中。在下一节中,我们将学习如何从计算机上的串行端口读取数据。
监控串行端口
当我们向串行端口写入调试日志或其他消息时,我们需要一种方便的方式来监控这些日志。在所有操作系统上监控串行端口的一个简单方法是使用 PuTTY。
让我们先看看如何在各种平台上安装 PuTTy:
-
apt。我们可以使用以下命令来安装它:tar.gz here: https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html -
brew。我们可以使用以下命令来安装它:brew install Putty -
.msi文件。
由于我们现在已经安装了 PuTTY,现在是时候监控我们的串行端口了:
-
确保上一节中的程序已经烧录到您的微控制器中,并且 USB 线已经插入。
-
下一步是启动 PuTTY。一旦 PuTTY 启动,点击会话并选择串行作为连接类型。这应该看起来像以下截图:
![图 3.2 – PuTTy 配置]()
图 3.2 – PuTTy 配置
-
现在我们必须选择串行线路。在 Windows 上,这通常是
/dev/ttyACM0或/dev/ttyUSB0。 -
由于我们现在已经成功配置了会话,我们可以保存此配置。为此,将
Microcontroller作为名称并点击保存。这应该看起来像以下截图:![图 3.3 – PuTTy 保存配置]()
图 3.3 – PuTTy 保存配置
-
由于我们现在已经保存了配置,我们可以在每次想要监控串行端口时重用它。现在从列表中选择微控制器并点击打开按钮:
![图 3.4 – PuTTy 微控制器会话已选择]()
图 3.4 – PuTTy 微控制器会话已选择
-
点击打开按钮后,将打开一个新窗口,显示我们程序的输出。它应该看起来类似于以下截图:

图 3.5 – PuTTy 程序输出
现在我们已经学会了如何监控我们程序的输出。接下来,我们将学习如何使用 4x4 键盘并监控按键。
监控键盘输入
在本节中,我们将从 4x4 键盘读取输入并将按下的按钮打印到串行端口。由于 TinyGo 没有为此键盘提供驱动程序,我们将看看如何创建一个驱动程序。这将帮助您理解这个过程,并且当您需要使用其他不受支持的硬件时,您可以使用这些知识。
作为这个练习的一部分,我还遵循了将其添加到 TinyGo 代码库的过程,并且它应该在未来得到支持。我们将从学习如何连接键盘开始。然后我们将继续编写驱动程序,然后我们将简要地看看如何将新驱动程序添加到 TinyGo。
构建电路
我们首先组装电路。我们需要一个 4x4 键盘和八根跳线。虽然我们可以使用跳线直接将键盘连接到 Arduino 端口,但我们将通过面包板进行连接。我们将在接下来的章节中添加更多组件。按照以下步骤正确连接键盘:
-
将引脚 D3 连接到 A32。
-
将引脚 D4 连接到 A31。
-
将引脚 D5 连接到 A30。
-
将引脚 D6 连接到 A29。
-
将引脚 D7 连接到 A28。
-
将引脚 D8 连接到 A27。
-
将引脚 D9 连接到 A26。
-
将引脚 D10 连接到 A25。
-
将 E32 与键盘上的 0 号引脚连接。
-
将 E31 与键盘上的 1 号引脚连接。
-
将 E30 与键盘上的 2 号引脚连接。
-
将 E29 与键盘上的 3 号引脚连接。
-
将 E28 与键盘上的 4 号引脚连接。
-
将 E27 与键盘上的 5 号引脚连接。
-
将 E26 与键盘上的 6 号引脚连接。
-
将 E25 与键盘上的 7 号 P 引脚连接。
完成这些后,您的电路应类似于以下截图:

图 3.6 – 键盘电路 – 图片来自 Fritzing
我们现在已经正确地连接了键盘。在我们继续编写代码之前,我们需要了解 4x4 键盘是如何工作的。
理解 4x4 键盘的工作原理
观察键盘,我们发现它基本上由 四行 组成,每行有 四列。键盘共有八个引脚。前四个引脚用于行,剩下的四个用于列。为了确定哪个键被按下,我们只需找到按下键在 4x4 坐标系统中的位置。
例如,按钮 1 的坐标是 0,0(行 0,列 0),而按钮 D 的坐标是 3,3(行 3,列 3)。
在键盘的内部电路中,行与列相连。当按钮被按下时,电路闭合。当电路闭合时,电流流动,这就是我们可以在引脚上读取的信号。由于键盘没有直接连接到 GND 和 VCC,我们需要为键盘提供电源。这就是为什么将使用四个引脚作为输入,四个作为输出引脚。
我已经拆解了这样一个 4x4 键盘,以提供内部电路的视觉:

图 3.7 – 键盘内部电路
如我们所知,我们基本上只需要检查这个 4x4 坐标系统中的每个坐标是否处于正确状态,然后我们可以继续编写代码。
编写驱动程序
由于我们希望有可重用的代码,我们将为键盘编写一个驱动程序包。驱动程序将提供一个易于使用的接口,同时隐藏更复杂的实现逻辑。这样做,我们可以在以后的工程中简单地重用这个包,甚至超出本书的范围。官方 TinyGo 驱动程序通常提供一个类似于构造函数的函数来创建驱动程序的新实例,以及一个 Configure 函数来处理初始化。我们也将提供类似的 API。
就像我们之前的工程一样,我们将在 Chapter03 文件夹内创建一个名为 controlling-keypad 的新文件夹。然后,我们将在 main.go 文件中创建一个空的 main 函数。此外,我们还需要创建一个名为 keypad 的新文件夹,并创建一个名为 driver.go 的新文件,然后命名包为 keypad。现在,您的项目结构应该如下所示:


图 3.8 – 编写驱动程序的工程结构
我们将逻辑分为以下五个部分:
-
Driver变量 -
Configure -
GetIndices -
GetKey -
main
让我们了解每个部分。
驱动变量
在我们的 Driver 结构体内部,我们需要一些变量。按照以下步骤设置它:
-
定义一个新的名为
Driver的结构体:type Driver struct { -
我们需要一个
inputEnabled变量来去抖动按键:inputEnabled bool -
lastColumn和lastRow用于保存上次按键的位置:lastColumn int lastRow int -
我们需要一个
machine.Pin数组来存储列引脚:columns [4]machine.Pin -
我们需要一个
machine.Pin数组来存储行引脚:rows [4]machine.Pin -
我们使用映射将键值映射到索引(位置):
mapping [4][4]string }
现在我们将初始化引脚和 Driver 变量。
Configure
首先创建一个名为 Configure 的空函数,该函数接受八个 machine.Pin 函数作为参数,并且是 Driver 的指针接收器。这应该看起来像以下代码片段:
func (keypad *Driver)Configure(r4, r3, r2, r1, c4, c3, c2 ,c1 machine.Pin) {}
下一步是将初始化逻辑放入此函数中。为此,请按照以下步骤操作:
-
使用 PinInputPullup 配置初始化列引脚。内部上拉电阻将使列保持在 5 V,直到按键被按下,然后我们可以将其作为输入读取:
inputConfig := machine.PinConfig{Mode: machine. PinInputPullup} outputConfig := machine.PinConfig{Mode: machine. PinOutput} c4.Configure(inputConfig) c3.Configure(inputConfig) c2.Configure(inputConfig) c1.Configure(inputConfig) -
将列引脚添加到
columns数组中。这样做之后,我们就可以通过循环遍历所有列:keypad.columns = [4]machine.Pin{c4, c3, c2, c1} -
使用
PinOutput配置初始化行引脚:outputConfig := machine.PinConfig{Mode: machine. PinOutput} r4.Configure(outputConfig) r3.Configure(outputConfig) r2.Configure(outputConfig) r1.Configure(outputConfig) -
将所有行引脚添加到行数组中。这样我们就可以通过循环遍历所有行:
keypad.rows = [4]machine.Pin{r4, r3, r2, r1} -
使用键值初始化映射。我们将映射按下的列和行索引以获取正确的键值:
keypad.mapping = [4][4]string{ {"1", "2", "3", "A"}, {"4", "5", "6", "B"}, {"7", "8", "9", "C"}, {"*", "0", "#", "D"}, } -
初始化
inputEnabled、lastColumn和lastRow:keypad.inputEnabled = true keypad.lastColumn = -1 keypad.lastRow = -1
这是我们初始化程序与键盘通信所需的一切。
GetIndices
现在我们只需要遍历数组和列,找到按下的键。我们首先创建一个名为 GetIndices 的新函数,该函数返回两个整数,并且是一个指向 Driver 的指针接收器。这应该看起来像以下代码片段:
func (keypad *Driver) GetIndices() (int, int){}
现在,按照以下步骤实现函数逻辑:
-
遍历所有行:
for rowIndex := range keypad.rows { -
将当前的
rowPin设置为Low。我们需要这样做,因为我们正在使用内部的rowPin设置为High:rowPin := keypad.rows[rowIndex] rowPin.Low() -
遍历所有列:
for columnIndex := range keypad.columns { -
获取当前的
columnPin:columnPin := keypad.columns[columnIndex] -
检查当前的
columnPin是否被按下,并在接受输入时执行逻辑。禁用接受输入并保存当前列和行,然后返回索引:if !columnPin.Get() && keypad.inputEnabled { keypad.inputEnabled = false keypad.lastColumn = columnIndex keypad.lastRow = rowIndex return keypad.lastRow, keypad.lastColumn } -
如果之前的键不再被按下,请再次接受输入:
if columnPin.Get() && columnIndex == keypad.lastColumn && rowIndex == keypad.lastRow && !keypad.inputEnabled { keypad.inputEnabled = true }} -
将
rowPin再次设置为High并关闭外部循环:rowPin.High() } -
如果没有按键被按下,则返回
–1, -1并关闭函数:return -1, -1 }
调用此函数将告诉我们按下的键在坐标系中的位置。如果您想更详细地了解上拉和下拉电阻,请查看以下链接:www.electronics-tutorials.ws/logic/pull-up-resistor.html。
GetKey
接下来,我们将创建一个函数来检查按下的键的索引并将索引映射到键值。为此,我们从一个名为 GetKey 的空函数开始,该函数返回一个字符串,并且是一个指向 Driver 的指针接收器。这应该看起来像以下代码片段:
func (keypad *Driver) GetKey() string {}
在此函数内部,我们只是调用 GetIndices 方法,检查是否按下了按钮,如果按下了按钮,我们就以字符串的形式返回键值。这看起来像以下代码:
row, column := keypad.GetIndices()
if row == -1 && column == -1 {
return ""
}
return keypad.mapping[row][column]
现在,只缺少 main 逻辑。让我们看看下一个!
main
我们调用初始化逻辑并无限循环以检查按下的键。以下步骤展示了如何操作:
-
初始化
keypadDevice:keypadDevice := keypad.Driver{} keypadDevice.Configure(machine.D3, machine.D4, machine. D5, machine.D6, machine.D7, machine.D8, machine.D9, machine.D10) -
现在,无限循环,检查按键,如果按下了键就打印值:
for { key := keypadDevice.GetKey() if key != "" { println("Button: ", key) } }
太好了!这就完成了。现在我们可以烧录程序并监控输出。使用以下命令烧录程序:
tinygo flash –target=arduino Chapter03/controlling-keypad/main.go
现在,打开 PuTTy 并在按键盘上的键时监控串行输出。输出应该类似于以下截图:

图 3.9 – PuTTy 中的按键输出
太好了,我们已经成功编写了自己的驱动程序来监控键盘上的按钮按下!
在下一节中,我们将学习在哪里找到用于外围硬件的 TinyGo 驱动程序。我们还将查看向 TinyGo 驱动程序存储库贡献的过程。
寻找 TinyGo 的驱动程序
截至写作时,TinyGo 支持 53 种设备。我们刚刚编写的,我将要贡献给 TinyGo 的驱动程序将支持 54 种设备。但我们如何找到我们想要使用的设备的驱动程序呢?答案是简单的:有一个用于此目的的仓库。您可以在github.com/tinygo-org/drivers找到它。
在下一章中,我们将学习如何在使用不同类型的显示器时使用此类驱动程序。
为 TinyGo 贡献驱动程序
TinyGo 社区非常欢迎所有贡献。如果您为设备开发了一个驱动程序并希望将其贡献给 TinyGo,您可以遵循以下简单步骤:
-
提出一个问题并解释您想添加的内容以及您计划如何实现它。
-
分叉仓库。
-
基于 dev 分支创建一个新的分支。
-
提交一个 pull request。
您可以在以下链接中找到贡献指南:github.com/tinygo-org/drivers/blob/release/CONTRIBUTING.md。
总的来说,我对 TinyGo 社区的个人经历极为积极。他们非常礼貌,并且会帮助您解决任何问题。我没有遇到过社区无法给我提供有用答案的问题。不要害怕在问题中或在 Gophers slack 的 TinyGo 频道中提问。
注意
请不要在任何官方 TinyGo 渠道(如 Slack 或 GitHub)中提出与此书直接相关的问题。如果您对此书有任何问题,可以在配套的 GitHub 仓库中提出问题或给我发送电子邮件。
如我们所知,如何使用键盘和在哪里找到驱动程序,我们可以继续我们的安全锁的下一部分。
控制伺服电机
由于我们现在能够读取键盘的输入,要构建一个安全锁所缺少的是某种类型的电机。为此,我们将使用 SG90 伺服电机。截至写作时,Arduino Uno 上的定时不够准确,无法完全控制 SG90 伺服电机,但这对我们用例来说不是问题。我们只是将伺服器向一个方向移动,即顺时针方向。此外,目前还没有 SG90 伺服电机的官方驱动程序,因此我们将编写自己的驱动程序!
理解 SG90 伺服电机
SG90 伺服电机由脉冲宽度调制(PWM)控制。基本上,SG90 在一个 50 赫兹的周期内读取输入。在这个周期内,我们可以通过设置一定时间的信号来告诉伺服电机调整到一定的角度。这个信号的长度被称为占空比。在占空比之后,我们等待剩余的周期。根据占空比(脉冲宽度),SG90 将调整其角度。
SG90 可以调整到以下三个位置:
-
使用 1.5 毫秒脉冲旋转 0 度(中心)。
-
使用 2 毫秒脉冲旋转+90 度(右侧)。
-
使用 1 毫秒脉冲旋转-90 度(左侧)。
通过对脉冲宽度大小进行一些数学运算,也可以将伺服电机调整到这个范围内的所有角度,但我们的示例中不需要这样做。
SG90 通常有三根线:
-
黑色/棕色用于地线
-
红色用于 VCC
-
橙色/黄色用于 PWM 信号
构建电路
我们将在上一个示例的基础上进行构建。我们只需按照以下步骤添加伺服电机:
-
将 Arduino Uno 的 5V 端口连接到电源总线上的正线。
-
将 Arduino Uno 的 GND 端口连接到电源总线上的地线。
-
将 SG90 的 GND 线连接到电源总线上的地线。
-
将 SG90 的 VCC 线连接到电源总线上的正线。
-
将 SG90 的 PWM 线连接到 Arduino Uno 的D11引脚。
我们现在的电路应该看起来像下面的截图:


图 3.10 – 键盘和伺服电机
很好。在我们开始编程之前,我们应该了解一些关于 Arduino Uno 上的 PWM 引脚的知识。只有 GPIO 端口中的六个引脚能够进行 PWM。这些引脚被标记为~符号。
注意
在 Arduino Uno 上,你可以使用D3、D5、D6、D9、D10和D11引脚进行 PWM。
编写伺服控制逻辑
我们需要在Chapter03文件夹内创建一个名为controlling-servo的新文件夹。接下来,在新的文件夹内创建一个名为main.go的新文件,并插入一个空的main函数。此外,我们还需要在servo包内创建一个名为servo的新文件夹,并包含一个名为driver.go的新文件。我们的项目结构现在应该看起来如下:


图 3.11 – 伺服控制逻辑的项目结构
注意
PWM 目前正在重做。在未来的处理中,PWM 设备将更加简单。它现在也由硬件 PWM 而不是模拟 PWM 行为来处理。你可以在以下 pull request 中查看进度:github.com/tinygo-org/tinygo/pull/1121。
我们现在正在构建的驱动器的主要目的是教我们 PWM 实际上是如何工作的,并且将在所有不是基于 8 位 AVR 架构的微控制器上运行得更好,例如 Arduino Uno 上的 ATmega328P。这是因为尽管 TinyGo 的 AVR 支持正在随着每个版本的发布而不断改进,但它仍然是实验性的。一旦之前提到的 PR 被合并,我建议使用基于该硬件 PWM 支持的驱动器来控制伺服电机。
也请注意,截至写作之时,当伺服电机到达最右侧位置时,你需要手动将其重置。
在driver.go文件中,我们需要按照以下步骤来让我们的伺服电机旋转一点:
-
声明包级别的常量用于占空比和
rightRemainingPeriod:const centerDutyCycle = 1500 * time.Microsecond const centerRemainingPeriod = 18500 * time.Microsecond const leftDutyCycle = 2000 * time.Microsecond const leftRemainingPeriod = 18000 * time.Microsecond const rightDutyCycle = 1000 * time.Microsecond const rightRemainingPeriod = 19000 * time.Microsecond -
创建一个名为
Driver的新结构体,它有一个machine.Pin成员:type Driver struct { pin machine.Pin } -
定义一个名为
Configure的新空函数,它接受machine.Pin作为参数,并且是Driver的指针接收器:func (servo *Driver) Configure(pin machine.Pin) {} -
将引脚配置为输出:
servo.pin = pin servo.pin.Configure(machine.PinConfig{Mode: machine. PinOutput}) -
循环四次,仅将电机旋转约 30 度:
for position := 0; position <= 4; position++ { -
设置占空比的信号,将其拉低,然后睡眠剩余的周期:
servo.pwm.Pin.High() time.Sleep(rightDutyCycle) servo.pwm.Pin.Low() time.Sleep(rightRemainingPeriod) }
在我们能够尝试我们的库之前,我们需要编写一个小型的示例程序。为此,将以下片段放入控制 servo 文件夹内的 main.go 文件中:
func main() {
servo := servo.Driver{}
servo.Configure(machine.D11)
servo.Right()
}
现在我们只需要通过以下命令闪烁程序来尝试该程序:
tinygo flash –target=arduino Chapter03/controlling-servo/main.go
恭喜,这是我们第一次使用代码移动某个东西。既然我们已经学会了如何稍微旋转伺服电机以及如何从键盘读取输入,下一步就是将所有这些整合在一起。
一旦 PWM 的重构合并到上游并发布在 TinyGo 版本中,你就不想再使用之前的驱动程序了。为此,我们创建了一个新的驱动程序,它使用硬件 PWM 而不是模拟行为。所以继续创建一个名为 servo-pwm 的新文件夹,并在其中创建一个新的 driver.go 文件。然后按照以下步骤实现更好的驱动程序:
-
我们定义了周期,为 20.000 微秒,并创建了一个新的
Device结构体,如下所示:const period = 20e6 type Device struct { pwm machine.PWM pin machine.Pin channel uint8 } -
下一步是添加一个构造函数,如下所示:
func NewDevice(timer machine.PWM, pin machine.Pin) *Device { return &Device{ pwm: timer, pin: pin, } } -
现在我们配置 PWM 接口。我们需要设置周期并获取输出引脚的通道:
func (d *Device) Configure() error { err := d.pwm.Configure(machine.PWMConfig{ Period: period, }) if err != nil { return err } d.channel, err = d.pwm.Channel(machine.Pin(d.pin)) if err != nil { return err} return nil } -
现在我们添加一些函数,让我们能够设置伺服电机的位置。我们以参数的形式传入占空比的微秒数,如下所示:
func (d *Device) Right() { d.setDutyCycle(1000) } func (d *Device) Center() { d.setDutyCycle(1500) } func (d *Device) Left() { d.setDutyCycle(2000) } -
作为最后一步,我们控制通道的占空比:
func (d *Device) setDutyCycle(cycle uint64) { value := uint64(d.pwm.Top()) * cycle / (period / 1000) d.pwm.Set(d.channel, uint32(value)) }
让我们尝试一下 Set 函数的工作原理。为此,我们查看文档,因为该函数在那里解释得非常清楚:


图 3.12 – pwm.Set()文档
现在,让我们也创建一个使用新驱动程序的替代示例程序。为此,在 Chapter03 文件夹内创建一个名为 controlling-servo-pwm 的新文件夹,并将以下代码放入 main 函数中:
servo := servopwm.NewDevice(machine.Timer1, machine.D9)
err := servo.Configure()
if err != nil {
for {
println("could not configure servo:", err.Error())
time.Sleep(time.Second)
}
}
for {
servo.Left()
time.Sleep(time.Second)
servo.Center()
time.Sleep(time.Second)
servo.Right()
time.Sleep(time.Second)
}
在前面的例子中,我们使用了 machine.Timer1,因为 Timer1 是一个 16 位定时器,可以与 machine.D9 引脚一起使用。Timer0 和 Timer2 是用于其他 PWM 引脚的 8 位定时器。
优秀!我还添加了使用基于硬件 PWM 的驱动器而不是我们在前面代码中使用的软件模拟驱动器的替代实现,用于本章所有后续项目。您可以在 GitHub 仓库的Chapter03文件夹中找到它们。我强烈建议使用这个伺服电机驱动器的实现,而不是我们最初创建的那个,因为这个实现与我们在第一个项目中编写的软件模拟 PWM 驱动器相比,在 Arduino UNO 上工作得更好。实现 PWM 接口的软件模拟仍然是理解 PWM 内部工作原理的好方法。我还为本章的最终项目实现了一个替代程序,该程序使用硬件 PWM 伺服电机驱动器。如果您无法构建使用新驱动器的项目,那么 PWM 重构尚未进入 TinyGo 发布分支。但我非常确信,这个特性将在今年(2021 年)发布。
使用键盘构建安全锁
我们现在知道如何从键盘读取输入以及如何控制伺服电机。我们将使用这些知识来构建一个安全锁,当通过键盘输入正确的密码时,该锁会打开。因为我们编写了控制伺服电机和从键盘读取数据的库,所以我们只需要编写检查密码并点亮 LED 的逻辑。我们将让红色 LED 在每次按键时闪烁。当我们输入错误的密码时,我们将红色 LED 点亮 3 秒。当我们输入正确的密码时,我们将绿色 LED 点亮 3 秒并触发伺服电机。
构建电路
我们将重用本章前几节中构建的电路。因为我们已经有了连接好的伺服电机和键盘,我们只需要添加 LED 和电阻。
要构建最终电路,请按照以下步骤操作:
-
将 Arduino Uno 的 GND 端口与电源总线上的 GND 通道连接。
-
将红色 LED 的阴极放置在G7,阳极放置在G8。
-
将绿色 LED 的阴极放置在G11,阳极放置在G12。
-
使用跳线将F7与电源总线上的地连接。
-
使用跳线将F11与电源总线上的地连接。
-
使用 220 欧姆电阻将D8与F8连接。
-
使用 220 欧姆电阻将D12与F12连接。
-
使用跳线将 Arduino Uno 的D12引脚与A12连接。
-
使用跳线将 Arduino Uno 的D13引脚与A8连接。
现在我们的电路应该看起来像以下屏幕截图中的电路:

图 3.13 – 键盘、伺服电机和 LED 电路
编写逻辑
由于我们已经成功连接了电路,我们现在可以开始编写程序的逻辑。我们首先在Chapter03文件夹内创建一个名为safety-lock-keypad的新文件夹,并在新文件夹内创建一个包含空main函数的新main.go文件。我们的项目结构现在应该如下所示:

图 3.14 – 安全锁程序的项目结构
由于我们可以重用我们的库,我们只需关注实际的密码逻辑。要实现逻辑,请使用以下步骤:
-
导入
keypad和servo驱动程序。然后,您需要调整路径以匹配您的Gopath中的包路径:"https://github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter03/keypad" "https://github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter03/servo" -
在
main函数内部,我们首先初始化keypadDriver:keypadDriver := keypad.Driver{} keypadDriver.Configure(machine.D2, machine.D3, machine. D4, machine.D5, machine.D6, machine.D7, machine.D8, machine.D9) -
现在,我们初始化
servoDriver:servoDriver := servo.Driver{} servoDriver.Configure(machine.D11) -
初始化一个新的
outPutConfig:outPutConfig := machine.PinConfig{Mode: machine. PinOutput} -
初始化两个 LED:
led1 := machine.D12 led1.Configure(outPutConfig) led2 := machine.D13 led2.Configure(outPutConfig) -
使用值
133742初始化密码:const passcode = "133742" -
使用空字符串作为值初始化一个名为
enteredPasscode的新变量:enteredPasscode := "" -
读取键盘输入:
for { key := keypadDriver.GetKey() -
检查是否按下了键,并将按下的键打印到串行端口,同时将按下的键追加到
enteredPasscode变量中:if key != "" { println("Button: ", key) enteredPasscode += key -
点亮红色 LED 以提供视觉反馈并关闭
if语句:led2.High() time.Sleep (time.Second / 5) led2.Low() } -
检查
enteredPasscode的长度是否与passcode相同:if len(enteredPasscode) == len(passcode) { -
如果
enteredPasscode与passcode值匹配,将Success打印到串行端口,重置enteredPasscode并触发伺服电机:if enteredPasscode == passcode { println("Success") enteredPasscode = "" servoDriver.Right() -
点亮绿色 LED 以提供成功的视觉反馈,并使用
else处理错误的密码情况:led1.High() time.Sleep(time.Second * 3) led1.Low() } else { -
将
Fail和输入的密码打印到串行端口,这有助于我们在调试程序时,并重置enteredPasscode:println("Fail") println("Entered Password: ", enteredPasscode) enteredPasscode = "" -
点亮红色 LED 以提供失败的视觉反馈,并关闭
else和if情况:led2.High() time.Sleep(time.Duration(time.Second * 3)) led2.Low() } } -
睡眠
50毫秒并关闭for循环。这有助于消除按键抖动:time.Sleep(50 * time.Millisecond) }
太好了,我们现在已经在本章中编写了最终项目的完整逻辑。现在使用以下命令烧录程序:
tinygo flash –target=arduino Chapter03/safety-lock-keypad/main.go
由于我们现在已经成功烧录了程序,打开 PuTTy 并打开通过加载您的保存配置文件的微控制器串行会话。现在输入一个随机密码以让程序失败。红色 LED 应该亮起 3 秒,PuTTy 中的输出应该如下截图所示:

图 3.15 – 错误输入
现在,让我们尝试正确的密码,所以输入133742作为密码。输出现在应该类似于以下截图:

图 3.16 – 正确输入
优秀,我们已经成功构建了一个接受密码并在输入正确密码时触发伺服电机的电路。
你可以找到一个使用新重构的 PWM 的替代实现:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/blob/master/Chapter03/safety-lock-keypad-pwm/main.go
摘要
在本章中,我们学习了如何向串行端口发送消息以及如何配置 PuTTy 来监控串行端口上的消息。然后,我们利用这些知识通过我们编写的驱动程序在键盘上输出按键操作。在这个过程中,我们学习了如何为目前没有官方驱动程序的设备编写驱动程序,并且了解了 TinyGo 的驱动程序仓库的贡献过程。
然后,我们学习了如何控制伺服电机,并编写了一个库来实现这一功能。作为最后一步,我们将本章学到的所有知识结合起来,构建了一个接受密码来打开锁的安全锁。如果你想要构建一个门锁或飞行控制系统,其中需要控制伺服电机,这些知识将非常有用。键盘也可以用作游戏手柄,其中你使用按键作为输入。作为额外奖励,我们还编写了两个可以在完成本书后重用于所有未来项目的驱动程序。
在下一章中,我们将学习如何使用 ADC 引脚读取传感器值,如何在值中找到阈值,如何控制泵,以及如何使用继电器和蜂鸣器。
问题
-
在了解了我们用于键盘的坐标系之后,3 号键的坐标是什么?
-
在我们的最终项目中,我们检查了当达到正确的密码长度时输入是否正确。你将如何修改代码,使其在按下按键编号时检查密码是否正确?
第四章:第四章:构建植物浇水系统
在前面的章节中,我们学习了如何向串行端口写入数据以及如何在我们的计算机上监控串行端口。此外,我们还学习了如何编写尚未由 TinyGo 社区实现的组件的驱动程序,并使用这些知识编写了 4x4 键盘和伺服电机的驱动程序,在第三章,使用键盘构建安全锁。
我们现在将在这个章节的基础上,通过引入一种新的引脚类型,并使用一些新设备构建一个自动植物浇水系统。我们将能够将水从容器中泵入植物的土壤中,测量土壤的湿度,检查容器的水位,并在容器中的水位低于某个阈值时让蜂鸣器发出声音。这将通过将项目分解成单个步骤并在章节末尾将其全部组合起来来实现。
在完成本章内容后,我们将了解如何从模拟引脚读取输入,如何测量传感器数据中的阈值,如何让蜂鸣器发出声音,以及如何使用继电器控制水泵。
在本章中,我们将介绍以下主要主题:
-
读取土壤湿度传感器数据
-
读取水位传感器数据
-
控制蜂鸣器
-
控制水泵
-
浇灌您的植物
技术要求
我们将需要以下组件来完成这个项目:
-
一个 Arduino UNO
-
电容式土壤湿度传感器 v1.2
-
K-0135 水位传感器
-
带有 2 个引脚的无源蜂鸣器
-
直流 3V-5V 微型潜水泵
-
面包板电源模块
-
跳线
-
一个面包板
-
一个 100 欧姆电阻
这些组件通常可以在在线商店和当地电子产品商店找到。本书中使用的多数组件也是所谓的Arduino 入门套件的一部分。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter04
本章的“代码在行动”视频可以在这里找到:bit.ly/3tlhRnx
读取土壤湿度传感器数据
在自动浇水植物时,我们需要知道何时需要向土壤中加水。检测土壤过于干燥的一个简单方法就是使用土壤湿度传感器。在本项目中,我们将使用电容式土壤湿度传感器,它提供模拟信号作为读数。
该传感器的以下技术规格如下:
-
3.3 V 至 5.0 V 的供电范围
-
3.3 V 的工作范围
-
1.5 V 至 3.3 V 范围内的模拟输出
-
工作电流为 5 mA
来自其他制造商的传感器在这些规格上可能略有不同。数据表通常由您购买硬件的供应商提供。我们现在将开始组装电路。
组装电路
我们最初只需要一些电缆、传感器本身和一个面包板。根据传感器的制造商不同,传感器端口上的标签可能不同。我使用的是以下标签:
-
AOUT(代表模拟输出)
-
VCC (+)(代表电压公共收集器)
-
GND (-)(代表地)
现在按照以下列表组装电路:
-
使用跳线将 GND 端口连接到电源总线上的 GND。
-
使用跳线将端口 D2 连接到面包板上的 A1。
-
使用跳线将端口 A5 连接到面包板上的 A2。
-
将面包板上的 E1 连接到传感器上的 AOUT。
-
使用跳线将 E2 连接到传感器上的 VCC。
-
将电源总线上的 GND 连接到传感器上的 GND。
你的电路现在应该看起来类似于以下图示:

图 4.1 – 土壤传感器电路 – 图片来自 Fritzing
图 4.1 – 土壤传感器电路图片来自 Fritzing
太好了!我们已经成功组装了电路。我们将使用这个电路来创建一个小型示例项目,以读取传感器的值。
寻找阈值
我们接下来的任务是找出表示以下状态的值:
-
干燥
-
在水中
为了检查干燥度,我们需要为这个项目创建一个新的文件夹。
首先,创建一个名为 Chapter04 的新文件夹。在该文件夹内,创建一个名为 soil-moisture-sensor-thresholds 的新文件夹,并在该文件夹内创建一个 main.go 文件,并插入一个空的 main() 函数。文件夹结构现在应该如下所示:

图 4.2 – 土壤湿度传感器阈值文件夹结构
现在按照以下步骤操作:
-
按照以下方式导入
machine包:import "machine" -
初始化用于 ADC 的寄存器:
machine.InitADC() -
创建一个名为
soilSensor的新变量,类型为machine.ADC,使用Pin machine.ADC5:soilSensor := machine.ADC{Pin: machine.ADC5} -
配置引脚以便能够读取模拟值:
soilSensor.Configure() -
将机器 D2 引脚配置为输出并将其设置为
high。我们不会将其存储在新变量中,因为我们永远不会再次更改引脚的状态。我们只使用它来提供电流:machine.D2.Configure(machine.PinConfig{Mode: machine.PinOutput}) machine.D2.High() -
以每秒两次的速度读取传感器值,并在无限循环中将其打印到串行端口:
for { value := soilSensor.Get() println(value) time.Sleep(500 * time.Millisecond) }
太好了,我们已经成功编写了第一个从模拟引脚读取传感器数据的程序。现在我们需要确定阈值值。为此,首先使用以下命令将程序闪存到你的 Arduino 上:
tinygo flash –target=arduino Chapter04/soil-moisture-sensor-thresholds/main.go
现在打开 PuTTY 并选择微控制器配置文件以查看传感器读取值。这应该看起来像以下截图:

图 4.3 – PuTTY 中的土壤湿度传感器输出
该值相当稳定,为37888。你可能会注意到在两次读取之间值会有一些小的变化。在这种情况下,只需取你看到的最高值即可。
我们现在将 37888 声明为干燥值的阈值。因此,所有等于或高于此值的值都可以被认为是完全干燥的。从你的传感器接收到的值可能略有不同,所以你可以做同样的事情;查看这些值,并取最低的一个作为你的阈值。
注意
请确保你的传感器完全干燥且清洁。否则,你可能会因为干燥值而遇到麻烦。
太棒了!我们刚刚找到了完全干燥土壤的值。现在我们需要找到一个完全湿润(在水中)的值。
现在拿一杯水,把传感器放进去,同时观察 PuTTY 中的传感器读数。一定要非常小心,只把传感器放入水中,直到它触及传感器上的白色线!不要让水接触到上面的电子元件!查看以下图示:

图 4.4 – 水杯中的电容式土壤湿度传感器
让我们看看 PuTTY 中的传感器读数 – 你可以在以下图中找到它们:

图 4.5 – PuTTY 中水杯内部土壤传感器的读数
这次我们以 PuTTY 中能找到的最高值作为阈值,在我的情况下是 17856。我们已经在所有前面的章节中使用了 GPIO 引脚,但我们还没有使用 模拟数字转换器,所以在我们继续编写传感器库之前,让我们先了解 Arduino 上的 模拟数字转换器(ADC)是如何工作的。
理解 TinyGo 中的 ADC
Arduino UNO 的 ADC 有 10 位精度。Get() 函数返回的值是 uint16 类型。因此,在内部,Get() 函数告诉 ADC 将 10 位值扩展到 16 位。
通常,我们可以使用以下方程式来获取 ADC 结果:
Resolution of the ADC / System Voltage = ADC Value / Analog Voltage measured
由于我们知道 Arduino UNO 有 10 位精度,电压约为 5V,我们可以将这个值代入方程式中,得到以下结果:
1023 / 5V = ADC Value / Analog Voltage Measured
假设测量的模拟电压是 3.33V。这将导致以下结果:
1023 / 5V = ADC Value / 3.33V
现在我们进行一些数学方程式的魔法,得到以下结果:
1023 / 5V * 3.33V = ADC Value
That resolves to: ADC Value = 681
这个结果现在将扩展到 16 位,相当于向左移动 6 位。
从 TinyGo 获得的结果将如下所示:
ADC Value = 43584
既然我们已经发现了 ADC 的工作原理,我们现在可以继续编写一个小型库,这将帮助我们稍后使用传感器。
编写传感器的库
由于拥有可重用的代码是一件好事,我们现在将继续编写一个小型库,以便在本书的最后一部分,浇灌你的植物中重用它。
为了做到这一点,我们需要在 Chapter04 文件夹内创建一个名为 soil-moisture-sensor 的新文件夹。在我们的新文件夹中,我们创建一个新的空 driver.go 文件,并将包命名为 soil。现在的结构应该如下所示:

图 4.6 – 土壤湿度传感器库的文件夹结构
我们希望有一个接口,它提供了一个获取当前 MoistureLevel 的函数,实例将是一个类似于枚举的类型。此外,我们希望提供功能来打开和关闭传感器,这样它就不会一直消耗电流。
为了实现这一点,执行以下步骤:
-
定义一个新的名为
SoilSensor的接口,具有Get()、Configure()、On()和Off()函数:type SoilSensor interface { Get() MoistureLevel Configure() On() Off() } -
定义一个新的名为
soilSensor的结构体。这个结构体将包含用于打开和关闭传感器的引脚以及用于读取传感器值的引脚。此外,我们希望能够配置用于识别传感器是否完全干燥或处于水中的阈值:type soilSensor struct { -
添加保存
completelyDry和water阈值的成员:waterThreshold uint16 completelyDryThreshold uint16 category uint16 pin machine.Pin adc machine.ADC voltage machine.Pin } -
定义一个类似于枚举的类型,以便我们可以在使用库时轻松检查这些值。我们在这里选择了六个
MoistureLevel类别,以便在土壤的不同状态之间有清晰的区分:type MoistureLevel uint8 const ( CompletelyDry MoistureLevel = iota VeryDry Dry Wet VeryWet Water ) -
定义一个构造函数,它接受
waterThreshold、dryThreshold、dataPin和voltagePin并返回SoilSensor:func NewSoilSensor(waterThreshold, dryThreshold, dataPin, voltagePin machine.Pin) SoilSensor { -
使用阈值创建
category,稍后用于计算category值。因为我们希望有六个类别,所以我们把从传感器所在位置读取的值除以六:category := (dryThreshold - waterThreshold) / 6 -
设置所有值并返回一个指向新
soilSensor实例的指针:return &soilSensor{ waterThreshold: waterThreshold, completelyDryThreshold: dryThreshold, category: category, pin: dataPin, voltage: voltagePin, } } -
定义一个新的名为
Get的func,它是一个指向soilSensor的指针接收器,并返回MoistureLevel:func (sensor *soilSensor) Get() MoistureLevel { -
从
sensor读取值并将其保存到新的float32类型的变量value中:value := sensor.adc.Get() -
检查值是否大于或等于
completelyDryThreshold:switch { case value >= sensor.completelyDryThreshold: return CompletelyDry -
检查
value是否属于第二类别:case value >= sensor.completelyDryThreshold-sensor.category: return VeryDry -
检查
value是否属于第三类别:case value >= sensor.completelyDryThreshold-sensor.category*2: return Dry -
检查
value是否属于第四类别:case value <= sensor.completelyDryThreshold-sensor.category*3: return Wet -
检查
value是否属于第五类别:case value >= sensor.completelyDryThreshold-sensor.category*Ł4: return VeryWet -
剩下的唯一可能状态是
Water,所以我们在这里使用默认情况:default: return Water } } -
定义一个名为
Configure的函数,它有一个指向soilSensor的指针接收器。我们使用指针接收器,因为我们会在soilSensor实例上设置值,否则我们将在函数作用域之外丢失这些值:func (sensor *soilSensor) Configure() { -
为 ADC 使用配置
dataPin:sensor.adc = machine.ADC{Pin: sensor.pin} sensor.adc.Configure(machine.ADCConfig{} -
将
voltage引脚配置为输出并将其设置为Low:sensor.voltage.Configure(machine.PinConfig{Mode: machine.PinOutput}) sensor.voltage.Low() } -
添加一个函数来打开电压:
func (sensor *soilSensor) On() { sensor.voltage.High() } -
添加一个函数来关闭电压:
func (sensor *soilSensor) Off() { sensor.voltage.Low() }
这是我们需要为我们的库提供的完整逻辑。让我们在下一节测试我们的代码。
测试库
接下来,我们将编写一个示例来测试新的库。为此,我们需要在 Chapter04 文件夹内创建一个名为 soil-moisture-sensor-example 的新文件夹,并在其中创建一个包含空 main() 函数的 main.go 文件。现在,您的项目结构应该如下所示:

图 4.7 – 测试土壤湿度传感器库
要测试我们的新库,请按照以下步骤操作:
-
按照以下代码导入
machine、time和soil-moisture-sensor包。请注意,你的库路径将根据它在文件系统中的位置而略有不同:import ( "machine" "time" "github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/ Chapter04/soil-moisture-sensor" ) -
在
main函数内部,初始化 ADC 接口:machine.InitADC() -
创建一个
SoilSensor的新实例。在这个例子中,这些值与上一个例子中测量的值略有不同。这些值倾向于更早地触发Water和CompletelyDry状态:soilSensor := soil.NewSoilSensor(18000, 34800, machine. ADC5, machine.D2) -
现在我们调用
Configure函数,该函数初始化我们的引脚:soilSensor.Configure() -
开始一个无限循环,打开传感器,并稍等片刻,让读数稳定一下:
for { soilSensor.On() time.Sleep(75 * time.Millisecond) -
然后将
Get()函数的结果切换过来,并根据情况打印一个字符串:switch soilSensor.Get() { case soil.CompletelyDry: println("completely dry") case soil.VeryDry: println("very dry") case soil.Dry: println("dry") case soil.Wet: println("wet") case soil.VeryWet: println("very wet") case soil.Water: println("pure water") } -
再次关闭传感器,等待一秒钟,直到下一次读取开始:
soilSensor.Off() time.Sleep(time.Second) } }
现在我们已经有了测试库的完整代码,让我们将其烧录到我们的 Arduino 上,并在 PuTTY 中检查输出。使用以下命令进行烧录:
tinygo flash –target=arduino Chapter04/soil-moisture-sensor-example/main.go
现在最好的做法是将传感器放入非常干燥的土壤中,同时关注读数。然后加一些水,检查你是否会看到 Wet、Very Wet 和可能还有 Water 状态。在我们继续下一节之前,我们绝对应该检查它。如果读数看起来很奇怪,尝试调整阈值,这些阈值在库中的 NewSoilSensor 函数中处理。
注意
请记住,如果你往土壤中倒太多水,可能会伤害植物。此外,没有必要将传感器完全插入土壤直到达到白色线。我建议在白色线和土壤之间留一些空气,这样你就有一些缓冲区在电子元件和土壤之间。在我进行测试时,当有大约 1 厘米的空气作为缓冲时,我得到了良好的结果。
我们现在已经学会了如何校准电容式土壤湿度传感器,第一次使用 ADC 接口,并编写了一个新的库。使用这个库,我们能够判断土壤的湿度状态。在下一节中,我们将学习如何使用水位传感器。
读取水位传感器数据
由于我们在本章后面计划有一个水罐,所以有一个水位传感器将很有用,这样我们就可以知道水罐是否已空。我们将首先将传感器添加到现有的电路中。按照以下步骤进行操作:
-
使用跳线电缆将 Arduino 的 A4 引脚与面包板上的 F22 连接。
-
使用跳线电缆将 Arduino 的 D3 引脚与面包板上的 F21 连接。
-
将面包板上的 J22 连接到传感器的 S(信号)端口,使用跳线电缆。
-
将面包板上的 J21 连接到传感器的 +(VCC)端口,使用跳线电缆。
-
将传感器的 - GND 连接到电源总线上的 GND。
结果现在应该看起来像以下图所示:
![Figure 4.8 – 水位传感器 – 图片来自 Fritzing
![img/Figure_4.8_B16555.jpg]
Figure 4.8 – 水位传感器 – 图片来自 Fritzing
组装好这个之后,我们还可以继续为这个传感器创建一个小型库。
编写水位传感器库
水位传感器有很多不同的类型。那些经常作为 Arduino 入门套件一部分的廉价传感器往往容易生锈。为了防止这种情况,我们将添加开启和关闭的可能性。但在那之前,我们将先看看技术数据:
-
工作电压:5 V
-
工作电流:小于 20 mA
-
工作温度:10°至 30°
因此,拥有这个传感器时,电流消耗小于 20 mA。我们可以再次使用一个 GPIO 引脚来供电。
我们首先在 Chapter04 文件夹内创建一个名为 water-level-sensor 的新文件夹。在新文件夹内,创建一个名为 driver.go 的新文件,并将包命名为 waterlevel。文件夹结构现在应该如下所示:
![Figure 4.9 – 水位传感器库的文件夹结构
![img/Figure_4.9_B16555.jpg]
Figure 4.9 – 水位传感器库的文件夹结构
由于项目结构现在已经设置好,我们可以继续实现实际的库。只需按照以下步骤操作:
-
导入
machine包:import "machine" -
定义一个名为
WaterLevel的新接口,包含以下函数。函数将在我们实现它们时进行解释:type WaterLevel interface { IsEmpty() bool Configure() On() Off() } -
定义一个名为
waterLevel的结构体,包含以下成员:type waterLevel struct { dryThreshold uint16 pin machine.Pin adc machine.ADC voltage machine.Pin } -
定义一个新的类似于构造函数的函数,它接受
dryThreshold、dataPin和voltagePin:func NewWaterLevel(dryThreshold uint16, dataPin, voltagePin machine.Pin) WaterLevel { return &waterLevel{ dryThreshold: dryThreshold, pin: dataPin, voltage: voltagePin, } } -
添加
IsEmpty检查。我们将检查传感器读数是否低于我们的阈值:func (sensor *waterLevel) IsEmpty() bool { return sensor.adc.Get() <= sensor.dryThreshold } -
配置传感器引脚用于 ADC 使用,并将电压引脚配置为输出:
func (sensor *waterLevel) Configure() { sensor.adc = machine.ADC{Pin: sensor.pin} sensor.adc.Configure(machine.ADCConfig{}) sensor.voltage.Configure(machine.PinConfig{ Mode: machine.PinOutput, }) sensor.voltage.Low() } -
打开电源:
func (sensor *waterLevel) On() { sensor.voltage.High() } -
关闭电源:
func (sensor *waterLevel) Off() { sensor.voltage.Low() }
我们现在已经编写了水位传感器的库。
测试库
现在让我们编写一个小型示例程序来测试我们的库。为此,我们首先在 Chapter04 文件夹内创建一个名为 water-level-sensor-example 的新文件夹。在新文件夹内,创建一个名为 main.go 的新文件,并在其中创建一个空的 main 函数。文件夹结构现在应该如下所示:
![Figure 4.10 – 测试库的文件夹结构
![img/Figure_4.10_B16555.jpg]
Figure 4.10 – 测试库的文件夹结构
由于项目结构现在已经设置好,我们可以继续编写测试代码。为此,请按照以下步骤操作:
-
初始化 ADC 接口:
machine.InitADC() -
创建一个
WaterLevelSensor的新实例:waterLevelSensor := waterlevel.NewWaterLevel(7000, machine.ADC4, machine.D3) -
配置引脚:
waterLevelSensor.Configure() -
我们打开传感器,然后等待一小段时间,让传感器的读数稳定下来,然后再访问它。然后,每秒打印
IsEmpty()的结果:for { waterLevelSensor.On() time.Sleep(100 * time.Millisecond) println("tank is empty", waterLevelSensor.IsEmpty()) waterLevelSensor.Off() time.Sleep(time.Second) }
当水位传感器未接触任何水时,返回值应为 0。在这种情况下,我们选择了 7000 作为 dryThreshold,这样传感器的尖端可以在水中,同时仍然能够告诉我们它是空的。这将在我们还需要抽水的情况下很有用。这是当水泵没有足够的水可抽时不应运行的情况。我们应该对这个阈值值进行一些调整。通过将程序烧录到 Arduino 上,使用传感器检查水的存在,当它意识到有水时,更改阈值值并再次烧录。
使用以下命令烧录程序:
tinygo flash –target=arduino Chapter04/water-level-sensor-example/main.go
因此,我们现在已经编写了一个库,该库可以检查任何类型的水箱是否为空。在下一节中,我们将使用蜂鸣器在水箱为空时发出音频信号。
控制蜂鸣器
我们将要编写一个非常简单的蜂鸣器库。我们只想让蜂鸣器发出声音,而不考虑音调。我们首先将蜂鸣器添加到电路中。为此,请按照以下步骤操作:
-
使用跳线将 Arduino 的 D4 引脚连接到面包板上的 A31。
-
使用一个 100 欧姆电阻将 E31 连接到面包板上的 G31。
-
将蜂鸣器的 VCC 引脚连接到 J31。
-
将 GND 引脚连接到电源总线上的 GND。
电路现在应如下所示:

图 4.11 – 蜂鸣器 – 图片来自 Fritzing
由于我们已经将蜂鸣器添加到电路中,我们现在可以开始编写我们的库。
编写蜂鸣器库
蜂鸣器库将有两个函数:Configure(),用于设置引脚,以及 Beep() 函数,用于发出声音。
我们首先在 Chapter04 文件夹内创建一个名为 buzzer 的新文件夹。在新文件夹内,创建一个名为 driver.go 的文件,并将包命名为 buzzer。项目结构现在应如下所示:

图 4.12 – 蜂鸣器库的项目结构
现在按照以下步骤实现驱动程序:
-
定义一个名为
Buzzer的接口,它具有Configure函数和Beep函数:type Buzzer interface { Configure() Beep(highDuration time.Duration, amount uint8) } -
创建一个名为
buzzer的struct,它包含machine.Pin:type buzzer struct { pin machine.Pin } -
添加一个名为
NewBuzzer的函数,它返回Buzzer:func NewBuzzer(pin machine.Pin) Buzzer { return buzzer{pin: pin} } -
添加一个名为
Configure的函数,用于将pin配置为输出:func (buzzer buzzer) Configure() { buzzer.pin.Configure(machine.PinConfig{ Mode: machine.PinOutput, }) } -
定义一个名为
Beep的函数,它接受time.Duration和amount的uint8类型的参数:func (buzzer buzzer) Beep(highDuration time.Duration, amount uint8) { -
循环指定次数,让蜂鸣器发出蜂鸣声并在其间休眠:
for i := amount; i > 0; i-- { buzzer.pin.High() time.Sleep(highDuration) buzzer.pin.Low() time.Sleep(highDuration) } }
蜂鸣器库的所有内容到此为止。
现在我们将使用一个小型示例项目来测试这个库。
要做到这一点,我们首先在 Chapter04 文件夹内创建一个名为 buzzer-example 的新文件夹。在新文件夹内,创建一个新的 main.go 文件,并在其中包含一个空的 main() 函数。项目结构现在应如下所示:

图 4.13 – 测试蜂鸣器
现在将以下内容放入 main 函数中:
-
获取一个新的
buzzer实例并配置它:buzzer := buzzer.NewBuzzer(machine.D4) buzzer.Configure() -
无限循环,蜂鸣器响三次,每次 100 毫秒,然后休眠 3 秒:
for { buzzer.Beep(time.Millisecond*100, 3) time.Sleep(3 * time.Second) }
这就是我们测试蜂鸣器所需的全部代码。要尝试此示例,请使用以下命令进行烧录:
tinygo flash –target=arduino Chapter4/buzzer-example/main.go
当程序运行时,你应该能够听到蜂鸣器响。如果在一段时间后它没有开始发声,请再次检查所有电缆和引脚。
我们现在已经成功编写了一个非常简单的蜂鸣器库,并使用一个示例项目进行了测试。在下一节中,我们将控制一个泵。
控制一个泵
由于泵比简单的传感器消耗的电流更多,我们不会直接通过 GPIO 端口为泵供电。过大的电流可能会永久损坏 Arduino。因此,我们将使用外部电源和一个继电器来为泵供电。在我们开始组装电路之前,让我们简要了解一下继电器的工作原理。
使用继电器进行工作
用于微控制器项目的继电器通常安装在板上,板上通常有六个端口。它有三个输入端口:VCC、GND 和 信号。它也有三个输出端口:常开、公共 和 常闭。
当提供一个 高信号 时,电流在 常开 和 公共 之间流动。
当提供一个 低信号 时,电流在 常闭 和 公共 之间流动。
既然我们已经知道如何使用继电器,我们可以继续将新组件添加到电路中。为此,请按照以下步骤操作:
-
使用跳线将继电器的 GND 引脚连接到电源总线上的 GND。
-
使用跳线将继电器的 VCC 引脚连接到电源总线上的 VCC。
-
使用跳线将继电器的 信号(输入)引脚连接到 Arduino 的 D5。
-
将泵的 VCC 引脚连接到继电器的 常开 端口。
-
将泵的 GND 引脚连接到电源总线上的 GND。
-
将继电器的 公共 引脚与电源总线的 VCC 通道连接。
-
使用跳线将 Arduino 的 VIN 连接到电源总线上的 VCC。
您的电路现在应该看起来与以下图中的类似:


图 4.14 – 包含泵的完整电路 – 图来自 Fritzing
使用这个电路,我们将能够使用外部电源为 Arduino 供电。由于我们可能需要给远离 USB 端口的地方的植物浇水,我们已经将 Arduino 的 VIN 引脚与电源总线的 VCC 通道连接,该通道由我们的外部电源供电。现在我们将继续编写一个能够控制泵的库。
编写一个泵库
泵库基本上将有两个功能:Configure,用于设置引脚,以及 Pump 功能,用于在给定的时间和迭代次数内泵水。
我们首先在 Chapter04 文件夹内创建一个名为 pump 的新文件夹。在新文件夹内,创建一个 driver.go 文件,并将包命名为 pump。项目结构现在应如下所示:

图 4.15 – 水泵库的项目结构
现在,我们已经设置了项目结构,我们可以继续编写控制水泵的代码。为此,请按照以下步骤操作:
-
定义名为
Pump的接口,它具有Configure或Pump函数:type Pump interface { Configure() Pump(duration time.Duration, iterations uint8) } -
定义一个名为
pump的新struct,它包含machine.Pin:type pump struct { pin machine.Pin } -
定义一个名为
NewPump的函数,它接受machine.Pin并返回一个新的指向pump的指针:func NewPump(pin machine.Pin) Pump { return &pump{ pin: pin, } } -
然后定义一个名为
Configure的函数,并将pin设置为输出引脚:func (pump *pump) Configure() { pump.pin.Configure(machine.PinConfig{ Mode: machine.PinOutput, }) } -
接下来,定义一个名为
Pump的函数,并循环iterations次数,将pin设置为high,休眠duration,然后将其再次设置为low:func (pump *pump) Pump(duration time.Duration, iterations uint8) { for i := iterations; i > 0; i-- { pump.pin.High() time.Sleep(duration) pump.pin.Low() time.Sleep(duration) } }
这是我们 pump 库所需的所有内容。现在,我们可以继续创建一个小型示例项目来测试库。为此,我们将在 Chapter04 文件夹内创建一个名为 pump-example 的新文件夹,并在其中创建一个包含空 main 函数的 main.go 文件。项目结构现在应如下所示:

图 4.16 – 测试水泵
在 main 函数内部,我们添加以下内容:
-
通过调用
NewPump函数创建一个pump的新实例,并将machine.D5作为pin传入:pump := pump.NewPump(machine.D5) pump.Configure() -
无限循环,并
pump 3次每次350毫秒,之后sleep30秒,如下所示:for { pump.Pump(350*time.Millisecond, 3) time.Sleep(30 * time.Second) }
这是尝试我们水泵的完整示例代码。
注意
由于物理定律是这样的,我建议接收容器应始终放置在源容器的水位之上,即使水泵停止抽水,水也会继续流动。
现在,将你的水泵放入一杯水或其他水罐中,并通过以下命令闪烁程序来测试它:
tinygo flash –target=arduino Chapter04/pump-example/main.go
使用此示例找出良好的水泵持续时间迭代时间,不要泵入过多的水。记住,我们想要灌溉植物,这将帮助我们找到良好的值。
这是我们需要的最后一个组件。我们已经学会了如何使用继电器来供电和控制水泵,并且我们编写了一个新的库。现在,我们将把所有内容在下一节中组合在一起。
灌溉你的植物
现在,我们将利用之前章节中创建的每一个组件。将所有组件组合起来,我们将构建一个完全自动化的植物灌溉系统。
首先,我们需要在 Chapter04 文件夹内创建一个名为 plant-watering-system 的新文件夹。在新文件夹内,创建一个包含空 main() 函数的新 main.go 文件。最终项目结构现在应如下所示:

图 4.17 – 植物灌溉系统项目结构
现在,在main函数中,按照以下步骤操作:
-
初始化
ADC接口:machine.InitADC() -
初始化一个新的
soilSensor:soilSensor := soil.NewSoilSensor(18000, 34800, machine. ADC5, machine.D2) soilSensor.Configure() -
初始化一个新的
waterLevelSensor:waterLevelSensor := waterlevel.NewWaterLevel(7000, machine.ADC4, machine.D3) waterLevelSensor.Configure() -
初始化一个新的
pump:pump := pump.NewPump(machine.D5) pump.Configure() -
初始化一个新的
buzzer:buzzer := buzzer.NewBuzzer(machine.D4) buzzer.Configure() -
打开
waterLevelSensor并短暂睡眠,以便读数稳定:for { waterLevelSensor.On() time.Sleep(100 * time.Millisecond) -
在继续
for循环之前,检查水容器是否为空,关闭传感器,蜂鸣三次,然后睡眠一小时:if waterLevelSensor.IsEmpty() { waterLevelSensor.Off() buzzer.Beep(150*time.Millisecond, 3) time.Sleep(time.Hour) continue } -
如果水容器不为空,关闭
waterLevelSensor:waterLevelSensor.Off() -
打开
soilSensor并短暂睡眠,以便让读数稳定:soilSensor.On() time.Sleep(100 * time.Millisecond) -
然后切换
soilSensor.Get()的结果:switch soilSensor.Get() { -
如果土壤是
非常干燥或完全干燥,关闭土壤传感器并浇水:case soil.VeryDry, soil.CompletelyDry: pump.Pump(350*time.Millisecond, 3) -
在所有其他情况下,关闭
soilSensor并睡眠一小时:default: soilSensor.Off() time.Sleep(time.Hour) } }
这是我们这个最终项目所需的一切。您可以通过以下命令将程序烧录到您的 Arduino 上以尝试程序:
tinygo flash –target=arduino Chapter04/plant-watering-system/main.go
重要注意事项
请记住,每种植物对水的需求都不同。因此,我们需要调整泵水量以适应被灌溉的植物的需求。
我们现在已经成功构建了一个完整的自动植物灌溉系统,并将其烧录到 Arduino 上。
摘要
在本章中,我们学习了如何使用 ADC 接口读取传感器值。我们还学习了 ADC 接口如何将电压转换为数字值,然后我们利用这些知识编写了土壤湿度传感器库。
我们随后利用在第一章的第一个项目中收集到的知识编写了水位传感器库。然后我们学习了如何使用蜂鸣器,并编写了一个非常简单的库,使我们能够让蜂鸣器发出警告声音。之后,我们学习了继电器的工作原理,并利用这些知识通过我们编写的库来控制水泵。在本章的最后,我们将所有库合并到一个项目中,并且只需要添加少量控制逻辑来构建自动植物灌溉系统。
在下一章中,我们将学习如何使用超声波传感器以及如何控制七段显示器。
问题
-
为什么水位传感器和土壤湿度传感器不永久供电?
-
在继电器中,电路在常开和GND之间何时闭合?
参考文献
电容式土壤湿度传感器的 fritzing 部分来自以下存储库的集合:github.com/OgreTransporter/fritzing-parts-extra
第五章:第五章:构建非接触式洗手计时器
在第四章“构建植物浇水系统”中,我们学习了 ADC 接口的工作原理,并利用这一知识编写了电容式土壤湿度传感器和水位传感器的库。我们还编写了一个小型库来控制蜂鸣器,并学习了继电器的工作原理,利用这些知识使用我们的代码控制水泵。然后,我们利用所有这些知识构建了一个自动植物浇水系统。
在本章中,我们将构建一个非接触式洗手计时器。完成本章学习后,您将了解超声波传感器的工作原理以及如何使用它们来测量距离。我们将利用这些知识来创建一个传感器,当手在传感器 20 至 30 厘米之间时,可以启动计时器。计时器随后将在 7 段显示器上显示。在实现这一功能的同时,我们还将了解 MAX7219 芯片及其如何用于控制不同类型的显示器。
本章将涵盖以下主要内容:
-
介绍 Arduino Nano 33 IoT
-
测量距离
-
使用 7 段显示器
-
整合所有内容
技术要求
我们将需要以下组件来完成这个项目:
-
一个 Arduino Nano 33 IoT
-
一个 HC-SR04 传感器
-
一个外部电源模块
-
HS420561K 4 位 7 段显示器共阴极
-
一个 MAX7219 或 MAX7221 串行输入/输出共阴极显示驱动器
-
一个 10,000 欧姆电阻
-
一个 1,000 欧姆电阻
-
一个 2,000 欧姆电阻
-
2 块面包板
-
跳线电缆
大多数组件都是所谓的 Arduino 入门套件的一部分。如果您没有这样的套件,它们可以在任何电子产品商店购买。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter05
本章的“代码在行动”视频可以在此处找到:bit.ly/3e2IYgG
介绍 Arduino Nano 33 IoT
我们已经达到一个阶段,TinyGo 对 Arduino UNO 的支持已经达到极限。在撰写本文时,无法使用 Arduino UNO 解决当前和后续章节的这个问题。原因是缺少我发起的相应Pull请求。此外,TinyGo 编译器工具链中的 Alf 和 Vegard 的 RISC(AVR)后端在当前 TinyGo 版本中存在一些问题,代码无法编译。因此,让我们看看另一个完全由 TinyGo 支持的板——Arduino Nano 33 IoT。与 UNO 相比,Nano 33 IoT 是一款性能强大的板。以下是它的技术规格:
-
微控制器:AMD21 Cortex®-M0+ 32 位低功耗 ARM MCU
-
无线模块:U-blox NINA-W102
-
工作电压:3.3V
-
输入电压(限制): 2V
-
每个 I/O 引脚的直流电流: 7 mA
-
时钟速度: 48 MHz
-
CPU 闪存: 256 KB
-
SRAM: 32 KB
-
GPIO 引脚: 14
-
模拟输入引脚: 8 (8/10/12 位)
-
模拟输出引脚: 1 (10 位)
因此,Arduino Nano 33 IoT 在 3.3V 而不是 5V 供电时具有更高的时钟速度、更多的 RAM 和更多的闪存。此外,Nano 33 IoT 还具备 Wi-Fi 通信功能。
现在,让我们简要地了解一下 Arduino Nano 33 IoT 的 5V 输出能力。
注意
虽然 Arduino Nano 33 IoT 有一个 5V 输出引脚,但该引脚默认是禁用的。要激活该引脚,需要进行一些焊接。
当通过 USB 为 Arduino Nano 33 IoT 供电时,我们也在Vin引脚上有一个 5V 电流,但该引脚的目的是为 Arduino 供电。我们将处理需要 5V 输入的设备,但这不是问题;我们只是将使用外部电源为这些设备供电。
现在我们对它有了基本的了解,让我们来看看引脚图。以下图表显示了 Arduino Nano 33 IoT 的引脚图:

图 5.1 – Arduino Nano 33 IoT 引脚图
引脚图来源可以在store.arduino.cc/arduino-nano-33-iot找到。
在本节中,我们简要地了解了 Arduino Nano 33 IoT 的技术规格。然而,在我们能够在项目中使用它之前,我们需要安装一些依赖项。
安装 Bossa
为了将程序烧录到 Arduino Nano 33 IoT 上,需要 Bossa。
首先,让我们看看在 Mac 系统上的安装过程:
-
您可以使用以下命令简单地安装依赖项:
msi:github.com/shumatech/BOSSA/releases/download/1.9.1/bossa-x64-1.9.1.msi -
当执行
msi时,请选择以下安装路径:bossa to the path using the following command:set PATH=%PATH%;"c:\Program Files\BOSSA";
-
为了在 Linux 系统上安装
bossa,请执行以下命令:sudo apt install libreadline-dev libwxgtk3.0-gtk3-dev git clone https://github.com/shumatech/BOSSA.git cd BOSSAmakesudo cp bin/bossac /usr/local/bin
要验证安装成功,请使用以下命令:
bossac –help
关于如何安装所需依赖项的最新信息可以在tinygo.org/microcontrollers/arduino-nano33-iot/找到。
我们现在已经设置了在 Arduino Nano 33 IoT 上烧录程序的所需依赖项。让我们继续本章的第一个项目。
学习测量距离
如果你曾经好奇无接触式肥皂分配器或无接触式吹风机是如何检测到它们下面有手的,那么它们很可能正在使用 HC-SR04 超声波传感器。我们将使用这个传感器来测量物体与传感器之间的距离。让我们从 HC-SR04 传感器开始。
理解 HC-SR04 传感器
HC-SR04 传感器以 40k Hz 的频率发射超声波,这些超声波穿过空气,如果发射的脉冲与路径上的任何物体碰撞,就会反弹。该传感器不能用作其他超声波脉冲的检测器,因为它只能注册它自己发出的确切脉冲的回声。通常,这些传感器看起来与以下照片中的类似:

图 5.2 – HC-SR04 传感器
这个传感器有以下技术规格:
-
它的检测范围从 2 到 400 厘米。
-
它的电流小于 2 mA。
-
它的工作电压为 5V。
-
它的分辨率为 0.3 厘米。
-
它的角度小于 15 度。
传感器有以下三个端口:
-
VCC:这个用于给传感器供电。
-
TRIG:这个触发脉冲。
-
ECHO:这个接收脉冲的回声。
现在,让我们看看超声波脉冲是如何精确地用来测量发送器和物体之间的距离的。传感器发射八个脉冲,这些脉冲穿过空气。如果它们击中一个物体,它们会被反射并作为回声返回,如下面的图所示:

图 5.3 – 八个脉冲和一个回声
当识别到回声时,传感器的回声引脚将被设置为高电平,持续的时间与脉冲离开并返回传感器所需的时间完全相同。现在,我们只需要进行一些数学计算来计算距离。
脉冲以 340 m/s 的速度传播,这是空气中的声速。这也可以表示为 0.034 m/μs(微秒)。如果物体距离传感器大约 30 厘米,脉冲需要传播大约 882 微秒。回声引脚将被设置为高电平,正好与脉冲需要传播整个路径的时间一样长;这就是为什么我们需要将结果除以 2。最后一步,我们将旅行时间除以 0.034,以得到以厘米为单位的旅行距离。
下面是如何实现这个例子的:
Time = Distance / Speed
t = s/v
t = 30cm / 0.034m/us
t = 882.352941176us
让我们重新排列这个公式来得到距离:
Distance = Time * Speed
30cm = 882.352941176us * 0.34m/us
现在我们已经学会了如何使用超声波传感器,从理论上讲,我们现在可以通过实时尝试来验证这个理论。
组装电路
在我们开始组装电路之前,我们需要确保传感器的回声引脚,它将被连接到 Arduino 的输入引脚,确实发送 3.3V 信号而不是 5V 信号。为此,我们可以使用分压器。传感器的回声引脚输出 5V,但 Arduino Nano 33 IoT 不应该连接到 5V,因为这可能会永久损坏 Arduino。这就是为什么我们使用分压器的理由。
计算输出电压(Vout)的公式如下:
Vout = Vs * R2 / (R1 + R2)
在这里,Vs是源电压,R2是连接到源电压的电阻,R1是连接到地线的电阻。
因此,我们需要一个 2,000 欧姆的电阻作为 R2,以及一个 1,000 欧姆的电阻作为 R1。这将导致以下方程:
3.333V = 5 * 2,000 / (1,000 + 2,000)
现在我们已经学会了如何构建分压器,我们可以继续按照以下步骤组装电路:
-
将 HC-SR04 传感器放置在面包板上,将VCC引脚放在面包板的J行上。
-
使用跳线将电源总线上的VCC通道与传感器的VCC连接起来。
-
使用跳线将电源总线上的GND通道与传感器的GND连接起来。使用跳线将 Arduino 的D2与传感器的Trig连接起来。
-
使用 2,000 欧姆电阻通过跳线将
GND与面包板上的C53连接起来。 -
使用 1,000 欧姆电阻通过面包板跳线将Echo与A53连接起来。
-
现在,使用跳线将 Arduino 的D3与面包板上的B53连接起来。我们可以在这里读取 3.3V 的Echo信号。
-
在面包板上放置一个外部电源。注意将跳线设置为5V。
这是我们编写和测试库所需的一切。您的电路现在应该类似于以下图示:
![Figure 5.4 – The HC-SR04 circuit (image taken from Fritzing)
![img/Figure_5.4_B16555.jpg]
图 5.4 – HC-SR04 电路(图片来自 Fritzing)
我们现在已经学会了超声波传感器的工作原理,并组装了电路。正如前几章所做的那样,这里我们也首先创建一个库来控制传感器。
编写库
我们将编写一个库,该库有一个函数,返回传感器到物体的当前距离,或者如果物体超出范围,则返回0。我们首先在项目内部创建一个名为Chapter05的新文件夹。在新的Chapter05文件夹内,创建一个名为ultrasonic-distance-sensor的新文件夹,并创建一个名为driver.go的新文件。将包命名为hcsr04。您的文件夹结构应如下所示:
![Figure 5.5 – The project structure for writing a library
![img/Figure_5.5_B16555.jpg]
图 5.5 – 编写库的项目结构
现在我们已经设置了项目结构,我们可以开始编写实际的逻辑。为此,执行以下步骤:
-
在包级别定义一个新的常量,命名为
speedOfSound,并将其值设置为0.0343,这是每微秒厘米的声速:const speedOfSound = 0.0343 -
接下来,定义一个新的接口,命名为 HCSR04,如下面的代码所示:
type Device interface { Configure() GetDistance() uint16 GetDistanceFromPulseLength( float32) uint16 } -
然后,我们定义一个新的
struct,称为hcsr04,它包含trigger和echo``pins以及微秒级的timeout,如下面的代码所示:type device struct { trigger machine.Pin echo machine.Pin timeout int64 } -
接下来,我们添加一个名为
NewHCSR04的函数,它接受一个trigger和echo引脚以及厘米单位的maxDistance,并返回HCSR04:func NewDevice(trigger, echo machine.Pin, maxDistance float32) HCSR04 { -
计算微秒级的
timeout。我们将maxDistance乘以2,因为脉冲需要到达物体并返回。然后将结果除以speedOfSound:timeout := int64(maxDistance * 2 / speedOfSound) -
创建一个新的
hcsr04实例,设置trigger、echo和timeout,并返回新实例的指针:return &device{ trigger: trigger, echo: echo, timeout: timeout, } } -
添加一个名为
Configure的函数,它是一个指针接收器,将trigger配置为输出,将echo引脚配置为输入:func (sensor *device) Configure() { sensor.trigger.Configure( machine.PinConfig{Mode: machine.PinOutput}, ) sensor.echo.Configure( machine.PinConfig{Mode: machine.PinInput}, ) } -
添加
sendPulse函数,该函数将trigger拉高10微秒,然后再次将trigger设置为低。这将触发 HC-SR04 传感器中的八个超声波脉冲:func (sensor *device) sendPulse() { sensor.trigger.High() time.Sleep(10 * time.Microsecond) sensor.trigger.Low() } -
添加一个名为
GetDistance的新函数,它返回uint16并且是一个指针接收器。首先,该函数发送脉冲并监听回声。当 echo 引脚读取高值时,我们收到回声:func (sensor *device) GetDistance() uint16 { i := 0 timeoutTimer := time.Now() sensor.sendPulse() for { if sensor.echo.Get() { timeoutTimer = time.Now() break } i++ -
检查
i是否大于15。我们这样做是为了节省一些时间,因为与获取当前时间戳相比,比较一个整数是一个非常快的操作。如果自我们的计时器开始以来经过的时间大于我们配置的timeout,则返回0,我们可以将其用作timeout值:if i > 15 { microseconds := time.Since(timeoutTimer). Microseconds() if microseconds > sensor.timeout { return 0 } } }现在我们必须测量
echo引脚被设置为高时的时间:var pulseLength float32 i = 0 for { if !sensor.echo.Get() { microseconds := time.Since(timeoutTimer). Microseconds() pulseLength = float32(microseconds) break } i++ if i > 15 { microseconds := time.Since(timeoutTimer). Microseconds() if microseconds > sensor.timeout { return 0 } } } return sensor.GetDistanceFromPulseLength(pulseLength) } -
添加一个名为
GetDistanceFromPulseLength的函数,它接受pulseLength作为参数,返回厘米距离,并且是一个指针接收器:func (sensor *hcsr04) GetDistanceFromPulseLength( pulseLength float32) uint16 { -
由于
pulseLength参数是信号到达目标并返回所需的时间,我们需要将其除以2:pulseLength = pulseLength / 2 -
为了得到厘米的结果,我们需要将
pulseLength乘以speedOfSound:result := pulseLength * speedOfSound -
将
result作为uint16返回,因为我们不关心小数位:return uint16(result) }
这就是我们需要为库的所有代码。从现在起,我们可以使用这个库通过 HCSR-04 传感器来测量距离。
在我们继续在真实世界的示例中测试库之前,让我们使用GetDistanceFromPulseLength函数简要地看看如何在 TinyGo 中进行单元测试。
TinyGo 中的单元测试
TinyGo 支持单元测试,这在您有复杂的逻辑且不想在尝试查找错误时将每个更改都闪存到微控制器上时非常有用。让我们看看目前以实用方式支持的内容。为此,在ultrasonic-distance-sensor文件夹内创建一个名为driver_test.go的新文件,并将包命名为hcsr04_test。项目结构应类似于以下内容:

图 5.6 – 第一个单元测试的项目结构
现在,让我们在 TinyGo 中添加我们的第一个单元测试。为此,执行以下步骤:
-
添加一个名为
TestGetDistanceFromPulseLength_30cm的新函数:func TestGetDistanceFromPulseLength_30cm(t *testing.T) { -
创建一个新的
HCSR04实例。我们实际上不需要为这个测试添加参数,因为这些参数将不会使用;然而,我们仍然可以添加一些正确的参数:sensor := hcsr04.NewHCSR04( machine.D2, machine.D3, 100) -
根据给定的
pulseLength参数计算distance,这正好是 30 厘米距离的脉冲长度:distance := sensor.GetDistanceFromPulseLength( 1749.27113703) -
检查
distance是否等于30。如果不等于,我们则失败测试并记录一些信息,如下所示:if distance != 30 { t.Error("Expected distance: 30cm", "actual distance: ", distance, "cm") } }
这是我们 TinyGo 中的第一个单元测试。不要忘记导入 testing 包。就像在正常的程序代码中一样,我们可以使用标准的 Golang 包。你可以使用以下命令运行测试:
tinygo test --tags "arduino_nano33" Chapter05/ultrasonic-distance-sensor/driver_test.go
TinyGo 内部大量使用 arduino_nano33,用于决定构建当前代码所需的哪些包和文件。如果我们省略 –tags 参数,测试将无法编译,因为那时将缺少 machine 包。
测试的输出应该看起来像以下内容:
=== RUN TestGetDistanceFromPulseLength_30cm
--- PASS: TestGetDistanceFromPulseLength_30cm
现在我们知道我们可以利用非常简单的测试来测试 TinyGo 中的逻辑。让我们更进一步,进行基于表的测试。执行以下步骤:
-
下一步是在其他测试下方添加一个新函数,称为
TestGetDistanceFromPulseLength_TableDriven。代码片段如下:func TestGetDistanceFromPulseLength_TableDriven( t *testing.T) { -
添加四个测试用例,每个测试用例都有一个
Name、预期的Result和我们用作输入的PulseLength,如下所示:var testCases = [4]struct { Name string Result uint16 PulseLength float32 }{ { Name: "1cm", Result: 1, PulseLength: 58.8235294117}, { Name: "30cm", Result: 30, PulseLength: 1749.27113703}, { Name: "60cm", Result: 60, PulseLength: 3498.54227405}, { Name: "400cm", Result: 400, PulseLength: 23323.6151603}, } -
创建一个
HCSR04Device实例的新实例。它应该看起来像以下代码片段:sensor := hcsr04.NewDevice( machine.D2, machine.D3, 100) -
现在我们可以为数组中的每个
testCase运行一个测试。这看起来像以下代码:for _, testCase := range testCases { t.Run(testCase.Name, func(t *testing.T) { -
计算
distance。并检查我们得到的结果是否与预定义的测试用例不同:distance := sensor.GetDistanceFromPulseLength( testCase.PulseLength) if distance != testCase.Result { t.Error("Expected distance:", testCase.Name, "actual distance: ", distance, "cm") } }) } }
这就是测试的全部内容。现在,让我们使用以下命令再次运行测试:
tinygo test --tags "arduino_nano33" Chapter5/ultrasonic-distance-sensor/driver_test.go
输出现在应该看起来像以下内容:

图 5.7 – tinygo 测试输出
注意
由于目前不支持为 Windows 构建二进制文件,前面的 tinygo test 命令将在 Windows 系统上失败。Windows 用户的一个选择是使用 WSL 进行单元测试。另一个可能性是使用 –target 参数设置构建目标。Windows 支持构建 wasm 或 wasi 目标,但由于我们的代码依赖于 machine 包,这在这个特定测试中不会工作。这是因为 machine 包对 wasm 和 wasi 目标不可用。
现在我们知道我们也可以在 TinyGo 中使用基于表的测试。在撰写本文时,测试包的大部分功能似乎已经实现。目前,似乎只有 Helper() 函数尚未实现。然而,可能还有一两个小问题我没有发现,它们可能不会正常工作。此外,我们已经检查过我们计算距离的逻辑似乎按预期工作。
在这个基础上,我们可以继续编写一个小型示例程序来测试我们代码在真实硬件上的其余部分。
为库编写示例程序
我们现在已经验证了从脉冲长度输入计算距离的公式似乎是正确的。因此,我们可以继续前进,创建一个将测量距离输出到串行的示例。为此,首先,我们需要在Chapter05文件夹内创建一个新的文件夹,命名为ultrasonic-distance-sensor-example。此外,我们还需要创建一个新的main.go文件,并包含一个空的main函数。项目结构应类似于以下内容:

图 5.8 – 示例程序的工程结构
示例逻辑包括初始化传感器,然后每秒打印一次距离。所有这些都在main函数内部完成。它看起来像以下代码片段:
sensor := hcsr04.NewHCSR04(machine.D2, machine.D3, 80)
sensor.Configure()
for {
distance := sensor.GetDistance()
if distance != 0 {
println("Current distance:", distance, "cm")
}
time.Sleep(time.Second)
}
这是示例的完整代码。库被导入并命名为hcsr04别名。现在,让我们使用以下命令将程序烧录到 Arduino Nano 33 IoT 上:
tinygo flash --target=arduino-nano33 Chapter05/ultrasonic-distance-sensor-example/main.go
要检查输出,我们可以使用在第三章“使用键盘构建安全锁”中创建的相同的PuTTY配置文件。打开 PuTTY 并选择微控制器配置文件。确保在之前已经将 USB 线插入与 Arduino UNO 相同的端口。根据传感器与任何物体之间的当前距离,输出应类似于以下屏幕截图:

图 5.9 – PuTTy 中的传感器读数
在本节中,我们编写了一个 HC-SR04 传感器的库,学习了 TinyGo 中也同样可以进行单元测试,然后编写了一个示例项目来测试这个库。因此,我们现在能够测量距离,这是项目的前半部分。
在下一节中,我们将探讨 7 段显示器,因为我们需要 7 段显示器在我们的最终项目中显示计时器。
使用 4 位 7 段显示器
7 段显示器可以用于多种用途。其中之一是显示时间,这正是我们最终项目想要做的。但我们如何控制它们呢?
4 位显示器有 12 个引脚:每个数字(从 0 到 9)一个引脚,每个段一个引脚,还有一个点引脚。因此,要显示任何内容,我们必须向要设置的数字发送高电平信号,然后只需将所有引脚设置为高电平,这是我们用来表示要显示的字符的方式。
例如,如果我们想在第四位显示字符“1”,我们会将引脚 4 以及 B 和 C 引脚设置为高电平。
为了更好地理解这一点,请查看以下图表:

图 5.10 – 7 段显示器引脚图
从前面的图中,你可以看到引脚 1 到 4 被用来选择数字。
7 段 A-G 引脚被用来控制段,而点引脚被用来设置点。
因此,要控制 12 个引脚有点困难,因为那样的话,在控制显示器时我们只剩下 2 个数字引脚。这就是为什么我们使用另一个设备来控制显示器:一个 MAX7219。下一节将解释如何做到这一点。
使用 MAX7219
MAX7219(和 Max7221)是一个 串行接口,8 位 LED 显示驱动器。简而言之,我们可以使用仅四根线来控制这个芯片,它可以控制多达八个 7 段数字。
要向该芯片发送数据,我们只需将负载引脚拉低,发送包含要设置的寄存器的 1 字节数据和设置段和点的 1 字节数据。然后,我们将负载引脚拉高,并处理已写入的 16 位。然后,芯片将解码数据并设置所有输出引脚。以下图是芯片的引脚图,仅供参考:

图 5.11 – MAX7219 和 MAX7221 引脚图
这些芯片通常用于 8x8 LED 矩阵。所以,如果你有这样的设备,你可以小心地使用镊子移除芯片。移除芯片可能会永久损坏芯片! 这些芯片在大多数微控制器组件商店也都可以自由购买。在我们开始为这个芯片编写库之前,让我们首先组装我们的电路。为此,执行以下步骤:
-
取另一个面包板;一个半尺寸的就足够了。
-
将 MAX7219 放置在面包板上。CLK 引脚应位于 E1,而 LOAD 引脚应位于 G1。
-
将 7 段显示器放置在面包板上。
E引脚应位于D25,而1引脚应位于 F25 或 G25(取决于哪个更适合)。 -
将 MAX7219 的 DIG0 连接到显示器的 Digit1。
-
将 MAX7219 的 DIG1 连接到显示器的 Digit2。
-
将 MAX7219 的 DIG2 连接到显示器的 Digit3。
-
将 MAX7219 的 DIG3 连接到显示器的 Digit4。
-
将 MAX7219 的 SEGA 连接到显示器的 A。
-
将 MAX7219 的 SEGB 连接到显示器的 B。
-
将 MAX7219 的 SEGC 连接到显示器的 C。
-
将 MAX7219 的 SEGD 连接到显示器的 D。
-
将 MAX7219 的 SEGE 连接到显示器的 E。
-
将 MAX7219 的 SEGF 连接到显示器的 F。
-
将 MAX7219 的 SEGG 连接到显示器的 G。
-
将 MAX7219 的 SEGDP 连接到显示器的 DOT。
-
将两个面包板电源总线上的 GND 通道连接起来。
-
使用跳线将两个面包板电源总线上的 VCC 通道连接起来。
-
使用一个 10,000-Ohm 电阻将 ISET 连接到 MAX7219 的 VCC。这是一个控制显示器亮度的硬件解决方案。
-
将 Arduino 的 D13 连接到 MAX7219 的 CLK。
-
将 Arduino 的 D6 连接到 MAX7219 的 LOAD。
-
将 Arduino 的 D11 连接到 MAX7219 的 DIN。
-
将 Arduino 的 D5 连接到蜂鸣器的 VCC。
-
将蜂鸣器的 GND 与电源总线上的 GND 连接。
当所有这些步骤都完成后,结果现在应该看起来类似于以下图示:

图 5.12 – 最终电路图
现在,让我们通过编写一个小型库来与这个芯片通信,更好地理解 MAX7219 的工作原理。
编写控制 MAX7219 的库
我们不仅想学习如何在单个项目中使用 MAX7219,还想创建一个库,我们可以在所有未来的项目中使用,甚至超出本书的范围。
首先,我们需要在 Chapter05 文件夹中创建一个新的文件夹,命名为 max7219spi。在新建的文件夹中创建两个名为 registers.go 和 device.go 的文件,并使用 MAX7219spi 作为包名。项目结构应如下所示:

图 5.13 – 控制 MAX7219 的项目结构
现在,我们已经准备好继续前进并编写一些代码。我们将在下一节中实现所需的寄存器。
Registers.go
在 registers.go 文件中,我们放置了一些代表寄存器地址的常量。我们将在使用代码中的常量时解释这些常量:
const (
REG_NOOP byte = 0x00
REG_DIGIT0 byte = 0x01
REG_DIGIT1 byte = 0x02
REG_DIGIT2 byte = 0x03
REG_DIGIT3 byte = 0x04
REG_DIGIT4 byte = 0x05
REG_DIGIT5 byte = 0x06
REG_DIGIT6 byte = 0x07
REG_DIGIT7 byte = 0x08
REG_DECODE_MODE byte = 0x09
REG_INTENSITY byte = 0x0A
REG_SCANLIMIT byte = 0x0B
REG_SHUTDOWN byte = 0x0C
REG_DISPLAY_TEST byte = 0x0F
)
这就是这个文件的全部内容。有关这些常量的进一步解释也可以在数据手册datasheets.maximintegrated.com/en/ds/MAX7219-MAX7221.pdf中找到。现在,我们将实现驱动程序。
Device.go
在这个文件中,我们可以定义一个接口来实现其方法。该接口将提供一个向 MAX7219 写入数据的函数,以及一些方便的函数来为我们的示例启动显示测试。要实现它,请按照以下步骤:
-
按照以下方式定义包含所有功能的
Device接口:type Device interface { WriteCommand(register, data byte) Configure() StartShutdownMode() StopShutdownMode() StartDisplayTest() StopDisplayTest() SetDecodeMode(digitNumber uint8) SetScanLimit(digitNumber uint8) } -
然后,定义一个包含
load的device结构体。我们将在使用这些引脚时详细解释它们:type device struct { bus machine.SPI load machine.Pin } -
然后,定义一个名为
NewDevice的函数,它创建一个新的device实例,并设置load引脚以及 SPI总线:func NewDevice( load machine.Pin, bus machine.SPI) Device { return &device{ load: load, bus: bus, } } -
定义一个名为
WriteCommand的函数,它接受 2 个字节作为其参数。第一个字节是register,第二个字节是设置的数据。对于寄存器,我们使用registers.go文件中的常量。我们通过将load引脚拉低来向 MAX7219 写入数据。接下来,我们写入register字节,然后是payload,然后我们将load引脚拉高。将load引脚拉高会触发 MAX7219 加载并处理数据:func (driver *device) WriteCommand( register, data byte) { driver.load.Low() driver.writeByte(register) driver.writeByte(data) driver.load.High() } -
定义一个名为
Configure的函数,将load引脚设置为输出:func (driver *device) Configure() { outPutConfig := machine.PinConfig{ Mode: machine.PinOutput, } driver.load.Configure(outPutConfig) } -
定义一个名为
SetScanLimit的函数,告诉 MAX7219 我们将在程序中使用多少位。MAX7219 中的数字从 0 开始,因此我们需要从我们的数字数量中减去 1,如下所示:func (driver *device) SetScanLimit(digitNumber uint8) { driver.WriteCommand(REG_SCANLIMIT, byte( digitNumber-1)) } -
接下来,定义一个名为
SetDecodeMode的函数,该函数告诉 MAX7219 应该解码多少位。解码模式将有助于我们以后,因为它将我们的输入转换为匹配 7 段显示屏的输出以显示字符。MAX7219 为此目的有一个预定义的字符集:func (driver *device) SetDecodeMode(digitNumber uint8) { -
切换到
digitNumber输入;如果我们只使用一个数字,我们告诉 MAX7219 只解码第一个数字:switch digitNumber { case 1: driver.WriteCommand(REG_DECODE_MODE, 0x01)如果我们使用两个、三个或四个数字,我们告诉 MAX7219解码前四个数字:
case 2, 3, 4: driver.WriteCommand( REG_DECODE_MODE, 0x0F) -
解码所有数字:
case 8: driver.WriteCommand(REG_DECODE_MODE, 0xFF) -
如果输入为 0 或大于 8,我们告诉 MAX7219不进行解码:
default: driver.WriteCommand(REG_DECODE_MODE, REG_SHUTDOWN register. This looks like the following snippet:func (driver *device) StartShutdownMode() {
driver.WriteCommand(REG_SHUTDOWN, 0x00)
}
func (driver *device) StopShutdownMode() {
driver.WriteCommand(REG_SHUTDOWN, 0x01)
}
-
现在,我们希望能够启动和停止显示测试模式。显示测试激活所有连接的 LED。这看起来如下所示:
func (driver *device) StartDisplayTest() { driver.WriteCommand(REG_DISPLAY_TEST, 0x01) } func (driver *device) StopDisplayTest() { driver.WriteCommand(REG_DISPLAY_TEST, 0x00) } -
定义一个名为
writeByte的函数,该函数接收一个字节并将其写入 MAX7219。在这里,我们使用 SPI 接口。首先,SPI 实现内部拉低时钟引脚,然后它取字节中的每个位,并将数据引脚设为低电平以表示 0,设为高电平以表示 1。在数据线位被设置后,它拉高时钟引脚:func (driver *device) writeByte(data byte) { driver.bus.Transfer(data) }
这是我们需要的 MAX7219 的所有内容。
在下一节中,我们将创建一个位于此设备之上的小型抽象层。我们的抽象层将实现 7 段显示屏的具体细节。我们已经以非常通用的方式实现了 MAX7219 包,这是故意的,以便我们可以根据这个包构建 7 段显示屏和 8x8 LED 矩阵的抽象层。
编写控制 hs42561k 显示屏的库
这个库使用 MAX7219 库来设置它用于 7 段显示屏的使用,并提供一个便利函数来设置特定数字的字符。我们首先在Chapter05文件夹内创建一个名为hs42561k的新文件夹,并创建两个名为constants.go和device.go的文件。然后,将包命名为hs42561k。项目结构应类似于以下内容:

图 5.14 – 控制 hs42561k 显示屏的项目结构
我们从constants.go文件开始。这个文件将包含一些常量和返回字符字符串的便利函数。为此,执行以下步骤:
-
为所有字符添加常量。这些值取自 MAX7219 数据表。如果我们使用这些值,MAX7219 将设置显示屏上的正确引脚,这要归功于集成的解码器:
const ( Zero Character = 0 One Character = 1 Two Character = 2 Three Character = 3 Four Character = 4 Five Character = 5 Six Character = 6 Seven Character = 7 Eight Character = 8 Nine Character = 9 Dash Character = 10 E Character = 11 H Character = 12 L Character = 13 P Character = 14 Blank Character = 15 Dot Character = 128 ) -
现在,让我们添加
Character结构体,它实现了String函数。String函数在调试时非常有用。我们在示例中截断了列表;当然,你也可能想添加One到Eight的情况:type Character byte func (char Character) String() string { switch char { case Zero: return "0" [...] case Nine: return "9" case Dash: return "-" case E: return "E" case H: return "H" case L: return "L" case P: return "P" case Blank: return "" case Dot: return "." } return "" }
这是 constants.go 文件中我们需要的所有内容。
现在,让我们按照以下步骤实现 device.go 文件:
-
添加一个名为
Device的接口,其中包含Configure函数和SetDigit函数:type Device interface { Configure() SetDigit(digit byte, character Character) error } -
添加一个名为
device的struct,它包含我们想要控制的数字数量以及 MAX7219 设备的引用:type device struct { digitNumber uint8 displayDevice MAX7219spi.Device } -
添加一个名为
NewDevice的函数,该函数返回一个Device实例:func NewDevice(displayDevice MAX7219spi.Device, digitNumber uint8) Device { return &device{ displayDevice: displayDevice, digitNumber: digitNumber, } } -
添加一个名为
Configure的函数。Configure函数用于初始化显示驱动器。它通过设置正确的decode mode和scan limit函数以及停止关闭模式以将显示屏带入操作模式来实现,如下面的代码片段所示:func (device *device) Configure() { device.displayDevice.StopDisplayTest() device.displayDevice.SetDecodeMode( device.digitNumber) device.displayDevice.SetScanLimit( device.digitNumber) device.displayDevice.StopShutdownMode() -
在每个数字旁边写
blank,这样我们就可以从一个干净的显示屏开始,就像以下代码所示:for i := 1; i < int(device.digitNumber); i++ { device.displayDevice.WriteCommand(byte(i), byte(Blank)) } } -
现在,我们可以定义一个用于无效数字选择的 错误:
var ErrIllegalDigit = errors.New("Invalid digit selected") -
下一步是定义一个名为
SetDigit的函数,该函数将给定的字符设置为给定的数字:func (device *device) SetDigit(digit byte, character Character) error { -
如果我们有一个无效的数字编号,我们需要验证
digit输入并返回一个错误。这是因为我们无法在不存在数字上显示值:if uint8(digit) > device.digitNumber { return ErrIllegalDigit } -
最后一步是将
character写入给定的digit,如下面的代码片段所示:device.displayDevice.WriteCommand( digit, byte(character)) return nil }
这是显示驱动器的完整逻辑。
现在,让我们添加一个小型示例项目来验证我们的代码是否按预期工作。为此,我们在 Chapter05 文件夹内创建一个名为 hs42561k-spi-example 的新文件夹,并创建一个包含空 main 函数的新 main.go 文件。项目结构应如下所示:

图 5.15 – 验证 hs42561k-spi-example 代码的项目结构
现在,我们可以在新的 main.go 文件中添加逻辑。按照以下步骤设置我们的示例程序:
-
首先,我们添加一个包含所有可能字符的
Character数组。以下是我们想要显示的字符:var characters = [17]hs42561k.Character{ hs42561k.Zero, hs42561k.One, hs42561k.Two, hs42561k.Three, hs42561k.Four, hs42561k.Five, hs42561k.Six, hs42561k.Seven, hs42561k.Eight, hs42561k.Nine, hs42561k.Dash, hs42561k.E, hs42561k.H, hs42561k.L, hs42561k.P, hs42561k.Blank, hs42561k.Dot, } -
配置
SPI0接口。SDO是我们的输出引脚,而SCK是我们的时钟引脚。我们以 10 MHz 的frequency发送数据,最 重要 的位首先发送。根据数据表,10 MHz 是 MAX7219 可以处理的最大频率:err := machine.SPI0.Configure(machine.SPIConfig{ SDO: machine.D11, SCK: machine.D13, LSBFirst: false, Frequency: 10000000, }) -
我们检查是否有错误,如果有错误,则打印错误信息。这些信息有助于我们在调试时使用:
if err != nil { println("failed to configure spi:", err.Error()) } -
使用
D6作为加载引脚和machine.SPI0作为 SPI 总线初始化 MAX7219 显示驱动器:displayDriver := max7219spi.NewDevice( machine.D6, machine.SPI0) displayDriver.Configure() -
现在,我们需要用 4 个数字初始化
display。完成此步骤后,显示屏就准备好使用了:display := hs42561k.NewDevice(displayDriver, 4) display.Configure() -
对于
characters中的每个character,将character设置为所有数字,并暂停半秒钟。这样,我们可以测试我们是否能够在每个数字上显示每个可能的字符:for { for _, character := range characters { println("writing", "characterValue:", character.String()) display.SetDigit(4, character) display.SetDigit(3, character) display.SetDigit(2, character) display.SetDigit(1, character) time.Sleep(500 * time.Millisecond) } } }
这是完整的示例程序。我们可以继续使用以下命令来烧录程序:
tinygo flash --target=arduino-nano33 Chapter05/hs42561k-spi-example/main.go
如果一切如预期进行,显示器现在应该开始打印每个可能的字符。
我们现在已经学会了如何控制 7 段显示器,了解了 MAX7219 显示器驱动器,编写了显示器驱动器和显示器的库,还编写了一个示例程序。在下一节中,我们将使用这些库和超声波距离传感器来构建本章的最终项目。
将所有这些组合起来
在本章的最终项目中,我们将利用前面章节所学的所有知识。我们将使用超声波距离传感器来识别传感器附近的手部动作。我们使用 7 段显示器从 20 倒数到 0,并且我们将使用蜂鸣器,为计时器的开始和结束提供额外的信号。在德国,官方建议我们洗手至少 20 秒,这就是为什么我们也会添加一个 20 秒的计时器。将这些全部组合起来,我们将创建一个非接触式洗手计时器。
在我们开始编写控制洗手计时器的代码之前,我们需要添加一个蜂鸣器。我们可以通过以下步骤来添加它:
-
将蜂鸣器的GND引脚连接到D53,并将蜂鸣器的VCC引脚连接到
D54。如果蜂鸣器的引脚太靠近,只需将蜂鸣器插入并相应地连接以下两根线。 -
使用跳线将 Arduino 的GND引脚与面包板上的A53连接。
-
使用跳线将 Arduino 的D5引脚与面包板上的A54连接。
电路应该看起来类似于以下图示:
.


Figure 5.16 – The touchless handwash timer circuit
现在我们已经设置了电路,我们可以继续编写逻辑。我们首先在Chapter05文件夹中创建一个名为touchless-handwash-timer的新文件夹。然后,我们创建一个名为main.go的新文件,其中包含一个空的main函数。项目结构应该如下所示:


Figure 5.17 – The project structure for the handwash timer
现在,在main函数内部按照以下步骤实现非接触式洗手计时器的逻辑:
-
第一步是初始化
SPI0接口,如下所示:err := machine.SPI0.Configure(machine.SPIConfig{ SDO: machine.D11, SCK: machine.D13, LSBFirst: false, Frequency: 10000000, }) -
如果发生错误,我们将打印出来。这样做使我们能够通过监控串行端口输出来调试程序:
if err != nil { println("failed to configure spi:", err.Error()) } -
现在我们想要初始化
display:displayDriver := max7219spi.NewDevice( machine.D6, machine.SPI0) displayDriver.Configure() display := hs42561k.NewDevice(displayDriver, 4) display.Configure() -
完成这些后,我们可以继续初始化
distanceSensor:distanceSensor := hcsr04.NewHCSR04( machine.D2, machine.D3, 60) distanceSensor.Configure() -
现在,我们初始化
buzzer。如果你跳过了第四章,构建植物浇水系统,只需从github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter04/buzzer导入 buzzer 包:buzzer := buzzer.NewBuzzer(machine.D5) buzzer.Configure() -
现在,我们获取并打印
currentDistance。打印距离有助于我们在以后出现问题时调试程序。如下所示:for { currentDistance := distanceSensor.GetDistance() println("current distance:", currentDistance) -
如果
currentDistance在12到25厘米之间,激活计时器。这在上面的代码片段中显示:if currentDistance >= 12 && currentDistance <= 25 { println("timer activated") handleTimer(display, displayDriver, buzzer) } -
现在我们必须暂停
100毫秒。我们这样做是为了防止回声重叠:time.Sleep(100 * time.Millisecond) } -
最后一个函数是
handleTimer函数,它接受display、displayDriver和buzzer作为参数:func handleTimer(display hs42561k.Device, displayDriver max7219spi.Device, buzzer buzzer.Buzzer) { -
首先,我们确保
display处于操作模式:display.Configure() -
现在,我们让
buzzer鸣响两次以指示计时器已启动:buzzer.Beep(100*time.Millisecond, 2) -
然后,我们从
20计数到0。这代表我们的计时器正在运行的 20 秒,如下所示:for i := 20; i > 0; i-- { println("counting:", i) -
如果我们还有超过 10 秒的时间,我们需要设置第三个
digit。因为我们需要设置多个数字,我们将设置数字3和4,如下所示:if i >= 10 { display.SetDigit(3, hs42561k.Character(i/10))此外,我们还需要处理所有带有尾随 0 的数字。这看起来与以下代码片段类似:
if i%10 == 0 { display.SetDigit(4, hs42561k.Character(0)) } else { display.SetDigit(4, hs42561k.Character(i-10)) }现在,我们需要处理所有小于 10 的数字。这在上面的代码片段中实现:
} else { display.SetDigit(3, hs42561k.Blank) display.SetDigit(4, hs42561k.Character(i)) } time.Sleep(time.Second) } -
计时器运行结束后,我们将两个使用的数字重置为
blank:display.SetDigit(3, hs42561k.Blank) display.SetDigit(4, hs42561k.Blank)让
buzzer鸣响半秒钟以指示计时器已完成:buzzer.Beep(500*time.Millisecond, 1) -
将显示驱动器置于关机模式:
displayDriver.StartShutdownMode()
这就是我们需要的所有代码。现在,通过以下命令将代码烧录到 Arduino 上尝试一下:
tinygo flash –target=arduino-nano33 Chapter05/touchless-handwash-timer/main.go
我们已经成功构建并烧录了程序。现在,是时候尝试一下了。
因此,我们将本章构建的所有组件组合成这个最终项目,并使用这些组件来识别传感器前方一定距离内的运动以启动计时器。这是本章的最终项目。
摘要
在本章中,我们学习了 Arduino Nano 33 IoT 的技术规格以及如何计算物体与超声波距离传感器之间的距离。此外,我们还学习了传感器内部的工作原理,并为它编写了库。我们还了解到 TinyGo 支持单元测试,并为超声波距离传感器库编写了一些测试。然后,我们学习了如何使用 MAX7219 串行接口显示驱动器来控制 7 段显示器,并为 MAX7219 和 7 段显示器编写了库。在本章的最后,我们将所有驱动器组合成一个单一的项目,并且只需要添加少量控制逻辑来构建一个非接触式洗手计时器。
在下一章中,我们将学习如何使用 16x02 LCD 和 ST7735 TFT 显示屏。
问题
-
是否可以从 Arduino Nano 33 IoT 上绘制出 5V 输出?
-
为什么在计算到物体的距离时,要将
pulseLength除以 2? -
将代码修改为从 120 开始倒数至 0。使用三位数字来显示剩余的秒数。
第六章:第六章:使用 I2C 和 SPI 接口构建用于通信的显示屏
在上一章中,我们学习了如何使用 7 段显示屏显示数据,MAX7219 芯片的工作原理,超声波距离传感器的工作原理,以及如何编写所有这些的库。我们使用 SPI 接口来完成这些操作。
在完成本章内容后,我们将了解如何使用不同类型的显示屏以及哪些显示屏使用不同的接口进行通信。我们将通过使用可以通过 I2C 总线连接的显示屏来学习 I2C 接口的工作原理。了解这一点后,我们将学习如何读取和解释用户输入。之后,我们将学习如何在显示屏上绘制形状和文本。最后,我们将学习如何构建一个可以在微控制器上运行的游戏。有了这些知识,我们将能够理解使用各种显示屏进行通信的整体概念。
在本章中,我们将涵盖以下主要主题:
-
探索 TinyGo 驱动程序
-
在 16x2 LCD 显示屏上显示文本
-
在显示屏上显示用户输入
-
构建命令行界面
-
显示简单游戏
技术要求
我们将需要以下组件来完成此项目:
-
Arduino Nano 33 IoT
-
配备 I2C 接口的 HD44780 1602 LCD 显示屏
-
ST7735 显示屏
-
1 个面包板
-
1 个 10k 欧姆电阻
-
1 个 4 针按钮
-
跳线
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter06
本章的“代码在行动”视频可以在以下链接找到:bit.ly/2Qo8Jji
探索 TinyGo 驱动程序
在第三章“使用键盘构建安全锁”,我们学习了 TinyGo 驱动程序存储库。让我们简要了解一下如何在存储库中查找驱动程序和示例。
当你计划一个新的项目时,检查驱动程序存储库是否为你计划使用的设备提供了驱动程序总是好的。这将加快你的项目进度,并使其更容易实现。
驱动程序存储库分为两部分:
-
驱动程序
-
示例
驱动程序直接位于存储库的根目录下。所有示例都位于一个示例文件夹中。
我们想在示例中使用具有 I2C 接口的 hd44780 LCD 显示屏,因此让我们检查是否可以在驱动程序存储库中找到它。参考以下截图:


图 6.1 – hd44780i2c 驱动程序
如我们所见,该软件包以它所使用的设备和接口(I2C)命名。有时,一个驱动程序包在一个包中提供多个接口供使用。大多数驱动程序在名称中省略了额外的接口。
要找到显示如何使用包的示例代码,请导航到examples文件夹,并查找与驱动程序包名称完全相同的文件夹。以下屏幕截图显示了hd47780i2c驱动程序的示例代码:

图 6.2 – hd44780i2c 驱动器示例
现在我们已经知道有一个用于我们想要使用的显示屏的驱动程序,并且知道在哪里可以找到该驱动程序的示例代码,让我们继续并使用该驱动程序。
在 HD44780 16x2 LCD 显示屏上显示文字
HD44780 16x2 LCD 显示屏便宜且易于使用。如果我们只想显示文字,这种类型的显示屏可以做到这一点,并且是首选设备。它有 16 个引脚,如果我们想在项目中结合更多设备,这会太多。这就是为什么使用 I2C 显示屏驱动器来控制显示屏是一种相当常见的做法。这与我们在上一章中使用 MAX7219 驱动 7 段显示屏的概念类似。
HD44780 16x2 显示屏可以与焊接在其上的 I2C 驱动器一起购买,或者它可以不带有 I2C 驱动器。显示屏可能有不同的颜色配置,涉及背景和文字颜色。它们通常看起来与以下图像中的类似:

图 6.3 – HD44780 正面
当显示屏带有 I2C 驱动器时,通常是一个 LCM1602 IIC,它提供四个端口:
-
GND
-
VCC
-
SDA
-
SCL
因此,当使用 LCM1602 时,我们只需要将GND和VCC连接到电源总线;其余的两根线用于SDA和SCL。LCM1602 IIC 板上有一个电位器,可以用来调整显示屏的对比度。以下图像显示了这样一个 LCM1602 IIC,它已经被焊接在 HD44780 的背面:

图 6.4 – LCM1602 IIC 焊接在 HD44780 的背面
注意
大多数 HD47780 显示屏在 5V 下运行,但有些只需要 3.3V。因此,仔细检查你显示屏的数据表,以防止可能的损坏!
现在,我们对 HD44780 有了初步的了解,并且可以利用 LCM1602 IIC 来节省一些引脚。现在,让我们继续并构建电路。
构建电路
在我们可以在显示屏上显示任何内容之前,我们需要构建电路。只需按照以下步骤操作即可:
-
确保电源跳线位于 5V。如果可能你有 3.3V 的显示屏,那么将跳线设置为 3.3V。
-
将显示屏的GND引脚连接到电源总线上的GND线路。
-
将显示屏的VCC引脚连接到电源总线上的VCC线路。
-
将A14连接到面包板(GND)与电源总线上的GND线路。
-
将A9连接到面包板(SCL)与显示屏的SCL引脚。
-
将A8连接到面包板(SDA)与显示屏的SDA引脚。
电路现在应该看起来类似于以下图像:

图 6.5 – 16x02 I2C 显示电路(图片来自 Fritzing)
注意
16x02 I2C LCD Fritzing 组件已从以下链接获取:
github.com/e-radionicacom/e-radionica.com-Fritzing-Library-parts-。
这是我们设置硬件设备所需的所有内容。然而,在我们开始编写代码之前,我们需要了解 I2C。
理解 I2C
I2C 是一种同步双线串行总线,其中所用的数据线是双向的。有时,I2C也被称为双线接口(TWI)。一根线用于提供时钟,而另一根线用于传输数据。
I2C 总线允许多个设备在同一总线上进行通信。与外围接口(SPI)总线不同,I2C 总线不需要芯片选择(CS)引脚;相反,它只需在消息中包含接收设备的地址。
一个 I2C 消息包含以下部分:
-
启动条件:启动条件表示正在发送一条新消息。
-
地址帧:地址帧包含应接收消息的设备的地址。
-
读/写位:此位用于指示数据是否从控制器发送到设备,或者是否从设备请求数据。
-
ACK/NACK 位:接收设备会通知发送方之前的数据帧是否已成功接收。
-
数据帧:一个消息可以包含 1 到 n 个 8 位的数据帧。
-
停止条件:停止条件表示消息已完全发送。
以下图像可视化了一个包含 16 位数据的消息:

图 6.6 – I2C 消息
注意
如果您不知道要使用的设备的地址,您可以通过迭代所有可能的地址并检查设备是否在地址上发送 ACK 来使用 ACK 位。如果是这样,您就找到了地址。
现在我们对 I2C 是什么以及它是如何工作的有了初步的了解,我们可以编写第一个使用 I2C 控制显示的程序。
编写代码
我们将首先在我们的项目中创建一个名为Chapter06的新文件夹。在Chapter06文件夹内,创建一个名为hd44780-text-display的新文件夹,并在其中创建一个包含空main函数的新main.go文件。现在,项目结构应如下所示:

图 6.7 – 项目结构
现在,按照以下步骤显示第一段文本:
-
导入驱动程序:
"tinygo.org/x/drivers/hd44780i2c" -
在
main函数内部,配置 I2C 接口并将时钟的频率设置为400KHz:machine.I2C0.Configure(machine.I2CConfig{ Frequency: machine.TWI_FREQ_400KHZ, }) -
创建一个新的
hd44780i2c实例,并将I2C接口以及address作为参数传递。大多数 LCM1602 IIC 应该监听在0x27地址,但有些模块监听在0x3F:lcd := hd44780i2c.New(machine.I2C0, 0x27) -
通过设置列(宽度)和行(高度)来配置显示屏。我们需要这样做,因为这个驱动器也支持 20x4 和其他类型的显示屏:
lcd.Configure(hd44780i2c.Config{ Width: 16, Height: 2, }) -
打印文本。
\n被驱动器解释,所有跟在\n后面的字符都被写入下一行。我们可以用以下代码做到这一点:lcd.Print([]byte(" Hello World \n LCD 16x02")) -
现在,让我们通过闪烁代码来测试它。使用以下命令:
tinygo flash --target=arduino-nano33 Chapter6/hd44780-text-display/main.go
你现在应该能看到屏幕上打印的文本。
让我们看看当我们尝试在屏幕上打印超过 16x2 个字符时会发生什么。要做到这一点,只需将以下片段添加到我们的 main 函数的末尾:
time.Sleep(5 * time.Second)
lcd.Print([]byte("We just print more text, to see what
happens, when we overflow the 16x2 character limit"))
现在,再次闪烁程序并查看结果。我们可以观察到,在达到第 32 个字符后,光标跳回到位置 x = 0 和 y = 0,并从这里继续打印。然而,我们希望在显示屏上打印超过 32 个字符,并且我们希望能够阅读所有这些字符。为了做到这一点,我们必须创建一个小动画。执行以下步骤:
-
在
main函数的末尾,暂停5秒并调用animation函数,并将lcd作为参数传递,如下面的代码片段所示:time.Sleep(5 * time.Second) animation(lcd) -
我们需要定义一个名为
animation的函数,它接受lcd作为参数:func animation(lcd hd44780i2c.Device) { -
现在,我们需要定义我们想要打印的文本:
text := []byte(" Hello World \n Sent by \n Arduino Nano \n 33 IoT \n powered by \n TinyGo") -
我们必须清除显示屏以移除之前打印的所有内容。这也将光标重置到第一个位置(0,0):
lcd.ClearDisplay() -
现在,让我们打印一个单个字符。在这里我们需要进行一些类型转换,因为显示驱动器只接受
[]byte作为参数。为此,请参考以下代码:for { for i := range text { lcd.Print([]byte(string(text[i]))) time.Sleep(150 * time.Millisecond) } -
当消息完全写入显示屏后,我们暂停
2秒并再次清除显示屏。这为下一次迭代提供了一个干净的开始:time.Sleep(2 * time.Second) lcd.ClearDisplay() } }
现在,再次闪烁更新后的程序。字符应该会依次出现。
现在我们已经了解了如何使用显示驱动器打印硬编码的文本以及如何创建简单的动画,让我们显示一些动态接收到的文本。
在显示屏上显示用户输入
在本节中,我们将打印用户的输入到显示屏上。输入是通过计算机发送到微控制器的 串行(UART),然后将其打印到显示屏上。
在 第二章 中,构建交通灯控制系统,我们学习了如何使用 UART 向计算机发送消息,并使用 PuTTY 观察它们。现在,我们将使用这个接口进行双向通信。对于这个项目,我们使用与上一节相同的硬件设置,这意味着我们可以直接进入代码。
首先,在 Chapter06 文件夹内创建一个名为 hd44780-user-input 的新文件夹。然后,在这个新创建的文件夹内,添加一个包含空 main() 函数的 main.go 文件。现在,项目的结构应该类似于以下内容:

图 6.8 – 项目结构
按照以下步骤实现程序:
-
将
carriageReturn的十六进制值保存为一个常量。稍后,我们将检查接收到的字节是否等于这个carriageReturn值:const carriageReturn = 0x0D -
将
uart接口保存在一个变量中,这样我们就不必每次都输入machine.UART0:var ( uart = machine.UART0 ) -
在
main函数中,首先初始化显示屏驱动程序:machine.I2C0.Configure(machine.I2CConfig{ Frequency: machine.TWI_FREQ_400KHZ, }) lcd := hd44780i2c.New(machine.I2C0, 0x27) // some // modules have address 0x3F err := lcd.Configure(hd44780i2c.Config{ Width: 16, // required Height: 2, // required CursorOn: false, CursorBlink: false, }) if err != nil { println("failed to configure display") } -
让用户知道我们可以输入一些内容,然后将其打印到显示屏上:
lcd.Print([]byte(" Type to print ")) -
我们希望在接收到第一个输入后立即清除显示屏。这就是为什么我们保存这个状态:
hadInput := false -
如果缓冲区中没有数据,我们不想做任何事情。TinyGo 使用环形缓冲区内部缓冲传入的数据:
for { if uart.Buffered() == 0 { continue } -
如果遇到第一个输入,我们必须清除显示屏并保存我们之前输入的状态:
if !hadInput { hadInput = true lcd.ClearDisplay() } -
接下来,我们从缓冲区读取一个字节,并记录任何可能的错误:
data, err := uart.ReadByte() if err != nil { println(err.Error()) } -
如果接收到
carriageReturn,例如用户按下了 Enter 键,我们还想在新的一行打印。我们将该字符打印到显示屏以及uart,以便 PuTTY 的输出和显示屏上的输出行为相似:if data == carriageReturn { lcd.Print([]byte("\n")) uart.Write([]byte("\r\n")) continue } -
最后一步是将数据简单地打印到两个输出上:
lcd.Print([]byte{data}) uart.WriteByte(data) } }
现在,我们可以从连接到微控制器的计算机接收数据并将其打印到显示屏上。通过以下命令将程序烧录到微控制器来尝试它:
tinygo flash –target=arduino-nano33 Chapter06/hd44780-user-input/main.go
现在,启动 PuTTy,连接到 microcontroller 配置文件,并开始输入以检查程序是否运行正确。如果一切正常,PuTTY 应该也会打印出你所写的内容,类似于以下截图所示:

图 6.9 – PuTTY 输出
UART 接口是一个串行接口,这意味着它也可以用于在两个微控制器之间发送和接收数据。在 Arduino Nano 33 IoT 上,发送(TX)引脚用于发送数据,而接收(RX)引脚用于接收数据。
在本节中,我们学习了如何从 UART 接口读取和解释单个字节,以及如何在不使用 print() 或 println() 函数的情况下手动将数据发送回 UART 接口。我们将在下一节中使用这些知识来学习如何解释更长的数据字符串。
构建一个 CLI
在本节中,我们将解析用户的输入,并将输入与预定义的命令进行比较。然后,这些命令将由微控制器执行。对于这个项目,我们将使用与上一个项目相同的硬件设置。
我们将首先在 Chapter06 文件夹内创建一个名为 hd44780-cli 的新文件夹。然后,我们必须创建一个包含空 main 函数的 main.go 文件。现在,项目的结构应该类似于以下内容:

图 6.10 – 项目结构
现在项目结构已经设置好,我们可以实现逻辑。要做到这一点,请按照以下步骤操作:
-
在
main函数上方,首先定义一些常量。commandConstant代表需要发送到微控制器的命令。我们将在代码中使用这些常量,并将它们与用户输入进行比较,以确定是否输入了 CLI 命令:const ( carriageReturn = 0x0D homeCommand = "#home" clearCommand = "#clear" ) -
将 UART 接口保存在一个变量中。我们也可以始终使用
machine.UART0来写,但通过这种方式,我们可以提高可读性:var ( uart = machine.UART0 ) -
在
main函数内部,我们初始化显示屏,如下所示:machine.I2C0.Configure(machine.I2CConfig{ Frequency: machine.TWI_FREQ_400KHZ, }) lcd := hd44780i2c.New(machine.I2C0, 0x27) err := lcd.Configure(hd44780i2c.Config{ Width: 16, Height: 2, CursorOn: false, CursorBlink: false, }) if err != nil { println("failed to configure display") } -
现在,让我们调用
homeScreen函数(我们将在实现它之后解释这个函数的功能):homeScreen(lcd) -
接下来,定义一个
commandBuffer。这是一个简单的字符串,我们用它来存储命令的部分:var commandBuffer string -
commandIndex正在被用来计算commandBuffer内部的字符数。如果索引大于最长命令的长度,那么我们知道我们可以重置缓冲区:var commandIndex uint8 -
我们将使用
commandStart布尔值作为信号,因此我们需要将后续的任何字符追加到commandBuffer中:commandStart := false -
就像在先前的项目中一样,我们将使用
hadInput标志在接收到第一个输入时清除屏幕:hadInput := false -
如果内部接收缓冲区中没有字符,我们不需要做任何事情:
for { if uart.Buffered() == 0 { continue } -
在收到第一个输入后,清除显示屏。我们将在实现它之前,在接下来的几步中解释
clearDisplay函数:if !hadInput { hadInput = true clearDisplay(lcd) } -
然后,我们从缓冲区中读取一个字节,如下所示:
data, err := uart.ReadByte() if err != nil { println(err.Error()) } -
检查我们是否收到了 井号 (#)。这是表示将跟随一个命令的指示器:
if string(data) == "#" { commandStart = true uart.Write([]byte("\ncommand started\n")) } -
当我们收到命令的开始时,我们将所有后续字符追加到
commandBuffer中。这是按照以下方式完成的:if commandStart { commandBuffer += string(data) commandIndex++ } -
要检查
commandBuffer中是否可能有一个完整的命令,我们必须切换我们的commandBuffer:switch commandBuffer { -
如果
commandBuffer的内容等于homeCommand,我们执行homeScreen函数并重置命令。我们还必须在 UART 接口中写回输入数据:case homeCommand: uart.WriteByte(data) homeScreen(lcd) commandStart = false commandIndex = 0 commandBuffer = "" continue -
如果
commandBuffer的内容等于clearCommand,我们必须执行clearDisplay函数并重置命令:case clearCommand: uart.WriteByte(data) clearDisplay(lcd) commandStart = false commandIndex = 0 commandBuffer = "" continue } -
如果
commandIndex大于我们最长命令的长度,我们必须重置命令:if commandIndex > 5 { commandStart = false commandIndex = 0 commandBuffer = "" uart.Write([]byte("\nresetting command state\n")) } -
如果我们收到一个
carriageReturn,我们必须打印一个新行:if data == carriageReturn { lcd.Print([]byte("\n")) uart.Write([]byte("\r\n")) continue } -
然后,我们打印接收到的数据,如下所示:
lcd.Print([]byte{data}) uart.WriteByte(data) } -
现在,定义
homeScreen函数,当输入匹配homeScreen命令时会被调用。我们必须清除显示屏并再次打印第一个输入:func homeScreen(lcd hd44780i2c.Device) { println("\nexecuting command homescreen\n") clearDisplay(lcd) lcd.Print([]byte(" TinyGo UART \n CLI ")) } -
现在,定义
clearDisplay函数,当输入匹配clearDisplay命令时调用该函数。我们在这里只是使用了显示器的ClearDisplay函数:func clearDisplay(lcd hd44780i2c.Device) { println("\nexecuting command cleardisplay\n") lcd.ClearDisplay() }
现在,使用以下命令烧录程序:
tinygo flash –target=arduino-nano33 Chapter06/hd44780-cli
现在,让我们尝试我们的程序。
启动 PuTTY 并选择微控制器配置文件。输入一些内容并使用我们在代码中定义的 #home 和 #clear 命令。现在 PuTTY 的输出应该类似于以下内容:

图 6.11 – PuTTY 中的 CLI 输出
有了这个,我们已经验证了程序按预期工作。这样的系统可以用来用另一个微控制器控制微控制器,而不仅仅是显示某些内容——它还可以用来请求传感器读数或触发其他事情。
在本节中,我们学习了如何一次解释多个输入字符,以及如何设置简单的 CLI 以执行通过 UART 发送的命令。在下一节中,我们将更深入地了解 SPI,因为我们将在最终项目中使用 SPI 驱动的显示屏。
理解 SPI
SPI 是一个具有控制器和一个或多个设备的总线系统。控制器选择一个设备,该设备应向控制器发送数据,或者将从控制器接收数据。
SPI 总线上的设备也可以级联在一起。级联是一种将多个设备排成一行的布线方案。
两个设备之间的 SPI 通信使用以下四个引脚:
-
CS: 芯片选择选择总线上应接收或发送数据的设备。
-
CLK: 时钟设置传输(DO)和接收(DI)线的频率。
-
DO: 数据输出或数字输出将数据传输到接收设备。
-
DI: 数据输入或数字输入从控制器接收数据。
以下图显示了 SPI 控制器和 SPI 设备的一对一连接:

图 6.12 – SPI 通信
以下图显示了控制器和两个设备之间的 SPI 连接。在这里,我们使用两个 CS 引脚来向接收设备发送信号。这是控制器正在与之通信的设备:

图 6.13 – 控制器和两个设备之间的 SPI 通信
以下图显示了设备如何级联在一起。第一个设备的 DO 引脚连接到下一个设备的 DI 引脚,同时它们共享 CLK 和 CS 线:

图 6.14 – 与级联设备的 SPI 通信
现在我们对 SPI 有更好的理解,让我们使用 ST7735 显示屏构建一个电路。
显示简单游戏
在本节中,我们将学习如何使用 SPI 接口来使用另一种显示类型。由于我们想要显示不仅仅是纯文本,我们需要一种新的显示类型。我们还将发现两个额外的 TinyGo 仓库,它们提供了在处理显示时方便的功能。在本节中我们将使用的显示设备是一款 1.8 英寸的 TFT ST7735 显示器,分辨率为 160x128 像素。因此,让我们简要了解一下该显示器的技术规格。
ST7735 显示器提供了一个可选的 SD 卡槽。该显示器在 TFT-LCD 模块上具有 262K 色彩深度。正在使用 SPI 接口。要在显示器上绘制内容,我们需要八个引脚。我们已经使用了 SPI,但因为我们没有深入探讨它,因为设备可以以不同的方式排列在 SPI 总线上。所以,在我们使用示例项目中的显示器之前,让我们更好地了解 SPI 的工作原理。
构建电路
与之前的项目一样,我们将使用外部电源。我们还需要一个 ST7735 显示器、Arduino Nano 33 IoT 和一些跳线。为了正确设置一切,请按照以下步骤操作:
-
将电源总线上的 GND 通道连接到面包板上的 J50 (GND) 引脚。
-
将面包板上的 E31 (LED) 引脚连接到面包板上的 A53 (D2) 引脚。
-
将面包板上的 E32 (SCK) 引脚连接到面包板上的 J63 (D13) 引脚。
-
将面包板上的 E33 (SDA) 引脚连接到面包板上的 A62 (D11) 引脚。
-
将面包板上的 E34 (AO) 引脚连接到面包板上的 A56 (D5) 引脚。
-
将面包板上的 E35 (RESET) 引脚连接到面包板上的 A57 (D6) 引脚。
-
将面包板上的 E36 (CS) 引脚连接到面包板上的 A58 (D7) 引脚。
-
将电源总线上的 GND 通道连接到面包板上的 E37 (GND) 引脚。
-
将电源总线上的 VCC 通道连接到面包板上的 E38 (VCC) 引脚。
-
将 ST7735 显示器放置,使 LED 引脚位于 A31,VCC 引脚位于 A37。
这是我们需要连接到显示器的所有内容。现在设置应该看起来如下:

图 6.15 – ST7735 电路
注意
1.8 英寸 TFT 显示器 Fritzing 部分由 vanepp 制作:forum.fritzing.org/u/vanepp。
现在我们已经设置了硬件,让我们实现一些逻辑。
使用 ST7735 显示器
TinyGo 为 ST7735 显示器提供了驱动程序。这意味着我们可以使用现有的驱动程序。此外,TinyGo 还提供了两个额外的包,名为 TinyFont 和 TinyDraw,我们都会使用。首先,让我们看看 TinyDraw 包。
TinyDraw 是位于 GitHub 上 TinyGo 组织内部的一个仓库。您可以在 github.com/tinygo-org/tinydraw 找到它。
TinyDraw仍然处于早期阶段,这意味着它尚未针对性能或内存使用进行优化。然而,它提供了有用的功能,例如绘制矩形、圆形、填充矩形和填充圆形等。由于显示驱动程序的 API 几乎(或完全)相同,它与大多数接口驱动程序兼容。现在,在我们看到它的实际应用之前,让我们先看看TinyFont包。
就像TinyDraw一样,TinyFont是 GitHub 上 TinyGo 组织内部的一个仓库。您可以在github.com/tinygo-org/tinyfont找到它。
TinyFont提供了一个 API,允许您使用TinyFont包中包含的字体在显示上绘制文本。它还允许您创建自己的自定义字体。TinyFont还利用了大多数 TinyGo 显示驱动程序共享相同接口的事实。
现在,让我们设置一个使用 ST7735、TinyDraw 和 TinyFont 的项目。为此,在Chapter06文件夹内创建一个名为st7735的新文件夹,并在其中创建一个包含空main()函数的新main.go文件。现在,项目的结构应该类似于以下内容:

图 6.18 – 测试程序的输出结果
在本节中,我们学习了如何在显示屏上绘制基本形状和写入文本。下一步合乎逻辑的是编写一个在微控制器上运行的程序。
开发一个游戏
在本节中,我们将开发一个非常简单的游戏,该游戏由一个代表敌人的红色方块组成,它试图到达屏幕的末端。一条绿色线将代表我们的家园区域,红色方块不应该穿越。我们还将有一个代表玩家的绿色方块,以及一个代表我们可以射击以阻止红色方块入侵我们家园区域的子弹的较小的绿色方块。我们将添加一个按钮,它将充当触发器并射击小绿色方块。因此,合乎逻辑的第一步是将按钮添加到我们的面包板上。为此,请按照以下步骤操作:
-
将按钮放置在面包板上,使一个引脚位于E23,其他引脚位于一边的E25和另一边的F25和F23。
-
将 Arduino 的+3V3输出连接到面包板上的J23。
-
使用一个10K 欧姆电阻将电源总线上的GND线路连接到J25。
-
将D25连接到面包板上的A60 (D9)。
这是我们需要添加到电路中的所有内容。现在它应该看起来如下所示:

图 6.19 – 带按钮的最终电路
现在,为本章的最后一个项目创建一个新的文件夹。将文件夹命名为tinygame,并将其放入Chapter06文件夹中。然后,创建一个包含空main()函数的新main.go文件。现在,项目的结构应该如下所示:

图 6.20 – 项目结构
要实现逻辑,请按照以下步骤操作:
-
添加一个
bool变量来保存buttonPressed状态。我们将全局定义它,因此我们不需要使用通道或其他方式在将要使用的 goroutines 之间传递状态。这只是一个简单方便的方法:var buttonPressed bool -
定义
enemySize、bulletSize以及游戏区域的像素宽度和高度属性:const enemySize = 8 const bulletSize = 4 const width = 128 const height = 160 -
添加两个变量来存储我们的
currentScore和highscore:var highscore int = 0 var currentScore int = 0 -
定义我们将要使用的颜色集:
var ( white = color.RGBA{255, 255, 255, 255} red = color.RGBA{255, 0, 0, 255} blue = color.RGBA{0, 0, 255, 255} green = color.RGBA{0, 255, 0, 255} black = color.RGBA{0, 0, 0, 255} ) -
现在,我们需要将代码移动到
main函数内部。在这里,分配buttonPin并将其配置为输入:buttonPin := machine.D9 buttonPin.Configure(machine.PinConfig{Mode: machine.PinInput}) -
在启动阶段更新
highscore。在这里,highscore是0:updateHighscore(0) -
初始化显示,如下所示:
machine.SPI0.Configure(machine.SPIConfig{ Frequency: 12000000, }) resetPin := machine.D6 dcPin := machine.D5 csPin := machine.D7 backLightPin := machine.D2 display := st7735.New(machine.SPI0, resetPin, dcPin, csPin, backLightPin) display.Configure(st7735.Config{}) -
在一个新的 goroutine 中运行
checkButton函数,使其非阻塞。这使我们能够在maingoroutine 中更新游戏循环:go checkButton(buttonPin) -
无限循环并在每轮游戏结束后用黑色填充屏幕以擦除屏幕上的所有内容:
for { display.FillScreen(black) updateGame(display) } -
无限循环并检查按钮的状态。如果按钮被按下,我们更新
buttonPressed状态。每次检查后,我们休眠20毫秒,因为我们需要一个阻塞调用,以便调度器可以再次处理其他 goroutines:func checkButton(buttonPin machine.Pin) { for { if buttonPin.Get() { buttonPressed = true } time.Sleep(20 * time.Millisecond) } } -
updateHighscore函数接受一个score,检查这个新的score是否大于highscore,如果是,则更新highscore并将highscore打印到串行端口:func updateHighscore(score int) { if score <= highscore && score != 0 { return } highscore = score println(fmt.Sprintf(" TinyInvader Highscore: %d", highscore)) }
这样,我们就实现了按钮按下的检查,一个更新highscore的函数,以及一个在游戏结束后立即开始新一轮游戏的main goroutine。现在,让我们实现实际的游戏逻辑。为此,请遵循以下步骤:
-
通常,最好将游戏物理的更新(如玩家的移动、子弹和敌人)与动画分开,分别放在逻辑的不同部分。当为其他平台开发游戏时,这两部分将独立更新,这样它们就不依赖于相同的帧率。然而,为了简化,我们将有一个单独的游戏循环,它不仅更新位置,还将绘制到屏幕上。
updateGame函数代表游戏的主要逻辑:func updateGame(display st7735.Device) { -
定义一些将存储敌人位置的变量:
var enemyPosX, enemyPosY int16 -
为了防止敌人从游戏区域上方开始,我们必须减去其大小:
enemyPosY = height - enemySize -
接下来,我们需要在变量中存储子弹的位置:
var bulletPosY int16 -
我们在一个布尔变量中存储是否发射了射击的状态:
shotFired := false -
我们在一个布尔变量中存储是否可以发射新射击的状态。我们将其初始化为
true,因为我们希望在游戏开始时玩家能够发射射击:canFire := true -
游戏刚刚开始,所以
currentScore是0:currentScore = 0 -
如果按钮被按下,我们将重置
buttonPressed状态,因为我们将会处理它。只要子弹仍在游戏区域内飞行,我们就不能再次射击:for { if buttonPressed { buttonPressed = false if canFire { shotFired = true canFire = false } } -
如果已经发射了射击,我们更新子弹:
if shotFired { -
这里,我们更新位置并绘制它:
bulletPosY = updateBullet(display, bulletPosY)如果子弹离开游戏区域,我们将重置其位置并重置
shotFired和canFire状态。这允许玩家再次射击:if bulletPosY > height { shotFired = false canFire = true bulletPosY = 0 } -
接下来,我们检查子弹是否在水平轴上与敌人相撞。为此,我们使用一个比子弹本身稍大的碰撞盒:
if enemyPosX >= 54 && enemyPosX <= 64 { -
现在,我们检查垂直轴上的碰撞。这次,碰撞盒的大小与
bulletSize相同。这些碰撞盒在我的测试中证明效果相当不错:if enemyPosY >= bulletPosY && enemyPosY <= bulletPosY+bulletSize { -
如果我们击中敌人,我们增加分数:
currentScore++ -
现在,我们必须在敌人上方画一个黑色盒子,让它消失:
display.FillRectangle(enemyPosX-1, enemyPosY, enemySize, enemySize, black) -
重置敌人的位置。这将使敌人在其出生位置重生:
enemyPosY = height - enemySize enemyPosX = 0 -
更新
highscore,如下所示:updateHighscore(currentScore) } } } -
更新并绘制敌人的位置:
enemyPosX, enemyPosY = updateEnemy(display, enemyPosX, enemyPosY) -
如果敌人通过了我们的主区域,我们就输了。如果发生这种情况,我们就返回,这样函数外部的循环就可以再次运行并开始新的一局:
if enemyPosY < enemySize { return } -
绘制主区域:
display.FillRectangle(0, 4, width, 1, green) -
绘制玩家:
display.FillRectangle(58, 0, 6, 6, green) -
睡眠
12毫秒。如果我们不在这里睡眠,敌人和子弹会在屏幕上移动得太快,看起来会闪烁,这看起来不太好。所以,我们使用这个小技巧来减慢速度并减少闪烁:time.Sleep(12 * time.Millisecond) } }现在我们已经实现了主要游戏逻辑,我们只需要在玩游戏之前创建更新子弹和敌人的逻辑。
通过在 y 轴上增加 2 来更新子弹的位置。在其后面画一个黑色盒子,这样它就不会在显示屏上留下痕迹:
func updateBullet(display st7735.Device, posY int16) int16 { display.FillRectangle(58, posY-2, bulletSize, 2, black) display.FillRectangle(58, posY, bulletSize, bulletSize, green) return posY + 2 }
我们最后需要做的就是更新敌人。为此,遵循以下最后几个步骤:
-
首先,我们必须定义我们将用来清除敌人之前位置的矩形的坐标和宽度:
func updateEnemy(display st7735.Device, posX, posY int16) (int16, int16) { var clearX, clearY, clearWidth int16 -
现在,我们必须计算我们需要清除敌人的位置:
clearX = posX - 1 clearY = posY clearWidth = 1 -
如果敌人到达左侧,我们需要完全移除其矩形,因为敌人将在屏幕的另一侧再次重生:
if posX == 0 { clearY = posY + enemySize clearX = width – enemySize clearWidth = enemySize } -
现在,我们必须清除敌人并绘制敌人到其新的位置。我们必须这样做以防止敌人在显示屏上留下痕迹:
display.FillRectangle(clearX, clearY, clearWidth, enemySize, black) display.FillRectangle(posX, posY, enemySize, enemySize, red) -
更新敌人在 x 轴上的位置:
posX++ -
如果敌人在 x 轴上到达屏幕边缘,它们也会在 y 轴上移动:
if posX > width-enemySize { posX = 0 posY -= enemySize } -
返回新的位置:
return posX, posY }
这就是这个游戏所需的所有逻辑。现在,让我们来玩一玩。使用以下命令闪存程序:
tinygo flash –target=arduino-nano33 Chapter06/tinygame/main.go
注意
我们不需要在这里指定一个调度器,因为默认情况下atsamd21不会禁用调度器。
一旦你玩了几轮游戏,你就可以开始考虑如何扩展游戏:
-
我们可以再添加两个按钮,以便我们可以左右移动玩家。
-
我们可以让玩家一次射击多个子弹。
-
敌人的移动可以是随机的,这样它们就不总是从右向左移动。
-
我们可以添加一个摇杆来控制玩家的位置。
-
可以生成多个敌人。
-
敌人可以掉落不同类型的道具,玩家可以捡起这些道具。
-
我们可以添加一个蜂鸣器来为游戏添加声音。
-
我们可以在每一轮结束时显示最高分。
这是开始深入探索#tinygame、#tinygo和#packtbookgame世界之前的最后一章,也请别忘了使用@Nooby_Games标签提及我。当然,你还可以在其他所有社交媒体渠道、博客等上分享你的游戏。你还可以在本书的 GitHub 仓库中打开一个 issue 来展示你的成果。这样,我也可以测试你的游戏。
摘要
在本章中,我们学习了 I2C 接口是什么以及如何使用它。我们还学习了如何使用 16x02 LCD 显示屏,如何显示静态文本,如何显示动画,以及如何构建一个小型 CLI(命令行界面),该 CLI 可以通过 UART 接收命令并控制显示屏。
然后,我们对 SPI 接口有了更深入的了解,并使用它来控制 1.8 英寸 TFT 显示屏。我们绘制了一些基本形状,然后使用TinyDraw绘制圆形和矩形,使用TinyFont绘制文本。到此为止,我们已经使用了微控制器的重要接口,因此我们现在有了在未来的项目中连接和控制任何所需设备的技能。
在本章的最后,我们利用本章学到的知识构建了一个简单的游戏,该游戏由一个按钮控制,并在 1.8 英寸 TFT 显示屏上显示。
在下一章,我们将学习如何使用 TinyGo 构建WebAssembly页面,以及如何使用集成在 Arduino Nano 33 IoT 板上的Wi-Fi 芯片。
问题
-
监听 I2C 总线的设备是如何知道消息是针对该设备的?
-
监听 SPI 总线的设备是如何知道消息是针对该设备的?
第七章:第七章:在 TinyGo Wasm 仪表板上显示天气警报
我们已经学习了如何使用不同类型的显示器来显示数据,这些显示器通过互集成电路(I2C)协议或串行外设接口(SPI)连接。在这个过程中,我们深入了解了 SPI 的工作原理,通过学习得知多个设备可以监听 SPI 总线,并且我们可以在总线上级联设备。此外,我们还构建了一个命令行界面(CLI),它可以解释通过串行发送的命令,并根据输入执行相应的功能。
在完成本章内容后,你将熟悉使用消息队列遥测传输(MQTT),通过 Web 服务器提供WebAssembly(Wasm)页面,如何设置本地 MQTT 代理,以及如何使用 Arduino Nano 33 IoT 板的 Wi-Fi 功能。
在本章中,我们将涵盖以下主要主题:
-
构建一个气象站
-
向代理发送 MQTT 消息
-
介绍 Wasm
-
在 Wasm 页面上显示传感器数据和天气警报
在本章结束时,你将了解如何利用 MQTT 代理从你的微控制器通过 Wi-Fi 发送消息。你还将了解如何在 Wasm 应用程序内部订阅 MQTT 消息,以及如何显示作为 MQTT 消息有效载荷发送的数据。
技术要求
以下软件需要安装:
- Docker——你可以通过以下链接找到安装指南:
docs.docker.com/engine/install/
我们将需要以下组件来完成这个项目:
-
一个 Arduino Nano 33 IoT 板
-
一个外部电源(5 伏(5V))
-
一个 BME280 传感器(I2C)
-
一个 ST7735 显示器
-
一个面包板
-
跳线
你可以在以下链接找到本章的代码:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter07
本章的“代码在行动”视频可以在以下链接找到:bit.ly/3dXGe4o
构建一个气象站
我们将通过构建一个气象站来开始我们的物联网(IoT)和 Wasm 世界的旅程。在本章的第一个项目中,我们将构建一个程序,在ST7735显示器上显示天气数据。我们将构建一些可重用的组件,这些组件将在本章的最终项目中使用。我们将学习如何使用能够感知空气压力、温度和湿度的BME280传感器——这些是记录天气变化所需的元素。但首先,我们需要组装一个电路——所以,让我们看看它是如何工作的。
组装电路
在我们能够读取和显示传感器数据之前,我们需要组装电路。我们将使用 I2C 接口连接 BME/BMP280 传感器,并使用 SPI 接口连接 ST7735 显示屏。为此,请执行以下步骤:
-
将带有串行数据引脚(SDA)的BME/BMP280传感器放置在F21。
-
使用跳线将引脚H21(SDA)与面包板上的J56(SDA)引脚连接。
-
使用跳线将引脚I22 串行时钟(SCL)与面包板上的I55(SCL)引脚连接。
-
将J23地(GND)与电源总线上的GND*通道连接。
-
将J24(VIN)与电源总线上的电压公共收集器(VCC)通道连接。
-
将带有发光二极管(LED)引脚的显示屏放置在面包板上的A31引脚。
-
使用跳线将E31(LED)与面包板上的A53(D2)引脚连接。
-
使用跳线将E32(SCK)与面包板上的A54(D13)引脚连接。
-
使用跳线将E33(SDA)与面包板上的A62(D11)引脚连接。
-
使用跳线将E34模拟引脚(AO)与面包板上的A56*引脚连接。
-
使用跳线将E35(AO)与面包板上的A57(D5)引脚连接。
-
使用跳线将E36(复位)与面包板上的A58(D6)引脚连接。
-
使用跳线将E37芯片选择(CS)与面包板上的A59*(D7)引脚连接。
-
将E37(GND)与电源总线上的GND通道连接。
-
将E38(VCC)与电源总线上的VCC通道连接。
-
将J51(GND)与电源总线上的GND通道连接。
电路现在应看起来类似于以下这样:

图 7.1 – 气象站电路(图片来自 Fritzing)
这是我们组装完整章节所需的所有内容。我们可以继续到下一节,编写能够读取传感器数据并将其显示在 ST7735 上的代码。
编程气象站
我们将把气象站逻辑,包括读取和解释传感器数据,放入一个单独的包中,以便我们可以在仅显示数据的示例中使用它。完成此操作后,我们将重用此包来获取传感器数据并计算警报以发送到MQTT 代理。
我们首先在项目文件夹内创建一个名为Chapter07的新文件夹,然后在Chapter07内创建一个名为weather-station的新文件夹。接着创建一个名为weather.go的新文件,并将包命名为weatherstation。此时,项目结构应如下所示:

图 7.2 – 编程气象站的工程结构
要实现逻辑,请按照以下步骤操作:
-
定义随后在显示屏上绘制内容时将使用的颜色,如下所示:
var ( white = color.RGBA{255, 255, 255, 255} black = color.RGBA{0, 0, 0, 255} ) -
接下来,定义一个接口并插入以下函数。我们将在此列表的一些后续步骤中,一旦实现就详细解释每个函数:
type Service interface { CheckSensorConnectivity() ReadData() (temperature, pressure, humidity int32, err error) DisplayData(temperature, pressure, humidity int32) GetFormattedReadings(temperature, pressure, humidity int32) (temp, press, hum string) SavePressureReading(pressure float64) CheckAlert(alertThreshold float64, timeSpan int8) (bool, float64) } -
然后我们定义一个
struct,其中包含传感器和显示,以及一些我们将使用时解释的更多字段。对于 BME280 设备,我们将使用 TinyGodrivers存储库中的驱动程序。您可以使用以下路径导入它:tinygo.org/x/drivers/bme280。代码如下所示:type service struct { sensor *bme280.Device display *st7735.Device readings [6]float64 readingsIndex int8 firstReadingSaved bool } -
然后我们添加一个新的构造函数,用于设置传感器和显示,并初始化所有值,如下所示:
func New(sensor *bme280.Device, display *st7735.Device) Service { return &service{ sensor: sensor, display: display, readingsIndex: int8(0), readings: [6]float64{}, firstReadingSaved: false, } } -
然后,添加
ReadData函数,这是一个便利函数,它读取所有传感器值并返回它们。代码如下所示:func (service *service) ReadData() (temp, press, hum int32, err error) {temp, err = service.sensor.ReadTemperature() if err != nil { return } press, err = service.sensor.ReadPressure() if err != nil { return } hum, err = service.sensor.ReadHumidity() if err != nil { return } return } -
然后我们添加一个函数,该函数将阻止程序执行,直到 BME280 传感器的连接得到批准,如下所示:
func (service *service) CheckSensorConnectivity() { for { connected := service.sensor.Connected() if !connected { println("could not detect BME280") time.Sleep(time.Second) } println("BME280 detected") break } } -
现在我们添加一个函数,该函数接收传感器读取值并在屏幕上显示,如下所示:
func (service *service) DisplayData( temperature, pressure, humidity int32) { -
填充屏幕,以确保没有来自先前调用的残留物。如果我们跳过此步骤,我们可能会在上面绘制之前绘制的图像,这将显得非常混乱。代码如下所示:
service.display.FillScreen(black) -
使用
tinyfont编写标题,如下所示:tinyfont.WriteLineRotated(service.display, &freemono.Bold9pt7b, 110, 3, "Tiny Weather", white, tinyfont.ROTATION_90) -
将读取值转换为字符串,如下所示:
temp, press, hum := service.GetFormattedReadings(temperature, pressure, humidity) -
构建并显示温度、压力和湿度字符串,如下所示:
tempString := "Temp:" + temp + "C" tinyfont.WriteLineRotated(service.display, &freemono.Bold9pt7b, 65, 3, tempString, white,tinyfont.ROTATION_90) pressString := "P:" + press + "hPa" tinyfont.WriteLineRotated(service.display, &freemono.Bold9pt7b, 45, 3, pressString, white, tinyfont.ROTATION_90) humString := "Hum:" + hum + "%" tinyfont.WriteLineRotated(service.display, &freemono.Bold9pt7b, 25, 3, humString, white, tinyfont.ROTATION_90) } -
添加一个函数,将传感器读取值转换为°C、百帕斯卡(hPa)和相对湿度百分比字符串,如下所示:
func (service *service) GetFormattedReadings( temperature, pressure, humidity int32) (temp, press, hum string) { temp = strconv.FormatFloat( float64(temperature/1000), 'f', 2, 64) press = strconv.FormatFloat( float64(pressure/100000), 'f', 2, 64) hum = strconv.FormatFloat( float64(humidity/100), 'f', 2, 64) return }
我们现在已经完成了读取和显示传感器数据的逻辑实现。下一步是计算天气警报。
计算天气警报
为了计算警报,我们需要保存一些读取值。我们可以通过以下步骤来实现:
-
对于天气警报的计算,我们只需要压力。这就是为什么我们在
service结构体中保留一个float64数组,如下所示:func (service *service) SavePressureReading( pressure float64) { -
如果我们之前保存过值,我们将用相同的值填充整个数组。这可以防止在计算警报时出现一些边缘情况。代码如下所示:
if !service.firstReadingSaved { for i := 0; i < len(service.readings); i++ { service.readings[i] = pressure } -
由于我们已经插入了第一个读取值,我们可以设置
true标志并return。这确保我们只执行前面的逻辑一次。代码如下所示:service.firstReadingSaved = true service.readingsIndex = 0 return } -
将读取值存储到当前索引。如果当前索引超过存储数据集的最大数量,我们将重置索引;因此,下一次读取将覆盖索引
0中的读取值。代码如下所示:service.readingsIndex++ service.readingsIndex = service.readingsIndex % int8(len(service.readings)) service.readings[service.readingsIndex] = pressure } -
添加一个函数,该函数使用保存的读取值,计算两者之间的差异,并在差异超过阈值时发出警报。我们将在本节稍后讨论阈值和时间段,当我们调用此函数时。代码如下所示:
func (service *service) CheckAlert(alertThreshold float64, timeSpan int8) (bool, float64) { currentReading := service.readings[service.readingsIndex] -
根据时间跨度值计算
comparisonIndex值,如下所示:currentReading := service.readings[currentIndex] comparisonIndex := currentIndex - timeSpan if comparisonIndex < 0 { comparisonIndex = 5 + comparisonIndex } -
计算两个值之间的差异,如果差异大于阈值,则返回
diff以发出警报,如下所示:comparisonReading := service.readings[comparisonIndex] diff := comparisonReading - currentReading return diff >= alertThreshold, diff }
好的——我们刚刚实现了一个应用程序编程接口(API),它允许我们读取、转换和显示传感器数据,并且我们可以保存传感器读数并计算天气警报。
现在,让我们尝试一下代码是否真的能够读取并显示传感器数据。为此,我们首先在Chapter07文件夹内创建一个名为weather-station-example的新文件夹。然后,我们创建一个包含空main函数的新main.go文件。现在,项目结构应该看起来像这样:

图 7.3 – 读取代码和显示传感器数据的项目结构
现在,按照以下步骤实现示例:
-
在
main函数内部,我们休眠5秒,以便有足够的时间打开 PuTTY,这样我们就能监控串行端口上的输出。代码如下所示:time.Sleep(5 * time.Second) -
初始化并配置显示屏,如下所示:
machine.SPI0.Configure(machine.SPIConfig{ Frequency: 12000000, }) resetPin := machine.D6 dcPin := machine.D5 csPin := machine.D7 backLightPin := machine.D2 display := st7735.New( machine.SPI0, resetPin, dcPin, csPin, backLightPin) display.Configure(st7735.Config{ Rotation: st7735.ROTATION_180, }) -
初始化并配置传感器。传感器需要校准,这是在
Configure函数中完成的。代码如下所示:machine.I2C0.Configure(machine.I2CConfig{}) sensor := bme280.New(machine.I2C0) sensor.Configure() -
创建
weatherstation的新实例并等待传感器连接。代码如下所示:weatherStation := weatherstation.New( &sensor, &display) weatherStation.CheckSensorConnectivity() -
读取并显示数据,如下所示:
for { temperature, pressure, humidity, err := weatherStation.ReadData() if err != nil { println("could not read sensor data:", err) time.Sleep(1 * time.Second) continue } weatherStation.DisplayData( temperature, pressure, humidity) time.Sleep(2 * time.Second) }
这个例子就到这里。现在,使用以下命令烧录程序:
tinygo flash --target=arduino-nano33 ch7/weather-station-example/main.go
稍微过一会儿,显示屏现在应该看起来与下面所示类似:

图 7.4 – 显示输出
我们现在已经验证了我们能够读取并显示传感器数据。因为我们已经学会了如何使用 BMP280 传感器,并准备了一个能够计算天气警报的包,我们现在可以继续到下一节,学习如何与 Wi-Fi 芯片通信以及如何发送 MQTT 消息。
向代理发送 MQTT 消息
现在,让我们开始深入探索物联网的世界。由于每个连接到互联网——或者至少连接到某些网络的设备——都可以被认为是物联网设备,本节中的项目可以被认为是物联网项目。Arduino Nano 33 IoT 板上有一个u-blox NINA-W102芯片,它能够进行 Wi-Fi 通信。我们可以使用 SPI 接口与该芯片通信。由于已经存在 NINA 芯片的驱动程序,我们不需要自己实现。
因此,我们的计划是通过 SPI 将数据发送到 NINA 芯片,然后该芯片通过网络将数据发送到 MQTT 代理。以下图表说明了这个过程:

图 7.5 – 通信图
尽管驱动功能被封装在一个包中,但仍然需要一些样板代码来开始使用 Wi-Fi 芯片。因此,让我们将其封装在一个新的包中。
实现 Wi-Fi 包
我们将创建一个 API,它提供初始化 NINA 芯片、检查硬件和设置连接的功能。因此,让我们首先在Chapter07文件夹内创建一个名为wifi的新文件夹,并在新创建的文件夹内创建一个名为wifi.go的新文件,并将包命名为wifi。项目结构现在应该如下所示:

图 7.6 – 项目结构
现在,执行以下步骤以实现逻辑:
-
定义包的接口,如下所示:
type Client interface { Configure() error CheckHardware() ConnectWifi() } -
添加一个客户端,它存储凭证以及 SPI 总线和
wifinina.Device,如下所示:type client struct { ssid string password string spi machine.SPI wifi *wifinina.Device } -
添加一个构造函数,用于设置 SPI 总线和凭证,如下所示:
func New(ssid, password string) Client { return &client{ spi: machine.NINA_SPI, ssid: ssid, password: password, } } -
添加
Configure函数,如下所示:func (client *client) Configure() error { -
使用默认引脚配置 NINA SPI 总线,如下所示:
err := client.spi.Configure(machine.SPIConfig{ Frequency: 8 * 1e6, SDO: machine.NINA_SDO, SDI: machine.NINA_SDI, SCK: machine.NINA_SCK, }) if err != nil {return err } -
创建一个新的
wifinina驱动实例,并传递 SPI 总线和默认引脚,如下所示:client.wifi = &wifinina.Device{ SPI: client.spi, CS: machine.NINA_CS, ACK: machine.NINA_ACK, GPIO0: machine.NINA_GPIO0, RESET: machine.NINA_RESETN, } client.wifi.Configure() -
芯片在准备好使用之前需要一点时间,这就是为什么我们要短暂休眠。代码如下所示:
time.Sleep(5 * time.Second) return nil } -
现在,我们添加一个检查硬件的函数,如下所示:
func (client *client) CheckHardware() { -
首先,我们打印当前安装的固件版本。如果您在使用 NINA 芯片时遇到任何问题,此信息可能很重要。此外,您可以使用此信息来检查固件支持哪些功能。代码如下所示:
firmwareVersion, err := client.wifi.GetFwVersion() if err != nil { return err } println("firmware version: ", firmwareVersion) -
现在,我们扫描可用的 Wi-Fi 网络并打印所有结果。内部缓冲区仅存储最多 10 个服务集标识符(SSID)。如果 Wi-Fi 网络的扫描没有错误,我们可以确信我们能够与芯片通信。代码如下所示:
result, err := client.wifi.ScanNetworks() if err != nil { return err } for i := 0; i < int(result); i++ { ssid := client.wifi.GetNetworkSSID(i) println("ssid:", ssid, "id:", i) } } -
现在,我们实现一个便利函数,用于建立与网络的连接,如下所示:
func (client *client) ConnectWifi() { println("trying to connect to network: ", client.ssid) client.connect() for { -
休眠一秒钟,因为连接建立可能需要一段时间。代码如下所示:
time.Sleep(1 * time.Second) -
获取连接状态并打印,如下所示:
status, err := client.wifi.GetConnectionStatus() if err != nil { println("error:",err.Error()) } println("status:",status.String()) -
如果状态等于
StatusConnected,如下面的代码片段所示,我们就成功连接到了网络:if status == wifinina.StatusConnected { break } -
有时,第一次尝试无法建立连接,这就是为什么我们只是再次尝试,如下面的代码片段所示:
if status == wifinina.StatusConnectFailed || status == wifinina.StatusDisconnected { client.connect() } } -
连接成功建立后,我们打印出由动态主机配置协议(DHCP)分配给我们的设备的互联网协议(IP)地址,如下所示:
ip, _, _, err := client.wifi.GetIP() if err != nil { println("could not get ip address:", err.Error()) } println("connected to wifi. got ip:", ip.String()) } -
我们可以只设置网络(
ssid),对于开放网络不设置密码,或者我们可以设置网络(ssid)和密码。设置这些选项中的任何一个都会触发连接尝试。如果没有设置密码,我们将尝试连接到一个开放网络。如果设置了密码和ssid,我们将尝试连接到一个受保护的网络,如下所示:func (client *client) connect() error { if client.password == "" { return client.wifi.SetNetwork(client.ssid) } return client.wifi.SetPassphrase( client.ssid, client.password) }
这是我们实现抽象层所需的所有内容。我们将与 MQTT 客户端抽象层一起测试这个包,我们将在下一个步骤中实现这个抽象层。
实现 MQTT 客户端抽象层
就像 Wi-Fi 驱动程序一样,MQTT 客户端需要一些样板代码才能启动运行。我们将通过添加一个抽象层来减少样板代码。这样,我们只需在一个可重用的组件中编写一次样板代码,就不必在未来的每个项目中重复编写相同的代码。
我们首先在Chapter07文件夹内创建一个名为mqtt-client的新文件夹,并在mqttclient包内创建一个名为client.go的新文件。现在,项目结构应该看起来像这样:

图 7.7 – 项目结构
在我们开始编写代码之前,我们首先需要了解 MQTT 是什么以及它是如何工作的。
理解 MQTT
MQTT 是一种物联网的消息协议。它基于发布/订阅架构。一个读取传感器数据的微控制器可以向所谓的主题(在物联网世界中这样的微控制器就是一个“物”)发布消息。这些消息被发送到一个代理。
MQTT 标准允许使用传输层安全性(TLS),以及开放授权(OAuth)进行身份验证。也可以完全不进行身份验证。可用的身份验证流程取决于所使用的 MQTT 代理的实现和配置。当在互联网上发送敏感数据时,使用身份验证流程来保护代理非常重要。以下图显示了单个 MQTT 代理和多个 MQTT 客户端的示例架构:

图 7.8 – MQTT 架构
为了使用 MQTT,我们需要一个活跃的代理,客户端可以向其发布消息。我们还需要一个或多个客户端,以便能够订阅来自特定主题的消息。
典型 MQTT 通信的序列图简单直接,基于命令和命令确认模式。让我们看看以下示例序列:
-
客户端连接到代理。
-
代理确认连接。
-
可选:客户端订阅一个主题。
-
代理确认订阅。
-
可选:客户端发布一条消息。
-
代理确认已发布的消息。
这看起来像是以下图中所示的序列:

图 7.9 – MQTT 序列图(图像使用 PlantUML 创建)
总结一下,这里我们指的是一个代理可以服务多个客户端,一个客户端可以订阅一个或多个主题,并且一个客户端可以在一个主题中发布消息。这应该就足够了,关于 MQTT 的基础知识。
注意
如果你想要深入了解 MQTT,你可能想查看规范:
现在,让我们编写抽象层。由于我们已经准备好了项目结构,我们可以在client.go文件中按照以下步骤直接开始编写代码:
-
由于我们的客户端只将发布消息,我们的 API 将相对简单。我们需要能够连接到代理,并且需要能够发布消息。现在我们添加
struct,它包含来自drivers存储库的mqtt.Client,如下所示:type Client struct { mqttBroker string mqttClientID string MqttClient mqtt.Client } -
要创建一个新的
Client,我们只需要设置mqttBrokerURL,如下所示:func New(mqttBroker, clientID string) *Client { return &client{ mqttBroker: mqttBroker, MqttClientID: clientID, } } -
现在,添加
ConnectBroker函数,该函数将建立与代理的连接。代码如下所示:func (client *client) ConnectBroker() error { -
我们创建新的客户端选项,这些选项将在创建新客户端时作为参数传递。这些选项包含建立连接所需的所有参数。当使用需要密码和用户名的代理时,这些也可以在这里设置。代码如下所示:
opts := mqtt.NewClientOptions(). AddBroker(client.mqttBroker).当使用本地代理测试程序时,我们有时会在旧客户端连接尚未丢弃的情况下尝试连接,这可能导致再次使用相同的
clientID时遇到问题,因此使用随机字符串非常有帮助。MQTT 规范指出,clientID的长度应在 1 到 23 个字符之间,但像 Mosquitto 这样的代理并没有实现这一点。我们将在本节后面学习关于 Mosquitto MQTT 代理的内容。客户端 ID 必须是唯一的——否则,客户端将被代理踢出。
-
我们将使用我们传入的
clientID和一个长度为4的随机字符串的组合,如下面的代码片段所示:SetClientID(client.mqttClientID + randomString(4)) -
现在我们创建一个新的客户端,并将
opts作为参数传递,并尝试以下方式连接到代理:client.mqttClient = mqtt.NewClient(opts) token := client.MqttClient.Connect() -
尽管当前实现中,使用
wait函数时令牌总是返回true,但我们仍然在这里添加它,以防你在完成本章内容时它已经被实现。我们可以使用这个函数来等待任何命令被确认(即被认可)。或者,我们也可以使用token.WaitTimeout,当给定的时间跨度结束后,它会内部超时。以下代码片段展示了第一种选项:if token.Wait() && token.Error() != nil { return token.Error() } return nil } -
添加
PublishMessage函数。qos(0、1或2)。在我们完全实现这个包之后,我们将更深入地了解qos级别。retain标志告诉代理存储带有retain标志的最后一条消息。当新客户端订阅代理时,保留消息将直接被投递。代码如下所示:func (client *client) PublishMessage( topic string, message []byte, qos uint8, retain bool) error { token := client.MqttClient.Publish( topic, qos, retain, message) if token.WaitTimeout(time.Second) && token.Error() != nil { return token.Error() } return nil } -
下一步是添加一个允许我们订阅特定主题的功能。以下代码片段说明了这一点:
func (client *Client) Subscribe( topic string, qos byte, callback mqtt.MessageHandler) error { token := client.MqttClient.Subscribe( topic, qos, callback) if token.WaitTimeout(time.Second) && token.Error() != nil { return token.Error() } return nil } -
现在,添加一个生成包含
A到Z字符的随机字符串的功能。以下函数是从mqtt驱动程序示例中取出的:func randomInt(min, max int) int { return min + rand.Intn(max-min) } func randomString(len int) string { bytes := make([]byte, len) for i := 0; i < len; i++ { bytes[i] = byte(randomInt(65, 90)) } return string(bytes) }
我们的抽象层就到这里。在我们继续编写实际的气象站程序之前,让我们看看 QOS 级别。
MQTT 提供了三个 QOS 级别,其工作方式如下:
-
QOS 0:消息只投递一次。消息不会被发送者存储,也不会被确认。因此,客户端只会尝试投递一次,如果失败,则消息将被丢弃。
-
duplicate标志直到它被代理确认。所有这些消息都将发送给订阅的客户端。 -
duplicate标志直到消息被确认。区别在于,只有当客户端发送PUBREL(发布释放)消息时,消息才会被投递给订阅者。
您可以通过以下链接了解有关底层过程的更多信息:
www.hivemq.com/blog/mqtt-essentials-part-6-mqtt-quality-of-service-levels/
由于我们现在已经对 MQTT 有了基本的了解并实现了我们的抽象层,现在是时候在下一步中将所有内容组合起来,并实际上开始向代理发送消息。
实现气象站
我们已经准备好了实现实际逻辑所需的所有代码,但我们还没有 MQTT 代理。因此,让我们设置一个本地 MQTT 代理,我们可以用它来运行这个程序。
设置 Eclipse Mosquitto MQTT 代理
我们将使用 Eclipse Mosquitto MQTT 代理。有关代理的更多信息,请参阅mosquitto.org/。
如果您不想设置本地 MQTT 代理或现在无法使用 Docker,您可以跳过此步骤并使用 Mosquitto 测试系统。但请仅将 Mosquitto 测试系统用于测试目的;此外,请不要向测试系统发布任何敏感数据,因为任何人都可以监听消息。您可以在以下位置找到测试系统所需的 URL 和端口号:test.mosquitto.org/。
也可以在不使用 Docker 的情况下本地安装 Mosquitto 代理,但这个过程不会在本书中介绍,因为使用 Docker 是一个简单直接的过程,而本地设置 Mosquitto 则更为复杂。要使用 Docker 设置 Mosquitto,我们需要创建一个配置文件。为此,在 Chapter07 文件夹内创建一个名为 mosquitto 的新文件夹,并在 mosquitto 文件夹内创建一个名为 config 的新文件夹。现在,创建一个新文件并将其命名为 mosquitto.conf。下一步是插入以下配置:
user mosquitto
listener 9001 127.0.0.1
protocol websockets
allow_anonymous true
我们已将 Mosquitto 配置为使用用户 mosquitto 并监听主机的所有 IP 地址。我们还监听本地主机的 Websocket 连接,使用端口 9001,这在本章后面的 实现天气应用 部分中的 Wasm 应用中会用到。
allow_anonymous 标志允许未经身份验证的客户端连接。有关可能的配置选项的更多信息,请参阅主手册页mosquitto.org/man/mosquitto-conf-5.html。
现在,我们只需要启动容器。映射端口 1883 和 9001 非常重要,这样我们才能实际访问这些端口。此外,我们需要 传递我们的配置文件路径。因此,将我使用的路径替换为你的系统上配置文件的实际路径,如下所示:
docker run -it --name mosquitto \
--restart=always \
-p 1883:1883 \
-p 9001:9001 \
-v ~/go/src/ github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly /Chapter07/mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro \
eclipse-mosquitto
由于我们现在有一个运行的 Mosquitto 实例,我们可以真正开始实现我们的客户端逻辑。
让我们从在 Chapter07 文件夹内创建一个名为 weather-station-mqtt 的新文件夹开始,然后在其中创建一个包含空 main 函数的新 main.go 文件。现在,项目结构应该看起来像这样:
![图 7.10 – weather-station-mqtt 项目的结构
![img/Figure_7.10_B16555.jpg]
图 7.10 – weather-station-mqtt 项目的结构
在开发这些示例时,我在 TinyGo drivers 仓库中的 wifinina 驱动程序遇到了一些问题。我目前正在努力解决这些问题。因此,如果你在连接 Wi-Fi 网络时遇到任何问题,请使用以下代码片段中显示的两个导入,而不是官方的导入。你还需要更改我们在本章早期开发的 wifi 和 mqtt-client 包中的这些导入:
github.com/Nerzal/drivers/wifinina
github.com/Nerzal/drivers/net/mqtt
此外,当使用我为 wifinina 驱动程序创建的 drivers 仓库的分支时,wifi.Client 的初始化看起来略有不同。当使用该驱动程序时,你会在 Configure 函数中看到一个错误。为了修复它,将 client.wifi 对象的初始化替换为以下片段:
wifiDevice := wifinina.NewSPI(
client.spi,
machine.NINA_CS,
machine.NINA_ACK,
machine.NINA_GPIO0,
machine.NINA_RESETN,
)
client.wifi = wifiDevice
要实现程序,请按照以下步骤操作:
-
定义
ssid和password的常量,如下面的代码片段所示。你必须在这里插入你自己的凭证:const ssid = "changeMe" const password = "changeMe" -
定义
temperature、pressure和humidity的变量,如下面的代码片段所示。这些变量将被多个 goroutine 访问:var ( temperature float64 pressure float64 humidity float64 ) -
当观看 Ron Evans(他是 TinyGo 维护者之一)的直播时,我学到了一个有用的技巧。如果在做非常重要的事情时发生错误,我们希望能够在串行输出中找到错误信息。在这种情况下,我们暂停程序执行并反复打印消息,如下所示:
func printError(message string, err error) { for { println(message, err.Error()) time.Sleep(time.Second) } } -
现在,在
main函数内部,我们首先短暂休眠,以便我们有足够的时间打开 PuTTY 来监控串行输出,同时初始化传感器和气象站,如下所示:time.Sleep(5 * time.Second) -
初始化
weatherStation,如下所示:machine.I2C0.Configure(machine.I2CConfig{}) sensor := bme280.New(machine.I2C0) sensor.Configure() weatherStation := weatherstation.New(&sensor, nil) weatherStation.CheckSensorConnectivity() -
创建一个新的
wifi客户端,如下所示:wifiClient := wifi.New(ssid, password) -
配置
wifi客户端,如下所示:println("configuring nina wifi chip") err := wifiClient.Configure() if err != nil { printError("could not configure wifi client", err) } -
现在,我们调用
CheckHardware函数,该函数将在扫描网络时输出固件版本和找到的ssids。如果这可行,我们可以确信微控制器能够与 NINA 芯片通信。代码如下所示:println("checking firmware") err = wifiClient.CheckHardware() if err != nil { printError("could not check hardware", err) } -
尝试连接到网络,如下所示:
wifiClient.ConnectWifi()创建一个新的
mqttClient实例。请注意,您必须将 IP 地址更改为 MQTT 代理运行的主机的地址。不要省略tcp://部分,因为驱动程序实现正在使用它来估计需要建立哪种类型的连接。代码如下所示:mqttClient := mqttclient.New("tcp://192.0.2.22:1883") -
尝试连接到 MQTT 代理,如下所示:
println("connecting to mqtt broker") err = mqttClient.ConnectBroker() if err != nil { printError("could not configure mqtt", err) } println("connected to mqtt broker") -
启动一个 goroutine 来发布传感器数据,如下所示:
go publishSensorData( mqttClient, wifiClient, weatherStation) -
启动一个 goroutine 来发布天气警报,如下所示:
go publishAlert( mqttClient, wifiClient, weatherStation) -
每分钟读取一次新的传感器值,如下所示:
for { temperature, pressure, humidity, err = weatherStation.ReadData() if err != nil { printError("could not read sensor data:", err) } time.Sleep(time.Minute) } -
现在,添加
publishSensorData函数。为了测试目的,它每分钟运行一次,但您可以根据需要自定义它。代码如下所示:func publishSensorData(mqttClient mqttclient.Client, wifiClient wifi.Client, weatherStation weatherstation.Service) { for { time.Sleep(time.Minute) println("publishing sensor data") tempString, pressString, humidityString:=weatherStation. GetFormattedReadings(temperature, pressure, humidity) -
由于目前大多数编码包不支持 TinyGo,我们使用字符分隔的字符串来序列化数据,因为这将便于在订阅者端反序列化。代码如下所示:
message := []byte(fmt.Sprintf("sensor readings#%s#%s#%s", tempString, pressString, humidityString))有时,我们会失去与 Wi-Fi 或 MQTT 代理的连接。在这种情况下,我们只需尝试建立一个新的连接,如下所示:
err := mqttClient.PublishMessage("weather/data", message, 0, true) if err != nil { switch err.(type) { println(err.Error()) case wifinina.Error: wifiClient.ConnectWifi() mqttClient.ConnectBroker() default: println(err.Error()) } } } } -
我们现在添加
publishAlert函数,该函数每小时运行一次,如下所示:func publishAlert(mqttClient mqttclient.Client, wifiClient wifi.Client, weatherStation weatherstation.Service) { for { time.Sleep(time.Hour) -
我们每小时保存一次压力读数,如下所示:
weatherStation.SavePressureReading(pressure) -
现在,我们检查是否需要发送警报。我们将
2作为警报阈值的值,用于每小时检查。在完成函数实现后,我们将更详细地讨论这些值。代码如下所示:alert, diff := weatherStation.CheckAlert(2, 1) -
如果我们有
alert,我们将通过运行以下代码来发布它:if alert { err := mqttClient.PublishMessage("weather/alert", []byte(fmt.Sprintf("%s#%v#%s", "possible storm incoming", diff, "1 hour")), 0, true) if err != nil { switch err.(type) { case wifinina.Error: println(err.Error()) wifiClient.ConnectWifi() mqttClient.ConnectBroker() default: println(err.Error()) } } } -
现在,我们按照 3 小时的时间表检查是否有
alert,如下所示:alert, diff = weatherStation.CheckAlert(6, 3)如果我们没有
alert,我们将继续运行以下代码:if !alert { continue } -
按照每 3 小时的时间表发布警报(如果我们有的话),如下所示:
err := mqttClient.PublishMessage("weather/alert", []byte(fmt.Sprintf("%s#%v#%s", "possible storm incoming", diff, "3 hours")), 0, true) if err != nil { println(err.Error()) switch err.(type) { case wifinina.Error: wifiClient.ConnectWifi() mqttClient.ConnectBroker() } } } }
这是我们第一个物联网项目所需的一切。我们现在已经开发了一个客户端,可以从传感器读取数据并将其发布到 MQTT 代理。该客户端还会检查数据中可能出现的风暴,并将这些警告作为不同主题上的消息发布。在我们尝试程序之前,让我们简要谈谈我们在警报中用作参数的阈值和时间段。
非常重要提示
我绝不是气象学家。这个程序无法预测所有可能出现的风暴。我用作示例值的数据未经测试,因为我写这本书时没有遇到任何风暴。此外,这些信息基于我在网上阅读的文章,可能只在我居住的地方效果很好,因此您可能需要自己研究压力变化与即将到来的恶劣天气的相关性。如果这个程序没有预测到即将到来的风暴,但您感觉可能会有,请检查您当地的新闻和天气预报信息。所以,再次提醒,如果您居住在经常遭受危险风暴的地区,请不要盲目相信这个程序。气象学比这个程序复杂得多,我们只根据压力的突然下降来检查即将到来的风暴。还有更多即将到来的风暴的指标,所以请将这个程序视为一个原型。
我的阈值值的来源是以下网页:
www.bohlken.net/airpressure2.htm
这表示在 1 小时期间压力下降>=2hPa可能表明可能出现的风暴。它还表示在 3 小时期间压力下降>=6hPa可能表明可能出现的风暴。
如果您对 MQTT 最佳实践感兴趣,请查看以下链接:
www.hivemq.com/blog/mqtt-essentials-part-5-mqtt-topics-best-practices/
我们现在可以继续并最终使用以下命令将程序烧录到微控制器上:
tinygo flash --target=arduino-nano33 ch7/weather-station-mqtt/main.go
我们现在已经烧录了程序,一切似乎都在正常运行,但我们如何知道消息确实被成功发布了呢?在这种情况下,我们可以使用 MQTT 图形用户界面(GUI)客户端。我强烈推荐MQTT Explorer。您可以从以下链接下载适用于每个平台的程序:
程序启动后,您只需插入hostname和port值。只需将localhost和端口1883作为参数,然后保存您的连接。现在连接窗口应该看起来类似于以下内容:

图 7.11 – MQTT Explorer 连接窗口
当程序在微控制器上运行时,您应该能够看到被发布到代理的主题和消息。这看起来类似于以下输出:

图 7.12 – MQTT 探索器发布的消息
我们已经学会了如何实现一个能够向 MQTT 代理发布不同主题消息的气象站。我们还学会了如何使用 Docker 设置 Mosquitto,以及如何使用 MQTT 探索器来检查我们的消息是否真的被发布。下一步是创建一个显示这些消息的 Wasm 应用程序。
介绍 Wasm
让我们弄清楚 Wasm 是什么。WebAssembly 主页声明如下:
“WebAssembly(简称 Wasm)是一种基于栈的虚拟机的二进制指令格式。Wasm 被设计为编程语言的便携式编译目标,使得客户端和服务器应用程序能够在网络上部署。”
换句话说,我们可以用任何语言编写代码,并将其编译成 Wasm 二进制格式,然后由浏览器执行。这使得 Wasm 非常有价值,因为我们可以使用除 JavaScript 之外的语言创建客户端应用程序。
与 JavaScript 相比,Wasm 的一个巨大优势是它旨在以原生速度执行,并且因为它在浏览器内的沙箱环境中运行,所以可以被认为是相对安全的。幸运的是,TinyGo 支持 Wasm 作为编译目标,因此我们可以利用 TinyGo 产生的极小的二进制文件大小,这与其他技术相比将显著加快页面加载时间。
这里有一个列表来总结前面的信息:
-
Wasm 是一种新的语言,它正式成为网络上的第四种语言,继 超文本标记语言(HTML)、层叠样式表(CSS)和 JavaScript 之后。来源:
www.w3.org/2019/12/pressrelease-wasm-rec.html.en -
Wasm 是一种二进制格式。它不旨在是可读的。
-
Wasm 非常底层,与 JavaScript 等高级语言相比,它带来了性能提升。
-
Go 代码可以编译成 Wasm 二进制格式。
既然我们现在对 Wasm 理论上是什么有一个简短的基本了解,我们就用它来获得更好的理解。
在 Wasm 页面上显示传感器数据和天气警报
我们的目的是开发一个小型应用程序,显示气象警报和发布到 MQTT 代理的传感器数据,因此我们需要一些非常基本的 HTML 和 JavaScript 技能才能实现这一点。我们首先开发一个小型服务器,向客户端提供 Wasm 应用程序。
提供应用程序服务
由于 Wasm 是提供给浏览器的,我们需要一个 HTTP 端点来提供我们可能需要的所有文件。我们首先在 Chapter07 文件夹内创建一个名为 wasm-server 的新文件夹,并在该文件夹内创建一个包含空 main 函数的新 main.go 文件。现在,按照以下步骤实现服务器:
-
定义
FileServer应查找文件的目录,如下所示:const dir = "Chapter07/html" -
现在,在
main函数中,创建一个新的FileServer实例,并将目录作为参数传递,如下所示:fs := http.FileServer(http.Dir(dir)) -
启动一个监听端口
8080的 HTTP 服务器,如下所示:err := http.ListenAndServe(":8080", http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { -
告诉浏览器不要缓存文件。我们只在我们的发展环境中使用此功能,以确保我们永远不会得到应用的缓存版本。在生产环境中,你不会希望禁用缓存。代码如下所示:
resp.Header().Add("Cache-Control", "no-cache") -
我们需要为不同类型的文件设置正确的
content-type头,如下所示:if strings.HasSuffix(req.URL.Path, ".wasm") { resp.Header(). Set("content-type", "application/wasm") } -
现在,最后,让文件服务器按照以下方式提供文件:
fs.ServeHTTP(resp, req) })) if err != nil { println("failed to server http requests:", err.Error()) }
这是我们准备服务器所需的一切。现在,服务器能够从我们稍后创建的html目录中提供所有文件。请注意,这个示例服务器几乎与 TinyGo 存储库中的示例服务器完全相同。
实现天气应用
现在,让我们创建实际的应用程序。该应用将包括三个部分,如下所示:
-
一个将包含页面内容的 HTML 文件。
-
一个
wasm.js文件,它将执行 Go 代码并包含一些其他辅助函数。 -
一个
wasm_exec.js文件,可以被认为是粘合代码,因为它将 Go 函数映射到 JavaScript 函数。这个文件将由 TinyGo 本身提供。
让我们开始创建我们的 HTML 文件。为此,在Chapter07文件夹内创建一个名为weather-app的新文件夹,并在该新文件夹内创建一个名为index.html的新文件。现在,在 HTML 文件内按照以下步骤操作:
-
在头部声明元数据,如
charset、title和viewport,如下所示:<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>TinyGo Weather Station</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> -
导入
paho mqtt库。我们将在使用它时详细介绍这一点。代码如下所示:<script src="img/mqttws31.min.js" type="text/javascript"></script> -
导入
wasm_exec.js和wasm.js文件。这些文件由我们的服务器提供。代码如下所示:<script src="img/wasm_exec.js" type="text/javascript"></script> <script src="img/wasm.js" type="text/javascript"></script> </head> -
现在,在主体中,我们想要告诉用户应用的内容,并显示我们的数据,如下所示:
<body> <h1>TinyGo Weather Station</h1> <p>Alerts:</p> -
定义一个包含四个列的表格,这些列将动态填充我们的天气警报。
tbody列获得一个id属性,以便我们能够识别该元素。代码如下所示:<table> <thead> <tr> <th>TimeStamp</th> <th>Message</th> <th>Pressure Difference</th> <th>Time span</th> </tr> </thead> <tbody id="tbody-alerts"></tbody> </table> -
定义一个包含五个列的表格,这些列将填充我们的传感器数据。
tbody列再次获得一个id。代码如下所示:<p>Events:</p> <table> <thead> <tr> <th>TimeStamp</th> <th>Message</th> <th>Temperature</th> <th>Pressure</th> <th>Humidity</th> </tr> </thead> <tbody id="tbody-data"></tbody> </table> </body> </html>
这是我们显示数据所需的一切。我没有包含任何 CSS,以使示例尽可能容易理解且简短。当然,你也可以包含内联 CSS 或引用一个可以从html目录提供的 CSS 文件。
阅读更多
如果你想学习如何使用 HTML5 和 CSS 创建漂亮的 Web 应用,我推荐由出色的作者本·弗莱恩(Ben Frain)所著的书籍《Responsive Web Design with HTML5 and CSS》。你可以在以下链接找到它:
《使用 HTML5 和 CSS 的响应式网页设计(第三版)》
下一步是实现实际的客户端逻辑。我们首先在weather-app目录中创建一个新文件,命名为wasm.go,并在新创建的文件中创建一个空的main函数。现在,按照以下步骤操作:
-
定义一个用于我们的传感器事件的 struct,如下所示:
type sensorEvent struct { TimeStamp string Message string Temperature float32 Pressure float32 Humidity float32 } -
定义一个用于我们的警报事件的 struct,如下所示:
type alertEvent struct { TimeStamp string Message string Diff string TimeSpan string } -
创建一个用于处理传感器事件的 channel,如下所示:
var sensorEvents = make(chan sensorEvent) -
创建一个用于处理警报事件的 channel,如下所示:
var alertEvents = make(chan alertEvent) -
在
main函数内部,我们将sensorDataHandler函数导出为sensorDataHandler到 JavaScript 环境。这样,我们就可以从 JavaScript 中调用go函数。代码如下所示:js.Global().Set("sensorDataHandler", js.FuncOf(sensorDataHandler)) -
我们还导出
alertHandler函数,如下所示:js.Global().Set("alertHandler", js.FuncOf(alertHandler)) -
启动一个处理传感器事件的 goroutine,如下所示:
go handleSensorEvents() -
启动一个处理警报事件的 goroutine,如下所示:
go handleAlertEvents() -
阻塞主 goroutine 的执行,这样程序在执行完
main函数后不会立即关闭,如下所示:wait := make(chan struct{}, 0) <-wait -
添加
alertHandler函数。为了能够使用js.Global().Set()调用导出函数,该函数必须具有接受一个js.Value和一个[]js.Value并返回interface{}的签名,如下所示:func alertHandler(this js.Value, args []js.Value) interface{} { -
在调用此函数时,我们传递一个字符串作为参数。我们将在
args的第一个索引中找到该字符串。之后,我们需要使用井号作为分隔符来分割消息。代码如下所示:message := args[0].String() splittedStrings := strings.Split(message, "#") -
将反序列化的消息添加到 channel 中。由于我们在发送消息时没有放置时间戳,我们现在添加时间戳,如下所示:
alertEvents <- alertEvent{ TimeStamp: time.Now().Format(time.RFC1123), Message: splittedStrings[0], Diff: splittedStrings[1], TimeSpan: splittedStrings[2], } -
简单地返回
nil,因为我们不需要将任何值写回调用此函数的 JavaScript 代码,如下所示:return nil } -
我们现在对传感器数据事件执行相同的程序,如下所示:
func sensorDataHandler(this js.Value, args []js.Value) interface{} { message := args[0].String() splittedStrings := strings.Split(message, "#") sensorEvents <- sensorEvent{ TimeStamp: time.Now().Format(time.RFC1123), Message: splittedStrings[0], Temperature: splittedStrings[1], Pressure: splittedStrings[2], Humidity: splittedStrings[3], } return nil } -
添加
handleAlertEvents()函数。此函数无限循环并从 channel 中读取警报。代码如下所示:func handleAlertEvents() { for { event := <-alertEvents -
由于我们已经读取了一个警报事件,我们需要在
html目录中找到tbody元素以便添加新行。我们使用一些辅助函数,我们将在实现它们时解释。代码如下所示:tableBody := dom.GetElementByID("tbody-alerts") -
创建一个新的表格行,如下所示:
tr := dom.CreateElement("tr") -
添加列数据,如下所示:
dom.AddTd(tr, event.TimeStamp) dom.AddTd(tr, event.Message) -
添加格式化的列数据,如下所示:
dom.AddTdf(tr, "%s hPa", event.Diff) dom.AddTdf(tr, "%s", event.TimeSpan) -
将新的
tableRow添加到tbody中,如下所示:dom.AppendChild(tableBody, tr) println("successfully added sensor event to table") } } -
handleSensorEvents函数以非常相似的方式工作。我们无限循环,从sensorEvents通道读取事件,并将数据添加到tbody中。代码如下所示:func handleSensorEvents() { for { event := <-sensorEvents tableBody := dom.GetElementByID("tbody-data") tr := dom.CreateElement("tr") dom.AddTd(tr, event.TimeStamp) dom.AddTd(tr, event.Message) dom.AddTdf(tr, "%s°C", event.Temperature) dom.AddTdf(tr, "%s hPa", event.Pressure) dom.AddTdf(tr, "%s", event.Humidity) dom.AppendChild(tableBody, tr) println("successfully added sensor event to table") } }
我们 Go 代码中缺少的只有dom辅助函数。因此,在Chapter07文件夹内创建一个名为dom的新文件夹,在该文件夹内创建一个名为dom.go的新文件,并将包命名为dom。现在,按照以下步骤来实现它:
-
添加一个
GetDocument函数来包装获取文档的调用。您也可以将Getdocument称为 HTML。代码如下所示:func GetDocument() js.Value { return js.Global().Get("document") } -
为
createElement调用添加一个包装器。创建的元素不会直接可见。在渲染之前,需要将新创建的元素添加到文档中。代码如下所示:func CreateElement(tag string) js.Value { document := GetDocument() return document.Call("createElement", tag) } -
为
getElementById函数添加一个包装器。我们使用此函数通过在html目录中定义的id获取tbody元素。代码如下所示:func GetElementByID(id string) js.Value { document := GetDocument() return document.Call("getElementById", id) } -
为
appendChild函数添加一个包装器。我们使用此函数将tableRows添加到tbody元素中。这实际上是将元素添加到html目录中。代码如下所示:func AppendChild(parent js.Value, child js.Value) { parent.Call("appendChild", child) } -
添加一个设置
innerHTML函数的包装器。此函数在html标签之间添加给定的值。代码如下所示:func SetInnerHTML(object js.Value, value interface{}) { object.Set("innerHTML", value) } -
AddTd函数创建一个新的td元素,设置innerHTML函数,并将子元素追加到给定的tr元素中,如下代码片段所示:func AddTd(tr js.Value, value interface{}) { td := CreateElement("td") SetInnerHTML(td, value) AppendChild(tr, td) } -
AddTdf函数与AddTd函数的功能相同,区别在于innerHTML函数进行了格式化。代码如下所示:func AddTdf(tr js.Value, formatString string, value interface{}) { td := CreateElement("td") SetInnerHTML(td, fmt.Sprintf(formatString, value)) AppendChild(tr, td) }
我们现在已经实现了在wasm.go文件中使用到的所有辅助函数。在我们能够构建和测试应用程序之前,唯一缺少的是wasm.js文件。因此,让我们创建一个名为wasm.js的新文件,并按照以下步骤来实现最后一部分:
-
声明此文件应在严格模式下执行。有关严格模式的更多信息,请查看以下网站:
-
为
wasm文件定义一个const值。我们构建的二进制文件将被命名为wasm.wasm。我们还添加了新的变量来存储mqtt客户端和wasm对象,如下代码片段所示:const WASM_URL = 'wasm.wasm'; var wasm; var mqtt; -
由于我无法找到适用于 Wasm 目标的 TinyGo 构建的 Go MQTT 客户端,我们正在使用 JavaScript 实现的 MQTT 客户端。将主机值替换为您的 MQTT 代理的 IP 地址。将来,肯定会有几个客户端可以用于 TinyGo Wasm 项目。代码如下所示:
const host = "192.2.0.23"; const port = 9001; -
当 MQTT 客户端成功建立连接时,会调用此函数。当这一事件发生时,我们会订阅对我们感兴趣的主题。代码如下所示:
function onConnect() { mqtt.subscribe("weather/data"); mqtt.subscribe("weather/alert"); } -
如果我们失去了与 MQTT 代理的连接,我们想要将错误记录到控制台。此函数稍后作为
connectionLost事件的回调函数传递。代码如下所示:function onConnectionLost(responseObject) { if (responseObject.errorCode !== 0) { console.log("onConnectionLost:" + responseObject.errorMessage); } } -
当收到一条新消息时,我们想要检查消息的类型并调用正确的 Go 函数。我们使用消息内部提供的信息来确定消息的类型。代码如下所示:
function onMessageArrived(message) { console.log("onMessageArrived:" + message.payloadString); var payload = message.payloadString; if (payload.indexOf("possible storm incoming") !== -1) { alertHandler(payload); } else { sensorDataHandler(payload); } } -
现在我们添加
MQTTconnect函数。此函数简单地创建一个新的mqttClient并为connect、connectionLost和messageArrived事件添加回调函数。代码如下所示:function MQTTconnect() { var cname = "weather-consumer"; mqtt = new Paho.MQTT.Client(host, port, cname); var options = { timeout: 3, onSuccess: onConnect, }; mqtt.onConnectionLost = onConnectionLost; mqtt.onMessageArrived = onMessageArrived; mqtt.connect(options); } -
现在,添加将要运行我们的 Go 代码的
init函数,如下所示:function init() { -
创建一个新的 go 实例,如下所示:
const go = new Go(); -
检查浏览器是否支持
instantiateStreaming函数,如果是,则使用此函数加载和运行 Wasm,如下所示:if ('instantiateStreaming' in WebAssembly) { WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function(obj) { wasm = obj.instance; go.run(wasm); }) } -
如果浏览器不支持
instantiateStreaming函数,我们将使用instantiate函数加载和运行 Wasm,如下所示:else { fetch(WASM_URL).then(resp => resp.arrayBuffer() ).then(bytes => WebAssembly.instantiate(bytes, go.importObject).then(function(obj) { wasm = obj.instance; go.run(wasm); }) ) -
在启动我们的
go代码后,我们可以尝试连接到 MQTT 代理,如下所示:MQTTconnect() } -
在文件末尾添加对
init()函数的调用,如下所示:init();
这就是我们的程序的全部代码。现在,我们需要下载 wasm_exec.js 文件并将其添加到 weather-app 文件夹。始终使用您当前安装的 TinyGo 版本的 wasm_exec.js 版本。您可以从这里简单地下载当前 TinyGo 发布版本的文件:
github.com/tinygo-org/tinygo/blob/release/targets/wasm_exec.js
为了构建和启动应用程序,我通常使用一个 Makefile 函数。Makefile 的内容如下所示:
wasm-app:
rm -rf Chapter07/html
mkdir Chapter07/html
tinygo build -o Chapter07/html/wasm.wasm -target wasm -no-debug ch7/weather-app/wasm.go
cp Chapter07/weather-app/wasm_exec.js ch7/html/
cp Chapter07/weather-app/wasm.js ch7/html/
cp Chapter07/weather-app/index.html ch7/html/
go run Chapter07/wasm-server/main.go
为了构建应用程序并启动服务器,我只需要使用以下命令调用那个 Makefile 函数:
make wasm-app
这在 Linux 和 Mac 系统上运行良好,如果安装了 GNU Make,也可能在 Windows 系统上运行。但让我们一步一步地完成这个过程,这样你也可以在不使用 make 的情况下构建和运行该应用程序,如下所示:
-
删除现有的
html文件夹。 -
创建一个新的 html 文件夹。
-
使用 Wasm 目标构建 Wasm 应用程序。此外,我们省略了调试信息,这导致二进制文件大小更小。
-
现在,将
wasm_exec.js文件复制到html文件夹。 -
将
wasm.js文件复制到html文件夹。 -
将
index.html文件复制到HTML文件夹。 -
使用
go run命令运行服务器。
在使用 make 命令或手动执行步骤之后,打开您的浏览器并访问以下 URL:localhost:8080。您现在应该看到一个类似于以下网站的网站:

图 7.13 –TinyGo 气象站,已打开开发者工具
太棒了!我们已经成功实现了一个订阅 MQTT 代理上主题并动态更新网站内容的 Wasm 应用程序。
摘要
在本章中,我们学习了如何使用 Arduino Nano 33 IoT 板上集成的 Wi-Fi 芯片。然后,我们编写了可重用的包来使用 Wi-Fi 芯片和 MQTT 客户端,我们发现了 MQTT 是什么,并学习了如何向一个主题发布消息。我们还学习了如何从 BME280 传感器读取传感器数据并将其发布到我们本地设置的 MQTT 代理。
然后,我们学习了 Wasm 是什么,并实现了我们的第一个 Wasm 应用。我们还学习了如何使用 JavaScript MQTT 客户端来订阅 MQTT 主题并对消息做出反应。在这个过程中,我们学习了如何操作 文档对象模型 (DOM) 以动态更新视图。
在下一章中,我们将学习如何通过使用登录视图来尝试 Wasm 应用,还将学习如何通过 MQTT 实现双向通信。
问题
-
要确保一个 MQTT 消息真正送达,需要做些什么?
-
是否可以有多个客户端订阅 MQTT 代理上的同一个主题?
第八章:第八章:通过 TinyGo Wasm 仪表板自动化和监控您的家
在上一章中,我们学习了如何使用 Arduino Nano 33 IoT 板上的 Wi-Fi 芯片来发送消息队列遥测协议(MQTT)消息。然后我们消费了包含天气数据和天气警报的消息,并在WebAssembly(Wasm)仪表板上显示它们,但我们无法从仪表板内部控制任何东西。现在我们将改变这一点。
在完成本章学习后,我们将知道如何通过添加登录页面来保护我们的 Wasm 应用。我们还将了解在客户端应用程序验证凭据时的安全方面。在构建登录视图后,我们将学习如何在我们要构建的仪表板内部发送和接收数据。通过这样做,我们还将学习一些新技术,这些技术将帮助我们通过动态添加和删除内容来帮助我们。通过操作文档对象模型(DOM),我们将知道如何通过 MQTT 进行双向通信。最后,我们将了解控制 130V(其中V代表伏特)或 230V 设备的可能性。
了解所有这些将使我们能够构建各种智能家居项目,而不仅仅是这本书中的项目。在本章中,我们将涵盖以下主要主题:
-
构建智能家居仪表板
-
构建智能家居客户端
-
从微控制器请求数据
技术要求
我们将需要以下组件来完成此项目:
-
Arduino Nano 33 IoT 板
-
面包板
-
一个发光二极管(LED)
-
一个 68 欧姆电阻
-
跳线
您可以在以下链接在 GitHub 上找到本章的代码:github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/tree/master/Chapter08
本章的“代码在行动”视频可以在以下链接找到:bit.ly/3uPLI7X
构建智能家居仪表板
在完成这本书后,你可能想要构建许多酷炫的项目,这些项目可能包括 LED 灯带或由运动传感器控制的灯光,或者你可能给窗帘添加一个电机,根据光强度或时间打开或关闭它们。这些将是非常酷的项目,但现在想象一下,你正坐在沙发上想看电影,但太阳太亮了,没有超过启动控制窗帘的电机阈值。在这种情况下我们能做什么呢?我们是站起来手动关闭窗帘,还是在我们智能手机或平板电脑上打开一个 Wasm 应用,只需按一下应用上的按钮就能控制窗帘的电机?你也可能想检查客厅里的 LED 灯带是否仍然开启,但你不想起床去检查。在这种情况下,有一个提供其状态的仪表板会很好。在本节中,我们将构建一个 Wasm 应用,它提供一个登录页面,用户在登录之前可以输入用户名和密码。然后页面应过渡到一个仪表板,提供启用或禁用特定房间灯光的功能。
我们将从一个可重用的 MQTT JavaScript 组件开始,直到创建一个与 TinyGo 兼容的 MQTT 库。
创建可重用的 MQTT 组件
在第七章,“在 TinyGo Wasm 仪表板上显示天气警报”,我们将 MQTT 客户端嵌入到wasm.js文件中。这对于项目来说效果很好,但不可重用。因此,我们现在将创建一个可重用的组件。
要做到这一点,首先为这个项目创建一个名为Chapter08的新文件夹。在新建的文件夹内,创建一个名为light-control的新文件夹。这个新文件夹将包含 Wasm 应用所需的所有文件。
现在,在light-control文件夹内创建一个新文件,并将其命名为mqtt.js。项目结构现在应该看起来像这样:

图 8.1 – 项目结构
在mqtt.js文件中,按照以下步骤实现它:
-
首先,我们定义一个变量来保存 MQTT 客户端和 MQTT 代理的常量。我们再次使用
strict模式,以防止我们使用未定义的变量。strict模式还可以消除一些静默错误,并将它们转换为抛出错误,并使 JavaScript 引擎能够执行在其他情况下不可能的优化。使用strict模式可能会导致执行速度更快。如果代理不在本地运行,host和port值必须设置为您的 MQTT 代理的主机和端口。以下代码片段显示了代码:'use strict'; var mqtt; const host = "192.2.0.23"; const port = 9001; const cname = "home-automation-dashboard"; -
然后,我们添加一个函数,当连接到 MQTT 代理成功建立时,它简单地记录到控制台,如下所示:
function onConnect() { console.log("Successfully connected to mqtt broker"); } -
由于 Wasm 应用程序正在客户端执行,我们可能会失去与 MQTT 代理的连接。这可能是由于不稳定的 Wi-Fi 连接造成的。如果发生这种情况,我们希望尝试创建一个新的连接。我们可以通过运行以下代码来完成此操作:
function onConnectionLost(err) { if (err.errorCode !== 0) { console.log("onConnectionLost:" + err.errorMessage); } MQTTconnect(); } -
我们现在需要为
messageArrived事件添加一个回调。当收到新消息时,我们希望调用由 Go 代码导出的消息处理程序。它的工作方式如下:function onMessageArrived(message) { console.log( "onMessageArrived:" + message.payloadString); handleMessage(message.payloadString); } -
接下来,我们希望能够发布消息。在这种情况下,我们设置
1,以确保消息确实被消费者接收。此外,我们不需要保留消息。在未来的项目中,您还可以参数化 QOS 级别和retain标志。代码如下所示:function publish(topic, message) { mqtt.send(topic, message, 1, false); } -
建立与 MQTT 代理的连接,如下所示:
function MQTTconnect() { console.log("mqtt client: connecting to " + host + ":" + port); mqtt = new Paho.MQTT.Client(host, port, cname); var options = { timeout: 3, onSuccess: onConnect, }; mqtt.onConnectionLost = onConnectionLost; mqtt.onMessageArrived = onMessageArrived; mqtt.connect(options); }
这就是我们需要用于我们的可重用 MQTT 组件的所有内容。当将其集成到项目中时,我们只需要做以下操作:
-
在 Go 代码中公开一个
handleMessage()函数。 -
在 JavaScript 文件中将
hostname、port和cname值设置为 MQTT 代理。
下一步是设置所谓的粘合代码,它将 JavaScript 代码与 Go 代码连接起来。
设置 Wasm 实例化代码
Wasm 实例化代码每次几乎都是相同的。只有当我们想在其中添加一些特定于项目的代码时,它才会改变。因此,让我们快速在light-control文件夹内创建一个名为wasm.js的新文件。现在,运行以下标准代码以在新的文件中初始化一个 Wasm 应用程序:
'use strict';
const WASM_URL = 'wasm.wasm';
var wasm;
function init() {
const go = new Go();
if ('instantiateStreaming' in WebAssembly) {
WebAssembly.instantiateStreaming(fetch(WASM_URL),
go.importObject).then(function (obj) {
wasm = obj.instance;
go.run(wasm);
})
} else {
fetch(WASM_URL).then(resp =>
resp.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes,
go.importObject).then(function (obj) {
wasm = obj.instance;
go.run(wasm);
})
)
}
}
init();
这几乎与第七章,“在 TinyGo Wasm 仪表板上显示天气警报”部分中的代码相同,但这次我们没有在文件中包含 MQTT 客户端代码。您可以使用此文件在本书之外的所有项目中。
下一步是添加wasm_exec.js文件。我们可以从 TinyGo GitHub 仓库下载它,或者从我们的本地安装中复制它。在基于 Unix 的系统上,您可以使用以下命令来复制文件:
cp /usr/local/tinygo/targets/wasm_exec.js /path/to/Chapter08/light-control
wasm_exec.js文件的路径在 Windows 上不同。当使用前面的命令时,您需要插入自己的 TinyGo 安装路径。路径基本上遵循以下模式:
/path/to/your/tinygo/installation/target/wasm_exec.js
这就是我们需要的所有 JavaScript 代码。我们现在可以继续创建我们的超文本标记语言(HTML)模板文件。
创建 HTML 模板
在第七章,“在 TinyGo Wasm 仪表板上显示天气警报”部分中,在实现天气应用程序部分,我们在 HTML 文件中定义了我们的基本结构,但这次我们的 HTML 模板将会更短。我们只包括所需的标题并定义一个空的身体元素,因为我们将在 Go 代码内部使用 DOM 操作动态创建所有 HTML 元素。
为了做到这一点,在light-control文件夹内创建一个名为index.html的新文件。主体元素需要获取一个id值,因为我们将通过 ID 来识别该元素。我们还在标题中导入了所有需要的 JavaScript 文件。它看起来是这样的:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>TinyGo Home Automation</title>
<meta name="viewport" content="width=device-width,
initial-scale=1" />
<script src="img/wasm_exec.js"
type="text/javascript"></script>
<script src="img/wasm.js" type="text/javascript"></script>
<script src="img/mqtt.js" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-
mqtt/1.0.1/mqttws31.min.js"
type="text/javascript"></script>
</head>
<body id="body-component"></body>
</html>
这就是 HTML 模板所需的所有内容。下一步是编写登录视图。
实现登录视图逻辑
登录组件需要将登录视图添加到 HTML 文档中,并实现处理用户输入的逻辑。让我们在light-control文件夹内创建一个名为login的新文件夹,并在新创建的文件夹内创建一个名为userinfo.go的新文件。
userinfo.go文件简单地持有UserInfo元素,其外观如下:
type UserInfo struct {
LoggedIn bool
UserName string
LoggedInAt time.Time
}
现在,我们在login文件夹内创建一个新的login.go文件,并按照以下步骤实现视图:
-
我们需要登录的用户名和密码值,因此我们定义如下:
const user = "tinygo" const password = "secure1234" -
我们只需要获取文档一次,所以我们只需将其存储在包级变量中,如下所示:
var doc = tinydom.GetDocument() -
现在,我们定义一个只需要持有通道的服务。该通道随后被用来将登录的用户名传播到其他组件。代码如下所示:
type Service struct { channel chan string } -
我们定义一个构造函数,它接受一个通道并返回一个新的
Service实例,如下所示:func NewService(channel chan string) *Service { return &Service{channel: channel} } -
下一步是实现创建视图的逻辑。我们希望通过告诉浏览器通过推送新的状态来改变统一资源定位符(URL),来模拟一个多页应用。实现此功能的代码如下所示:
func (service *Service) RenderLogin() { tinydom.GetWindow().PushState(nil, "login", "/login") -
现在,我们创建一个新的
div标签,它将包含所有后续元素,如下所示:div := doc.CreateElement("div"). SetId("login-component") -
我们首先设置一个
h1,同时告诉用户组件的名称,如下所示:h1 := doc.CreateElement("h1"). SetInnerHTML("Login") -
现在,我们创建一个包含输入字段的表单。因此,我们简单地创建一个新的
form实例,并创建一个新的userName输入字段以及相应的标签,其工作原理如下:loginForm := form.New() userNameLabel := label. New(). SetFor("userName"). SetInnerHTML("UserName:") userName := input. New(input.TextInput). SetId("userName") -
现在,我们想要添加一个类型为
password的输入字段,它会隐藏输入。为此,运行以下代码:passwordLabel := label. New(). SetFor("password"). SetInnerHTML("Password:") password := input. New(input.PasswordInput). SetId("password") -
由于我们现在有了两个输入字段,我们需要一个按钮来触发
click和keyPress事件,我们可以使用这些事件来触发login逻辑。以下是实现此功能的代码:login := input.New(input.ButtonInput). SetValue("login"). AddEventListener("click", js.FuncOf(service.onClick)). AddEventListener("keypress", js.FuncOf(service.onKeyPress)) -
我们现在已经在
loginForm内部创建了所有需要的组件,因此我们可以继续将它们附加到loginForm中,如下所示:loginForm.AppendChildrenBr( userNameLabel, userName, passwordLabel, password, login, ) -
最后一件要做的事情是将前面的元素添加到
div中。我们将在div中添加所有内容,这样我们就可以轻松地再次删除这些元素。为了显示新创建的元素,我们只需将div添加到主体中,如下所示:div.AppendChildren(h1, loginForm.Element) body := doc.GetElementById("body-component") body.AppendChild(div) }
现在,我们可以创建视图本身。这里唯一缺少的是处理login按钮的EventListener逻辑以及登录逻辑本身。为此,请按照以下最后几个步骤为此组件执行:
-
当用户点击
login按钮时,我们只想尝试登录。以下代码片段说明了这一点:func (service *Service) onClick(this js.Value, args []js.Value) interface{} { service.login() return nil } -
当输入按钮获得焦点并且用户按下 Enter 按钮时,我们还想尝试登录。我们将在以下代码片段中展示如何将事件
args包装成一个提供便利函数的tinydom事件:func (service *Service) onKeyPress(this js.Value, args []js.Value) interface{} { if len(args) != 1 { println("bad number of arguments in keyPress event") return nil } event := tinydom.Event{Value: args[0]} if event.Key() == "Enter" { service.login() } return nil } -
login函数从username和password输入字段获取输入,并将它们与我们的定义凭证进行比较。当发现无效凭证时,我们触发一个警报。这个函数中最重要的部分是需要将写入通道的调用包裹在一个 goroutine 中。如果我们没有围绕它包裹一个 goroutine,代码将无法编译。请参考以下代码:func (service *Service) login() { userElem := input.FromElement( doc.GetElementById("userName")) userName := userElem.Value() if userName != user { tinydom.GetWindow().Alert("Invalid username or password") return } passwordElem := input.FromElement( doc.GetElementById("password")) passwordInput := passwordElem.Value() if passwordInput != password { tinydom.GetWindow().Alert("Invalid username or password") return } go func() { service.channel <- userName }() }
太好了!我们已经完成了登录组件。但这个视图在浏览器中会是什么样子呢?让我们查看以下截图来找出答案:


![图 8.3 – 二进制文件中泄露的凭证
要在 Wasm 二进制文件中找到凭证,我只需在文本编辑器中打开二进制文件并搜索密码。那么,我们还有哪些其他可能性来确保凭证的安全?以下是一些选项:
-
向一个 REpresentational State Transfer (REST) 应用程序编程接口 (API) 发起 HTTP 调用,以验证凭证。
-
使用任何能够与 Open Authorization 2 (OAuth 2) 服务通信的 JavaScript 库。
可能有很多其他可能性,但它们都归结为将实际的凭证验证逻辑移动到任何类型的外部 API。但就我们的范围而言,这个解决方案足够好,可以在客户端内部验证凭证。下一步是实现仪表板组件。
实现仪表板组件
我们现在将实现我们的智能家居仪表板。仪表板将包含一系列组件及其关联的动作,这些动作由按钮表示。我们还希望在用户 5 分钟不活动后注销用户。在我们深入代码之前,我们需要在 light-control 文件夹内创建一个名为 dashboard 的新文件夹,并在其中创建一个名为 dashboard.go 的新文件。现在,按照以下步骤实现逻辑:
-
我们保存了对当前文档的引用,如下所示:
var doc = tinydom.GetDocument() -
服务对象持有一个我们用来发出注销信号的通道。
UserInfo对象将稍后用于检查loginTime,它将被用作不活动计时器。我们还从UserInfo中获取UserName。代码如下所示:type Service struct { user login.UserInfo logoutChannel chan struct{ }构造函数需要获取从
wasm.go文件发送到通道的注销事件注入的通道。代码如下所示:func New(logout chan struct{}) *Service { return &Service{ logoutChannel: logout, } } -
我们希望能够在 Go 代码内部触发对 MQTT 代理的连接尝试,因此我们调用位于
mqtt.js文件中的js函数,如下所示:func (service *Service) ConnectMQTT() { println("connecting to mqtt") js.Global(). Get("MQTTconnect"). Invoke() } -
现在,我们定义一个可以作为事件监听器回调使用的函数。由于此函数是在 JavaScript 内部被调用的,我们需要满足一个接受
js.Value和[]js.Value参数并返回interface{}的函数签名,如下所示:func (service *Service) logout(this js.Value, args []js.Value) interface{} { service.logoutChannel <- struct{}{} return nil } -
bedroomOn函数被用作 JavaScript 代码的回调,当用户点击On按钮时将被调用。代码如下所示:func (service *Service) bedroomOn(this js.Value, args []js.Value) interface{} { -
当用户执行任何操作时,我们需要检查活动计时器是否超时。我们通过检查
loggedInAt时间戳来完成此操作。如果用户超过 5 分钟未活动,我们将执行注销,如下所示:if time.Now().After(service.user.LoggedInAt.Add(5 * time.Minute)) { println("timeOut: perform logout") service.logout(js.ValueOf(nil), nil )return nil } -
现在,我们只需在 JavaScript 代码中调用
publish函数并重置loggedInAt计时器,如下所示:println("turning lights on") // room # module # action js.Global().Get("publish").Invoke("home/bedroom/lights", "on") service.user.LoggedInAt = time.Now() return nil -
关闭灯光的方式与打开灯光的方式类似。唯一的区别是消息的有效负载。我们在这里发送
off而不是on,如下所示:func (service *Service) bedroomOff(this js.Value, args []js.Value) interface{} { if time.Now().After(service.user.LoggedInAt.Add(5 * time.Minute)) { println("timeOut: perform logout") service.logout(js.ValueOf(nil), nil) return nil } println("turning lights off") js.Global().Get("publish").Invoke("home/bedroom /lights","off") service.user.LoggedInAt = time.Now() return nil }
我们已成功实现了完整控制逻辑。现在,我们需要实现创建视图的逻辑。以下是完成此操作的必要步骤:
-
当我们创建仪表板视图时,我们了解哪个用户刚刚登录,因此我们如下存储此信息:
func (service *Service) RenderDashboard(user login.UserInfo) { service.user = user -
就像在登录视图中一样,我们告诉浏览器通过推送新的状态显示另一个 URL,如下所示:
tinydom.GetWindow(). PushState(nil, "dashboard", "/dashboard") -
我们创建一个新的
div元素并设置一个Id值,以便我们可以在稍后识别该元素,在注销时将其删除。代码如下所示:body := doc.GetElementById("body-component") div := doc.CreateElement("div"). SetId("dashboard-component") -
现在,我们通过以下方式向用户问候他们的名字:
h1 := doc.CreateElement("h1"). SetInnerHTML("Dashboard") h2 := doc.CreateElement("h2"). SetInnerHTML(fmt.Sprintf("Hello %s", service.user.UserName)) -
由于我们希望有一种简单的方法向仪表板添加新组件,我们使用表格来控制组件。这样,我们就可以简单地稍后添加新的表格行。当然,我们也可以创建新的自定义组件或使用任何其他类型的结构,但向表格中添加行更容易理解。整个过程如下所示:
tableElement := table.New(). SetHeader("Component", "Actions") tbody := doc.CreateElement("tbody") tr := doc.CreateElement("tr") componentNameElement := doc.CreateElement("td"). SetInnerHTML("Bedroom Lights") componentControlElement := doc.CreateElement("td") onButton := input.New(input.ButtonInput). SetValue("On"). AddEventListener("click", js.FuncOf(service.bedroomOn)) offButton := input.New(input.ButtonInput). SetValue("Off"). AddEventListener("click", js.FuncOf(service.bedroomOff)) componentControlElement.AppendChildren(onButton, offButton) tr.AppendChildren(componentNameElement, componentControlElement) tbody.AppendChildren(tr) tableElement.SetBody(tbody) -
除了基于不活动的注销外,我们还想让用户有手动注销的可能性。以下是设置此功能的方法:
logout := input.New(input.ButtonInput). SetValue("logout"). AddEventListener("click", js.FuncOf(service.logout), ) -
最后一步是将所有子元素附加到
div,然后将div附加到主体中,如下面的代码片段所示:div.AppendChildren( h1, h2, tableElement.Element, tinydom.GetDocument().CreateElement("br"), logout, ) body.AppendChild(div) }
太好了!我们现在已经完全实现了创建视图所需的逻辑。当由浏览器渲染时,视图看起来类似于以下这样:

图 8.4 – 仪表板视图
现在,我们只需要在应用本身完成之前实现主要逻辑。
实现主要逻辑
我们将把不同组件(登录、仪表板)的逻辑拆分到单独的文件中。我们现在在light-control文件夹中创建的wasm.go文件将包含main()函数,并用于控制应用中的流程。
现在我们将介绍一个新的库,称为tinydom。tinydom库封装了syscall/js API,并提供了一些额外的数据类型,如Video、Form或Label。使用这个库,我们可以节省大量的tinydom在js.Value类型上的工作,它与syscall/js API 完全兼容。您可以使用以下命令安装tinydom:
go get github.com/Nerzal/tinydom
现在已经设置好了,让我们继续按照以下步骤实现逻辑:
-
在
main函数上方,我们定义了一些变量。我们将在main函数外部定义它们,因为我们将在函数内部使用它们。代码如下所示:var window = tinydom.GetWindow() var loginService *login.Service var loginState login.UserInfo var dashboardService dashboard.Service -
我们使用
main函数来渲染登录屏幕,并设置登录和注销事件处理器。这是通过以下方式完成的:func main() { loginState = login.UserInfo{} loginChannel := make(chan string, 1) loginService = login.NewService(loginChannel) loginService.RenderLogin() go onLogin(loginChannel) logoutChannel := make(chan struct{}, 1) go onLogout(logoutChannel) dashboardService = dashboard.New(logoutChannel) wait := make(chan struct{}, 0) <-wait } -
当从通道接收到登录事件时,我们初始化
loginState,连接到 MQTT,并渲染仪表板视图,如下所示:func onLogin(channel chan string) { for { userName := <-channel println(userName, "logged in!") loginState.UserName = username loginState.LoggedIn = true loginState.LoggedInAt = time.Now() removeLoginComponent() dashboardService.ConnectMQTT() dashboardService.RenderDashboard(loginState) } } -
为了从视图中移除一个对象,我们只需从 DOM 中移除它。我们通过获取 body 元素并移除具有
login-componentID 的子元素来实现,如下所示:func removeLoginComponent() { doc := tinydom.GetDocument() doc.GetElementById("body-component"). RemoveChild(doc.GetElementById( "login-component")) } -
我们还希望能够移除仪表板视图,以便能够返回到登录视图。我们通过运行以下代码来实现:
func removeDashboardComponent() { doc := tinydom.GetDocument() doc.GetElementById("body-component"). RemoveChild(doc.GetElementById( "dashboard-component")) } -
当我们从通道接收到注销事件时,我们移除仪表板视图,重置登录状态,并再次渲染登录视图,如下所示:
func onLogout(channel chan struct{}) { for { <-channel println("handling logout event") removeDashboardComponent() loginState = login.UserInfo{} loginService.RenderLogin() } }
这就是我们的主要逻辑所需的所有内容。下一步是实现一个为客户端提供应用的服务器。
提供应用服务
提供应用服务与在第七章中提供应用服务的方式类似,在 TinyGo Wasm 仪表板上显示天气警报,但我们在这里添加了一个额外的技巧。当用户刷新页面或尝试访问我们通过推送状态设置的 URL 之一时,服务器通常不会意识到这些 URL。这就是为什么我们将客户端重定向到正确的 URL。我们通过简单地重定向用户到根 URL 来处理这种情况。
现在,将以下代码添加到位于新创建的wasm-server文件夹中的main.go文件中,该文件夹位于Chapter08文件夹内:
const dir = "Chapter08/html"
var fs = http.FileServer(http.Dir(dir))
func main() {
log.Print("Serving " + dir + " on http://localhost:8080")
http.ListenAndServe(":8080",
http.HandlerFunc(handleRequest))
}
func handleRequest(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Cache-Control", "no-cache")
if strings.HasSuffix(req.URL.Path, ".wasm") {
resp.Header().Set("content-type", "application/wasm")
}
requestURI := req.URL.RequestURI()
if strings.Contains(requestURI, "dashboard") ||
strings.Contains(requestURI, "login") {
http.Redirect(resp, req, "http://localhost:8080",
http.StatusMovedPermanently)
return
}
fs.ServeHTTP(resp, req)
}
我们已经完成了应用及其提供应用的服务器。现在让我们构建并运行一切。我们将使用Makefile来完成此示例,但您也可以使用 Docker 容器、shell 脚本或类似的东西。我们需要构建 Wasm 应用,复制所有依赖项,并启动服务器。Makefile 方法如下所示:
light-control:
rm -rf Chapter08/html
mkdir Chapter08/html
tinygo build -o Chapter08/html/wasm.wasm -target wasm -no-debug Chapter08/light-control/wasm.go
cp Chapter08/light-control/wasm_exec.js Chapter08/html/
cp Chapter08/light-control/wasm.js Chapter08/html/
cp Chapter08/light-control/mqtt.js Chapter08/html/
cp Chapter08/light-control/index.html Chapter08/html/
go run Chapter08/wasm-server/main.go
为了运行服务器,我们使用以下命令:
make light-control
当这成功后,继续通过在浏览器中访问以下 URL 来尝试我们的应用:
localhost:8080
当使用 Mosquitto Docker 容器时,别忘了检查容器是否已启动,并且容器没有运行。只需使用以下命令启动它:
docker start mosquitto
由于我们已经成功构建了一个能够向 MQTT 代理发布消息的 Wasm 应用,我们现在可以继续创建一个消费这些消息的客户端,这正是我们将在下一节中要做的。
构建智能家居客户端
智能家居基本上是基于前提条件来激活和停用事物。例如,我们可能想在夜间有人进入房间时打开灯光。在这本书的整个过程中,我们已经根据前提条件激活和停用了许多事物,但它们大多数都没有连接到网络。现在我们将学习如何通过网络发送信号。这些信号将被用作前提条件。完成本节后,我们将为构建自己的智能家居客户端做好准备,这些客户端可以通过网络触发。
将在 Arduino Nano 33 IoT 上运行的客户端将简单地连接到 MQTT 代理,并订阅一个主题。当有消息进入该主题时,我们需要反序列化消息并执行消息中定义的操作。
对于我们的示例项目,我们将要实现 LED 的开关。当然,单个 LED 可能不足以照亮整个卧室,所以我们将在本节末尾讨论其他实际解决方案。让我们先设置电路。
设置电路
这个项目的电路相当简单。只需按照以下步骤设置一切:
-
将一个阴极在E40上的 LED 放置在面包板上。
-
将 A41(GND)与电源总线上的GND通道连接。
-
将 LED 的阳极与 D4 引脚连接,并在之间放置一个 68 欧姆的电阻。如果你没有 68 欧姆的电阻,你也可以使用一个 100 欧姆的。将B52与电源总线上的GND通道连接。
结果应该看起来像这样:

图 8.5 – 灯光控制电路(图片来自 Fritzing)
如果你不确定你的 LED 有哪些技术规格,因为你根本就没有数据表,请查看以下 URL。这里提供了一个电阻计算器,以及不同 LED 颜色的良好电压:
www.digikey.de/en/resources/conversion-calculators/conversion-calculator-led-series-resistor
太好了!我们现在已经设置完毕,准备实现逻辑。
实现逻辑
对于我们的最终项目,我们需要在Chapter08文件夹内创建一个名为light-control-client的新文件夹,并在其中创建一个名为main.go的新文件。main函数的逻辑仅用于初始化一切,而实际的逻辑将位于单独的函数中。要实现它,请按照以下步骤操作:
-
在主函数上方,我们添加了 Wi-Fi 凭证和 LED 引脚的常量。我们只需将 SSID 和密码替换为我们自己的数据,如下所示:
const ssid = "" const password = "" const bedroomLight = machine.D4 -
现在,在主函数内部,我们想要控制 LED。为此,我们需要将引脚配置为输出,如下面的代码片段所示:
time.Sleep(5 * time.Second) bedroomLight.Configure(machine.PinConfig{Mode: machine.PinOutput}) -
下一步是建立 Wi-Fi 连接,如下面的代码片段所示:
wifiClient := wifi.New(ssid, password) println("configuring nina wifi chip") err := wifiClient.Configure() if err != nil { printError("could not configure wifi client", err) } println("checking firmware") wifiClient.CheckHardware() wifiClient.ConnectWifi() -
现在,我们需要连接到 MQTT 代理。你需要将 IP 地址替换为你的 MQTT 代理的 IP 地址,如下所示:
mqttClient := mqttclient.New("tcp://192.168.2.102:1883", "lightControl") println("connecting to mqtt broker") err = mqttClient.ConnectBroker() if err != nil { printError("could not configure mqtt", err) } println("connected to mqtt broker") -
为了订阅一个主题,我们需要提供 QOS 级别和一个当该主题上的消息到达时被调用的函数,如下所示:
err = mqttClient.Subscribe( "home/bedroom/lights", 0, HandleActionMessage, ) if err != nil { printError("could not subscribe to topic", err) } -
最后一步是添加一个阻塞函数,以便程序不会终止,如下面的代码片段所示:
println("subscribed to topic, waiting for messages") select {}
这就是我们初始化所需的一切。我们现在只需要实现处理传入消息的逻辑。为此,请按照以下步骤操作:
-
首先,我们需要通过分割字符串来反序列化传入的消息,然后根据接收到的房间调用相应的函数。如果我们收到无效的消息或完成消息的处理,我们将消息
Ack,如下所示:func HandleActionMessage(client mqtt.Client, message mqtt.Message) { println("handling incoming message") payload := string(message.Payload()) controlBedroom(client, payload) message.Ack() } -
在下一步中,我们只需根据提供的模块和动作执行正确的函数。完整的函数在以下代码片段中实现:
func controlBedroom(module, action string) { switch action { case "on": controlBedroomlights(client, true) case "off": controlBedroomlights(client, false) default: println("unknown action:", action) } } -
现在,我们只需激活或关闭 LED,如下所示:
func controlBedroomlights(action bool) { if action { bedroomLight.High() } else { bedroomLight.Low() } } -
我们希望在初始化一切的同时停止执行并重复打印错误。为此,我们使用以下辅助函数:
func printError(message string, err error) { for { println(message, err.Error()) time.Sleep(time.Second) } }
这就是我们实现客户端所需的一切。我们现在可以继续使用以下命令闪存程序:
tinygo flash --target arduino-nano33 Chapter08/light-control-client/main.go
当程序运行时,我们现在可以使用 Wasm 应用来打开和关闭 LED。所以,现在就试试吧。
好的——你尝试了;一切按预期工作,现在你想知道下一步是什么。如果出了问题,LED 从未激活或关闭怎么办?
在这种情况下,我强烈建议在 PuTTY 中查看串行端口的输出。如果那里看起来一切正常,你可以尝试通过 MQTT Explorer 向代理发送 MQTT 消息。如果你仍然没有运气,你应该检查你的接线;如果其他方法都不起作用,你可能想尝试直接从 GitHub 仓库闪存代码。
现在一切按预期工作,你可能会认为只能激活和关闭灯光是件好事,但关于在仪表板上显示灯光的当前状态怎么办?让我们作为下一步来做这件事。
从微控制器请求数据
我们可能想知道客厅里的灯是开着还是关着,而不必走到房间里去。所以,如果 Wasm 应用程序能够请求灯的状态并显示它,那就太好了。
现在,让我们假设我们有一个或多个微控制器在不同的房间里监听消息。对于这个例子,我们不希望微控制器持续报告灯的状态,因为这会导致不必要的网络流量。所以,我们继续发送一个请求数据的消息。微控制器订阅了状态主题,并接收到了消息。在收到状态请求后,它们通过各自发送状态消息来回答。
这个过程在以下图中表示:

图 8.6 – 架构图
为了实现这种行为,一个微控制器就足够了。所以,让我们继续并相应地更新我们的代码。为此,请按照以下步骤操作:
-
在
wasm.js文件中,我们订阅了home/status主题。这是微控制器将要发布状态消息的主题。我们还想在连接建立时调用一个go函数。请参考以下代码:function onConnect() { console.log("Successfully connected to mqtt broker"); mqtt.subscribe("home/status") handleOnConnect() } -
在
dashboard.go文件中,我们在Service结构体中添加了一个Boolean来保存卧室灯的状态,如下所示:type Service struct { user login.UserInfo bedroomLights bool logoutChannel chan bool } -
我们需要将
handleMessage函数暴露给 JavaScript 代码,以便在收到新消息时调用它。我们还向 JavaScript 代码暴露了一个handleConnect函数,该函数在连接到代理时被调用。代码如下所示:func New(logout chan bool) Service { js.Global(). Set("handleMessage", js.FuncOf(handleMessage)) js.Global(). Set("handleOnConnect", js.FuncOf(handleOnConnect)) return Service{ logoutChannel: logout, } } -
由于我们想在表格中添加一个新列,我们需要添加一个新的列标题。我们可以使用以下代码来完成此操作:
tableElement := table.New(). SetHeader( "Component", "Actions", "Status", ) -
现在,我们想在表格中添加一个新的“状态”列,因此我们需要在
RenderDashboard函数中添加一些代码行。在controlElement下方,我们添加一个新的statusElement,如下所示:componentControlElement := doc.CreateElement("td") statusElement := doc.CreateElement("td"). SetId("bedroom-light-status"). SetInnerHTML("off") -
由于我们添加了一个列,我们需要将其添加到表格行中。我们可以通过运行以下代码来完成此操作:
tr.AppendChildren( componentNameElement, componentControlElement, statusElement, ) -
现在,我们添加了一个新的函数,它允许我们请求状态。我们使用
home/status-request主题来达到这个目的。这在上面的代码片段中有说明:func requestStatus() { js.Global(). Get("publish"). Invoke("home/status-request", "") } -
由于我们现在有了请求状态的能力,我们只需要调用它来获取状态更新。我们在 MQTT 连接建立后立即这样做,如下所示:
func handleOnConnect(this js.Value, args []js.Value) interface{} { requestStatus() return nil } -
我们需要添加的最后一件事情是处理消息。所以,让我们将消息拆分为房间、组件和动作,并根据房间和组件调用正确的函数,如下所示:
func handleMessage(this js.Value, args []js.Value) interface{} { message := args[0].String() println("status message arrived:", message) messageParts := strings.Split(message, "#") room := messageParts[0] component := messageParts[1] switch room { case "bedroom": switch component { case "lights": doc.GetElementById("bedroom-light- status"). SetInnerHTML(messageParts[2])default: println("unknown component:", component)} default: println("unknown room:", room)} return nil }
我们已经成功地将所有需要添加的内容添加到了 Wasm 应用程序中。现在,让我们扩展 light-control-client 程序的逻辑。为此,请按照以下步骤操作:
-
我们需要保存灯的当前状态,因此我们在包级别添加了一个新变量,如下所示:
var bedroomLightStatus = false -
在
main函数中,我们订阅了home/status-request主题,如下面的代码片段所示:err = mqttClient.Subscribe("home/status-request", 0, HandleStatusRequestMessage) if err != nil { printError("could not subsribe to topic", err) } -
现在我们需要实现状态请求的处理程序。我们简单地报告状态,并在之后
Ack消息,如下面的代码片段所示:func HandleStatusRequestMessage(client mqtt.Client, message mqtt.Message) { reportStatus(client) message.Ack() } -
reportStatus函数只需要检查并报告状态。这可以通过运行以下代码来完成:func reportStatus(client mqtt.Client) { status := "off" if bedroomLightStatus { status = "on" } token := client.Publish( "home/status", 0, false, fmt.Sprintf("bedroom#lights#%s", status), ) if token.Wait() && token.Error() != nil { println(token.Error()) } } -
在
HandleActionMessage函数中,我们需要将mqtt.Client作为附加参数传递给controlBedroom函数。我们可以通过运行以下代码来实现:controlBedroom( client, splittedString[1], splittedString[2], ) -
现在,我们还需要将
mqtt.Client添加到controlBedroom参数列表中。我们可以通过运行以下代码来实现:func controlBedroom(client mqtt.Client, module, action string) { -
我们随后将客户端传递给
controlBedroomlights函数,如下所示:controlBedroomlights(client, true) -
最后一步是在
controlBedroomLights函数中更新和报告状态。我们也在这里更新状态,以便在点击开/关按钮后,在 Wasm 应用中获取反馈。下面的代码片段显示了这一代码:func controlBedroomlights(client mqtt.Client, action bool) { if action { bedroomLight.High() bedroomLightStatus = true } else { bedroomLight.Low() bedroomLightStatus = false } reportStatus(client) } }
太棒了!现在客户端可以在 Wasm 应用中检查灯光的状态。
好吧,恭喜!你已经完成了这本书中的所有项目。现在让我们来看看可能的替代解决方案,以替代我们当前的实现。
检查其他实现想法
通过在 Wasm 应用中按下一个按钮来点亮一个小型 LED 灯很令人兴奋,但就家庭自动化而言,这实际上并没有太大的帮助。LED 可以被看作是你能想到的任何东西的占位符。我们已经实现了触发任何类型动作的逻辑。我们有哪些可能性来控制真正的灯光或其他组件?
使用智能插座
一种选择是使用智能插座,这些插座可以通过 Wi-Fi 或蓝牙进行控制。大多数它们不提供公开 API,需要你逆向工程信号来控制它们,但也有一些制造商为他们的产品提供了 API 参考。
这的一个例子是 NETIO PowerBOX 3Px,这是一个支持许多 API 的插座,如 MQTT、HTTP、JavaScript 对象表示法(JSON)、传输控制协议(TCP)等。另一个例子是 WIFIPLUG——他们也生产具有公开 API 的智能插座。
使用继电器
在构建我们的自动植物浇水系统时,我们已经学会了如何控制继电器。一些继电器和板子支持高达 230V 和 10 安培(A)的电压,这足以供电几乎任何电气设备。尽管继电器可能能够处理 230V 或 130V 的电压,但你永远不应该操作主电压。使用高达 12V 的电流可以构建许多优秀的项目。
使用 TLS
在开发 物联网(IoT)应用程序时,考虑安全性非常重要。在撰写本文时,Arduino Nano 33 IoT 的 Wi-Fi 驱动程序实现不支持 TLS。这是一个正在积极工作的主题,并肯定会很快实现。因此,在实现操作超出您本地网络的功能时,您应该确保使用 TLS。此外,如前所述,在实现登录视图时,我们了解到将凭据嵌入到 Wasm 中不如将凭据嵌入到二进制文件中安全。
我们现在已经了解到,有多个智能插座制造商提供了开放 API,这使得它们很容易被集成到我们的项目中安全地。我们还了解到,我们可以利用继电器来控制 LED 灯带或其他设备。
摘要
在本章中,我们学习了如何构建一个完全且动态创建视图的 Wasm 应用程序。我们通过操作 DOM 来学习这一点。我们还学习了如何在 Wasm 中处理用户输入以及如何创建可重复使用的 JavaScript 组件,以便在未来的 Wasm 项目中使用。
然后,我们学习了如何通过实现一个能够切换由 LED 表示的灯光的仪表板,从 Wasm 应用程序内部发布 MQTT 消息。
这本书的任务是让您更接近编程微控制器和 Wasm,并教您如何用很少的代码实现小型项目——希望您会玩得很开心。您现在已经学到了实现您自己的项目想法所需的一切。
问题
-
为什么在 Wasm 代码内部验证凭据不安全?
-
在 Wasm 代码内部验证凭据有哪些替代方案?
附录 – “Go”前进
在本附录部分,我们将探讨一些在前面章节中没有解释的 Go 编程语言的概念。
以下主题被涵盖:
-
阻塞 goroutine
-
查找堆分配
阻塞 goroutine
阻塞 goroutine 可能很重要。最简单的例子是main函数。如果我们没有在main函数内部使用阻塞调用或循环,程序将终止并重新启动。在大多数情况下,我们不希望程序终止,因为我们可能希望等待任何可能触发代码中进一步操作的输入信号。
现在我们来看一下阻塞 goroutine 的一些可能性。在某些情况下,阻塞 goroutine 是必要的,以便获得时间让调度器在其他 goroutine 上工作。
从通道读取
阻塞 goroutine 的一种非常常见的方式是从通道读取。从通道读取将阻塞 goroutine,直到可以读取值。以下代码示例说明了这一点:
func main() {
blocker := make(chan bool, 1)
<-blocker
println("this gets never printed")
}
一个 select 语句
一个select语句让 goroutine 等待多个操作。其语法与switch语句的语法类似。以下代码示例实现了一个阻塞直到两个情况之一可以运行的select语句:
func main() {
resultChannel := make(chan bool)
errChannel := make(chan error)
select {
case result := <-resultChannel:
println(result)
case err := <-errChannel:
println(err.Error())
}
}
注意
如果两个情况恰好同时准备好,select语句将随机选择一个情况。
我们有时会遇到这样的情况,即我们的main函数在其他 goroutine 等待处理传入消息时应该什么也不做。在这种情况下,我们可以使用一个空的select语句来无限期地阻塞。以下代码片段是一个这样的例子:
func main() {
select {}
println("this gets never printed")
}
休眠是一个阻塞调用
在某些情况下,我们只想为调度器工作在另一个 goroutine 上争取一些时间。在这种情况下,我们可以使用time.Sleep()来短暂休眠,然后继续在当前的 goroutine 上工作。这可以像以下代码示例那样:
func main() {
for {
println("do some work")
time.Sleep(50 * time.Millisecond)
}
}
我们已经学习了不同的方法来阻塞 goroutine,现在让我们学习一点关于分配的内容。
查找堆分配
TinyGo 编译器工具链试图以这种方式优化代码,即结果中不留下任何堆分配,但有些分配无法优化。是否有办法知道哪些分配?是的!我们可以禁用build和flash命令。
当 GC 被禁用时,编译过程将失败并抛出一个错误,该错误指向导致堆分配的代码行。让我们看看以下导致堆分配的代码示例:
package main
var myString *string
func main() {
value := "my value"
myString = &value
}
在构建此程序时,我们将使用以下命令禁用 GC:
tinygo build -o Appendix/allocations/hex --gc=none --target=arduino Appendix/allocations/main.go
这将引发以下错误:
/tmp/tinygo589978451/main.o: In function `main.main':
/home/tobias/go/src/github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/blob/master/Appendix/allocations/main.go:6: undefined reference to `runtime.alloc'
collect2: Error: ld returned 1 as End-Status
error: failed to link /tmp/tinygo589978451/main: exit status 1
将值的指针存储在全局对象中会导致堆分配。我们如何改进程序以不分配堆内存?我们可以简单地在这里省略使用指针。查看以下示例:
package main
var myString string
func main() {
value := "my value"
myString = value
}
我们现在可以尝试再次构建程序,使用以下命令:
tinygo build -o Appendix/allocations/hex --gc=none --target=arduino Appendix/allocations/main.go
此命令将创建输出文件,并且不会抛出任何错误。
如果你想要检查哪些操作会导致堆分配,哪些不会,请查看以下链接:
tinygo.org/compiler-internals/heap-allocation/
如果你想要更好地理解堆,请查看以下链接:
medium.com/eureka-engineering/understanding-allocations-in-go-stack-heap-memory-9a2631b5035d
第九章:评估
第一章
-
tinygo info命令。 -
tinygo flash命令。 -
Arduino UNO 的时钟速度为 16 MHz。以 16 MHz 闪烁非常快,我们无法看到它。这就是为什么我们设置 LED 以毫秒为单位开启和关闭。
-
您可以在代码仓库中找到解决方案:
github.com/PacktPublishing/Creative-DIY-Microcontroller-Projects-with-TinyGo-and-WebAssembly/blob/master/Chapter01/blink-sos
第二章
-
我们这样做是为了防止 LED 损坏。Arduino 入门套件或类似套件中的大多数 LED 都使用低于 5V 的电压。用 5V 供电可能会永久损坏它们。
-
可以通过使用外部上拉(或下拉)电阻,或者使用内置电阻来实现。
-
为了给调度器运行 goroutine 的时间,它需要进入休眠状态。
-
您可以在 GitHub 仓库中的
traffic-lights-blink文件夹下的Chapter02目录中找到这个问题的解决方案。
第三章
-
键 3 位于第 0 行第 2 列,因此坐标是 0,2。
第四章
-
关闭这些传感器可以节省能量并延长水位传感器的使用寿命,因为它减缓了腐蚀。
-
当信号(输入)端口信号为高时,电路闭合。
第五章
-
默认情况下,5V 引脚是不激活的。要激活它,需要焊接。或者,当 Arduino 通过 USB 端口供电时,可以使用 VIN 引脚。
-
pulseLength存储发送脉冲直到返回的时间。因此脉冲走了两倍的距离。这就是为什么我们必须将pulseLength除以2。
第六章
-
I2C 消息包含消息针对的设备的地址。
-
CS 引脚被用来表示消息是针对特定设备的,因为 CS 引脚直接连接到设备。
第七章
-
为了确保消息能够送达,我们需要使用 QOS 级别 1 或级别 2,因为级别 0 是一种“发射后不管”的方法。
-
是的,没有、一个或多个客户端可以订阅一个主题。
第八章
-
在 Wasm 代码内部验证凭证是不安全的,因为 Wasm 二进制文件正在发送给客户端。然后 Wasm 二进制文件可以被反编译,凭证可以被提取。
-
我们始终可以使用任何类型的授权服务。一般来说,凭证不应该在客户端逻辑内部进行验证,而应该在任何其他服务上进行验证。
第十章:后记
写这本书是一件非常有趣的事情。我真的很享受比以往任何时候都更深入地挖掘 TinyGo。我在过程中发现了一些问题,创建了问题,贡献了一些驱动程序,并学到了很多。由于写这本书,GitHub 上还创建了两个新的库,TinyDom 和 TinySocket。
我想利用这个机会感谢所有帮助我写这本书的人。首先,有阿洛克·杜里,他在互联网的荒野中找到我,给了我写这本书的机会。他还提供了许多天才的想法,这些想法已经在一些项目中得到了实施。
此外,我还要感谢 Nitee Shetty 和 Tiksha Abhimanyu Lad,他们通过提供大量的反馈来帮助我在书中找到良好的节奏,他们还提出了许多好问题,以便从我最深处挖掘。
此外,还要感谢 Packt 团队的其他成员,他们帮助我完成了这本书!
当然,我还要抓住机会感谢恩里科·冯·奥特和约翰内斯·科拉塔,他们立即接受了我的请求,担任这本书的技术审稿人。
特别感谢弗洛里安·普赫尔,他向我解释了合同细节,以及马丁·纳哈冈在公关话题上的帮助。
没有感谢我的父母,卡尔滕和戴安娜·蒂尔,他们抚养我长大,这份感激之情又怎能表达?

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及行业领先的工具,帮助你规划个人发展并推进职业生涯。更多信息,请访问我们的网站。
第十一章:为什么订阅?
-
使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,更多时间编码
-
通过为你量身定制的技能计划提高学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于快速访问关键信息
-
复制粘贴、打印和收藏内容
你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在 packt.com 升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。
在 www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
你可能还喜欢以下书籍
如果你喜欢这本书,你可能对 Packt 的以下其他书籍也感兴趣:
精通 Go 语言 – 第二版
Mihalis Tsoukalos
ISBN: 978-1-83855-933-5
-
使用 Go 进行生产系统的清晰指导
-
详细解释 Go 内部工作原理,语言背后的设计选择,以及如何优化你的 Go 代码
-
所有 Go 数据类型、组合类型和数据结构的完整指南
-
掌握包、反射和接口,以实现有效的 Go 语言编程
-
构建高性能的系统网络代码,包括服务器和客户端应用程序
-
使用 WebAssembly、JSON 和 gRPC 与其他系统进行接口
-
编写可靠、高性能的并发代码
-
在 Go 中构建机器学习系统,从简单的统计回归到复杂的神经网络

动手实践 Go 语言软件工程
Achilleas Anagnostopoulos
ISBN: 978-1-83855-449-1
-
理解软件开发生命周期的不同阶段和软件工程师的角色
-
使用 gRPC 创建 API 并利用 gRPC 生态系统提供的中间件
-
探索管理项目依赖关系的各种方法
-
从零开始构建端到端项目,并探索不同的扩展策略
-
开发一个图处理系统,并将其扩展为分布式运行
-
在 Kubernetes 上部署 Go 服务,并使用 Prometheus 监控其健康状态
Packt 正在寻找像你这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们与全球技术社区分享他们的见解。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。
留下评论 - 让其他读者了解您的想法
请通过在您购买本书的网站上留下评论来与其他人分享您对本书的看法。如果您从亚马逊购买了本书,请在本书的亚马逊页面上留下诚实的评论。这对其他潜在读者来说至关重要,他们可以看到并使用您的客观意见来做出购买决定,我们了解客户对我们产品的看法,我们的作者可以看到他们对与 Packt 合作创建的标题的反馈。这只需您几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都很有价值。谢谢!
目录
-
使用 TinyGo 和 WebAssembly 的创意 DIY 微控制器项目
-
贡献者
-
关于作者
-
关于审稿人
-
前言
-
本书面向的对象
-
本书涵盖的内容
-
充分利用本书
-
下载示例代码文件
-
代码实战
-
下载彩色图片
-
使用的约定
-
联系
-
评论
-
-
第一章:TinyGo 入门
-
技术要求
-
理解 TinyGo 是什么
-
TinyGo 是如何工作的
-
比较 TinyGo 和 Go
-
-
设置 TinyGo
-
在 Linux 上安装
-
在 Windows 上安装
-
在 macOS 上安装
-
在 Docker 上安装
-
-
理解 IDE 集成
-
VS Code 集成
-
通用 IDE 集成
-
设置 Goland
-
集成任何编辑器
-
-
Arduino UNO
-
了解技术规格
-
探索引脚布局
-
-
检查事物的“Hello World”
-
准备要求
-
准备项目
-
编程微控制器
-
闪存程序
-
使用 TinyGo 游乐场
-
-
总结
-
问题
-
-
第二章:构建交通灯控制系统
-
技术要求
-
点亮外部 LED
-
按按钮时点亮 LED
-
构建电路
-
编程逻辑
-
-
构建交通灯
-
构建电路
-
创建文件夹结构
-
编写逻辑
-
-
构建带人行横道的交通灯
-
组装电路
-
设置项目结构
-
编写逻辑
-
-
总结
-
问题
-
进一步阅读
-
-
第三章:使用键盘构建安全锁
-
技术要求
-
写入串行端口
-
监控串行端口
-
监控键盘输入
-
构建电路
-
理解 4x4 键盘的工作原理
-
-
编写驱动程序
-
驱动变量
-
配置
-
GetIndices
-
GetKey
-
main
-
-
为 TinyGo 寻找驱动程序
- 向 TinyGo 贡献驱动程序
-
控制伺服电机
-
理解 SG90 伺服电机
-
构建电路
-
编写伺服控制逻辑
-
-
使用键盘构建安全锁
-
构建电路
-
编写逻辑
-
-
总结
-
问题
-
-
第四章:构建植物浇水系统
-
技术要求
-
读取土壤湿度传感器数据
-
组装电路
-
寻找阈值
-
理解 TinyGo 中的 ADC
-
为传感器编写库
-
测试库
-
-
读取水位传感器数据
- 编写水位传感器库
-
控制蜂鸣器
- 编写蜂鸣器库
-
控制泵
-
使用继电器
-
编写泵库
-
-
给你的植物浇水
-
总结
-
问题
-
参考文献
-
-
第五章:构建无接触洗手计时器
-
技术要求
-
介绍 Arduino Nano 33 IoT
- 安装 Bossa
-
学习测量距离
-
理解 HC-SR04 传感器
-
组装电路
-
编写库
-
TinyGo 中的单元测试
-
编写库的示例程序
-
-
使用 4 位 7 段显示屏
-
使用 MAX7219
-
编写控制 MAX7219 的库
-
编写控制 hs42561k 显示屏的库
-
-
将所有内容整合在一起
-
摘要
-
问题
-
-
第六章:使用 I2C 和 SPI 接口构建用于通信的显示屏
-
技术要求
-
探索 TinyGo 驱动程序
-
在 HD44780 16x2 液晶显示屏上显示文本
-
构建电路
-
理解 I2C
-
编写代码
-
-
在显示屏上显示用户输入
-
构建命令行界面
-
理解 SPI
-
在显示屏上显示简单游戏
-
构建电路
-
使用 ST7735 显示屏
-
开发游戏
-
-
摘要
-
问题
-
-
第七章:在 TinyGo Wasm 仪表板上显示天气警报
-
技术要求
-
建立气象站
-
组装电路
-
编程气象站
-
-
向代理发送 MQTT 消息
-
实现 Wi-Fi 包
-
实现 MQTT 客户端抽象层
-
实现气象站
-
-
介绍 Wasm
-
在 Wasm 页面上显示传感器数据和天气警报
-
提供应用程序服务
-
实现天气应用程序
-
-
摘要
-
问题
-
-
第八章:通过 TinyGo Wasm 仪表板自动化和监控您的家庭
-
技术要求
-
构建智能家居仪表板
-
创建可重用的 MQTT 组件
-
设置 Wasm 实例化代码
-
创建 HTML 模板
-
实现登录视图逻辑
-
实现仪表板组件
-
实现主逻辑
-
提供应用
-
-
构建智能家居客户端
-
设置电路
-
实现逻辑
-
-
从微控制器请求数据
- 检查其他实现想法
-
摘要
-
问题
-
-
附录 – "Go" 前行
-
阻塞 goroutine
-
从通道读取
-
一个选择语句
-
睡眠是一个阻塞调用
-
-
查找堆分配
-
-
评估
-
第一章
-
第二章
-
第三章
-
第四章
-
第五章
-
第六章
-
第七章
-
第八章
-
-
后记
- 为什么订阅?
-
你可能喜欢的其他书籍
-
Packt 正在寻找像你这样的作者
-
留下评论 - 让其他读者了解你的想法
-
标记
-
封面
-
目录










浙公网安备 33010602011771号