ユーザー入力の追加 - 応用
ボタンを押して温度の単位を変換する
ユーザー体験は非常に単純です。ボタンが押されている間はプログラムがある処理を行い、ボタンが押されていないときは別の処理を行います。温度の表示方法を切り替えるような、ボタンを押したときに一度だけイベントを発生させたい場合は、これがより複雑になります。
この実装の例はこちらにあります: 8_temp_unit_convert_buttons.rs。
✅ 前章のファイルから始めます。
✅ 次のリソースをスコープに取り込みます。
#![allow(unused)]
fn main() {
use nrf52840_hal::{
self as hal,
gpio::{p0::Parts as P0Parts, Input, Pin, PullUp},
prelude::*,
Temp,
Timer,
};
}
温度を定期的に更新しながら、温度を表示する単位を切り替えられるようにしたいとします。プログラムの動作の一部は現在選択されている単位に依存するため、その単位を追跡しておく必要があります。
温度の表示方法には、一般的に Celsius、Kelvin、Fahrenheit の 3 つがあります。これらは同じ概念の 3 つのバリアントなので、この型には enum を使うのが適しています。
✅ struct Button の前に次の enum を追加します。
#![allow(unused)]
fn main() {
enum Unit {
Fahrenheit,
Celsius,
Kelvin,
}
}
センサーは温度を摂氏度で出力します。
✅ fn main() に移動します。ループの前に、現在の表示単位を設定する変数を追加します。
#![allow(unused)]
fn main() {
let mut current_unit = Unit::Celsius;
loop {
// ...
}
}
struct に対してメソッドを定義できるのと同じように、enum に対してもメソッドを定義できます。
✅ enum Unit に、match 文を含むメソッドを追加します。各 match アームで、温度値を対応する単位へ変換する処理を実装します。
#![allow(unused)]
fn main() {
impl Unit {
fn convert_temperature(&self, temperature: f32) -> f32 {
match self {
Unit::Fahrenheit => {
// 温度を変換して返す
},
Unit::Kelvin => {
// 温度を変換して返す
},
Unit::Celsius => {
// 温度をそのまま返す
},
};
}
}
}
次に、ボタンを押したときに単位を変更する処理を実装する必要があります。
✅ fn main() に移動します。ループ内で match 文を使い、現在の単位に応じて、ボタンが押された場合に別の単位へ切り替えます。単位が変更されたことを示すログ文を追加します。
#![allow(unused)]
fn main() {
if button_1.is_pressed() {
current_unit = match current_unit {
Unit::Fahrenheit => Unit::Kelvin,
Unit::Kelvin => Unit::Celsius,
Unit::Celsius => Unit::Fahrenheit,
};
defmt::info!("Unit changed");
};
}
✅ プログラムを実行します。ボタンを押すと、連続したログ出力が表示されるはずです。
✅ periodic timer インスタンスを実装します。通常のタイマーの代わりにこのタイマーを使います。
#![allow(unused)]
fn main() {
let mut periodic_timer= Timer::periodic(board.TIMER0);
}
✅ ループ内で、センサーから温度を読み取った後、current_unit に対して convert_temperature メソッドを呼び出し、新しい変数に束縛します。その後に match 文を置き、正しい単位を表示して温度値をログに出力します。
#![allow(unused)]
fn main() {
loop {
let temperature: f32 = temp.measure().to_num();
let converted_temp = current_unit.convert_temperature(temperature);
match current_unit {
Unit::Fahrenheit => defmt::info!("{=f32} °F", converted_temp),
Unit::Kelvin => defmt::info!("{=f32} K", converted_temp),
Unit::Celsius => defmt::info!("{=f32} °C", converted_temp),
};
if button_1.is_pressed() {
// ...
};
}
}
✅ プログラムを実行します。
これにより、現在の単位で温度を表示するログ出力が多数発生するはずです。ボタンを 1 回押すと単位が何度も変更されるため、特定の単位へ意図的に変更することは不可能です。
✅ ループの末尾に 100 ms の遅延を追加します。
#![allow(unused)]
fn main() {
loop {
// ...
if button_1.is_pressed() {
// ...
};
periodic_timer.delay_ms(100_u32);
}
}
✅ プログラムを実行します。
プログラムは一応やりたいことを実行しますが、ユーザー体験はかなりひどいものです。改善しましょう。
ここまでの実装例はこちらにあります: 7_temp_convert_button_noisy.rs。
最初のステップは、求める動作をもう少し詳しく定義することです。3 つの要素を見てみましょう。
人間の視点から見たボタンの状態
ボタンは 4 つの状態を取り得ます。
- 押されている
- 押されていない
- 押されている状態から押されていない状態へ遷移中
- 押されていない状態から押されている状態へ遷移中
これらの状態をもう少し二値的に定義するには、ボタンが直前にどの位置にあり、今どの位置にあるのかを考えます。
| 以前 | 現在 | |
|---|---|---|
| 1. | 押されている | 押されている |
| 2. | 押されていない | 押されていない |
| 3. | 押されている | 押されていない |
| 4. | 押されていない | 押されている |
機械の視点から見たボタンの状態
人間の視点では非常に単純に見えますが、ハードウェア上でボタンの状態が何を意味するのかを判断するのは、もう少し複雑です。理論上、ボタンを押すと信号が変化しますが、この変化は多くの場合それほどきれいではなく、むしろノイズを含みます。特にボタンが古くなるとそうなります。この挙動を補正することを、ボタンの debouncing と呼びます。ソフトウェアでは、ボタンの 4 つの状態を追跡するステートマシンを用意し、押されたボタンが押されたボタンとして扱われるのは、導電性のほこりの粒が邪魔をしたことによる突然の信号スパイクではなく、一定時間押されている場合である、と定義することで実現できます。
システム変更の持続性
ボタンを実装するのは、人々がシステムと対話し、ボタンを押すことでシステムの動作を変更できるようにしたいからです。この変更は、ボタンが押されている間だけ存在し、ボタンを離すことで終了するものにすることもできますし、ボタンを押すことで開始され、ボタンを離しても持続するものにすることもできます。
プログラムの動作はどうあるべきか?
ボタンを押すことで、温度を表示する単位を変更したいとします。その変更は、ボタンを離した後も持続する必要があります。単位変換のトリガーイベントとして、ボタンが「押されている」状態から「押されていない」状態へ遷移するタイミングの 1 つを使います。ボタンの遷移を検出するために、プログラムはボタンの過去の状態を追跡します。温度は 1000ms ごとに表示されるべきです。
ボタンの動作を改善する
✅ button struct に、ボタンの過去の状態を bool で追跡するフィールドをもう 1 つ追加します。初期状態は false です。
以前の匿名 struct にフィールドが追加された点に注意してください。この変更は、この struct に対して実装されているメソッドにも反映する必要があります。
#![allow(unused)]
fn main() {
struct Button {
pin: Pin<Input<PullUp>>,
was_pressed: bool,
}
}
✅ impl Button ブロックに、信号の立ち上がりエッジを検出するメソッドを追加します。
- ボタンの現在の状態を読み取る
- 現在の状態を、ボタンの struct に保存されている過去の状態と比較する。
- ボタンが押されていたが、現在は押されていない場合に
trueを返す。 - ボタンの過去の状態を更新する。
#![allow(unused)]
fn main() {
fn check_rising_edge(&mut self) -> bool {
let mut rising_edge = false;
let is_pressed = self.is_pressed();
// 信号の「立ち上がりエッジ」でのみトリガーする
// 用語: 「エッジトリガー」
if self.was_pressed && !is_pressed {
// 押されていたが、今は押されていない:
rising_edge = true;
}
self.was_pressed = is_pressed;
rising_edge
}
}
✅ fn main() に移動します。ボタンのピンを mutable として宣言します。is_pressed メソッドを check_rising_edge() に置き換えます。
#![allow(unused)]
fn main() {
let mut button_1 = Button::new(pins.p0_11.degrade());
loop {
// ...
if button_1.check_rising_edge() {
// ...
}
// ...
}
}
✅ プログラムを実行します。
ボタンをどれだけ長く押しても、単位は一度だけ変更されます。100 ms 以内にボタンを複数回押さなければ、すべての操作が登録されます。しかし、ログ出力はまだ計画より 10 倍多く、ボタンのタイミングも理想的ではありません。
タイミング
人間によるボタン操作をすべて検出し、ボタンの状態を登録するには、ボタンの状態をかなり頻繁に読み取る必要があります。ハードウェアからのノイズを除去するには、約 5 ms ごとにボタンを読み取れば十分です。ここでは、意図的な操作と見なせる十分な長さの立ち上がりエッジを検出したいと考えています。ボタン押下の立ち下がりエッジの後、ボタン解放の立ち上がりエッジに反応することで、その信号が意図的であることをさらに確実にできます。
大まかに見ると、実装は次のようになります。タイマーは 1000 マイクロ秒までカウントアップします。1000 µs が経過するたびに、経過ミリ秒を追跡するカウンターが更新されます。経過ミリ秒数が 5 で割り切れ、かつ立ち上がりエッジが検出された場合、単位が変更されます。経過ミリ秒数が 1000(1 秒)で割り切れるたびに、温度がログに記録されます。
ここでは、カウンターがどの型の符号なし整数であるかが重要です。その型の最大値に達すると問題が発生します。参考までに、u32 のカウンターは 49.7 日後に上限に達し、u64 のカウンターは 267844497 年後に上限に達します。
✅ タイマーインスタンスの後に、経過ミリ秒を追跡する変数を追加します。
#![allow(unused)]
fn main() {
let mut periodic_timer= Timer::periodic(board.TIMER0);
let mut millis: u64 = 0;
}
✅ ループ内で、タイマーを最大値 1000 µs で開始します。ボタンの更新と温度のログ出力の制御フローを実装します。次に、ループの各反復後に経過マイクロ秒のカウンターへ 1 を加算する行を追加します。
#![allow(unused)]
fn main() {
loop {
periodic_timer.start(1000u32);
if (millis % 1000) == 0 {
defmt::info!("Tick (milliseconds): {=u64}", millis);
// 温度を測定する
// 温度を表示する
};
if (millis % 5) == 0 {
// ボタンの状態を読み取って更新する
};
millis = millis.saturating_add(1);
}
}
✅ コードを実行します。
ループ全体の実行が 1000 µs 未満で完了するため、温度は依然として 1000 ms ごとよりもはるかに頻繁にログ出力されます。つまり、実際にその時間が経過する前に、経過マイクロ秒数が増加しています。プログラムを正しいタイミングにするには、その数を増やす前に、1000 µs が経過するまでループの実行をブロックする必要があります。
✅ cargo.toml ファイルに移動します。
✅ クレート nb = "1.0.0" をインポートします。
✅ プログラムファイルに戻り、そのクレートとその block モジュールをスコープに取り込みます。
#![allow(unused)]
fn main() {
use nb::block;
}
✅ ミリ秒数をインクリメントする前に、次の行を追加します。これにより、ノンブロッキングのカウンターは 1000 µs までカウントアップするまでブロッキングになります。
#![allow(unused)]
fn main() {
block!(periodic_timer.wait()).unwrap();
}
✅ プログラムを実行します。ボタンを押すのを楽しんでください!