UDOO-入门手册-全-

UDOO 入门手册(全)

原文:zh.annas-archive.org/md5/4AF381CD21F1B858B50BF52774AC99BB

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2000 年代初以来,由于工程和微电子方面的许多进步,全球对硬件制造的重新兴趣被点燃,这促进了新型低成本制造工具的激增。各年龄段的人们,甚至儿童,开始将他们的坏设备、旧玩具和所有未使用的硬件零件转变为令人惊叹的新物体。这种非传统的设计和创造新事物的做法,以表达创造力的新方式为特征,这是形成创客文化的关键因素。

这就是创客革命,一个彻底改变了我们世界的运动。开源项目提供了所有必要的工具,释放了创造力,让我们能够构建事物,无需深厚的编程和工程知识,也无需一套昂贵的组件。事实上,创客革命取得的最重要成就之一,就是将原型制造从大小工厂转移到我们的家中。

2012 年 2 月,另一个名为 UDOO 的开源项目启动了一个集成了 Linux 和 Android 操作系统的原型开发板,目标是结合 Arduino 和 Raspberry Pi 的优势于一块单板。在项目工作一年后的 2013 年 4 月,UDOO 开发板加入了 Kickstarter 众筹平台,创客社区的反馈非常积极——项目在短短 2 天内就完成了资金筹集。

全世界的创客们都如此喜欢这个项目,以至于他们决定贡献自己的力量,不仅通过 Kickstarter 的承诺支持,还在电路设计阶段提供了有用的想法和建议。创客社区提供的帮助促成了一个强大的原型开发板,让我们能够构建一直想要的互动和创意项目。

本书将教你如何使用 UDOO 开发板作为快速原型工具,来构建你的第一个硬件项目。从涉及基础电子元件的简单应用开始,你将通过不同的项目学习构建电子电路,这些项目提供了由 Android 操作系统支持的增强互动。

本书内容涵盖

第一章 启动引擎 将引导你完成 UDOO 平台的设置和所需开发环境的配置。首先介绍开发板,展示其独特性和与其他板不同的功能;然后指导你安装 Android 操作系统。最后一部分,解释如何为 Arduino 和 Android 配置开发环境,以启动第一个 Hello World Android 应用程序。

第二章,了解你的工具,讲述了 Android 应用如何控制连接的设备。首先介绍一些 Arduino 板载特性,然后解释如何创建第一个能够与集成 Arduino 设备通信的 Android 应用。接着展示如何使用面包板创建一个功能完整的电路,以便快速原型制作。

第三章,测试你的物理应用,解释了物理应用测试背后的主要概念。第一部分展示了如何构建一个可以从软件应用中测试的电路。然后展示了如何实现一个诊断模式,以测试连接的电路是否正常工作。

第四章,使用传感器监听环境,首先解释了传感器的工作原理以及如何使用它们使原型具有上下文感知能力。然后展示了如何构建一个心跳监测器,编写 Arduino 草图读取传感器数据,以及一个 Android 应用来可视化计算结果。

第五章,管理物理组件的交互,讲述了如何管理用户交互。首先解释了一些可以用来让外部世界与系统交互的组件。然后展示了如何构建一个带有物理控制器的网络收音机,以管理原型音量和更改当前电台。在最后一部分,使用 Android API 播放网络广播流。

第六章,为家庭自动化构建 Chronotherm,解释了如何使用 UDOOUDOO 的一些功能进行家庭自动化。展示了使用检测环境温度的电路创建 Chronotherm,以及一个 Android 用户界面来可视化传感器数据,并改变每个时间间隔所需的温度。

第七章,使用 Android API 进行人机交互,为前一章的应用增加了更多功能,扩展了设置管理,使用语音识别和语音合成存储不同的预设,以管理用户的交互。

第八章,添加网络功能,再次扩展了 Chronotherm 应用,具备通过 RESTful 网络服务收集天气预报数据的能力。在最后一部分,展示了如何使用收集到的数据为 Chronotherm 提供更多功能。

第九章使用 MQTT 监控您的设备,介绍了物联网的主要概念和 MQTT 协议,用于物理设备之间的数据交换。然后展示了如何设置一个基于云的 MQTT 代理,能够接收和分发 Chronotherm 温度更新。最后一部分展示了如何编写一个独立的 Android 应用程序,以接收来自 Chronotherm 发送的数据。

这是一个附录章节,可以从以下链接下载:www.packtpub.com/sites/default/files/downloads/1942OS_Chapter_9.pdf

阅读本书所需的条件

为了运行本书中演示的代码,您需要配置开发环境,包括 Android 和 Arduino 的环境,以及安装了 Android 操作系统的双核或四核 UDOO 板,具体配置请参考第一章,启动引擎中的下载和安装 Android设置开发环境部分。

本书适合的读者

本书适合想要将技能应用于构建真实环境中能与 Android 应用交互的设备的 Android 开发者。开始构建基于 Android 的真实设备需要具备基本的 Android 编程知识。不需要预先了解原型平台或电路构建知识。

本书将教您通过一些在原型构建期间经常使用的电子组件来构建真实世界设备的基础知识,以及如何将它们与 Android 用户界面集成。

约定

在本书中,您会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码字如下显示:"The play() 方法设置当前活动电台的流媒体 URL 并开始异步准备。"

代码块如下设置:

public class HelloWorld extends Activity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_hello_world);
  }
}

当我们希望您注意代码块中的特定部分时,相关的行或项目会以粗体显示:

public class HelloWorld extends Activity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_hello_world);
  }
}

新术语重要词汇以粗体显示。您在屏幕上看到的词,例如菜单或对话框中的,会在文本中这样显示:"为了这个 HelloWorld 应用程序的目的,选择一个空白活动并点击下一步。"

注意

警告或重要注意事项会以这样的方框显示。

提示

技巧和诀窍会这样显示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或可能不喜欢的内容。读者的反馈对我们开发能让您获得最大收益的标题非常重要。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件的主题中提及书名。

如果您在某个主题上有专业知识,并且有兴趣撰写或参与书籍编写,请查看我们在www.packtpub.com/authors上的作者指南。

客户支持

既然您已经自豪地拥有了一本 Packt 图书,我们有一系列的事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您的账户中下载您已购买的 Packt 图书的示例代码文件,访问地址为www.packtpub.com。如果您在别处购买了这本书,可以访问www.packtpub.com/support进行注册,我们会将文件直接通过电子邮件发送给您。

勘误

尽管我们已经尽力确保内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——我们非常感激您能向我们报告。这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误被验证,您的提交将被接受,勘误将在我们网站的相应位置上传,或添加到现有勘误列表中。任何现有的勘误可以通过从www.packtpub.com/support选择您的标题来查看。

盗版

互联网上版权资料的盗版是所有媒体面临的持续问题。在 Packt,我们非常重视保护我们的版权和许可。如果您在互联网上以任何形式遇到我们作品非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

如果您发现疑似盗版材料,请通过<copyright@packtpub.com>与我们联系,并提供链接。

我们感谢您在保护我们作者权益方面所提供的帮助,以及我们能够向您提供有价值内容的能力。

问题

如果您在书的任何方面遇到问题,可以联系<questions@packtpub.com>,我们会尽力解决。

第一章:启动引擎

任何想法都应该从原型开始。不管是游戏、网络或移动应用程序,还是一般的软件组件,都无关紧要。每次我们想要向最终用户交付某些东西时,首先必须创建一个原型。这是最重要的一步,因为这时我们开始面临最初的困难,并且可能会改变我们项目的某些重要方面。

如果我们正在编写一个软件组件,第一个原型并不会太昂贵,因为我们需要的只是时间和热情。然而,当项目包含一些硬件部分时,这可能不适用,因为购买所有必需的组件可能过于昂贵。这一说法直到程序员、工程师和开源爱好者开始发布如Arduino之类的项目时才不再正确。

快速原型开发板使人们能够使用便宜或回收的旧组件来实现项目,再加上自己动手DIY)的理念,使得一个遍布全球的巨大社区得以创建。这正是 UDOO 主板在创客社区中发挥重要作用的地方:硬件原型生态系统与传统编写软件应用程序的方式相结合,为交互式项目的创建提供了强大的组合。

在本章中,我们将更详细地探讨 UDOO 主板,重点关注开始时需要了解的重要元素。特别是,我们将涵盖以下内容:

  • 探索 UDOO 平台及其主要特性

  • 使用 Android 操作系统设置主板

  • 为 Arduino 和 Android 配置开发环境

  • 引导一个简单的 Android 应用程序

  • 部署一个 Android 应用程序

介绍 UDOO 平台

UDOO 主板旨在为我们提供极大的灵活性,包括工具、编程语言以及构建第一个原型的环境。该主板的主要目标是参与物联网时代,这就是为什么内置 Atmel SAM3X8E ARM Cortex-M3 处理器成为其第一个构建块的原因。

这个处理器与 Arduino Due 主板所使用的相同,并且完全符合 Arduino 引脚布局。这一特性的结果是,该主板兼容所有 Arduino Due 屏蔽板以及大多数 Arduino Uno 屏蔽板,因此开发者可以转换和重用他们的旧程序和电路。

注意

UDOO 的 I/O 引脚是 3.3V 兼容的。例如,如果你使用的是一个 5V 供电的传感器,但其信号输出到 UDOO 引脚时为 3.3V,那么是可以的。另一方面,如果传感器以 5V 的信号输出到 UDOO,则会损坏你的主板。每次使用屏蔽或传感器时,请注意提供给 UDOO 引脚的输出电压。这一预防措施对于传统的 Arduino Due 主板同样适用。

第二个核心组件是强大的 Freescale i.MX 6 ARM Cortex-A9 处理器,有双核和四核版本。官方支持的操作系统是UDOObuntu,这是一个基于Lubuntu 12.04 LTS armHF的操作系统,出厂时预装了许多工具,可以快速上手。实际上,在第一次启动后,您就可以使用完全配置好的开发环境,直接在开发板上对板载 Arduino 进行编程。

尽管如此,使 UDOO 与其他开发板真正不同的是对 Android 的支持。凭借流畅的运行能力,这个操作系统对于新手或经验丰富的 Android 开发人员来说是一个巨大的机会,因为他们可以创建一种由 Android 用户界面、其强大的设计模式,甚至其他开发者的应用程序提供支持的新型真实世界应用程序。

注意

开发人员可以选择使用 Linux 操作系统编写他们的真实应用程序。在这种情况下,他们可以使用许多知名的编程语言编写 Web 服务或桌面应用程序,如 Python、Javascript(Node.js)、Php 和 Java。然而,我们将重点放在 Android 下的应用程序开发上。

最后一个核心组件与所有 I/O 组件相关。UDOO 可以配备内部 Wi-Fi 和千兆以太网,它们都可以被 Linux 和 Android 识别。它还提供HDMI高清晰度多媒体接口)输出连接,并配有集成的晶体管-晶体管逻辑TTL)到低电压差分信号LVDS)扩展槽,以便开发人员可以连接外部 LVDS 触摸屏。

注意

在本书的学习过程中,我们假设您将通过 HDMI 线将 UDOO 连接到外部显示器。然而,如果您拥有一个外部 LVDS 面板,可以在本章的我们的第一次运行部分之前进行连接。为了让 Android 使用外部面板,您应该按照官方网站上的步骤进行操作,具体步骤可以在www.udoo.org/faq-items/how-do-i-set-up-my-lvds/找到。

另一个官方支持的重要组件是摄像头模块,它易于插入开发板,并可用于需要计算机视觉或图像分析的项目。最后一个集成组件是音频卡,通过外部麦克风可以实现完全功能的音频播放和录制。

这些组件的结合,加上互联网接入和许多 Android API,使我们有机会构建真实世界的应用程序,这些程序能够监听环境并与设备进行交互,一块可以参与物联网的板子。

下载和安装 Android

我们已经了解了一些可能用于开始构建惊人项目的 UDOO 组件列表。但是,在我们继续之前,我们需要配置我们的开发板以运行 Android 操作系统,还需要配置我们的开发环境,这样我们就可以开始编写并部署我们的第一个应用程序。

注意

在本书中构建的所有原型都是基于 Android KitKat 4.4.2,这是本书编写时支持的最新版本。在本书的学习过程中,你将构建许多项目,这些项目使用了Android 支持库以确保与 UDOO 开发板将支持的较新 Android 版本兼容。

UDOO 开发板没有内置存储或内置启动程序,因为它依赖于外部存储,即 microSD 卡,你可以在其中安装引导加载程序和兼容的操作系统。创建可启动 microSD 卡的最简单方法是下载并复制预编译的镜像,尽管也可以使用发布的二进制文件和内核源代码创建干净的操作系统。

www.udoo.org/downloads/指向 UDOO 官方网站的下载页面,其中包含所有可用的预编译镜像的链接。

在 Linux 镜像中,我们可以找到并下载最新支持的 Android KitKat 4.4.2 版本。正如之前所述,UDOO 有两个不同版本,分别配备双核和四核处理器,因此我们必须根据所拥有的平台下载正确的版本。

从 Windows 安装

要从 Windows 安装 Android 镜像,你需要一些额外的工具来解压并将镜像复制到 microSD 卡中。下载的.zip文件是 7-Zip 压缩格式,因此你需要安装一个第三方解压缩程序,如 7-Zip。解压过程完成后,我们得到了一个未压缩的.img文件,可以将其复制到空卡上。

要将未压缩的镜像写入我们的 microSD 卡,请执行以下步骤:

  1. 将你的 microSD 卡插入内置的插槽读取器或外部读卡器。

  2. 使用FAT32文件系统格式化卡片。

  3. 要将镜像写入 microSD 卡,我们需要使用 Win32DiskImager 工具。从以下链接下载:sourceforge.net/projects/win32diskimager/

  4. 运行应用程序,但请记住,如果我们使用的是 Windows 7 或 Windows 8.x,我们必须右键点击Win32DiskImager.exe可执行文件,并确保从上下文菜单中选择以管理员身份运行的选项。

  5. Win32DiskImager 是一个使用低级别指令写入原始磁盘镜像的工具。这意味着你需要严格按照以下步骤操作,并确保你正确选择了输出设备。如果这个选项错了,你可能会丢失来自不想要存储内存的所有数据。

  6. 应用程序启动后,你可以看到如下截图所示的主窗口:从 Windows 安装

  7. 在应用程序的主窗口中,在镜像文件框内,选择之前解压的.img文件。

  8. 准确地在设备下拉菜单中选择 microSD 驱动器,并记住如果我们选择了错误的驱动器,可能会破坏计算机硬盘上的所有数据。

  9. 点击写入按钮,等待进程完成,以便在 microSD 卡中拥有可启动的 Android 操作系统。

从 Mac OS X 安装

要从 Mac OS X 安装 Android 镜像,我们需要一个第三方工具来解压下载的.zip文件,因为它采用 7-Zip 压缩格式,我们不能使用内置的解压缩软件。我们必须下载像 Keka 这样的软件,它可以在www.kekaosx.com/免费获得。

如果我们喜欢 Mac OS X 终端,可以使用 Homebrew 包管理器,它可以在brew.sh/找到。

在此情况下,从命令行,我们可以简单地安装p7zip包并使用7za工具按以下方式解压文件:

brew install p7zip
7za x [path_to_zip_file]

为了将未压缩的镜像写入我们的 microSD 卡,执行以下步骤:

  1. 启动终端应用程序,进入我们下载并解压 Android 镜像文件的文件夹。假设该文件夹名为Downloads,我们可以输入以下命令:

    cd Downloads
    
    
  2. 使用以下命令获取所有已挂载设备的列表:

    df -h
    
    
  3. 所有系统和内部硬盘分区的列表将与以下截图类似:从 Mac OS X 安装

  4. 使用内置或外置读卡器连接 microSD 卡。

  5. 通过系统已提供的磁盘工具应用程序格式化 microSD 卡。启动它,并从左侧列表中选择正确的磁盘。

  6. 在窗口的主面板上,从顶部菜单选择擦除标签页,并在格式下拉菜单中选择MS-DOS (FAT)文件系统。准备好后,点击擦除按钮。

  7. 从终端应用程序中,再次启动之前的命令:

    df –h
    
    
  8. 挂载分区的列表已经改变,如下面的截图所示:从 Mac OS X 安装

  9. 我们可以假设在首次运行时缺少的设备是我们的 microSD 卡,因此我们必须记住文件系统列下的新值。如果你查看之前的截图,我们的分区名为/dev/disk1s1而不是/dev/disk0s2,因为那是我们的硬盘。

  10. 找到正确的分区后,我们必须使用以下命令卸载它:

    sudo diskutil unmount /dev/[partition_name]
    
    
  11. 为了将镜像写入 microSD 卡,我们必须找到原始磁盘设备,这样我们就可以擦除并将 Android 镜像写入卡中。假设之前找到的分区名为/dev/disk1s1,相关的原始磁盘将是/dev/rdisk1

    注意

    我们将要使用 dd 工具。这个命令使用低级指令写入原始磁盘镜像。这意味着你需要严格遵循以下步骤,并确保你选择了正确的磁盘设备,因为如果选择错误,你可能会因为不想要的存储而丢失所有数据。

  12. 使用 dd 将之前解压的镜像写入 microSD 卡,命令如下:

    sudo dd bs=1m if=[udoo_image_name].img of=/dev/[raw_disk_name]
    
    

    之前命令的完整示例如下:

    sudo dd bs=1m if=[udoo_image_name].img of=/dev/rdisk1
    
    
  13. 当我们执行命令时,看似没有任何反应,但实际上,dd 在后台写入 Android 镜像。一旦进程完成,它会输出传输字节的报告,如下例所示:

    6771+1 records in
    6771+1 records out
    7100656640 bytes transferred in 1395.441422 secs (5088466 bytes/sec)
    
    
  14. 现在我们有了可启动的 Android 操作系统,我们可以使用以下命令弹出 microSD 卡:

    sudo diskutil eject /dev/[raw_disk_name]
    
    

从 Linux 安装

要从 Linux 安装 Android 镜像,我们需要一个第三方工具来解压下载的 .zip 文件。因为文件是使用 7-Zip 压缩格式,我们需要通过命令行使用发行版的包管理器安装 p7zip 包。然后我们可以使用 7za 工具解压文件,或者使用任何让你感到舒适的图形化解压缩工具。

我们可以通过以下步骤将未压缩的镜像写入我们的 microSD 卡:

  1. 打开 Linux 终端,进入我们下载并解压 Android 镜像的文件夹。假设文件在我们的 Downloads 文件夹中,我们可以输入以下命令:

    cd Downloads
    
    
  2. 使用内置或外置读卡器连接 microSD 卡。

  3. 通过以下命令找到正确的设备名称:

    sudo fdisk -l | grep Disk
    
    
  4. 输出是找到的所有设备的筛选列表,其中包含,例如:

    Disk /dev/sda: 160.0 GB, 160041885696 bytes
    Disk /dev/mapper/ubuntu--vg-root: 157.5 GB, 157454172160 bytes
    Disk /dev/sdb: 7948 MB, 7948206080 bytes
    
    

    在此例中,/dev/sda 是我们的硬盘,而 /dev/sdb 是我们的 microSD 卡。如果情况并非如此,且你使用的是内置读卡器,那么设备名称可能是 /dev/mmcblk0

    找到正确的设备名称后,请记住,我们稍后会使用它。

  5. 通过以下命令查找上述设备的所有已挂载分区:

    mount | grep [device_name]
    
    
  6. 如果之前的命令产生了输出,找到输出中第一列可用的分区名称,并通过以下命令卸载列出的任何分区:

    sudo umount /dev/[partition_name]
    
    

    注意

    dd 是一个使用低级指令写入原始磁盘镜像的工具。这意味着你需要严格遵循以下步骤,并确保你选择了正确的磁盘设备,因为如果选择错误,你可能会因为不想要的存储设备而丢失所有数据。

  7. 使用 dd 命令将之前解压的镜像写入上述设备名称:

    sudo dd bs=1M if=[udoo_image_name].img of=/dev/[device_name]
    
    

    假设 /dev/sdb 是我们的 microSD 卡,以下是一个完整示例:

    sudo dd bs=1M if=[udoo_image_name].img of=/dev/sdb
    
    
  8. 当我们执行命令时,看似没有任何反应,但实际上,dd 在后台写入镜像。进程完成后,它会输出传输字节的报告,如下所示:

    6771+1 records in
    6771+1 records out
    7100656640 bytes transferred in 1395.441422 secs (5088466 bytes/sec)
    
    
  9. 现在我们有了可启动的 Android 操作系统,可以使用以下命令弹出 microSD 卡:

    sudo eject /dev/[device_name]
    
    

我们的首个运行

一旦我们有了可启动的 microSD 卡,我们可以将其插入 UDOO 主板,使用外部显示器或 LVDS 面板,并连接鼠标和键盘。打开电源后,会出现 Android 标志,当加载过程完成后,我们最终可以看到 Android 主界面。

设置开发环境

现在 UDOO 主板上的 Android 系统已经完全功能正常,是时候配置开发环境了。我们将要构建的每个项目都由两个不同的运行应用程序组成:第一个是物理应用程序,由一个能够通过 UDOO I/O 引脚控制外部电路的 Arduino 程序组成;第二个是在板上运行并处理用户界面的 Android 应用程序。

因为我们需要编写两个相互交互的不同应用程序,所以我们需要用两个不同的 IDE 配置开发环境。

安装和使用 Arduino IDE

在我们开始上传程序之前,需要安装 microUSB 串行端口驱动程序,以便我们可以正确与主板上的 Arduino 进行通信。与 通用异步收发传输器 (UART) 相兼容的 USB 驱动程序,适用于板上的 CP210x 转换器,可以从以下链接下载

www.silabs.com/products/mcu/pages/usbtouartbridgevcpdrivers.aspx.

在这里,我们需要根据操作系统选择正确的版本。下载完成后,我们可以解压存档,并双击可执行文件进行安装。安装过程完成后,我们可能需要重启系统。

现在 microUSB 桥接驱动程序已经可以工作,从 Arduino 网站,我们需要下载 IDE 1.5x 测试版,因为目前,测试版是唯一支持 Arduino Due 主板的版本。链接 arduino.cc/en/Main/Software#toc3 直接指向最新版本。

注意事项

为了上传新程序,UDOO 需要在上传前后分别从串行端口接收 ERASE 和 RESET 信号。在官方的 Arduino Due 主板上,这个操作是由集成的 ATmega16U2 微控制器执行的,而 UDOO 主板上缺少这个微控制器。Arduino IDE 将会处理这个过程,但如果你将来想使用另一个 IDE,你就需要自己处理。

在 Windows 中的安装

在 Windows 上安装时,我们有两种不同的选择:使用提供的安装程序或使用归档文件进行非管理员安装。如果我们选择使用安装程序,可以双击可执行文件。当安装程序询问我们想要安装哪些组件时,请确保选中所有的复选框。如果我们选择使用归档文件而不是安装程序,提取文件并将结果目录放入你的用户文件夹中。

在 Mac OS X 上安装

在 Mac OS X 上安装时,我们需要下载归档版本。如果我们运行的是大于 10.7 的 OS X 版本,可以下载 Java 7 版本。在其他情况下,或者如果你不确定,请下载 Java 6 版本。

下载完成后,我们需要双击归档文件以进行解压,然后将 Arduino 应用程序图标拖放到我们的 Applications 文件夹中。

在 Linux 上安装

在 Linux 上安装时,我们需要下载与我们 32 位或 64 位架构支持的归档版本。下载完成后,我们可以解压 IDE 并将其放入我们的 home 文件夹或其他你选择的文件夹中。

首次启动

既然我们已经完成了通信驱动和 IDE 的配置,并打上了正确的补丁,我们可以启动并查看如下截图所示的 Arduino IDE:

首次启动

安装和使用 Android Studio

搭载 Android 操作系统的 UDOO 与其他传统 Android 设备类似。这意味着我们可以使用标准的工具链、构建系统和用于开发智能手机或平板应用程序的 IDE。目前,可用的工具链与两个主要的 IDE 相关:Eclipse 和 Android Studio。

Eclipse 是一个开源 IDE,拥有一个高级插件系统,可以轻松扩展其许多核心功能。这使得 Google 开发了Android Development ToolADT)插件,以创建一个集成开发环境,让开发者可以编写、调试和打包他们的 Android 应用程序。

Android Studio 是一个较新项目,2013 年 5 月发布了第一个测试版,而第一个稳定版本是在 2014 年 12 月发布的。基于知名的 Java IDE IntelliJ IDEA,它由 Gradle 构建系统提供支持,该系统结合了 Ant 的灵活性以及 Maven 的依赖管理。所有这些特点,加上越来越多的插件、最佳实践、Google Cloud Platform集成和第三方服务如 Travis CI 的集成,使得 Android Studio 成为未来项目开发的一个绝佳选择。

本书涵盖的所有 Android 项目都是使用 Android Studio 构建的,如果你是一个新手或经验丰富的 Android 开发者,且习惯使用 Eclipse,这可能是一个尝试新 Android Studio 的好机会。

首先需要从developer.android.com/sdk/下载适用于您操作系统的最新版 Android Studio。

当开始下载时,我们会重定向到与我们的操作系统相关的安装说明,当我们完成安装后,可以启动 IDE。在首次运行时,IDE 将进行所有必要的检查以获取并安装最新的可用 SDK、虚拟设备和构建系统,让您开始开发第一个应用程序。在设置向导 - SDK 设置页面,确保选择Android SDKAndroid Virtual Device组件,然后点击下一步。在下一页中,您应该接受所有 Android 许可,然后点击完成

安装完 IDE 后,我们可以启动 Android Studio。以下截图显示了未打开项目时的主窗口:

安装和使用 Android Studio

运行您的第一个 Android 应用程序

现在 Android 已经安装在我们的 UDOO 板上,所有开发环境都已配置,我们可以开始编写并部署我们的第一个 Android 应用程序。以下是其他开发者在开始深入研究新技术时的默认模式。我们将编写并部署一个简单的 Android 应用程序,该程序打印出 Hello World!。

为了启动我们的第一个项目,请执行以下步骤:

  1. 在 Android Studio 的主窗口中,点击开始一个新的 Android Studio 项目

  2. 应用程序名称字段中,输入HelloWorld;在公司域名中,写入您的域名或如果您目前没有的话,可以写example.com。然后点击下一步

  3. 在形态因素选择窗口中,选择手机和平板,并在最低 SDK中选择API 19: Android 4.4 (KitKat)。然后点击下一步

  4. 在添加活动页面,为了这个 hello world 应用程序的目的,选择空白活动选项并点击下一步

  5. 活动选项页面,在活动名称中写入HelloWorld并点击完成

    提示

    在接下来的章节中,我们将从头开始创建应用程序,因此我们必须记住前面的步骤,因为在这本书中我们将多次重复这个过程。

现在 Android Studio 将开始下载所有 Gradle 需求,以准备我们的构建系统。当这个过程完成后,我们得到了第一个 HelloWorld 应用程序。

在不编写任何代码的情况下,我们已经创建了一个可部署的应用程序。现在,我们需要使用 microUSB 到 USB 电缆连接我们的 UDOO 板。如果我们查看一下主板,我们会看到两个不同的 microUSB 端口。左边的第一个端口,我们将在下一章中使用它,将我们的计算机连接到两个处理器的串行端口,因此我们可以使用它将 Arduino 程序上传到 UDOO 微控制器,或者我们可以使用它访问 Android 系统 shell。串行端口的激活通信取决于 J18 跳线的状态,是插入还是未插入。而右边的 microUSB 端口则将我们的计算机连接到运行 Android 的 i.MX 6 处理器,我们将使用它来上传我们的 Android 应用程序。你可以在 UDOO 官方网站上找到更多关于处理器通信的信息www.udoo.org/features/processors-communication/

为了将我们的计算机连接到 Android 操作系统以进行应用程序上传过程,我们需要使用下面截图中标有黑色的右侧 microUSB 端口:

运行你的第一个 Android 应用程序

就像在传统的 Android 应用程序中所做的那样,我们可以从顶部菜单点击Run(运行),然后点击Run app(运行应用)。此时,我们需要选择一个运行设备,但不幸的是,我们可用的设备列表是空的。这个问题是由于处理器间内部通信的方式导致的。

启动时间之后,两个处理器之间的连接已启用,插入 microUSB 电缆将不会产生任何效果。这是因为 Android 在与 Arduino 通信时并不使用内部 UART 串行端口。它使用的是USB On-The-GoOTG)总线,允许设备充当主机,并让其他组件(如闪存驱动器、鼠标、键盘或 Arduino)通过它连接。

i.MX 6 处理器物理连接到 OTG 总线,而总线的另一端同时连接到 Arduino 和外部 microUSB 连接器。当前活动的连接可以通过软件控制的开关进行更改。当外部 OTG 端口启用时,Android 可以通过 microUSB 端口与外部计算机通信,但不能将任何数据发送回板载 Arduino。相反,当外部 OTG 端口禁用时,Android 可以与 Arduino 通信,但与计算机的连接会中断。

后者是我们的实际配置,我们需要切换 OTG 端口以启用与计算机的外部通信,完成应用程序部署。在 Android 系统中,我们必须进入设置菜单,选择开发者选项。在那里,我们需要勾选启用外部 OTG 端口的复选框。如果连接了 USB 线,会出现一个弹窗要求我们允许 USB 调试。如果是我们的主计算机,我们可能想要选择始终允许此计算机,然后点击确定。如果没有勾选这个选项,每次我们连接 UDOO 到计算机时都会显示弹窗。

注意事项

请记住,每次我们需要部署 Android 应用程序时,都需要启用外部 OTG 端口。相反,当我们的应用程序部署好,需要 Android 与 Arduino 通信时,我们需要禁用外部 OTG 端口。

现在,我们的计算机可以将 UDOO 板视为传统的 Android 设备,我们可以尝试再次部署我们的应用程序。这次,在选择设备对话框中,我们可以找到一个 Freescale UDOO Android 设备。选择它并点击确定。我们的首次部署完成,现在我们可以在连接的监视器上看到 HelloWorld 应用程序。

总结

在本章中,我们了解了一些 UDOO 的特性,这些特性使这块开发板与其他开发板区分开来。最大的区别之一是与 Android 平台的全面支持,这让我们能够在板上安装和配置最新支持的版本。

我们探索了开始开发实际应用所需的工具,并配置了我们的开发环境以编写 Android 应用程序和 Arduino 程序。

我们简要介绍了两个处理器之间如何通信以及如何切换 OTG 端口以启用外部访问,完成首次部署。在下一章中,我们将从零开始创建一个新的 Android 应用程序,能够使用并控制通过一套原型工具构建的物理设备。

第二章:了解你的工具

如上一章所述,现实世界应用不仅仅是软件。它们由在物理世界中执行动作的简单或复杂电路组成。在我们开始构建第一个交互式项目之前,我们需要了解这些物理组件是如何工作的,这样我们才知道工具箱里有什么。

在本章中,我们将涵盖以下主题:

  • 上传第一个 Arduino 程序

  • 与 Arduino 建立连接

  • 编写一个能作为控制器作用的 Android 应用

  • 构建一个由 Android 控制的简单电路

介绍 Arduino Due 的功能

物理世界由我们以光、热、声音或运动形式感知的多种能量形式组成。当我们在驾车时,靠近交通灯,看到前方红灯亮起,我们会开始减速并停车。我们只是感知了一种光能形式,这使我们改变了活动,因为有人教过我们每个交通灯阶段的意义。

这种自然行为正是我们希望带到我们的交互式物理应用中的。我们使用的硬件设备叫做传感器,它们监听环境,并与其他硬件组件,即执行器协同工作,执行现实世界中的动作。然而,我们需要一个叫做微控制器的第三种元素,它使用连接的传感器和执行器来感知并改变周围环境,根据上传的程序进行操作。

板载的 Arduino Due 采用了最新的部件,并提供了一种连接外部电子组件的通用方式。它有 54 个数字 I/O 引脚,我们可以使用它们发送或接收数字信号。当我们想要从外部设备(如开关或按钮)收集输入时,这特别有用,同时我们可以发送数字信号以打开或关闭简单的组件。在下面的图表中,你可以看到所有的数字引脚都是黑色的:

介绍 Arduino Due 的功能

我们可以使用 12 个模拟输入,其 12 位分辨率可以读取 4096 个不同的值。当需要从传感器收集数据,并使用返回值作为程序改变物理设备行为的条件时,它们非常有用。读取值的良好例子与温度、光线或接近传感器相关。板子还提供了 2 个数字至模拟转换器DAC),具有 12 位分辨率,当需要使用数字信号驱动模拟设备时,可以作为模拟输出使用。当你需要用你的设备创建音频输出时,使用 DAC I/O 引脚的一个好例子。在下面的图表中,你将找到所有模拟引脚都是黑色的,而 2 个 DAC 引脚是灰色的:

介绍 Arduino Due 的功能

有了这些功能,我们就有了一切必要的工具来从我们的 Android 应用程序中控制小型设备。另一方面,我们也可以反过来利用,让连接的设备改变我们 Android 界面的行为。

然而,当 UDO 用于控制复杂的电路并且可能需要一个硬件驱动程序与它交互时,UDO 才能真正显示出其强大的功能。当我们打算回收我们已拥有的设备,如旧玩具,或者购买新设备如小型电动机器人或漫游车时,这可能会成为一种常见的方法。

构建硬件驱动程序是一项昂贵的任务,需要软件和电子方面的丰富经验。UDO 通过板载 Arduino 使这项任务变得简单,因为它重用了制造商社区构建的所有组件。我们可以通过将 UDO 与一个盾板结合来添加其他功能,这是一个可插拔的板,它实现了一个复杂的电路,包含了所有必需的硬件逻辑。好的例子包括兼容 Arduino 的 LCD 屏幕、蓝牙控制器以及控制连接电机的电机盾板,只需几行代码,无需构建外部电路。

上传第一个程序

既然我们已经了解了 UDO 板的主要组件和能力,我们可以开始编写并上传我们的第一个程序。我们必须牢记,尽管 SAM3X 是一个独立的处理器,但我们仍需要一个带有有效 UDO 镜像的工作 microSD 卡,否则 Arduino 编程器将无法工作。

就像之前为 Android 所做的那样,我们将编写一个简单的应用程序,在屏幕上打印“Hello World!”,此时不需要任何 Android 交互。在打开 Arduino IDE 之前,我们需要通过左侧的 microUSB 端口将板连接到我们的计算机,如下图所示:

上传第一个程序

然而,这种连接不足以让 Arduino SAM3X 和我们的计算机之间进行正确的通信,因为这两个处理器都使用这个 microUSB 端口通过串行端口与连接的设备进行通信。一个内部物理开关在运行 Android 的 i.MX6 和 Arduino SAM3X 之间选择连接的处理器。

注意

这是一个不同的连接,不是前一章中使用的那个。它指的是串行端口,不应与用于部署 Android 应用程序的 OTG microUSB 端口混淆。

为了使我们的计算机和 SAM3X 之间能够连接,我们必须拔掉下图所示的物理跳线 J18

上传第一个程序

现在我们准备启动 Arduino IDE 并继续编写和上传我们的第一个程序。当 IDE 出现时,它将打开一个空程序。为 Arduino 编写的每个程序和代码都称为草图。Arduino 草图使用一组简化的 C/C++编写,如果您感兴趣,可以在arduino.cc/en/Reference/HomePage找到完整的参考资料。

初始草图包含以下两个函数:

  • setup(): 这在初始执行时被调用一次,我们在其中放置所有初始配置。

  • loop(): 这会在设备关闭之前不断被调用,它代表了我们草图的内核。

我们所有的草图都必须包含这两个函数,否则程序将无法工作。我们可以添加自己的函数以使代码更具可读性和可重用性,这样我们就可以遵循编程原则不要重复自己DRY)。

注意

我们必须记住,我们是为一个最多有 512 KB 可用内存来存储代码的微控制器编写软件。此外,草图在运行时创建和操作变量的 96 KB SRAM 限制。对于复杂项目,我们应该始终优化代码以减少使用的内存,但为了本书的目的,我们编写代码使其更具可读性和易于实现。

要在屏幕上打印出“Hello World!”,我们需要编写一个向内置串行端口写入字符串的草图。这个草图可以通过以下简单步骤实现:

  1. setup()函数中,以指定的每秒比特数波特)初始化串行端口,如下所示:

    void setup() {
     Serial.begin(115200);
    }
    

    我们选择每秒115200波特率,因为板载的 Arduino Due 支持这个数据率。

    提示

    下载示例代码

    您可以从您的账户下载您购买的所有 Packt 图书的示例代码文件,网址是www.packtpub.com。如果您在别处购买了这本书,可以访问www.packtpub.com/support注册,我们会将文件直接通过电子邮件发送给您。

  2. 在主loop()函数中使用println()函数向串行端口写入:

    void loop() {
     Serial.println("Hello World!");
    }
    

    即使我们有上传我们项目的冲动,我们也必须记住loop()函数会不断被调用,这意味着我们可能会收到太多的“Hello World!”实例。一个好方法是添加一个delay()函数,这样 Arduino 在再次开始loop()函数之前会等待给定毫秒数。

  3. 要每秒打印一句话,请添加以下突出显示的代码:

    void loop() {
     Serial.println("Hello World!");
     delay(1000);
    }
    

现在我们准备开始上传过程。这个过程包括两个阶段,首先编译我们的代码,然后上传到 SAM3X 处理器。如果我们上传两个不同的草图,最新的会覆盖第一个,因为我们一次只能加载和执行一个草图。

在这种情况下,我们需要配置 IDE,使其能够为连接到正确串行端口的正确电路板编程。点击工具,悬停在电路板上并选择Arduino Due (编程端口)。现在点击工具,悬停在端口上,并选择你配置的端口。正确的端口取决于你的操作系统,它们通常具有以下值:

  • 在 Windows 中:编号最高的COM端口

  • 在 Mac OS X 中:/dev/tty.SLAB_USBtoUART

  • 在 Linux 中:/dev/ttyUSB0

要上传程序,请点击文件,然后点击上传,或者使用工具栏中可用的快捷方式。如果上传过程顺利,你将在窗口底部看到以下输出的记录器:

上传第一个程序

为了确保我们的第一个草图按预期工作,我们需要使用串行端口阅读器,而 Arduino IDE 提供了一个内置的串行监视器。点击工具,然后点击串行监视器,或者使用工具栏中可用的快捷方式。我们可能会看到一些奇怪的字符,这是因为串行监视器默认配置为以 9600 波特读取串行。在右下角的下拉菜单中,选择115200 波特以查看以下输出:

上传第一个程序

注意

使用Serial.println()函数可以通过串行端口发送数据。这并不是用来与 i.MX6 处理器通信的,但这是从电脑调试变量或草图流程的好方法。

当我们完成草图上传后,我们可以插入J18 跳线。现在我们知道如何部署 Android 应用程序和 Arduino 草图了,是时候从头开始构建我们的第一个项目了。

与现实世界的互动

我们第一个现实世界的原型应该是一个可以用来控制简单电子元件的 Android 应用程序。我们必须选择一个不太简单的东西,以便我们可以对其进行实验,同时也不要太复杂,以便我们可以深入了解所有主要概念,而不需要太多实现细节。一个好的起点是创建一个控制器,我们可以使用它来打开和关闭实际的发光二极管LED)组件。

然而,在我们继续之前,我们必须了解如何创建 Android 应用程序和草图之间的通信。在部署过程中,我们通常会启用外部 OTG 端口,以便从电脑与 i.MX6 处理器通信。如果我们禁用这个选项,内部的开关会激活 i.MX6 和 SAM3X 处理器之间的双向通信。这是可能的,因为 Arduino Due 完全支持 USB OTG 连接,我们使用这个连接让 Android 和 Arduino 相互通信。

不幸的是,如果我们没有一个通信协议,上述软件开关并不十分有用。这就是Accessory Development KitADK)发挥重要作用的地方。它是谷歌开发的参考实现,用于构建 Android 配件,并提供了一套软件库。UDOOboard 完全支持 ADK。通过将内部 Android API 与外部 Arduino 库相结合,我们可以轻松地使用这些功能发送命令和接收数据。这样,我们的 Android 将把我们的 Arduino 设备视为一个Android 配件,从而在应用程序和整个系统中支持这种连接。我们可以在developer.android.com/tools/adk/index.html找到关于 ADK 的更多详细信息。

与 Arduino 通信

这个原型的第一步是开始一个新的草图,并从 Arduino 端设置初始连接。在我们空白的草图顶部,我们应该添加以下代码:

#include <adk.h>
#define BUFFSIZE 128
#define LED 2

adk.h头文件包含了所有我们需要的声明,用于许多实用工具和函数,例如初始化 ADK 连接,向 Android 发送硬件信息,以及两个处理器之间缓冲数据的读写。在上述代码中,我们还定义了两个宏对象,分别提供了读写缓冲区的最大尺寸以及用于打开和关闭 LED 的引脚。我们需要记住这个数字,因为稍后当我们连接第一个电子元件时会重新使用到它。

通过 ADK 使用的协议,Android 将 Arduino 识别为外部配件。为了将我们的配件与其他配件区分开来,Android 需要一个配件描述符,我们可以使用以下代码提供:

char accessoryName[] = "LED lamp";
char manufacturer[] = "Example, Inc.";
char model[] = "LedLamp";
char versionNumber[] = "0.1.0";
char serialNumber[] = "1";
char url[] = "http://www.example.com";

在这里,我们提供了关于配件名称、硬件制造商名称和模型唯一标识符的信息。除了这些原型描述符之外,我们还必须定义硬件版本和序列号,因为当我们将设备连接到 Android 应用程序时,这些信息是强烈需要的。实际上,versionNumbermodelmanufacturer参数将与稍后我们提供给 Android 应用程序的值进行匹配,如果有不匹配的情况,我们的草图将不会被 Android 应用程序识别。通过这种方式,我们还可以在应用程序版本和硬件版本之间保持强绑定,以避免旧的 Android 应用程序错误地控制新的硬件发布。

注意

前面的描述符是 Android 应用程序识别草图和硬件所必需的。但是,请记住,这是良好编程礼仪的一部分,对于每个应用程序和原型,你都应该提供版本编号以及变更日志。在本书中,我们将使用语义版本控制,你可以访问semver.org了解更多信息。

最后一个参数是url,Android 使用它将用户重定向到一个网站,在那里他们可以找到关于已连接配件的更多信息。每当 Android 找不到能够管理 Arduino 配件交互的已安装应用程序时,它都会显示该消息。

提示

在大多数情况下,将url参数设置为可以下载并安装打包的 Android 应用程序的链接是一个好主意。这样,如果缺少 Android 应用程序,我们就提供了一种快速获取和安装的方法,这对于将我们原型的原理图和草图分发给其他开发者尤其有用。你可以访问developer.android.com/tools/building/building-studio.html了解更多关于如何使用 Android Studio 创建打包应用程序的信息。

为了完成 ADK 配置,我们必须在之前的声明下方添加以下代码:

uint8_t buffer[BUFFSIZE];
uint32_t bytesRead = 0;
USBHost Usb;
ADK adk(&Usb, manufacturer, model, accessoryName, versionNumber, url, serialNumber);

我们在读写操作期间声明了使用的buffer参数和一个USBHost对象。我们在主loop()函数中使用它来初始化连接,以便在发现过程中 Android 接收所有必要的信息。在最后一行,我们使用定义的值初始化 ADK 配件描述符。

要开始连接,我们需要将以下代码放入loop()函数中:

void loop(){
 Usb.Task();
 if (adk.isReady()) {
 // Do something
 }
}

Usb.Task()函数调用轮询连接的 USB 设备以获取它们状态更新,并等待 5 秒钟以查看是否有任何设备响应更新请求。当 Android 响应轮询时,我们使用条件语句评估adk.isReady()函数调用。当设备连接并准备好与 Android 通信时,它返回True,这样我们就能确切知道 Android 系统何时读取原型描述符以及何时通知已安装的应用程序连接了新的配件。

我们的初始配置已完成,现在可以将草图上传到电路板中。当草图上传完毕,我们禁用 OTG 外部端口时,Android 将发现正在运行的配件,然后显示一条消息,通知用户没有可用的应用程序可以与连接的 USB 配件一起工作。它还给了用户跟随所选 URL 的机会,如下面的屏幕截图所示:

与 Arduino 通信

编写 Android 应用程序控制器

我们的第一块构建模块已经准备好了,但目前它还没有任何我们可以使用的物理执行器,也没有用户界面进行控制。因此,下一步是通过 Android Studio 创建我们的第二个 Android 项目,名为 LEDLamp。就像在第一个应用程序中所做的那样,记得选择 API 级别 19 和一个空白活动,我们可以将其称为 LightSwitch

当活动编辑器出现时,最好更改用户界面的可视化预览,因为我们将使用监视器视图而不是普通的智能手机视图。我们可以通过应用程序屏幕右侧的预览标签页进行更改,并在上下文菜单中选择 Android TV (720p)

因为我们需要一个非常简单的活动,所以我们需要使用以下步骤更改默认布局:

  1. res/layout/activity_light_switch.xml 文件中,将 RelativeLayout 参数更改为垂直的 LinearLayout 参数,如下所示的高亮代码:

    <LinearLayout
    
     android:orientation="vertical"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:paddingLeft="@dimen/activity_horizontal_margin"
     android:paddingRight="@dimen/activity_horizontal_margin"
     android:paddingTop="@dimen/activity_vertical_margin"
     android:paddingBottom="@dimen/activity_vertical_margin"
     tools:context=".LightSwitch">
    </LinearLayout>
    
    
  2. 在前面的 LinearLayout 中,使用以下代码更改默认的 TextView 参数:

    <TextView
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:textAppearance="@android:style/TextAppearance.Large"
     android:text="Available controlled devices"/>
    

    我们创建一个标题,并将其放置在布局顶部。在此视图下方,我们将放置所有可控制的设备,比如我们的第一个 LED。

  3. 在前面的 TextView 下面添加以下 Switch 视图:

    <Switch
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:text="LED 2"
     android:id="@+id/firstLed"/>
    

    为了保持用户界面简洁,我们需要一个按钮来控制 LED 的开关。为此,我们将使用一个开关按钮,这样我们就可以将动作发送到微控制器,同时提供 LED 实际状态的视觉反馈。

    提示

    在我们的 Android 应用程序中,了解微控制器正在做什么的视觉反馈总是好的。这样,我们可以轻松知道草图的状态,这有助于我们查找异常。这特别是在实际设备没有给用户任何即时反馈时尤为重要。

没有进一步的自定义,以下是预期的用户界面截图:

编写 Android 应用控制器

为了在电路板上尝试,我们可以像在前一章中那样进行应用程序部署,然后继续编写 ADK 通信逻辑。

Android 配件开发套件

为了在我们的应用程序中启用 Android ADK,我们需要向 AndroidManifest.xml 文件添加一些配置。因为我们使用了 Android 系统的特殊功能,这依赖于可用的硬件,所以我们需要在 manifest 文件顶部添加以下声明:

<manifest

  package="me.palazzetti.ledlamp">

<uses-feature
android:name="android.hardware.usb.accessory"
android:required="true"/>

<!-- other declarations -->
</manifest>

当应用程序在系统中注册时,它应该声明能够响应在连接 USB 配件时引发的事件。为了实现这一点,我们需要向我们的 LightSwitch 活动声明中添加一个意图过滤器,如下所示的高亮代码:

<activity
 android:name=".LightSwitch"
 android:label="@string/app_name">
 <!-- other declarations -->

 <intent-filter>
 <action android:name=
 "android.hardware.usb.action.USB_ACCESSORY_ATTACHED"/>
 </intent-filter>
</activity>

Android 系统要求我们填写与之前在 Arduino 草图中的配件信息相同的配件信息。实际上,我们必须提供我们配件的制造商、型号和版本,为了保持组织性,我们可以创建res/xml/文件夹并在其中放入一个名为usb_accessory_filter.xml的 XML 文件。在这个文件中,我们可以添加以下代码:

<resources>
   <usb-accessory
    version="0.1.0"
    model="LampLed"
    manufacturer="Example, Inc."/>
</resources>

要将上述文件包含在 Android 清单中,只需在 USB 意图过滤器下方添加以下代码:

<activity
 android:name=".LightSwitch"
 android:label="@string/app_name">
 <!-- other declarations -->

 <meta-data
 android:name=
 "android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
 android:resource="@xml/usb_accessory_filter"/>
 </activity>

既然我们的应用程序已经准备好进行发现过程,我们需要包含一些逻辑来建立连接并开始通过 ADK 发送数据。

注意

在这个原型中,我们将通过 Android 内部 API 使用 ADK。从第四章,使用传感器聆听环境开始,我们将通过一个外部库使用高级抽象,这将帮助我们更容易地实现项目,并且不需要任何样板代码。

下一步是将 ADK 的一些功能隔离在一个新的 Java 包中,以便更好地组织我们的工作。我们需要创建一个名为adk的新包,并在其中添加一个名为Manager的新类。在这个类中,我们需要使用从 Android Context参数中获取的UsbManager类、一个文件描述符和用于在 OTG 端口中写入数据的输出流。在Manager类中添加以下代码:

public class Manager {
 private UsbManagermUsbManager;
  private ParcelFileDescriptormParcelFileDescriptor;
  private FileOutputStreammFileOutputStream;

  public Manager(UsbManagerusbManager) {
  this.mUsbManager = usbManager;
  }
}

提示

Java 代码段需要在文件的顶部导入许多内容,为了更好的代码可读性,这些导入被故意省略了。然而,为了让一切按预期工作,我们需要编写它们并使用 Android Studio 中提供的自动补全功能。当你发现缺失导入时,只需将光标放在红色标记的语句上方,并按Ctrl+Space键。我们现在可以从建议框中选择正确的导入。

我们期望将UsbManager方法作为参数,因为我们无法访问 Android Context,我们稍后将从主活动中获取它。为了在 ADK 通信期间简化我们的工作,以下助手应该包含在我们的包装器中:

  • openAccessory(): 当找到设备时,它应该与设备建立连接

  • closeAccessory(): 如果有任何设备连接,它应该关闭并释放任何已使用的资源

  • writeSerial(): 当设备连接时,它应该通过已打开的流发送数据

第一个助手与配件建立连接并初始化相关输出流可以通过以下方法实现,我们应该将其添加到Manager类的底部:

public void openAccessory() {
 UsbAccessory[] accessoryList = mUsbManager.getAccessoryList();
  if (accessoryList != null &&accessoryList.length> 0) {
    try {
     mDescriptor = mUsbManager.openAccessory(accessoryList[0]);
     FileDescriptor file = mDescriptor.getFileDescriptor();
     mOutput = new FileOutputStream(file);
    }
   catch (Exception e) {
      // noop
    }
  }
}

我们使用存储的UsbManager对象来获取所有可用的配件。如果我们至少有一个配件,我们会打开它以初始化一个描述符和一个输出流,我们稍后将会使用它们向配件发送数据。为了关闭上述连接,我们可以按如下方式添加第二个助手:

public void closeAccessory() {
  if (mDescriptor != null) {
    try {
     mDescriptor.close();
    }
   catch (IOException e) {
      // noop
    }
  }
 mDescriptor = null;
}

如果我们已经打开了一个配件,我们使用创建的描述符来关闭激活的流,并从实例变量中释放引用。现在我们可以添加最新的写入助手,其中包括以下代码:

public void writeSerial(int value) {
  try {
   mOutput.write(value);
  }
 catch (IOException e) {
    // noop
  }
}

前面的方法将给定的value写入启用的输出流中。这样,如果连接了一个配件,我们使用输出流引用来写入 OTG 端口。

最后,我们需要在活动中创建一个Manager类的实例,这样我们就可以使用它来与 Arduino 打开通信。在LightSwitch活动的onCreate方法中,添加以下高亮代码:

public class LightSwitch extends ActionBarActivity{
 private Manager mManager;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   setContentView(R.layout.activity_light_switch);
 mManager = new Manager(
 (UsbManager) getSystemService(Context.USB_SERVICE));
  }
}

我们正在查询系统中的 USB 服务,以便我们可以在Manager类中使用它来访问 USB 配件的状态和功能。我们将Manager类的引用存储在类内部,以便我们将来可以访问我们的助手函数。

一旦Manager类初始化完成,我们应该根据活动的开启和关闭来上下文地打开和关闭我们的配件。实际上,通常在活动的onResume()onPause()回调中调用openAccessory()closeAccessory()函数是个好主意。这样,我们可以确保在活动方法中使用 ADK 通信时,它已经被初始化。为了实现这个实现 ADK 通信的最后一块拼图,请在onCreate()成员函数下面添加以下方法:

@Override
protected void onResume() {
 super.onResume();
 mManager.openAccessory();
}

@Override
protected void onPause() {
 super.onPause();
 mManager.closeAccessory();
}

既然 Android 应用程序已经准备好了,我们可以继续部署,当我们禁用外部 OTG 端口时,会出现以下消息:

Android 配件开发套件

安卓系统已经发现了物理配件,并请求使用 LED Lamp 应用程序与之工作的权限。如果我们点击确定,应用程序将被打开。我们甚至可以将我们的应用程序设置为默认;这样,每当配件开始与 Android 系统通信时,我们的应用程序将立即启动。

快速原型设计电路

我们已经实现了 Android 和 Arduino 之间完全功能的通信,现在是时候构建一个真正的电路了。我们的目标是使用 Android 系统来开关一个 LED,这个问题既小又独立。然而,一开始,我们可以更有野心一些,不是打开一个 LED,而是可能想打开卧室的灯泡。那么,当我们能做得更有趣时,为什么要创建这样一个简单的项目呢?因为我们对项目进行快速原型设计

快速原型制作是一组我们可以使用的技巧,以便尽快创建我们的工作项目。这非常有帮助,因为我们可以移除许多实现细节,比如产品设计,只专注于我们项目的核心。在我们的案例中,我们移除了所有与点亮灯泡相关的难题,比如使用晶体管、继电器和外部电池,我们专注于创建一个由 Android 系统供电的灯开关。当第一个原型开始工作时,我们可以逐步增加要求,直到实现最终项目。

使用面包板

为了继续我们的项目,我们应该创建一个电路原型。我们可以使用许多工具来实现这一目标,但在一开始,最重要的工具之一就是面包板。它可用于连接我们的电路板和其他电子组件,无需焊接。这允许我们在设计电路时进行实验,同时还可以将面包板用于其他项目。

下面是一个典型的面包板:

使用面包板

面包板由两个相同的部分组成,中间有一条水平行将两部分隔开,以断开两侧之间的任何连接。每一侧都包含一红一蓝两行,位于侧面的顶部或底部,它们代表电源总线。它们在整条水平线上是连接的,我们将使用它来连接 UDOOboard 的电源和地线。颜色通常用红色表示电源,蓝色表示地线,但请记住,这只是一种约定,你的面包板颜色可能会有所不同。

剩下的五条水平线是原型区域,这是我们连接设备的地方。与电源总线不同,这些线在垂直方向上是连接的,而水平线之间没有连接。例如,如果我们把一根跳线插入 A1 孔,金属条就会与从 B1 到 E1 的孔形成电气连接。另一方面,A2-E2 和 F1-J1 范围内的孔与我们的 A1-E1 列没有连接。

作为我们的第一个原型,我们打算使用面包板连接将 LED 连接到我们的 UDOOboard 上。然而,我们需要另一个叫做电阻器的电子组件。它通过电线对电流的通过产生阻力,这是必要的;否则,过多的电流可能会损坏组件。另一方面,如果我们提供过多的电阻,那么通过组件的电流将不足以使其工作。该组件的电阻以欧姆为单位测量,在我们的案例中,我们需要一个220 欧姆的电阻来正确地给 LED 供电。

现在我们需要将我们的组件连接到面包板上,正如我们在下面的电路中所看到的那样:

使用面包板

我们需要将引脚 2 连接到电源总线的正线,而地线则应连接到负线。然后我们将 LED 连接到原型区域,并在其正极前放置电阻。我们可以通过观察 LED 的腿长来区分其极性:较长的腿是正极,较短的腿是负极。记住这一点,我们可以将长腿连接到电阻上。为了闭合电路,我们只需将电阻连接到电源总线的正线,并将 LED 的负极连接到地线。这样我们就制作了我们的第一个电路。

注意

LED 应该关闭,但可能仍有一小部分电流流经它。这可能是由于我们的 Arduino 草图默认没有禁用引脚造成的。这种行为是安全的,我们将在下一节中处理这个问题。

与外部电路的交互

在这一点上,我们已经有了工作的通信和原型电路。我们应该实现的最后一步是从 Android 应用程序发送打开和关闭的信号,并在草图中解析并执行此命令。我们可以从我们的草图中开始,在其中我们需要配置引脚以作为输出引脚工作。这类配置是在setup()函数中完成的;在其中,我们应该添加以下代码:

void setup(){
 pinMode(LED, OUTPUT);
 digitalWrite(LED, LOW);
}

使用pinMode()函数,我们声明所选择的引脚将作为OUTPUT工作,这样我们就可以控制通过它的电流流动。因为我们之前定义了LED宏对象,它指的是引脚 2。digitalWrite()函数是 Arduino 语言的另一个抽象,我们使用它来允许或阻止电流流经所选择的引脚。在这种情况下,我们表示不应该有电流通过该引脚,因为在初始化步骤中,我们希望 LED 处于关闭状态。

因为 Android 应用程序将向我们发送一个只能具有01值的命令,我们需要一个函数来解析此命令,以便 Arduino 知道相关的动作是什么。为了实现这一点,我们可以在草图的底部简单地添加一个executor()函数,如下所示:

void executor(uint8_t command){
  switch(command) {
    case 0:
   digitalWrite(LED, LOW);
      break;
    case 1:
   digitalWrite(LED, HIGH);
      break;

    default:
      // noop
      break;
  }
}

我们正在创建一个解析command参数的开关。如果该值为0,Arduino 使用digitalWrite()函数关闭 LED;然而,如果值为1,它使用相同的函数打开 LED。在其它任何情况下,我们只需丢弃接收到的命令。

在这一点上,我们需要在adk.isReady条件下的主loop()函数中将事物组合在一起,如下所示:

if (adk.isReady()) {
 adk.read(&bytesRead, BUFFSIZE, buffer);
 if (bytesRead> 0){
 executor(buffer[0]);
 }
}

在主loop()函数期间,如果我们发现 ADK 连接,我们从通信通道读取任何消息,并通过adk.read()函数调用将结果写入我们的buffer变量。如果我们至少读取了 1 个字节,我们将字节数组的第一个值传递给executor()函数。完成此步骤后,我们可以将草图上传到 UDOOboard。

从 Android 发送命令

既然 UDOOS 已经准备好进行物理操作,我们就需要完成 Android 应用程序,并在LightSwitch类中实现命令发送。作为第一步,我们需要向我们的活动添加一个变量来存储 LED 的状态。在我们的类顶部,添加mSwitchLed声明:

private Manager mManager;
private booleanmSwitchLed = false;

需要做的最后一件事情是创建一个使用 ADK 写入包装器向 Arduino 发送命令的方法。在onCreate()方法下面,添加以下代码:

public void switchLight(View v) {
 mSwitchLed = !mSwitchLed;
 int command = mSwitchLed ? 1 : 0;
 mManager.writeSerial(command);
}

我们改变 LED 的状态,并从中创建command参数,该参数可能是01的值。然后我们使用mManager将命令写入 OTG 端口。为了完成应用程序,我们只需要将switchLight方法绑定到我们的视图上。在activity_light_switch.xml文件中,像下面这样为我们的开关按钮添加onClick()属性:

<Switch
 android:layout_width="wrap_content"
 android:layout_height="wrap_content"
 android:text="LED 2"
 android:id="@+id/firstLed"
 android:onClick="switchLight"/>

这是我们的最后一步,现在我们有了第一个真实世界的原型。现在我们可以将 Android 应用程序上传到 UDOOboard,并使用它来开关 LED。

概述

在本章中,你已经了解到了 UDOOS 一些与可用输入输出引脚相关的特性,以及两个处理器是如何通过内部串行总线连接在一起的。此外,在第一部分,我们编写并将我们的第一个草图部署到电路板上。

然后,我们深入探讨了通过 ADK 实现的通信机制,并编写了一个新的 Arduino 草图,能够通过内部 OTG 端口与 Android 建立通信。为 Android 做同样的事情,我们创建了一个简单的用户界面,在设备使用期间提供视觉反馈。我们还编写了 Android 应用程序中的包装器,以便轻松地公开常用的 ADK 方法来打开和关闭连接,以及写入通信通道。

在本章的最后,你学习了如何使用面包板快速原型电路,并构建了你的第一个使用 LED 和电阻的电路。然后,我们添加了所有必要的代码,从我们的 Android 应用程序发送开关信号,并从草图中接收并执行此命令。这是一个更复杂的 Hello World 应用程序,它确实有助于构建我们的第一个真实世界设备。

在下一章中,我们将扩展上述电路的调试功能,以便测试我们的硬件,看看设备是否有任何损坏的电子组件。

第三章:测试您的物理应用程序

软件开发过程中最重要的步骤之一是测试。当我们测试软件组件时,我们使用测试框架编写单元测试,也许还有集成测试,这有助于复现错误并检查我们应用程序的预期行为。在物理应用中,这一过程并不容易,因为我们需要测试我们的草图与硬件电路的交互情况。

我们将为 LedLamp 应用程序添加所有必要的功能,以实现一种简单的方法来查找电路中的异常,这样我们可以避免复杂的调试过程。

在本章中,我们将讨论以下主题:

  • 关于电子元件和电路的更多细节

  • 向电路添加组件,以便它们可以被草图测试

  • 编写第一个用于电路调试的测试

  • 从您的原型运行电路测试

构建可测试的电路

在编写安卓应用程序时,我们可能会使用内部测试框架编写仪器测试。通过它们,我们可以检查应用程序在安卓堆栈所有层面的行为,包括用户界面压力测试。然而,在我们的 UDOO 项目中,我们利用安卓与板载微控制器交互,以控制和收集物理设备的数据。当我们的安卓应用程序通过测试覆盖了良好特性,并且符合我们所有要求时,我们首先遇到的问题很可能与硬件故障和异常有关。

注意事项

在本书中,我们将不介绍安卓单元测试框架,因为它不是在硬件原型制作初期所必需的。但是,请记住,您应该学习如何编写安卓测试,因为要提高软件质量,这是必须的。您可以在官方文档中找到更多信息,地址是developer.android.com/training/activity-testing/index.html

在上一章中,我们使用了许多电子元件,比如 LED 和电阻,构建了我们的第一个原型,并编写了一个安卓应用程序作为设备控制器。这是一个很好的起点,因为我们已经拥有了一个可以添加其他功能的正常工作的设备。为了使电路简单,我们将从第一个 LED 独立添加另一个 LED,使我们的设备能够控制两个不同设备的开关。我们需要对 LedLamp 电路进行一些更改,以便将第二个 LED 连接到 UDOO 板上。请查看以下电路图:

构建可测试的电路

要实现上述电路图,请采取以下步骤:

  1. 从电源总线的正线断开连接,因为我们需要从不同的引脚控制不同的组件。

  2. 保持地线连接到电源总线的负线,因为我们将所有的地线都连接在一起。

  3. 使用两个220 欧姆电阻器将负极腿连接到负电源总线。

  4. 将正极腿连接到 UDOOb 引脚 2 和 3。

在上一章中,我们将电阻器连接到正极腿,而现在我们连接负极腿。这两种配置都是正确的,因为当 LED 和电阻器串联连接时,电流将以相同的强度流过它们。我们可以发现,电路类似于高速公路,而汽车就像电荷。如果汽车遇到一个或多个路障,它们将从高速公路的每个点开始减速,而且不管它们距离路障是远是近。因此,即使电阻器位于电路末端,正确数量的电流仍会流过 LED。

既然电路包括了一个新的 LED,我们必须按照以下步骤更改我们的草图,使其符合我们的需求:

  1. 在草图的顶部添加以下类似对象的宏:

    #define LED 2
    #define LED_TWO3
    
    
  2. setup()函数中初始化新的 LED,如高亮代码所示:

    void setup(){
     pinMode(LED, OUTPUT);
     pinMode(LED_TWO, OUTPUT);
     digitalWrite(LED, LOW);
     digitalWrite(LED_TWO, LOW);
    }
    
  3. executor()函数中添加以下代码,使新的 LED 模仿我们已经编程的第一个 LED 的行为:

    switch(command) {
     case 0:
       digitalWrite(LED, LOW);
       break;
     case 1:
       digitalWrite(LED, HIGH);
       break;
     case 2:
     digitalWrite(LED_TWO, LOW);
     break;
     case 3:
     digitalWrite(LED_TWO, HIGH);
     break;
     default:
      // noop
       break;
    }
    
  4. 更改文件顶部的配件描述符,以更新草图版本:

    char versionNumber[] = "0.2.0";
    

更改版本号总是一个你应该注意的好习惯。在我们的案例中,这也是一个要求,因为我们必须通知 Android 硬件行为已经改变。正如你在第二章,了解你的工具中看到的,当 Android 和 Arduino 中定义的版本不匹配时,Android 应用程序将不会与微控制器通信,这防止了意外的行为,特别是在硬件更改时。实际上,如果我们再次部署新的草图,可以看到 Android 将找不到任何可用的应用程序来管理配件。

最后一步,让原型再次工作,是更新 Android 应用程序,从其用户界面和逻辑开始,使其能够管理新设备。为了实现这个目标,我们应该采取以下步骤:

  1. res/layout/activity_light_switch.xml文件中,在firstLed声明下方添加一个新的开关按钮:

    <Switch
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:text="LED 3"
     android:id="@+id/secondLed"
     android:onClick="switchLightTwo"/>
    
  2. 在类的顶部LightSwitch活动中添加以下声明,以存储第二个 LED 的状态:

    private boolean mSwitchLed = false;
    private boolean mSwitchLedTwo = false;
    
    
  3. switchLight()方法下方添加以下代码,根据草图开关案例控制第二个 LED:

    public void switchLightTwo(View v) {
     mSwitchLedTwo = !mSwitchLedTwo;
     int command = mSwitchLedTwo ? 3 : 2;
     mManager.writeSerial(command);
    }
    
  4. res/xml/下的usb_accessory_filter.xml描述符文件中更新新的硬件版本:

    <resources>
     <usb-accessory
     version="0.2.0"
     model="LedLamp"
     manufacturer="Example, Inc."/>
    </resources>
    

我们正在匹配草图的版本,以便 Android 知道这个应用程序可以再次管理连接的配件。部署新应用程序后,我们可以使用原型来打开和关闭两个连接的 LED。

开发一个诊断模式。

拥有一个可工作的原型后,是时候添加一个功能来测试我们的电路了。即使我们很想动手写代码,但首先需要模拟一个物理损坏,这个损坏会在原型中引起故障。因为不想真正损坏我们的 LED 灯,我们可以更改电路元件来复现异常。

实际上,我们可以模拟连接到引脚 3 的电阻器一条腿断裂的情况。如果发生这种情况,电路会被切断,这会阻止电流流过 LED 灯。为了在面包板上复现这个问题,我们可以简单地移除第一个电阻器,如下一个图表中所示:

开发诊断模式

现在我们已经模拟了第一个硬件故障。如果我们打开 Android 应用程序并使用开关,可以看到第二个 LED 灯按预期工作,而第一个停止工作。然而,由于软件组件对内部发生的情况一无所知,所以它们没有注意到任何问题。如果出现这样的问题,我们会感到迷茫,因为我们在不知道应该将注意力集中在哪个部分来查找故障的情况下,开始进行软件和硬件调试。

当软件出现问题时,我们通常会使用调试器。不幸的是,当处理电路问题时,我们没有太多的工具,可能需要自己实现一些功能。一个好的起点是给原型添加一个功能,使其能够通过诊断模式自我调试。这个模式应该模拟并模仿我们电路的真实行为,但要以受控的方式进行。诊断模式对于识别原型中与软件错误无关的异常原因非常有帮助。

提示

诊断模式是我们寻找异常应该遵循的第一步。然而,当我们发现硬件故障时,应该开始使用其他工具,比如一个能够测量电压、电流和电阻的万用表

在我们开始在草图上实现这个模式之前,需要连接一个按钮,我们将用它来启用诊断模式。我们需要将这个组件添加到我们的面包板上,如下一个图表的左侧部分所示:

开发诊断模式

按照图表所示,将组件添加到面包板的步骤如下:

  1. 将按钮添加到面包板的中间,使得同一垂直线上的腿不要连接。

  2. 将按钮的左腿连接到+5V 引脚。

  3. 将按钮的右腿连接到引脚 4。

  4. 将一个10 KOhm电阻的一侧连接到按钮的右腿,另一侧连接到电源总线的负线。

通过这些连接,当我们按下按钮时,我们从引脚 4 读取数字信号,因为电流会选择电阻较小的路径,就像水一样。在我们的案例中,机械开关将在+5V 和 4 引脚之间建立连接,并且由于这条路径的电阻远小于地线中的10 KOhm,UDOOb 将读取这个电压差并将其转换为数字信号。当开关打开时,唯一的路径是引脚 4 和地线,因此 UDOOb 不会读取到电压差。这使我们能够知道开关是否被按下。

编写第一个测试

既然我们已经有了一个物理硬件开关,我们需要在用户按下按钮时激活诊断模式。为了检测按钮按下,我们应该按照以下步骤更改草图:

  1. 在 ADK 初始化之后,添加突出显示的声明:

    ADKadk(&Usb, manufacturer, model, accessoryName, versionNumber, url, serialNumber);
    int reading = LOW;
    int previous = LOW;
    long lastPress = 0;
    
    

    我们需要每次读取阶段的按钮状态,这样我们就可以在当前和之前的读取期间保存状态。lastPress变量将包含上次按下按钮的时间戳。我们将按钮状态设置为LOW,因为我们认为没有电流流过按钮,这意味着它没有被按下。

  2. 在草图的顶部,定义以下类似对象的宏:

    #define LED_TWO3
    #define BUTTON 4
    #define DEBOUNCE 200
    
    

    我们设置按钮引脚 4 和 DEBOUNCE 值,该值表示在代码开始再次评估按钮按下之前应经过的毫秒数。使用这个阈值是必要的,因为它防止读取到错误的阳性结果。如果我们省略这部分,当按钮被按下时,草图将检测到数千次读数,因为 UDOOb 的读取阶段比我们松开按钮的反应要快。这个值称为消抖阈值

  3. setup()函数中按如下配置按钮引脚模式:

    pinMode(LED_TWO, OUTPUT);
    pinMode(BUTTON, INPUT);
    
    
  4. loop()函数的内容移动到一个名为readCommand()的新函数中,使其与以下内容相匹配:

    void readCommand() {
     Usb.Task();
     if (adk.isReady()) {
       adk.read(&bytesRead, BUFFSIZE, buffer);
       if (bytesRead> 0) {
         executor(buffer[0]);
      }
     }
    }
    
  5. 在空的loop()函数中,我们应该添加以下代码进行读取阶段:

    void loop(){
      // Reads the digital signal from the circuit
     reading = digitalRead(BUTTON);
      // Checks the button press if it's outside a
      // debounce threshold
     if (reading == HIGH && previous == LOW &&millis() - lastPress>DEBOUNCE) {
       lastPress = millis();
        // Visual effect prior to diagnostic activation
       digitalWrite(LED, HIGH);
       digitalWrite(LED_TWO, HIGH);
       delay(500);
       digitalWrite(LED, LOW);
       digitalWrite(LED_TWO, LOW);
       delay(500);
       startDiagnostic();
     }
     previous = reading;
     readCommand();
    }
    

    我们使用内置的digitalRead()函数存储按钮的值,该函数抽象了从所选引脚读取电压差的复杂性。然后,我们检查当前状态是否与之前不同,这样我们就能确定按钮正是在这一刻被按下。

    然而,我们还需要检查自按下按钮以来是否超过了消抖阈值。我们使用内置的millis()函数,它返回自 UDOOb 板开始当前程序以来的毫秒数。

    如果捕捉到按下按钮的事件,我们设置lastPress值,并提供视觉反馈以通知用户诊断模式即将启动。无论如何,我们都会保存先前的按钮状态,并继续执行标准操作。

    提示

    有时诊断模式需要激活和停用阶段。在我们的案例中,我们简化了流程,使得诊断模式仅在按下按钮后运行一次。在其他项目中,我们可能需要一个更复杂的激活机制,可以将其隔离在独立函数中。

  6. 作为最后一步,按照以下方式实现startDiagnostic()函数:

    void startDiagnostic() {
     // Turn on the first LED
     executor(1);
     delay(1000);
     executor(0);
     // Turn on the second LED
     executor(3);
     delay(1000);
     executor(2);
     // Turn on both
     executor(1);
     executor(3);
     delay(1000);
     executor(0);
     executor(2);
    }
    

    诊断功能应该模仿我们电路的所有或几乎所有可能的行为。在本例中,我们打开和关闭第一个和第二个 LED,作为最后的测试,我们同时为它们供电。在诊断模式下,使用内部函数来复现电路动作非常重要。这有助于我们测试executor()函数的输入,确保我们已经映射了 Android 应用程序发送的所有预期输入。

既然我们已经有了诊断功能,我们必须再次部署 LedLamp 草图,并按下按钮开始诊断。如预期的那样,由于虚拟损坏的电阻器,只有一个 LED 会亮起。现在我们可以重新连接电阻器,并启动诊断模式,以测试 LED 连接是否已修复。

总结

在本章中,我们深入探讨了硬件测试,以提高我们项目的质量。我们发现这个过程非常有价值,因为通过这种方法,我们可以将硬件故障与软件错误区分开来。

我们在之前的原型中添加了另一个 LED,以便我们可以从 Android 应用程序控制多个设备。然后,我们在其中一个电子组件中模拟了一个硬件故障,从电路中移除一个电阻器以产生一个受控的异常。这促使我们编写了自己的诊断模式,以便快速找到这类故障。

第一步是为我们的原型添加一个按钮,我们可以使用它来启动诊断模式,然后我们利用这个功能模拟所有可能的电路行为,以便轻松找到损坏的电阻器。

在下一章中,我们将从零开始构建一个新原型,它能够通过一组新的电子组件从环境中收集数据。我们还将编写一个 Android 应用程序,能够读取草图发送的这些值,并可视化处理后的数据。

第四章:使用传感器倾听环境

当我们构建原型时,希望为最终用户提供最佳的交互体验。有时,我们构建的实际应用没有任何人为交互,但它们只是监听环境以收集数据并决定要做什么。无论我们的原型是什么,如果我们想要读取和理解人类行为或环境变化,我们需要使用一组新的电子组件:传感器

每次我们构建物理应用时,都必须牢记,我们的项目越复杂,就越有可能需要添加传感器来实现所需的交互。

在本章中,我们将从零开始构建一个能够感知我们的心跳并将结果发布到我们的安卓应用程序中的真实应用。

在本章中,我们将涵盖以下主题:

  • 使用环境传感器进行工作

  • 构建心跳监测器

  • 从传感器收集数据

  • 从安卓应用程序展示收集的数据

使用环境传感器进行工作

在电子学中,传感器是构建来检测特定物质或粒子属性任何变化的组件。当发生任何变化时,传感器提供一个电压变化,可以改变其他电子组件的电流流动和行为。如果微控制器连接到传感器,它可以根据运行程序决定采取不同的行动。

传感器可以检测许多属性的变化,如热辐射、湿度、光线、无线电、声波等。当我们在项目中使用传感器时,必须选择一个特定的属性进行监听,然后需要读取并管理电压的变化。有时,为了执行检查,我们需要将这些电学变化转换为其他测量单位,如米或温度度数。在其他时候,我们可能会使用更复杂的传感器,这些传感器已经为我们完成了全部或部分的转换。例如,如果我们正在构建一个机器人探测器,可能需要使用传感器来检测与物体的距离,以避开任何房间障碍。在这种情况下,我们将使用基于雷达或声纳原理相似的超声波传感器。它发射高频声波并评估接收到的回声。通过分析发送和接收信号回声之间的时间间隔,我们可以确定与物体的距离。

实际上,在一个通用的草图中,我们读取的是从传感器收到信号回声之前经过的微秒数。为了使这些值更有用并找到正确的距离,我们可能需要在草图内部编写一个微秒到厘米或英寸的转换器。

然而,只有在我们了解传感器的工作原理以及信号每微秒传播了多少厘米或英寸的情况下,这才可能实现。幸运的是,我们可以从组件制造商发布的文档中找到这些信息,这个文档被称为数据手册。有了这些知识,我们可以轻松地将所有探测到的值转换为我们要寻找的内容。当我们完成本章的原型后,可以查看 URL arduino.cc/en/tutorial/ping,其中包含了一个关于如何使用超声波传感器以及如何轻松地将检测到的信号转换为不同测量单位的示例。

构建心跳监测器

在前面的章节中,我们构建了第一个配备 LED 执行器的原型,用以改变周围环境,并通过内部 ADK 通信使 Android 应用程序控制 LED 的行为。我们已经看到传感器对于提高我们原型的交互性非常有帮助,我们可能想要将这项新功能添加到之前的项目中。实际上,由于我们使用的是一个能够发光的组件,我们可能会考虑添加一个外部光传感器,以便微控制器可以根据环境光线来开关 LED。

这只是一个关于如何使用光传感器的示例。实际上,我们必须牢记每个传感器都可以以不同的方式使用,我们的任务是要找到检测值与物理应用目标之间的相关性。我们绝不应该仅限于使用传感器的主要用途,正如我们将在心跳监测器中看到的那样。

创建带有光传感器的电路

与之前的原型类似,心跳监测器由两部分组成。第一部分是电路和草图,应该从光传感器收集数据并将其转换为代表每分钟节拍数bpm)的值。第二部分是 Android 应用程序,它会在屏幕上显示我们心率计算出的值。

注意

即使这个原型可能取得不错的效果,但用自制的原型用于医疗原因是不可取的。光敏电阻仅用于演示,不应用于任何医疗目的。

对于这个物理应用,我们将使用光敏电阻作为光传感器的一部分。光敏电阻,也称为光依赖电阻器LDR),其工作原理与之前原型中使用的传统电阻类似,但在提供的电阻方面略有不同。实际上,它的电阻根据测量的光照强度而变化,如果我们监测这个值,可以轻松计算出环境强度是在增加还是减少。我们还使用了一个鲜红色的 LED,它不同于之前使用的 LED,因为其亮度足以让光线透过我们的皮肤。

我们的目标是创建一个电路,我们可以将食指的一侧放在光敏电阻的顶部,另一侧是明亮的 LED。这样,一部分光线会穿过我们的手指,并被光敏电阻检测到。在每次心跳时,沿着动脉的血压力波会向外移动,增加我们的血量。当光线穿过我们的组织时,这种血量变化会改变落在传感器上的光线量。因此,当我们看到探测值中出现中等到高度变化时,这很可能是我们的心跳。

为了开始构建我们的原型,我们需要将光敏电阻放入面包板中,以便我们可以实现以下电路图:

使用光传感器创建电路

按照以下步骤操作,以实现前面的电路图:

  1. 光敏电阻的腿可能太长。使用电子元件剪钳将腿剪短,最多 1.5cm。这不是必须的,但可能会简化原型的使用。

  2. 将 UDOO 的+3.3V 引脚连接到面包板的第一行。确保不要连接+5V 电源引脚,因为在连接过程中可能会损坏电路板。

  3. 在电路板上放置一个10 KOhm电阻,并将其连接到+3.3V 引脚;我们还需要将另一端连接到模拟输入 A0 引脚。

  4. 将光敏电阻连接到电阻和 A0 引脚的同一列;第二个引脚应连接到电源总线的负线。

    提示

    光敏电阻的作用与其他电阻一样,所以在这一步我们连接哪一端并不重要,因为它们没有极性

  5. 将 UDOO 的地线连接到电源总线的负线。

通过这些步骤,我们构建了一个由两个电阻组成的电压分压器电路。这类电路根据电阻值产生一个输入电压的分数作为输出电压。这意味着,由于电阻值会随光照强度变化而变化,电压分压器输出的电压也会随光照变化。这样,电路板可以检测到这些变化,并将其转换为一个 0 到 1023 之间的数值。换句话说,当光敏电阻处于阴影中时,我们读取到一个高值;而当它处于光照中时,我们读取到一个低值。由于我们将10 KOhm电阻连接到+3.3V 引脚,我们可以认为这个电压分压器是使用了一个上拉电阻构建的。

提示

电压分压器在许多电子电路中经常使用。你可以在learn.sparkfun.com/tutorials/voltage-dividers找到关于这类电路其他应用的信息。

为了完成我们的原型,我们不得不将高亮 LED 添加到电路中。然而,因为我们需要将 LED 放在手指的另一侧,我们不能直接将组件连接到我们的面包板上,但我们需要使用一对鳄鱼夹。作为第一步,我们需要按照以下电路图扩展电路:

使用光传感器创建电路

按照以下步骤实现前面的电路图:

  1. 将 UDOOU +5V 电源引脚连接到电源总线的正线上。

  2. 在面包板上添加一个220 欧姆电阻,并将一个引脚连接到电源总线的负线上。

  3. 将电线连接器的一边接到220 欧姆电阻的另一引脚上。

  4. 将电线连接器的一边接到电源总线的正线上。

  5. 将第一个鳄鱼夹的一边连接到连接到电源总线正线的导线上。

  6. 将第二个鳄鱼夹的一边连接到电阻器的导线上。

  7. 将延长+5V 引脚的鳄鱼夹连接到 LED 的长腿上。

    注意

    在进行下一步之前,请记住你正在使用一个非常亮的 LED。你应该避免将其直接对准你的眼睛。

  8. 将延长电阻和接地连接的鳄鱼夹连接到 LED 的短腿上。

如果所有连接都设置好了,LED 应该会亮起,我们可以将其作为原型的一个活动部分。需要记住的一件事是,鳄鱼夹的金属端头绝对不能相互接触,否则电路将停止工作,一些组件可能因为短路而损坏。

从草图中收集数据

既然我们已经有一个工作的电路,我们应该开始编写草图以从光传感器收集数据。然后我们应该分析这些结果,考虑一个将读数转换为心跳计数的算法。我们应该开始一个新的草图,并添加以下步骤:

  1. 在草图顶部添加以下声明:

    #define SENSOR A0
    #define HEARTBEAT_POLL_PERIOD50
    #define SECONDS 10
    constint TIMESLOTS = SECONDS * 1000 / HEARTBEAT_POLL_PERIOD;
    int sensorReading = 0;
    

    我们定义了一个类似对象的宏SENSOR,值为A0,这是我们将用于模拟读数的引脚。我们设置HEARTBEAT_POLL_PERIOD以指定微控制器在连续传感器读数之间应该等待多少毫秒。使用SECONDS参数,我们定义了在处理和估计心率之前应该过去多少秒。实际上,我们将SECONDS乘以1000将这个值转换为毫秒,然后除以HEARTBEAT_POLL_PERIOD参数来定义TIMESLOTS常数。这个变量定义了我们应该循环读取阶段多少次以收集估计心率所需正确数量的读数。这样,我们在每个TIMESLOTS周期进行一次读取,当周期结束时,我们计算心率。最后一个变量sensorReading用于在每次循环迭代中存储传感器读数。

  2. setup()函数中,添加串行端口的初始化,以便我们可以在 UDOOboard 和计算机之间打开通信:

    void setup() {
     Serial.begin(115200);
    }
    
  3. 在草图的底部添加以下函数,通过串行端口打印读取的值:

    void printRawData() {
     sensorReading = analogRead(SENSOR);
     Serial.println(sensorReading);
    }
    

    我们使用内置的analogRead函数从模拟输入引脚读取传入数据。因为这些引脚是只读的,我们不需要在setup()函数中进行进一步配置或更改输入分辨率。

    提示

    有时我们可能需要更好的模拟读取分辨率,范围在 0 到 4095 之间,而不是 0 到 1023。在这种情况下,我们应该使用analogReadResolution参数来改变分辨率。我们可以在官方文档中找到更多关于模拟输入分辨率的信息,地址是arduino.cc/en/Reference/AnalogReadResolution

    当读取完成时,我们在串行端口打印结果,这样我们就可以通过 Arduino IDE 串行监视器读取这些值。

  4. 在主loop()函数中,为每个读取时隙添加printRawData()函数调用:

    void loop() {
     for (int j = 0; j < TIMESLOTS; j++) {
     printRawData();
     delay(HEARTBEAT_POLL_PERIOD);
     }
     Serial.println("Done!");
     delay(1000);
    }
    

    我们进行TIMESLOTS迭代是为了在 10 秒内获取读数,如之前定义的。所有读数完成后,我们在串行端口打印一条消息,并在重新开始读取前等待一秒。

    提示

    一秒的延迟和完成!的消息仅证明读取周期正在正确工作。我们稍后会移除它们。

配置完毕后,我们可以上传草图并继续我们的第一次实验。将食指的底部放在光电阻上,同时将 LED 放在另一侧。

提示

为了获得更精细的读数,如果光电阻和 LED 的接触部分是指关节和指甲之间的部分会更好。

开始实验时,点击串行监视器按钮,当草图打印出完成!的消息时,我们将看到如下截图所示的一些数值:

从草图收集数据

这些是我们心跳期间光传感器捕捉到的绝对值。如果我们把一个完整的 10 秒迭代复制粘贴到 Microsoft Excel、Libre Office Calc 或 Numbers 表格中,我们可以绘制一个折线图,以更易于理解的形式查看给定结果:

从草图收集数据

我们可以看到,数值随时间变化,当发生心跳时,光传感器检测到光强的变化,这一事件导致图表中产生一个峰值。换句话说,我们可以假设每个峰值都对应一次心跳。下一步是改进我们的草图,以近似和转换这些数值,因为我们应该尝试去除读数错误和假阳性。主要思想是在每次迭代后收集固定数量的样本,以存储这次读数和上一次读数之间的差值。如果我们随着时间的推移存储所有差值,我们可以轻松找到读数趋势,并识别出我们读取峰值的时候。为了改进我们的算法,我们需要执行以下步骤:

  1. 在草图的顶部添加以下变量:

    #define SECONDS 10
    #define SAMPLES 10
    
    constint TIMESLOTS = SECONDS * 1000 / HEARTBEAT_POLL_PERIOD;
    
    int sensorReading = 0;
    int lastReading = 0;
    int readings[SAMPLES];
    int i = 0;
    int delta = 0;
    int totalReading = 0;
    
    

    我们设置用于计算增量差值的SAMPLES数量。然后使用lastReadingidelta变量分别存储上一次读数、用于迭代readings数组的当前索引,以及与上一次读数的当前差值。然后我们定义一个累加器来存储当前的读数总和。

  2. setup函数中初始化readings数组:

    void setup() {
     Serial.begin(115200);
    
     for (int j = 0; j < SAMPLES; j++) {
     readings[j] = 0;
     }
    }
    
  3. 在草图的底部添加collectReads()函数:

    void collectReads() {
     sensorReading = analogRead(SENSOR);
      delta = sensorReading - lastReading;
     lastReading = sensorReading;
     totalReading = totalReading - readings[i] + delta;
      readings[i] = delta;
     i = (i + 1) % SAMPLES;
    }
    

    在第一部分,我们将读取当前值并计算与上一次读数的差值。然后我们使用当前的totalReadingreadings数组中存储的上一个差值来累加这个差值。现在我们可以用新的delta对象更新当前索引的readings数组,该索引在最后一行递增,并通过模运算符保持在界限内。

  4. 在主loop()函数中,用新的collectReads()函数替换printRawData()函数调用,然后打印累积的值:

    for (int j = 0; j < TIMESLOTS; j++) {
     collectReads();
     Serial.println(totalReading);
      delay(HEARTBEAT_POLL_PERIOD);
    }
    

进行这些增强后,我们可以上传新的草图,并像之前一样重复进行实验:

  1. 将你的食指放在光电阻和 LED 之间。

  2. 在 Arduino IDE 上点击串行监视器

  3. 完成一个完整的 10 秒迭代。

  4. 将这些值复制并粘贴到之前的电子表格中,并绘制条形图。我们应该避免包含前八个读数,因为它们与第一次迭代有关,而此时readings数组尚未初始化。

收集到的值产生了如下图表:

从草图中收集数据

在这些处理过的读数中,正负值之间会有波动,这种情况出现在我们攀登或下降之前看到的峰值时。有了这些知识,我们可以稍微改进一下算法,以便追踪攀登或下降阶段,并选择是丢弃读数还是将其计为一次心跳。要完成这部分,我们需要按照以下步骤添加以下代码:

  1. 在草图的顶部添加这些声明:

    #define SECONDS 10
    #define POS_THRESHOLD 3
    #define NEG_THRESHOLD -3
    
    const int TIMESLOTS = SECONDS * 1000 / HEARTBEAT_POLL_PERIOD;
    const int PERMINUTE = 60 / SECONDS;
    int beats = 0;
    boolean hillClimb = false;
    
    

    我们定义了POS_THRESHOLDNEG_THRESHOLD参数来设置我们丢弃值的区间边界,以避免误报。同时,我们还定义了一个PERMINUTE常数,以得知获取每分钟心跳数的乘数以及beats累加器。最后,我们设置了一个hillClimb变量,用来存储下一次读数是在上升阶段还是下降阶段。例如,True值意味着我们处于上升阶段。

  2. 在草图的底部添加findBeat()函数:

    void findBeat() {
      if (totalReading<NEG_THRESHOLD) {
       hillClimb = true;
      }
      if ((totalReading>POS_THRESHOLD)&&hillClimb) {
       hillClimb = false;
        beats += 1;
      }
    }
    

    我们检查totalReading参数是否低于NEG_THRESHOLD参数,以确定我们是否处于峰值下降阶段。在这种情况下,我们将hillClimb变量设置为True。在最后的代码块中,我们检查是否超过了POS_THRESHOLD并且处于上升阶段。如果是这样,我们将hillClimb设置为False,并将此阶段变化计为一次心跳。如果我们查看之前的图表,通过前面的代码,我们可以轻松确定每次读数时我们处于哪个阶段,并且利用这些信息尽可能多地排除错误和误报。

  3. 在草图的底部添加实用函数calcHeartRate()

    int calcHeartRate() {
      return beats * PERMINUTE;
    }
    
  4. 在主loop()函数中,添加以下代码以使用前面的函数,并在串行端口中打印心率及心跳数:

    for (int j = 0; j < TIMESLOTS; j++) {
     collectReads();
     findBeat();
      delay(HEARTBEAT_POLL_PERIOD);
    }
    Serial.print(calcHeartRate());
    Serial.print(" with: ");
    Serial.println(beats);
    beats = 0;
    delay(1000);
    
  5. 再次上传草图并开始计算心跳。在串行监视器中,我们会注意到以下值:

    72 with: 12
    84 with: 14
    66 with: 11
    78 with: 13
    90 with: 15
    84 with: 14
    

对我们草图的最后改进是添加 ADK 功能,将计算出的心跳发送到我们的 Android 应用程序。在草图的顶部,添加以下accessory descriptor,它与我们之前原型中使用的基本相同:

#include <adk.h>
#define BUFFSIZE 128
char accessoryName[] = "Heartbeat monitor";
char manufacturer[] = "Example, Inc.";
char model[] = "HeartBeat";
char versionNumber[] = "0.1.0";
char serialNumber[] = "1";
char url[] = "http://www.example.com";
uint8_t buffer[BUFFSIZE];
uint32_tbytesRead = 0;
USBHostUsb;
ADKadk(&Usb, manufacturer, model, accessoryName, versionNumber, url, serialNumber);

作为最后一步,在主loop()函数中,将草图执行包裹在 ADK 通信中,并移除所有的串行打印以及最后的 1 秒延迟:

void loop() {
Usb.Task();
  if (adk.isReady()) {
    // Collect data
    for (int j = 0; j < TIMESLOTS; j++) {
      collectReads();
      findBeat();
      delay(HEARTBEAT_POLL_PERIOD);
    }
  buffer[0] = calcHeartRate();
 adk.write(1, buffer);
  beats = 0;
 }
}

这样,心率监测器将在 ADK 通信启动并运行时开始工作,我们将使用adk.write()函数将计算出的心率发送回 Android 应用程序。

Android 用于数据可视化

既然我们的物理应用程序已经有了一个完全工作的电路,可以通过对光传感器的非常规使用来读取心率,我们应该用 Android 应用程序来完成原型设计。从 Android Studio 开始,启动一个名为HeartMonitor的新 Android 项目,使用Android API 19。在引导过程中,选择一个名为Monitor空白活动

我们从用户界面开始编写应用程序,并且需要思考和设计活动布局。为了这个应用程序的目的,我们编写了一个简单的布局,包含一个标题和一个文本组件,每次 Android 从草图中接收到心跳估算时,我们都会更新这个组件。这个布局可以通过以下步骤实现:

  1. res/values/下的styles.xml文件中,添加这些颜色声明并替换标准主题:

    <color name="sulu">#CBE86B</color>
    <color name="bright_red">#A30006</color>
    
    <style name="AppTheme" parent="Theme.AppCompat">
    <!-- Customize your theme here. -->
    </style>
    

    AppTheme参数继承了Theme.AppCompat参数,它指的是 Android 支持库中可用的Holo Dark主题。我们还创建了绿色和红色,稍后将在我们的应用程序中使用。

  2. res/layout/下的activity_monitor.xml文件中,用高亮显示的更改替换根布局:

    <LinearLayout
    
     android:orientation="vertical"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:paddingLeft="@dimen/activity_horizontal_margin"
     android:paddingRight="@dimen/activity_horizontal_margin"
     android:paddingTop="@dimen/activity_vertical_margin"
     android:paddingBottom="@dimen/activity_vertical_margin"
     tools:context=".Monitor">
    </LinearLayout>
    
    
  3. 使用以下代码更改前一个布局中包含的TextView参数,以拥有一个更大的绿色标题,显示应用程序名称:

    <TextView
     android:text="Android heart rate monitor"
     android:gravity="center"
     android:textColor="@color/sulu"
     android:textSize="30sp"
     android:layout_width="match_parent"
     android:layout_height="wrap_content" />
    
  4. 在根布局中嵌套一个新的LinearLayout

    <LinearLayout
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
     android:layout_marginTop="30sp"
     android:gravity="center">
    </LinearLayout>
    

    我们从上一个元素设置一个边距,使用所有可用空间将内部组件放置在居中位置。

  5. 添加以下 TextView 以显示标签和占位符,占位符将包含计算出的每分钟节拍数:

    <TextView
     android:text="Current heartbeat: "
     android:textColor="@color/sulu"
     android:textSize="20sp"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"/>
    
    <TextView
     android:id="@+id/bpm"
     android:text="0 bpm"
     android:textColor="@color/bright_red"
     android:textSize="20sp"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"/>
    
  6. 在活动类中获取小部件,以便在每次读取后更改它。在Monitor类的顶部添加以下声明:

    private TextViewmBpm;
    
  7. onCreate()回调中通过高亮代码找到由bpm标识符标识的视图:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
     setContentView(R.layout.activity_monitor);
     mBpm = (TextView) findViewById(R.id.bpm);
    }
    

在没有进一步配置的情况下,以下是获得的布局:

用于数据可视化的 Android

现在应用程序布局已完成,我们可以继续设置 ADK 通信。

设置 ADKToolkit

就像我们对第一个原型所做的那样,我们需要重新编写所有的 ADK 类和方法以发送和接收数据。然而,由于软件开发的良好原则是“不要重复自己”(DRY),我们将使用一个外部库,它为所有需要的功能提供了高抽象。这个库被称为ADKToolkit,它是原生 ADK API 的封装,可以防止我们在每次开始新项目时重复代码。我们可以在docs.adktoolkit.org找到更多关于该库的信息和示例。

首先需要做的是将 ADKToolkit 库添加到应用程序依赖项中。在用 Android Studio 构建的项目中,有两个名为build.gradle的不同文件。这些文件包含了与 Gradle 构建系统相关的所有配置,其中一个与全局项目相关,另一个与我们正在构建的应用程序模块相关。尽管这两个文件都包含依赖项列表,但我们应该将库添加到位于app文件夹中的与应用程序模块相关的build.gradle文件中。如果我们使用 Android Studio 界面左侧可用的Project面板,必须双击build.gradle (Module: app)脚本。在这个文件中,我们需要在dependencies块中添加高亮显示的代码:

dependencies {
  compile fileTree(dir: 'libs', include: ['*.jar'])
 compile 'com.android.support:appcompat-v7:21.0.3'
 compile 'me.palazzetti:adktoolkit:0.3.0'
}

现在我们可以点击闪存消息中可用的Sync Now按钮,并等待 Gradle 完成同步过程,这个过程会自动下载 ADKToolkit 库。

正如在第二章,了解你的工具中所做的那样,我们应该更新 Android 清单文件,以注册具有正确意图过滤器和配件描述符的应用程序。要继续进行 ADK 配置,请遵循以下提醒:

  1. res/xml/目录下创建配件过滤器文件usb_accessory_filter.xml,并使用以下代码:

    <resources>
     <usb-accessory
        version="0.1.0"
        model="HeartBeat"
        manufacturer="Example, Inc."/>
    </resources>
    
  2. AndroidManifest.xml文件中添加 USB <uses-feature>标签。

  3. AndroidManifest.xml文件的 Activity 块中,添加 ADK <intent-filter><meta-data>标签,以设置 USB 配件过滤器。

现在我们必须初始化 ADKToolkit 库以启用通信并开始读取处理后的数据。在Monitor类中,添加以下代码片段:

  1. 在类的顶部声明AdkManager对象:

    private TextViewmBpm;
    private AdkManagermAdkManager;
    
    
  2. onCreate()方法中添加AdkManager的初始化:

    mBpm = (TextView) findViewById(R.id.bpm);
    mAdkManager = new AdkManager(this);
    
    

    AdkManager是 ADKToolkit 库的主要类。为了初始化管理器实例,我们应该将当前上下文传递给它的构造函数,由于活动类从Context类继承,我们可以简单地使用this关键字传递实例。所有与 ADK 通信相关的功能都将通过mAdkManager实例来使用。

  3. 重写onResume()onPause()回调,以便在Monitor活动打开或关闭时开始和停止 ADK 连接:

    @Override
    protected void onResume() {
     super.onResume();
     mAdkManager.open();
    }
    
    @Override
     protected void onPause() {
     super.onPause();
     mAdkManager.close();
    }
    

    mAdkManager实例暴露了close()open()方法,以便轻松控制配件连接。我们必须记住,在onResume()方法中打开 ADK 通信是一个要求,因为AdkManager的初始化不足以启用 Android 和 Arduino 之间的通道。

通过以上步骤,我们已经完成了 ADK 配置,现在可以开始编写接收草图数据的逻辑。

从 Android 进行连续数据读取

我们 Android 应用程序的主要概念是使用 ADKToolkit 对 UDOOboard 收集的数据进行连续读取。每次估算通过 OTG 串行端口写入时,我们需要读取这些值并更新 Android 用户界面,但在我们继续之前,我们需要对 Android 线程系统进行一些考虑。

当 Android 应用程序启动时,该应用程序的所有组件都在同一个进程和线程中运行。这称为主线程,它托管诸如当前前台Activity实例等其他组件。每当我们需要更新当前活动的任何视图时,我们应该在主线程中运行更新代码,否则应用程序将会崩溃。另一方面,我们必须记住,主线程中完成的任何操作都应该立即完成。如果我们的代码运行缓慢或执行阻塞操作(如 I/O),系统将会弹出应用程序无响应ANR)对话框,因为主线程无法处理用户输入事件。

如果我们在主线程中运行连续读取,这种错误肯定会发生,因为我们应该在一个循环中查询光线传感器,这会导致每 10 秒发生阻塞 I/O 操作。因此,我们可以使用ExecutorService类来运行周期性的计划线程。在我们的案例中,我们将定义一个生命周期较短的线程,该线程将每隔 10 秒从上述调度程序中创建。

当计划线程从 OTG 串行端口读取数据完成后,它应该通过Handler类将接收到的消息传递给主线程。我们可以在官方 Android 文档中找到更多关于如何与主线程通信的信息和示例:

在主线程中通信

首先,我们应该通过以下步骤公开所有需要更新 Android 用户界面的方法:

  1. 创建一个名为OnDataChanges的新 Java 接口,并添加以下方法:

    public interface OnDataChanges {
      void updateBpm(byte heartRate);
    }
    

    通过这段代码,我们定义了将在我们的Handler中使用的接口,以给定heartRate参数更新用户界面。

  2. Monitor类中通过高亮代码实现接口:

    public class Monitor extends ActionBarActivity implements OnDataChanges {
      private TextViewmBpm;
      // ...
    
  3. 在类的末尾编写以下代码,通过updateBpm方法更新 Android 用户界面:

    @Override
    public void updateBpm(byte heartRate) {
     mBpm.setText(String.format("%d bpm", heartRate));
    }
    

最后一个必需的步骤是实现我们的计划线程,从 Arduino 读取处理后的数据,并在用户界面中写入这些值。要完成这个最后的构建块,请执行以下步骤:

  1. 在你的命名空间中创建一个名为adk的新包。

  2. adk包中,添加一个名为DataReader的新类。

  3. 在类的顶部,添加以下声明:

    private final static int HEARTBEAT_POLLING = 10000;
    private final static int HEARTBEAT_READ = 0;
    private AdkManager mAdkManager;
    private OnDataChanges mCaller;
    private ScheduledExecutorService mScheduler;
    private Handler mMainLoop;
    

    我们定义了心跳轮询时间和一个后面要使用的int变量,用于在我们的处理程序中识别发布的信息。我们还存储了AdkManager参数和caller活动的引用,分别用于 ADK 的read方法和updateBpm回调。然后我们定义了ExecutorService实现以及一个要附加到主线程的Handler

  4. 实现构造函数DataReader,以定义当主线程从后台线程接收到新消息时的处理消息代码。

    public DataReader(AdkManageradkManager, OnDataChangescaller) {
     this.mAdkManager = adkManager;
     this.mCaller = caller;
     mMainLoop = new Handler(Looper.getMainLooper()) {
        @Override
        public void handleMessage(Message message) {
          switch (message.what) {
            case HEARTBEAT_READ:
         mCaller.updateBpm((byte) message.obj);
              break;
          }
        }
      };
    }
    

    存储了AdkManager实例和caller活动引用之后,我们向应用程序的主 looper 附加一个新的Handler,该 looper 位于主线程中。我们应该重写handleMessage回调,以便检查用户定义的消息代码,以识别HEARTBEAT_READ消息。在这种情况下,我们使用接收到的message参数中附加的对象来调用updateBpm回调。

    提示

    每个Handler都有自己消息代码的命名空间,因此你不需要担心你的message.what属性的可能值与其他处理程序发生冲突。

  5. DataReader类的底部,添加以下实现了Runnable接口的私有类,以读取和发布传感器数据:

    private class SensorThread implements Runnable {
      @Override
      public void run() {
        // Read from ADK
       AdkMessage response = mAdkManager.read();
        // ADK response back to UI thread for update
        Message message = mMainLoop.obtainMessage(HEARTBEAT_READ, response.getByte());
       message.sendToTarget();
      }
    }
    

    当线程启动时,我们使用AdkManager read方法读取可用的数据。这个方法返回一个包含原始接收字节和一些用于解析响应的工具的AdkMessage实例;在我们的案例中,我们使用getByte方法获取第一个接收的字节。作为最后一步,我们应该通过主线程处理器发布收集到的值。然后我们使用obtainMessage方法创建一个Message实例,该方法将从处理器消息池中获取一条新消息。现在我们可以使用sendToTarget方法将消息派发给主线程。

  6. 添加DataReader start()方法以启动定期生成线程的调度程序:

    public void start() {
      // Initialize threads
     SensorThread thread = new SensorThread();
      // Should start over and over publishing results
    
     Executors.newSingleThreadScheduledExecutor();
     mScheduler.scheduleAtFixedRate(thread, 0, HEARTBEAT_POLLING, TimeUnit.MILLISECONDS);
    }
    

    当我们从Monitor活动中调用这个方法时,ExecutorService参数将使用newSingleThreadScheduledExecutor()函数进行初始化。这将创建一个单线程的执行器,保证在任何给定时间执行的任务不会超过一个,尽管有轮询周期。作为最后一步,我们使用周期性调度程序每HEARTBEAT_POLLING毫秒运行一次我们的SensorThread

  7. DataReader类中添加stop()方法,以停止调度程序生成新线程。在我们的案例中,我们只需使用执行器的shutdown()方法:

    public void stop() {
      // Should stop the calling function
    mScheduler.shutdown();
    }
    
  8. 现在我们应该回到Monitor类,在活动生命周期内启动和停止我们的线程调度程序。在Monitor类的顶部添加DataReader声明:

    private AdkManager mAdkManager;
    private DataReader mReader;
    
    
  9. onResume()onPause()活动的回调中启动和停止读取调度程序,正如以下高亮代码所示:

    @Override
    protected void onResume() {
     super.onResume();
     mAdkManager.open();
     mReader = new DataReader(mAdkManager, this);
     mReader.start();
    }
    @Override
    protected void onPause() {
     super.onPause();
     mReader.stop();
     mAdkManager.close();
    }
    

没有其他事情可做,我们的原型已经准备好部署。现在我们可以将食指放在光敏电阻和 LED 之间,同时查看 Android 应用程序,结果每 10 秒更新一次。

改进原型

即使原型获得了良好的结果,我们可能希望获得更准确的读数。为物理应用获得更好改进的一个方法是,为光敏电阻和明亮的 LED 提供一个更好的外壳。实际上,如果我们能够移除环境光线,并在读取时使这两个组件更加稳定,我们就能获得很大的改进。

实现此目标的一个好方法是使用一个容易获得的组件:一个木制销钉。我们可以一次性钻好销钉,使孔对齐。这样,我们可以将光敏电阻放在一个孔中,而 LED 在另一个孔中。其余的组件和面包板本身保持不变。以下插图显示了一个木制销钉,用于容纳这两个组件:

改进原型

另一个改进是改变和调整草图中可用的算法参数。改变间隔和样本数量可能会获得更好的结果;然而,我们必须记住,这些更改也可能导致读数变得更糟。以下是我们可以更改的一些算法参数的集合:

#define SAMPLES 10
#define POS_THRESHOLD 3
#define NEG_THRESHOLD -3
#define HEARTBEAT_POLL_PERIOD 50
#define SECONDS 10

例如,如果我们发现光敏电阻在 50 毫秒的HEARTBEAT_POLL_PERIOD对象宏下工作效果不佳,我们可能会尝试使用更常见的时序,如 100 毫秒或 200 毫秒。

总结

在本章中,我们探讨了使用外部传感器来增强我们的物理应用功能。我们了解了传感器的工作原理,并查看了一个检测距离和物体接近程度的示例。

作为第一步,我们获取了一些关于心跳生物过程的信息,并发现了一个光敏电阻与一个明亮的 LED 如何帮助我们检测心率。我们使用第一个心率监测原型进行了一些初步实验,并收集了各种绝对值,我们后来将这些值绘制成图表。在初次分析后,我们发现每个峰值可能是一次心跳,这促使我们通过一个能够在选定间隔内计算读数差值的算法来增强读取阶段。

利用之前的数值,我们绘制了一张新图表,并发现我们应该检查相位变化以找到可能的心跳。实际上,我们最后的工作是添加一个功能,用于计算心率,并通过 ADK 通信协议将其发送回 Android 应用。

为了展示之前的结果,我们为 Android 应用创建了一个布局。我们配置并使用了 ADKToolkit 库以简化通信过程。通过一个ScheduledExecutorService实例,该实例启动短生命周期的线程进行数据采集,我们在自定义用户界面中设置了处理后的心率。在本章末尾,我们探讨了在进入下一章之前,如何通过一些建议来改进我们的工作原型。

在下一章中,我们将构建另一个物理应用,它将使用外部组件来控制 Android 应用。它将利用一些 Android 原生 API,以简单的方式实现一些没有复杂硬件和草图就无法完成的功能。

第五章:管理与物理组件的交互

电子设备改变了我们的生活。我们被许多看不见的物体所包围,它们收集并最终计算环境数据。正如我们在上一章所看到的,这些设备使用传感器来获取信息,并且我们可以在日常生活中找到它们,例如在我们的汽车中,当我们穿过超市的滑动门时,或者当我们回到家时。

我们可以从这些事物中汲取灵感,构建能够对环境和周围的人做出反应的惊人的物理应用。然而,如果我们的项目需要直接的与人交互,我们可能需要使用物理组件来管理这种交互。

本章的目标是构建一个使用内置 Android API 管理网络流的网络收音机,同时所有交互都由物理组件管理,就像旧式收音机一样。

在本章中,我们将涵盖以下主题:

  • 管理用户交互

  • 使用物理交互构建网络收音机

  • 使用 Arduino 发送多个数据

  • 编写用于音频流的 Android 应用程序

管理用户交互

区分交互式原型的其中一个方面是能够对用户的任何操作做出反应。正如我们在上一章所看到的,传感器是实现这一重要目标的最重要构建块之一。然而,有时我们希望提供一个物理交互,让用户能够通过双手改变应用程序的行为,尽管存在传感器。这些仍然广泛使用部分是简单的机械电子组件,它们将模拟动作转换为微控制器可以用来改变程序流程的数字值。有许多我们可以用来与设备交互的组件:按钮开关按钮操纵杆扭钮踏板杠杆,这些只是这类组件的例子。

扭钮是我们用来调整一些原型配置的组件。实际上,我们可以创建一个维护控制台,通过改变某些设备常数以避免新草图的重编译和上传阶段。在其他时候,扭钮用于直接操作,其使用是用户交互活动的积极部分。扭钮的常见用途与电器设备相关,例如音频设备中的音量控制。

另一个例子可能是与火星车有关,当我们希望为用户提供直接控制而不是依靠机器人自身的人工智能时。在这种情况下,我们可以使用一个按钮来方便地激活停止动作并切换到手动模式。例如,我们可以提供一个操纵杆,用于控制火星车的移动。

提示

按钮的一个使用例子与停止微控制器或原型所做的任何动作相关。这种按钮的使用称为紧急停止,在许多全自动且配备有活动部件的 DIY 项目中都会用到。

所有这些元素都有两个基本组件:开关电位计。按钮是机械开关的好例子,它们可以关闭或打开电路,并通过微控制器引脚控制电流流。这样,我们可以根据检测到的电压激活电路的特定功能,就像在第三章,测试您的物理应用中所做的那样。

相反,电位计是电子元件,更像是电阻器。电子部分由三个终端腿组成,我们可以用不同的方式使用它们来改变电位计的目的。实际上,如果我们将一端和中间腿连接到一个组件,它就像一个可变电阻器。另一方面,如果我们使用所有三个终端,它就像一个可调节的分压电路。我们可以从一个方向转到另一个方向的电位计的,用于改变电阻器或分压电路的值。电位计应用的好例子有旋钮、操纵杆和吉他踏板。

带物理交互的构建网络收音机

微控制器并非为复杂工作而设计,因此我们需要小心地将项目的需求分配到正确的环境中。对于网络收音机,我们可以使用微控制器读取旋钮和开关,让 Android API 和 UDOO 强大的 CPU 处理其余工作。这将防止 Android 在读取硬件时分心,并防止微控制器因网络流和播放的复杂性而过载。

我们原型的第一部分是构建一个电路并编写一个草图,从两个电位计和一个按钮收集值:

  • 我们使用第一个电位计来更改活动电台并增加或减少音量

  • 我们使用物理按钮来控制收音机的播放

这样,我们就移除了所有通过 Android 用户界面进行的交互。

作为第一步,拿两个电位计并将它们连接到板上,这样我们就可以实现以下电路:

带物理交互的构建网络收音机

按照下面的步骤将电路连接到电位计,如前面的原理图所示:

  1. 在你的面包板右侧放置两个电位计,因为我们需要在左侧的自由插槽中使用按钮。

  2. 将 UDOO +3.3V 引脚连接到电源总线的正线。确保不要连接+5V 电源引脚,因为未来连接时可能会损坏模拟输入引脚。

  3. 将 UDOO 地线连接到电源总线的负线。

  4. 将第一个电位计的左端子连接到电源总线的负线。

    提示

    电位计就像电阻一样,如果你连接了错误的正极端子,不会有任何区别。唯一的副作用是检测到的值将从[0-1023]范围变为[1023-0]。如果你注意到这一点,请反转这些连接

  5. 将第一个电位计的右端子连接到电源总线的正线。

  6. 将中间端子连接到模拟输入 A0。

  7. 对第二个电位计重复步骤 4、5、6,并将其中间端子连接到模拟输入 A1。

通过这个电路,我们使用两个电位计作为电压分压器,当我们转动轴时,微控制器注意到电压输出的变化,并将这个值转换成数值范围[0-1023]。这个电路与之前章节中构建的光传感器电路非常相似,但由于电位计已经在其包装内包含了一个电阻,我们不需要任何其他电子组件来保持其工作。

现在我们需要一个按钮来开始和停止播放。我们需要在面包板的左侧添加组件,并按以下方式连接到 UDOOboard:

使用物理交互构建网络收音机

按照给定的步骤连接组件,如前图所示:

  1. 将按钮的左端子连接到电源总线的正线。

  2. 使用 10 KOhm 电阻将右端子连接到电源总线的负线。

  3. 将右端子连接到 UDOOboard 的 12 号引脚。

通过这个电路,我们可以使用 UDOOboard 的 12 号引脚读取按钮的值;当按下按钮时,我们可以改变微控制器的内部状态。

既然我们已经有了所有必需组件的电路,我们就必须开始一个新的草图,并准备一个函数来收集所有数据。草图的目标是准备一个包含播放状态音量电台的有序值的三元组。这种方法简化了稍后与 Android 应用程序通信时的工作。我们可以按照以下方式开始编写新草图:

  1. 在草图顶部定义连接:

    #define RADIO_POLL_PERIOD 100
    #define PLAY_BUTTON 12
    #define KNOB_VOLUME A0
    #define KNOB_TUNER A1
    

    我们使用 12 号引脚作为播放按钮,A0 输入作为音量,A1 输入来切换当前电台。在这个项目中,我们设置了一个 100 毫秒的轮询时间,这对于物理组件和 Android 应用程序之间的快速交互是必需的。

  2. 在之前的声明后添加以下变量:

    boolean playback = true;
    int buttonRead = LOW;
    int previousRead = LOW;
    int tuner = 0;
    int volume = 0;
    

    我们使用一个播放变量作为简单的状态指示器,以便草图知道收音机是否正在播放。由于我们正在构建一个依赖于物理交互的收音机,因此草图中的设备状态被认为是整个应用程序的真实来源,Android 应该信任这些值。我们还定义了其他变量来存储按钮和两个电位计的读数。

  3. setup()函数中添加引脚模式,并打开串行通信:

    void setup() {
      pinMode(PLAY_BUTTON, INPUT);
      Serial.begin(115200);
    }
    
  4. 在草图的底部创建一个readData()函数,在其中检测用户从物理组件的输入:

    void readData() {
      buttonRead = digitalRead(PLAY_BUTTON);
      if (buttonRead == HIGH && previousRead != buttonRead) {
        playback = !playback;
      }
      previousRead = buttonRead;
      tuner = analogRead(KNOB_TUNER);
      volume = analogRead(KNOB_VOLUME);
    }
    

    在第一部分,我们将按钮的值赋给buttonRead变量,以检查它是否被按下。同时,我们还将最后一次检测到的值存储在previousRead变量中,因为我们希望在连续读取时避免状态错误变化。这样,如果用户按住按钮,只会发生一次状态变化。

    在最后几行,我们进行analogRead调用,从两个电位计收集数据。

  5. 在主loop()函数内调用readData()函数,并按以下方式打印收集的值:

    void loop() {
      readData();
      Serial.print("Playing music: ");
      Serial.println(playback);
      Serial.print("Radio station: ");
      Serial.println(tuner);
      Serial.print("Volume: ");
      Serial.println(volume);
      delay(RADIO_POLL_PERIOD);
    }
    

现在,我们可以将草图上传到我们的电路板上,并打开串行监视器,开始玩转旋钮和播放按钮。以下是预期输出的一个示例:

使用物理交互构建网络收音机

在发送之前规范化收集的数据

如我们所见,如果我们转动电位计的轴或按下播放按钮,我们的值会立即改变。这是一个非常好的起点,但现在我们需要转换这些数据,以便它们能被 Android 应用程序轻松使用。

因为我们想要管理五个广播电台,草图应该将调谐器的读数映射到[0-4]范围内的值。我们将在[0-1023]范围内创建固定间隔,这样当我们转动轴并通过一个间隔时,应更新活动的电台。为了实现这种映射,我们需要遵循以下步骤:

  1. 在草图的顶部,添加突出显示的声明:

    #define KNOB_TUNER A1
    #define STATIONS 5
    #define MAX_ANALOG_READ 1024.0
    const float tunerInterval = MAX_ANALOG_READ / STATIONS;
    boolean playback = true;
    

    我们将管理的电台数量定义为5,并设置最大模拟读取值。这样,我们可以重用上面的类似对象的宏来定义tunerInterval常数,以将读数映射到正确的间隔。

  2. 在草图的底部添加mapStations()函数:

    int mapStations(int analogValue) {
      int currentStation = analogValue / tunerInterval;
    }
    

    为了找到currentStation变量,我们将模拟读取值除以调谐器间隔。这样,我们可以确保返回的值被限制在[0-4]范围内。

使用前面的映射函数不足以让我们的收音机工作。另一个必要的步骤是转换音量值,因为 Android 使用[0.0-1.0]范围内的浮点数。因此,我们应该通过以下步骤规范化音量旋钮:

  1. mapStations()函数下面添加此功能:

    float normalizeVolume(int analogValue) {
      return analogValue / MAX_ANALOG_READ;
    }
    
  2. 更改主loop()函数,如下所示,以便我们可以检查是否所有值都正确转换:

    void loop() {
      readData();
      Serial.print("Playing music: ");
      Serial.println(playback);
      Serial.print("Radio station: ");
      Serial.println(mapStations(tuner));
      Serial.print("Volume: ");
      Serial.println(normalizeVolume(volume));
      delay(RADIO_POLL_PERIOD);
    }
    
  3. 上传新的草图以查看以下截图显示的结果:在发送前规范化收集的数据

    注意

    通过前面的步骤,我们从物理输入设备收集数据,并转换这些值以从旋钮计算当前的电台和收音机音量。然而,我们需要在 Android 应用程序中也放置这个逻辑,因为它应该为每个可能的电台映射相关的网络流媒体 URL。这意味着相同的逻辑被复制,这不是一个好方法,特别是如果我们将来需要添加新的频道。在这种情况下,我们的代码必须在两个应用程序中更改,并且我们应始终避免那些容易出错的情况。一个好方法是只使用微控制器报告输入,并让 Android 应用程序管理和转换接收到的原始数据。我们仅在本书的范围内使用这种方法,以帮助您更熟悉草图代码。

使用 Arduino 发送多个数据

在第四章《使用传感器聆听环境》中,我们需要发送由微控制器计算的一个字节。然而,在大多数常见情况下,我们需要从不同的传感器或物理组件读取数据,并且可能需要一次性将它们发送回 Android。在这个原型中,我们应该关注这一需求,因为微控制器必须读取所有三个值,并且只能通过一次 ADK 写入将它们发送回去。一个简单的方法是构建一个表示我们三元组的字符串,并使用逗号分隔这些值,格式为<playback>,<volume>,<station>。通过这种表示,我们得到以下值:

0,0.332768,2
1,0.951197,4

然后,我们可以在 ADK 缓冲区中写入收音机状态的序列化表示,并在 Android 应用程序中进行反序列化处理。

提示

我们可能会考虑实施或使用更复杂的通信协议,以将通用数据从 Arduino 传输到 Android,但我们应该始终牢记,在开始时,每个好主意都必须遵循KISS 原则保持简单傻瓜(一个由美国海军在 1960 年提出的设计原则)。因为软件越简单,它就越可能运行良好。

我们需要在草图的顶部编写配件描述符,如下所示的建议代码片段:

#include <adk.h>
#define BUFFSIZE 128
char accessoryName[] = "Web radio";
char manufacturer[] = "Example, Inc.";
char model[] = "WebRadio";
char versionNumber[] = "0.1.0";
char serialNumber[] = "1";
char url[] = "http://www.example.com";
uint8_t buffer[BUFFSIZE];
USBHost Usb;
ADK adk(&Usb, manufacturer, model, accessoryName, versionNumber, url, serialNumber);

我们还需要一个用于保存三元组的第二个缓冲区;我们可以在 ADK 缓冲区变量之前添加其声明,如下所示:

char triple[BUFFSIZE];
uint8_t buffer[BUFFSIZE];

在草图的底部,添加以下函数以在 ADK 缓冲区中写入三元组:

void writeBuffer(int playback, float volume, int station) {
  sprintf(triple, "%f,%f,%f", (float) playback, normalizeVolume(volume), (float) mapStations(station));
  memcpy(buffer, triple, BUFFSIZE);
}

writeBuffer()函数期望三个用于构建三元组的参数。为此,我们使用sprintf()函数将这些值写入中间triple缓冲区。在sprintf()函数调用中,我们还使用normalizeVolume()mapStations()函数获取转换后的值。然后我们使用memcpy()函数将triple变量写入 ADK buffer

注意

我们需要这个额外的步骤,因为我们不能将triple变量写入 ADK buffer中。adk.write()函数期望一个unsigned char*类型,而triplechar*类型。

既然 ADK 缓冲区包含了序列化的数据,我们就必须移除所有的Serial调用,并按以下方式重写主loop()函数:

void loop() {
  Usb.Task();
  if (adk.isReady()) {
    readData();
    writeBuffer(playback, volume, tuner);
    adk.write(BUFFSIZE, buffer);
  }
  delay(RADIO_POLL_PERIOD);
}

当 ADK 准备就绪时,我们从推按键和两个电位计中读取数据,然后将这些值序列化到一个三元组中,该三元组将被写入 ADK 输出缓冲区。一切准备就绪后,我们将记录的输入发送回 Android。

我们现在可以更新我们的草图,并使用 Android 应用程序完成原型。

从 Android 应用程序中流式传输音频

Android 操作系统提供了一组丰富的 UI 组件,这是所有物理应用的重要构建块。它们都是针对手机或平板交互的,这是一项杰出的改进,因为用户已经知道如何使用它们。然而,Android 不仅仅是一组 UI 组件,因为它允许许多 API 来实现常规任务。在我们的案例中,我们希望一个物理应用能够与 Web 服务交互,以打开和播放音频流。

如果没有 i.MX6 处理器和 Android 操作系统,这项任务将不可能轻松实现,但在我们的情况下,UDOO 开发板提供了我们所需要的一切。

设计 Android 用户界面

在 Android Studio 中,启动一个名为WebRadio的新应用,使用Android API 19。在引导过程中,选择一个名为Radio空白活动

我们的首要目标是改变默认布局,以一个简单但花哨的界面替代。主布局必须显示当前激活的广播电台,并提供不同的信息,如可选的图片——频道名称以及描述。在编写 Android 绘制用户界面所需的 XML 代码之前,我们应该规划工作以检测所需的组件。在下面的截图中,我们可以查看提供所有必需元素的用户界面草图:

设计 Android 用户界面

上面的布局包括一个数字标记,定义了组件创建的顺序。根据此布局,我们应该按照以下顺序提供三个不同的视图:

  1. 作为第一步,我们应该创建一个不同颜色的背景框架,以提供一个块,我们将把所有其他组件放入其中。

  2. 尽管这是可选的,但我们可以准备一个框,如果可用的话,将用于显示电台频道图片。

  3. 最后一个块包含两个不同的文本区域,第一个代表频道名称,而另一个代表频道描述。

使用这种布局设计,我们应该按照以下步骤继续操作,替换标准主题:

  1. res/values/dimens.xml 资源文件中,添加以下定义,为我们提供一些组件的尺寸,如背景框架高度和字体大小:

    <resources>
      <dimen name="activity_horizontal_margin">16dp</dimen>
      <dimen name="activity_vertical_margin">16dp</dimen>
      <dimen name="activity_frame_height">220dp</dimen>
      <dimen name="activity_image_square">180dp</dimen>
      <dimen name="layout_padding">50dp</dimen>
      <dimen name="title_size">40sp</dimen>
      <dimen name="description_size">25sp</dimen>
    </resources>
    
  2. res/values/styles.xml 资源文件中,添加背景框架和文本元素使用的以下颜色:

    <resources>
      <color name="picton_blue">#33B5E5</color>
      <color name="white">#FFFFFF</color>
      <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
      </style>
    </resources>
    
  3. res/layout/ 下的 activity_radio.xml 文件中,用以下 FrameLayout 替换 RelativeLayout 以实现背景框架:

    <FrameLayout 
    
      android:layout_width="match_parent"
      android:layout_height="@dimen/activity_frame_height"
      android:paddingLeft="@dimen/activity_horizontal_margin"
      android:paddingRight="@dimen/activity_horizontal_margin"
      android:paddingTop="@dimen/activity_vertical_margin"
      android:paddingBottom="@dimen/activity_vertical_margin"
      android:background="@color/picton_blue"
      tools:context=".Radio">
    </FrameLayout>
    
    

    我们使用 FrameLayout 创建一个区域,该区域以定义的高度和背景色容纳所有其他组件。

  4. 在上述 FrameLayout 参数中创建一个 LinearLayout

    <LinearLayout
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="match_parent">
    
        <ImageView
          android:id="@+id/radio_image"
          android:src="img/ic_launcher"
          android:layout_height="@dimen/activity_image_square"
          android:layout_width=
            "@dimen/activity_image_square" />
    
        <LinearLayout
          android:orientation="vertical"
          android:layout_marginLeft="@dimen/layout_padding"
          android:layout_width="match_parent"
          android:layout_height="match_parent">
        </LinearLayout>
    </LinearLayout>
    

    第一个 LinearLayout 将包含根据活动频道而变化的 radio_image ImageView。第二个 LinearLayout 用于容纳电台名称和描述。

  5. 在第二个 LinearLayout 中添加以下视图:

    <TextView
      android:id="@+id/radio_name"
      android:text="Radio name"
      android:textColor="@color/white"
      android:textSize="@dimen/title_size"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content" />
    
    <TextView
      android:id="@+id/radio_description"
      android:text="Description"
      android:textSize="@dimen/description_size"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content" />
    

根据之前定义的样式,以下是获得的布局:

设计 Android 用户界面

在我们继续逻辑实现之前,我们必须在 onCreate() 回调中通过以下步骤获取所有视图引用:

  1. Radio 类的顶部添加以下声明:

    private TextView mRadioName;
    private TextView mRadioDescription;
    private ImageView mRadioImage;
    
  2. onCreate() 回调的底部,添加高亮代码:

    setContentView(R.layout.activity_radio);
    mRadioName = (TextView) findViewById(R.id.radio_name);
    mRadioDescription = (TextView) findViewById(R.id.radio_description);
    mRadioImage = (ImageView) findViewById(R.id.radio_image);
    
    

现在布局已完成,我们可以继续进行 ADK 配置。

设置 ADK 工具包

在我们开始网络电台实现之前,我们首先应该像上一章一样配置 ADKToolkit。为了拥有可工作的 ADK 配置,请按照以下步骤操作:

  1. app 下的 build.gradle 文件中添加 ADKToolkit 库依赖。

  2. 同步你的 Gradle 配置。

  3. res/xml/ 下创建配件过滤器文件 usb_accessory_filter.xml,使用以下代码:

    <resources>
      <usb-accessory
       version="0.1.0"
       model="WebRadio"
       manufacturer="Example, Inc."/>
    </resources>
    
  4. AndroidManifest.xml 文件中添加 USB accessory support 选项要求和 USB accessory intent filter 选项。

  5. Radio.java 类文件中,在类的顶部声明 AdkManager 对象。

  6. Radio 活动类的 onCreate 方法中添加 AdkManager 初始化。

  7. 重写 onPause()onResume() 回调,根据活动生命周期来启动和停止 ADK 连接。

    提示

    每次我们开始一个新项目时,都应该使用上述清单。最好将这些步骤写下来,确保我们的项目始终以此 ADK 配置开始。

  8. 作为初始配置的最后一步,我们需要添加互联网访问权限,因为我们将使用网络流。在你的AndroidManifest.xml文件的 manifest 标签内添加以下权限:

    <uses-permission android:name="android.permission.INTERNET" />
    

更换网络电台

下一步是编写必要的 Android 代码以播放和停止配置的电台。我们需要正式化电台对象和一个实用程序类,该类抽象了内置媒体播放器的相同功能。以下是所需类的使用清单:

  • Station:正式定义音频频道,并包括标题、描述和电台图片,以及启动远程播放所需的流媒体 URL

  • RadioManager:在初始化期间配置所有可用的电台,并将所有管理播放和频道切换的通用方法抽象出来

我们从可以通过以下步骤实现的Station类开始:

  1. 在我们的命名空间内创建一个名为streaming的新 Java 包。

  2. 在新创建的 Java 包中创建Station类,并添加以下声明和类构造函数:

    private final static String STREAMING_BASE_URL = "https://streaming.jamendo.com/";
    private String title;
    private String description;
    private int imageId;
    public Station(String title, String description, int imageId) {
      this.title = title;
      this.description = description;
      this.imageId = imageId;
    }
    

    我们定义了我们将用于构建频道流媒体 URL 的第一部分。在这种情况下,我们将使用提供许多在Creative Commons许可下发布的音乐频道的Jamendo服务。如果你想获取更多信息,可以查看服务网站:

    Jamendo 网站

    我们将使用的其他属性是电台的titledescription属性以及 Android 资源标识符。

  3. 在类的底部,以下获取器用于检索实例属性:

    public String getTitle() {
      return title;
    }
    public String getDescription() {
      return description;
    }
    public int getImageId() {
      return imageId;
    }
    public String getStreamUrl() {
      return STREAMING_BASE_URL + title;
    }
    

    getStreamUrl()方法中,我们使用带有电台名称的基础 URL 来查找正确的音频流。

    提示

    这个字符串连接与 Jamendo 服务的工作方式有关。如果你使用另一个服务或不想在 URL 构建时使用标题属性,你应该更改这个方法。

既然我们已经有了正式的Station类表示,我们需要定义一个能够管理 Android 播放的类。我们通过以下步骤实现RadioManager类:

  1. streaming包中,创建RadioManager类,并在开始处添加以下声明:

    private static ArrayList<Station> mChannels;
    private static MediaPlayer mMediaPlayer;
    private static int mPlayback;
    private static int mIndex;
    private static Station mActiveStation;
    

    我们使用 Android 高级MediaPlayer对象来管理远程流媒体;我们使用一些状态变量,如当前活动电台及其数组索引和播放状态。我们将在RadioManager类初始化期间填充mChannels ArrayList对象,它将托管所有可用的音乐频道。

  2. 在类的底部添加初始化方法,如下所示:

    public static void initialize() {
      // Prepare all stations object
      mChannels = new ArrayList();
      mChannels.add(new Station("JamPop", "Pop", R.drawable.ic_launcher));
      mChannels.add(new Station("JamClassical", "Classical", R.drawable.ic_launcher));
      mChannels.add(new Station("JamJazz", "Jazz", R.drawable.ic_launcher));
      mChannels.add(new Station("JamElectro", "Electronic", R.drawable.ic_launcher));
      mChannels.add(new Station("JamRock", "Rock", R.drawable.ic_launcher));
      // Initializes the MediaPlayer with listeners
      mMediaPlayer = new MediaPlayer();
      mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
      mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
        @Override
        public void onPrepared(MediaPlayer mediaPlayer) {
          mediaPlayer.start();
        }
      });
    }
    

    在第一部分,我们根据之前的 Station 构造函数配置所有可用频道的列表。我们配置 MediaPlayer 对象,以便在 prepare 过程完成后立即开始网络流。

    注意

    你可以在以下 URL 查找更多关于 Android MediaPlayer 类如何工作的信息:

    developer.android.com/reference/android/media/MediaPlayer.html

  3. 添加以下方法,以抽象播放和停止功能,防止代码重复:

    private static void stop() {
      mMediaPlayer.reset();
    }
    private static void play() {
      try {
        mMediaPlayer.setDataSource(mActiveStation.getStreamUrl());
        mMediaPlayer.prepareAsync();
      }
      catch (IOException e) {
        // noop
      }
    }
    

    当播放器停止时,我们必须重置媒体播放器对象,因为我们可能需要立即设置另一个数据源。play 方法设置当前激活频道的流媒体 URL 并开始一个非阻塞的 prepare 任务。

  4. 添加以下公共方法,该方法改变播放状态:

    public static void playback(int value) {
      // If the playback status has changed
      if (value != mPlayback) {
        // Play or stop the playback
        if (value == 0) {
          stop();
        }
        else {
          play();
        }
        mPlayback = value;
      }
    }
    

    通过 ADK 的草图,我们的应用程序每隔 100 毫秒就会收到连续的数据,这提高了用户界面的响应性。然而,我们不想多次重复相同的命令,所以我们只有在收到的值与存储的值不同时才会执行操作。在第二部分,我们根据给定的参数选择开始或播放当前流。

  5. 作为最后一步,我们需要一个方法来更改激活的频道。在类的底部添加以下代码:

    public static Station changeStation(int stationId) {
      Station station = null;
      if (stationId != mIndex) {
        mIndex = stationId;
        // Set the current station
        mActiveStation = mChannels.get(mIndex);
        station = mActiveStation;
        stop();
        if (mPlayback == 1) {
          play();
        }
      }
      return station;
    }
    

    正如我们之前所做的,如果收到的值与我们当前播放的值相同,我们避免更改频道。然后,我们更新当前频道并停止最后的流。这样,如果我们处于播放状态,我们可以安全地播放新的电台流。在任何情况下,我们返回选择的 Station 实例,如果频道没有变化则返回 null

从物理设备读取输入

正如我们在上一章所做的,我们需要准备我们的应用程序,以使 ADK 缓冲区中用户输入的连续读取变得可用。正如之前所做,我们将创建一个 Java 接口,公开所需的方法以更新用户界面。我们可以通过以下步骤实现这一点:

  1. 创建一个名为 OnDataChanges 的新 Java 接口,并添加以下方法:

    public interface OnDataChanges {
      void updateStation(Station station);
    }
    
  2. Radio 类通过高亮代码实现前面的接口:

    public class Radio extends ActionBarActivity implements OnDataChanges {
    
  3. 在类的末尾实现接口代码,以更新 Android 用户界面:

    @Override
    public void updateStation(Station station) {
      mRadioName.setText(station.getTitle());
      mRadioDescription.setText(station.getDescription());
      mRadioImage.setImageResource(station.getImageId());
    }
    

    在这部分,我们根据 station 实例属性简单地更新所有视图。

最后一个必要的步骤是实现我们的计划线程,从微控制器读取处理过的数据,并一起更新 MediaPlayer 类的流和 Android 用户界面。要完成这最后一个构建块,请执行以下步骤:

  1. 在你的命名空间中创建一个名为 adk 的新包。

  2. adk 包中,添加一个名为 DataReader 的新类。

  3. 在类的顶部,添加以下声明:

    private final static int INPUT_POLLING = 100;
    private final static int STATION_UPDATE = 0;
    private AdkManager mAdkManager;
    private OnDataChanges mCaller;
    private ScheduledExecutorService mScheduler;
    private Handler mMainLoop;
    

    与前一章一样,我们定义主线程处理器使用的轮询时间和消息类型。我们还存储了AdkManager参数和调用活动的引用,分别用于 ADK 读取方法和updateStation函数的回调。然后我们定义了ExecutorService方法的实现以及主线程Handler

  4. 实现主线程从后台线程接收到新消息时设置消息处理器的DataReader构造函数:

    public DataReader(AdkManager adkManager, OnDataChanges caller) {
      this.mAdkManager = adkManager;
      this.mCaller = caller;
      mMainLoop = new Handler(Looper.getMainLooper()) {
        @Override
        public void handleMessage(Message message) {
          switch (message.what) {
            case STATION_UPDATE:
              mCaller.updateStation((Station) message.obj);
              break;
          }
        }
      };
    }
    

    我们存储AdkManagercaller活动的引用,然后设置一个附加到应用程序主循环的HandlerhandleMessage回调检查消息代码以识别STATION_UPDATE消息。在这种情况下,我们调用updateStation方法并传递附加的对象。

  5. DataReader类的底部,添加以下私有类,实现Runnable接口以读取和管理物理输入设备:

    private class InputThread implements Runnable {
      @Override
      public void run() {
        // Read from ADK
        AdkMessage response = mAdkManager.read();
        // Parse the response
        String[] collectedInputs = response.getString().split(",");
        int playback = (int) Float.parseFloat(collectedInputs[0]);
        int station = (int) Float.parseFloat(collectedInputs[2]);
        // Start radio and get the changed station
        RadioManager.playback(playback);
        Station currentStation = RadioManager.changeStation(station);
        // Updated station back to the main thread
        if (currentStation != null) {
          Message message = mMainLoop.obtainMessage(STATION_UPDATE, currentStation);
          message.sendToTarget();
        }
      }
    }
    

    线程启动时,我们使用AdkManager方法读取用户输入。然后我们从响应中获取原始字符串,并使用分割方法反序列化接收到的三元组。第一个位置指的是播放状态,我们在RadioManager类中使用它来启动或停止播放。第三个位置是激活的频道,我们将其传递给changeStation方法。根据之前的实现,如果currentStation变量没有改变,我们避免将消息发布到主线程,以防止无用的界面重绘。

  6. DataReader类添加一个方法,以定期生成短生命周期的线程来启动调度程序:

    public void start() {
      // Initialize threads
      InputThread thread = new InputThread();
      // Should start over and over while publishing results
      mScheduler = Executors.newSingleThreadScheduledExecutor();
      mScheduler.scheduleAtFixedRate(thread, 0, INPUT_POLLING, TimeUnit.MILLISECONDS);
    }
    

    与上一个项目一样,我们使用一个调度程序,每次在INPUT_POLLING变量毫秒时生成一个单独的InputThread参数。

  7. 在类的底部添加停止方法,通过执行器的shutdown方法停止调度程序生成新线程:

    public void stop() {
      mScheduler.shutdown();
    }
    
  8. 现在,我们应该回到Radio类中,在活动生命周期内启动和停止调度程序。在Radio类的顶部添加DataReader方法声明:

    private AdkManager mAdkManager;
    private DataReader mReader;
    
    
  9. 在活动创建时初始化RadioManager类和DataReader实例,通过以下高亮代码,你应该将其添加到onCreate()回调的底部:

    mRadioImage = (ImageView) findViewById(R.id.radio_image);
    RadioManager.initialize();
    mAdkManager = new AdkManager(this);
    mReader = new DataReader(mAdkManager, this);
    
    
  10. 如高亮代码所示,在onResume()onPause()活动的回调中启动和停止读取调度程序:

    @Override
    protected void onPause() {
      super.onPause();
      mReader.stop();
      mAdkManager.close();
    }
    
    @Override
    protected void onResume() {
      super.onResume();
      mAdkManager.open();
      mReader.start();
    }
    

完成这些步骤后,广播电台就完成了,我们可以将 Android 应用程序上传到 UDO0 板,并通过旋钮和按钮开始播放。

注意

因为我们没有处理网络错误,请确保 UDO0 已连接到互联网,并且你正在使用以太网或 Wi-Fi 网络适配器,否则应用程序将无法工作。

管理音频音量

在我们可以发布第一个广播原型之前,我们应该从 Android 应用程序管理音量旋钮。这部分非常简单,这要感谢MediaPlayer方法的 API,因为它公开了一个公共方法来改变激活流的音量。为了用音量管理器改进我们的项目,我们需要添加以下代码片段:

  1. RadioManager类中,请在类顶部添加高亮的声明:

    private static Station mActiveStation;
    private static float mVolume = 1.0f;
    
    
  2. RadioManager类的底部,添加以下公共方法:

    public static void setVolume(float volume) {
      if (Math.abs(mVolume - volume) > 0.05) {
        mVolume = volume;
        mMediaPlayer.setVolume(volume, volume);
      }
    }
    

    setVolume方法预期接收来自 Arduino 的浮点数作为参数,我们用它来改变mMediaPlayer实例的音量。然而,由于我们不希望因为微小的变化而改变音量,因此我们放弃了所有与之前记录的输入差异不大的请求。

  3. DataReader类中编写的InputThread实现中添加音量解析和setVolume函数调用:

    float volume = Float.parseFloat(collectedInputs[1]);
    int station = (int) Float.parseFloat(collectedInputs [2]);
    RadioManager.playback(playback);
    RadioManager.setVolume(volume);
    
    

有了这最后一块,网络广播就完成了,我们可以继续进行最后的部署。现在,我们的用户可以使用旋钮和按钮与原型互动,控制应用程序的各个方面。

改进原型

在进一步讨论其他原型之前,我们应该考虑当发生一些意外事件时,我们如何改进我们的设备。一个好的起点是考虑错误处理,特别是当 Android 应用程序停止从外设接收数据时会发生什么。有许多方法可以防止错误操作,一个好的解决方案是在 Android 应用程序中包含一个默认行为,这些紧急情况下原型应该遵循。

我们本可以使用另一个周期性定时器,每次执行时增加一个变量。当InputThread实例完成一次成功的读取后,它应该重置上述变量。通过这种方式,我们可以监控停止接收用户输入的时间,根据这个时间,我们可能决定改变应用程序的行为。通过这个变量,例如,如果外设停止提供用户输入,我们可以停止广播播放,或者稍微降低音量。

关键点是,我们应始终为失败和成功设计我们的原型。大多数如果发生的问题在前端很容易融入,但后来很难添加。

总结

在本章中,您学习了当需要人机交互时,如何提高我们原型的质量。我们探索了一些常见的物理组件,它们可以用来改变或控制 Android 应用程序。通过强大的 Android API,我们构建了一个能够执行复杂任务如网络流传输的网络广播。

在第一部分,我们使用两个电位计和一个按钮构建了所需的电路。当通过串行监视器检查返回的值时,我们发现它们在这种格式下并不太有用,因此我们编写了映射和归一化函数。

我们继续为 Android 应用程序提供新的布局,但我们避免通过用户界面添加任何交互。我们编写了一个类来抽象化与内置媒体播放器的所有可能交互,这样我们可以轻松地在应用的任何部分控制这个组件。事实上,我们在后台任务中使用它,每当它读取用户输入时,它会立即改变收音机的状态。通过这种方式,我们启用了按钮来启动和停止播放,以及两个电位器来改变活动电台和音乐音量。

在下一章中,我们开始讨论家居自动化。我们从零开始设计一个新的原型,能够使用传感器数值和用户设置的组合来控制外部设备的开关。我们将利用其他 Android API 来存储应用的设置,并在稍后使用它们来修改应用流程。

第六章:为智能家居构建 Chronotherm 电路

几十年来,控制家庭设备如灯光、恒温器和电器已经变得可能,甚至简单,通过自动和远程控制。一方面,这些自动化设备节省了人力和能源,但另一方面,即使是微小的调整对最终用户来说也不方便,因为他们需要对系统有很好的了解才能进行任何更改。

在过去几年中,由于缺乏标准或易于定制的解决方案,人们不愿采用智能家居技术。如今,情况正在发生变化,UDOO 等原型开发板在设计及构建DIY自己动手做)自动化设备时发挥着重要作用。更妙的是,由于开源项目,这些平台易于扩展,并且可以被不同的设备控制,如个人电脑上的网络浏览器、手机和平板电脑。

在本章中,我们将涵盖以下主题:

  • 探索智能家居的优势

  • 构建一个 chronotherm 电路

  • 发送数据与接收指令

  • 编写 Chronotherm 安卓应用程序

智能家居

“智能家居”这个词相当通用,可能有多种不同的含义:控制环境灯光的定时器,响应来自外部的各种事件做出动作的智能系统,或者负责完成重复任务的编程设备。

这些都是智能家居的有效示例,因为它们共享同一个关键概念,使我们即使不在家也能管理家务和活动。智能家居设备通常在公共或私人网络上运行,以相互通信,以及与其他类型的设备如智能手机或平板电脑进行通信,接收指令或交换它们的状态信息。但当我们需要自动化简单的电器或电子元件,如灯泡时,该怎么办?解决这个问题的常见方法是通过开发一种控制系统设备,物理连接到我们想要管理的电器上;由于控制系统是一种智能家居设备,我们可以使用它来驱动它所连接的每个电器的行为。

如果我们在智能家居领域积累足够的经验,我们有可能开发并构建一个高端系统,用于我们自己的房子,这个系统足够灵活,可以轻松扩展,而不需要进一步的知识。

构建一个 chronotherm 电路

温控器主要由一个控制单元组成,负责检查环境温度是否低于预配置的设定点,如果是,则打开锅炉加热房间。这种行为很简单,但没有进一步的逻辑就不太有用。实际上,我们可以通过向温控器逻辑中添加时间参数来扩展此行为。这样,用户可以为每天每小时定义一个温度设定点,使温度检查更加智能。

注意

在这个原型中,控制单元是板载 Arduino,这是一个简化整体设计的实现细节。

这就是传统温控器的工作原理,为了实现它,我们应该:

  • 构建带有温度传感器的电路

  • 实现微控制器逻辑,以检查用户的设定点与当前温度

不幸的是,第二部分并不容易,因为用户的设定点应该存储在微控制器中,因此我们可以将这项任务委托给我们的安卓应用程序,通过在 microSD 卡中保存设置来实现。这种方法以下列方式解耦责任:

  • Arduino 草图:

    • 从温度传感器收集数据

    • 将检测到的温度发送到安卓

    • 期待一个安卓命令来启动或停止锅炉

  • 安卓应用程序:

    • 管理用户交互

    • 实现用户设置,以存储每天每小时的温度设定点

    • 读取微控制器发送的温度

    • 实现逻辑以选择是否应该打开或关闭锅炉

    • 向微控制器发送命令以启动或停止锅炉

通过这个计划,我们可以依赖安卓用户界面组件轻松实现简洁且易用的界面,同时避免设置存储层的复杂性。

要开始构建原型,我们需要在我们的面包板上插入一个温度传感器,如TMP36,以获得以下电路:

构建温控器电路

以下是连接组件的逐步操作过程,如前图所示:

  1. 将 TMP36 传感器放在面包板的右侧部分。

  2. 将 UDOO 的+3.3V 引脚连接到电源总线的正极。确保不要连接+5V 电源引脚,因为未来连接时可能会损坏模拟输入引脚。

  3. 将 UDOO 的地线连接到电源总线的负极。

  4. 将 TMP36 传感器的左端连接到电源总线的正极。

    提示

    使用封装传感器时,我们可以通过观察平整的部分来判断方向。使用这种方法来找到左端和右端。

  5. 将 TMP36 传感器的右侧终端连接到电源总线的负极。

  6. 将 TMP36 传感器的中间终端连接到模拟输入 A0。

这个封装的传感器非常容易使用,它不需要任何其他组件或电压分压器来为微控制器提供电压变化。现在我们应该继续从我们的电路管理锅炉点火。为了原型的需要,我们将用简单的 LED 替换锅炉执行器,就像我们在第二章,了解你的工具中所做的那样。这将使我们的电路更简单。

我们可以在面包板上添加一个 LED,以实现以下原理图:

构建一个计时恒温电路

以下是按照前述原理图连接组件的步骤:

  1. 将 LED 放在面包板的左侧。

  2. 将 LED 较长的终端(阳极)连接到 UDO 数字引脚 12。

  3. 使用一个 220 欧姆的电阻,将 LED 较小的终端(阴极)连接到电源总线的负线上。

使用这个电路,我们拥有了从环境中收集数据和模拟锅炉点火所需的所有组件。现在我们需要打开 Arduino IDE 并开始一个新的草图。第一个目标是将检测到的温度检索并转换成方便的计量单位。为了实现这个目标,我们需要执行以下步骤:

  1. 在草图的顶部定义这些类似对象宏和变量:

    #define TEMPERATURE_POLL_PERIOD 1000
    #define SENSOR A0
    #define BOILER 12
    int reading;
    

    我们定义了SENSOR对象来表示模拟引脚 A0,而BOILER对象与我们的数字引脚 12 相关联。我们还声明了一个reading变量,稍后用来存储当前检测到的温度。TEMPERATURE_POLL_PERIOD宏表示微控制器在两次读数之间等待的秒数,以及它通知 Android 应用程序检测到的温度之前等待的秒数。

  2. setup()函数中,添加引脚模式声明并打开串行通信,如下所示:

    void setup() {
      pinMode(BOILER, OUTPUT);
      digitalWrite(BOILER, LOW);
      Serial.begin(115200);
    }
    
  3. 在草图的底部,按照以下方式创建convertToCelsius()函数:

    float convertToCelsius(int value) {
      float voltage = (value / 1024.0) * 3.3;
      return (voltage - 0.5) * 100;
    }
    

    在这个函数中,我们期望一个传感器读数,并以摄氏度的形式返回它的表示。为此,我们使用了一些数学计算来确定实际检测到的电压是多少。因为 UDO 微控制器的模数转换器提供的值范围是[0-1023],但我们想要计算从 0 到 3.3V 的范围,所以我们应该将值除以 1024.0,然后将结果乘以 3.3。

    我们在摄氏度转换中使用电压,因为如果我们阅读 TMP36 的数据表,我们会发现传感器每 10 毫伏的变化相当于 1 摄氏度的温度变化,这就是我们为什么将值乘以 100。我们还需要从电压中减去 0.5,因为此传感器可以处理 0 度以下的温度,而 0.5 是选择的偏移量。

    提示

    这个函数可以将 TMP36 的读数轻松转换为摄氏度。如果你想使用其他计量单位,比如华氏度,或者你使用的是其他传感器或热敏电阻,那么你需要改变这个实现方式。

  4. 在主loop()函数中,从传感器读取模拟信号并使用loop()函数打印转换后的结果:

    void loop() {
      reading = analogRead(SENSOR);
      Serial.print("Degrees C:");
      Serial.println(convertToCelsius(reading));
      delay(TEMPERATURE_POLL_PERIOD);
    }
    

如果我们上传草图并打开串行监视器,我们会注意到当前的室温。实际上,如果我们把手指放在传感器周围,我们会立即看到之前检测到的温度升高。以下屏幕截图是草图输出的一个示例:

构建恒温器电路

发送数据和接收命令

下一步是像往常一样启用 ADK 通信,并且我们需要在草图顶部添加配件描述符代码,如下所示:

#include <adk.h>
#define BUFFSIZE 128
char accessoryName[] = "Chronotherm";
char manufacturer[] = "Example, Inc.";
char model[] = "Chronotherm";
char versionNumber[] = "0.1.0";
char serialNumber[] = "1";
char url[] = "http://www.example.com";
uint8_t buffer[BUFFSIZE];
uint32_t readBytes = 0;
USBHost Usb;
ADK adk(&Usb, manufacturer, model, accessoryName, versionNumber, url, serialNumber);

现在我们需要将检测到的浮点温度发送回 Android 应用程序,就像我们在第五章,管理与物理组件的交互中所做的那样。为了将缓冲区加载一个浮点数并通过内部总线发送该值,我们需要添加一个writeToAdk()辅助函数,代码如下:

void writeToAdk(float temperature) {
  char tempBuffer[BUFFSIZE];
  sprintf(tempBuffer, "%f", temperature);
  memcpy(buffer, tempBuffer, BUFFSIZE);
  adk.write(strlen(tempBuffer), buffer);
}

前面的函数期望从传感器读数转换而来的浮点温度。我们使用sprintf()函数调用填充一个临时缓冲区,然后使用memcpy()函数用tempBuffer变量替换 ADK 缓冲区内容。加载完成后,我们将缓冲区内容发送到 Android 应用程序。

在主loop()函数中,我们还需要监听 Android 发送的任何命令,这些命令描述了需要打开或关闭锅炉的需求。因此,我们需要像在第二章,了解你的工具中所做的那样创建一个执行器函数。然后,我们需要从 ADK 读取命令并将结果传递给执行器。为此,我们需要执行以下步骤:

  1. 添加executor()函数,该函数读取一个命令并打开或关闭外部设备:

    void executor(uint8_t command) {
      switch(command) {
        case 0:
          digitalWrite(BOILER, LOW);
          break;
        case 1:
          digitalWrite(BOILER, HIGH);
          break;
        default:
          // noop
          break;
      }
    }
    
  2. 添加executeFromAdk()函数,该函数从 ADK 读取命令并将其传递给前面的executor()函数:

    void executeFromAdk() {
      adk.read(&readBytes, BUFFSIZE, buffer);
      if (readBytes > 0){
        executor(buffer[0]);
      }
    }
    

如果我们查看本章开始时定义的计划,我们拥有 Arduino 草图所需的所有组件,因此我们可以使用以下代码在主loop()函数中将所有内容组合在一起:

void loop() {
  Usb.Task();
  if (adk.isReady()) {
    reading = analogRead(SENSOR);
    writeToAdk(convertToCelsius(reading));
    executeFromAdk();
    delay(DELAY);
  }
}

当 ADK 准备就绪时,我们读取传感器值,并将其摄氏度转换写入 ADK 缓冲区。然后我们期望从 ADK 接收一个命令,如果命令可用,我们就执行该命令,打开或关闭锅炉。现在草图完成了,我们可以继续编写 Chronotherm Android 应用程序。

通过 Android 管理恒温器

当我们通过 UDOO 平台构建物理应用程序时,要牢记我们可以利用 Android 组件和服务来提升项目质量。此外,与硬件相比,Android 的用户界面元素更加用户友好且易于维护。因此,我们将创建一个软件组件来管理温度设定点,而不是使用电位计。

要开始应用程序原型设计,请打开 Android Studio 并启动一个名为Chronotherm的新应用程序,使用 Android API 19。在引导过程中,选择一个名为Overview空白活动

设置 ADK 工具包

在我们开始应用程序布局之前,需要配置 ADKToolkit 以实现内部通信。请遵循以下提示以完成正确的配置:

  1. app/build.gradle文件中添加ADKToolkit库依赖。

  2. 同步你的 Gradle 配置。

  3. res/xml/目录下创建配件过滤器文件usb_accessory_filter.xml,包含以下代码:

    <resources>
      <usb-accessory
        version="0.1.0"
        model="Chronotherm"
        manufacturer="Example, Inc."/>
    </resources>
    
  4. AndroidManifest.xml文件中添加USB 配件支持选项要求和USB 配件意图过滤器选项。

  5. Overview.java类文件中,在类的顶部声明AdkManager对象。

  6. Overview活动类的onCreate()方法中添加AdkManager对象初始化。

  7. 重写onResume()活动回调,在活动打开时启动 ADK 连接。在这个项目中,我们在onPause()回调中不关闭 ADK 连接,因为我们将使用两个不同的活动,并且连接应该保持活动状态。

在 ADK 通信启动并运行后,我们可以继续编写 Chronotherm 用户界面。

设计 Android 用户界面

下一步是设计 Chronotherm 应用程序的用户界面,以处理设定点管理以及适当的反馈。我们将通过编写两个不同职责的 Android 活动来实现这些要求:

  • 一个Overview活动,显示当前时间、检测到的温度和当前锅炉状态。它应该包括一个小组件,显示用户每天每个小时的设定点。这些设定点用于决定是否打开或关闭锅炉。

  • 一个Settings活动,用于更改每天每个小时的当前设定点。这个活动应该使用与Overview活动相同的组件来表示温度设定点。

我们从Overview活动以及温度设定点小组件开始实现。

编写 Overview 活动

这个活动应提供有关 Chronotherm 应用程序当前状态的所有详细信息。所有必需的组件在以下模拟图中总结,该图定义了创建组件的顺序:

编写 Overview 活动

第一步是更新活动布局,根据前面草图的建议,我们应该执行以下步骤:

  1. 在布局的顶部,我们可以包含一个显示当前系统时间的TextClock视图。

  2. 顶栏应该提供锅炉状态的反馈。我们可以添加一个灰色的TextView,带有Active文字,当锅炉开启时它会变成绿色。

  3. Overview主体必须提供当前检测到的温度。因为这是 Chronotherm 应用程序提供的最重要的细节之一,我们将通过使其比其他组件更大来强调这个值。

  4. 在室内温度附近,我们将通过一系列垂直条形图创建一个小部件,以显示用户每天每个小时的设定点,从而展示当前激活的日程。在Overview活动中,这个小部件将保持只读模式,仅用于快速查看激活的程序。

  5. 在活动操作栏中,我们应该提供一个菜单项,用于打开Settings活动。这个活动将用于在 Chronotherm 应用程序中存储设定点。

我们从顶部栏和检测到的温度组件开始实现Overview,要实现前面的布局,需要以下步骤:

  1. res/values/dimens.xml文件中,添加以下高亮资源:

    <resources>
      <dimen name="activity_horizontal_margin">16dp</dimen>
      <dimen name="activity_vertical_margin">16dp</dimen>
      <dimen name="text_title">40sp</dimen>
      <dimen name="temperature">100sp</dimen>
      <dimen name="temperature_round">300dp</dimen>
      <dimen name="circle_round">120dp</dimen>
    </resources>
    
  2. res/values/styles.xml文件中,添加以下资源,并更改AppTheme parent属性如下:

    <resources>
      <color name="mine_shaft">#444444</color>
      <color name="pistachio">#99CC00</color>
      <color name="coral_red">#FF4444</color>
      <style name="AppTheme" parent="Theme.AppCompat"></style>
    </resources>
    
  3. 为了强调当前检测到的温度,我们可以创建一个圆形形状来包围温度值。要实现这一点,请在res/drawable/目录下创建circle.xml文件,并添加以下代码:

    <shape
    
      android:shape="oval">
    
      <stroke
        android:width="2dp"
        android:color="@color/coral_red"/>
    
      <size
        android:width="@dimen/circle_round"
        android:height="@dimen/circle_round"/>
    </shape>
    
  4. 现在我们可以继续并在res/layout/目录下的activity_overview.xml文件中替换布局,使用以下高亮代码:

    <LinearLayout 
    
      android:orientation="vertical"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:paddingLeft="@dimen/activity_horizontal_margin"
      android:paddingRight="@dimen/activity_horizontal_margin"
      android:paddingTop="@dimen/activity_vertical_margin"
      android:paddingBottom="@dimen/activity_vertical_margin"
      tools:context=".Overview">
    </LinearLayout>
    
    
  5. 在前面的LinearLayout中放置以下代码,以创建包含当前系统时间和锅炉状态的活动顶栏:

    <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content">
    
      <TextClock
        android:textSize="@dimen/text_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
    
      <TextView
        android:id="@+id/boiler_status"
        android:text="ACTIVE"
        android:gravity="end"
        android:textColor="@color/mine_shaft"
        android:textSize="@dimen/text_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
    </LinearLayout>
    
  6. 下一步是创建活动主体。它应该包含两个不同的项目:第一个是LinearLayout,我们将在活动的onCreate()回调中使用LayoutInflater类来填充设定点小部件;第二个是被我们之前创建的圆形形状包围的当前检测到的温度。在根LinearLayout中,嵌套以下元素:

    <LinearLayout
      android:orientation="horizontal"
      android:gravity="center"
      android:layout_width="match_parent"
      android:layout_height="match_parent">
    
      <LinearLayout
        android:id="@+id/view_container"
        android:gravity="center"
        android:orientation="horizontal"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="match_parent">
      </LinearLayout>
    
      <TextView
        android:id="@+id/temperature"
        android:text="20.5°"
        android:background="@drawable/circle"
        android:gravity="center"
        android:textColor="@color/coral_red"
        android:textSize="@dimen/temperature"
        android:layout_width="@dimen/temperature_round"
        android:layout_height="@dimen/temperature_round" />
    </LinearLayout>
    
  7. 作为最后几步,在活动代码中存储所有视图引用。在Overview类的顶部,添加temperatureboiler_status视图的引用,使用以下高亮代码:

    private AdkManager mAdkManager;
    private TextView mTemperature;
    private TextView mStatus;
    
    
  8. OverviewonCreate()回调中,使用以下代码获取引用:

    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_overview);
    mTemperature = (TextView) findViewById(R.id.temperature);
    mStatus = (TextView) findViewById(R.id.boiler_status);
    
    

这些步骤提供了一个部分布局,我们将通过添加设定点小部件和设置菜单项来完成它。

创建自定义 UI 组件

为了保持用户界面的精简、可用和直观,我们可以使用一组垂直条,例如音频均衡器,以便用户可以立即了解他们想要获得的房间温度趋势。安卓自带一个名为SeekBar的内置组件,我们可以使用它来选择温度设定点。不幸的是,此组件绘制了一个水平条,并且没有提供其垂直对应物;因此,我们将扩展其默认行为。

注意

安卓 API 11 及更高版本为 XML 中的每个组件添加了rotate属性。即使我们使用 270 度的旋转来获得一个垂直组件,我们也可能会遇到正确放置一个条旁边另一个条的问题。在这种情况下,我们最初对定制此组件的努力将简化我们后续的工作。

安卓为构建自定义 UI 元素提供了复杂和组件化的模型,我们可以在developer.android.com/guide/topics/ui/custom-components.html深入了解更多细节。

SeekBar组件的自定义可以按以下方式进行组织:

  1. 作为第一步,我们应该创建一个实现垂直滑动行为的TemperatureBar类。大部分的更改与继承SeekBar类有关,同时将组件的宽度与高度进行切换。

  2. 小部件需要一个 XML 布局,以便从我们的代码中程序化地添加。因此,我们将创建一个包含TemperatureBar视图、所选度数和与条相关的小时的布局。

  3. 当垂直条组件发生任何变化时,应更新度数。在这一步中,我们将创建一个监听器,将条的变化传播到度数组件,为用户提供适当的反馈。

  4. 我们定制的包含TemperatureBar类、度数和小时视图的组件,应该为一天中的每个小时程序化地创建。我们将创建一个工具类,负责将组件布局膨胀 24 次,并添加适当的监听器。

我们开始编写垂直的SeekBar类,可以通过以下步骤实现:

  1. 在您的命名空间中创建一个名为widget的新包。

  2. 在新创建的包中,添加一个扩展SeekBar类实现的TemperatureBar类,同时定义默认的类构造函数,如下所示:

    public class TemperatureBar extends SeekBar {
      public TemperatureBar(Context context) {
        super(context);
      }
      public TemperatureBar(Context context, AttributeSet attrs) {
        super(context, attrs);
      }
      public TemperatureBar(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
      }
    }
    
  3. 继续实现TemperatureBar类,并在类的底部添加绘制和测量方法:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
      super.onSizeChanged(h, w, oldh, oldw);
    }
    
    @Override
    protected synchronized void onMeasure(int width, int height) {
      super.onMeasure(height, width);
      setMeasuredDimension(getMeasuredHeight(), getMeasuredWidth());
    }
    
    @Override
    protected void onDraw(Canvas c) {
      c.rotate(-90);
      c.translate(-getHeight(), 0);
      onSizeChanged(getWidth(), getHeight(), 0, 0);
      super.onDraw(c);
    }
    

    在第一个方法中,我们将小部件的宽度与高度进行切换,以便我们可以使用此参数来提供组件内容的准确测量。然后我们重写由安卓系统在组件绘制期间调用的onDraw()方法,通过对SeekBar画布应用平移并将其放置在垂直位置。作为最后一步,我们再次调用onSizeChanged回调以在画布平移后调整组件的大小。

  4. 因为我们已经切换了条宽和高度,我们需要重写onTouchEvent()方法,以便在计算值时使用组件高度。在TemperatureBar()类的底部,添加以下回调:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
      if (!isEnabled()) {
        return false;
      }
      switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
        case MotionEvent.ACTION_MOVE:
        case MotionEvent.ACTION_UP:
          setProgress(getMax() - (int) (getMax() * event.getY() / getHeight()));
          onSizeChanged(getWidth(), getHeight(), 0, 0);
          break;
        case MotionEvent.ACTION_CANCEL:
          break;
      }
      return true;
    }
    

    使用前面的代码,我们每次在ACTION_DOWNACTION_MOVEACTION_UP方法事件发生时更新组件进度。由于本项目不需要其他行为,所以我们保留其余实现不变。

现在我们可以继续编写承载前一个组件以及度和小时的TextView的 XML 布局。通过以下步骤,我们可以实现一个从我们的工具类中填充的布局:

  1. res/values/下的dimens.xml文件中添加bar_height声明,这样我们可以在需要时轻松地更改它:

    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>
    <dimen name="bar_height">400dp</dimen>
    <dimen name="text_title">40sp</dimen>
    
  2. res/layout/目录下创建temperature_bar.xml文件,其中包含小部件布局。在这个文件中,我们应该将此LinearLayout作为根元素添加:

    <LinearLayout 
      android:orientation="vertical"
      android:layout_width="0dp"
      android:layout_weight="1"
      android:layout_height="wrap_content">
    </LinearLayout>
    
  3. 向前一个LinearLayout中包含以下组件:

    <TextView
      android:id="@+id/degrees"
      android:text="0"
      android:gravity="center"
      android:layout_width="match_parent"
      android:layout_height="match_parent" />
    
    <me.palazzetti.widget.TemperatureBar
      android:id="@+id/seekbar"
      android:max="40"
      android:layout_gravity="center"
      android:layout_width="wrap_content"
      android:layout_height="@dimen/bar_height" />
    
    <TextView
      android:id="@+id/time"
      android:text="00"
      android:gravity="center"
      android:layout_width="match_parent"
      android:layout_height="match_parent" />
    

    提示

    始终将me.palazzetti命名空间替换为你的命名空间。

既然我们已经有了温度条组件和小部件布局,我们需要创建一个将degreesseekbar视图绑定的绑定。通过以下步骤进行小部件实现:

  1. widget包中创建DegreeListener类。

  2. 前一个类应该实现SeekBar监听器,同时存储连接的degrees视图的引用。我们使用这个TextView引用来传播垂直条的价值:

    public class DegreeListener implements SeekBar.OnSeekBarChangeListener {
      private TextView mDegrees;
      public DegreeListener(TextView degrees) {
        mDegrees = degrees;
      }
    
  3. 将进度值传播到mDegrees视图,覆盖OnSeekBarChangeListener接口所需的以下方法:

      @Override
      public void onProgressChanged(SeekBar seekBar, int progress, boolean b) {
        mDegrees.setText(String.valueOf(progress));
      }
    
      @Override
      public void onStartTrackingTouch(SeekBar seekBar) {}
    
      @Override
      public void onStopTrackingTouch(SeekBar seekBar) {}
    }
    

最后缺失的部分是提供一个工具类,用于初始化带有DegreeListener类的TemperatureBar类来填充小部件布局。该填充过程应针对一天的每个小时重复进行,并且需要引用小部件将被填充的布局。要完成实现,请按照以下步骤操作:

  1. widget包中创建TemperatureWidget类。

  2. 这个类应该公开一个静态的addTo()方法,该方法需要活动上下文、父元素以及是否应以只读模式创建垂直条。这样,我们可以将此小部件用于可视化和编辑。我们可以在以下代码片段中找到完整的实现:

    public class TemperatureWidget {
      private static final int BAR_NUMBER = 24;
      public static TemperatureBar[] addTo(Context ctx, ViewGroup parent, boolean enabled) {
        TemperatureBar[] bars = new TemperatureBar[BAR_NUMBER];
        for (int i = 0; i < BAR_NUMBER; i++) {
          View v = LayoutInflater.from(ctx).inflate(R.layout.temperature_bar, parent, false);
          TextView time = (TextView) v.findViewById(R.id.time);
          TextView degree = (TextView) v.findViewById(R.id.degrees);
          TemperatureBar bar = (TemperatureBar) v.findViewById(R.id.seekbar);
          time.setText(String.format("%02d", i));
          degree.setText(String.valueOf(0));
          bar.setOnSeekBarChangeListener(new DegreeListener(degree));
          bar.setProgress(0);
          bar.setEnabled(enabled);
          parent.addView(v, parent.getChildCount());
          bars[i] = bar;
        }
        return bars;
      }
    }
    

    在类的顶部,我们定义了生成的条形数的数量。在addTo()方法中,我们填充temperature_bar布局以创建条形对象的实例。然后,我们获取timedegreesseekbar对象的所有引用,以便我们可以设置初始值并创建带有degrees TextView绑定的DegreeListener类。我们继续将小部件添加到parent节点,用当前创建的条形填充bars数组。最后一步,我们返回这个数组,以便调用活动可以使用它。

完成概览活动

设置点小部件现在已完成,我们可以继续在活动创建期间填充温度条。我们还将添加在活动菜单中启动Settings活动的操作。要完成Overview类,请按照以下步骤操作:

  1. OverviewonCreate()回调中通过添加高亮代码来填充设置点小部件:

    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_overview);
    mTemperature = (TextView) findViewById(R.id.temperature);
    mStatus = (TextView) findViewById(R.id.boiler_status);
    ViewGroup container = (ViewGroup) findViewById(R.id.view_container);
    mBars = TemperatureWidget.addTo(this, container, false);
    
    
  2. 处理操作栏菜单以启动Settings活动,按照以下方式更改onOptionsItemSelected()方法:

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
      int id = item.getItemId();
      if (id == R.id.action_settings) {
        Intent intent = new Intent(this, Settings.class);
        startActivity(intent);
        return true;
      }
      return super.onOptionsItemSelected(item);
    }
    

    注意

    Settings活动目前不可用,我们将在下一节中创建它。

我们已经完成了Overview类的布局,以下是获得的结果截图:

完成概览活动

编写设置活动

在实现我们的温控逻辑之前,下一步是创建一个Settings活动,以便在白天更改温度设置点。要启动新活动,请从窗口菜单中选择文件,然后选择新建以打开上下文菜单。在那里,选择活动,然后选择空白活动。这将打开一个新窗口,我们可以在活动名称中填写Settings,然后点击完成

注意

即使我们可以使用带有同步首选项的内置设置模板,我们还是使用空白活动以尽可能简化这部分内容。

我们从以下草图开始设计活动布局,展示所有必需的组件:

编写设置活动

首先需要更新活动布局,根据之前草图的建议,我们应该:

  1. 添加一个保存按钮,该按钮将调用活动方法,保存从温度小部件中选择的设置点。

  2. 在选择设置点期间,填充使用的温度小部件。

为了实现前面的布局,更新res/layout/下的activity_settings.xml文件,进行以下更改:

  1. 使用以下LinearLayout替换根布局元素:

    <LinearLayout 
    
      android:orientation="vertical"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:paddingLeft="@dimen/activity_horizontal_margin"
      android:paddingRight="@dimen/activity_horizontal_margin"
      android:paddingTop="@dimen/activity_vertical_margin"
      android:paddingBottom="@dimen/activity_vertical_margin"
      tools:context="me.palazzetti.chronotherm.Settings">
    </LinearLayout>
    
    
  2. 在前面的布局中,添加小部件占位符和保存按钮:

    <LinearLayout
      android:id="@+id/edit_container"
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="wrap_content">
    </LinearLayout>
    
    <Button
      android:text="Save settings"
      android:layout_marginTop="50dp"
      android:layout_width="match_parent"
      android:layout_height="wrap_content" />
    

我们可以通过在Settings类中进行以下步骤,添加小部件初始化来完成活动:

  1. Settings类顶部添加高亮变量:

    public class Settings extends ActionBarActivity {
      private TemperatureBar[] mBars;
      // ... 
    
  2. Settings类的onCreate()方法中,添加高亮代码以填充设置点小部件:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_settings);
      ViewGroup container = (ViewGroup)   findViewById(R.id.edit_container);
      mBars = TemperatureWidget.addTo(this, container, true);
    }
    

如果我们再次上传 Android 应用程序,可以使用菜单选项打开Settings活动,如下截图所示:

编写 Settings 活动

Chronotherm 应用程序的界面已完成,我们可以继续处理用户设置存储层的管理。

管理用户的设定点

Chronotherm 应用程序的活动提供了必要的用户界面组件,以显示和更改用户的设定点。为了让它们工作,我们应该实现保存持久应用程序数据的逻辑。根据我们的需求,我们可以使用SharedPreferences类以键值对的形式存储基本数据,为整个应用程序提供设定点值。在这个项目中,我们将使用设定点小时作为键,选择的温度作为值。

注意事项

SharedPreferences类是 Android 框架提供的一种存储选项。如果在其他项目中我们需要不同的存储方式,可以查看 Android 官方文档:developer.android.com/guide/topics/data/data-storage.html

从 Overview 活动中读取设定点

我们首先在Overview活动中实现一个方法,该方法读取存储的设定点并更新温度条数值。在活动创建期间,我们可以通过以下步骤读取用户的偏好设置:

  1. 对于每个进度条,我们使用存储的值来设置进度。当没有找到设置时,我们使用0作为默认值。这个实现需要以下代码,我们应该将其添加到Overview类中:

    private void readPreferences() {
      SharedPreferences sharedPref = getSharedPreferences("__CHRONOTHERM__", Context.MODE_PRIVATE);
      for (int i = 0; i < mBars.length; i++) {
        int value = sharedPref.getInt(String.valueOf(i), 0);
        mBars[i].setProgress(value);
      }
    }
    

    我们打开应用程序的偏好设置,并使用一天中的小时作为键来更新每个条形图。相关的小时由i循环计数器间接表示。

  2. onResume()活动回调中调用前面的方法,并添加高亮显示的代码:

    protected void onResume() {
      super.onResume();
      readPreferences();
      mAdkManager.open();
    }
    

通过这些步骤,我们在Overview活动中完成了设定点的管理,并将继续处理Settings活动。

从 Settings 活动中写入设定点

Settings活动中,当用户点击保存设置按钮时,我们应该实现存储用户设定点的逻辑。此外,当活动创建时,我们必须加载先前存储的设定点,以便在用户开始更改偏好设置之前,向他们展示当前的时间表。为实现这些功能,我们可以按照以下步骤进行:

  1. 与在Overview活动中所做的一样,我们需要加载设定点值并更新温度条。因为我们已经实现了这个功能,所以可以直接从Overview类将readPreferences()方法复制粘贴到Settings类中。

  2. Settings类的底部添加以下代码以存储选定的设定点:

    public void savePreferences(View v) {
      SharedPreferences sharedPref = getSharedPreferences("chronotherm", Context.MODE_PRIVATE);
      SharedPreferences.Editor editor = sharedPref.edit();
      for (int i = 0; i < mBars.length; i ++) {
        editor.putInt(String.valueOf(i), mBars[i].getProgress());
      }
      editor.apply();
      this.finish();
    }
    

    在使用后台提交检索并存储所有设定点之后,我们关闭当前活动。

  3. res/layout/下的activity_settings.xml布局文件中,更新保存按钮,使其在点击时调用前面的方法,如以下高亮代码所示:

    <Button
      android:onClick="savePreferences"
      android:text="Save settings"
      android:layout_marginTop="50dp"
      android:layout_width="match_parent"
      android:layout_height="wrap_content" />
    

这是实现 Chronotherm 应用程序接口和设置管理的最后一步。现在我们可以继续实现读取检测到的温度以及开启或关闭锅炉所需的逻辑。

与 Arduino 交互

我们的应用程序已准备好接收温度数据,检查是否应激活锅炉。整体设计是使用ExecutorService类,该类运行周期性的计划任务线程,并且应该:

  1. 从 ADK 读取检测到的温度。

  2. 更新锅炉状态,检查温度是否低于当前选择的设定点。

  3. 将温度发送到主线程,以便它可以更新temperature TextView

  4. 向 Arduino 发送命令以开启或关闭锅炉。此任务应仅在当前锅炉状态自上一次任务执行以来发生变化时执行。在这种情况下,它还应将锅炉状态发送到主线程,以便它可以更新相关的TextView

在我们开始线程实现之前,我们应该提供一个 Java 接口,它公开了更新活动用户界面所需的必要方法。我们可以通过以下步骤完成此操作:

  1. 创建一个名为OnDataChangeListener的新 Java 接口,并添加以下代码片段:

    public interface OnDataChangeListener {
      void onTemperatureChanged(float temperature);
      void onBoilerChanged(boolean status);
    }
    
  2. 使用高亮代码将前面的接口添加到Overview类:

    public class Overview extends ActionBarActivity implements OnDataChangeListener {
    
  3. 通过编写更新当前温度和锅炉状态TextViews的代码来实现接口:

    @Override
    public void onTemperatureChanged(float temperature) {
      mTemperature.setText(String.format("%.1f°", temperature));
    }
    
    @Override
    public void onBoilerChanged(boolean status) {
      if (status) {
        mStatus.setTextColor(getResources().getColor(R.color.pistachio));
      }
      else {
        mStatus.setTextColor(getResources().getColor(R.color.mine_shaft));
      }
    }
    

现在我们可以继续实现先前解释的整体设计的计划任务线程:

  1. 在您的命名空间中创建一个名为adk的新包。

  2. adk包中,添加一个名为DataReader的新类。

  3. 在类的顶部,添加以下声明:

    private final static int TEMPERATURE_POLLING = 1000;
    private final static int TEMPERATURE_UPDATED = 0;
    private final static int BOILER_UPDATED = 1;
    private AdkManager mAdkManager;
    private Context mContext;
    private OnDataChangeListener mCaller;
    private ScheduledExecutorService mSchedulerSensor;
    private Handler mMainLoop;
    boolean mBoilerStatus = false;
    

    我们定义了计划任务的轮询时间以及主线程处理器中使用的消息类型,以识别温度或锅炉更新。我们保存了AdkManager实例、活动上下文以及实现前一个接口的调用活动引用。然后,我们定义了将用于创建短生命周期的线程以读取传感器数据的ExecutorService实现。

  4. 实现设置消息处理器的DataReader构造函数,当主线程从传感器线程接收到消息时:

    public DataReader(AdkManager adkManager, Context ctx, OnDataChangeListener caller) {
      this.mAdkManager = adkManager;
      this.mContext = ctx;
      this.mCaller = caller;
      mMainLoop = new Handler(Looper.getMainLooper()) {
        @Override
        public void handleMessage(Message message) {
          switch (message.what) {
            case TEMPERATURE_UPDATED:
              mCaller.onTemperatureChanged((float) message.obj);
              break;
            case BOILER_UPDATED:
              mCaller.onBoilerChanged((boolean) message.obj);
              break;
          }
        }
      };
    }
    

    我们保存所有必要的引用,然后定义主线程处理器。在处理器内部,我们使用OnDataChangeListener回调根据消息类型在视图中更新温度或锅炉状态。

  5. DataReader构造函数的底部,添加以下实现了先前定义的整体设计的Runnable方法:

    private class SensorThread implements Runnable {
      @Override
      public void run() {
        Message message;
        // Reads from ADK and check boiler status
        AdkMessage response = mAdkManager.read();
        float temperature = response.getFloat();
        boolean status = isBelowSetpoint(temperature);
        // Updates temperature back to the main thread
        message = mMainLoop.obtainMessage(TEMPERATURE_UPDATED, temperature);
        message.sendToTarget();
        // Turns on/off the boiler and updates the status
        if (mBoilerStatus != status) {
          int adkCommand = status ? 1 : 0;
          mAdkManager.write(adkCommand);
          message = mMainLoop.obtainMessage(BOILER_UPDATED, status);
          message.sendToTarget();
          mBoilerStatus = status;
        }
      }
      private boolean isBelowSetpoint(float temperature) {
        SharedPreferences sharedPref = mContext.getSharedPreferences("__CHRONOTHERM__", Context.MODE_PRIVATE);
        int currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
        return temperature < sharedPref.getInt(String.valueOf(currentHour), 0);
      }
    }
    

    在这个实现中,我们创建了一个isBelowSetpoint()方法,用于检查当前小时的温度是否低于所选的设定点。我们从应用程序的共享偏好设置中获取这个值。

  6. DataReader类添加一个方法,以定期创建短生命周期的线程来启动调度程序,如下所示:

    public void start() {
      // Start thread that listens to ADK
      SensorThread sensor = new SensorThread();
      mSchedulerSensor = Executors.newSingleThreadScheduledExecutor();
      mSchedulerSensor.scheduleAtFixedRate(sensor, 0, TEMPERATURE_POLLING, TimeUnit.MILLISECONDS);
    }
    
  7. 在类的底部添加stop()方法,通过执行器的shutdown()方法停止调度程序创建新线程:

    public void stop() {
      mSchedulerSensor.shutdown();
    }
    
  8. 现在,我们应该回到Overview类中,在活动生命周期内开始和停止调度程序。在Overview类的顶部添加DataReader声明:

    private AdkManager mAdkManager;
    private DataReader mReader;
    
    
  9. onCreate()回调中初始化DataReader实例,通过以下突出显示的代码:

      mAdkManager = new AdkManager(this);
      mReader = new DataReader(mAdkManager, this, this);
    }
    
  10. onResume()onPause()活动的回调中开始和停止读取调度程序,如突出显示的代码所示:

    @Override
    protected void onPause() {
      super.onPause();
      mReader.stop();
    }
    
    @Override
    protected void onResume() {
      super.onResume();
      readPreferences();
      mAdkManager.open();
      mReader.start();
    }
    

UDOO 和 Android 之间的通信已经运行起来,我们恒温器的逻辑已经准备好激活和关闭锅炉。现在,我们可以再次上传 Android 应用程序,添加一些温度设置,并开始玩原型。我们已经完成了原型,最后缺少的任务是在app/build.gradle文件中将应用程序版本更新为0.1.0版本,如下面的代码所示:

defaultConfig {
  applicationId "me.palazzetti.chronotherm"
  minSdkVersion 19
  targetSdkVersion 21
  versionCode 1
  versionName "0.1.0"
}

改进原型

在本章中,我们做出了不同的设计决策,使恒温器的实现更加容易。尽管这个应用程序对于家庭自动化来说是一个很好的概念验证,但我们必须牢记,还需要做很多事情来提高原型的质量和可靠性。这个应用程序是一个经典场景,分别用 Android 应用程序和 Arduino 微控制器实现了人机界面(HMI)控制系统。在这种场景中,自动化设计的一个基本原则是,即使在没有 HMI 部分的情况下,控制单元也应该能够做出合理且安全的决策

在我们的案例中,我们解耦了责任,将打开或关闭锅炉的决定委托给 Android 应用程序。虽然这不是一个任务关键的系统,但这样的设计可能会导致如果 Android 应用程序崩溃,锅炉可能会永远保持开启状态。更好的解耦方式是只使用 HMI 显示反馈和存储用户的设定点,而改变锅炉状态的决定仍然留在控制单元中。这意味着,我们不应该向 Arduino 发送开或关的命令,而应该发送当前的设定点,该设定点将存储在微控制器的内存中。这样,控制单元可以根据最后收到的设定点做出安全的选择。

另一个我们可以作为练习考虑的改进是实施滞后逻辑。我们的恒温器设计为在检测到的温度超过或低于选定设定点时分别开启或关闭锅炉。这种行为应该得到改进,因为在这种设计中,当温度稳定在设定点周围时,恒温器将开始频繁地开启和关闭锅炉。我们可以在控制系统的滞后逻辑应用中找到有关详细信息和建议。

总结

在本章中,我们探讨了智能家居领域以及如何使用 UDOO 解决一些日常任务。你了解了使用智能对象的优势,这些对象能够在你不在家时解决地点和时间问题。然后,我们规划了一个恒温器原型,通过传感器控制我们的客厅温度。为了使设备完全自动化,我们设计了一个用例,用户可以决定每天每个小时的温度设定点。

起初,我们使用温度传感器和 LED 构建了应用电路,模拟了锅炉。我们开始编写 Android 用户界面程序,自定义常规 UI 组件以更好地满足我们的需求。我们开始编写概述活动,显示当前时间、锅炉状态、当前室温以及全天选择的设定点的小部件。接着,我们继续编写设置活动,用于存储恒温器温度计划。作为最后一步,我们编写了一个计划任务线程,读取环境温度并根据检测到的温度与当前设定点匹配来开启或关闭锅炉。

在下一章中,我们将利用一系列强大的 Android API 扩展此原型,增加新功能以增强人与设备的交互。

第七章:使用 Android API 进行人机交互

20 世纪 80 年代个人电脑的出现开启了一个新的挑战:让电脑和计算对业余爱好者、学生以及更广泛的技术爱好者有用和可用。这些人需要一个简单的方法来控制他们的机器,因此人机交互迅速成为一个开放的研究领域,旨在提高可用性,并导致了图形用户界面和新型输入设备的发展。在过去的十年中,诸如语音识别、语音合成、动作追踪等其他的交互模式在商业应用中被使用,这一巨大改进间接导致了电话、平板和眼镜等物体向新型智能设备的演变。

本章的目标是利用这些新的交互模式,使用 Android API 的一个子集来增强 Chronotherm 原型,增加一组新功能,使其变得更加智能。

在本章中,我们将涵盖以下主题:

  • 利用 Android API 扩展原型

  • 使用语音识别来控制我们的原型

  • 通过语音合成向用户提供反馈

利用 Android API 扩展原型

Chronotherm 应用程序旨在当检测到的温度超过用户的温度设定点时启动锅炉。在之前的原型中,我们创建了一个设置页面,用户可以设置他们每天每个小时的偏好。我们可以扩展原型的行为,让用户能够存储不止一个设定点配置。这样,我们可以提供预设管理,用户可以根据不同的因素,如星期几或当前季节来激活。

在添加此功能时,我们必须牢记这并不是一个桌面应用程序,因此我们应避免创建一组新的令人眼花缭乱的界面。Chronotherm 应用程序可以部署在用户的家中,由于这些地方通常很安静,我们可以考虑使用语音识别来获取用户的输入。这种方法将消除创建或编辑存储预设的其他活动的需要。同时,我们必须考虑到在语音识别过程结束时我们需要提供反馈,以便用户知道他们的命令是否被接受。即使我们可以使用小弹窗或通知来解决此问题,但使用语音合成来向用户提供反馈可以带来更好的用户体验。

注意

语音识别和合成是可以用来为我们的应用程序提供新型交互的功能。然而,我们必须牢记,这些组件可能会为视障、身体障碍或听障人士带来严重的可访问性问题。每次我们想要创建一个好的项目时,都必须努力工作,以制作出既美观又可供每个人使用的应用程序。安卓通过可访问性框架为我们提供了很大帮助,因此,在未来的项目中,请记得遵循developer.android.com/guide/topics/ui/accessibility/index.html上提供的所有最佳实践。

安卓 SDK 提供了一系列 API,我们可以用它们与安装的文字转语音服务和语音输入法进行交互,但是 UDOOU 盘自带的原生安卓并没有直接提供这些功能。为了让我们的代码工作,我们需要安装一个用于语音识别的应用程序,以及另一个实现文字转语音功能的应用。

例如,市场上几乎任何安卓设备都预装了作为谷歌移动服务套件一部分的这类应用程序。有关此主题的更多详细信息,请点击链接www.udoo.org/guide-how-to-install-gapps-on-udoo-running-android/

改进用户设置

在我们继续实现语音识别服务之前,需要改变物理应用程序中设置存储的方式。目前,我们正在使用 Chronotherm 应用程序的共享偏好设置,我们在其中存储每个SeekBar类选择的设定点。根据新要求,这不再适合我们的应用程序,因为我们需要为每个预设持久化不同的设定点。此外,我们需要持久化当前激活的预设,所有这些变化都迫使我们设计一个新的用户界面以及一个新的设置系统。

我们可以通过以下截图来看看需要做出哪些改变:

改进用户设置

第一步是更新我们的用户界面。根据上述草图的建议,我们应该:

  1. 在布局顶部添加一个新的TextView,显示当前预设的名称。在加载活动时以及用户激活新预设时,应更改名称。

为了实现上述布局,更新res/layout/目录下的activity_overview.xml文件,在包含TextClockboiler_status视图的头部LinearLayout中进行以下更改:

  1. 更改TextClock视图,用高亮代码替换layout_width属性,并添加layout_weight属性:

    android:layout_width="0dp"
    android:layout_weight="1"
    
    
  2. 按照上一步的操作,更改boiler_status TextView的布局:

    android:layout_width="0dp"
    android:layout_weight="1"
    
    
  3. 在前一个组件之间添加以下TextView以显示激活的预设:

    <TextView
      android:id="@+id/current_preset"
      android:text="NO PRESET ACTIVATED"
      android:gravity="center"
      android:textColor="@color/coral_red"
      android:textSize="@dimen/text_title"
      android:layout_width="0dp"
      android:layout_weight="2"
      android:layout_height="match_parent" />
    
  4. Overview 类的顶部,使用高亮代码添加 current_preset 视图的引用:

    private TextView mCurrentPreset;
    private TextView mTemperature;
    private TextView mStatus;
    
  5. OverviewonCreate 回调中,使用以下代码获取视图引用:

    setContentView(R.layout.activity_overview);
    mCurrentPreset = (TextView) findViewById(R.id.current_preset);
    
    

下面的截图是通过前面的布局获得的:

改善用户设置

存储预设配置

如先前讨论的,我们应该改变 Chronotherm 应用程序中用户设置点的存储和检索方式。想法是将对应用程序共享首选项的访问隔离在一个新的 Preset 类中,该类公开以下方法:

  • 一个 set() 方法,用于保存与预设名称对应的设置点配置。设置点值数组被序列化为逗号分隔的字符串,并使用预设名称作为键进行保存。

  • 一个 get() 方法,用于返回给定预设名称的存储设置点。设置点字符串被反序列化并作为值数组返回。

  • 一个 getCurrent() 方法,用于返回最新激活预设的名称。

  • 一个 setCurrent() 方法,用于将给定的预设名称提升为最新激活的预设。

要创建 Preset 类,请按照以下步骤操作:

  1. chronotherm 包中创建 Preset 类。

  2. Preset 类的顶部添加以下声明:

    private static final String SHARED_PREF = "__CHRONOTHERM__";
    private static final String CURRENT_PRESET = "__CURRENT__";
    private static final String NO_PRESET = "NO PRESET ACTIVATED";
    

    我们将前一章中使用的偏好设置名称放在一个名为 SHARED_PREF 的变量中。CURRENT_PRESET 键用于获取或设置当前使用的预设。NO_PRESET 赋值定义了在没有找到预设时返回的默认值。这处理了首次运行应用程序的情况,在没有找到预设时显示 NO PRESET ACTIVATED 屏幕。

  3. Preset 类的底部添加 set() 方法:

    public static void set(Context ctx, String name, ArrayList<Integer> values) {
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      SharedPreferences.Editor editor = sharedPref.edit();
      String serializedValues = TextUtils.join(",", values);
      editor.putString(name, serializedValues);
      editor.apply();
    }
    

    前面的方法期望 values 数组,该数组表示给定预设 name 变量的用户设置点。我们使用 TextUtils 类将值数组序列化为逗号分隔的字符串,同时使用预设 name 变量作为键。

  4. Preset 类的底部添加 get() 方法:

    public static ArrayList<Integer> get(Context ctx, String name) {
      ArrayList<Integer> values = new ArrayList<Integer>();
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
  String serializedValues = sharedPref.getString(name, null);
      if (serializedValues != null) {
        for (String progress : serializedValues.split(",")) {
          values.add(Integer.valueOf(progress));
        }
      }
      return values;
    }
    

    我们用预设的 name 变量获取到的设置点填充 values 数组。我们知道这些值是以逗号分隔的序列化字符串,因此我们将其拆分并解析,将每个值添加到前面的数组中。如果我们没有找到与给定预设 name 变量相匹配的内容,我们将返回一个空数组。

  5. 在类的底部添加 getCurrent() 方法,以返回当前激活的预设:

    public static String getCurrent(Context ctx) {
      String currentPreset;
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      currentPreset = sharedPref.getString(CURRENT_PRESET, NO_PRESET);
      return currentPreset;
    }
    
  6. 在类的底部添加 setCurrent() 方法,以存储当前激活的预设:

    public static void setCurrent(Context ctx, String name) {
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      SharedPreferences.Editor editor = sharedPref.edit();
      editor.putString(CURRENT_PRESET, name);
      editor.apply();
    }
    

既然我们已经有了用户预设的正式表示,我们应该调整这两个活动以反映最新的变化。

在活动间使用预设

我们从概览活动开始,该活动应在活动恢复阶段加载当前预设。如果激活了预设,我们应该将current_preset TextView更改为预设名称。为实现此步骤,我们应该用以下代码替换readPreferences方法:

private void readPreferences() {
  String activatedPreset = Preset.getCurrent(this);
  mCurrentValues = Preset.get(this, activatedPreset);
  for (int i = 0; i < mCurrentValues.size(); i++) {
    mBars[i].setProgress(mCurrentValues.get(i));
  }
  mCurrentPreset.setText(activatedPreset.toUpperCase());
}

下一步是使设置活动适应以下步骤总结的新行为:

  1. 当用户打开设置活动时,语音识别系统应该请求预设名称。

  2. 如果找到给定的预设,我们应该加载预设的设定点,并更新所有温度条。当用户保存新偏好时,旧的设定点将被更新。

  3. 如果未找到给定的预设,则无需更新温度条。当用户保存新偏好时,将使用给定的设定点存储新的预设条目。

我们仍然没有实现第一步所需的所有组件,因为我们缺少语音识别实现。与此同时,我们可以通过以下步骤更新此活动中的预设存储和检索方式:

  1. 在类的顶部,添加突出显示的变量,该变量将存储识别的预设名称:

    private TemperatureBar[] mBars;
    private String mEditingPreset;
    
    
  2. 设置活动的onCreate()回调中,移除readPreferences()方法的调用。

  3. 更新readPreferences()成员函数,使其加载给定预设名称(如果可用)的值,并返回表示是否找到此预设的值。我们可以通过以下代码实现此行为:

    private boolean readPreferences(String presetName) {
      boolean found;
      ArrayList<Integer> values;
      values = Preset.get(this, presetName);
      found = values.size() > 0;
      for (int i = 0; i < values.size(); i ++) {
        mBars[i].setProgress(values.get(i));
      }
      return found;
    }
    
  4. 更新savePreferences()方法,使其使用Preset类来存储或更新给定的设定点:

    public void savePreferences(View v) {
      ArrayList<Integer> values = new ArrayList<Integer>();
      for (int i = 0; i < mBars.length; i++) {
        values.add(mBars[i].getProgress());
      }
      Preset.set(this, mEditingPreset, values);
      this.finish();
    }
    

通过这些步骤,我们在两个活动中都改变了预设管理。我们仍然需要完成设置活动,因为我们缺少识别阶段。我们将在实现语音识别后,稍后完成这些步骤。

在将 Chronotherm 应用程序适应新的预设管理的最后一步,是更改SensorThread参数中的温度检查。实际上,isBelowSetpoint方法应该检索与最后温度读数匹配的激活预设的此设定点的值。如果选择了任何预设,它应该默认关闭锅炉。我们可以通过用突出显示的代码更改isBelowSetpoint方法来实现此行为:

private boolean isBelowSetpoint(float temperature) {
  int currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
  String currentPreset = Preset.getCurrent(mContext);
  ArrayList<Integer> currentValues = Preset.get(mContext,   currentPreset);
  if (currentValues.size() > 0) {
    return temperature < currentValues.get(currentHour);
  }
  else {
    return false;
  }
}

这结束了预设配置过程,现在我们可以继续实现语音识别。

实现语音识别

既然我们的原型可以处理不同的预设,我们应该提供一种快速的方法,通过语音识别来更改、创建或编辑用户预设。管理语音识别的最简单方法之一是使用 Android 的Intent消息对象,将此操作委托给另一个应用程序组件。正如我们在本章开头所讨论的,如果我们安装并配置了一个符合要求的语音输入应用程序,Android 可以使用它进行语音识别。

主要目标是提供一个抽象类,供我们的活动扩展以管理识别回调,同时避免代码重复。整体设计如下:

  • 我们应该为需要语音识别的活动提供一个通用接口。

  • 我们应该提供一个startRecognition()方法,通过Intent对象启动识别活动。

  • 我们应该实现onActivityResult()回调,当启动的活动完成语音识别时将调用此回调。在这个回调中,我们使用在语音识别过程中产生的所有结果中最好的一个。

    注意

    作业委托是 Android 操作系统最有用的功能之一。如果你需要更多信息了解它的工作原理,请查看 Android 官方文档 developer.android.com/guide/components/intents-filters.html

以下步骤可以实现重用语音识别能力的先前抽象:

  1. chronotherm包中添加IRecognitionListener接口,定义onRecognitionDone()回调,用于将结果发送回调用活动。我们可以通过以下代码实现这一点:

    public interface IRecognitionListener {
      void onRecognitionDone(int requestCode, String bestMatch);
    }
    
  2. 创建一个名为voice的新包,并添加一个名为RecognizerActivity的新抽象类。该类应定义如下:

    public abstract class RecognizerActivity extends ActionBarActivity implements IRecognitionListener {
    }
    
  3. 添加一个公共方法来初始化识别阶段,并将获取结果的责任委托给以下代码:

    public void startRecognition(String what, int requestCode) {
      Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
      intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US");
      intent.putExtra(RecognizerIntent.EXTRA_PROMPT, what);
      startActivityForResult(intent, requestCode);
    }
    

    requestCode参数是识别Intent的标识符,由调用活动使用以正确识别结果以及如何处理它。what参数用于提供屏幕消息,如果外部应用程序支持的话。

  4. 添加onActivityResult()回调以提取最佳结果,并通过通用接口将其传递给调用活动:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
      if (resultCode == RESULT_OK) {
        ArrayList<String> matches = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS);
        this.onRecognitionDone(requestCode, matches.get(0));
      }
    }
    

使用语音识别添加或编辑预设

通过RecognizerActivity类,我们将繁重的工作委托给 Android 框架。根据活动的性质,我们应该以不同的方式处理结果。我们在活动创建阶段使用Settings活动开始使用语音输入,询问我们想要创建或编辑的预设名称。如果预设存在,我们应在保存过程中加载存储的设定点并更新它们。否则,我们应在偏好设置中创建新的记录。为了实现这种行为,请执行以下步骤:

  1. 根据以下代码片段,从Settings类扩展RecognizerActivity

    public class Settings extends RecognizerActivity {
      //...
    }
    
  2. 声明我们将用于识别和处理识别结果的意图请求代码。在类的顶部,添加以下高亮代码:

    public class Settings extends RecognizerActivity {
      private static final int VOICE_SETTINGS = 1001;
      private TemperatureBar[] mBars;
      // ...
    }
    
  3. onCreate()回调的底部,添加以下代码以尽快开始语音识别:

    mBars = TemperatureWidget.addTo(this, container, true);
    startRecognition("Choose the preset you want to edit", VOICE_SETTINGS);
    
    
  4. 实现onRecognitionDone()回调,这是之前定义的IRecognitionListener接口所要求的,以处理识别意图返回的结果。在类的底部,添加以下代码:

    @Override
    public void onRecognitionDone(int requestCode, String bestMatch) {
      if (requestCode == VOICE_SETTINGS) {
        boolean result = readPreferences(bestMatch);
        mEditingPreset = bestMatch;
      }
    }
    

    如果识别与VOICE_SETTINGS意图代码相关,则将bestMatch参数传递给readPreferences参数,该参数加载并设置所有带有预设设定点的温度条。设置mEditingPreset变量,以便在保存过程中我们可以重用预设名称。

我们已经对Settings活动做了所有必要的更改,现在可以在Overview活动中使用语音识别来加载和设置激活的预设。

使用语音识别来更改激活的预设

既然用户可以存储不同的预设,我们就必须提供一种在Overview活动中更改激活的设定点的方法。之前,我们添加了一个显示当前预设名称的TextView类;为了保持界面简洁,我们可以使用这个组件来启动语音识别。用户可以通过当前流程更改激活的预设:

  1. 当用户点击TextView选项时,系统应启动语音识别以获取预设名称。

  2. 如果找到了预设,应该用用户选择的预设替换激活的预设,并更新Overview的温度条。

  3. 如果找不到预设,则不应有任何反应。

要实现上述交互流程,请按照以下步骤进行:

  1. 正如我们对Settings活动所做的那样,从Overview类扩展RecognizerActivity类,如下面的代码片段所示:

    public class Overview extends RecognizerActivity implements OnDataChangeListener {
      //...
    }
    
  2. 声明我们将用来识别和处理识别结果的意图请求代码。在类的顶部,添加高亮代码:

    public class Overview extends RecognizerActivity implements OnDataChangeListener {
      public static final int VOICE_PRESET = 1000;
      private AdkManager mAdkManager;
      //...
    }
    
  3. 在类的底部,添加一个方法来启动预设名称识别:

    public void changePreset(View v) {
      startRecognition("Choose the current preset", VOICE_PRESET);
    }
    
  4. 实现onRecognitionDone()回调以处理识别意图返回的结果。在这个方法中,我们调用setPreset()成员函数来更新激活的预设并加载温度设定点,如果找到了给定的预设。在类的底部,添加以下代码:

    @Override
    public void onRecognitionDone(int requestCode, String bestMatch) {
      if (requestCode == VOICE_PRESET) {
        setPreset(bestMatch);
      }
    }
    
  5. 实现setPreset()方法来处理最佳识别结果。在类的底部,添加以下代码:

    private void setPreset(String name) {
      ArrayList<Integer> values = Preset.get(this, name);
      if (values.size() > 0) {
        Preset.setCurrent(this, name);
        readPreferences();
      }
    }
    
  6. 将启动语音识别的changePreset()方法与TextView组件连接起来。在res/layout/下的activity_overview.xml文件中,通过高亮代码使current_preset视图可点击:

    <TextView
      android:id="@+id/current_preset"
      android:clickable="true"
      android:onClick="changePreset"
      android:text="NO PRESET ACTIVATED"
      android:gravity="center"
      android:textColor="@color/coral_red"
      android:textSize="@dimen/text_title"
      android:layout_width="0dp"
      android:layout_weight="2"
      android:layout_height="match_parent" />
    

通过这一节,我们创建了一个抽象层来通过 Android 意图处理语音识别,并且更新了SettingsOverview活动以使用它。现在我们可以上传 Chronotherm 应用程序,并再次使用带有预设和语音识别功能的应用程序。

改进用户与语音合成的交互

即使 Chronotherm 应用程序工作正常,我们至少还有一件事要做:提供适当的反馈,让用户知道已采取的行动。实际上,这两个活动都没有提供关于识别输入的任何视觉反馈;因此,我们决定在初始设计中引入语音合成 API。

因为我们希望在不同的活动中共享合成过程,我们可以创建一个管理器,通过共同的初始化抽象合成 API。这个想法是提供一个类,它公开了一个方法,使用给定的字符串开始语音识别;我们按照以下步骤实现它:

  1. voice包内创建VoiceManager类。

  2. 使用以下代码初始化类:

    public class VoiceManager implements TextToSpeech.OnInitListener {
      private TextToSpeech mTts;
      //...
    }
    

    这个类实现了OnInitListener接口,该接口定义了在初始化TextToSpeech引擎后应调用的回调。我们存储当前的TextToSpeech实例,我们将在以下代码段中使用它作为一个变量。

  3. 重写onInit()方法,使其在TextToSpeech实例服务初始化成功时设置美国地区:

    @Override
    public void onInit(int status) {
      if (status == TextToSpeech.SUCCESS) {
        mTts.setLanguage(Locale.US); 
      }
    }
    
  4. 添加类构造函数,在其中使用给定的活动Context初始化文本转语音服务。在类内部,编写以下代码:

    public VoiceManager(Context ctx) {
      mTts = new TextToSpeech(ctx, this);
    }
    
  5. 实现一个speak()方法,通过在类底部添加以下代码,将给定文本代理给TextToSpeech实例:

    public void speak(String textToSay) {
      mTts.speak(textToSay, TextToSpeech.QUEUE_ADD, null);
    }
    

    TextToSpeech.speak方法采用队列策略使其异步化。调用该方法时,合成请求会被添加到队列中,并在服务初始化后进行处理。队列模式可以作为 speak 方法的第二个参数进行定义。我们可以在以下链接找到关于文本转语音服务的更多信息:

    developer.android.com/reference/android/speech/tts/TextToSpeech.html

向用户提供反馈

我们现在应该调整我们的活动以使用前面类中实现的简单抽象。我们从Overview活动开始,初始化VoiceManager实例,并在setPreset()方法中使用它,以提供是否找到识别的预设的正确反馈。要在Overview活动中使用合成 API,请执行以下步骤:

  1. 在类顶部,在变量声明之间添加高亮显示的代码:

    private DataReader mReader;
    private VoiceManager mVoice;
    
    
  2. onCreate()回调的底部,按以下代码片段所示初始化VoiceManager实例:

    mReader = new DataReader(mAdkManager, this, this);
    mVoice = new VoiceManager(this);
    
    
  3. 使用高亮显示的代码更新setPreset()方法,使其在预设激活期间调用合成 API 以提供反馈:

    private void setPreset(String name) {
      ArrayList<Integer> values = Preset.get(this, name);
      String textToSay;
      if (values.size() > 0) {
        Preset.setCurrent(this, name);
        readPreferences();
        textToSay = "Activated preset " + name;
      }
      else {
        textToSay = "Preset " + name + " not found!";
      }
      mVoice.speak(textToSay);
    }
    

原型几乎完成,我们只需要对Settings活动重复前面的步骤。在这个活动中,我们应该初始化VoiceManager参数,并在onRecognitionDone()回调中使用合成 API。在那里,我们应该告知用户识别的预设是什么,以及根据检索到的设定点,它是将被创建还是编辑。要在Settings活动中使用合成 API,请执行以下步骤:

  1. 在类的顶部,按照高亮代码声明VoiceManager变量:

    private String mEditingPreset;
    private VoiceManager mVoice;
    
    
  2. onCreate()回调的底部,初始化VoiceManager实例:

    mVoice = new VoiceManager(this);
    startRecognition("Choose the preset you want to edit", VOICE_SETTINGS);
    
  3. 更新onRecognitionDone()回调,使其调用合成 API 以提供适当的反馈:

    @Override
    public void onRecognitionDone(int requestCode, String bestMatch) {
      if (requestCode == VOICE_SETTINGS) {
        String textToSay;
        boolean result = readPreferences(bestMatch);
        if (result) {
          textToSay = "Editing preset " + bestMatch;
        }
     else {
          textToSay = "Creating preset " + bestMatch;
        }
        mEditingPreset = bestMatch;
        mVoice.speak(textToSay);
      }
    }
    

我们已经完成了对原型的增强,加入了语音识别和合成功能。最后缺失的任务是再次上传应用程序,并检查一切是否如预期般工作。然后我们可以将 Chronotherm 应用程序在app/build.gradle文件中更新为0.2.0版本。

总结

在本章中,我们通过少量工作成功引入了许多功能。我们学会了如何利用语音识别和合成,制作一个精简且快速的用户界面。

我们开始了一段旅程,创造了一种新的存储用户预设的方法,这需要对活动和SensorThread温度检查进行重构。我们继续进行语音识别的第一个实现,并且为了简化我们的工作,我们创建了一个从SettingsOverview活动扩展的通用活动类。这使得我们能够抽象出一些常见行为,便于在不同的代码部分调用识别意图。

作为最后一步,我们准备了语音合成管理器,以便轻松使用 Android 的文本到语音引擎。实际上,我们使用这个组件在识别过程后,当用户更改设置和当前激活的预设时提供反馈。

在下一章中,我们将为 Chronotherm 应用程序添加网络功能,以便它能够检索天气预报数据;使用这些信息,我们将制作一个稍微更好的算法来决定是否打开或关闭我们的锅炉。

第八章:添加网络功能

在第六章,为家庭自动化构建 Chronotherm中,我们探讨了家庭自动化的定义,并且一步一步地构建了一个可以根据用户偏好程序化控制锅炉的原型。我们扩展了这个原型,提供了一个预设配置以存储不同的温度计划,并通过语音识别和合成改善了用户交互。

这一次,我们通过添加另一个利用网络功能从互联网收集数据的功能来增强 Chronotherm 应用程序。本章的目标是使我们的原型能够对无法通过连接的传感器轻松捕获的外部事件做出反应。

在本章中,我们将涵盖以下主题:

  • 使用网络功能扩展 Chronotherm 应用程序

  • 使用网络服务收集天气预报数据

  • 使用收集的数据改变 Chronotherm 的行为

为 Chronotherm 扩展网络功能

Chronotherm 应用程序解决了一个具体问题。它每天在当前温度低于每个小时的配置设定点时开启锅炉。这个逻辑对于传统 Chronotherm 来说已经足够,但我们可以改进这种行为,使其考虑家庭温度与天气条件之间的密切关系。例如,在寒冷的日子里,内部温度通常下降得更快;如果我们在锅炉逻辑中包含这些信息,我们可以使我们的原型更加智能。

此外,如果天气真的很冷,我们的锅炉可能会因为内部的水结冰而停止工作。如果实现一个防冻功能,当外部温度降至一个定义值以下时,即使违背用户偏好,也会启动锅炉,这个问题就可以得到解决。这样的功能将处理用户不在家或夜间时的意外情况。

不幸的是,连接外部传感器并不容易,而且构建和使用无线热传感器可能过于复杂。然而,考虑到外部温度的重要性,我们必须找到一种方法来收集天气条件数据。由于 UDOOU Chronotherm 位于我们的家中,且很可能连接到互联网,我们可以从提供预报数据的网络服务中获取这些信息,在我们的计算中使用这些知识。这样,我们甚至可以添加完整的天气条件概览,在提供用户有用信息的同时改善用户界面。

根据之前提到的需求,我们可以按以下步骤组织我们的工作:

  1. 实现一个模块,用于将我们的原型连接到天气预报的 REST API。

  2. 定期收集并显示天气预报数据。

  3. 编写将使用先前数据的锅炉防冻逻辑。

连接到 REST API

我们的工作从提供一个实现开始,以连接到 RESTful 网络服务。REpresentational State Transfer (REST) 是一种通常运行在 HTTP 协议之上的简单无状态架构风格。REST 背后的理念涉及将系统的状态作为我们可以操作的资源集合暴露出来,通过它们的名称或 ID 来定位它们。后端服务负责通过通常使用数据库服务器来持久化资源数据。

当客户端通过 HTTP 协议请求资源时,应用服务器从数据库服务器检索资源并发送回客户端,使用如 XML 或 JSON 的交换格式。暴露 REST API 可以极其容易地向移动客户端、浏览器扩展或任何需要访问和处理应用程序数据的软件提供数据。

在本章中,我们将仅使用 REST API 进行信息检索。如果您对 REST 架构有更多兴趣,请点击此链接 en.wikipedia.org/wiki/Representational_state_transfer

在开始实现 API 连接器之前,我们应在 AndroidManifest.xml 文件中的 <application> 标签之前添加以下权限(以便在我们的应用程序中使用互联网):

<uses-permission android:name="android.permission.INTERNET" />

然后,为了使我们的应用程序具备网络功能,我们必须创建一个对 HttpURLConnection 类的抽象,以便我们可以通过更简单的 API 使用外部服务。要为我们的应用程序创建一个连接器,请执行以下步骤:

  1. 在名为 http 的新包中创建 UrlConnector 类。

  2. 在类的顶部,添加以下声明以存储 HttpURLConnection 类实例:

    private HttpURLConnection mConnector;
    
  3. 添加以下构造函数,我们将使用它来初始化请求参数:

    public UrlConnector(String encodedUrl) throws IOException {
      URL url = new URL(encodedUrl);
      mConnector = (HttpURLConnection) url.openConnection();
      mConnector.setReadTimeout(10000);
      mConnector.setConnectTimeout(15000);
    }
    

    我们期望一个 encodedUrl 参数作为参数,并使用它来初始化稍后用于打开连接的 URL 对象。然后,我们为读取和连接阶段设置超时,使用适合我们原型的值。

  4. 添加一个泛型方法来设置我们请求的 HTTP 头:

    public void addHeader(String header, String content) {
      mConnector.setRequestProperty(header, content);
    }
    
  5. get() 方法下面添加以下代码片段,用于进行调用:

    public int get() throws IOException {
      mConnector.setRequestMethod("GET");
      return mConnector.getResponseCode();
    }
    

    对于 mConnector 实例,我们设置 GET 请求方法,返回响应的状态码。此状态码将用于检查请求是否成功或失败结束。

  6. 添加以下 getResponse() 方法以从网络服务器连接获取结果:

    public String getResponse() throws IOException {
      BufferedReader readerBuffer = new BufferedReader(new InputStreamReader(mConnector.getInputStream()));
      StringBuilder response = new StringBuilder();
      String line;
      while ((line = readerBuffer.readLine()) != null) {
        response.append(line);
      }
      return response.toString();
    }
    

    我们使用 mConnector 实例的输入流创建一个缓冲阅读器,然后通过上述阅读器获取服务器发送的内容。当我们完成后,我们不进行任何修改直接返回字符串。

  7. 创建一个 disconnect() 方法以关闭与服务器连接:

    public void disconnect() {
      mConnector.disconnect();
    }
    

UrlConnector 类简化了 HTTP 调用,这种实现足以连接到许多不使用任何认证流程的 Web 服务。在我们继续之前,我们必须选择一个提供天气预报数据的 Web 服务,我们将对其进行查询。为了我们原型的目的,我们将使用 OpenWeatherMap 服务,因为它提供了一个无需认证流程的免费层级,并且它也通过 REST API 提供。你可以在 openweathermap.org/openweathermap.org/current 了解更多关于该服务的信息,以及学习它们的 REST API 是如何构建的:

当我们调用上述 RESTful 服务时,我们应该解析 JSON 响应,使其在我们的应用程序中可用。这种方法可以通过一个知道响应结构并按照我们的需求进行解析的 Java 类来实现。实现需要以下步骤:

  1. 在名为 weather 的新包中创建 Weather 类。

  2. 在类的顶部,添加以下声明:

    private String mStatus;
    private double mTemperature;
    private int mHumidity;
    

    我们根据需要从给定的响应中声明变量。在我们的例子中,我们使用 mStatus 变量来存储天气状况,以便用户知道天气是晴朗还是多云。我们还使用 mTemperature 变量,这是我们第一个需求,以及 mHumidity 属性为用户提供额外信息。

  3. 添加如下类构造函数:

    public Weather(JSONObject apiResults) throws JSONException, NullPointerException {
      mStatus = apiResults.getJSONArray("weather").getJSONObject(0).getString("description");
      mTemperature = convertTempKtoC(apiResults.getJSONObject("main").getDouble("temp"));
      mHumidity = apiResults.getJSONObject("main").getInt("humidity");
    }
    

    我们期望作为参数的 JSONObject 参数,是成功调用后的 API 结果。从这个对象中,我们获取 weather 字段的第一元素,并在 JSONObject 对象中获取 description 键的值。然后我们从 main 字段获取 temperature 变量的值;这应该传递给 convertTempKtoC() 函数,因为服务返回的值是以开尔文为单位的。最后一步是从同一个字段获取 humidity 参数。这段代码在 JSON 解析期间可能会引发一些异常,因此,如果构造函数抛出列表,我们会添加这些异常。

  4. 添加 convertTempKtoC() 成员函数,该函数在构造函数中使用,用于将开尔文转换为摄氏度:

    private double convertTempKtoC(double temperature) {
      return temperature - 273.15;
    }
    

    注意

    这只是一个示例;你可以使用你喜欢的任何温度单位。

  5. Weather 类添加以下获取器,以获取实例数据:

    public String getStatus() {
      return mStatus;
    }
    
    public double getTemperature() {
      return mTemperature;
    }
    
    public int getHumidity() {
      return mHumidity;
    }
    

既然我们已经有一个抽象的 HTTP 调用和 JSON 结果解析器,我们需要实现最后一个调用 REST API 并返回 Weather 实例的构建块。我们可以通过以下步骤实现这个实现:

  1. weather 包内创建 WeatherApi 类。

  2. 在类的顶部,声明以下变量:

    private static final String BASE_URL = "http://api.openweathermap.org/data/2.5/weather";
    private static final String API_PARAM = "?q=%s&lang=%s";
    

    BASE_URL属性定义了我们调用以获取天气数据的端点。API_PARAM属性定义了使用的查询字符串,其中q参数是我们想要查询的位置,而lang参数要求服务器为给定的地区翻译结果。

  3. 定义一个static方法以生成有效的请求 URL:

    private static String getUrl(String location) {
      String params = String.format(API_PARAM, location, Locale.US);
      return BASE_URL + params;
    }
    

    此方法期望一个location参数,与有效的位置一起生成params字符串。这样,它设置qlang参数,然后返回与适当连接的BASE_URL属性。

  4. 添加静态方法以进行 API 调用并返回Weather类的一个实例:

    public static Weather getForecast(String location) {
      JSONObject results = null;
      Weather weather = null;
      UrlConnector api;
      try {
        api = new UrlConnector(getUrl(location));
        api.addHeader("Content-Type", "application/json");
        // Do GET and grab tweets into a JSONArray
        int statusCode = api.get();
        if (statusCode == HttpURLConnection.HTTP_OK) {
          results = new JSONObject(api.getResponse());
          weather = new Weather(results);
        }
        else {
          // manage 30x, 40x, and 50x status codes
        }
        api.disconnect();
      }
      catch (IOException e) {
        // manage network errors
      }
      catch (JSONException e) {
        // manage response parsing errors
      }
      return weather;
    }
    

    此方法期望传入location参数,该参数被传递到我们之前看到的getUrl()方法,以生成应该查询的端点。通过addHeader()方法,我们将请求媒体类型定义为application/json参数,服务器使用它来推断我们请求的格式。我们使用正确配置了端点的api实例进行 HTTP 调用,检查状态码以确认成功。调用后,我们关闭连接,如果引发异常,则返回初始化的Weather实例或null引用。

    提示

    在本节中,我们将处理不同的状态码,IOException异常和JSONException异常,这些异常分别在 API 调用未成功完成、发生网络错误或 API 调用响应解析错误时引发。每次在您的原型中处理异常时,请记住错误绝不应该默默传递。我们应该始终处理这些错误,并通过适当的反馈通知用户问题。

扩展 Android 用户界面

既然我们可以通过WeatherApi类收集天气预报数据,我们应该开始考虑用户交互。首先,我们应该询问用户的家庭位置,使用当前选定的位置和相关天气条件更新 Chronotherm 用户界面。其次,我们应该提供一个组件来设置一个防冻设定点,根据用户的偏好,该设定点可以被启用或禁用。

为了实现这两种交互,我们可以使用一个可点击的TextView对象,根据用户输入启动语音识别,正如我们在第七章《使用 Android API 进行人机交互》中所做的那样。所有必需的组件在以下模拟中都有总结:

扩展 Android 用户界面

第一步是更新Overview参数布局。按照前面的建议,我们应该:

  • 添加天气预报 TextView:每当短时线程使用WeatherApi类加载Weather实例时,此组件会发生变化。在这种情况下,它会显示当前位置、天气状况、温度和湿度。当用户点击此组件时,我们应该启动语音识别意图来获取用户的位置。

  • 添加防冻 TextView:当启用防冻功能时,此组件以绿色显示当前的防冻设定点;另一方面,当用户禁用防冻检查时,它会变成灰色。当用户点击此组件时,我们应该启动语音识别意图来获取用户的防冻设定点;如果启用了防冻,应该从用户的偏好设置中移除设定点。

我们开始处理可以实现的布局,更新res/layout/下的activity_overview.xml文件和Overview类,如下面的步骤所示:

  1. 更改包含view_containertemperature视图的LinearLayout,使用以下高亮代码:

    <LinearLayout
      android:orientation="horizontal"
      android:gravity="center"
      android:layout_width="match_parent"
      android:layout_height="0dp"
      android:layout_weight="1">
    
  2. 在之前的LinearLayout下方,添加以下布局,其中将包含两个TextViews

    <LinearLayout
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="0dp"
      android:layout_weight="0.2">
    </LinearLayout>
    
  3. 在之前的容器中,使用以下代码添加防冻天气预报 TextViews

    <TextView
      android:id="@+id/weather_antifreeze"
      android:clickable="true"
      android:onClick="changeAntifreeze"
      android:text="ANTIFREEZE: OFF"
      android:textColor="@color/mine_shaft"
      android:textSize="@dimen/text_title"
      android:layout_width="wrap_content"
      android:layout_height="match_parent"/>
    
    <TextView
      android:id="@+id/weather_status"
      android:clickable="true"
      android:onClick="changeLocation"
      android:text="NO LOCATION SET"
      android:textSize="@dimen/text_title"
      android:gravity="end"
      android:layout_height="match_parent"
      android:layout_width="0dp"
      android:layout_weight="1"/>
    

    在这两个组件中,我们定义了调用changeAntifreezechangeLocation方法的onClick属性。这些成员函数实现了之前描述的交互,我们将在下一节继续实现它们。

  4. 现在,我们应该继续处理Overview活动,实现更新两个TextViews的缺失代码。首先,在Overview类的顶部声明它们的引用:

    private TextView mCurrentPreset;
    private TextView mTemperature;
    private TextView mStatus;
    private TextView mWeatherStatus;
    private TextView mAntifreeze;
    
    
  5. onCreate()活动方法中,用高亮代码获取这两个引用:

    setContentView(R.layout.activity_overview);
    mCurrentPreset = (TextView) findViewById(R.id.current_preset);
    mTemperature = (TextView) findViewById(R.id.temperature);
    mStatus = (TextView) findViewById(R.id.boiler_status);
    mWeatherStatus = (TextView) findViewById(R.id.weather_status);
    mAntifreeze = (TextView) findViewById(R.id.weather_antifreeze);
    
    
  6. 因为短时线程应该更新mWeatherStatus TextView参数,我们必须在OnDataChangeListener参数接口中提供一个回调,该接口期待一个Weather实例。在OnDataChangeListener参数接口中添加高亮的方法:

    public interface OnDataChangeListener {
      void onTemperatureChanged(float temperature);
      void onBoilerChanged(boolean status);
      void onWeatherChanged(Weather weather);
    }
    
  7. 作为最后一步,在Overview类的底部添加以下代码,实现onWeatherChanged()接口:

    @Override
    public void onWeatherChanged(Weather weather) {
      if (weather != null && weather.getStatus() != null) {
        String status = "%s: %s, %.1f° (%d%%)";
        status = String.format(status,
          Preset.getLocation(this).toUpperCase(),
          weather.getStatus().toUpperCase(),
          weather.getTemperature(),
          weather.getHumidity()
        );
        mWeatherStatus.setText(status);
      }
    else {
        mWeatherStatus.setText("NO LOCATION SET");
      }
    }
    

    如我们之前所讨论的,如果我们有一个weather实例,我们会用格式化的字符串更新mWeatherStatus属性,显示当前位置、天气状况、温度和湿度。

通过前面的更改,我们可以上传我们的 Chronotherm 应用程序。它的展示效果如下截图所示:

扩展 Android 用户界面

收集天气预报数据。

现在我们的应用程序用户界面已完成,我们可以继续实现存储用户位置并从 RESTful 网络服务获取天气数据的逻辑。此实现可以按照以下步骤组织:

  1. 更新Preset类以存储用户的位置。

  2. 当用户点击weather_status TextView参数时,处理语音识别结果。

  3. 添加一个新的计划线程,获取天气数据并使用onWeatherChanged()回调更新用户界面。

我们开始更新Preset类,并按照以下步骤实现它:

  1. 在类的顶部,添加高亮声明,用作存储和检索用户设置位置的键:

    private static final String CURRENT_PRESET = "__CURRENT__";
    private static final String CURRENT_LOCATION = "__LOCATION__";
    
    
  2. 在类的底部,添加以下 setter 以存储给定的位置:

    public static void setLocation(Context ctx, String name) {
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      SharedPreferences.Editor editor = sharedPref.edit();
      editor.putString(CURRENT_LOCATION, name);
      editor.apply();
    }
    
  3. 要检索存储的值,请添加以下 getter:

    public static String getLocation(Context ctx) {
      String location;
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      location = sharedPref.getString(CURRENT_LOCATION, null);
      return location;
    }
    

    通过CURRENT_LOCATION键,我们检索存储的位置,如果未设置,则返回null值。这样,我们涵盖了未设置位置时的首次运行,防止了任何无用的 API 调用。

现在我们可以继续更新应用程序的交互,通过语音识别来更改当前存储的位置。要完成这一步,请进行以下更改:

  1. Overview类的顶部,添加高亮声明以定义新Intent结果的请求代码,该结果将请求用户的位置:

    public static final int VOICE_PRESET = 1000;
    public static final int VOICE_LOCATION = 1002;
    
    
  2. 实现weather_status可点击视图使用的changeLocation()方法:

    public void changeLocation(View v) {
      startRecognition("Provide your location", VOICE_LOCATION);
    }
    
  3. 实现一个成员函数,使用Preset类来设置当前位置,并为用户提供适当的反馈:

    private void setLocation(String location) {
      Preset.setLocation(this, location);
      mWeatherStatus.setText(location.toUpperCase() + ": WAITING DATA");
      mVoice.speak("Loading forecast data for " + location);
    }
    

    在应用程序的共享偏好设置中存储当前位置后,我们使用占位符消息更新weather_status视图,直到计划线程获取天气条件。

  4. onRecognitionDone()回调中添加高亮代码,将bestMatch参数传递给上一个方法:

    if (requestCode == VOICE_PRESET) {
      setPreset(bestMatch);
    }
    else if (requestCode == VOICE_LOCATION) {
      setLocation(bestMatch);
    }
    
    

我们缺少的最后一个构建块是通过新的计划线程定期收集和显示天气预报数据。这最后一部分可以通过以下步骤更新DataReader类来实现:

  1. 在类的顶部,添加高亮声明:

    private final static int TEMPERATURE_POLLING = 1000;
    private final static int WEATHER_POLLING = 5000;
    private final static int TEMPERATURE_UPDATED = 0;
    private final static int BOILER_UPDATED = 1;
    private final static int WEATHER_UPDATED = 2;
    private AdkManager mAdkManager;
    private Context mContext;
    private OnDataChangeListener mCaller;
    private ScheduledExecutorService mSchedulerSensor;
    private ScheduledExecutorService mSchedulerWeather;
    private Handler mMainLoop;
    private boolean mBoilerStatus = false;
    private Weather mWeather = null;
    
    

    提示

    在前面的代码片段中,我们将天气线程轮询时间设置为 5 秒,但我们必须考虑到外部温度永远不会变化得这么快,因此创建太多对网络服务的查询是没有用的。我们仅为了测试目的选择了这个值;当原型准备好时,我们将需要设置更合理的时序。

  2. 在类的底部,添加以下Runnable实现,用于收集天气数据并将Weather实例发布到主线程:

    private class WeatherThread implements Runnable {
      @Override
      public void run() {
        String location = Preset.getLocation(mContext);
        if (location != null) {
          mWeather = WeatherApi.getForecast(location);
          Message message = mMainLoop.obtainMessage(WEATHER_UPDATED, mWeather);
          message.sendToTarget();
        }
      }
    }
    
  3. start()方法中添加新的调度器初始化,为天气数据获取生成短生命周期的线程,如高亮代码所示:

    public void start() {
      // Start thread that listens to ADK
      SensorThread sensor = new SensorThread();
      mSchedulerSensor = Executors.newSingleThreadScheduledExecutor();
      mSchedulerSensor.scheduleAtFixedRate(sensor, 0, TEMPERATURE_POLLING, TimeUnit.MILLISECONDS);
      // Start thread that updates weather forecast
      WeatherThread weather = new WeatherThread();
      mSchedulerWeather = Executors.  newSingleThreadScheduledExecutor();
      mSchedulerWeather.scheduleAtFixedRate(weather, 0,   WEATHER_POLLING, TimeUnit.MILLISECONDS);
    }
    
  4. 停止之前的调度器,使用以下代码更改stop()方法:

    public void stop() {
      mSchedulerSensor.shutdown();
      mSchedulerWeather.shutdown();
    }
    
  5. 更新主线程处理器,根据消息类型将Weather实例传递给适当的回调:

    case BOILER_UPDATED:
      mCaller.onBoilerChanged((boolean) message.obj);
      break;
    case WEATHER_UPDATED:
      mCaller.onWeatherChanged((Weather) message.obj);
      break;
    
    

现在我们有一个能够定期收集和显示天气数据的原型,我们可以将应用程序上传到 UDOOboard。当我们点击天气状态视图并通过语音识别输入我们的位置后,应用程序应该使用当前天气条件更新Overview界面。下一步是改进锅炉点火检查,添加防冻功能。

改进带有防冻检查的锅炉

既然天气预报已经可以获取并运行,我们可以继续实现防冻功能。为了实现之前讨论的交互和逻辑,我们应当:

  1. 加强Preset类,存储用户的防冻设定点。在这个类中,我们应该提供两个实用工具来禁用防冻检查以及判断功能是否启用。

  2. Overview活动中处理防冻功能,在选择设定点时更新用户界面。

  3. 更新SensorThread类中的锅炉逻辑,以便在启用防冻检查时考虑在内。

我们通过以下步骤开始工作,更改Preset类:

  1. 在类顶部,添加高亮声明:

    private static final String CURRENT_LOCATION = "__LOCATION__";
    private static final String CURRENT_ANTIFREEZE = "__ANTIFREEZE__";
    private static final float ANTIFREEZE_DISABLED = -Float.MAX_VALUE
    
    

    我们使用ANTIFREEZE_DISABLED属性作为一个不可能达到的默认温度。这样,我们可以匹配这个变量以判断防冻功能是否激活。

  2. 在类底部添加以下 setter 来存储防冻设定点:

    public static void setAntifreeze(Context ctx, float temperature) {
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      SharedPreferences.Editor editor = sharedPref.edit();
      editor.putFloat(CURRENT_ANTIFREEZE, temperature);
      editor.apply();
    }
    
  3. 与前一个方法保持一致,添加以下 getter 来检索防冻设定点:

    public static float getAntifreeze(Context ctx) {
      float temperature;
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      temperature = sharedPref.getFloat(CURRENT_ANTIFREEZE, ANTIFREEZE_DISABLED);
      return temperature;
    }
    

    采用这种方法,我们返回CURRENT_ANTIFREEZE键的值,如果未设置则返回ANTIFREEZE_DISABLED属性。

  4. 添加以下方法来移除防冻设定点:

    public static void disableAntifreeze(Context ctx) {
      SharedPreferences sharedPref = ctx.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE);
      SharedPreferences.Editor editor = sharedPref.edit();
      editor.remove(CURRENT_ANTIFREEZE);
      editor.apply();
    }
    
  5. 添加以下实用程序,如果启用了防冻功能,则返回值:

    public static boolean antifreezeIsEnabled(Context ctx) {
      return getAntifreeze(ctx) != ANTIFREEZE_DISABLED;
    }
    

在下一步中,我们应在Overview活动中添加防冻功能,提供所有更新用户界面同时通过语音识别处理用户输入的方法。实现该功能需要以下步骤:

  1. Overview类的顶部,添加mFreeze布尔值,指出当前是否激活了防冻检查:

    private TextView mWeatherStatus;
    private TextView mAntifreeze;
    private boolean mFreeze = false;
    
    
  2. 在类底部,添加以下用于更新Overview布局的方法:

    public void updateAntifreeze() {
      float freezeTemperature = Preset.getAntifreeze(this);
      mFreeze = Preset.antifreezeIsEnabled(this);
      if (mFreeze) {
        String status = "ANTIFREEZE: %.1f °C";
        status = String.format(status, freezeTemperature);
        mAntifreeze.setText(status);
        mAntifreeze.setTextColor(getResources().getColor(R.color.pistachio));
      }
      else {
        mAntifreeze.setText("ANTIFREEZE: OFF");
        mAntifreeze.setTextColor(getResources().getColor(R.color.mine_shaft));
      }
    }
    

    作为第一步,我们从共享偏好设置中获取防冻温度,通过antifreezeIsEnabled()方法设置mFreeze布尔值。在这一点上,如果启用了防冻功能,我们会显示一个带有给定设定点的绿色信息;否则,我们会显示一个灰色信息,表明该功能已禁用。

  3. readPreferences()成员函数的底部调用updateAntifreeze()方法,如高亮代码所示:

      // ...
      mCurrentPreset.setText(activatedPreset.toUpperCase());
      updateAntifreeze();
    }
    

既然我们已经有一个与存储的防冻设定点一起工作的布局,我们应该为用户提供语音识别和合成功能,以激活或关闭防冻检查。要实现这一实现,需要执行以下步骤:

  1. Overview类的顶部,添加高亮的Intent请求码:

    public static final int VOICE_LOCATION = 1002;
    public static final int VOICE_ANTIFREEZE = 1003;
    
    
  2. 添加changeAntifreeze()方法,以在用户点击weather_antifreeze视图时启用或禁用该功能:

    public void changeAntifreeze(View v) {
      if (mFreeze) {
        Preset.disableFreezeAlarm(this);
        updateAntifreeze();
        mVoice.speak("Antifreeze disabled");
      }
      else {
        startRecognition("Provide antifreeze degrees", VOICE_ANTIFREEZE);
      }
    }
    
  3. 实现成员函数以启用并存储防冻设定点:

    private void enableAntifreeze(float temperature) {
      Preset.setAntifreeze(this, temperature);
      updateAntifreeze();
      mVoice.speak("Antifreeze set to " + temperature + " degrees");
    }
    
  4. 将高亮代码添加到onRecognitionDone()回调中,将bestMatch属性传递给前面的方法:

    if (requestCode == VOICE_PRESET) {
      setPreset(bestMatch);
    }
    else if (requestCode == VOICE_LOCATION) {
      setLocation(bestMatch);
    }
    else if (requestCode == VOICE_ANTIFREEZE) {
      try {
        float temperature = Float.parseFloat(bestMatch);
        enableAntifreeze(temperature);
      }
      catch (NumberFormatException e) {
        mVoice.speak("Unrecognized number, " + bestMatch);
      }
    }
    
    

    如果识别意图与VOICE_ANTIFREEZE请求码相关,我们会尝试将bestMatch参数解析为浮点数,并将值传递给enableAntifreeze()方法。如果浮点解析失败,我们会通过语音合成提供适当的反馈。

Chronotherm 原型几乎完成;剩下的唯一任务是使用防冻功能改进锅炉逻辑。在DataReader类中,我们应在isBelowSetpoint()方法中添加以下高亮代码,使SensorThread类能够了解防冻设定点,如下所示:

private boolean isBelowSetpoint(float temperature) {
  int currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
  String currentPreset = Preset.getCurrent(mContext);
  ArrayList<Integer> currentValues = Preset.get(mContext, currentPreset);
  float antifreeze = Preset.getAntifreeze(mContext);
  if (mWeather != null && mWeather.getTemperature() < antifreeze) {
    return true;
  }
  if (currentValues.size() > 0) {
    return temperature < currentValues.get(currentHour);
  } else {
    return false;
  }
}

使用此代码,如果外部温度低于存储的防冻设定点,锅炉将不管用户的偏好而开启。如果此条件不发生,将继续默认行为。

原型已完成;通过天气预报数据,它保持了我们的房屋温暖,同时也消除了由于温度过低导致锅炉损坏的风险。我们可以上传应用程序,然后我们可以设置防冻温度。以下屏幕截图显示了预期的结果:

通过防冻检查改进锅炉

既然原型已经完成,我们可以在app/build.gradle文件中将 Chronotherm 应用程序更新为0.3.0版本。

概述

在本章中,我们了解到互联网对我们的设备有多么重要,这得益于它的大量数据和服务。我们发现,通过使用外部温度,我们的原型可以得到改进,而且在不改变电路的情况下,通过网络收集天气条件。

在第一部分,我们编写了一个通用连接器,这样我们可以不用做太多工作就能发出 HTTP 调用。然后我们使用这个组件实现了一个 RESTful 网络服务的部分抽象,能够获取给定位置的当前天气。我们在 Chronotherm 布局中添加了新元素以显示天气预报数据,并通过语音识别处理位置输入。

最后,我们决定将外部温度整合到我们的锅炉逻辑中。实际上,我们实现了防冻功能,当外部温度过低时,无论用户的偏好如何,都会开启锅炉。

这个原型是本书最后一次探讨 UDOO 板与 Android 操作系统提供的众多功能。然而,如果你对 Chronotherm 应用程序还有进一步的改进兴趣,你可以深入研究附加章节,第九章使用 MQTT 监控你的设备,它介绍了物联网的主要概念和MQTT 协议,这些协议用于物理设备之间的数据交换。即使你的下一个项目使用的是另一个原型板或技术,我也希望你能找到有用的建议,并且享受我们一起完成的构建简单但互动的设备的工作。

posted @ 2024-05-23 11:06  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报