Raspberry Piで、ひかり電話の着信をSlack通知してみた

ひかり電話ルータ PR-400KI の着信をSlack通知してくれる仕組みを作りました。
少し改変すれば他のルータでも動作すると思います。

## 背景

我が家の電話はとてもシンプルで、不在着信を確認する手段がありません。
ただ、ルータの管理画面から通話ログを見ることはできるので、たまたま電話に出られなかったときはPCで確認していました。
とても不便です。

そんなとき、はるばる英国から小さくて強い子がやってきました。そう、Raspberry Pi です。
(まぁ [pimoroni](https://shop.pimoroni.com/) で自分が注文したんだけど)

管理画面をPythonでスクレイピングして、最強の着信通知を作ってみましょう。ファイッ！


## はじめに試したこと
ルータの管理画面にアクセスして、通話ログのテキスト部分を取り出してみたいと思います。
まず使ってみたのは、ブラウザテスト自動化で使われる Selenium です。ヘッドレスのFirefoxをオートパイロットして、ルータ管理画面からリンクを辿り、XPathでログ部分だけを抜き出します。

```py
# coding:utf-8
from selenium import webdriver

browser = webdriver.Firefox()
browser.get('http://user:YOUR_PASSWORD@192.168.1.1') # BASIC認証を通してブラウザ管理画面にアクセス

information_link = browser.find_element_by_id('folder6')
information_link.click() # 「情報」リンクをクリック

call_log_link = browser.find_element_by_id('sub_folder6_3')
call_log_link.click() # 「通話ログ」リンクをクリック

call_log = browser.find_element_by_xpath('//pre') # preタグ(通話ログの場所)を取得
```
うまく取れはするのですが、読み込みタイミングの問題か、時々データを取れなくて例外を吐くようです。
途中にsleepを入れたら改善したものの、美しくないですね。
また、完全な機能をもつブラウザを起動させるため、処理が重いです。

## 次に試したこと
管理画面はJavaScriptで動的にコンテンツを生成しているわけでもないので、ヘッドレスブラウザは不要です。
PyCurlに書き換えました。

```py
# coding:utf-8
import pycurl
from io import BytesIO
import lxml.html
from lxml import etree
from lxml.etree import XMLParser

curl = pycurl.Curl()
curl.setopt(pycurl.URL, "http://192.168.1.1/cgi-bin/mainte.cgi?st_clog") # 通話ログページを直接呼べたみたい
curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
curl.setopt(pycurl.USERPWD, 'user:YOUR_PASSWORD') # BASIC認証
buffer = BytesIO()
curl.setopt(curl.WRITEDATA, buffer)
curl.perform()

page = buffer.getvalue().decode('Shift_JIS')
parser = XMLParser(ns_clean=True, recover=True)
doc = etree.fromstring(page, parser)
call_log = doc.xpath('//PRE/text()')[0]
```
重量級ブラウザが立ち上がらなくなりました。
結果、以下のようなテキストが取得できます。

```
CALL.LOG : There are 100 entries.

  1. Sun Nov 21 00:04:09 2021  **********                Sun Nov 21 00:04:10 2021
     0ABCDEFGHIJ                                         外線着信                
     **********                                          AUDIO  - 
     0A0BCDEFGHIJ                                         
     203.0.113.1                                         NW 016  000    
     接続先切断        
  2. Sat Nov 20 23:55:16 2021  **********                Sat Nov 20 23:55:17 2021
     0ABCDEFGHIJ                                         外線着信                
     **********                                          AUDIO  - 
     0A0BCDEFGHIJ                                         
     203.0.113.1                                         NW 016  000    
     接続先切断        
```

最初の2行を読み飛ばし、6行ごとに処理することで通話ログは処理できそうです。
ここには不在着信だけではなく、自分がかけた電話や通話が成立したものも含まれているため、見分けなくてはなりません。

不在着信は、以下の2つを満たすものです。

- 最初の行の中央の日付が `**********` である = 通話が成立していない
- 2番目の行に「外線着信」と書かれている

### Slack通知
Incoming Webhook の URL に JSON を POST するとメッセージが投稿されます。
(Slack の Webhook は廃止予定なので新しい仕組みにしたいところです……)

### cron設定
```03-59/10 7-23 * * * python3 /home/pi/notify_incoming_call.py > /dev/null 2>&1```
cronで10分おきに起動させます。深夜帯は黙ってもらおうね。
このままだと同じ通知が何度も送られてしまうため、最後に処理した通話のタイムスタンプをファイルに記録する仕組みも実装しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122348/f9f8bcaa-2b22-4373-8290-332e53e44676.png)
これで外出先でも不在着信を確認できます。

### 完成したソース
```py:notify_incoming_call.py
# coding:utf-8
import os
import pycurl
from io import BytesIO
import lxml.html
from lxml import etree
from lxml.etree import XMLParser
from datetime import datetime
import re
import json

basedir = os.path.dirname(os.path.realpath(__file__))

# 最後に処理した通話の時刻を読み込み
try:
  fr = open(basedir + '/.notify_incoming_call', 'r')
  recentlog = fr.readline()[:24]
  fr.close()
except Exception as e:
  recentlog = 'Mon Nov  1 01:00:00 2021'

curl = pycurl.Curl()
curl.setopt(pycurl.URL, "http://192.168.1.1/cgi-bin/mainte.cgi?st_clog")
curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
curl.setopt(pycurl.USERPWD, 'user:YOUR_PASSWORD')
buffer = BytesIO()
curl.setopt(curl.WRITEDATA, buffer)
curl.perform()

#通話ログ全件取得
page = buffer.getvalue().decode('Shift_JIS')
parser =XMLParser(ns_clean=True, recover=True)
doc = etree.fromstring(page, parser)
call_log = doc.xpath('//PRE/text()')[0]

#最初の空行以降を読み込み
call_log_main = call_log.split('\n\n')[1]

lines_call_log_main = call_log_main.splitlines()
#6行ずつ処理
regex = re.compile(r"^[0-9]+\. ")
for i,line in enumerate(lines_call_log_main):
  if i%6==0:
    timestamps = regex.sub('',line.strip())
    logdate = timestamps[:24]

    connect_date = timestamps[26:50]
    disconnect_date = timestamps[52:]

  if i%6==1:
    phone_number = line.strip().split()[0]
    call_type = line.strip().split()[1]

  if i%6==3:
    #相手側番号
    peer_number = line.strip()

    #不在着信のみを処理
    if connect_date.strip() != '**********':
      continue

    if peer_number == '-':
      peer_number = '非通知設定'
    else:
      # 電話番号があればリンクにする
      peer_number = '<tel:{}|{}>'.format(peer_number, peer_number)

    #外線着信のみを処理
    if call_type != '外線着信':
      continue

    if datetime.strptime(recentlog, '%a %b %d %H:%M:%S %Y') < datetime.strptime(logdate, '%a %b %d %H:%M:%S %Y'):
      payload = {}
      payload['username'] = '不在着信'
      payload['icon_emoji'] = ':telephone:'
      #payload['channel'] = '{チャンネル名(指定する場合)}'
      payload['text'] = '{} から不在着信がありました\n{}'.format(peer_number,datetime.strptime(logdate, '%a %b %d %H:%M:%S %Y'))

      c = pycurl.Curl()
      c.setopt(pycurl.URL, 'https://hooks.slack.com/services/{WEBHOOKのURLを記載}')
      c.setopt(pycurl.POST, 1)
      c.setopt(pycurl.POSTFIELDS, "payload=" + json.dumps(payload))
      c.perform()

recentlog = call_log_main.split('\n', 1)[0][5:29]
fw = open(basedir + '/.notify_incoming_call', 'w')
fw.write(recentlog)
fw.close()

```

## オチ
公式がメール通知を提供してんのかよ！ (でもSlack通知の方が使いやすいし、月額料金もかからないのでいいよね)

https://flets.com/hikaridenwa/service/mail.html