売れたメールをラズパイが声で知らせる仕組みを作った:queue とスピーカーの話
作業部屋に、使っていなかったラズパイ 3 が転がっていました。せっかくなので小さなスピーカーをつないで、商品が売れたときに声で知らせてくれる仕組みを作ってみました。
フリマアプリや EC で商品が売れると、お知らせのメールが届きます。そのメールを見つけると、ラズパイが「メルカリで、商品が売れました。」のように、売れた先ごとに決まった声で話してくれます。メールの画面を開いていなくても、部屋にいれば気づけるのが便利なところです。
作っているうちに欲が出て、ラズパイが重いときや、Wi-Fi・インターネットが切れたときにも話すようにしました。この記事では、仕組みの全体の形と、途中でつまずいたスピーカーのノイズと電源の電圧の話をまとめます。

全体の形:声は PC で作り、ラズパイは鳴らす
仕組みは、PC とラズパイに分けています。
| どこで | いつ | すること |
|---|---|---|
| PC | 声を足すとき | VOICEVOX で文を声にして、WAV のファイルをラズパイに置く |
| ラズパイ | 3 分ごと | メールをのぞき、売れたメールなら決まった WAV を queue に入れる |
| ラズパイ | 1 分ごと | queue に来た WAV を名前の順に鳴らして、消す |
| ラズパイ | 5 分ごと | 重さ・電圧・熱を見て、気になるときは WAV を queue に入れる |
| ラズパイ | ずっと | 10 秒ごとに ping を打ち、Wi-Fi やインターネットが切れたら WAV を queue に入れる |
声は PC の VOICEVOX で作ります。知らせる文はいつも同じなので、前もって WAV にしておけば十分です。声のファイルをラズパイの中に置いておけば、Wi-Fi が切れているときでも「切れています」と話せます。
ラズパイのスクリプトは Python 3 の標準のライブラリだけで書いて、cron で動かしています。
鳴らすのは queue の役目だけにする
メールの通知だけなら、売れたメールを見つけたその場で鳴らせば済みます。ただ、知らせることを増やしていくと、2 つの声が同時に鳴ってしまうことがありますし、「夜は鳴らさない」という決まりも知らせるたびに書くことになります。
そこで、音を鳴らすのは 1 つのスクリプトだけにして、ほかのスクリプトは鳴らしたい WAV を queue というフォルダにコピーするだけ、という分け方にしました。
def enqueue(wav):
q = os.path.join(HERE, "queue")
os.makedirs(q, exist_ok=True)
name = time.strftime("%Y%m%d-%H%M%S") + "-" + wav
tmp = os.path.join(q, "." + name)
shutil.copyfile(os.path.join(HERE, "sounds", wav), tmp)
os.replace(tmp, os.path.join(q, name))
files = sorted(f for f in os.listdir(QUEUE)
if f.lower().endswith(".wav") and not f.startswith("."))
for f in files:
path = os.path.join(QUEUE, f)
if play_path(path, device):
os.remove(path)
else:
os.replace(path, os.path.join(FAILED, f))
| 書き方 | 役目 |
|---|---|
| 名前の頭に日時 | 名前の順に並べると、来た順に鳴る |
"." + name で置いてから os.replace |
コピーの途中のファイルを鳴らさない(「.」で始まる名前は鳴らす側が見ない) |
os.remove(path) |
鳴らせたら消す。queue に残っているものは、まだ鳴っていないもの |
FAILED |
鳴らせなかったものは消さずに別のフォルダへ移す |
play_path の中では、aplay を呼ぶ前にファイルのロック(fcntl.flock)を取っています。スピーカーの確認のために手で直接鳴らしたときと重なっても、音は 1 つずつ順番に鳴ります。
この形にしておくと、PC で好きな文を声にして scp で queue に送るだけで、ラズパイに話させることもできます。
売れたメールだけを見分ける
メールは IMAP で読みます。最初に決めたのは「のぞくだけ」にすることで、既読にしない、消さない、送らない、の 3 つを守っています。ふだん使っているメールソフトから見たときに、何も変わっていないようにしたかったからです。
m = imaplib.IMAP4_SSL(host, 993, timeout=30)
m.login(user, password)
m.select("INBOX", readonly=True)
typ, data = m.search(None, "SINCE", since)
for i in data[0].split():
typ, msg = m.fetch(i, "(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM MESSAGE-ID)])")
| 書き方 | 役目 |
|---|---|
readonly=True |
受信箱を読むだけで開く |
BODY.PEEK[...] |
読んでも既読にならない取り出し方 |
HEADER.FIELDS (...) |
本文は取らず、件名・差出人・Message-ID だけを取る |
売れたメールかどうかは、件名の形と差出人のドメインの両方を見て決めています。件名だけで判断すると、同じ件名のメールをだれかが送ってきたときにも鳴ってしまうからです。
RULES = [
(re.compile(r"^【サービス A】「.*」が売れました"), ("example-a.jp",), "sold_a.wav"),
(re.compile(r"^注文が入りました[::]"), ("example-b.jp",), "sold_b.wav"),
]
def sender_ok(sender, domains):
addr = email.utils.parseaddr(sender or "")[1].lower()
dom = addr.rpartition("@")[2]
return any(dom == d or dom.endswith("." + d) for d in domains)
def match(subject, sender):
for rx, domains, wav in RULES:
if rx.search(subject):
return wav if sender_ok(sender, domains) else None
return None
| 書き方 | 役目 |
|---|---|
RULES |
件名の形・差出人のドメイン・鳴らす声の組。サービスを足すときはここに 1 行足す |
dom.endswith("." + d) |
mail.example-a.jp のような下のドメインも認める。example-a.jp.example のような似せた名前は認めない |
return ... else None |
件名が合っても、差出人が違えば鳴らさない |
差出人のドメインは、これまでに届いたメールの差出人を見て決めました。まだ届いたことがないサービスは、そのサービスの公式のドメインにしています。差出人の欄は送る側が書き換えられるので、これでなりすましを全部防げるわけではありませんが、件名だけで判断するよりはずっと鳴りにくくなります。
一度見たメールは Message-ID をファイルに書いておき、二度鳴らさないようにしています。はじめて動かしたときだけは、今あるメールを覚えるだけで鳴らさないようにしました。これがないと、最初の 1 回で過去の売れたメールが次々に読み上げられてしまいます。
夜は鳴らさず、朝にまとめて
夜中に「売れました」と話されても困るので、21 時から朝 8 時までは鳴らさないことにしました。とはいえ、メールを見るのは夜も続けています。見つけたものは queue に入れておき、鳴らす側が朝まで待つので、8 時を過ぎると夜のあいだにたまった声がまとめて鳴ります。
def quiet_now(cfg):
start = cfg.getint("notify", "quiet_start", fallback=21)
end = cfg.getint("notify", "quiet_end", fallback=8)
h = datetime.datetime.now().hour
return (h >= start or h < end) if start > end else (start <= h < end)
| 書き方 | 役目 |
|---|---|
start > end |
21 時〜8 時のように日をまたぐとき |
h >= start or h < end |
日をまたぐときは「21 時以降」か「8 時より前」なら鳴らさない |
start <= h < end |
13 時〜14 時のように日をまたがないとき |
この判定は鳴らす側の 1 か所にしかないので、ほかの知らせも queue に入れさえすれば、同じように朝まで待ってくれます。
つまずいたこと:1 秒おきのノイズと電圧不足
スピーカーから 1 秒おきにノイズが出る
はじめは、スピーカーをラズパイのイヤホンの差し口につないでいました。声はちゃんと鳴るのですが、何も鳴らしていないときにも、スピーカーから 1 秒おきにノイズが出ていました。
1 秒おきに動いているものといえば、通信の見張りのために打っている ping です。ping は Wi-Fi で送るので、そのたびにラズパイが電波を出し、電気の使い方も変わります。
最初は、イヤホンの差し口に電波が混じっているのだろうと考えて、音の出口を USB の音の変換アダプターに替えてみました。ところが、USB から音を出してもノイズは消えません。Wi-Fi の省電力の設定を切ってみても変わりませんでした。
最後に ping をしばらく止めてみたところ、ノイズがぴたりと消えました。音の出口を替えても消えず、ping を止めると消えるので、原因は 1 秒ごとの ping だったことになります。
とはいえ、ping は Wi-Fi の切れを見つけるのに使っているので、なくすわけにはいきません。そこで間隔を 1 秒から 10 秒に広げ、「切れた」と判断するまでの時間を 60 秒にしました。これで、ノイズが出るのは 10 秒に 1 回に減りました。
NAS へデータを送っているあいだも同じようなノイズが出ますが、こちらは「今、動いている」合図だと思って、そのままにしています。
電源のケーブルが細いと電圧が足りない
もう 1 つは電源です。ラズパイには、電気と熱の状態を見るコマンドがあります。
$ vcgencmd get_throttled
throttled=0x50005
手元にあった細い USB ケーブルで電源をとっていたときは、0x50005 と出ました。この値は、いくつかのビットを足したものです。
| ビット | 意味 |
|---|---|
0x1 |
今、電圧が足りない |
0x4 |
今、速さを落としている |
0x10000 |
起動してから一度でも、電圧が足りなくなった |
0x40000 |
起動してから一度でも、速さを落とした |
つまり、電圧が足りず、そのせいで速さも落としている状態でした。太いケーブルに替えて起動し直すと、0x0 に戻りました。
ケーブルは、見た目が同じでも流せる電気の量が違います。ラズパイの調子が悪いときは、まずこのコマンドを見てみるのがよさそうです。


電源はポータブル電源から、ソーラーで充電
今は、ラズパイをポータブル電源の USB につなぎ、スピーカーはラズパイにつないでいます。

ポータブル電源の画面を見ると、出ている電気は 1W ほどです。この形で、充電しないまま 3 日間ずっと動かせることを確かめました。3 日たったときの残りは 47% でした。
ポータブル電源はソーラーパネルでも充電でき、天気のよい日の昼に充電したら 100% まで戻りました。3 日に 1 回、天気のよい日にソーラーで充電すれば、家のコンセントを使わずに動かし続けられそうです。
Wi-Fi でつないでいれば、コンセントのない場所にもそのまま持って行けます。うちではこのあと有線 LAN に替えるので置き場所は決まってしまいますが、こういう使い方もできる、という例として書いておきます。
声で知らせることを足していく
queue に入れれば鳴る形にしたので、知らせることを増やすのは簡単です。WAV を 1 つ作って、queue に入れるスクリプトを書けば足せます。
ラズパイが重い
5 分ごとに、5 分の平均の負荷(/proc/loadavg)を見ています。ラズパイ 3 は 4 コアなので、3.0 を超えた回が 2 回続いたら(10 分以上)知らせ、一度知らせたら 1 時間は黙っています。
ただ「重い」と言われても、何で重いのかが分からないと調べにくいので、原因ごとに声を分けました。
REASONS = [
("mem", lambda v: v["avail_mb"] < 100, "load_mem.wav"),
("io", lambda v: v["wa"] >= 30, "load_io.wav"),
("cpu", lambda v: v["us"] + v["sy"] >= 70, "load_cpu.wav"),
]
| 書き方 | 役目 |
|---|---|
avail_mb |
/proc/meminfo の MemAvailable(MB)。100 MB を切ったら「メモリ不足で重い」 |
wa |
vmstat のディスクの待ち(%)。30 以上なら「ディスクの待ちで重い」 |
us + sy |
vmstat の CPU の使用(%)。70 以上なら「CPU で重い」 |
どれにも当てはまらなければ、ただ「重くなっています」と話します。知らせるときには CPU を多く使っている処理の一覧をログに残しているので、声を聞いたらまずそのログを見れば、何が動いていたかが分かります。
電圧不足と熱
同じ 5 分ごとに、さきほどの vcgencmd get_throttled も見ています。「今、電圧が足りない」(0x1)なら電圧の声を、「今、速さを落としている」(0x4・0x8)なら熱の声を鳴らします。
「起動してから一度でも」のビットは見ていません。これを見てしまうと、一度起きただけで、そのあとずっと鳴り続けてしまうからです。
Wi-Fi やインターネットが切れた
10 秒ごとの ping は、ルーター・回線の入口・外の DNS サーバーの 3 か所に打っています。
| どこに届かないか | 話すこと |
|---|---|
| ルーターに届かない | 「ワイファイが切れています」 |
| ルーターには届くのに、外の 2 か所に届かない | 「インターネットが、切れています。ルーターの先を確かめてください」 |
ラズパイの Wi-Fi は、1 回だけ失敗することがふだんからよくあります。そのたびに話されては困るので、60 秒続けて届かないときだけ知らせるようにしました。1 回の切れにつき話すのは 1 回だけで、戻ったら「戻りました」と話します。
ルーターまでは届いているのに外に出られない、という切れ方もあります。声が分かれていれば、ルーターの先を見ればよいのか、ラズパイの近くを見ればよいのかがすぐに分かります。
家の NAS のファイルの一覧づくり
声とは別の話ですが、同じラズパイで、家の NAS のファイルの一覧も作っています。
ラズパイも NAS も、いつも動いているとは限りません。決まった時刻に 1 回だけ動かす形だと、その時刻に NAS が止まっていれば、その日は一覧が作られません。そこで 1 時間ごとに起こして、前の一覧から 20 時間以上たっていて、夜で、NAS が 3 秒以内に応えるときだけ作り直すようにしました。
一覧を作っているあいだは NAS を読む待ちで負荷が上がるので、その間は重さの知らせを休ませています。
まとめ
売れたメールを声で知らせるだけのつもりが、気づけばラズパイの重さや電圧、通信の切れまで話すようになりました。ここまで増やせたのは、はじめに「鳴らすのは queue の役目だけ」と決めておいたおかげです。
つまずいたのは、コードよりもつなぎ方のほうでした。ノイズは、音の出口を替えても消えなかったことと 1 秒という間隔から ping に見当をつけ、止めてみて確かめました。電圧不足は vcgencmd の値で確かめました。
電気は 1W ほどなので、ポータブル電源とソーラーがあれば、コンセントなしでも動かせそうです。メールを見て何かをする、決まった時刻に何かを確かめて知らせる、といった小さな仕事なら、使っていない機械 1 台で自動にできます。
参考
- imaplib — IMAP4 プロトコルクライアント(Python ドキュメント)
- email.header: 国際化されたヘッダ(Python ドキュメント)
- Raspberry Pi OS(Raspberry Pi Documentation・vcgencmd の説明があります)
- VOICEVOX 公式サイト
音声:VOICEVOX(里石ユカ)


