5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

「アレクサ、有吉の壁を見せて」でテレビのチャンネルを変えたい

Last updated at Posted at 2020-12-30

最近はほとんどのテレビにスマートホーム連携機能が搭載されています。音声でテレビを操作できる機能ですが、使い勝手が良いとは言えません。

特にチャンネルを変える場合には**「フジテレビに変えて」「チャンネルを8に変えて」**のように、放送局を指定して変えるのが基本です。しかし私はあくまで”番組”が見たいのであって、その番組が”どの放送局でやっているのか”、そして”その放送局が何チャンネルなのか”に興味が無く、わかりません。

本当なら**「アレクサ、有吉の壁を見せて」のように番組名を指定してチャンネルを変えたいのです。** 1

アレクサの定型アクションを使って全番組分発話とリモコン操作の紐付けをするという力技もできなくは無いですが、まぁ大変です。それならリモコンポチポチします。

それなら「ラズパイとクラウドサービスを使って自力解決してみよう」と思い、アレクサに番組名を伝えてテレビのチャンネルを変える仕組みを作ってみました。

環境とシステム仕様

利用プラットフォーム

  • ラズベリーパイ4 Model B
  • GCP Comupte Engine (GCE)
    • Debian 4.9
    • MariaDB 10.1.45(検索エンジンにMroonga)
  • AWS Lambda
  • Alexa SDK(Amazon Echo)
  • AWS IoT Core
  • Python3

この他ラズパイでテレビを操作するために赤外線受信モジュールやLED、その接続のためのワイヤーやらブレッドボードやらを使います。

なおこの記事ではAWS IoT Coreの構成手順は割愛します。

構成と仕様

全体の構成は以下のとおりです。
無題のプレゼンテーション (5).png

  1. GCEにDBサーバ(MariaDB)を構成して事前にYahoo!のテレビ番組表をスクレイピング、1日分の番組名と放送局の組み合わせをDB上に保存しておきます。(Yahoo!の番組表の仕様上、1日は4時~25時まで)

  2. ユーザーが「〇〇(番組名)を見せて」とアレクサに話しかけると、バックエンドモジュール(AWS Lambda)が、番組名をキーワードにDBサーバーから全文検索します。

  3. 放送局のチャンネル番号をIoT Coreのトピックへ送信、家のラズパイでサブスクライブして受け取ったチャンネル番号を赤外線信号で送信します。

マルチクラウドの構成をとっているのは、GCEの無料枠が使えるからというだけです。

各プログラムはGitHubにアップしています。
https://github.com/quotto/tv-channel-changer

DBサーバーを構築する

インストール

まずはGCE上にDBサーバーを構築します。全文検索を行う予定のため、MariaDBとMroongaをインストールします。

# MariaDBのインストール
$ sudo apt-get install mariadb-server=10.1.45-0+deb9u1 mariadb-client=10.1.45-0+deb9u1 mariadb-server-10.1=10.1.45-0+deb9u1 mariadb-client-10.1=10.1.45-0+deb9u1 libmariadbclient18:amd64=10.1.45-0+deb9u1 mariadb-client-core-10.1=10.1.45-0+deb9u1 mariadb-server-core-10.1=10.1.45-0+deb9u1 mariadb-common=10.1.45-0+deb9u1

# MroongaとTokenizerとしてのMecabをインストール
$ sudo apt install -y -V mariadb-server-10.1-mroonga
$ sudo apt install -y -V groonga-tokenizer-mecab

そのままapt install mariadb-serverだとMroongaの要求バージョンとあわなかったため、過去バージョンを指定してインストールしました。

DBとテーブルの作成

次にDBtvDBとその中に全文検索インデックスを持つテーブルtv_programを構築します。構築前に文字コード周りのデフォルト値を確認しておきます。

$ cat /etc/mysql/mariadb.conf.d/50-server.cnf
...
# * Character sets
#
# MySQL/MariaDB default is Latin1, but in Debian we rather default to the full
# utf8 4-byte character set. See also client.cnf
#
character-set-server  = utf8mb4
...

$ cat /etc/mysql/mariadb.conf.d/50-client.cnf 
...
[client]
# Default is Latin1, if you need UTF-8 set this (also in server section)
default-character-set = utf8mb4
...
$ sudo mariadb
MariaDB [(none)]> show variables like 'char%';
+--------------------------+----------------------------+
| Variable_name            | Value                      |
+--------------------------+----------------------------+
| character_set_client     | utf8mb4                    |
| character_set_connection | utf8mb4                    |
| character_set_database   | utf8mb4                    |
| character_set_filesystem | binary                     |
| character_set_results    | utf8mb4                    |
| character_set_server     | utf8mb4                    |
| character_set_system     | utf8                       |
| character_sets_dir       | /usr/share/mysql/charsets/ |
+--------------------------+----------------------------+

問題無さそうなのでDBとテーブルを作成します。

MariaDB [(none)]> create database tvDB;
MariaDB [(none)]> use tvDB;
MariaDB [(tvDB)]> create table tv_program (
                    id varchar(255) not null primary key,
                    title text,time varchar(255) not null, 
                    provider varchar(255) not null
                  ) fulltext index (time) comment='tokenizer "TokenMecab(\'use_reading\',true)",
                    normalizer "NormalizerNFKC100(\'unify_prolonged_sound_mark\',true,\'unify_middle_dot\',true,\'unify_katakana_v_sound\',true)",
                    token_filters "TokenFilterNFKC100(\'unify_kana\',true,\'unify_kana_case\',true,\'unify_hyphen\',true)"'
                  engine=Mroonga

テーブルtv_programの各項目は以下のとおりです。今回作るスキルではtitleproviderを使っています。

  • id: 番組を一意に識別するID
  • title: 番組名
  • time: 放送開始時刻を示すhh:mm形式の文字列
  • provider: 放送局名

ポイントはfulltext index(title)に続くコメント部分です。このコメントの内容で登録されたtitleをどのようにインデクシングするか定義します。大きく3つで構成されます。

  • tokenizer: 項目全文のトークナイザー。今回はTokenMecabを指定したのでMecab辞書に従って形態素解析した結果でインデックスを作成します。またTokenMecabの中に指定したuse_readingは漢字の読みがなもインデックスとして登録するので、検索時に漢字とひらがなの差を吸収することができます。
  • normalizer:インデックス登録時の前処理で、これはtokenizerより前に適用されます。unify_prolonged_sound_markは長音記号の正規化、unify_middle_dotは中点「・」の削除、unify_katakana_v_soundは"ヴァヴィヴヴェヴォ"を"バビブベボ"に変換します。NormalizerNFKC100をフルセットで適用すると、その後のMecabの形態素解析に支障が出るオプションもあるため、個別に指定しています。
  • token_filters:tokenizerによる解析後に適用されます。unify_kanaは全角ひらがな、全角カタカナ、半角カタカナをすべて同一視します。nify_kana_caseは全角ひらがな、全角カタカナ、半角カタカナの小さな文字を大きな文字と同ツシします。unify_hyphenはハイフン記号を正規化します。

すべてのオプションはGroongaのドキュメントを参照してください。
https://groonga.org/ja/docs/reference/normalizers/normalizer_nfkc100.html

さてfulltext indexに指定したコメントですが、コメントなのでシンタックスエラーがあってもエラーになりません。きちんと設定ができたかはSQLで確認します。

MariaDB [(tvDB)]> select mroonga_command('schema --output_pretty yes');
...
"tv_program#title": {
       "tokenizer": {
          "id": 64,
          "name": "TokenMecab",
          "options": [
            "use_reading",
            true
          ]
        },
        "normalizer": {
          "id": 85,
          "name": "NormalizerNFKC100",
          "options": [
            "unify_middle_dot",
            true
          ]
        },
        "token_filters": [
          {
            "id": 218,
            "name": "TokenFilterNFKC100",
            "options": [
              "unify_kana",
              true,
              "unify_kana_case",
              true,
              "unify_hyphen",
              true
            ]
          }
...

tokenizernormalizertoken_filtersが表示されていればOKです。

外部接続設定

今回のDBサーバーは外部(AWS Lambda)から直接接続することになるため、そのためのユーザー作成と権限設定が必要です。

MariaDB [tvDB]> create user `myuser`@`%` identified by 'myuser';
MariaDB [tvDB]> grant select,insert,update,drop on tvDB.tv_program to `myuser`@`%`;
/* 確認 */
MariaDB [tvDB]> show grants for myuser@`%`;
+-------------------------------------------------------------------------------------------------------+
| Grants for myuser@%                                                                                   |
+-------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'myuser'@'%' IDENTIFIED BY PASSWORD '*XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' |
| GRANT SELECT, INSERT, UPDATE, DROP ON `tvDB`.`tv_program` TO 'myuser'@'%'                             |
| GRANT SELECT, INSERT, DELETE, DROP ON `sampleDB`.`menus` TO 'myuser'@'%'                              |
+-------------------------------------------------------------------------------------------------------+

スクレイピング

Yahoo!のテレビ番組表からスクレイピングしたデータをDBに格納します。ここではかいつまんでポイント部分のコードだけ掲載します。

DB接続部分

connection=mariadb.connect(
        user='myuser',
        password='myuser',
        host='localhost',
        database='tvDB',
        autocommit=False
        )
cursor=connection.cursor()

番組表の取得。Yahoo!の番組表はパラメータによって最大6時間分の情報が取得できます。また、0時~3時の番組は前日の扱いになるので、パラメータdに対象日を指定して、パラメータstに4,10,16,22を順番に指定してスクレイピングを繰り返す構造にしています。

endpoint = 'https://tv.yahoo.co.jp'
stlist = [4,10,16,22]
today = datetime.now(timezone(timedelta(hours=9))).strftime('%Y%m%d')
insert_data_params = []

try:
    for st in stlist:
        driver.get('{}/listings/17/?&d={}&st={:d}'.format(endpoint,today,st))
        res = driver.page_source.encode('utf-8')
        soup = BeautifulSoup(res,'html.parser')

最後に現在のテーブルを全削除して、収集した1日分の番組表を登録します。

    cursor.execute('truncate tv_program')
    query = 'insert into tv_program (id,title,time,provider) values(?,?,?,?)'
    cursor.executemany(query,insert_data_params)
    
    connection.commit()

全文検索するとこのようにデータが取得できました。

MariaDB [tvDB]> select * from tv_program where match(title) against('*D+ 岸辺露伴' in boolean mode);
+----------+------------------------------------------------------+-------+---------------------+
| id       | title                                                | time  | provider            |
+----------+------------------------------------------------------+-------+---------------------+
| 80615586 | 岸辺露伴は動かない (1)「富豪村」                  | 22:00 | NHK総合1・仙台      |
+----------+------------------------------------------------------+-------+---------------------+
1 row in set (2.91 sec)

スキルの構築

次にアレクサスキルを作成します。

インテントとスロット作成

スキルの呼び出し名は**「チャンネル検索」**としました。もう少し自然な呼び出し名にしたいのですが、「テレビ」や「番組」がつくとアレクサのデフォルト(?)のスマートホーム機能が起動してしまうので、とりあえずこの名前にします。
2020-12-28_23h50_31.png

次にチャンネル変更用の「ChangeChannelIntent」を作成します。
2020-12-28_23h51_35.png

ポイントは「チャンネル検索で~」のように呼び出し名を付けた発話を登録しておくことです。これによりスロットTVProgramの認識率が上がりました。

スロット値TVProgramですが、プリセットのスロットタイプにAMAZON.TVSeriesなるものがあるので、これを設定しました。このスロットタイプに合致しなくてもインテントさえ認識してくれれば、番組名部分は抽出してくれるので問題ありません。
2020-12-28_23h51_46.png

AWS Lambdaのコード実装

スキルのバックグラウンドになるAWS Lambdaのコードを実装します。ここでもポイント部分だけ掲載します。

ユーザーの発話から{TVProgram}に該当する部分が抜き出せたら、DBに接続して全文検索を行います。全文検索ではmatch(...) against(...)をカラムに指定すると、スコアとして取得できるので、スコアの降順つまり一致度が高い順に取得します。

    def handle(self, handler_input):
        slot = ask_utils.request_util.get_slot(handler_input,"TVProgram")
        speak_output = "すみません、わかりませんでした"
        if(slot != None):
            speak_output = "{}です。".format(slot.value)
            connection=pymysql.connect(
                    user='myuser',
                    password='myuser',
                    host='xx.xx.xx.xx',
                    database='tvDB',
                    autocommit=False
                    )
            cursor=connection.cursor()
            try:
                    cursor.execute("select id,title,time,provider,match(title) against ('*D+ {0}' in boolean mode) as score from tv_program where match(title) against ('*D+ {0}' in boolean mode) > 0 order by score desc".format(slot.value))
                    result = cursor.fetchall()

全文検索結果が1件以上あれば、最もスコアの高い番組情報の放送局を取得して、該当するチャンネル番号をAWS IoT Coreのトピックとしてpublishします。

mqtt_connection = mqtt_connection_builder.mtls_from_path(
            endpoint=ENDPOINT,
            cert_filepath=PATH_TO_CERT,
            pri_key_filepath=PATH_TO_KEY,
            client_bootstrap=client_bootstrap,
            ca_filepath=PATH_TO_ROOT,
            on_connection_interrupted=on_connection_interrupted,
            on_connection_resumed=on_connection_resumed,
            client_id=CLIENT_ID,
            clean_session=False,
            keep_alive_secs=6,
            region="ap-northeast-1"
            )
logger.info("Connecting to {} with client ID '{}'...".format(
        ENDPOINT, CLIENT_ID))

connect_future = mqtt_connection.connect()

connect_future.result()
logger.info("Connected!")

# 不足の事態に備えて3回送信する
for i in range (3):
    message = {"channel": CHANNEL[provider],"count": i+1}
    mqtt_connection.publish(topic=TOPIC, payload=json.dumps(message), qos=mqtt.QoS.AT_LEAST_ONCE)
    logger.info("Published: '" + json.dumps(message) + "' to the topic: " + TOPIC)
    time.sleep(0.1)
logger.info('Publish End')
disconnect_future = mqtt_connection.disconnect()
disconnect_future.result()

ここまでのコードをAWS LambdaにデプロイしたらAlexaデベロッパーコンソールでテストしてみましょう。

2020-12-29_13h18_09.png

番組名に対する放送局を取ることができました!

ついでにAWSコンソール上のIoT Coreテストクライアントでメッセージをsubscribeしてみます。
2020-12-29_13h23_39.png

こちらもメッセージが送信されていることを確認できました。

ラズパイの構築

番組名と放送局を検索する仕組みはできたので、いよいよ実際の赤外線信号を送るべくラズパイを構築します。

今回のラズパイに必要な機能は次のとおりです。

  • テレビリモコンの赤外線信号を覚える
  • 覚えた赤外線信号を送信する
  • AWS IoT Coreからのメッセージを受信(subscribe)する

テレビリモコンの赤外線信号を覚える

このためにはラズパイに赤外線信号受信用のモジュールを取り付ける必要があります。
I-04659.jpg
https://akizukidenshi.com/catalog/g/gI-04659/)

向かって右から電源(+)、接地(-)、データ出力となります。ブレッドボードにこんな感じで取り付けました。データ出力のGPIOピンは22番です。

P_20201229_133401 - コピー.jpg

このモジュールに向かってテレビリモコンのボタンを押すと赤外線信号をキャッチしてラズパイに流してくれるわけですが、ラズパイ側でそれをキャッチする準備が必要です。

ありがたいごとにpigpioという汎用ライブラリがpythonのサンプルコード付きで提供されています。
https://github.com/joan2937/pigpio

まずpigpioライブラリをインストールして、デーモンを起動します。

$ sudo apt install pigpio
$ sudo service pigpiod start

pythonライブラリをインストールします。

pip install pigpio

続いてpythonのサンプルをダウンロードします。

$ wget http://abyz.me.uk/rpi/pigpio/code/irrp_py.zip
$ ungip irrp_py.zip

解凍ディレクトリの中にirrp.pyは赤外線信号の記録と、記録した信号を送信できるサンプルになっています。このサンプルを使ってまずは記録してみます。

$ python3 irrp.py -r -f codes.json --post 130 -g 22 channel1
Recording
Press key for 'channel1'
Okay
Press key for 'channel1' to confirm
Okay

パラメータ-rは記録モードでの起動、-fは記録した赤外線の出力ファイル、-gが受信モジュールのGPIOピン番号、最後の引数(channel1)は任意の値で記録する信号の識別IDです。あとオプションの--postは赤外線受信後の待機時間(ms)です。

これを実行すると2回同じボタンを押すように表示されるので、そのとおりに押します。

正常に記録されると指定したファイル(codes.json)に保存されます。

$ cat codes.json
{"channel1": [3496, 1734, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 1300, 442, 1300, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 1300, 442, 1300, 442, 75679, 3496, 1734, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 1300, 442, 1300, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 1300, 442, 430, 442, 430, 442, 1300, 442, 1300, 442]}

この作業を必要な数だけ繰り返します。今回はチャンネルを変えたいのでテレビのリモコン1~12番までを記録させました。

覚えた赤外線信号を送信する

赤外線信号を記録できたので、その信号をラズパイから送信します。実際に試すと、キャップが有ると無いとでは信号を送れる範囲がかなり違うので、おすすめです。

送信するには赤外線LEDが必要です。
image.png
https://akizukidenshi.com/catalog/g/gI-03261/

赤外線LEDは指向性のため拡散キャップを付けると設置位置の調整がしやすいです。
image.png
https://akizukidenshi.com/catalog/g/gI-00641/

ラズパイにLEDを取り付ける際の注意点として、通常の電子回路と同じように抵抗の設置が必須です。またGPIOピンに流れる電流は全体で最大50mA、1ピンあたり最大16mAまでの制限があるので、それに合わせた抵抗が必要です。2

今回はLEDの順電圧が1.35V、ラズパイのGPIOピンの電圧が3.3Vのため250Ωの抵抗を入れます。

(3.3V-1.35V)/250Ω=7.8mA

こんな感じで取り付けました。出力(LED+側)GPIOピンは27番です。
P_20201229_133425 - コピー.jpg

赤外線信号送信のテストにも先ほどのサンプルirrp.pyが使えます。

python3 irrp.py -p -f codes.json -g 27

パラメータ-pは赤外線送信モード、-fは信号が記録されているJSONフォーマットファイル、-gにLEDをつないだGPIOピン番号を指定します。

テレビのチャンネルが変わればOKです。

AWS IoT Coreのメッセージをサブスクライブする

最後の仕上げにAWS Lambdaが送信したメッセージをサブスクライブして、チャンネル変更するようにプログラムを作成します。

awsiotsdkのサンプルがほぼそのまま使えます。サブスクライブが終わるまで、あるいは常時起動するための仕組みとしてthreading.Event()を利用します。

subscribe.py
# サブスクライブ終了判定
received_count = 0
received_all_event = threading.Event()
# サブスクライブすると起動する
def on_message_received(topic, payload, **kwargs):
    # メッセージは{"channel":8,"count":1}の形式で配信されている。
    print("Received message from topic '{}': {}".format(topic, payload))
    receivedData = json.loads(payload.decoede("utf-8"))
    # 赤外線信号送信、赤外線信号のidは"channel"+チャンネル番号で登録しているので、メッセージからidを生成してirrp.playbackを呼び出す
    irrp.playback(args.gpio,args.record_file,"channel{:d}".format(receivedData["channel"]))
    global received_count
    received_count += 1
    # 常時起動するためには引数--countに0を指定する
    if received_count == args.count:
        received_all_event.set()
...
if __name__ == '__main__':
    # Spin up resources
    event_loop_group = io.EventLoopGroup(1)
    host_resolver = io.DefaultHostResolver(event_loop_group)
    client_bootstrap = io.ClientBootstrap(event_loop_group, host_resolver)
    mqtt_connection = mqtt_connection_builder.mtls_from_path(
        endpoint=args.endpoint,
        cert_filepath=args.cert,
        pri_key_filepath=args.key,
        client_bootstrap=client_bootstrap,
        ca_filepath=args.root_ca,
        on_connection_interrupted=on_connection_interrupted,
        on_connection_resumed=on_connection_resumed,
        client_id=args.client_id,
        clean_session=False,
        keep_alive_secs=6)
    connect_future = mqtt_connection.connect()
    connect_future.result()

    # サブスクライブ開始
    subscribe_future, packet_id = mqtt_connection.subscribe(
        topic=args.topic,
        qos=mqtt.QoS.AT_LEAST_ONCE,
        callback=on_message_received)

    subscribe_result = subscribe_future.result()

    # on_message_receivedでreceived_all_event.set()されるまでプログラムを起動させる
    received_all_event.wait()
...

このモジュールをラズパイ上で起動させれば完成です!


それっぽいものができましたが、このシステムの肝はやはり全文検索部分です。今回の実装ではなんの工夫もしていないので、類義語や通称(例えば「ガキの使いやあらへんで」を「ガキ使」と言う)には対応できません。

実用的にするにはどうしてもここがネックになりそうですが、クラウドサービスにラズパイを組み合わせれば、家の中限定のちょっとしたソリューションが作れると思います。

あと個人的には物理の知識は遠い彼方に葬られていましたが、ラズパイの電子工作をするにあたって基本的な部分をおさらいできました。今後も色々な使い方に挑戦してみたいですね。

参考

  1. アレクサではスキル名の指定が必須なので実際には「アレクサ、チャンネル検索で有吉の壁を見せて」のような感じになります。

  2. 2.製品公式の仕様は見つけられないのですが、フォーラムhttps://www.raspberrypi.org/forums/viewtopic.php?t=12498やネットの情報を見る限りではそのようです。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?