原文:
zh.annas-archive.org/md5/4AF381CD21F1B858B50BF52774AC99BB译者:飞龙
第六章:为智能家居构建 Chronotherm 电路
几十年来,控制家庭设备如灯光、恒温器和电器已经变得可能,甚至简单,通过自动和远程控制。一方面,这些自动化设备节省了人力和能源,但另一方面,即使是微小的调整对最终用户来说也不方便,因为他们需要对系统有很好的了解才能进行任何更改。
在过去几年中,由于缺乏标准或易于定制的解决方案,人们不愿采用智能家居技术。如今,情况正在发生变化,UDOO 等原型开发板在设计及构建DIY(自己动手做)自动化设备时发挥着重要作用。更妙的是,由于开源项目,这些平台易于扩展,并且可以被不同的设备控制,如个人电脑上的网络浏览器、手机和平板电脑。
在本章中,我们将涵盖以下主题:
-
探索智能家居的优势
-
构建一个 chronotherm 电路
-
发送数据与接收指令
-
编写 Chronotherm 安卓应用程序
智能家居
“智能家居”这个词相当通用,可能有多种不同的含义:控制环境灯光的定时器,响应来自外部的各种事件做出动作的智能系统,或者负责完成重复任务的编程设备。
这些都是智能家居的有效示例,因为它们共享同一个关键概念,使我们即使不在家也能管理家务和活动。智能家居设备通常在公共或私人网络上运行,以相互通信,以及与其他类型的设备如智能手机或平板电脑进行通信,接收指令或交换它们的状态信息。但当我们需要自动化简单的电器或电子元件,如灯泡时,该怎么办?解决这个问题的常见方法是通过开发一种控制系统设备,物理连接到我们想要管理的电器上;由于控制系统是一种智能家居设备,我们可以使用它来驱动它所连接的每个电器的行为。
如果我们在智能家居领域积累足够的经验,我们有可能开发并构建一个高端系统,用于我们自己的房子,这个系统足够灵活,可以轻松扩展,而不需要进一步的知识。
构建一个 chronotherm 电路
温控器主要由一个控制单元组成,负责检查环境温度是否低于预配置的设定点,如果是,则打开锅炉加热房间。这种行为很简单,但没有进一步的逻辑就不太有用。实际上,我们可以通过向温控器逻辑中添加时间参数来扩展此行为。这样,用户可以为每天每小时定义一个温度设定点,使温度检查更加智能。
注意
在这个原型中,控制单元是板载 Arduino,这是一个简化整体设计的实现细节。
这就是传统温控器的工作原理,为了实现它,我们应该:
-
构建带有温度传感器的电路
-
实现微控制器逻辑,以检查用户的设定点与当前温度
不幸的是,第二部分并不容易,因为用户的设定点应该存储在微控制器中,因此我们可以将这项任务委托给我们的安卓应用程序,通过在 microSD 卡中保存设置来实现。这种方法以下列方式解耦责任:
-
Arduino 草图:
-
从温度传感器收集数据
-
将检测到的温度发送到安卓
-
期待一个安卓命令来启动或停止锅炉
-
-
安卓应用程序:
-
管理用户交互
-
实现用户设置,以存储每天每小时的温度设定点
-
读取微控制器发送的温度
-
实现逻辑以选择是否应该打开或关闭锅炉
-
向微控制器发送命令以启动或停止锅炉
-
通过这个计划,我们可以依赖安卓用户界面组件轻松实现简洁且易用的界面,同时避免设置存储层的复杂性。
要开始构建原型,我们需要在我们的面包板上插入一个温度传感器,如TMP36,以获得以下电路:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_01.jpg
以下是连接组件的逐步操作过程,如前图所示:
-
将 TMP36 传感器放在面包板的右侧部分。
-
将 UDOO 的+3.3V 引脚连接到电源总线的正极。确保不要连接+5V 电源引脚,因为未来连接时可能会损坏模拟输入引脚。
-
将 UDOO 的地线连接到电源总线的负极。
-
将 TMP36 传感器的左端连接到电源总线的正极。
提示
使用封装传感器时,我们可以通过观察平整的部分来判断方向。使用这种方法来找到左端和右端。
-
将 TMP36 传感器的右侧终端连接到电源总线的负极。
-
将 TMP36 传感器的中间终端连接到模拟输入 A0。
这个封装的传感器非常容易使用,它不需要任何其他组件或电压分压器来为微控制器提供电压变化。现在我们应该继续从我们的电路管理锅炉点火。为了原型的需要,我们将用简单的 LED 替换锅炉执行器,就像我们在第二章,了解你的工具中所做的那样。这将使我们的电路更简单。
我们可以在面包板上添加一个 LED,以实现以下原理图:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_02.jpg
以下是按照前述原理图连接组件的步骤:
-
将 LED 放在面包板的左侧。
-
将 LED 较长的终端(阳极)连接到 UDO 数字引脚 12。
-
使用一个 220 欧姆的电阻,将 LED 较小的终端(阴极)连接到电源总线的负线上。
使用这个电路,我们拥有了从环境中收集数据和模拟锅炉点火所需的所有组件。现在我们需要打开 Arduino IDE 并开始一个新的草图。第一个目标是将检测到的温度检索并转换成方便的计量单位。为了实现这个目标,我们需要执行以下步骤:
-
在草图的顶部定义这些类似对象宏和变量:
#define TEMPERATURE_POLL_PERIOD 1000 #define SENSOR A0 #define BOILER 12 int reading;我们定义了
SENSOR对象来表示模拟引脚 A0,而BOILER对象与我们的数字引脚 12 相关联。我们还声明了一个reading变量,稍后用来存储当前检测到的温度。TEMPERATURE_POLL_PERIOD宏表示微控制器在两次读数之间等待的秒数,以及它通知 Android 应用程序检测到的温度之前等待的秒数。 -
在
setup()函数中,添加引脚模式声明并打开串行通信,如下所示:void setup() { pinMode(BOILER, OUTPUT); digitalWrite(BOILER, LOW); Serial.begin(115200); } -
在草图的底部,按照以下方式创建
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 的读数轻松转换为摄氏度。如果你想使用其他计量单位,比如华氏度,或者你使用的是其他传感器或热敏电阻,那么你需要改变这个实现方式。
-
在主
loop()函数中,从传感器读取模拟信号并使用loop()函数打印转换后的结果:void loop() { reading = analogRead(SENSOR); Serial.print("Degrees C:"); Serial.println(convertToCelsius(reading)); delay(TEMPERATURE_POLL_PERIOD); }
如果我们上传草图并打开串行监视器,我们会注意到当前的室温。实际上,如果我们把手指放在传感器周围,我们会立即看到之前检测到的温度升高。以下屏幕截图是草图输出的一个示例:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_03.jpg
发送数据和接收命令
下一步是像往常一样启用 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 读取命令并将结果传递给执行器。为此,我们需要执行以下步骤:
-
添加
executor()函数,该函数读取一个命令并打开或关闭外部设备:void executor(uint8_t command) { switch(command) { case 0: digitalWrite(BOILER, LOW); break; case 1: digitalWrite(BOILER, HIGH); break; default: // noop break; } } -
添加
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 以实现内部通信。请遵循以下提示以完成正确的配置:
-
在
app/build.gradle文件中添加ADKToolkit库依赖。 -
同步你的 Gradle 配置。
-
在
res/xml/目录下创建配件过滤器文件usb_accessory_filter.xml,包含以下代码:<resources> <usb-accessory version="0.1.0" model="Chronotherm" manufacturer="Example, Inc."/> </resources> -
在
AndroidManifest.xml文件中添加USB 配件支持选项要求和USB 配件意图过滤器选项。 -
在
Overview.java类文件中,在类的顶部声明AdkManager对象。 -
在
Overview活动类的onCreate()方法中添加AdkManager对象初始化。 -
重写
onResume()活动回调,在活动打开时启动 ADK 连接。在这个项目中,我们在onPause()回调中不关闭 ADK 连接,因为我们将使用两个不同的活动,并且连接应该保持活动状态。
在 ADK 通信启动并运行后,我们可以继续编写 Chronotherm 用户界面。
设计 Android 用户界面
下一步是设计 Chronotherm 应用程序的用户界面,以处理设定点管理以及适当的反馈。我们将通过编写两个不同职责的 Android 活动来实现这些要求:
-
一个Overview活动,显示当前时间、检测到的温度和当前锅炉状态。它应该包括一个小组件,显示用户每天每个小时的设定点。这些设定点用于决定是否打开或关闭锅炉。
-
一个Settings活动,用于更改每天每个小时的当前设定点。这个活动应该使用与
Overview活动相同的组件来表示温度设定点。
我们从Overview活动以及温度设定点小组件开始实现。
编写 Overview 活动
这个活动应提供有关 Chronotherm 应用程序当前状态的所有详细信息。所有必需的组件在以下模拟图中总结,该图定义了创建组件的顺序:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_04.jpg
第一步是更新活动布局,根据前面草图的建议,我们应该执行以下步骤:
-
在布局的顶部,我们可以包含一个显示当前系统时间的
TextClock视图。 -
顶栏应该提供锅炉状态的反馈。我们可以添加一个灰色的
TextView,带有Active文字,当锅炉开启时它会变成绿色。 -
Overview主体必须提供当前检测到的温度。因为这是 Chronotherm 应用程序提供的最重要的细节之一,我们将通过使其比其他组件更大来强调这个值。 -
在室内温度附近,我们将通过一系列垂直条形图创建一个小部件,以显示用户每天每个小时的设定点,从而展示当前激活的日程。在
Overview活动中,这个小部件将保持只读模式,仅用于快速查看激活的程序。 -
在活动操作栏中,我们应该提供一个菜单项,用于打开
Settings活动。这个活动将用于在 Chronotherm 应用程序中存储设定点。
我们从顶部栏和检测到的温度组件开始实现Overview,要实现前面的布局,需要以下步骤:
-
在
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> -
在
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> -
为了强调当前检测到的温度,我们可以创建一个圆形形状来包围温度值。要实现这一点,请在
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> -
现在我们可以继续并在
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> -
在前面的
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> -
下一步是创建活动主体。它应该包含两个不同的项目:第一个是
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> -
作为最后几步,在活动代码中存储所有视图引用。在
Overview类的顶部,添加temperature和boiler_status视图的引用,使用以下高亮代码:private AdkManager mAdkManager; private TextView mTemperature; private TextView mStatus; -
在
Overview的onCreate()回调中,使用以下代码获取引用: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组件的自定义可以按以下方式进行组织:
-
作为第一步,我们应该创建一个实现垂直滑动行为的
TemperatureBar类。大部分的更改与继承SeekBar类有关,同时将组件的宽度与高度进行切换。 -
小部件需要一个 XML 布局,以便从我们的代码中程序化地添加。因此,我们将创建一个包含
TemperatureBar视图、所选度数和与条相关的小时的布局。 -
当垂直条组件发生任何变化时,应更新度数。在这一步中,我们将创建一个监听器,将条的变化传播到度数组件,为用户提供适当的反馈。
-
我们定制的包含
TemperatureBar类、度数和小时视图的组件,应该为一天中的每个小时程序化地创建。我们将创建一个工具类,负责将组件布局膨胀 24 次,并添加适当的监听器。
我们开始编写垂直的SeekBar类,可以通过以下步骤实现:
-
在您的命名空间中创建一个名为
widget的新包。 -
在新创建的包中,添加一个扩展
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); } } -
继续实现
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回调以在画布平移后调整组件的大小。 -
因为我们已经切换了条宽和高度,我们需要重写
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_DOWN、ACTION_MOVE或ACTION_UP方法事件发生时更新组件进度。由于本项目不需要其他行为,所以我们保留其余实现不变。
现在我们可以继续编写承载前一个组件以及度和小时的TextView的 XML 布局。通过以下步骤,我们可以实现一个从我们的工具类中填充的布局:
-
在
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> -
在
res/layout/目录下创建temperature_bar.xml文件,其中包含小部件布局。在这个文件中,我们应该将此LinearLayout作为根元素添加:<LinearLayout android:orientation="vertical" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content"> </LinearLayout> -
向前一个
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命名空间替换为你的命名空间。
既然我们已经有了温度条组件和小部件布局,我们需要创建一个将degrees和seekbar视图绑定的绑定。通过以下步骤进行小部件实现:
-
在
widget包中创建DegreeListener类。 -
前一个类应该实现
SeekBar监听器,同时存储连接的degrees视图的引用。我们使用这个TextView引用来传播垂直条的价值:public class DegreeListener implements SeekBar.OnSeekBarChangeListener { private TextView mDegrees; public DegreeListener(TextView degrees) { mDegrees = degrees; } -
将进度值传播到
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类来填充小部件布局。该填充过程应针对一天的每个小时重复进行,并且需要引用小部件将被填充的布局。要完成实现,请按照以下步骤操作:
-
在
widget包中创建TemperatureWidget类。 -
这个类应该公开一个静态的
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布局以创建条形对象的实例。然后,我们获取time、degrees和seekbar对象的所有引用,以便我们可以设置初始值并创建带有degrees TextView绑定的DegreeListener类。我们继续将小部件添加到parent节点,用当前创建的条形填充bars数组。最后一步,我们返回这个数组,以便调用活动可以使用它。
完成概览活动
设置点小部件现在已完成,我们可以继续在活动创建期间填充温度条。我们还将添加在活动菜单中启动Settings活动的操作。要完成Overview类,请按照以下步骤操作:
-
在
Overview的onCreate()回调中通过添加高亮代码来填充设置点小部件: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); -
处理操作栏菜单以启动
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类的布局,以下是获得的结果截图:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_05.jpg
编写设置活动
在实现我们的温控逻辑之前,下一步是创建一个Settings活动,以便在白天更改温度设置点。要启动新活动,请从窗口菜单中选择文件,然后选择新建以打开上下文菜单。在那里,选择活动,然后选择空白活动。这将打开一个新窗口,我们可以在活动名称中填写Settings,然后点击完成。
注意
即使我们可以使用带有同步首选项的内置设置模板,我们还是使用空白活动以尽可能简化这部分内容。
我们从以下草图开始设计活动布局,展示所有必需的组件:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_06.jpg
首先需要更新活动布局,根据之前草图的建议,我们应该:
-
添加一个保存按钮,该按钮将调用活动方法,保存从温度小部件中选择的设置点。
-
在选择设置点期间,填充使用的温度小部件。
为了实现前面的布局,更新res/layout/下的activity_settings.xml文件,进行以下更改:
-
使用以下
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> -
在前面的布局中,添加小部件占位符和保存按钮:
<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类中进行以下步骤,添加小部件初始化来完成活动:
-
在
Settings类顶部添加高亮变量:public class Settings extends ActionBarActivity { private TemperatureBar[] mBars; // ... -
在
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活动,如下截图所示:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_06_07.jpg
Chronotherm 应用程序的界面已完成,我们可以继续处理用户设置存储层的管理。
管理用户的设定点
Chronotherm 应用程序的活动提供了必要的用户界面组件,以显示和更改用户的设定点。为了让它们工作,我们应该实现保存持久应用程序数据的逻辑。根据我们的需求,我们可以使用SharedPreferences类以键值对的形式存储基本数据,为整个应用程序提供设定点值。在这个项目中,我们将使用设定点小时作为键,选择的温度作为值。
注意事项
SharedPreferences类是 Android 框架提供的一种存储选项。如果在其他项目中我们需要不同的存储方式,可以查看 Android 官方文档:developer.android.com/guide/topics/data/data-storage.html。
从 Overview 活动中读取设定点
我们首先在Overview活动中实现一个方法,该方法读取存储的设定点并更新温度条数值。在活动创建期间,我们可以通过以下步骤读取用户的偏好设置:
-
对于每个进度条,我们使用存储的值来设置进度。当没有找到设置时,我们使用
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循环计数器间接表示。 -
从
onResume()活动回调中调用前面的方法,并添加高亮显示的代码:protected void onResume() { super.onResume(); readPreferences(); mAdkManager.open(); }
通过这些步骤,我们在Overview活动中完成了设定点的管理,并将继续处理Settings活动。
从 Settings 活动中写入设定点
在Settings活动中,当用户点击保存设置按钮时,我们应该实现存储用户设定点的逻辑。此外,当活动创建时,我们必须加载先前存储的设定点,以便在用户开始更改偏好设置之前,向他们展示当前的时间表。为实现这些功能,我们可以按照以下步骤进行:
-
与在
Overview活动中所做的一样,我们需要加载设定点值并更新温度条。因为我们已经实现了这个功能,所以可以直接从Overview类将readPreferences()方法复制粘贴到Settings类中。 -
在
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(); }在使用后台提交检索并存储所有设定点之后,我们关闭当前活动。
-
在
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类,该类运行周期性的计划任务线程,并且应该:
-
从 ADK 读取检测到的温度。
-
更新锅炉状态,检查温度是否低于当前选择的设定点。
-
将温度发送到主线程,以便它可以更新
temperatureTextView。 -
向 Arduino 发送命令以开启或关闭锅炉。此任务应仅在当前锅炉状态自上一次任务执行以来发生变化时执行。在这种情况下,它还应将锅炉状态发送到主线程,以便它可以更新相关的
TextView。
在我们开始线程实现之前,我们应该提供一个 Java 接口,它公开了更新活动用户界面所需的必要方法。我们可以通过以下步骤完成此操作:
-
创建一个名为
OnDataChangeListener的新 Java 接口,并添加以下代码片段:public interface OnDataChangeListener { void onTemperatureChanged(float temperature); void onBoilerChanged(boolean status); } -
使用高亮代码将前面的接口添加到
Overview类:public class Overview extends ActionBarActivity implements OnDataChangeListener { -
通过编写更新当前温度和锅炉状态
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)); } }
现在我们可以继续实现先前解释的整体设计的计划任务线程:
-
在您的命名空间中创建一个名为
adk的新包。 -
在
adk包中,添加一个名为DataReader的新类。 -
在类的顶部,添加以下声明:
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实现。 -
实现设置消息处理器的
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回调根据消息类型在视图中更新温度或锅炉状态。 -
在
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()方法,用于检查当前小时的温度是否低于所选的设定点。我们从应用程序的共享偏好设置中获取这个值。 -
向
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); } -
在类的底部添加
stop()方法,通过执行器的shutdown()方法停止调度程序创建新线程:public void stop() { mSchedulerSensor.shutdown(); } -
现在,我们应该回到
Overview类中,在活动生命周期内开始和停止调度程序。在Overview类的顶部添加DataReader声明:private AdkManager mAdkManager; private DataReader mReader; -
在
onCreate()回调中初始化DataReader实例,通过以下突出显示的代码:mAdkManager = new AdkManager(this); mReader = new DataReader(mAdkManager, this, this); } -
在
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类选择的设定点。根据新要求,这不再适合我们的应用程序,因为我们需要为每个预设持久化不同的设定点。此外,我们需要持久化当前激活的预设,所有这些变化都迫使我们设计一个新的用户界面以及一个新的设置系统。
我们可以通过以下截图来看看需要做出哪些改变:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_07_01.jpg
第一步是更新我们的用户界面。根据上述草图的建议,我们应该:
- 在布局顶部添加一个新的
TextView,显示当前预设的名称。在加载活动时以及用户激活新预设时,应更改名称。
为了实现上述布局,更新res/layout/目录下的activity_overview.xml文件,在包含TextClock和boiler_status视图的头部LinearLayout中进行以下更改:
-
更改
TextClock视图,用高亮代码替换layout_width属性,并添加layout_weight属性:android:layout_width="0dp" android:layout_weight="1" -
按照上一步的操作,更改
boiler_statusTextView的布局:android:layout_width="0dp" android:layout_weight="1" -
在前一个组件之间添加以下
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" /> -
在
Overview类的顶部,使用高亮代码添加current_preset视图的引用:private TextView mCurrentPreset; private TextView mTemperature; private TextView mStatus; -
在
Overview的onCreate回调中,使用以下代码获取视图引用:setContentView(R.layout.activity_overview); mCurrentPreset = (TextView) findViewById(R.id.current_preset);
下面的截图是通过前面的布局获得的:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_07_02.jpg
存储预设配置
如先前讨论的,我们应该改变 Chronotherm 应用程序中用户设置点的存储和检索方式。想法是将对应用程序共享首选项的访问隔离在一个新的 Preset 类中,该类公开以下方法:
-
一个
set()方法,用于保存与预设名称对应的设置点配置。设置点值数组被序列化为逗号分隔的字符串,并使用预设名称作为键进行保存。 -
一个
get()方法,用于返回给定预设名称的存储设置点。设置点字符串被反序列化并作为值数组返回。 -
一个
getCurrent()方法,用于返回最新激活预设的名称。 -
一个
setCurrent()方法,用于将给定的预设名称提升为最新激活的预设。
要创建 Preset 类,请按照以下步骤操作:
-
在
chronotherm包中创建Preset类。 -
在
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 屏幕。 -
在
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变量作为键。 -
在
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变量相匹配的内容,我们将返回一个空数组。 -
在类的底部添加
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; } -
在类的底部添加
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());
}
下一步是使设置活动适应以下步骤总结的新行为:
-
当用户打开
设置活动时,语音识别系统应该请求预设名称。 -
如果找到给定的预设,我们应该加载预设的设定点,并更新所有温度条。当用户保存新偏好时,旧的设定点将被更新。
-
如果未找到给定的预设,则无需更新温度条。当用户保存新偏好时,将使用给定的设定点存储新的预设条目。
我们仍然没有实现第一步所需的所有组件,因为我们缺少语音识别实现。与此同时,我们可以通过以下步骤更新此活动中的预设存储和检索方式:
-
在类的顶部,添加突出显示的变量,该变量将存储识别的预设名称:
private TemperatureBar[] mBars; private String mEditingPreset; -
在
设置活动的onCreate()回调中,移除readPreferences()方法的调用。 -
更新
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; } -
更新
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。
以下步骤可以实现重用语音识别能力的先前抽象:
-
在
chronotherm包中添加IRecognitionListener接口,定义onRecognitionDone()回调,用于将结果发送回调用活动。我们可以通过以下代码实现这一点:public interface IRecognitionListener { void onRecognitionDone(int requestCode, String bestMatch); } -
创建一个名为
voice的新包,并添加一个名为RecognizerActivity的新抽象类。该类应定义如下:public abstract class RecognizerActivity extends ActionBarActivity implements IRecognitionListener { } -
添加一个公共方法来初始化识别阶段,并将获取结果的责任委托给以下代码:
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参数用于提供屏幕消息,如果外部应用程序支持的话。 -
添加
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活动开始使用语音输入,询问我们想要创建或编辑的预设名称。如果预设存在,我们应在保存过程中加载存储的设定点并更新它们。否则,我们应在偏好设置中创建新的记录。为了实现这种行为,请执行以下步骤:
-
根据以下代码片段,从
Settings类扩展RecognizerActivity:public class Settings extends RecognizerActivity { //... } -
声明我们将用于识别和处理识别结果的意图请求代码。在类的顶部,添加以下高亮代码:
public class Settings extends RecognizerActivity { private static final int VOICE_SETTINGS = 1001; private TemperatureBar[] mBars; // ... } -
在
onCreate()回调的底部,添加以下代码以尽快开始语音识别:mBars = TemperatureWidget.addTo(this, container, true); startRecognition("Choose the preset you want to edit", VOICE_SETTINGS); -
实现
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类;为了保持界面简洁,我们可以使用这个组件来启动语音识别。用户可以通过当前流程更改激活的预设:
-
当用户点击TextView选项时,系统应启动语音识别以获取预设名称。
-
如果找到了预设,应该用用户选择的预设替换激活的预设,并更新
Overview的温度条。 -
如果找不到预设,则不应有任何反应。
要实现上述交互流程,请按照以下步骤进行:
-
正如我们对
Settings活动所做的那样,从Overview类扩展RecognizerActivity类,如下面的代码片段所示:public class Overview extends RecognizerActivity implements OnDataChangeListener { //... } -
声明我们将用来识别和处理识别结果的意图请求代码。在类的顶部,添加高亮代码:
public class Overview extends RecognizerActivity implements OnDataChangeListener { public static final int VOICE_PRESET = 1000; private AdkManager mAdkManager; //... } -
在类的底部,添加一个方法来启动预设名称识别:
public void changePreset(View v) { startRecognition("Choose the current preset", VOICE_PRESET); } -
实现
onRecognitionDone()回调以处理识别意图返回的结果。在这个方法中,我们调用setPreset()成员函数来更新激活的预设并加载温度设定点,如果找到了给定的预设。在类的底部,添加以下代码:@Override public void onRecognitionDone(int requestCode, String bestMatch) { if (requestCode == VOICE_PRESET) { setPreset(bestMatch); } } -
实现
setPreset()方法来处理最佳识别结果。在类的底部,添加以下代码:private void setPreset(String name) { ArrayList<Integer> values = Preset.get(this, name); if (values.size() > 0) { Preset.setCurrent(this, name); readPreferences(); } } -
将启动语音识别的
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 意图处理语音识别,并且更新了Settings和Overview活动以使用它。现在我们可以上传 Chronotherm 应用程序,并再次使用带有预设和语音识别功能的应用程序。
改进用户与语音合成的交互
即使 Chronotherm 应用程序工作正常,我们至少还有一件事要做:提供适当的反馈,让用户知道已采取的行动。实际上,这两个活动都没有提供关于识别输入的任何视觉反馈;因此,我们决定在初始设计中引入语音合成 API。
因为我们希望在不同的活动中共享合成过程,我们可以创建一个管理器,通过共同的初始化抽象合成 API。这个想法是提供一个类,它公开了一个方法,使用给定的字符串开始语音识别;我们按照以下步骤实现它:
-
在
voice包内创建VoiceManager类。 -
使用以下代码初始化类:
public class VoiceManager implements TextToSpeech.OnInitListener { private TextToSpeech mTts; //... }这个类实现了
OnInitListener接口,该接口定义了在初始化TextToSpeech引擎后应调用的回调。我们存储当前的TextToSpeech实例,我们将在以下代码段中使用它作为一个变量。 -
重写
onInit()方法,使其在TextToSpeech实例服务初始化成功时设置美国地区:@Override public void onInit(int status) { if (status == TextToSpeech.SUCCESS) { mTts.setLanguage(Locale.US); } } -
添加类构造函数,在其中使用给定的活动
Context初始化文本转语音服务。在类内部,编写以下代码:public VoiceManager(Context ctx) { mTts = new TextToSpeech(ctx, this); } -
实现一个
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,请执行以下步骤:
-
在类顶部,在变量声明之间添加高亮显示的代码:
private DataReader mReader; private VoiceManager mVoice; -
在
onCreate()回调的底部,按以下代码片段所示初始化VoiceManager实例:mReader = new DataReader(mAdkManager, this, this); mVoice = new VoiceManager(this); -
使用高亮显示的代码更新
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,请执行以下步骤:
-
在类的顶部,按照高亮代码声明
VoiceManager变量:private String mEditingPreset; private VoiceManager mVoice; -
在
onCreate()回调的底部,初始化VoiceManager实例:mVoice = new VoiceManager(this); startRecognition("Choose the preset you want to edit", VOICE_SETTINGS); -
更新
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温度检查进行重构。我们继续进行语音识别的第一个实现,并且为了简化我们的工作,我们创建了一个从Settings和Overview活动扩展的通用活动类。这使得我们能够抽象出一些常见行为,便于在不同的代码部分调用识别意图。
作为最后一步,我们准备了语音合成管理器,以便轻松使用 Android 的文本到语音引擎。实际上,我们使用这个组件在识别过程后,当用户更改设置和当前激活的预设时提供反馈。
在下一章中,我们将为 Chronotherm 应用程序添加网络功能,以便它能够检索天气预报数据;使用这些信息,我们将制作一个稍微更好的算法来决定是否打开或关闭我们的锅炉。
第八章:添加网络功能
在第六章,为家庭自动化构建 Chronotherm中,我们探讨了家庭自动化的定义,并且一步一步地构建了一个可以根据用户偏好程序化控制锅炉的原型。我们扩展了这个原型,提供了一个预设配置以存储不同的温度计划,并通过语音识别和合成改善了用户交互。
这一次,我们通过添加另一个利用网络功能从互联网收集数据的功能来增强 Chronotherm 应用程序。本章的目标是使我们的原型能够对无法通过连接的传感器轻松捕获的外部事件做出反应。
在本章中,我们将涵盖以下主题:
-
使用网络功能扩展 Chronotherm 应用程序
-
使用网络服务收集天气预报数据
-
使用收集的数据改变 Chronotherm 的行为
为 Chronotherm 扩展网络功能
Chronotherm 应用程序解决了一个具体问题。它每天在当前温度低于每个小时的配置设定点时开启锅炉。这个逻辑对于传统 Chronotherm 来说已经足够,但我们可以改进这种行为,使其考虑家庭温度与天气条件之间的密切关系。例如,在寒冷的日子里,内部温度通常下降得更快;如果我们在锅炉逻辑中包含这些信息,我们可以使我们的原型更加智能。
此外,如果天气真的很冷,我们的锅炉可能会因为内部的水结冰而停止工作。如果实现一个防冻功能,当外部温度降至一个定义值以下时,即使违背用户偏好,也会启动锅炉,这个问题就可以得到解决。这样的功能将处理用户不在家或夜间时的意外情况。
不幸的是,连接外部传感器并不容易,而且构建和使用无线热传感器可能过于复杂。然而,考虑到外部温度的重要性,我们必须找到一种方法来收集天气条件数据。由于 UDOOU Chronotherm 位于我们的家中,且很可能连接到互联网,我们可以从提供预报数据的网络服务中获取这些信息,在我们的计算中使用这些知识。这样,我们甚至可以添加完整的天气条件概览,在提供用户有用信息的同时改善用户界面。
根据之前提到的需求,我们可以按以下步骤组织我们的工作:
-
实现一个模块,用于将我们的原型连接到天气预报的 REST API。
-
定期收集并显示天气预报数据。
-
编写将使用先前数据的锅炉防冻逻辑。
连接到 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 使用外部服务。要为我们的应用程序创建一个连接器,请执行以下步骤:
-
在名为
http的新包中创建UrlConnector类。 -
在类的顶部,添加以下声明以存储
HttpURLConnection类实例:private HttpURLConnection mConnector; -
添加以下构造函数,我们将使用它来初始化请求参数:
public UrlConnector(String encodedUrl) throws IOException { URL url = new URL(encodedUrl); mConnector = (HttpURLConnection) url.openConnection(); mConnector.setReadTimeout(10000); mConnector.setConnectTimeout(15000); }我们期望一个
encodedUrl参数作为参数,并使用它来初始化稍后用于打开连接的 URL 对象。然后,我们为读取和连接阶段设置超时,使用适合我们原型的值。 -
添加一个泛型方法来设置我们请求的 HTTP 头:
public void addHeader(String header, String content) { mConnector.setRequestProperty(header, content); } -
在
get()方法下面添加以下代码片段,用于进行调用:public int get() throws IOException { mConnector.setRequestMethod("GET"); return mConnector.getResponseCode(); }对于
mConnector实例,我们设置GET请求方法,返回响应的状态码。此状态码将用于检查请求是否成功或失败结束。 -
添加以下
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实例的输入流创建一个缓冲阅读器,然后通过上述阅读器获取服务器发送的内容。当我们完成后,我们不进行任何修改直接返回字符串。 -
创建一个
disconnect()方法以关闭与服务器连接:public void disconnect() { mConnector.disconnect(); }
UrlConnector 类简化了 HTTP 调用,这种实现足以连接到许多不使用任何认证流程的 Web 服务。在我们继续之前,我们必须选择一个提供天气预报数据的 Web 服务,我们将对其进行查询。为了我们原型的目的,我们将使用 OpenWeatherMap 服务,因为它提供了一个无需认证流程的免费层级,并且它也通过 REST API 提供。你可以在 openweathermap.org/ 或 openweathermap.org/current 了解更多关于该服务的信息,以及学习它们的 REST API 是如何构建的:
当我们调用上述 RESTful 服务时,我们应该解析 JSON 响应,使其在我们的应用程序中可用。这种方法可以通过一个知道响应结构并按照我们的需求进行解析的 Java 类来实现。实现需要以下步骤:
-
在名为
weather的新包中创建Weather类。 -
在类的顶部,添加以下声明:
private String mStatus; private double mTemperature; private int mHumidity;我们根据需要从给定的响应中声明变量。在我们的例子中,我们使用
mStatus变量来存储天气状况,以便用户知道天气是晴朗还是多云。我们还使用mTemperature变量,这是我们第一个需求,以及mHumidity属性为用户提供额外信息。 -
添加如下类构造函数:
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 解析期间可能会引发一些异常,因此,如果构造函数抛出列表,我们会添加这些异常。 -
添加
convertTempKtoC()成员函数,该函数在构造函数中使用,用于将开尔文转换为摄氏度:private double convertTempKtoC(double temperature) { return temperature - 273.15; }注意
这只是一个示例;你可以使用你喜欢的任何温度单位。
-
向
Weather类添加以下获取器,以获取实例数据:public String getStatus() { return mStatus; } public double getTemperature() { return mTemperature; } public int getHumidity() { return mHumidity; }
既然我们已经有一个抽象的 HTTP 调用和 JSON 结果解析器,我们需要实现最后一个调用 REST API 并返回 Weather 实例的构建块。我们可以通过以下步骤实现这个实现:
-
在
weather包内创建WeatherApi类。 -
在类的顶部,声明以下变量:
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参数要求服务器为给定的地区翻译结果。 -
定义一个
static方法以生成有效的请求 URL:private static String getUrl(String location) { String params = String.format(API_PARAM, location, Locale.US); return BASE_URL + params; }此方法期望一个
location参数,与有效的位置一起生成params字符串。这样,它设置q和lang参数,然后返回与适当连接的BASE_URL属性。 -
添加静态方法以进行 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 进行人机交互》中所做的那样。所有必需的组件在以下模拟中都有总结:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_08_01.jpg
第一步是更新Overview参数布局。按照前面的建议,我们应该:
-
添加天气预报
TextView:每当短时线程使用WeatherApi类加载Weather实例时,此组件会发生变化。在这种情况下,它会显示当前位置、天气状况、温度和湿度。当用户点击此组件时,我们应该启动语音识别意图来获取用户的位置。 -
添加防冻
TextView:当启用防冻功能时,此组件以绿色显示当前的防冻设定点;另一方面,当用户禁用防冻检查时,它会变成灰色。当用户点击此组件时,我们应该启动语音识别意图来获取用户的防冻设定点;如果启用了防冻,应该从用户的偏好设置中移除设定点。
我们开始处理可以实现的布局,更新res/layout/下的activity_overview.xml文件和Overview类,如下面的步骤所示:
-
更改包含
view_container和temperature视图的LinearLayout,使用以下高亮代码:<LinearLayout android:orientation="horizontal" android:gravity="center" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> -
在之前的
LinearLayout下方,添加以下布局,其中将包含两个TextViews:<LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="0.2"> </LinearLayout> -
在之前的容器中,使用以下代码添加防冻和天气预报
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"/>在这两个组件中,我们定义了调用
changeAntifreeze和changeLocation方法的onClick属性。这些成员函数实现了之前描述的交互,我们将在下一节继续实现它们。 -
现在,我们应该继续处理
Overview活动,实现更新两个TextViews的缺失代码。首先,在Overview类的顶部声明它们的引用:private TextView mCurrentPreset; private TextView mTemperature; private TextView mStatus; private TextView mWeatherStatus; private TextView mAntifreeze; -
在
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); -
因为短时线程应该更新
mWeatherStatusTextView参数,我们必须在OnDataChangeListener参数接口中提供一个回调,该接口期待一个Weather实例。在OnDataChangeListener参数接口中添加高亮的方法:public interface OnDataChangeListener { void onTemperatureChanged(float temperature); void onBoilerChanged(boolean status); void onWeatherChanged(Weather weather); } -
作为最后一步,在
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 应用程序。它的展示效果如下截图所示:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_08_02.jpg
收集天气预报数据。
现在我们的应用程序用户界面已完成,我们可以继续实现存储用户位置并从 RESTful 网络服务获取天气数据的逻辑。此实现可以按照以下步骤组织:
-
更新
Preset类以存储用户的位置。 -
当用户点击
weather_statusTextView参数时,处理语音识别结果。 -
添加一个新的计划线程,获取天气数据并使用
onWeatherChanged()回调更新用户界面。
我们开始更新Preset类,并按照以下步骤实现它:
-
在类的顶部,添加高亮声明,用作存储和检索用户设置位置的键:
private static final String CURRENT_PRESET = "__CURRENT__"; private static final String CURRENT_LOCATION = "__LOCATION__"; -
在类的底部,添加以下 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(); } -
要检索存储的值,请添加以下 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 调用。
现在我们可以继续更新应用程序的交互,通过语音识别来更改当前存储的位置。要完成这一步,请进行以下更改:
-
在
Overview类的顶部,添加高亮声明以定义新Intent结果的请求代码,该结果将请求用户的位置:public static final int VOICE_PRESET = 1000; public static final int VOICE_LOCATION = 1002; -
实现
weather_status可点击视图使用的changeLocation()方法:public void changeLocation(View v) { startRecognition("Provide your location", VOICE_LOCATION); } -
实现一个成员函数,使用
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视图,直到计划线程获取天气条件。 -
在
onRecognitionDone()回调中添加高亮代码,将bestMatch参数传递给上一个方法:if (requestCode == VOICE_PRESET) { setPreset(bestMatch); } else if (requestCode == VOICE_LOCATION) { setLocation(bestMatch); }
我们缺少的最后一个构建块是通过新的计划线程定期收集和显示天气预报数据。这最后一部分可以通过以下步骤更新DataReader类来实现:
-
在类的顶部,添加高亮声明:
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 秒,但我们必须考虑到外部温度永远不会变化得这么快,因此创建太多对网络服务的查询是没有用的。我们仅为了测试目的选择了这个值;当原型准备好时,我们将需要设置更合理的时序。
-
在类的底部,添加以下
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(); } } } -
在
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); } -
停止之前的调度器,使用以下代码更改
stop()方法:public void stop() { mSchedulerSensor.shutdown(); mSchedulerWeather.shutdown(); } -
更新主线程处理器,根据消息类型将
Weather实例传递给适当的回调:case BOILER_UPDATED: mCaller.onBoilerChanged((boolean) message.obj); break; case WEATHER_UPDATED: mCaller.onWeatherChanged((Weather) message.obj); break;
现在我们有一个能够定期收集和显示天气数据的原型,我们可以将应用程序上传到 UDOOboard。当我们点击天气状态视图并通过语音识别输入我们的位置后,应用程序应该使用当前天气条件更新Overview界面。下一步是改进锅炉点火检查,添加防冻功能。
改进带有防冻检查的锅炉
既然天气预报已经可以获取并运行,我们可以继续实现防冻功能。为了实现之前讨论的交互和逻辑,我们应当:
-
加强
Preset类,存储用户的防冻设定点。在这个类中,我们应该提供两个实用工具来禁用防冻检查以及判断功能是否启用。 -
在
Overview活动中处理防冻功能,在选择设定点时更新用户界面。 -
更新
SensorThread类中的锅炉逻辑,以便在启用防冻检查时考虑在内。
我们通过以下步骤开始工作,更改Preset类:
-
在类顶部,添加高亮声明:
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属性作为一个不可能达到的默认温度。这样,我们可以匹配这个变量以判断防冻功能是否激活。 -
在类底部添加以下 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(); } -
与前一个方法保持一致,添加以下 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属性。 -
添加以下方法来移除防冻设定点:
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(); } -
添加以下实用程序,如果启用了防冻功能,则返回值:
public static boolean antifreezeIsEnabled(Context ctx) { return getAntifreeze(ctx) != ANTIFREEZE_DISABLED; }
在下一步中,我们应在Overview活动中添加防冻功能,提供所有更新用户界面同时通过语音识别处理用户输入的方法。实现该功能需要以下步骤:
-
在
Overview类的顶部,添加mFreeze布尔值,指出当前是否激活了防冻检查:private TextView mWeatherStatus; private TextView mAntifreeze; private boolean mFreeze = false; -
在类底部,添加以下用于更新
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布尔值。在这一点上,如果启用了防冻功能,我们会显示一个带有给定设定点的绿色信息;否则,我们会显示一个灰色信息,表明该功能已禁用。 -
在
readPreferences()成员函数的底部调用updateAntifreeze()方法,如高亮代码所示:// ... mCurrentPreset.setText(activatedPreset.toUpperCase()); updateAntifreeze(); }
既然我们已经有一个与存储的防冻设定点一起工作的布局,我们应该为用户提供语音识别和合成功能,以激活或关闭防冻检查。要实现这一实现,需要执行以下步骤:
-
在
Overview类的顶部,添加高亮的Intent请求码:public static final int VOICE_LOCATION = 1002; public static final int VOICE_ANTIFREEZE = 1003; -
添加
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); } } -
实现成员函数以启用并存储防冻设定点:
private void enableAntifreeze(float temperature) { Preset.setAntifreeze(this, temperature); updateAntifreeze(); mVoice.speak("Antifreeze set to " + temperature + " degrees"); } -
将高亮代码添加到
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;
}
}
使用此代码,如果外部温度低于存储的防冻设定点,锅炉将不管用户的偏好而开启。如果此条件不发生,将继续默认行为。
原型已完成;通过天气预报数据,它保持了我们的房屋温暖,同时也消除了由于温度过低导致锅炉损坏的风险。我们可以上传应用程序,然后我们可以设置防冻温度。以下屏幕截图显示了预期的结果:
https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/gtst-udoo/img/1942OS_08_03.jpg
既然原型已经完成,我们可以在app/build.gradle文件中将 Chronotherm 应用程序更新为0.3.0版本。
概述
在本章中,我们了解到互联网对我们的设备有多么重要,这得益于它的大量数据和服务。我们发现,通过使用外部温度,我们的原型可以得到改进,而且在不改变电路的情况下,通过网络收集天气条件。
在第一部分,我们编写了一个通用连接器,这样我们可以不用做太多工作就能发出 HTTP 调用。然后我们使用这个组件实现了一个 RESTful 网络服务的部分抽象,能够获取给定位置的当前天气。我们在 Chronotherm 布局中添加了新元素以显示天气预报数据,并通过语音识别处理位置输入。
最后,我们决定将外部温度整合到我们的锅炉逻辑中。实际上,我们实现了防冻功能,当外部温度过低时,无论用户的偏好如何,都会开启锅炉。
这个原型是本书最后一次探讨 UDOO 板与 Android 操作系统提供的众多功能。然而,如果你对 Chronotherm 应用程序还有进一步的改进兴趣,你可以深入研究附加章节,第九章,使用 MQTT 监控你的设备,它介绍了物联网的主要概念和MQTT 协议,这些协议用于物理设备之间的数据交换。即使你的下一个项目使用的是另一个原型板或技术,我也希望你能找到有用的建议,并且享受我们一起完成的构建简单但互动的设备的工作。
1632

被折叠的 条评论
为什么被折叠?



