はじめに
前記事でM5PaperでのLチカまでやってみました。M5Paper本体にせっかくEPaperが搭載されていますので、なにか表示できないと寂しいなということで、内蔵のSHT30から温湿度を取ってきて、画面に表示してみることにします。
テーマは大きく3つになります。
- I2C通信の使い方
- SPI通信の使い方
- IT8951 EPDディスプレイコントロールチップの制御
環境は、前記事で、揃っているものとします。
新しく、cargo generate esp-rs/esp-idf-template cargo
でプロジェクトを用意しておきます。
目標
実行した写真がこちらです。
えっと・・・。字が小さい。実は、グラフィックライブラリとして使用したembedded_graphicsがデフォルトで表示できる最大サイズの文字だったりします。これを大きくするには、フォントをなんとかする必要があったりします。
使用する外部ライブラリ
今回使用する外部ライブラリは次のとおりです。
- anyhow
- thiserror
- embedded-graphics
- it8951
すべて、crates.ioに掲載されているものです。
上の2つは、エラー処理に使用しています。次は、汎用のグラフィック処理ライブラリで、it8951は、epaperの制御チップの制御ドライバです。
全部、Cargo.tomlのdependenciesに追加します。
[package]
name = "ondokei-text"
version = "0.1.0"
authors = []
edition = "2021"
resolver = "2"
rust-version = "1.71"
[profile.release]
opt-level = "s"
[profile.dev]
debug = true # Symbols are nice and they don't increase the size on Flash
opt-level = "z"
[features]
default = ["std", "embassy", "esp-idf-svc/native"]
pio = ["esp-idf-svc/pio"]
std = ["alloc", "esp-idf-svc/binstart", "esp-idf-svc/std"]
alloc = ["esp-idf-svc/alloc"]
nightly = ["esp-idf-svc/nightly"]
experimental = ["esp-idf-svc/experimental"]
embassy = ["esp-idf-svc/embassy-sync", "esp-idf-svc/critical-section", "esp-idf-svc/embassy-time-driver"]
[dependencies]
log = { version = "0.4", default-features = false }
esp-idf-svc = { version = "0.48", default-features = false }
anyhow = "1.0.80"
thiserror = "1.0.57"
embedded-graphics = "0.8.1"
it8951 = "0.2.0"
[build-dependencies]
embuild = "0.31.3"
I2Cインターフェースの初期化
最初に必要なのが、I2Cインターフェースです。
この制御は、esp-idf-svc::i2c::I2cDriver
が担ってくれますので、この構造体を構築することでハードの初期化も完了します。
// i2c initialize
let i2c_config = I2cConfig::new()
.baudrate(100.kHz().into())
.sda_enable_pullup(false)
.scl_enable_pullup(false);
let mut i2c = I2cDriver::new(
periph.i2c0,
periph.pins.gpio21,
periph.pins.gpio22,
&i2c_config,
)?;
log::info!("Ready I2C");
I2cDriver
のnew()
関数のドキュメント見ると、esp_idf_svc::hal::i2c::config::Config
への参照が必要と出るのですが、これ、構造体「だけ」が定義されていたりします。実は、esp_idf_svc::hal::i2c::I2cConfig
という型エイリアスが定義されていて、こちらにはちゃんとnew()
関数や値の設定メソッドが用意されていたりします。名前もだぶりにくくなってますし、こっちを使いましょう。
また、ボーレートの指定には、esp_idf_svc::hal::units::Heltz
という構造体を使うことになってます。これを直接作ってもよいのですが、use esp_idf_svc::hal::prelude::*;
を取り込んでおくと、u32に、kHz()
とMHz()
関数を生やしてくれます。各々KiloHertz
とMegaHertz
という構造体を返しますが、この両方とも、Heltz
へのFrom
トレイトの定義があります。これで、100.kHz().into()
のような指定ができるようになります。このほうが見やすいですね。
あと、M5Paperでは、I2Cのラインにハードウェアでプルアップが実装されているので、I2cConfig
の指定では、プルアップを無効にします。
M5paperでは、GPIO21にSDAが、GPIO22にSCLが接続されていますので、そのように指定してI2cDriver
を構築すれば、初期化は終わりです。
SPIインターフェースの初期化
続いて、SPIインターフェースの初期化です。SPIインターフェースでは、CSラインを使い分けることでマルチドロップが可能です。M5Paperでも、IT8951とSDカードへのインターフェースが、同じSPIを共用しています。
その関係で、SPIドライバは、2段階に分けて構築することになります。今回は、SDカードへのアクセスは無視して、単一で使用する形で初期化します。
//SPI Driver
let spiconf = SpiConfig::default().baudrate(10.MHz().into());
let spi_d = SpiDriver::new(
periph.spi2,
periph.pins.gpio14, //sclk
periph.pins.gpio12, //sdo
Some(periph.pins.gpio13), //sdi
&SpiDriverConfig::default(),
)?;
let spi = SpiDeviceDriver::new(
spi_d,
Some(periph.pins.gpio15), //CS
&spiconf,
)?;
log::info!("Ready SPI");
今度は、Config構造体が2種類必要です。両方とも、SpiConfig
と、SpiDriverConfig
の形で型エイリアスがあるのでこちらを使用します。
SpiConfig
の方がSPIの基本設定です。SPIのクロックモードなど設定項目は多いですが、今回はすべてデフォルトで通ります。ボーレートのみ設定します。
SpiDriverConfig
の方は、デフォルトで使用できます。この中で、DMAの設定ができそうな気配がありますが、今回は不使用とします。
まず最初に、SpiDriver
を構築します。これが、SPIインターフェースのベースになります。
そうして、このドライバを使用して、SpiDeviceDriver
を構築します。マルチドロップを使用するときは、ここで調整することになります。
インターフェースの各GPIOラインは、ソースのコメントのとおりです。
IT8951ドライバの構築
IT8951チップは、先に構築したSPIインターフェースに接続されています。このチップの命令をembedded-graphicsで使用できる形に落とし込むのは相当大変です。が、幸いなことに、crates.ioにそのまんまの名前でドライバが登録されているので、これを使用させてもらうことにします。
このドライバの構築も2段階に分かれています。ベースは、SPIインターフェースとのやり取りの実装。そうしてもうひとつが、embedded-graphicsとのやり取りの実装となっています。
// gpio
let rst = PinDriver::output(periph.pins.gpio23)?;
let rdy = PinDriver::input(periph.pins.gpio27)?;
【中略】
// IT8951 Driver
let it8951_if = IT8951SPIInterface::new(spi, rdy, rst, Delay::default());
let mut epd = IT8951::new(it8951_if)
.init(2300)
.map_err(It8951Error::from)?;
log::info!("End of initialization of peripherals.");
let delay = Delay::default();
IT8951の制御には、SPIの他に、チップのリセットとレディー信号の2本のGPIOが必要です。
レディー信号の方はそのままなのですが、リセットは実はちょっとグレーです。M5Paperの回路図を見ると、チップのリセット信号は、チップの電源オンの時のシーケンスの中で発行されています。そして、他にはラインが出ていません。ESP32側からは、このチップの電源のオン・オフのラインがGPIOとして出ています。仕方がないのでリセット信号は、電源リセットでやっちゃうことにします。
後、ドライバ内での遅延のためのタイマーが必要です。これにはesp_idf_svc::hal::delay::Delay
をそのまま使用します。
後は、2段階のドライバ構造体をそのまま構築するだけです。
最後に、出来上がったIT8951構造体からinit()
を呼び出して完了です。この関数の引数の2300という数字は、IT8951チップ内でのVCOMの電圧レベルを指定するためのものです。この数字、実はドキュメントがありません。M5Paperのarduinoライブラリのソースを拝見すると、この数値には、2300が設定されているので、そのままそっとその数字を持ってきました。
この構造体には、もう一つ、問題点があります。この構造体のメソッドの大半は、Result
を返します。そのエラーの構造体である、it8951::Error
には、std::error::Error
が実装されていません。そのため、anyhow::Error
もこれを補足することが出来ないんです。このままでは?演算子が使えません。
そのため、このエラーを、ラップした構造体を用意しました。
/// IT8951モジュールのエラーのラッパー
/// it8951::errorはstd::error::Errorを実装しない。
#[derive(Error, Debug)]
enum It8951Error {
#[error("IT8951: A error in the spi driver.")]
Spi,
#[error("IT8951: A error in the gpio driver.")]
Gpio,
#[error(transparent)]
Esp(#[from] esp_idf_svc::sys::EspError),
}
impl From<it8951::Error> for It8951Error {
fn from(e: it8951::Error) -> Self {
let it8951::Error::Interface(err) = e;
Self::from(err)
}
}
impl From<it8951::interface::Error> for It8951Error {
fn from(e: it8951::interface::Error) -> Self {
match e {
it8951::interface::Error::SpiError => Self::Spi,
it8951::interface::Error::GPIOError => Self::Gpio,
}
}
素直に、IT8951が返すエラーをFrom
トレイトで変換しているだけです。Error
の構築には、他にもトレイトの実装が必要で面倒なので、ここでは、thiserror
クレートを使用して実装しています。
これで、map_err
を使ってエラーの変換が可能になりました。
ここまでで、ハードウェアの初期化は終了です。
メインループ
ここからは、温湿度の取得と画面への表示を行うメインループとなります。
温湿度の取得
M5Paperに搭載されている温湿度計、SHT30というチップは、単発で温湿度を取得するだけなら難しいことはなにもないです。I2Cで、0x2c06を送ると、しばらくして、i2cから温湿度を表す6バイトの数値を得ること出来ます。後は、その数値から温湿度を簡単な変換式で求めます。
let mut sht30buff: [u8; 6] = Default::default();
// main loop
loop {
// SHT30 温湿度データ取得
// i2cアドレス: 0x44, コマンドコード: 0x2C06(精度レベル「高」クロスストレッチ有効)
// 測定に、最大15msが必要。
log::info!("start of SHT30 measurement.");
i2c.write(0x44, &[0x2c, 0x06], BLOCK)?;
delay.delay_ms(15);
i2c.read(0x44, &mut sht30buff, BLOCK)?;
// buff[0-1]:温度 buff[2]:チェックサム buff[3-4]:湿度 buff[5]:チェックサム
let st = (sht30buff[0] as u32) << 8 | sht30buff[1] as u32;
let temp: f32 = -45.0 + 175.0 * (st as f32 / 65535.0);
let srh = (sht30buff[3] as u32) << 8 | sht30buff[4] as u32;
let hum: f32 = 100.0 * (srh as f32 / 65535.0);
log::info!("temp: {:.2}, hum: {:.2}", temp, hum);
ドキュメントによると、測定コマンドを発行してから値の準備ができるまで最大15ms(精度レベル「高」を使用時。精度を落とすと時間も減ります。)がかかるとあります。その間はI2Cのクロックラインをロックしてくれるのですが、ちょっと長すぎて、ESP32のI2Cインターフェース側でタイムアウトを返してしまいます。ですので、writeとreadの間に明示的に15msのディレイを入れておきます。
tempとhumを求める式は、SHT30のドキュメントそのままです。
epaperへの温湿度の表示
最後に、取得した温湿度のepaperへの表示部分です。
embedded-graphicsクレートを使用します。このクレートですが、基本は、embedded_graphics::Drawable
を実装する「書くもの」を用意して、これをembedded_graphics::draw_target::DrawTarget
を実装する書かれるものに対して描画するのが基本です。DrawTarget
は、先に構築したIT8951
構造体が実装しています。ですから、「描画するもの」に対して、draw(&mut IT8951構造体)
とやってあげれば、描画が出来ます。
epd
は、先に構築したIT8951構造体です。
// 温湿度の表示
epd.fill_solid(
&Rectangle::new(Point::new(100, 100), Size::new(170, 140)),
Gray4::WHITE,
)
.map_err(It8951Error::from)?;
let textstyle = MonoTextStyle::new(&FONT_10X20, Gray4::BLACK);
let temp = format!("temp:{:.2}", temp);
Text::new(&temp, Point::new(120, 120), textstyle)
.draw(&mut epd)
.map_err(It8951Error::from)?;
let hum = format!("hum:{:.2}", hum);
Text::new(&hum, Point::new(120, 150), textstyle)
.draw(&mut epd)
.map_err(It8951Error::from)?;
epd.display(it8951::WaveformMode::GrayscaleClearing16)
.map_err(It8951Error::from)?;
まず最初に、描画エリアを白で塗りつぶして消しておきます。(でないと、後から後から重ね書きして読めなくなります。
次に、embedded_graphics::text::Text
構造体を表示する文字列を指定して用意します。この構造体は、Drawable
を実装するので、Draw()
関数を持ちます。温度と湿度を各々表示しています。
IT8951は、描画しただけでは画面に表示してくれません。チップ内部の描画バッファを実画面にフラッシュする必要があります。その処理が、epd.display()
の呼び出しとなります。
ループの最後で
後は、後始末です。このままループするとガンガン表示を更新しますが、まぁ、そこまで頻繁に更新してもらってもePaperが可愛そうなだけです。(あまり高頻度でePaperを更新するのはハードにも良くないそうです。)
ついでに、電力削減のため、IT8951とESP32はスリープモードにすることにします。
// IT8951 及び、 ESP32をスリープモードにして・・・
let epd_down = epd.sleep().map_err(It8951Error::from)?;
unsafe {
esp_idf_svc::sys::sleep(30);
}
// IT8951を起こす。
epd = epd_down.sys_run().map_err(It8951Error::from)?;
}
注意点が一つ。epd.sleep()
関数は、チップをスリープにして、スリープ状態にあるという状態を持つ、別の型の構造体を返します。かつ、使用した値を消費(move)します。なので、帰ってきた値を保存しなおす必要がありますが、同じ変数に代入しようとすると、型が違うとコンパイラに怒られます。別変数への保存が必要です。Rustで有限状態マシンを実装する時の定形です。
残念ながら、unsafeでないesp32をスリープ状態にする関数や構造体は見つけられませんでした。というわけで、ここだけはunsafeです。
後は、ESP32が起きた後、IT8951チップも起こしてあげるだけです。
ディープスリープにすることも可能です。その場合は、表示のたびにハードのイニシャライズから全部やり直すことになります。
全ソースリスト
最後に、全ソースリストを掲載します。
use anyhow::Result;
use embedded_graphics::{
geometry::Point,
mono_font::{ascii::FONT_10X20, MonoTextStyle},
pixelcolor::Gray4,
prelude::*,
primitives::Rectangle,
text::Text,
};
use esp_idf_svc::hal::{
delay::{Delay, BLOCK},
gpio::PinDriver,
i2c::{I2cConfig, I2cDriver},
peripherals::Peripherals,
prelude::*,
spi::{SpiConfig, SpiDeviceDriver, SpiDriver, SpiDriverConfig},
};
use it8951::{interface::IT8951SPIInterface, IT8951};
use thiserror::Error;
fn main() -> Result<()> {
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
let periph = Peripherals::take()?;
// m5paper battery power on
PinDriver::output(periph.pins.gpio2)?.set_high()?;
// i2c initialize
let i2c_config = I2cConfig::new()
.baudrate(100.kHz().into())
.sda_enable_pullup(false)
.scl_enable_pullup(false);
let mut i2c = I2cDriver::new(
periph.i2c0,
periph.pins.gpio21,
periph.pins.gpio22,
&i2c_config,
)?;
log::info!("Ready I2C");
// IT8951 initialize
// gpio
let rst = PinDriver::output(periph.pins.gpio23)?;
let rdy = PinDriver::input(periph.pins.gpio27)?;
//SPI Driver
let spiconf = SpiConfig::default().baudrate(10.MHz().into());
let spi_d = SpiDriver::new(
periph.spi2,
periph.pins.gpio14, //sclk
periph.pins.gpio12, //sdo
Some(periph.pins.gpio13), //sdi
&SpiDriverConfig::default(),
)?;
let spi = SpiDeviceDriver::new(
spi_d,
Some(periph.pins.gpio15), //CS
&spiconf,
)?;
log::info!("Ready SPI");
// IT8951 Driver
let it8951_if = IT8951SPIInterface::new(spi, rdy, rst, Delay::default());
let mut epd = IT8951::new(it8951_if)
.init(2300)
.map_err(It8951Error::From);
log::info!("End of initialization of peripherals.");
let delay = Delay::default();
// SHT30 データ取得用バッファ
let mut sht30buff: [u8; 6] = Default::default();
// main loop
loop {
// SHT30 温湿度データ取得
// i2cアドレス: 0x44, コマンドコード: 0x2C06(精度レベル「高」クロスストレッチ有効)
// 測定に、最大15msが必要。
log::info!("start of SHT30 measurement.");
i2c.write(0x44, &[0x2c, 0x06], BLOCK)?;
delay.delay_ms(15);
i2c.read(0x44, &mut sht30buff, BLOCK)?;
// buff[0-1]:温度 buff[2]:チェックサム buff[3-4]:湿度 buff[5]:チェックサム
let st = (sht30buff[0] as u32) << 8 | sht30buff[1] as u32;
let temp: f32 = -45.0 + 175.0 * (st as f32 / 65535.0);
let srh = (sht30buff[3] as u32) << 8 | sht30buff[4] as u32;
let hum: f32 = 100.0 * (srh as f32 / 65535.0);
log::info!("temp: {:.2}, hum: {:.2}", temp, hum);
// 温湿度の表示
epd.fill_solid(
&Rectangle::new(Point::new(100, 100), Size::new(170, 140)),
Gray4::WHITE,
)
.map_err(It8951Error::from)?;
let textstyle = MonoTextStyle::new(&FONT_10X20, Gray4::BLACK);
let temp = format!("temp:{:.2}", temp);
Text::new(&temp, Point::new(120, 120), textstyle)
.draw(&mut epd)
.map_err(It8951Error::from)?;
let hum = format!("hum:{:.2}", hum);
Text::new(&hum, Point::new(120, 150), textstyle)
.draw(&mut epd)
.map_err(It8951Error::from)?;
epd.display(it8951::WaveformMode::GrayscaleClearing16)
.map_err(It8951Error::from)?;
// IT8951 及び、 ESP32をスリープモードにして・・・
let epd_down = epd.sleep().map_err(It8951Error::from)?;
unsafe {
esp_idf_svc::sys::sleep(30);
}
// IT8951を起こす。
epd = epd_down.sys_run().map_err(It8951Error::from)?;
}
}
/// IT8951モジュールのエラーのラッパー
/// it8951::errorはstd::error::Errorを実装しない。
#[derive(Error, Debug)]
enum It8951Error {
#[error("IT8951: A error in the spi driver.")]
Spi,
#[error("IT8951: A error in the gpio driver.")]
Gpio,
#[error(transparent)]
Esp(#[from] esp_idf_svc::sys::EspError),
}
impl From<it8951::Error> for It8951Error {
fn from(e: it8951::Error) -> Self {
let it8951::Error::Interface(err) = e;
Self::from(err)
}
}
impl From<it8951::interface::Error> for It8951Error {
fn from(e: it8951::interface::Error) -> Self {
match e {
it8951::interface::Error::SpiError => Self::Spi,
it8951::interface::Error::GPIOError => Self::Gpio,
}
}
}