技術Labo

気になったことやちょっとした作業の覚書

売れたメールをラズパイが声で知らせる仕組みを作った:queue とスピーカーの話

作業部屋に、使っていなかったラズパイ 3 が転がっていました。せっかくなので小さなスピーカーをつないで、商品が売れたときに声で知らせてくれる仕組みを作ってみました。

フリマアプリや EC で商品が売れると、お知らせのメールが届きます。そのメールを見つけると、ラズパイが「メルカリで、商品が売れました。」のように、売れた先ごとに決まった声で話してくれます。メールの画面を開いていなくても、部屋にいれば気づけるのが便利なところです。

作っているうちに欲が出て、ラズパイが重いときや、Wi-Fi・インターネットが切れたときにも話すようにしました。この記事では、仕組みの全体の形と、途中でつまずいたスピーカーのノイズと電源の電圧の話をまとめます。

ケースに入れたラズパイ 3
使っていなかったラズパイ 3。ケースに入れて机の上に置いています

全体の形:声は 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 ケーブル
はじめに使っていた細いケーブル
白い太いケーブルと黒い細いケーブル
白いのが今の太いケーブル。黒いのが前の細いケーブル

電源はポータブル電源から、ソーラーで充電

今は、ラズパイをポータブル電源の 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 台で自動にできます。

参考

音声:VOICEVOX(里石ユカ)

関連記事

フリースタイル
WEBシステムエンジニア
制作作業の合間に、情報の整理。ポートフォリオ用まとめサイト
ランサーズ ランサーズの現在のランク:認定ランサー
Lancers にてお仕事中
  • 評価・実績