Alexa Skills Kit + AWS IoT + Raspberry Pi + 赤外線LED でテレビリモコンを作る

 Amazon Echo や Google Home では Fire TV や Chromecast 等と組み合わせることで音声でテレビを操作することができるようになりますが、今回は Amazon Echo と Raspberry Pi を連携させ、赤外線LEDなどと組み合わせることでテレビを操作してみたいと思います。

全体構成

 今回の全体の構成は下記の図のようになります。Alexa Skills Kit でカスタムスキルを実装して Amazon Echo から呼び出し、 Fulfillment として Lambda Function を実装してそこから AWS IoT の Shadow の更新リクエストを投げます。Raspberry Pi 上では AWS IoT に MQTT で Subscribe する処理を稼働させておき、Echo からのリクエストで Shadow が更新されたのを検知したら赤外線LEDから赤外線を送信してテレビを操作するという流れです。

f:id:akanuma-hiroaki:20171206084727p:plain

カスタムスキルの実装

 ではまずはカスタムスキルを実装してみます。 Alexa Skills Kit で下記の内容でスキルを作成します。

スキル名:テレビリモコン
呼び出し名:テレビリモコン

 今回はひとまずできるだけシンプルに一通り動くようにしてみたいと思いますので、テレビのON/OFFのみ操作するようにします。 Alexa へのリクエストとしては「テレビリモコンでテレビをつけて」「テレビリモコンでテレビを消して」の2種類のみを受け取ります。カスタムインテントを一つ作ってスロットで ON/OFF のリクエストを切り分けても良いのですが、少し複雑になってしまうので、今回はスロットは使わずにそれぞれのリクエストに対応するカスタムインテントを定義しておきます。

インテントスキーマ

{
  "intents": [
    { "intent": "TVPowerOnIntent" },
    { "intent": "TVPowerOffIntent" },
    { "intent": "AMAZON.HelpIntent" },
    { "intent": "AMAZON.StopIntent" },
    { "intent": "AMAZON.CancelIntent" }
  ]
}

 サンプル発話は前述の通り2つだけ定義しておきます。一通り動くようになったら他のバリエーションにも対応したいと思います。下記のように定義することで、「テレビリモコンでテレビをつけて」と言った時には TVPowerOnIntent が起動し、「テレビリモコンでテレビを消して」と言った時には TVPowerOffIntent が起動することになります。

サンプル発話

TVPowerOnIntent テレビをつけて
TVPowerOffIntent テレビを消して

 Fulfillment としての Lambda Function は下記のように実装します。

# -*- coding: utf-8 -*-
from __future__ import print_function
import boto3
import json

# --------------- Helpers that build all of the responses ----------------------

def build_speechlet_response(output, reprompt_text, should_end_session):
    return {
        'outputSpeech': {
            'type': 'PlainText',
            'text': output
        },
        'reprompt': {
            'outputSpeech': {
                'type': 'PlainText',
                'text': reprompt_text
            }
        },
        'shouldEndSession': should_end_session
    }

def build_response(speechlet_response):
    return {
        'version': '1.0',
        'sessionAttributes': {},
        'response': speechlet_response
    }

# --------------- Functions that control the skill's behavior ------------------

def get_welcome_response():
    speech_output = "テレビを操作するには、「テレビリモコンでテレビをつけて」、" \
                    "または、「テレビリモコンでテレビを消して」、と言ってください。"
    reprompt_text = None
    should_end_session = True
    return build_response(build_speechlet_response(
        speech_output, reprompt_text, should_end_session))

def handle_session_end_request():
    speech_output = None
    reprompt_text = None
    should_end_session = True
    return build_response(build_speechlet_response(
        speech_output, reprompt_text, should_end_session))

def publish(power):
    client = boto3.client('iot-data')
    response = client.update_thing_shadow(
        thingName='home_tv',
        payload=json.dumps({"state":{"desired": {"power": power}}})
    )

def turn_tv_power(power, session):
    should_end_session = True

    publish(power)
    
    speech_output = "テレビの電源を" + power + "にしました。"
    reprompt_text = None
    return build_response(build_speechlet_response(
        speech_output, reprompt_text, should_end_session))

# --------------- Events ------------------

def on_session_started(session_started_request, session):
    print("on_session_started requestId=" + session_started_request['requestId']
          + ", sessionId=" + session['sessionId'])

def on_launch(launch_request, session):
    print("on_launch requestId=" + launch_request['requestId'] +
          ", sessionId=" + session['sessionId'])
    # Dispatch to your skill's launch
    return get_welcome_response()

def on_intent(intent_request, session):
    print("on_intent requestId=" + intent_request['requestId'] +
          ", sessionId=" + session['sessionId'])

    intent = intent_request['intent']
    intent_name = intent_request['intent']['name']

    if intent_name == "TVPowerOnIntent":
        return turn_tv_power('on', session)
    elif intent_name == "TVPowerOffIntent":
        return turn_tv_power('off', session)
    elif intent_name == "AMAZON.HelpIntent":
        return get_welcome_response()
    elif intent_name == "AMAZON.CancelIntent" or intent_name == "AMAZON.StopIntent":
        return handle_session_end_request()
    else:
        raise ValueError("Invalid intent")

def on_session_ended(session_ended_request, session):
    print("on_session_ended requestId=" + session_ended_request['requestId'] +
          ", sessionId=" + session['sessionId'])

# --------------- Main handler ------------------

def lambda_handler(event, context):
    print("event.session.application.applicationId=" +
          event['session']['application']['applicationId'])

    if event['session']['new']:
        on_session_started({'requestId': event['request']['requestId']},
                           event['session'])

    if event['request']['type'] == "LaunchRequest":
        return on_launch(event['request'], event['session'])
    elif event['request']['type'] == "IntentRequest":
        return on_intent(event['request'], event['session'])
    elif event['request']['type'] == "SessionEndedRequest":
        return on_session_ended(event['request'], event['session'])

 処理の内容はインテントの種別で切り分けています。

    if intent_name == "TVPowerOnIntent":
        return turn_tv_power('on', session)
    elif intent_name == "TVPowerOffIntent":
        return turn_tv_power('off', session)
    elif intent_name == "AMAZON.HelpIntent":
        return get_welcome_response()
    elif intent_name == "AMAZON.CancelIntent" or intent_name == "AMAZON.StopIntent":
        return handle_session_end_request()
    else:
        raise ValueError("Invalid intent")

 turn_tv_power メソッドの中で publish メソッドを呼び出し、その中で AWS SDK を使って AWS IoT の Shadow の更新リクエストを投げています。今回は Shadow の内容としてはテレビの ON/OFF のステータスのみを持たせています。

def publish(power):
    client = boto3.client('iot-data')
    response = client.update_thing_shadow(
        thingName='home_tv',
        payload=json.dumps({"state":{"desired": {"power": power}}})
    )

 テストをして問題なければ Alexa Skills Kit のエンドポイントの設定で上記の Lambda Function の ARN を指定します。

 カスタムスキルの実装については前回も書いてますので、よろしければご覧ください。

blog.akanumahiroaki.com

Raspberry Pi と電子部品の配線

 今度はテレビを操作する側を実装していきます。まずは Raspberry Pi と赤外線LED等を下記の図のように配線します。

f:id:akanuma-hiroaki:20171206081102p:plain:w300:left

 今回使っているパーツは下記の3つです。

 ・赤外線受信モジュール
 ・赤外線LED
 ・NPN型トランジスタ

 これらを抵抗とジャンパーコードで接続します。

 赤外線受信モジュールはテレビのリモコンの赤外線を受信してパターンを記録するために使います。今回使用したモジュールのピンは左から Output, GND, VCC となっているので、 Output を GPIO17 に接続し、 GND と VCC をそれぞれ GND と 3V 出力に接続します。

 そして記録したパターンで赤外線を送信するために赤外線LEDを使うのですが、通常のGPIOで出力される電圧では小さいため、トランジスタを使用して Raspberry Pi の 5V の電源を増幅して使います。今回使用したトランジスタはNPN型で、ピンは左から Emitter, Collector, Base です。 赤外線LEDのアノードを 5V 出力に接続し、 4.7Ωの抵抗を介して Collector に接続します。Emitter は GND に接続します。 Base は 1kΩの抵抗を介して GPIO18 に接続します。

www.aitendo.com

www.aitendo.com

www.aitendo.com

LIRC で赤外線パターンを記録する

 今回はちょっと手抜きな感じもありますが、 赤外線の送信に LIRC を使って動かしてみます。

www.lirc.org

 まず下記コマンドで LIRC をインストールします。

$ sudo apt-get install lirc

 起動時に LIRC モジュールを起動するように、 /boot/config.txt に下記を追記します。

dtoverlay=lirc-rpi,gpio_in_pin=17,gpio_out_pin=18

 追記後に再起動すると下記のようにデバイスファイルが現れます。

pi@raspberrypi:~ $ ls -l /dev/lirc0 
crw-rw---- 1 root video 243, 0 Dec  5 20:52 /dev/lirc0

 そしてテレビのリモコンの赤外線のパターンを記録させます。 irrecord コマンドを使うのですが、そのためには一度 LIRC を停止します。

pi@raspberrypi:~ $ sudo /etc/init.d/lirc stop
Stopping lirc (via systemctl): lirc.service.

 停止させたら irrecord コマンドで赤外線パターンを記録し、 lircd.conf というファイルに出力します。

pi@raspberrypi:~ $ irrecord -n -d /dev/lirc0 lircd.conf
〜〜〜中略〜〜〜
Please send the finished config files to <lirc@bartelmus.de> so that I
can make them available to others. Don't forget to put all information
that you can get about the remote control in the header of the file.

Press RETURN to continue. ← Enter押下

Now start pressing buttons on your remote control.

It is very important that you press many different buttons and hold them
down for approximately one second. Each button should generate at least one
dot but in no case more than ten dots of output.
Don't stop pressing buttons until two lines of dots (2x80) have been
generated.

Press RETURN now to start recording. ← Enter押下
↓テレビのリモコンの色々なボタンを1秒以上押し続ける。受信できている間はドットが表示されていく
................................................................................
Found gap: 74690
Please keep on pressing buttons like described above.
................................................................................
Suspicious data length: 99.
Found possible header: 3461 1746
Header is not being repeated.
Found trail pulse: 422
No repeat code found.
Signals are space encoded.
Signal length is 48
Now enter the names for the buttons.

Please enter the name for the next button (press <ENTER> to finish recording)
power ← これから押すボタン(power)の名前を入力してEnter

Now hold down button "power". ← 電源ボタンを押す

Please enter the name for the next button (press <ENTER> to finish recording)
ch1 ← 1チャンネルボタンの名前を入力してEnter

Now hold down button "ch1". ← 1チャンネルボタンを押す

Please enter the name for the next button (press <ENTER> to finish recording)
ch2 ← 2チャンネルボタンの名前を入力してEnter

Now hold down button "ch2". ← 2チャンネルボタンを押す

Please enter the name for the next button (press <ENTER> to finish recording) ← 入力を終了するために空Enter

Checking for toggle bit mask.
Please press an arbitrary button repeatedly as fast as possible.
Make sure you keep pressing the SAME button and that you DON'T HOLD
the button down!.
If you can't see any dots appear, then wait a bit between button presses.

Press RETURN to continue. ← Enter押下
........................ ← 任意の一つのボタンを連打する
No toggle bit mask found.
Successfully written config file.

 出力された lircd.conf ファイルの中身は下記のようになっています。

pi@raspberrypi:~ $ cat lircd.conf

# Please make this file available to others
# by sending it to <lirc@bartelmus.de>
#
# this config file was automatically generated
# using lirc-0.9.0-pre1(default) on Thu Dec  7 14:49:17 2017
#
# contributed by 
#
# brand:                       lircd.conf
# model no. of remote control: 
# devices being controlled by this remote:
#

begin remote

  name  lircd.conf
  bits           24
  flags SPACE_ENC|NO_HEAD_REP
  eps            30
  aeps          100

  header       3461  1746
  one           424  1309
  zero          424   442
  ptrail        422
  pre_data_bits   24
  pre_data       0x400401
  gap          74690
  min_repeat      5
#  suppress_repeat 5
#  uncomment to suppress unwanted repeats
  toggle_bit_mask 0x0

      begin codes
          power                    0x00BCBD
          ch1                      0x900293
          ch2                      0x908213
      end codes

end remote

 これを /etc/lirc ディレクトリにコピーします。

$ sudo cp lircd.conf /etc/lirc/.

 続いて設定ファイル /etc/lirc/hardware.conf を編集します。編集前のファイルとの差分は下記の通りです。

pi@raspberrypi:~ $ diff /etc/lirc/hardware.conf.bak /etc/lirc/hardware.conf
4c4
< LIRCD_ARGS=""
---
> LIRCD_ARGS="--uinput"
16c16
< DRIVER="UNCONFIGURED"
---
> DRIVER="default"
18,19c18,19
< DEVICE=""
< MODULES=""
---
> DEVICE="/dev/lirc0"
> MODULES="lirc_rpi"

 ここまでで設定は完了なので、 LIRC を再起動します。

pi@raspberrypi:~ $ sudo /etc/init.d/lirc restart
Restarting lirc (via systemctl): lirc.service.

AWS IoT Shadow の更新情報を受け取ってテレビを操作する

 それでは赤外線LEDからテレビに赤外線を送信する部分の処理を実装します。 Ruby のコード中から Open3 を使って LIRC の irsend コマンドを呼んでいます。

require 'bundler/setup'
require 'pi_piper'
require 'open3'
require 'logger'

class IR_Transmitter
  LOG_FILE = 'logs/ir_transmitter.log'

  def initialize
    @led_pin = PiPiper::Pin.new(pin: 18, direction: :out)
    @log = Logger.new(LOG_FILE)
  end

  def transmit
    send_signal
  end

  def send_signal
    begin
      result = Open3.capture3('irsend SEND_START TV power')
      @log.debug("SEND_START #{result.inspect}")

      sleep 2

      result = Open3.capture3('irsend SEND_STOP TV power')
      @log.debug("SEND_STOP #{result.inspect}")
    rescue => e
      @log.error(e.backtrace.join("\n"))
    ensure
      Open3.capture3('irsend SEND_STOP TV power')
    end
  end
end

 今回使っているテレビのリモコンは ON と OFF のボタンは分かれていないので、 いずれの場合も同じように赤外線を2秒送信してストップしています。

 次に、 AWS IoT に Subscribe して Shadow の更新を受け取る処理の実装です。下記内容を subscriber.rb として保存します。AWS IoT に MQTT で接続し、 Shadow 更新時の差分情報が配信される delta トピックに Subscribe して待ち受け、差分情報を受け取った時にその内容から ON/OFF の state を抜き出して前述の赤外線送信処理を実行します。

require 'bundler/setup'
require 'mqtt'
require 'json'
require 'open3'
require './ir_transmitter.rb'

AWS_IOT_URL = 'xxxxxxxxxxxxxx.iot.ap-northeast-1.amazonaws.com'
AWS_IOT_PORT = 8883
TOPIC = '$aws/things/home_tv/shadow/update'
DELTA_TOPIC = "#{TOPIC}/delta"

class Subscriber
  LOG_FILE = 'logs/subscriber.log'

  def initialize
    @ir_transmitter = IR_Transmitter.new
    @log = Logger.new(LOG_FILE)
  end

  def statement(power:)
    {
      state: {
        reported: {
          power: power,
        }
      }
    }
  end

  def subscribe
    MQTT::Client.connect(host: AWS_IOT_URL, port: AWS_IOT_PORT, ssl: true, cert_file: 'home_tv-certificate.pem.crt', key_file: 'home_tv-private.pem.key', ca_file: 'root-CA.crt') do |client|
      initial_state = statement(power: 'off').to_json
      client.publish(TOPIC, initial_state)
      @log.info("Published initial statement: #{initial_state}")

      client.subscribe(DELTA_TOPIC)
      @log.info("Subscribed to the topic: #{DELTA_TOPIC}")

      client.get do |topic, json|
        state = JSON.parse(json)['state']
        power = state['power']

        @ir_transmitter.transmit

        reported_state = statement(power: power).to_json

        client.publish(TOPIC, reported_state)
        @log.info("Reported state: #{reported_state}")
      end
    end
  end
end

if $PROGRAM_NAME == __FILE__
  subscriber = Subscriber.new
  subscriber.subscribe
end

 AWS IoT については以前の記事でも書いてますので、詳細はそちらをご覧ください。

blog.akanumahiroaki.com

動作確認

 ここまでで一通りの実装ができたので、 Raspberry Pi 上で Subscriber を起動しておきます。

$ sudo bundle exec ruby subscriber.rb

 また、カスタムスキルが Alexa の Your Skills のリストに表示されていることを確認します。

f:id:akanuma-hiroaki:20171207092043p:plain:w450

 この状態で Alexa に「テレビリモコンでテレビをつけて」と言うと、赤外線LEDから赤外線が送信され、Alexa が「テレビの電源をONにしました」と返答してくれます。また、「テレビリモコンでテレビを消して」と言うと、同様に赤外線LEDから赤外線が送信され、Alexa が「テレビの電源をOFFにしました」と返答してくれます。

課題

 今回使用しているテレビのリモコンは ON/OFF のボタンが同一で、実際にテレビの ON/OFF の状態がどうなっているかは検知できていないので、テレビが ON/OFF どちらの状態から始めるかで逆の状態になってしまう可能性があります。また、赤外線が正しく受信されて ON/OFF が切り替わったかも検知できていないので、何らかの方法で実際のテレビのステータスを検知することが必要です。また、赤外線は指向性が強いのと、今回の装置ぐらいだと結構近受信部に近づかないと届かないので、設置場所には工夫が必要そうです。

まとめ

 赤外線はリモコンの各処理のパターンを記憶させる手間はかかりますが、色々な処理を自動化したりリモートで実行させたりすることができそうなので、今回の内容をベースに外出先からエアコンを操作できるようにしたりしてみたいと思います。もちろんもともと対応している家電があればそれがお手軽ですが、自前の装置で操作できるというのはやっぱり面白いですね。