65.9K
CodeProject 正在变化。 阅读更多。
Home

GarageWatcher - 一个全栈物联网项目,从硬件到应用程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2020年6月2日

CPOL

31分钟阅读

viewsIcon

18177

开发物理设备、无服务器后端和Android应用程序的思考、技巧和经验教训

前言

想象一下,你正驾车行驶在社区街道上,跟着收音机哼着歌,享受着阳光明媚的一天,突然一个不可避免的问题闪过你的脑海。

“我把车库门开着了吗?”

恐慌!恐惧!害怕!当你意识到也许,仅仅是也许,你忘记做了一件曾经是习惯的事情时,一股情感的洪流涌上心头。但你也不能确定你没有关门。值得回家再检查一遍吗?你能相信你的直觉吗?去商店的短途旅行是安全地把门开着的时间吗?

当然,解决这个问题的简单方法是在车道上建一条护城河,直到你关上车库门才打开。或者,你购买了许多支持物联网的设备之一,将它们硬连接到你的车库门开启器上,花一天时间尝试将其连接到正确的网络,然后余生都在担心其他人会窃取你的登录凭据,并用你的房子玩红绿灯游戏。

相反,让我们通过构建自己的物联网设备、无服务器云后端和应用程序,让我们的生活变得更困难——也更有成就感!

引言

本文将带您了解这一系列复杂项目的过程和经验教训

  1. 构建适合我们前提的电池供电的ESP-32设备
  2. 通过Firebase在云端组装后端
  3. 编写Android应用程序以检索和影响我们的数据

我将借此机会声明,我绝不是这些方面的专家——甚至也不是新手。事实上,您会看到我只是在好奇心和跨学科的虐待狂本性的驱动下,磕磕绊绊地完成了许多步骤。请不要把我的话(或工作)奉为圭臬。事实上,我恳请您做得更好。我之所以不分享这些项目的全部源代码,是因为它们是几天工作的糟糕结合,我并不引以为豪。然而,我在此提供的代码片段应该足以作为参考,让您能够独立完成并组装出精美的东西。

既然如此,让我们把这当作一次学习经历来享受,好吗?

设备

构建硬件

几个月前,我在一门大学课程中偶然发现了NodeMCU ESP-32S开发套件,当时我们正在构建一辆应用程序控制的玩具车。以前,我主要使用Arduino及其Adafruit变体,当时我抱怨获得互联网连接是一个昂贵而复杂的过程。我所知道的唯一替代方案是Raspberry Pi,这对于我心中设想的小项目来说通常是杀鸡用牛刀,而且开销也太大。仔细阅读ESP-32的规格突然为我解锁了一整套新的规则,我尽可能快地抢购了一些WROOM模块。

对于这个项目,我尝试了几种操作变体。

  • 让ESP-32始终开启,向云端发送连续数据流。这可以通过直接从墙壁插座为设备供电来实现。不幸的是,我的车库里插座不常见,而且将长长的扬声器线缠绕在天花板上似乎很不雅观。
    • 传感器可以是车库门底部的接触开关,也可以是激光中断器。您也可以在门关闭时将传感器安装在门的顶部,以避免雨水意外渗入。
    • 太阳能本来是一个不错的补充,但遗憾的是,我居住的地方阳光并不那么充足。
  • 让ESP-32由车库门开关供电。这会有风险,因为你需要调整现有的线路,而且我没有信心能找到每根线功能的正确文档。此外,这里的项目范围从来不是远程控制车库门,而只是关于门打开的通知或状态指示。
  • 让ESP-32仅在门打开或打开时供电。这将减少功耗,但很难判断门何时关闭。

最终,我选择了最后一个选项。由于最重要的信息是门是否开着,我将其作为决定因素。

那么,我将如何构建一个只在门打开时才通电的设备呢?事实证明,车库门不会垂直升起(至少我的不会)——它们会水平折叠在天花板上。这意味着物理倾斜开关将作为打开和关闭我们设备的完美开关。通过将开关与关闭的门板垂直方向稍微偏离几度安装,一旦门板开始缩回天花板,电路就会闭合。

如果您不熟悉倾斜开关,它们是带有内部液态水银珠和两根电线的微型玻璃胶囊。水银珠始终与较长的电线接触,但如果您以正确的方向倾斜胶囊,水银就会与较短的电线接触,从而完成电路。它们容易受到惯性影响,所以理论上摇晃它也可以使其触发,但固定在车库门上,我并不担心。

我用一个220µF的电容器来阻尼水银在开关中弹跳可能产生的电压冲击——我不是电气工程师,但反复向ESP-32施加6伏电压似乎是个坏主意,如果我没记错我的ECE课程,电容器有助于阻尼这种噪音。事后看来,也许电感器会更有用,但唉,我手头没有现成的。

6伏电压来自四节AA电池,我天真地预测它们至少可以使用几个月,因为设备大部分时间都处于关闭状态。

蜂鸣器连接到ESP-32的引脚12,用于提供一些声音响应,ESP-32的内置状态LED连接到引脚2。蜂鸣器有点安静,所以加一个晶体管给它提供6V而不是区区3V可能会有帮助,但我手头没有。

然后我将所有东西都装在一个塑料食品容器中,以防潮,并将整个盒子固定在我车库门第二高面板的内部。我最初尝试使用胶带,但当四节AA电池的重量似乎太重时,我钻了几个孔。到目前为止,它保持得很好。温馨提示:留出足够的空间将USB插入微控制器,以防您需要更新它。我的盒子里所有东西都是热熔胶固定的,所以拆卸起来要麻烦得多。幸运的是,我留出了足够的间隙,可以舒适地插入我的MicroUSB电缆,而不会损坏组件。

设备物理组装完成后,是时候查看代码了。

构建代码

该设备的前提是它只在门打开时才通电,因此设备代码本身充当“打开”传感器。因此,我们所需要做的就是连接到WiFi,向云端发送信号,并持续发送信号直到被告知休眠。

对于这个项目,我坚持使用Arduino IDE为ESP-32编写和编译代码。我本可以在上面安装MicroPython并运行,但这是一个不小的过程,而且我并不真正需要任何Python包来从ESP-32获取我需要的东西。

第一步是连接到互联网。我只是简单地使用了一个常见的代码片段来实现这一点

  // connect to wifi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Establishing connection to WiFi..");
  }

除了串行输出,我还设置了一个状态灯代码,它会让我知道代码中何时达到了某些里程碑。启动时,灯会闪烁一次,使用on()表示它已开始搜索WiFi

  Serial.begin(9600);
  pinMode(LED, OUTPUT);
  pinMode(BUZZ, OUTPUT);

  // start indication that power is available
  on(50); // value indicates ms to remain on

  // connect to wifi

接着快速闪烁五次,表示连接成功

  // indicate wifi found
  Serial.println("WiFi found!");
  for (short i = 0; i < 5; i++){
    on(75);
    delay(50);
  }

建立连接后,设备将向云端发送通知,告知所有人门已打开。

此时的问题是“哪种基础设施最有意义?”。我想避免运行虚拟机或网络服务器,原因是我知道停机时间远远超过正常运行时间,而且我还想涉足无服务器应用程序的荒野。正如您将在本文后面看到的那样,我最终使用了Firebase的Functions来实现这一点。

ESP-32发送的“唤醒”信号将触发一个HTTP函数,该函数将更新一些其他内容,同时返回一些关键信息供ESP-32遵守。

void initialChirp() {
  // send a hello message
  String result = sendAwake();
  Serial.println(result);
  // result should tell us delay values in seconds 
  // {"result":"good morning","d1":"#","d2":"#","quiet":"?"}

sendAwake()函数创建一个HTTP客户端,将其连接到我的云函数,并对返回的值进行一些解析

String sendAwake() { // adapted heavily from randomnerdtutorials
  HTTPClient http;
  Serial.println("Sending awake...");
  http.begin("https://redacted-app-name.cloudfunctions.net/awake");

  // Send HTTP POST request
  int httpResponseCode = http.GET();
  String payload = "{}";
  if (httpResponseCode>0) {
    Serial.print("HTTP Response code: ");
    Serial.println(httpResponseCode);
    payload = http.getString();
    blinkCode(httpResponseCode);
  }
  else 
  {
    Serial.print("Error code: ");
    Serial.println(httpResponseCode);
  }

  // Free resources
  http.end();
  return payload;
}

blinkCode()函数只是简单地获取HTTP返回代码的第一位数字,并使状态LED闪烁该数字的次数,因此成功的HTTP调用(200)将闪烁两次

void blinkCode(short value){
  for (int i = 0; i < value / 100; i++){
     on(50);
     delay(100);
  }
}

sendAwake()函数的返回值实际上是数据库的JSON格式内容

{"result":"good morning","d1":"#","d2":"#","quiet":"?"}

有了这个,我们可以为当前时期初始化一些设置,即两个延迟时间和一个静音模式。第一个延迟是开门到发送通知之间的时间量;这应该足够长,以便在发送通知之前发生正常的开闭事件。如果你的手提满了杂货,或者你正在倒车入车道,手机上的蜂鸣声催促你并不是很受欢迎。第二个延迟是通知之间的时间量,旨在促使你关门。静音模式启用或禁用蜂鸣器,因此如果这个声音让你心烦意乱,可以远程禁用它,而无需拔掉电线。

我是这样提取每个细节的

  d1 = (result.substring(31, 33)).toInt();

  d2 = (result.substring(41, 43)).toInt();

  quiet = (result.substring(54, 55) != "f"); // defaults to true rather than false

我听到你们抱怨在这里使用String和substring——请忍耐一下。在16 MHz和2KB SRAM的普通Arduino Uno上,这些是昂贵的操作,但ESP-32给我提供了240 MHz和520 KB来使用。

另外,是的,如果我尝试将延迟设置为超过一个半小时,子字符串将不起作用——但我认为这里的标准用例可能在5到20分钟之间,所以请将其视为操作限制。如果您要追求类似的项目,但希望能够使用任意数量的数字,我建议您研究strtok()或其他形式的索引和拆分。这可能也是考虑进入MicroPython的好时机。

设置完所有运行时设置后,是时候开始工作了。

  // first wait period
  delay(1000 * 60 * d1);

  // send a chirp
  result = sendChirp();

sendChirp()类似于sendAwake(),只是调用的函数不同,并且它只返回一个简单的“good chirp”消息。有一个带有参数的单个方法来决定调用哪个云函数会是更好的做法吗?是的。我没有这样做的唯一原因是因为当我第一次开始编写此代码时,我试图使用更多数据附加到HTTP调用来调用云函数,这意味着我需要一个专门的方法来组织所有这些。

现在,在发送“唤醒”信号和第一个“啾啾”信号之后,代码进入主程序循环。

void loop() {
  delay(1000 * ((60 * d2) - 15)); // minus 15 seconds to check for snooze

  // check and see if we've been told to snooze
  if (readSnooze()) {
    // go to sleep
    Serial.println("Going to sleep! Good night.");

    // funcs are auto-included via compiler
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_OFF);
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_OFF);
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);
    esp_deep_sleep_start();
  } 
  else
  {
    // wait 15 seconds and then chirp
    delay(1000 * 15);
    Serial.println(sendChirp());
  }
}

主循环的任务是检查 ESP-32 是否被命令“小睡”,如果不是,则发送另一个“啾啾”信号。它通过轻微休眠 d2 指定的时间来实现这一点,但在下一次“啾啾”之前 15 秒,它会向云函数询问我们是否有“小睡”命令。

这种设置引出了一个问题:如果我有数据库可用,为什么不直接从数据库请求信息,特别是因为不需要特殊的解析或处理?

答案很简单:身份验证。安全的数据库需要身份验证,但这也意味着我需要做更多的工作才能从中提取数据。特别是在ESP-32、HTTP和REST协议、Firebase以及我为云后端探索的所有其他选项这样对我来说不熟悉的环境中工作,简单是最好的。调用云函数不需要身份验证,只要我确保这些函数只涵盖特定范围的操作(并且函数不会泄露),它就足够安全了。我将在云部分更详细地讨论这些。

回到代码;readSnooze()是对云函数的另一次调用,它只返回类似{"result":"true"}的内容。我所要做的就是获取结果的第一个字符,即“t”或“f”,以知道设备是否应该休眠。事实上,我在函数本身中就做了这一点

bool readSnooze() {
  HTTPClient http;
  ...
  String payload = "{}";
  ...
  http.end();
  return (payload.substring(11, 12) == "t");
}

如果我们应该“小睡”,在该循环中间有一大段奇怪的esp_函数。ESP-32提供了许多选项,您可以将其功耗降低到什么程度,甚至可以禁用大多数计算功能,只留下RTC运行。我想利用这一点来节省电量,因为我们使用的是AA电池,所以即使车库门必须长时间开着,CPU也不会持续消耗高电流。

如果您有兴趣让ESP-32在延迟停机期间也进入睡眠状态,可以使用esp_sleep_enable_timer_wakeup(time),它会使CPU休眠给定数量的微秒——但这种启动不会从堆栈的最后一个位置继续;相反,它会再次启动整个程序,从setup()函数开始。您需要保存有关当前运行时的状态信息,即通过将RTC_DATA_ATTR应用于某个状态保持变量,让设备知道这是定时唤醒而不是完全重置。RandomNerdTutorials有一篇精彩文章介绍了所有这些,我建议您查看。

通过“小睡”检查和CPU休眠调用,硬件上的代码就此结束。我们现在可以继续构建支持此代码的云函数和数据库。

选择服务,又名漫漫长路

在将这个项目从零开发到完成的过程中,我尝试了几家云服务提供商。我从我的Android手机开始搜索——也就是,我需要将数据推送到手机上。我不想设置一个轮询服务器以获取状态更新的应用程序,而是想尝试推送通知。据我所知,最直接的解决方案是使用Firebase云消息传递(FCM)。这很合理——谷歌当然会为他们的硬件吹捧自己的服务。

在这方面,我别无选择。我以前从未构建过Android应用程序,所以从头开始构建一个在后台运行的轮询系统对我来说有点挑战。Firebase将提供一套完善的SDK,让我可以更专注于学习所有其他东西。

从那以后,我首先去了我最熟悉的云平台——微软Azure。

是的,在我确定Google拥有我需要的工具之后,我立即选择Microsoft似乎有些迂回。但是,请记住,在那个时候,我对无服务器架构的理解充其量是模糊的——我只是想留在浅水区。

Azure确实提供了与FCM兼容的通知中心,所以我认为这是一个很好的起点。剩下的就是创建一些Azure函数来调用,我就可以开始了。然而,麻烦也从这里开始。Azure提供的设置最终Android应用程序的教程相当模糊,并且基于Kotlin——我当时还没有准备好学习。此外,从通知中心到FCM就像从一个消息服务到另一个消息服务,我觉得很傻。

相反,我尝试编写一个Azure函数,该函数将使用REST或Firebase SDK直接请求FCM消息。REST很快就被放弃了,因为我对Web协议的不熟悉阻碍了我理解正在发生的事情。那么,我唯一的选择就是使用Python中的SDK。这应该足够简单;几年前我就广泛使用过Python,我期待着检验我的知识。

不幸的是,一定出了问题,因为我在尝试在函数上安装正确的SDK时完全无助。Azure的KUDU控制台对我失败了,并且拒绝加载——这意味着我无法管理Web应用程序。

事后看来,这可能是一种伪装的祝福,因为我最终选择的解决方案比为了让所有东西都能在这里通信所需的拼凑要优雅得多。

我立即查看的第二个平台是亚马逊网络服务(AWS)。此时,我仍在寻找能够编写使用Firebase SDK与FCM通信的云函数的能力。亚马逊的Lambda服务看起来很有希望——直到我试图弄清楚如何在函数中安装SDK。

再次强调——请记住,我对所有这些无服务器架构的东西都是完全的新手。Lambda要求您使用Lambda Layers,这是他们通过CLI上传依赖项等的系统。现在,虽然这并非火箭科学,但我通常是一名PC开发者。我了解如何使用库打包应用程序,我了解如何嵌套Python目录以使它们相互引用。被要求上传一个ZIP文件触动了我精疲力尽的神经,因为我已经对Azure和Firebase感到压力重重。

好吧,我心想。我直接深入Firebase,看看我能做些什么。

就这样,我准备好学习所有关于Firebase函数、实时数据库和FCM的知识。

组织一切

花在研究和故障排除上的所有时间让我有足够的时间思考如何安排从设备到云端到应用程序,再返回的数据和消息。事件的顺序最终如下

  1. 设备向云函数发送“唤醒”或“啁啾”消息。
  2. 云函数操作数据库以指示设备的最后已知状态。
  3. 云函数在必要时,要求FCM向订阅的设备发送推送通知。

我开始编写云函数,同时开发硬件代码,这样我就可以确保双方都能传递对方可以理解的信息。Firebase Functions,与Azure或AWS不同,不支持Python——所以我最终不得不学习一点Javascript。老实说,它并没有我想象的那么糟糕。

我最终写了四个函数

exports.awake = functions.https.onRequest(async (req, res) => {...});

exports.chirp = functions.https.onRequest(async (req, res) => {...});

exports.getSnooze = functions.https.onRequest(async (req, res) => {...});

exports.setSnooze = functions.https.onRequest(async (req, res) => {...});

它们的用途非常直接。awake将数据库中的“awake”和“snooze”标志分别设置为“true”和“false”,表示设备处于活动状态。getSnooze检索“snooze”值,如前面硬件代码中所述,而setSnooze强制将“awake”设置为“false”,并将“snooze”设置为“true”。我们将在稍后的应用程序部分看到这两个标志如何用于确定设备状态。

现在,由于我将函数托管在Firebase本身,我可以立即访问我的Firebase项目的其余部分。在安装Firebase CLI并设置好项目之后,我可以快速连接到项目的实时数据库并根据需要操作条目。

初始函数文件`index.js`提供了一个示例函数供使用。我从它开发了我的函数

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.awake = functions.https.onRequest(async (req, res) => {
// called when the esp32 wakes up. reset cache in database.
    var db = admin.database().ref()
    db.child('awake').set('true')
    db.child('snooze').set('false')

    var d1 = 20
    var d2 = 10
    var quiet = "true"

    db.child('d1').once('value').then(function(dataSnapshot){
        d1 = dataSnapshot.val();
        db.child('d2').once('value').then(function(dataSnapshot){
            d2 = dataSnapshot.val();
            db.child('quiet').once('value').then(function(dataSnapshot){
                quiet = dataSnapshot.val();
                res.json({result:'good morning', d1:`${d1}`, d2:`${d2}`, quiet:`${quiet}`})
            });
        });
    });
});
...

为了访问数据库,需要两个元素:const admin = require('firebase-admin'),以及var db = admin.database().ref()。有了这个,db现在持有一个数据库的引用,可以实时读写。然而,写入数据库使用简单的.set()方法,而检索信息则稍微复杂一些。

Firebase SDK提供了db.child().once()异步方法,用于检索元素中存储的值。由于我刚开始学习JS,我决定使用快速粗暴的方法,嵌套同步调用once() ,并在轮询到最后一个元素后返回所有值。

值得注意的是,如果您不以res.json({})调用结束函数,Firebase函数也可以正常执行,但函数执行会返回错误代码-11,并且需要额外的几秒钟才能传输。因此,我建议您始终从函数调用中返回一些内容,即使您的调用代码,即sendChirp(),实际上不需要接收有效负载

exports.chirp = functions.https.onRequest(async (req, res) => {
    var topic = 'all';
    var message = {
        data: {
            note: 'chirp'
            },
        topic: topic
    };

    // Send a message to devices subscribed to the provided topic.
    admin.messaging().send(message)
    .then((response) => {
        // Response is a message ID string.
        console.log('Successfully sent message:', response);
    })
    .catch((error) => {
        console.log('Error sending message:', error);
    });
    res.json({result:'good chirp'})
});

在Firebase这边,剩下的唯一一件事就是为数据库设置身份验证。因为我只打算将我的应用程序分发给我的家人,所以为此创建用户帐户是不必要的,所以我简单地启用了匿名登录——即,任何拥有该应用程序的设备都可以操作数据库,但仅仅查看HTTP端点的人则不能。这是通过简单地检查身份验证令牌是否存在来实现的

{
  "rules": {
    ".read": "auth != null",
    ".write": "auth != null "
  }
}

这将允许同一总体项目内的Firebase函数和拥有该应用程序的用户完全访问数据库。

有了这些,我们就可以应对安卓应用这个庞然大物了。

应用程序

在开始这个项目之前,我编写应用程序的经验几乎为零。我以前曾将一些测试WPF应用程序部署到我的Windows Phone上,直到它成为过时的新闻,以及一些Unity增强现实艺术项目部署到我的Android手机上。这一次,我将从头开始。

Android Studio一开始就相当令人望而生畏。说实话——有很多按钮和设置,作为一个习惯于Visual Studio的人,这比我预想的要密集得多。

设置应用程序

Android开发的第一个奇怪之处是`build.gradle`文件和依赖同步。这是一个我以前从未接触过的领域——VS的包管理器和依赖导入器(以及Python的`pip install`)把我宠坏了,不得不在多个地方通过包名字符串添加自己的依赖项相当令人困惑。我最终掌握了基本知识,但如果你让我再次编写应用程序,而没有同时打开大约二十个教程,那将是一个真正的挑战。

在项目的`build.gradle`中添加Google服务插件后,

buildscript {
    repositories {
        ...
    }
    dependencies {
        ...       
        classpath 'com.google.gms:google-services:4.3.3'

然后,在应用的`build.gradle`中实现所有必要的包后,

apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services'
...
dependencies {
    ...
    implementation 'com.google.firebase:firebase-messaging:20.2.0'
    implementation 'com.google.firebase:firebase-functions:19.0.2'
    implementation 'com.google.firebase:firebase-database:19.3.0'
    implementation 'com.google.firebase:firebase-auth:19.3.1'
}

真正的工作可以开始了。至于Firebase SDK的版本号,我使用了在在线教程中找到的版本号——我不知道最新的版本号是什么。我确信有资源可以告诉您应该使用哪个,但是嘿——如果它没坏,就不要修它。(我开玩笑的,请修复它。)

应用程序最重要的部分——也是Firebase深度集成的全部原因——是它必须接收推送通知。我以此作为起点,无论是在代码方面,还是在学习Android应用程序基础设施的经验方面。

根据我粗略的阅读和搜索教程的理解,`AndroidManifext.xml`文件向操作系统描述了应该应用于应用程序的钩子。在这种情况下,最重要的钩子是允许我们的应用程序监听来自Firebase的消息

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.--.--">
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <application ... >
    ...
    <!-- [START firebase_service] -->
        <service
            android:name="com.--.--.MyFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>

再说一次,据我所知,这允许项目类“MyFirebaseMessagingService”通过其他地方引发“MESSAGING_EVENT”操作来接收通知。那么,这个类就需要像这样

public class MyFirebaseMessagingService extends FirebaseMessagingService {
    @Override
    public void onDestroy() {}
    @Override
    public void onCreate() {}
    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {}
}

`FirebaseMessagingService`是Firebase SDK中的一个类,它被这样导入

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;

最终结果是,当您的手机收到FCM消息时,onMessageReceived会被调用。这个断言之后的问题是双重的:您的手机是如何首先连接到消息服务的,以及当应用程序未启动时,您如何让此服务进行监听?

为了将应用程序连接到FCM服务,Firebase提供了一个`google-services.json`文件,其中包含应用程序的身份验证信息。似乎此文件在应用程序构建或执行时会自动使用,以授予对Firebase项目的访问权限,您会注意到`com.google.gms.google-services`也应用于应用程序的build.gradle中。

需要注意的另一件事是清单中声明的RECEIVE_BOOT_COMPLETED权限。这就是我们让Android知道在手机启动时启动应用程序的方式,以便无需用户手动启动应用程序即可立即接收消息。为了将此事件与应用程序关联起来,我们必须通过清单将其指向另一个类,如下所示

<manifest ...>
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    ...
    <application
        ...
        <receiver android:name=".autostart">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>

只要BOOT_COMPLETED事件被触发,就会执行“autostart.java”类。

编写应用程序

在这里,我们终于开始在应用程序中构建管道,允许用户与设备状态等进行交互。

与消息服务类类似,我们必须扩展一个Android基类才能接收BOOT_COMPLETED事件并执行我们的初始化

public class autostart extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent arg1) {
        Intent intent = new Intent(context, MyFirebaseMessagingService.class);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            context.startForegroundService(intent);
        } else {
            context.startService(intent);
        }
    }
}

请注意,我们创建的Intent启动的是MyFirebaseMessagingService实例,而不是应用程序的MainActivity。这样做的原因是我们不需要在启动时弹出应用程序屏幕;我们所需要做的就是确保推送通知侦听器正在运行,以便消息可以到达用户。

从那里,我们还需要确保侦听器在Firebase提供的发布/订阅模型中正确地侦听正确的“主题”。我们将在每次实例化类时执行此操作

public class MyFirebaseMessagingService extends FirebaseMessagingService {
    ...
    @Override
    public void onCreate()
    {
        FirebaseMessaging.getInstance().subscribeToTopic("all")
                .addOnCompleteListener(new OnCompleteListener<Void>() {
                    @Override
                    public void onComplete(@NonNull Task<Void> task) {
                        String msg = "subscribed to 'all'.";
                        if (!task.isSuccessful()) {
                            msg = "couldn't subscribe to firebase!"; 
                        }
                        Log.d(TAG, msg);
                    }
                });
    }

这说起来很多,就是将我们的FirebaseMessaging实例订阅到“all”主题,然后将结果报告到日志中。如果您还记得,当我们从设备向Firebase函数发送“啁啾”时,我们通过以下方式提供数据负载和主题

exports.chirp = functions.https.onRequest(async (req, res) => {
    var topic = 'all';
    var message = {
        data: {
            note: 'chirp'
            },
        topic: topic
    };
    ...
});

如果您想扩展主题,理论上可以将通知发送给选择接收该类型通知的用户;在我目前的模型中,车库门打开时或另一个用户暂停或关闭车库时没有即时通知——如果这些信息对某人有用,他们可以选择收听更详细的通知,而无需设置更多的侦听器和FCM服务器。

从这里开始,如何处理收到的推送通知取决于您。在我的情况下,我只是显示了一个弹出通知,提醒用户车库门已长时间打开,并提供了一个选项,可以立即暂停进一步的通知一小时

@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
    ...
    NotificationManager notificationManager =
            (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
    popNotification(this, notificationManager);
}

public static void popNotification(Context dis, NotificationManager notificationManager){

    Intent intent = new Intent(dis, MainActivity.class);
    intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
    PendingIntent pendingIntent = PendingIntent.getActivity(dis, 0 , intent,
            PendingIntent.FLAG_ONE_SHOT);

    Intent IgnoreIntent = new Intent(dis, sendIgnore.class); 
    PendingIntent PendingIgnoreIntent = PendingIntent.getActivity(dis, 0, IgnoreIntent, 
            PendingIntent.FLAG_ONE_SHOT);
    String channelId = "com.--.--.app.channel";

    Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
    NotificationCompat.Builder notificationBuilder =
            new NotificationCompat.Builder(dis, channelId)
                    .setContentTitle("GarageWatcher Notification")
                    .setSmallIcon(R.drawable.ic_stat_ic_notification)
                    .setContentText("Garage door is open!")
                    .setAutoCancel(true)
                    .setSound(defaultSoundUri)
                    .setContentIntent(pendingIntent)
                    .setPriority(NotificationCompat.PRIORITY_MAX)
                    .setDefaults(Notification.DEFAULT_ALL)
                    .addAction(R.drawable.ignore, "Snooze 1 hour", PendingIgnoreIntent);
    
    // Since android Oreo notification channel is needed.
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        NotificationChannel channel = new NotificationChannel(channelId,
                "GarageWatcher Channel",
                NotificationManager.IMPORTANCE_HIGH);
        assert notificationManager != null;
        notificationManager.createNotificationChannel(channel);
    }

    assert notificationManager != null;
    notificationManager.notify(0, notificationBuilder.build());
}

在这里,您会发现我创建了一个static方法(以防其他活动也需要它),它会弹出具有最高可用优先级的通知,以便用户在没有完全静音通知的情况下也能收到声音提示和振动。

在我深入探讨上面代码的每个部分之前,我想强调一下我有多不喜欢它。使用“dis”传递上下文是令人厌恶的,因为它可能更容易简单地使用getApplicationContext(),但我对Android的Contexts和Activities不熟悉,无法自信地使用这种安排。

除此之外,代码是直观的,并分为四个部分(如果算上奥利奥注释,则为五个):声明当用户点击通知时应该运行的Intent,即显示应用程序屏幕;声明当通知的“小睡”按钮被点击时应该运行的Intent,即让设备进入睡眠状态并在一个小时后提醒用户;开发通知并对其应用所有必要的设置,例如名称、图标和优先级;最后使用我们可以稍后引用的ID启动通知。

下一个相关代码将是sendIgnore.class,我们将用它来解除通知并设置一个定时事件,以提醒用户门仍然开着

public class sendIgnore extends Activity { 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        AlarmManager alarmManager = (AlarmManager) getSystemService(ALARM_SERVICE);
        Intent ChirpIntent = new Intent(getApplicationContext(), wakeup.class);
        ChirpIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent PendingChirpIntent = PendingIntent.getActivity(getApplicationContext(), 
                0, ChirpIntent, PendingIntent.FLAG_UPDATE_CURRENT);

        // send a snooze request to database
        FirebaseFunctions
                .getInstance()
                .getHttpsCallable("setSnooze")
                .call();

        long seconds = 60 * 60 * 1; // s / m / h
        AlarmManager.AlarmClockInfo info =
                new AlarmManager.AlarmClockInfo(
                        System.currentTimeMillis()+ (seconds * 1000),
                        PendingChirpIntent);

        assert alarmManager != null;
        alarmManager.setAlarmClock(info, PendingChirpIntent);
        NotificationManager manager = 
                (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
        manager.cancel(0);
        finish();
    }
}

`sendIgnore.onCreate`的第一个重要部分是通过我们的Firebase函数`setSnooze`来操作数据库。回想一下,这会将我们的设备状态设置为`awake` = `false`和`snooze` = `true`。然后设备将读取它应该进入小睡状态,并进入深度睡眠。

下一节设置Android的闹钟系统。因为我们希望这个提醒无论应用程序是否正在运行都能显示出来,而且由于时间相对较长,我们将使用早上叫醒你的相同闹钟。我们将一个新的Intent,这里命名为“`ChirpIntent`”,分配给闹钟的动作,它只是更新数据库并再次弹出主应用程序。

有几种选项可以设置未来的闹钟;由于Android内置了各种省电功能,不同的选项在准确性上有所不同。在我们的例子中,提醒不一定要精确到秒,所以我们将使用更通用的setAlarmClock()方法。

最后,设置闹钟后,我们将通过引用我们之前给它的相同ID来关闭通知弹出窗口——在本例中为“0”。在onCreate方法结束时调用finish()可以阻止Activity类的其余实例化和销毁代码运行,从而节省资源。

请注意,`ChirpIntent`调用了另一个类,`wakeup.class`

public class wakeup extends Activity { 
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // do stuff on the db to say not snoozing
        FirebaseDatabase database = FirebaseDatabase.getInstance();
        DatabaseReference snooze = database.getReference("snooze");
        DatabaseReference awake = database.getReference("awake");
        snooze.setValue("false");
        awake.setValue("true"); // fake an awake device 

        // cancel any alarms
        Intent ChirpIntent = new Intent(getApplicationContext(), wakeup.class);
        ChirpIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent PendingChirpIntent = PendingIntent.getActivity(getApplicationContext(), 
                0, ChirpIntent, PendingIntent.FLAG_UPDATE_CURRENT);
        final AlarmManager alarmManager = (AlarmManager) getSystemService(ALARM_SERVICE);
        alarmManager.cancel(PendingChirpIntent);

        // then go to main page
        startActivity(new Intent(wakeup.this, MainActivity.class));
    }
}

这里,您会看到另一个代码很糟糕的实例,但请耐心听我说。

首先,我们获取一个`FirebaseDatabase`实例的引用,它允许我们操作值。以前,我们的操作都围绕着调用我们的函数;这个类演示了也可以直接编辑数据库值。

然后,您会看到我以与`sendIgnore`类中完全相同的方式重新实例化了“`ChirpIntent`”。是的,创建一个全局变量并再次引用它会更好。无论如何,这是取消任何待定闹钟所必需的。Android在创建和维护闹钟方面没有提供太多选项,因此取消闹钟的唯一方法是向其提供具有相同“签名”的`PendingIntent`。

如果这个闹钟本身就是触发该类的原因,那我们为什么要取消它呢?嗯,有可能在闹钟到达预定的时间之前激活这个触发器。这有两种情况:第一种是,如果您点击Android通知中心中的闹钟通知——第二种是来自我们应用程序的`MainActivity`。无论哪种情况,我们都希望确保在手动取消闹钟后它不会响起。

最后,我们开始构建应用程序的MainActivity

我在垂直LinearLayout上布置了几个TextView元素,以及几个按钮,它们将作为用户界面。

状态标签,目前显示“`UNKNOWN`”,在操作期间将显示五种消息之一

  • 加载中”:在应用程序有机会进行身份验证并连接到数据库以检索状态信息之前。
  • 门开着”:当数据库中的“awake”值为`true`时。只有在门打开后设备立即开启,或者如上所述,小睡期间被闹钟打断时,“awake”才为`true`。
  • 小睡中”:当“awake”为`false`且“snooze”为`true`时。这发生在按下“snooze”按钮时,无论是在弹出通知中还是在应用程序的MainActivity中。
  • 您关了”:当“awake”和“snooze”都为`false`时。这仅发生在用户手动按下应用程序上的“我将关闭它”按钮时。
  • 未知”:当“awake”或“snooze”的数据库值无法识别时。虽然值发生任何变化导致正常操作中断的可能性极小,但最好向用户提供反馈,而不是将错误隐藏在可能不准确的状态之后。

我们稍后会看到如何实现这一点。

当“snooze”激活时,应用程序的小睡按钮会消失。解除闹钟的唯一方法是按下“我将关闭它”按钮或闹钟通知,这两个操作都会促使用户关闭门(请记住,解除闹钟也会弹出主页,现在应该显示“门开着”)。

我们的MainActivity代码看起来像这样

public class MainActivity extends AppCompatActivity {
    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {...}
    protected void ConnectDatabase(){...}
    protected void SetStatus(){...}
}

我特意创建了一些易于访问的应用程序视觉元素引用,以及实例化了一个数据库引用和一个身份验证类,作为MainActivity类的变量

    FirebaseDatabase database = FirebaseDatabase.getInstance();
    TextView txt_Status;
    Button button_close;
    Button button_snooze1;
    LinearLayout snoozer;
    String knownAwake = "unknown";
    String knownSnooze = "unknown";
    private FirebaseAuth mAuth;

这些在`onCreate`方法中按需分配。此外,我还在这里为按钮附加了行为,这些行为已在前面描述过,因此在此省略

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        //get auth for firebase
        mAuth = FirebaseAuth.getInstance();
        mAuth.signInAnonymously();

        final AlarmManager alarmManager = (AlarmManager) getSystemService(ALARM_SERVICE);
        setContentView(R.layout.activity_main); 
        txt_Status = findViewById(R.id.txt_status);
        txt_Status.setText("Loading...");
        button_close = findViewById(R.id.button_close);
        button_close.setOnClickListener(new View.OnClickListener(){
            public void onClick(View view) {
                // edits the firebase database to indicate the state is closed
                ...
                // cancel any alarms
                ...            
            }
        });

        button_snooze1 = findViewById(R.id.button_snooze1);
        button_snooze1.setOnClickListener(new View.OnClickListener(){
            @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
            public void onClick(View view) {
                // edits the firebase database to indicate snoozin
                ...                
                // proactively clear any previous alarms j.i.c.
                alarmManager.cancel(PendingChirpIntent);
                // set new alarm
                alarmManager.setAlarmClock(info, PendingChirpIntent);
            }
        });

        snoozer = findViewById(R.id.snoozer); // the LinearLayout

        ConnectDatabase();
    }

您在这里看到的最后一个方法调用是`ConnectDatabase()`,它利用了`FirebaseDatabase`提供的一个功能

    protected void ConnectDatabase(){
        DatabaseReference awake = database.getReference("awake");
        DatabaseReference snooze = database.getReference("snooze");
        awake.addValueEventListener(new ValueEventListener() {
            @Override
            public void onDataChange(DataSnapshot dataSnapshot) {
                // This method is called once with the initial value and again
                // whenever data at this location is updated.
                String value = dataSnapshot.getValue(String.class);
                knownAwake = value;
                SetStatus();
            }
            @Override
            public void onCancelled(DatabaseError error) {
                // Failed to read value
                knownAwake = "false";
            }
        });

        snooze.addValueEventListener(new ValueEventListener() {
            @Override
            public void onDataChange(DataSnapshot dataSnapshot) {
                String value = dataSnapshot.getValue(String.class);
                knownSnooze = value;
                SetStatus();
            }
            @Override
            public void onCancelled(DatabaseError error) {
                knownSnooze = "false";
            }
        });
    }

ValueEventListener让我们无需“刷新”按钮或循环事件,因为它在数据库中值更改时自动触发。通过每次收到此事件时调用SetStatus(),应用程序可以始终显示最后已知状态。当然,这依赖于侦听器不会断开连接——所以添加一个刷新按钮是可能的。然而,由于每次启动MainActivity时都会创建此连接,因此只需重新启动应用程序就足以通过onCreate重新建立连接。

最后,我们有更新两个按钮可用性和状态文本的方法

    protected void SetStatus(){

        if (knownAwake.equalsIgnoreCase("true")){
            txt_Status.setText("DOOR IS OPEN!");
            button_close.setVisibility(View.VISIBLE);
            snoozer.setVisibility(View.VISIBLE);
        } else if (knownAwake.equalsIgnoreCase("false")) {
            if (knownSnooze.equalsIgnoreCase("true")){
                txt_Status.setText("SNOOZING...");
                button_close.setVisibility(View.VISIBLE);
                snoozer.setVisibility(View.GONE);
            } else if (knownSnooze.equalsIgnoreCase("false")) {
                txt_Status.setText("You closed it.");
                button_close.setVisibility(View.GONE);
                snoozer.setVisibility(View.GONE);
            } else {
                txt_Status.setText("Unknown.");
                button_close.setVisibility(View.GONE);
                snoozer.setVisibility(View.GONE);
            }
        } else {
            txt_Status.setText("Unknown");
            button_close.setVisibility(View.GONE);
            snoozer.setVisibility(View.GONE);
        }
    }
}

除了对Android界面构建器进行了一些巧妙的处理——这与WPF并非不同,但它确实没有使布局变得容易——应用程序现已完成。

结论

在这个项目中,我们发现了Firebase在为我们的物联网需求创建后端方面的多功能性。我们构建了一个利用现实世界工程约束的物理设备,并将其转化为一个高效节能的传感器。最后,我们构建了一个Android应用程序,它在启动时运行,接收并显示推送通知,并使用警报管理器设置短期提醒。

我希望这个项目对您来说就像我追求它一样有趣,并且您能在其中找到有用的东西,帮助您开发自己的项目。我也希望您能原谅我在这里犯的一些基本错误,并欢迎您的反馈和建议。祝您安全健康!

后记

我之所以开始构思这个项目的核心想法,是因为我父亲有时会忘记关车库门,尤其是在他搬运大量杂货或心事重重的时候。这当然让我的母亲非常恼火,她也很珍惜我们车库里存放的所有设备。我碰巧在他生日的时候完成了应用程序的最后一点构建,作为一份礼物,让他下次忘记关车库门时免于再次被唠叨。

在构思这个想法的过程中,我决定探索无服务器架构。纯粹在云端运行而无需昂贵虚拟机的固有可伸缩性、多功能性和可靠性非常有吸引力,但我很少有机会深入研究新技术。COVID-19的封锁给了我一个完美的时机,让我可以坐下来研读数百份文档和教程——所以,如果您也处于类似的情况,我强烈鼓励您也追求一个可以拓宽视野的项目,哪怕只是一点点。

我不得不承认——在这个项目的整个过程中,我多次自问:在这里实现服务器会不会更容易?答案是……很复杂。是的——这样做更容易,尤其是对于像这样微小的项目范围,但如果你想将这项服务扩展到更多人,或者你想避免因停电或*天哪*Windows更新等本地问题造成的停机时间,无服务器确实有其重要的利基市场需要填补。

感谢您的阅读,并与我一起踏上这段令人眼花缭乱的旅程。我迫不及待地想看看接下来会发生什么。

历史

  • 2020年6月2日:初始版本
© . All rights reserved.