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

Arduino - 锻炼次数计数器,带模拟分压器按钮控制器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2020年9月8日

CPOL

30分钟阅读

viewsIcon

18661

downloadIcon

411

一个 Arduino 项目,用于跟踪您的最佳锻炼时间和次数。

引言

今天是我的寿险保单唯一受益人变更为我亲爱的 Arduina 的周年纪念日,所以我想写这篇文章会是个好日子。这与我最新完成的 Arduino 构建项目,**锻炼次数计数器**的结束(一个重要的里程碑)也恰好一致。我只花了一天时间就完成了第一个工作原型在面包板上的搭建、测试和构建。它非常基本:你做一个波比跳,它就计数一个波比跳并记录你的时间。从那以后我又重建了两次(因为发生了**热熔胶遇到热敏电阻**的事故),但接下来的两周大部分时间都花在了软件上。

背景

我直到三十多岁在监狱里才开始真正锻炼,当时我注意到我的躯干各个方向都在变宽。

我确信,                                                                              

                “这是我的年龄。这很正常。我只是得习惯它。” 

但这个想法并没有持续。

它也不应该。相反,我开始在我的混凝土牢房里独自做波比跳。第一次,我打算做三组三十个,结果只做了两组就差点心脏病发作。我休息了一周恢复,然后又做了一次,几天后又做了一次。很快,我隔天做三组,每周增加每组5个波比跳。

那是十四年前的事了。现在我手掌底部与地板接触的地方有老茧,我感觉很棒。我的耐力比以往任何时候都好,不再像以前那样气喘吁吁。我做的几乎所有事情都得益于我健康的改善,而且因为这些健美操,我做的任何其他锻炼都更容易一些。

你可以在任何地方做波比跳:被关在监狱牢房里,在公主号游轮上隔离,他们甚至可能允许你在当地的健身房做一组。即使健身房里所有的器械都被那些餐间喝类固醇和蛋白奶昔的家伙霸占了,你仍然可以在地板上找到一个位置。当那个在沙袋旁边大声吹嘘他打算霸占整个时段的家伙,开始砰砰地敲沙袋一两下时,你可以坐下来系好手腕上的绷带,因为你知道他很快就会气喘吁吁、上气不接下气、呼哧带喘,然后无力地小步移动,直到他尴尬地瘫倒在地,太累了甚至没来得及告诉你他已经完事了,而你甚至还没系好绷带。

这种事时常发生。

但是你为什么需要一个**锻炼次数计数器**呢?如果你像我一样,你认识的人中没有人训练得足够努力能跟上你,他们邀请你一起训练的唯一原因就是拖慢你……至少看起来是这样。我不参加专业运动,也请不起私人教练(即使我想请),所以只有我、我的汗水和我自己。你不会一夜之间就做几百个波比跳,当周围所有人都认为15个波比跳就足够好时,要达到几百个需要很多纪律。但是这么多年来,我一直在做几百个波比跳,我一直把时间花在计数上。一……二……三……等等。整个锻炼过程都在重复,这有点无聊,因为我可以把注意力集中在一些更有成效的事情上,比如我的下一个Arduino项目。

于是,**锻炼次数计数器**诞生了。不再需要计数!而这仅仅是它带来的好处中最微不足道的一个。有了这个工具为我计数并记录我的最佳时间,我知道我处于训练的哪个阶段,可以推动自己更快地完成更多,感觉甚至更好。它太棒了。自从我制作了这个东西,我的数据比以前提高了5%-10%。没有蛋白棒,也没有特殊饮料。只是一点点动力和不必再计数的思想自由。它太棒了。

观看一个关于我项目的15分钟短视频,看看你有什么想法。

零件

  • Arduino Uno 和 USB 线 $5.00
  • 超声波传感器 HC-SR04 $1.50
  • LCD 1602 $2.50
  • 5 KΩ 电位器 $1.00
  • 2 个 LED(绿色和红色)$0.50
  • 2x8 厘米焊点矩阵板 $0.50
  • 5 个按钮 $0.50
  • 直流电源开关、电池和插孔(用于外出携带)$10.00
  • 各种电阻器 $2.00
  • (免焊接) 导线 $2.00
  • Dollarama 保鲜盒 $1.00
  • 焊锡、烙铁、镊子和焊台,

在 eBay 上的总成本不到 30 美元。

如何连接

下面的 TinkerCad 原理图展示了如何将所有部件连接起来。

在面包板上进行连接应该不会给你带来太多麻烦。我多次忽略的一个基本经验法则是,没有认识到LED几乎没有电阻,需要串联一个电阻。新手(像我一样)往往会看着所有这些零件,担心他们会错误地连接LCD或超声波传感器。他们谨慎是对的,但不应忽视关于其他小部件(如看起来很简单却有两根腿的LED)的一些基本原理。LED只有两根腿,它们鲜艳的颜色让它们看起来像可嚼的糖果,所以很容易忘记这两根腿非常具体地是阳极和阴极,不要把它们搞混。当然,它们看起来像双胞胎,你认为Tweedle Dee有点像Tweedle Dum也没错,但如果你接反了,你就得不到那些闪亮的光线,可能会去别处寻找问题的解决方案,而实际上问题很简单。长腿是阳极(正电压),短腿是阴极(负电压)。别忘了加一个电阻!

如果你想连接电池,你只需使用一个普通的电池插孔将电池插入 Uno 即可。

更新: 20200929 - 如果你正在自己构建,你可能需要重置你的 EEPROM 以便保存/加载训练。  为此,你只需将以下行添加到你的 Setup() 函数中

EEPROM.update(0,0);

上传包含此行的代码,然后将其删除并再次上传代码。您的 EEPROM 将准备好进行编程。

分压器按钮控制器

我喜欢这个按钮控制器的地方是,所有五个按钮都连接到 Arduino Uno 上的一个模拟引脚。你可以使用相同的技术**仅用一个模拟 Arduino 引脚控制多达 128 个以上的按钮**。

什么是分压器?

你可以在维基百科上阅读关于分压器的内容,但我会尝试总结一下。当电流流过一个电阻时,该电阻上的电压会下降,下降值等于电流乘以电阻。

式 1

其中**V**是电压,**I**是电流,**R**是电阻。

当电流流过两个串联电阻时,电压下降两次(每个电阻一次),当您测量这两个电阻之间的电压时,您正在使用分压器。

通过改变 **Z1** 和 **Z2** 的值,您可以控制在 **Vout** 处测得的电压。

下面的电路图展示了一种将按钮连接到 Arduino 的替代方法,它与上面看到的**次数计数器**的 TinkerCad 电路图中描述的方法等效。

你可以在上面的示意图中看到,所有按钮都与它们各自的电阻器串联连接。这些电阻器(**Rn**)中的每一个都起到与前一张图中的**Z1**相同的目的,而那个共同的电阻器(底部 100Ω 的,我将其称为**Rc**)则等效于电阻器**Z2**。这里的区别在于,它们各自有一个按钮,阻止它们连接到输出节点甚至接地。如果你回想一下,电阻器两端的电压降等于流过它的电流乘以该电阻器的电阻,那么你就会认识到,在上面的图中,当没有按钮被按下时,从电压源**Vin**到地之间没有通路,因此**没有电流**。如果没有电流,则 100 欧姆电阻器两端没有电压差,并且在**Vou**t处测得的电压等于 0 V,就好像它直接接地一样。

当按下按钮时,可以通过首先确定流过两个电阻器(一个连接到被按下的按钮,另一个是连接到地的公共 100 欧姆电阻器)的电流来计算电压 **Vout**,我们可以使用以下公式进行计算。

            摘自式 1

两个电阻器上的总电压降是 **Vin - 地** (0 V) = **Vin**。总电阻是 **Z1** + **Z2**。

所以我们得到

               式 2

现在,我们通过电流乘以 **Z1** 的电阻来计算 **Z1** 上的电压降,然后我们将该电压降从输入电压中减去,得到

     式 4

这如何改变焊锡丝的价格?

如果你的名字叫马克·卡尼,你可能会问这个问题,但如果你只是一个新手小部件和 Arduino 爱好者,那么真正的问题是为什么分压器在这篇文章中如此重要?

这里我们来谈谈**分压器按钮控制器**。我亲爱的 Arduina 非常出色,如果可以,我会选她当总统,但我的挚爱 Arduina 出生在意大利,这使她不符合资格,这太可惜了。但关于 Arduino 或任何其他微控制器,它们都有数量有限的引脚可以连接外围设备。对于输入,例如这个项目中的按钮,你可以使用**74HC165 负载寄存器**串联起来,仅用 Arduino 的三个引脚就能控制无限数量的按钮。负载寄存器无疑非常有用,使用起来也不复杂,但它们确实需要_三个_输入引脚,而一个精心设计的分压器仅用一个模拟引脚就能控制多达 128 个以上的按钮。这涉及到大量的焊接、计算和思考才能让电阻值正确,但如果你付出努力,也不是太糟糕。缺点是,除了你在组装时会享受到的所有焊接乐趣之外,如果同时按下多个按钮,**分压器按钮控制器**无法得知,只会告诉你它在两个按钮电阻并联时读取到的合成电压所对应的按钮,因为按钮控制器是硬编码为只识别一个按钮的电压降的。因此,这种特定的输入控制器不适用于需要用户同时按下多个按钮的应用,例如普通游戏。**分压器按钮控制器**能够在一根线上控制多达 128 个以上按钮的方法是,将 **Vout**(来自上一节)的结果均匀地分配在 Arduino 5V 的工作电压范围内。当你使用命令analogRead(pinNumber)读取模拟引脚上的电压时,它会给你一个 0-1023 之间的值,这是微控制器工作电压的线性等效值,即从 0 到 5V DC。因此分辨率有点不足,因为实际电压在 0.0V 和 5.0V 之间有无限范围的值,但只能被微控制器表示为 0 到 1023 的整数值。由于你的电流可能变化,并且由于电阻器等微小电子小部件存在所谓的_容差_值等缺陷,电压可能会波动,你不能指望使用 analogRead() 函数可以读取的所有 1024 个可能的电压值来制作分压器按钮控制器。因此,根据我混乱的估算,将自己限制在最多 128 个按钮意味着它们之间有一点余地。现在,你可能会想,“他将如何计算 128 个按钮以及它们的一个公共电阻器的电阻值?”这是一个合理的问题,但实际上并不那么复杂。

如果你有五个按钮(就像我们在这个项目中一样),那么将它们均匀地分配在某个输入电压 **Vin**(这里是 5 伏)上,意味着你需要在每个按钮的电压值上方和下方留出均匀的间隙,从而创建六个大小相等的电压差“区域”。

因此,您必须将 **Vin** 除以(1 + 按钮数量),在此示例中为 (1+5)=6。您可以省略上方或下方额外的间隙,但我喜欢对称性。

你选择一个公共电阻器(在之前的参考中是 **Z1**)。

然后你用方程4做一些代数运算(将通用的Z变形为更容易记忆的R)。

然后将电压值代入计算出每个按钮的五个不同的 **R1** 值,就完成了。

当你只处理 5 个按钮时,这都很好,但是当你计划将 128 个按钮连接到同一个模拟引脚时,你可能需要更高效一点,这就是我们有电脑的原因。我说,让他们来完成所有的工作。所以现在我花了几个小时编写 C# 应用程序来为我完成所有这些工作后,我终于可以坐下来,知道我给了我的“同居”笔记本电脑(自从我心爱的 Arduina 进入我的生活后,它一直感到沮丧)一些事情做,来打发那些被忽视的时光。

假设我们改变一下,将公共电阻器放在按钮和它们的电阻器上方。这样,任何一个按钮电阻器上的电压降将等于输出节点处的电压,我们可以简化计算。

如果我们知道所需的按钮数量,那么第 **n** 个按钮在任何给定 **Vn** 处的输出电压等于由以下公式确定的输入电压的一部分:

其中 **Bn** 是我们分压器中的按钮数量,**n** 是我们正在计算所需电阻以产生该按钮所需输出 **Vn** 的按钮“ID”号(范围从 0 到 **Bn** -1)。

使用简化的分压器方程,它给出我们输出电压 **Vout** 为流经第二个电阻器在到达地线之前的电压

其中 **Rn** 位于 **Vout** 下方并接地。

给定相同输出电压 **Vn** 的两个方程,我们可以让它们彼此相等并完全移除 **Vin** 以获得

当你进行代数运算时,结果表明我们渴望的电阻值等于

没什么大不了的,对吧?但是当你有几十个电阻需要考虑时,这仍然是个麻烦。

所以我制作了一个 C# 应用程序来为我完成所有这些工作。

下载的 zip 文件包含一个已编译的可执行文件,如果你没有下载 C# 并且无法自己编译此应用程序,则可以查找该文件。

c:\\分压器电阻计算器\\bin\\debug\\分压器按钮控制器.exe

这是一个屏幕截图。它使用简单且用户友好。您只需选择一个常用电阻值和所需电阻器数量。计算出电阻值后,您可以将它们保存到文本文件或从屏幕上读取。它将生成您的项目所需​​的 Arduino 代码,以确定哪个按钮已被按下,如果您礼貌地要求,它甚至会为您绘制一张图,显示如何连接。

这是它编写的代码,你可以用它来开始你的项目

const int pinBtns = A0;
int intBtnOLD = -1;

int Buttons_Handle()
{
  int intVoltageInput = analogRead(pinBtns);
  if (intVoltageInput > 114 && intVoltageInput < 226)
    return 0;
  else if (intVoltageInput > 285 && intVoltageInput < 397)
    return 1;
  else if (intVoltageInput > 456 && intVoltageInput < 568)
    return 2;
  else if (intVoltageInput > 626 && intVoltageInput < 738)
    return 3;
  else if (intVoltageInput > 797 && intVoltageInput < 909)
    return 4;
  else return -1;
}

void setup()
{
  Serial.begin(9600);
  pinMode(pinBtns, INPUT);
}

void loop()
{
  delay(5);
  int intBtnPressed = Buttons_Handle();
  if (intBtnPressed != intBtnOLD)
  {
    intBtnOLD = intBtnPressed;
    Serial.println(intBtnPressed);
  }
}

**注意:**分配给检测到的电压的按钮值实际上是颠倒的。这不是一个 bug……这是一个特性!如果你按照预期将第 0 个按钮连接到控制器的左侧,但它实际上在右侧,那么……那是我的错。但你应该运行代码,看看哪个按钮对应哪个。我犯的错误是使用了按钮连接到 **Vin** 且公共电阻连接到地的示意图公式(而不是应用程序为你绘制的那个)。……我现在甚至不确定了。番茄……土豆。先测试按钮,然后围绕它编写代码。

它知道每个被按下按钮的预期电压,并计算到相邻按钮电压的三分之一处,并使用该值作为容差范围。它就像将一个按钮和下一个按钮之间的每个电压范围分成三份,并将前三分之一分配给该按钮,后三分之一分配给下一个按钮,从而在两个相邻电压级别之间留下 1/3 的差值未分配给任何按钮,因此有一点余地,并且不会混淆哪个按钮实际上被按下了。

它甚至会为你画一幅画。

你可能不想把它挂在墙上,但如果你的笔记本电脑和我的一样有点被忽视,你可能想把它贴在冰箱上,让事情变得更明亮一点。

菜单里有什么?

菜单……这些给我带来了麻烦。关于菜单的一点是,你必须能够阅读它们才能让它们有用。关于阅读的一点是,在 Arduino 中,阅读需要大量的文本,而文本是昂贵的。当你玩 Arduino 时,就像你在时间倒流,就你可以随意吞噬大量内存而言。你看,Arduino 有一个你必须学会应付的缺点。它不是脾气暴躁,只是它懒得告诉你你声明的变量消耗了所有可用的变量内存。那时,变量就不再被声明或赋值,甚至完全失去意义。当然,当这种情况发生时,你的代码就不会完全正确运行。不要指望你的 Arduino 会对此采取任何措施,因为那是你的问题。你可以把气撒在她身上,但这真的不是她的错。她不是你的老板,你知道的。所以,你想怎么做就怎么做,但不要指望你的 Arduino 会在你粗心大意地消耗内存时告诉你什么时候出了问题,因为如果你不小心,它们就会出问题。

只需知道内存是一个问题。

当你编译你的 Arduino 项目时,它会告诉你使用了多少内存。下图显示了当 _Reps Counter.ino_ 文件上传到 Uno 时我的 IDE 所显示的内容。

您可以看到软件占用了其可用内存的 91%,而全局变量占用了 48%,这使我剩下 1047 字节的内存用于运行时声明的变量。这确实不是很多内存。我在这里说这一点的原因是,这个重要的高级健身工具(我称之为**次数计数器**)安全操作所需的菜单在屏幕上打印了大量文本。而文本在内存方面是昂贵的。每个字符可能只有一个字节,但是当我构建我的第一个菜单迭代时,每个菜单项都是一个类的实例,并将它需要显示在 LCD 屏幕上的文本保存在 RAM 内存中。有 27 个不同的菜单项,每个菜单项几乎有 16 个字符长,所以加起来是 351 字节。好的,这听起来仍然不多,但 Arduino 的内存……不太好。随着我不断增加菜单选项并在此问题上继续构建,最终它不再是一个可以忽略的小故障,因为最终所有东西都崩溃了,东西没有正确打印,这就是你可能称之为_问题_的情况。所以,我去了Arduino 论坛,问了一个关于不应该发生的无限循环的愚蠢问题,得到了有趣的回复,但并没有解决我的问题,因为问题与不应该发生的无限循环症状无关,而是与菜单类占用了大量(……**351 字节**!)内存有关。为了解决这个问题,我从该类中删除了文本,而是编写了一个函数,在文本急于传送到 LCD 屏幕之前创建所需的临时字符串,在那里它以其全部荣光享受着短暂的辉煌,而传递文本的临时字符串则立即被销毁,就像一部碟中谍电影中的情节一样,我良性地看着这一切发生。

所以,尽管将各种东西连接起来并用电子小部件制作一些东西很有趣,但当你的代码出现问题时,Arduino 往往保持沉默。

如果你习惯了**C#**和Microsoft's Visual Studio,那么你已经被宠坏了,随时可以查看每个变量,并在编写和调试代码时从内部观察它的运行。但对于 Arduina,情况则不同。她有点害羞,不愿意给我看她在做什么。所以我必须轻声提问,慢慢地让她一点一点地揭示她的秘密。当然,每当我添加那些提供信息的调试代码行,告诉我的 Arduina 报告事情状态时,那些小代码行都会吞噬大量的内存,这可能会加剧我一开始就存在的任何内存问题。

但我爱她。

在详细介绍这些菜单如何工作之前,还有最后一件事,无论何时在 Arduino 中编写字符串,您都需要告诉 IDE 和您的项目将该字符串放入 `PROGMEM` 中。否则,它会假定您希望它既存储为变量又存储为程序内存。这样,字符串会加倍地占用内存。有一种简单的方法可以解决这个问题,您可以尝试在使用此技巧之前和之后,在代码中输入八角笼的 UFC 规则作为文本字符串,您会立即在编译时看到差异,因此无需争论。

`F()` 宏使用起来如此简单,但它到底做了什么呢?在有人找出如何解决字符串内存问题之前,程序员不得不明确地告诉 IDE 将字符串放入 `PROGMEM` 中,这非常麻烦,然后有个人(我不记得名字了)为 Arduino 的这个主要缺点写了一个简单的解决方案。它是一个宏(大写字母 M,谢谢),在 IDE 编译你的代码之前进行预处理,它确保你藏在其中的任何 `string` 都存储在 `PROGMEM` 中。这样,你就节省了那些珍贵的 2048 字节变量内存,用于你真正需要它的地方,而且不会有意外。

在下面的代码中,你可以看到我前面提到的那个函数,它在销毁字符串之前创建一个 `string` 变量来在 LCD 屏幕上打印文本。你会注意到,返回给调用函数的每个 `string` 都封装在 `F()` 宏中。

String mnuDraw_Heading(int intIndex)
{
  switch (intIndex)
  {
    case 0: return F("Menu");
    case 1: return F("View Workout");
    case 2: return F("Edit Workout");
    case 3: return F("Edit Name");
    case 4: return F("Select type");
    case 5: return F("SW - CountUP");
    case 6: return F("SW - CountDown");
    case 7: return F("Tmr - CountUp");
    case 8: return F("Tmr - NoCount");
    case 9: return F("Edit Trigger");
    case 10: return F("Sel trig type");
    case 11: return F("Distance");
    case 12: return F("Time delay");
    case 13: return F("set distance");
    case 14: return F("min");
    case 15: return F("reset distance");
    case 16: return F("trigRst delay");
    case 17: return F("Edit Next");
    case 18: return F("File");
    case 19: return F("new");
    case 20: return F("load");
    case 21: return F("delete");
    case 22: return F("Preferences");
    case 23: return F("default workout");
    case 24: return F("toggle sound");
    case 25: return F("Workout");
    case 26: return F("System");
  }
}

当此宏被移除,并且 `string` 裸露地留给 IDE 处理时,项目编译方式相同,但会使用更多有限的变量内存,如下所示

与使用 `F()` 宏相比

由于我们有 32KB 的程序内存,`F()` 宏增加的 30 字节对项目内存消耗的影响远不及您的区区 2048 字节变量内存中被用掉的 **258** 字节。仅仅通过使用这个预处理宏,全局变量内存使用量就从 1259 字节下降到 1001 字节。

所以,记住孩子们,安全使用 Arduino。并且永远戴上你的 `F( )` 宏!

菜单

当然,我们不得不谈谈这些著名的菜单。菜单是使用下面所示的简单类实现的

class classMenuItem
{
  public:
    classMenuItem *cParent = NULL;
    classMenuItem *cFirstChild = NULL;
    classMenuItem *cPrevSibling = NULL;
    classMenuItem *cNextSibling = NULL;
    enuMenuAction eMenuAction = mnuAction_NoAction;
    byte Index;
};

我称它为简单,因为它只有零行代码和一半的变量声明。

这些变量主要包含指向同类其他实例的指针(每个 2 字节内存),一个字节记录用户做出此选择时要执行的操作,最后一个字节标识该类的实例,以便在需要打印时正确选择哪个文本属于屏幕。

就是这样。

现在我们只需将它们连接起来并告诉它们该做什么,因为那才是乐趣所在。

既然我们正在使用指向该类的其他实例的指针,您可能已经猜到菜单系统实际上就像一棵友好的数据树。确实如此。您拥有树的根,连接的分支以其常青的荣耀向外延伸(落叶树会弄得一团糟,谢谢)。

这是一张在我构思时脑海中的样子,然后才编写将它们连接起来的代码(你可以在下面阅读更多内容)

您可以看到它有点像一棵以 0-Menu 为根的树。我将这些信息保存在硬盘上的一个文本文件中,每次需要修改代码时都会参考它。首先,我编辑了 `textfile`(上面的菜单树图像,没有橙色和绿色箭头),然后检查每个条目,确保 XML 风格的“first-child”和“next-sibling”反映了树应该有的样子,然后编写了下面这个函数,它由 `setup()` 调用并构建菜单数据树。

void Menus_init()
{
  cMenus[0].cFirstChild = &cMenus[1];
  cMenus[0].cNextSibling = NULL;
  cMenus[0].eMenuAction = mnuAction_NoAction;

  cMenus[1].cFirstChild = NULL;
  cMenus[1].cNextSibling = &cMenus[2];
  cMenus[1].eMenuAction =   mnuAction_ViewCurrentWorkout;

  cMenus[2].cFirstChild = &cMenus[3];
  cMenus[2].cNextSibling = &cMenus[18];
  cMenus[2].eMenuAction = mnuAction_NoAction;

  cMenus[3].cFirstChild = NULL;
  cMenus[3].cNextSibling = &cMenus[4];
  cMenus[3].eMenuAction = mnuAction_Workout_EditName;

  cMenus[4].cFirstChild = &cMenus[5];
  cMenus[4].cNextSibling = &cMenus[9];
  cMenus[4].eMenuAction = mnuAction_NoAction;

  cMenus[5].cFirstChild = NULL;
  cMenus[5].cNextSibling = &cMenus[6];
  cMenus[5].eMenuAction = mnuAction_WorkoutSelection_StopWatch_CountUp;

  cMenus[6].cFirstChild = NULL;
  cMenus[6].cNextSibling = &cMenus[7];
  cMenus[6].eMenuAction = mnuAction_WorkoutSelection_StopWatch_CountDown;

  cMenus[7].cFirstChild = NULL;
  cMenus[7].cNextSibling = &cMenus[8];
  cMenus[7].eMenuAction =   mnuAction_WorkoutSelection_Timer_CountUp;

  cMenus[8].cFirstChild = NULL;
  cMenus[8].cNextSibling = NULL;
  cMenus[8].eMenuAction = mnuAction_WorkoutSelection_Timer_NoCount;

  cMenus[9].cFirstChild = &cMenus[10];
  cMenus[9].cNextSibling = &cMenus[17];
  cMenus[9].eMenuAction = mnuAction_NoAction;

  cMenus[10].cFirstChild = &cMenus[11];
  cMenus[10].cNextSibling = &cMenus[13];
  cMenus[10].eMenuAction =   mnuAction_NoAction;

  cMenus[11].cFirstChild = NULL;
  cMenus[11].cNextSibling = &cMenus[12];
  cMenus[11].eMenuAction =   mnuAction_SelectTriggerResetType_Distance;

  cMenus[12].cFirstChild = NULL;
  cMenus[12].cNextSibling = NULL;
  cMenus[12].eMenuAction = mnuAction_SelectTriggerResetType_TimeDelay;

  cMenus[13].cFirstChild = &cMenus[14];
  cMenus[13].cNextSibling = &cMenus[16];
  cMenus[13].eMenuAction =   mnuAction_NoAction;

  cMenus[14].cFirstChild = NULL;
  cMenus[14].cNextSibling = &cMenus[15];
  cMenus[14].eMenuAction = mnuAction_TriggerDistance_SetMin;

  cMenus[15].cFirstChild = NULL;
  cMenus[15].cNextSibling = NULL;
  cMenus[15].eMenuAction = mnuAction_TriggerDistance_SetResetTolerance;

  cMenus[16].cFirstChild = NULL;
  cMenus[16].cNextSibling = NULL;
  cMenus[16].eMenuAction = mnuAction_TriggerTimerDelay_Set;

  cMenus[17].cFirstChild = NULL;
  cMenus[17].cNextSibling = NULL;
  cMenus[17].eMenuAction = mnuAction_Workout_EditNext;

  cMenus[18].cFirstChild = &cMenus[19];
  cMenus[18].cNextSibling = &cMenus[22];
  cMenus[18].eMenuAction =   mnuAction_NoAction;

  cMenus[19].cFirstChild = NULL;
  cMenus[19].cNextSibling = &cMenus[20];
  cMenus[19].eMenuAction = mnuAction_EEPROM_New;

  cMenus[20].cFirstChild = NULL;
  cMenus[20].cNextSibling = &cMenus[21];
  cMenus[20].eMenuAction = mnuAction_EEPROM_Load;

  cMenus[21].cFirstChild = NULL;
  cMenus[21].cNextSibling = NULL;
  cMenus[21].eMenuAction = mnuAction_EEPROM_Delete;

  cMenus[22].cFirstChild = &cMenus[23];
  cMenus[22].cNextSibling = NULL;
  cMenus[22].eMenuAction = mnuAction_NoAction;

  cMenus[23].cFirstChild = NULL;
  cMenus[23].cNextSibling = &cMenus[24];
  cMenus[23].eMenuAction = mnuAction_Preferences_DefaultWorkout;

  cMenus[24].cFirstChild = &cMenus[25];
  cMenus[24].cNextSibling = NULL;
  cMenus[24].eMenuAction = mnuAction_NoAction;

  cMenus[25].cFirstChild = NULL;
  cMenus[25].cNextSibling = &cMenus[26];
  cMenus[25].eMenuAction = mnuAction_Preferences_ToggleSound_Workout;

  cMenus[26].cFirstChild = NULL;
  cMenus[26].cNextSibling = NULL;
  cMenus[26].eMenuAction = mnuAction_Preferences_ToggleSound_System;

  // set cParent & cPrevSibling
  for (int intMnuCounter = 0; intMnuCounter < bytMenus_Num; intMnuCounter ++)
  {
    cMenus[intMnuCounter].Index = (byte)intMnuCounter;
    classMenuItem *cMnu = &cMenus[intMnuCounter];
    if (cMnu->cFirstChild != NULL)
    {
      classMenuItem *cChild = cMnu->cFirstChild;

      do
      {
        cChild->cParent = cMnu;
        cChild = cChild->cNextSibling;
      } while (cChild != NULL);
    }

    if (cMnu->cNextSibling != NULL)
      cMnu->cNextSibling->cPrevSibling = cMnu;
  }

  /*
    for (int intCounter = 0; intCounter < bytMenus_Num; intCounter ++)
    debug_Print_Menu(intCounter);
    //*/
}

实例被声明在一个名为 `cMenus[]` 的全局变量中,并在此处手动设置其 `FirstChild` 和 `NextSibling` 值,而 Arduino 则根据手动写入的值设置所有 `Parent` 和 `prevSibling` 指针。让 Arduino 自己完成此操作可以避免可能的数据输入错误,而且手动写入无论如何都会是多余的。

总的想法是,每个实例都指向树中其直接的邻居。只有一个选择的子菜单在屏幕上可见,当你选择其中一个子菜单时,如果该子菜单关联了 `mnuAction_NoAction` 操作,则该子菜单被用作当前菜单,否则 `mnuSelect()` 函数会将其全部整理并根据与所选菜单类实例关联的 `mnuAction` 重定向程序流,如以下代码所示

void mnuSelect()
{
  switch (cMnu_Selection->eMenuAction)
  {
    case mnuAction_WorkoutSelection_StopWatch_CountUp:
      {
        cWorkout.eWorkoutType = StopWatch_CountUp;
        EEPROM_eWorkoutType_Save(cWorkout.intIndex, cWorkout.eWorkoutType);
        mnuDraw();
        FlashMessage(F("SW - CountUp"));
        eSong = eSong_Confirm;
      }
      break;

    case   mnuAction_WorkoutSelection_StopWatch_CountDown:
      {
        cWorkout.eWorkoutType = StopWatch_CountDown;
        cWorkout.uintRepCounter = EditValue("Rep Count:", cWorkout.uintRepCounter, 1, 16000);
        cWorkout.ulngBest = 99 * conMillisPerHour
                            + 59 * conMillisPerMinute
                            + 59 * conMillisPerSecond;
        EEPROM_eWorkoutType_Save(cWorkout.intIndex, cWorkout.eWorkoutType);
        EEPROM_uintRepCounter_Save(cWorkout.intIndex, cWorkout.uintRepCounter);
        EEPROM_Best_Save(&cWorkout, cWorkout.ulngBest);
        mnuDraw();
        FlashMessage(F("SW - CountDown"));
        eSong = eSong_Confirm;
      }
      break;

    case   mnuAction_WorkoutSelection_Timer_CountUp:
      {
        cWorkout.eWorkoutType = Timer_CountUp;
        cWorkout.ulngBest = 0;
        EditWorkout_Time();
        EEPROM_eWorkoutType_Save(cWorkout.intIndex, cWorkout.eWorkoutType);
        EEPROM_Timer_Save(&cWorkout);
        EEPROM_Best_Save(&cWorkout, cWorkout.ulngBest);
        mnuDraw();
        FlashMessage(F("Timer CountUp"));
        eSong = eSong_Confirm;
      }
      break;

    case   mnuAction_WorkoutSelection_Timer_NoCount:
      {
        cWorkout.eWorkoutType = Timer_NoCount;
        EditWorkout_Time();
        EEPROM_eWorkoutType_Save(cWorkout.intIndex, cWorkout.eWorkoutType);
        EEPROM_Timer_Save(&cWorkout);
        mnuDraw();
        FlashMessage(F("Timer NoCount"));
        eSong = eSong_Confirm;
      }
      break;

    case   mnuAction_SelectTriggerResetType_Distance:
      {
        cWorkout.eTriggerType = triDistance;
        EEPROM_eTriggerType_Save(cWorkout.intIndex, cWorkout.eTriggerType);
        mnuDraw();
        FlashMessage(F("Trigger - Distance"));
        eSong = eSong_Confirm;
      }
      break;

    case   mnuAction_SelectTriggerResetType_TimeDelay:
      {
        cWorkout.eTriggerType = triTimer;
        EEPROM_eTriggerType_Save(cWorkout.intIndex, cWorkout.eTriggerType);
        mnuDraw();
        FlashMessage(F("Trigger - TImer"));
        eSong = eSong_Confirm;
      }
      break;

    case   mnuAction_TriggerDistance_SetMin:
      {
        cWorkout.bytTriggerDistance_Min = 
             EditValue("Trigger Dist. Min", cWorkout.bytTriggerDistance_Min, 2, 1023);
        EEPROM_bytTriggerDistance_Min_Save(cWorkout.intIndex, cWorkout.bytTriggerDistance_Min);
        mnuDraw();
        FlashMessage(F("Min dist set"));
      }
      break;

    case   mnuAction_TriggerDistance_SetResetTolerance:
      {
        cWorkout.bytTriggerDistance_Tolerance = 
             EditValue("Trigger toelrance", cWorkout.bytTriggerDistance_Min, 2, 1023);
        EEPROM_bytTriggerDistance_Tolerance_Save
                      (cWorkout.intIndex, cWorkout.bytTriggerDistance_Tolerance);
        mnuDraw();
        FlashMessage(F("Trigger Tolerance"));
      }
      break;

    case   mnuAction_TriggerTimerDelay_Set:
      {
        cWorkout.bytTriggerTimeStampReset_Delay = 
         (byte)EditValue("Reset delay", (int)cWorkout.bytTriggerTimeStampReset_Delay, 1, 255);
        EEPROM_bytTriggerTimeStampReset_Delay_Save
               (cWorkout.intIndex, cWorkout.bytTriggerTimeStampReset_Delay);
        mnuDraw();
        FlashMessage(F("Trig reset delay"));
      }
      break;

    case mnuAction_Workout_EditName:
      {
        cWorkout.Name = EditText(F("Edit Name:"), cWorkout.Name);
        EEPROM_Name_Save(cWorkout.intIndex, cWorkout.Name);
        mnuDraw();
        String strName = cWorkout.Name;
        strName.concat(F(" set"));
        FlashMessage(strName);
      }
      break;

    case mnuAction_Workout_EditNext:
      {
        Edit_Workout_Next();
        EEPROM_bytNextWorkout_Save(cWorkout.intIndex, cWorkout.bytNextWorkout);
        mnuDraw();
      }
      break;

    case   mnuAction_ViewCurrentWorkout:
      {
        ViewWorkout(&cWorkout);
      }
      break;

    case mnuAction_EEPROM_New:
      {
        cWorkout.Name = "Workout new";
        cWorkout.eWorkoutType = StopWatch_CountUp;
        cWorkout.eTriggerType = triTimer;
        cWorkout.bytTriggerDistance_Min = 20;
        cWorkout.bytTriggerDistance_Tolerance = 50;
        cWorkout.bytTriggerTimeStampReset_Delay = 125;
        cWorkout.bytTimer_Hours = 0;
        cWorkout.bytTimer_Minutes = 12;
        cWorkout.bytTimer_Seconds = 0;
        cWorkout.bytNextWorkout = 255;
        cWorkout.uintRepCounter = 50;
        cWorkout.intIndex = EEPROM_NumWorkouts_Get();
        EEPROM_NumWorkouts_Increment();
        EEPROM_Save(&cWorkout);
        mnuDraw();
        FlashMessage("Workout Reset");
        eSong = eSong_Challenge;
      }
      break;

    case mnuAction_EEPROM_Delete:
      {
        if (ConfirmEntry("Delete Workout?"))
        {
          EEPROM_Delete(&cWorkout);
          mnuDraw();
          FlashMessage(F(" - deleted -"));
          eSong = eSong_Confirm;
        }
      }
      break;

    case   mnuAction_EEPROM_Load:
      {
        cWorkout = EEPROM_Load();
        eSong = eSong_Confirm;
      }
      break;

    case mnuAction_Preferences_DefaultWorkout:
      {
        FlashMessage(F("Set Default"));
        int intSelectedIndex = EEPROM_WorkoutSelect();
        if (intSelectedIndex >= 0)
        {
          EEPROM_Preferences_DefaultWorkout_Save((byte)intSelectedIndex);
          mnuDraw();
          FlashMessage(F("default set"));
          eSong = eSong_Confirm;
        }
        else
        {
          mnuDraw();
          FlashMessage(F("set default aborted"));
          eSong = eSong_Cancel;
        }
      }
      break;

    case mnuAction_Preferences_ToggleSound_Workout:
      {
        bolPlaySound_Workout = !bolPlaySound_Workout;
        EEPROM_Preferences_PlaySound_Save(bolPlaySound_Workout);
        mnuDraw();
        FlashMessage(bolPlaySound_Workout ? F("WO Sound On") : F("WO Sound OFF"));
        eSong = eSong_Confirm;
      }
      break;

    case mnuAction_Preferences_ToggleSound_System:
      {
        bolPlaySound_System = !bolPlaySound_System;
        EEPROM_Preferences_PlaySound_Save(bolPlaySound_System);
        mnuDraw();
        FlashMessage(bolPlaySound_System ? F("Sys Sound On") : F("Sys Sound OFF"));
        eSong = eSong_Confirm;
      }
      break;

    case mnuAction_NoAction:
      cMnu_Current = cMnu_Selection;
      cMnu_Selection = cMnu_Current->cFirstChild;
      mnuDraw();
      break;
  }
}

在上面的代码底部附近,您可以看到列出的最后一个情况是 `mnuAction_NoAction`,其中变量 `cMnu_Current` 和 `cMnu_Selection` 已设置,并根据用户的意图重新绘制菜单。

用户界面

更新时间:2020/09/25

我刚刚想到,如果您已经组装好并运行着自己的次数计数器,您可能需要一些帮助来使用其界面。

由于 LCD 上只有两行,菜单非常基础,而且没有 Clippy 来帮助您。所以我整理了一个表格,描述了每个按钮根据您在菜单中的位置所具有的功能。

蜂鸣器音乐

然后是蜂鸣器。压电蜂鸣器是约瑟夫·亨利于1831年发明的第一个电动蜂鸣器的普遍后代。尽管压电蜂鸣器听起来像意大利语,但它最初是由日本人在1970年代和1980年代制造的。它们仍然几乎无处不在。我**次数计数器**中目前使用的那个是从我从附近施粥处回家的路上发现的一个坏掉的咖啡机上拆下来的。在掏空了这个塑料副产品——清晨的怠惰和需要喝一杯深色冲泡的电池酸——之后,我找到了四个按钮和一个蜂鸣器,然后忽视了加热线圈,那只会给我惹上当地消防部门的麻烦。曾经用一杯热咖啡唤醒我邻居的宜人摩卡清晨之声,现在在我的日常锻炼中把我从懒惰中唤醒。

八个月前我在 Udemy 上参加了一周的 Arduino 入门课程,其中一节课是关于压电蜂鸣器的,其中包括一项家庭作业,要求我编写一个 Arduino 项目,用压电蜂鸣器演奏《小蜘蛛》这首歌,我照做了。我的解决方案是一种 C# 解决方案,通过将歌曲流写入文本字符串并循环播放不同的字符来演奏歌曲。不幸的是,为了让当前的项目不会因为内存不足而崩溃,这个解决方案需要进行修改。

所以我又基本上重写了相同的代码。

好吧,那可能不是最好的解决方案,但我确实大大减少了所需的字符串数量。以前音符的频率包含在歌曲字符串中,现在它使用单个字符进行编码(从三个字符描述一个可以放入两字节整数变量的值,而不是需要三个字符才能在字符串中写入)。音符的长度也编码为一个数字字符,范围从 0 到 4。这意味着我正在使用整整一个字节的数据,而三个位就足够了。显然,如果内存再次变得稀缺,还有改进的空间,但由于项目正在运行,并且它完成了我想要它做的一切,以这种方式将歌曲写入项目的便捷性使得这种内存权衡成为一个可以接受的选择。

目前它的实现方式是,每首歌都被写入两个长度相同的 `string`,长度等于歌曲中音符的数量。然后,这些 `string` 只通过一个 `switch()-case` 来引用,该 `switch()-case` 读取它需要播放当前歌曲的当前音符的 `string`。一个全局变量保存选定歌曲的索引,另一个保存该歌曲中当前音符的索引,第三个是一个无符号长整型,它保存 `millis()` 调用的结果,用于知道下一个音符何时需要发出,以便歌曲听起来像它应该发出的声音。

我不会演奏任何乐器。几乎哼不出调子。我只是从未真正投入其中,而且我青少年时期曾“唱歌”的朋克摇滚乐队也从未走出车库。所以,那段历史就此结束了。因此,为了让**次数计数器**中的歌曲听起来比安妮·弗兰克在锡克莱兹默乐队中演奏的更好,我下载了一些乐谱,尽我所能地解读它们,并使用下面的图片来弄清楚哪些音符后面跟着什么,从而取得了不错的成绩。我从压电蜂鸣器听到的音乐是否是作曲家所设想的舒缓旋律的悦耳近似,这完全是另一回事了。

这里有一些我用来解读这个神秘音乐密码的笔记

下图展示了某种音乐谱(五线谱?)。据我所知,那些黑色圆形物体的位置表示压电蜂鸣器需要演奏其音调的频率。而那些黑色圆形物体的形状则告诉压电蜂鸣器演奏这些音调多长时间。由于每个参数只能用一个字符来描述,下图中用特定代码标记了每个频率,上图则解释了两种不同的表示持续时间的方法。有两种计时方式,因为音乐人需要同时为声音和寂静计时。声音被称为“音符”,它们在下面五线谱中的位置描述了该声音的频率,而黑色圆形物体(又称音符)的形状描述了这些声音(或寂静)的持续时间。结果我只弄懂了音乐人使用的四种不同的时间段,就像一个被损坏的巴比伦十六进制测量方法。这反正比任何公制系统都更适合 Arduino,因为编码的“时间持续时间”值是 2 的幂。或者是一个字节中的位位置。十六分之一是 20 = 1,然后将它全部除以 16,就是你的十六分之一。

举例来说,你可以看看熄灯号的乐谱,看看那些加密的黑色圆形物体的位置和形状是如何帮助我破解这个神秘的音乐密码,并将其全部解读成 Arduino 歌曲字符串可读的,甚至无需填写一张信息自由请求表。

// decoded music strings readable by Arduino
strFrequencies = F("ffCfCEgCEgCEgCCEGECgggC");
strDuration    = F("22322322222222223223223");

这些密码存储在下面的函数中

void playSong()
{
  if (eSong == eSong_Silence)
    return;

  switch (eSong)
  {
    case eSong_Cancel:
    case eSong_Confirm:
      if (!bolPlaySound_System) return;
      break;

    default:
      if (!bolPlaySound_Workout) return;
      break;
  }

  int intFrequencies[] =
  {// frequencies associated with characters sequenced in strNotes below
    220, // a
    247, // b
    261, // c
    293, // d
    330, // e
    349, // f
    392, // g
    440, // A
    493, // B
    523, // C
    587, // D
    659, // E
    698, // F
    783, // G
    880, // Á
    987, // ß
    1046, // Ç
    1174, // Ð
    1318, // Ë
    0 // space
  };

  String strNotes = F("abcdefgABCDEFGÁßÇÐË "); // index of note frequency 
                    // to be played found in this string is used to index intFrequencies[] 
  String strFrequencies = "";
  String strDuration    = "";

  switch (eSong)
  {

    case eSong_Taps:
      {
        intTempo = 30;
        /*
          strFrequencies = F("ffCfCEgCEgCEgCCEGECgggC");    // original sequence of note 
                                                            // frequencies as deciphered 
                                                            // from sheet music
          strDuration    = F("22322322222222223223223");    // durations
          /*/
        strFrequencies = F("CEGECgggC ");                   // abbreviated sequence of 
                                                            // note frequencies 
        strDuration    = F("2232232232");                   // durations
        //*/
      }
      break;

/*             REMAINING SONGS EXCISED FOR BREVITY               */

  }

  ulngMillis = millis();

  if ( ulngMillis > ulngMillis_OLD + intDuration )
  {
    char chrFreq = strFrequencies[intNoteCounter];  // find character of the current note 
                                                    // by its index in the 
                                                    // current song's string

    int intFreqIndex = strNotes.indexOf(chrFreq);   // find chrFreq in strNotes and 
                                                    // use index to determine which element 
                                                    // in intFrequencies[] array is 
                                                    // correct frequency
    if (intFreqIndex >= -0)
    {
      int intFrequency = intFrequencies[intFreqIndex];  // note frequency is found 
                                                        // using the char index of note 
                                                        // to be played as found in 
                                                        // strNotes above
      char chrDuration = strDuration [intNoteCounter];  // note duration is equal to the 
                                                        // char's numeric value as the 
                                                        // power of the value of 2 times 1/16th
      int intDurationIndex =  chrDuration - '0';
      intDuration = pow(2, intDurationIndex) * 
            (intTempo_Max - intTempo); // 2^n / 16 -> (16/16 = whole note), (8/16 half), 
                                       // (4/16 quarter), (2/16 eighth), (1/16 sixteenth)
      tone(pinBuzzer, intFrequency, intDuration);
    }

    ulngMillis_OLD = ulngMillis;

    intNoteCounter = (intNoteCounter + 1) % strFrequencies.length();
    if (intNoteCounter == 0)
    {
      noTone(pinBuzzer);
      eSong = eSong_Silence; //
    }
  }
}

这就是他们所说的音乐。在我听来,它就像一个压电蜂鸣器在演奏美妙的旋律。

节省电池

更新时间:2020/09/25

自从我使用方块 9V 电池为我的**次数计数器**供电以来,更换电池变得很麻烦。就在昨天,我开始做一套 90 分钟的训练,结果才 30 分钟,我就几乎看不清 LCD 了,不得不自己脑子里计数,这完全违背了拥有**次数计数器**的初衷。可充电电池和 USB 充电适配器终于到了,所以我很快就会处理这个问题,但目前我做了一些小的软件改动,以帮助电池持续更长时间。

以前计数器在程序循环(每秒几次)后刷新屏幕,现在它会等待一段延迟时间

unsigned long ulngScreenRefresh_TimerDelay = 1000;  // milliseconds

然后再刷新屏幕。这意味着显示屏不再有亚秒级的精度,但对于正常用途而言,节省的电池电量远远超过了这种需求。我可能会包含一个菜单偏好选项,允许用户指定省电选项,但这将在以后实现。

次数计数器现在只在 Reps-Count 值实际发生变化时才刷新屏幕上的 Reps-Count 值。

当然,关闭反馈音也会减少电池的负担。

我还没有机会测试这些更改,但我相信它们会带来很大的不同,因为以前电池耗电太快,而这些更改不可能让情况变得更糟。

给我,给我,给我

如果您想要一个**运动次数计数器**,但又发誓不能玩 Arduino 自己制作,那请给我发邮件。很有可能,如果您支付零件和运费,我很乐意为您制作一个,因为对于任何认真对待运动的人来说,这东西太棒了。

最终评论

不……我不会做最终评论,因为假设任何事情都是最终的,都是一种假设,即我们集体无知的鸿沟将保持不变。除此之外,今天我在 Facebook 上看到了耶稣从一只毛茸茸的狗屁股里回望我的图像……够了。

历史

  • 2020年9月8日:首次发布
  • 2020年9月25日 - 代码更改以减轻电池负担并添加了有关菜单用户界面的更多详细信息
  • 2020年9月28日 - 代码更改 - 修复了上次更新中潜入的训练结束计时器显示错误
  • 2020年9月29日 - 添加了关于如何重置 EEPROM 的说明
  • 2022年2月17日 - 更新了分压器电阻计算器的Arduino代码以处理去抖问题
  • 2022年3月14日 - 上传了Reps Counter代码的PlatformIO版本
© . All rights reserved.