Minutus という mruby の Rust バインディングを作った

このところ、夏休みの自由研究として「mruby と Rust をいい感じにつなぎこむ」というのをやっていました。

github.com

お盆休みのすべてを費やし、なんとか「実用可能」といえそうなレベル*1まで来たので、この記事で簡単に説明したいと思います。

(↓は Matz にリツイートされてとても嬉しかったツイート)

Minutus とは

Minutus は、Rust と mruby をいい感じに連携するためのライブラリです *2

Minutus を使うと、Rust の中で mruby のスクリプトを eval できます。以下のようなイメージです。

gist.github.com

minutus/minutus-test/src を覗くと、他にも多くの例を見ることができます。
例えば、mruby のメソッドを Rust から呼べたりします。

また、それとは別に、 mrbgem (mruby 用の ライブラリ) を Rust で作ることもできます。
minutus/examples/mruby-polars はその例で、Rust のデータフレームである polars を mruby に(全く不完全な形で)ポートした mrbgem です。

Minutus で達成したいこと

Minutus で達成したいことは2つあります。

  1. 最新の mruby (3系) で動く
  2. 利用者が難しいことを考えずに、とにかく mruby と Rust を接続できる

これらを目指す理由を次で述べます。

mruby と Rust を接続する際の問題点

人類には「Rust で作ったツールに mruby の DSL を入れたい」という普遍的な欲求がある、ということは広く知られています。また、mruby のネイティブ拡張を Rust で書きたいという人類も多く存在していると思います。

しかし、その実現には高いハードルがあります。

まず、新しい mruby (3系)で動く Rust / mruby のバインディングは存在しません。例えば、mrusty がサポートする mruby のバージョンは 1.2.0 です。現在の mruby の最新のバージョンは 3.1.0 なので、これは相当古いと言えるでしょう*3*4

かといって、mruby の C API を、 Rust から直接触るのは茨の道です。

  • 避けられない unsafe 祭り
  • 自前で mruby をビルドし、リンクする必要がある
  • mruby の C API に習熟する必要がある
    • つまり、Rust も mruby も書ける、というだけではダメ
  • mruby の C API はマクロの形態で提供されるモノが多く、bindgen が認識してくれない*5
  • mruby から取り出した値を Rust の型に変換するのは面倒
  • Rust で作ったバイナリを mrbgem にするのは地味にハマりどころが多い
    • src/ 以下に C のソースコードがないと(おそらく) _init 関数が呼ばれない
    • mrbgem のインストール時に C言語以外のバイナリをビルドさせる正式な方法が(おそらく)ない*6

むかし私もこの問題にぶち当たり、Rust に mruby を埋め込むのを諦めたことがあります。
Rubyで設定を書けるLinux用キーマッパー 「rumap」をRustで作った - さんちゃのblog

Minutus の工夫

これらの問題を受け、最新の mruby で動き、かつ mruby の C API に詳しくなくても使える mruby / Rust のバインディングが必要だと感じました。

特に後者に関しては、以下のような工夫をしています。

  • mruby と接続するためのコードを生成するマクロを提供する
  • mrbgem のテンプレートを生成する CLI ツールを提供する
  • 実装例を提供する 
    • 前述の CLI ツール自体も Minutus の利用例となっています

これらの工夫により、mruby の C API に詳しくない人でも、気軽に mruby と Rust を接続できます。

Minutus の課題

「実用可能」といえるレベルにはなったと思うものの、Minutus には様々な問題が残っています。

  • GC と向き合えていない
    • 基本的には正しく実装できているはずで、利用中の値がうっかりGCされることはおそらく無いが、メモリリークしていない自信がない
    • evaluate 関数の実装に使っている mruby_load_string の返り値に対する GC の挙動がよくわからない
  • mruby のメソッドを Rust から安全に呼ぶ方法がない
    • 現状では、`define_funcall!` で定義した関数で問題が発生するとパニックするようになっている
    • これはマクロを改良したらいい感じにできるはずなので、暇を見つけて直したい
  • キーワード引数のある mruby のメソッドを呼ぶ方法がない
    • Rust の関数にはキーワード引数がなく、`define_funcall!` に渡すシグネチャが Rust の文法からどうしても乖離してしまうため、マクロの文法をどうするか悩んでいる
    • mruby 側に適当なメソッドを定義すれば、そのメソッド経由で呼び出すことはいちおう可能
  • マクロのエラーメッセージが雑すぎて文法ミスをデバッグできない
    • 例えば `define_funcall! { fn hoge(i64) -> i64 }` はコンパイルが失敗するが、`unexpected token` みたいなエラーメッセージしか出ないため、何が悪いのか全然わからない*7
  • Rust のモジュールシステムと組み合わせて動かせるのか未検証
    • `define_funcall!` や `wrap` と、それらを利用するコードが同じファイルにまとまってるパターンしか検証しておらず、それらが別モジュールに分離したときにうまく動くか怪しい
  • パフォーマンス
    • mruby と Rust の世界で値をやり取りするときの関数呼び出しの回数がエグいし、inline 展開とかもしてない
  • `wrap` で生成したクラスに initialize_copy を実装してないので、たぶん dup すると死ぬ
    • やるだけなので暇なときにやる
  • 実用的な例が存在しない
    • Rust の資産を mruby にポートするというようなことをやって、minutus 自体の欠陥を洗い出して洗練させつつ、minutus 利用者が参考にできるようにしたい
    • 例えば actix-web の mruby ラッパーを作りたいなと思っている

書き出してみると色々ありますが、暇を見つけて一つづつ潰していこうと思っています。

おわりに

この記事では、mruby と Rust をいい感じに接続するためのライブラリである Minutus について説明しました。

個人的には割と良いコンセプトのものができたと思っているので、興味のある方は使っていただけるととても嬉しいです。
こんなことはできないの?とか、ここがイケてないとか、ドキュメントが分かりづらいとか、なんでも良いのでフィードバックをいただけると泣いて喜びます。

以下の記事で言及されている通り、mruby 界隈は、特に周辺のエコシステムが古くなりつつあり*8、厳しい気持ちになることもあるのですが、mruby 自体は触っていてとても面白いので、盛り上げていけたらいいなと思っています。

udzura.hatenablog.jp

以上

*1:割と限定された用途ではあるが...

*2: Ruby の Rust バインディングである Magnus をめっちゃ参考にしました

*3:2系に追従しようとしている人もいるが、PR がスルーされている Support mruby 2.1.0 by wordijp · Pull Request #100 · anima-engine/mrusty · GitHub

*4:頑張って探したら他にもあるかもしれないが、サッと見た感じでは無さそう

*5: see: Improve macro defined constants to recognize #define CONSTANT ((int) 1) · Issue #316 · rust-lang/rust-bindgen · GitHub

*6: つまり、mrbgem をインストールするときに `cargo build --release` を実行させる方法がない

*7:このケースでは、引数名を書き忘れていることが原因。そもそも引数を省略しても動くようにしろという話もある。

*8: mrusty が 1.2.0 しかサポートしていないのはその一例

Ruby の拡張ライブラリを、Rust を使ってお手軽に実装する

magnus というcrate を利用すると、超簡単に Ruby の拡張ライブラリが実装できます。

具体的には、Rust 側の記述はこんな感じになります。

use magnus::{define_class, function, method, prelude::*, Error};

#[magnus::wrap(class = "Point")]
struct Point {
    x: isize,
    y: isize,
}

impl Point {
    fn new(x: isize, y: isize) -> Self {
        Self { x, y }
    }

    fn x(&self) -> isize {
        self.x
    }

    fn y(&self) -> isize {
        self.y
    }

    fn distance(&self, other: &Point) -> f64 {
        (((other.x - self.x).pow(2) + (other.y - self.y).pow(2)) as f64).sqrt()
    }
}

#[magnus::init]
fn init() -> Result<(), Error> {
    let class = define_class("Point", Default::default())?;
    class.define_singleton_method("new", function!(Point::new, 2))?;
    class.define_method("x", method!(Point::x, 0))?;
    class.define_method("y", method!(Point::y, 0))?;
    class.define_method("distance", method!(Point::distance, 1))?;
    Ok(())
}

これをビルドすると、Ruby 側に Point クラスが生えます。 Ruby の世界 と Rust の世界 の間で相互に自動で型が変換されていてすごいですね。

point_1 = Point.new(0, 0)
point_2 = Point.new(100, 100)
point_1.distance(point_2) # => 141.4213562373095

この記事では、渡された文字列をシャッフルする Shuffle という gem の実装を通じて、Rust で拡張ライブラリを作る方法を説明します。

なお、実行環境は macOS Monterey 12.4、CPU は M1 Pro です。とはいえ、他の環境でも似たような手順で行けると思います。

gem を作る

拡張ライブラリを入れるための gem を作っていきます。

gem を初期化する

$ bundle gem shuffle
$ cd shuffle

諸々のバージョンを揃える

Ruby のバージョンを 3.1.1 以上にする必要があります*1。 2022/06/12 現在では、 3.1.2 を入れておけば良いと思います。

$ rbenv install 3.1.2
$ rbenv local 3.1.2

また、Rubyems のバージョンを 3.3.11 以上にする必要があります*2

$ gem -v
3.3.15

# 古い場合はバージョンを上げる
$ gem update --system

Rust をセットアップする

rustup を利用して適当に Rust をセットアップします。 1.57.0 以上をインストールしてください*3

gemspec を適当に埋める

shuffle.gemspec の TODO になっている欄を適当に埋めます。埋めないとこの先の作業ができません。

参考: fix gemspec · genya0407/shuffle@5563f9c · GitHub

Rust で拡張ライブラリを作る

いよいよ拡張ライブラリを作っていきます。

拡張ライブラリをビルドする環境を作る

まず、拡張ライブラリをビルドするのに必要な gem を dependency として追加します。 shuffle.gemspec に以下を書き加え、bundle install を実行します。

  spec.add_dependency "rake-compiler"
  spec.add_dependency "rb_sys"
$ bundle install

次に、bundle exec rake compile で拡張ライブラリをビルドするため、 Rakefile に以下を書き加えます。

require "rake/extensiontask"

Rake::ExtensionTask.new("shuffle") do |ext|
  ext.lib_dir = "lib/shuffle"
  ext.source_pattern = "*.{rs,toml}"
end

そして、 ext/shuffle/extconf.rb を作成します。

require "mkmf"
require "rb_sys/mkmf"

create_rust_makefile("shuffle/shuffle")

最後に、 shuffle.gemspecextconf.rb を認識させるため、以下を書き加えます。

  spec.extensions = ["ext/shuffle/extconf.rb"]

ここまでで、Rust 拡張をビルドする準備が整いました。

参考: setup rake-compiler && rb_sys · genya0407/shuffle@61af062 · GitHub

Rust を書く

まず、 ext/shuffle で Rust のプロジェクトを初期化します。

$ cd ext/shuffle
$ cargo init --lib
$ echo 'target' >> .gitignore

そして、 Cargo.toml に以下を書き加えます。

[lib]
crate-type = ["cdylib"] # 拡張ライブラリを作るために必要

[dependencies]
magnus = "0.3"
rand = "0.8.5" # 文字列をシャッフルするのに使う

最後に、文字列をシャッフルするクラスを定義しましょう。 ext/shuffle/src/lib.rs に以下を記述します。

use magnus::{define_class, function, method, prelude::*, Error};
use rand::seq::SliceRandom;

#[magnus::wrap(class = "Shuffle")]
struct Shuffle {
    original: String,
}

impl Shuffle {
    fn new(original: String) -> Self {
        Self { original }
    }

    fn shuffle(&self) -> String {
        let mut rng = rand::thread_rng();
        let mut v = self.original.chars().collect::<Vec<_>>();
        v.shuffle(&mut rng);
        v.into_iter().collect()
    }
}

#[magnus::init]
fn init() -> Result<(), Error> {
    let class = define_class("Shuffle", Default::default())?;
    class.define_singleton_method("new", function!(Shuffle::new, 1))?;
    class.define_method("shuffle", method!(Shuffle::shuffle, 0))?;
    Ok(())
}

そしておもむろに bundle exec rake compile を実行すると、 lib/shuffle/shuffle.bundle というファイルが生成されます。

$ bundle exec rake compile
$ file lib/shuffle/shuffle.bundle
lib/shuffle/shuffle.bundle: Mach-O 64-bit dynamically linked shared library arm64

このファイルを require すると、 Shuffle クラスが使えます。

$ bundle exec irb
irb(main):001:0> require './lib/shuffle/shuffle'
=> true
irb(main):002:0> Shuffle.new('abc').shuffle
=> "bca"

gem のお作法的には、 lib/shuffle.rb から require_relative しておくのが良いです。

# frozen_string_literal: true

require_relative "shuffle/version"
require_relative "shuffle/shuffle" # これを追加

class Shuffle
  class Error < StandardError; end
  # Your code goes here...
end

なお、 bundle gem 実行時に、Shuffle は module として宣言されているので、そのままだと「Shuffle は class じゃなくて module だよ」みたいなエラーが出ます。 これを回避するために、 lib/shuffle.rblib/shuffle/version.rb を修正しておきましょう。

参考:implement extension · genya0407/shuffle@4389ef1 · GitHub

作った gem を利用する

RubyGems に publish するのも何なので、 github からインストールしてみます。 先程の shuffle gem を github に push しておきつつ、以下のような Gemfile を作って bundle install してみましょう。

$ cat Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem 'shuffle', github: 'genya0407/shuffle', branch: 'trunk'

$ bundle install
Fetching https://github.com/genya0407/shuffle.git
Fetching gem metadata from https://rubygems.org/.
Fetching rake 13.0.6
Installing rake 13.0.6
Using bundler 2.3.15
Fetching rake-compiler 1.2.0
Fetching rb_sys 0.9.4
Installing rb_sys 0.9.4
Installing rake-compiler 1.2.0
Using shuffle 0.1.0 from https://github.com/genya0407/shuffle.git (at trunk@f24fe1f)
Bundle complete! 1 Gemfile dependency, 5 gems now installed.
Bundled gems are installed into `./vendor`

$ bundle exec irb
irb(main):001:0> require 'shuffle'
=> true
irb(main):002:0> Shuffle.new('abcdefg').shuffle
=> "gdecafb"

このように、 gem として利用できることが確認できました。 なお、利用者側の環境にも Rust が必要である点に注意して下さい。

magnus に関する雑感

magnus の型変換がちゃんとしててすごいと思いました。 感動ポイントを箇条書きします*4

  • 関数の型宣言を見て、勝手に型変換をやってくれる
  • 型変換ができないときは手動でも変換できる
  • Ruby のオブジェクトと Rust の構造体の紐付けを勝手にやってくれる
    • C拡張みたいに TypedData_Wrap_Struct とかしなくていい。

全体的に、「そうなってほしい」という思いが達成されていて、おまじないが少ない作りになっているのが良いです。

まとめ

拡張ライブラリを実装したくなる機会は現状ではそれほどないと思いますが、このレベルまで簡単に実装できるようになったことで、Rust の拡張ライブラリを作るという選択肢が一般的になってくると Ruby の将来という観点でも面白い と思いました。

例えば、 GitHub - genya0407/reing_print_image は、 GitHub - genya0407/reing_text2image を拡張 gem にしたものです。

reing_text2image はもともと、 Rails で作った 自作質問箱 でOGPの画像を生成するために作ったツールでした。

以前はこれをCLI ツールとしてRuby から呼び出して、一度ディスクに画像ファイルとして書き出したものを Ruby から read して send_data で返却するという、結構すごい作りをしていました。

しかし、Rust を使った拡張 gem が手軽に作れるようになったことで、ファイルを介すことなく、png のバイナリを直接 Rust → Ruby で受け渡せるようになりました。

reing_print_image/reing_print_image_spec.rb at trunk · genya0407/reing_print_image · GitHub

このように、全体的には RubyRails で実装しておき、無理なところだけ Rust に切り出す、ということができるようになったことで、Ruby が関与できる世界が広がっていく*6のではないかと思いました。

Rust で拡張ライブラリを作るのは思いの外面白かったので、他にもネタを見つけてやっていきたいと思います。

*1:RubyGems 3.3.11 を利用するため

*2:Rust extension support を利用するため 3.3.11 Released - RubyGems Blog

*3:magnus がサポートしているのが 1.57.0 以上なので GitHub - matsadler/magnus: Ruby bindings for Rust

*4:筆者は拡張ライブラリを真面目に書いたことがないので、的はずれなことを言っている可能性も高いですが...

*5:Rust の String は UTF-8 として valid な文字列しか入らない

*6:あるいは縮小を緩和できる

スポットインスタンスで個人開発 Web サーバーを運用する技術

趣味の Web アプリを廉価にデプロイしたい、という話題が最近盛り上がっています。

この記事では、最近の私の個人開発 Web サーバーの運用方法を簡単に説明します。

TL; DR

  • AWS のスポットインスタンスを一台借りて、terminate されたときはいい感じに再起動する
    • データ永続化用の EBS を別途マウントする
  • そこに docker でアプリをたくさんデプロイして相乗りする
    • DB も docker で立てる
  • wildcard 証明書を取って、リバースプロキシに subdomain ベースでルーティング先のコンテナを振り分けさせる
  • 料金は諸々合わせて $13/月 ぐらい

詳解

スポットインスタンス

スポットインスタンス は、AWSから廉価にEC2サーバーを借りる手段ですが、突然サーバーをシャットダウンされる可能性があります。そのため、スポットインスタンス1台だけでサービスを運用するのは、通常は許されない行為です*1

しかし、利用者が少なくお金も取ってないような個人開発アプリケーションという文脈では、多少のダウンタイム(数分/週程度)は十分許容できます。つまり、スポットインスタンスが落とされても、数分以内に新しいインスタンスが立ち上げられれば問題ありません。

私は、個人開発サーバーをスポットインスタンスで運用し、以下のような lambda スクリプトを5分おきに実行するようにしています。

  • 特定のタグがついた EC2 インスタンスが存在するかチェックする
    • 存在しない場合は、適切なインスタンスタイプを判定して新しく作る
  • そのEC2インスタンスに特定の EIP がアタッチされているかチェックする
    • アタッチされていない場合はアタッチする
  • そのEC2インスタンスに特定の EBS がマウントされているかチェックする
    • マウントされていない場合はマウントする
  • そのEC2インスタンスで dockerd が起動しているかチェックする
    • 起動していない場合は起動する

これによって、インスタンスが terminate されても最長10分程で元の状態に戻ります。

ちなみに、大体4ヶ月ぐらい運用していて、実際にこの処理が実行されたのは12回ほどでした。ただ、これには動作確認のため私が手動で terminate したものも含まれます。体感では、 t4g.small を使う限りでは月に一回程度 terminate されるかどうか、といったところです。

また、Heroku と違って東京リージョンにサーバーを立てられるので、日本からアクセスしたときのレイテンシが体感できるレベルで速いです*2*3

docker でアプリをデプロイ

以下の記事を参考にして、docker compose でアプリをデプロイしています。

Docker Contextsを使ってDocker Composeをデプロイする際の注意点 - ぷらすのブログ

たくさんアプリをデプロイしても、母艦となる EC2 インスタンスが汚れないので持続性が高いです。なお、概ね記事通りの構成ですが、いくつか差分があります。

  • Docker Registry を自前で立てて、そこから image を落としてきてコンテナを起動する構成
    • Registry なしの構成はなんかうまく行かなかったので断念
    • Registry も「アプリ」の一つとして運用している
    • ECR は高いので却下
  • 諸々を楽にやるための CLI Tool を作った
    • GitHub - genya0407/carrier

      • `carrier init` で設定ファイルを生成
      • `carrier release` で image を build / push
      • `carrier deploy` でコンテナを起動
    • 自分以外の人が使うことをあんまり想定してない作りです

RDBMS についても、docker コンテナの一つとしてサービスごとに立ち上げています。MySQL はメモリの消費量が多かったので*4PostgreSQLSQLite を使うようにしています。

リバースプロキシ・SSL

前述のブログを参考にして、リバースプロキシには Caddy を使っています。適切に設定するとワイルドカード証明書を勝手に取得してくれるすごいやつです。Caddy も docker コンテナとしてデプロイして、 Dockerの埋め込みDNSサーバを使ったService Discovery を利用して各サービスにルーティングしています。

料金について

月々の料金は大体 $13 ぐらいですが、その内訳は、

  • EBS:$7.5
  • EC2:$4.5
  • Secrets Manager:$0.5
  • Route53:$0.5

という感じで、データの永続化のために契約している EBS の料金が支配的です。

EC2 に関しては、t4g.small というかなり弱めのインスタンスタイプを利用しているということもあり、かなり安く抑えられていると思います。アプリが増えてリソースが足りなくなってきたら、もう少し強いインスタンスに乗り換えても良いかもしれません。

雑感

  • 可用性を犠牲にし、アプリもDBも全部スポットインスタンスに立てるという意思決定が出来たのは良かった
    • RDS とか借り始めるといきなりお金がかかるようになるので厳しい
    • DynamoDB 等を使うという選択肢もあるが、やはり SQL で開発したいという気持ちは強い
  • サーバーのスペックアップ・スペックダウンが自在に行える点は良い
    • 万が一アプリがバズったときに札束で殴る余地がある
    • さくらのVPSとかだと、スペックアップはできてもスペックダウンができないので、一時的なスペックアップという選択肢が取りづらい
  • arm64 のマシンが使えるのが良い
    • ローカルの mac が M1 なので、手元で docker image をビルドする関係上、サーバーも arm64 でないとビルドにかかる時間がえらいことになる
    • 前に使っていたさくらのVPSには arm64 マシンがなかったような気がする(今はあるかも)
  • スポットインスタンス再起動用の lambda をいい感じに作れたのが個人的には気に入っている
    • 冪等なので、インスタンスが存在するときに動かしても問題なく、定期的に実行すれば良いだけなので、考えることが少なくて済んでいる
  • EBS の料金が高いのはちょっと意外だった
  • 本当にアクセス数が少ないアプリしかないので、もう少しコンスタントにアクセスが来るようになったら成立しなくなる可能性は十分ある
    • そのときはそのとき考える
  • k8s で似たようなことができるんじゃないかなと思ってるけど、やり方がよくわからないので勉強中です

以上

*1:実はオンデマンドインスタンスも突然死ぬことはあるので、オンデマンドインスタンス1台で運用するのもやってはいけないのだが...

*2:Heroku はアメリカとヨーロッパにしかサーバーが立てられないので、体感できるレベルでレイテンシがでかい

*3:例えば  Reing あたりにアクセスすると速さを感じられると思う

*4:設定を適切にすれば解決できる気がするが私には無理でした

「ハイパフォーマンスブラウザネットワーキング」を読んだ

ISUCON11を振り返る - さんちゃのblog に書いたとおり、ブラウザ・HTTP周りの知識の少なさを感じていた。この問題を解決するために、「ハイパフォーマンスブラウザネットワーキング」を読んだ。

この本は、単にベストプラクティスを列挙するのではなく、TCP / TLS / WiFi / 3G・4G回線などの通信経路の「原理」あるいは「メンタルモデル」を提供する。そのため、読者が自分で検討する能力を獲得することができる。

HTTP/2 や WebSocket、 WebRTC などの新しく導入されたプロトコルについての解説も厚くされているのだが、その背景となる通信やデバイスの特性とセットで示されるため、その必要性や意味するところ、導入するべきユースケースなどが想像しやすい。

もちろん単に原理を述べるだけではなく、そこから導き出される具体的な種々のベストプラクティスもしっかり記載されている。例えば、最新のTCPの設定を有効にするためにサーバーのカーネルをバージョンアップすることの重要性とか、HTTPレイヤのキャッシュの設定方法とか、HTTP/2への乗り換えの案内とか。

そのため、「今すぐ役立つパフォーマンス改善の手引」として読むこともできるし、読者の能力を拡張してくれる教科書として読むこともできる。

この本を読むことで、自分が運用しているシステムの普段意識しない詳細について知りたくなるだろうし、また、これからソフトウェアを実装する際に意識できるポイントが大きく変わってくると思う。非常に学びが多かったと感じるし、現実的に読み切れる分量の技術書でもあるし、個人的にはオススメの本だ。

なお、初版が2014年とやや古いことに起因するためか、2021年の第2版においても 5G や HTTP/3 (QUIC) などのトピックについての言及がないことはやや残念ではある。第3版を楽しみに待っている。

自宅の最強の加湿器、あるいは私は如何にして心配するのを止めて加湿器を起動するようになったか

最強の加湿器

世の中には「最強の加湿器」を作った人がいる。

www.slideshare.net

曰く、「最強の加湿器」は湿度を自動で適切な値に維持してくれる*1。この加湿器が欲しくなったので作ることにした。

作成中に発覚した問題

数時間の作業の結果、自室の湿度を測定するのはラズパイにセンサーを付けたらできたし、その値をもとに加湿器が起動しているべきか停止しているべきかを判定するロジックを組むこともできた。

しかし、実際に加湿器を操作するロジックを組む段になって、問題が発覚した*2

加湿器を操作する難しさ

自宅にある加湿器は、押し込みボタンでオンオフを制御するタイプの製品だ。

f:id:threetea0407:20220321225831p:plain

このボタンは内部に状態(押し込まれているか否か)を持っている。そのため、加湿器をオンオフするためには、ソフトウェア側でボタンの状態を管理する必要がある。

例えば、加湿器がオフになっているべきだと判定されたとしよう。ボタンが押し込まれている状態ならボタンを押す必要がある。一方で、ボタンが押し込まれていないならば、何もしてはいけない

このように、ボタン経由で加湿器の操作をする場合、「いま、ボタンは押し込まれているか?」という状態をソフトウェア上に保持しなければならない。これでは実装が面倒になる上に、ソフトウェアと実機上での状態が乖離する可能性もある。状態が乖離した場合、それをソフトウェアによって修正することは不可能である。

ボタンの押し込まれ具合やランプの色から現在の状態を判別することも可能かもしれないが、新しくセンサーを導入する必要があり、管理が煩雑になる。また、湿度の時間微分の正負によって加湿器の状態を推定するといったことも検討したが、湿度は加湿器の状態のみに依存するわけではない*3ということもあり、正確に推定するのはかなり難易度が高いと思われた。

この問題を解決するために、加湿器のオンオフ操作をボタン経由ではなく、電源経由で行うことにした。

加湿器を操作する最強の方法

加湿器のオンオフ操作を電源経由で行うというのは、ボタンは「入」の状態で固定しておき、加湿器をオフにしたくなったらコンセントを抜き、オンにしたくなったら差す、ということである。

実際、コンセントの仮想的な抜き差しを実現するデバイスは Switch bot から提供されている。

このデバイスは、現在電源がついているかどうかに関わらず「電源をオフにする」と「電源をオンにする」という操作を行うことができる(そういうAPIが生えてる)。そのため、加湿器の現在の状態を気にすることなく、加湿器のオンオフ操作を行うことができる。

最強の加湿器

最強の加湿器が動作する様子がこちらだ。

f:id:threetea0407:20220321235032p:plain

しきい値を上回ったり下回ったりしたタイミングで加湿器のオンオフが切り替わり、湿度が反転している様子がわかる。

雑感

問題に気づいたときに、無理やりセンサーでどうにかしたり、煩雑な状態管理の処理を書くのではなく、シンプルかつ堅牢な方法を考えることで対処できたのは良かったと思った。

まだ最強の加湿器を運用し始めて日も浅いので、今後も様子を見守りつつ、他のパラメータ(例えばCO2濃度)についても自動で制御されるようにしていきたいと考えている。

*1:スライド中には他にもいくつか条件があるが、本記事ではこれを定義とする

*2:実際には問題が解決してから判定ロジックを組んでいるのだが、話をわかりやすくするために事実とは異なる時系列を採用している

*3:例えば、室温が上がると湿度は下がる