LoginSignup
1
2

More than 3 years have passed since last update.

Raspberry Pi で赤外線リモコン

Last updated at Posted at 2021-04-03

家のテレビはAVアンプ経由で色々なゲーム機など繋げて利用しているのですが、電源ON/OFFするとき、AVアンプとテレビとそれぞれやらないといけないのが少し面倒です。
ゲームをやった後にニュースを見ようと思ったときも、チューナーに切り替え、放送をCATVに、チャンネル1ボタンにセットしてあるNNNチャンネルを呼びだし、と結構手間がかかります。

そこで、Raspberry Pi 経由で一括制御できるようにしてみました。

リモコン操作側(タッチパネル)

IMG_6126.JPEG

赤外線発光部(兼学習用受光部)

IMG_6123.JPEG

音声コマンド例

  • OK Google テレビ電源
    • テレビとAVアンプの電源コードを送信
  • OK Google テレビ電源スイッチ
    • テレビとAVアンプの電源コードを送信+入力をSwitchに
    • 話しかけながらスイッチの電源いれたりリングコン準備したりできるので便利になりました。
  • OK Google テレビの電源いれてニュースに
    • テレビとAVアンプの電源コードを送信+AVアンプの入力をチューナーに+チューナーの放送切り替えをCATVに+チャンネル1を押してNNNに切り替え

構成

赤外線の発光部は結構指向性があるようなので、AV機器の側に置けるように、Raspberry Pi Zero で別途作成し、操作は手元側のタッチパネル+Raspberry Pi 4 で行うことにしました。

また、追加で IFTTT と連動し、リクエストを Raspberry Pi 4 で受け取り、タッチパネルのボタン操作の代わりに音声でも操作できるようにしました。

赤外線発行側のセットアップ

pigpio のインストール

赤外線リモコンのやり方いくつかあるようですが、pigpioを使う方法がわかりやすそうだったので、pigpioを準備します。

$ sudo apt install pigpio
$ sudo apt install python3-pigpio python-pigpio
$ sudo systemctl enable pigpiod
$ sudo systemctl start pigpiod

赤外線リモコンツール

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

17番を出力に、18番を入力に使うので、初期設定してLED・受光パーツを繋げます。

echo 'm 17 w w 17 0 m 18 r pud 18 u' > /dev/pigpio

最初は他記事でよくあるように、ラズパイのGPIO17に抵抗とLEDを付けて動作確認しました。

Apacheのセットアップ

タッチパネル側のラズパイからリクエストを受け付けて、赤外線をだすようにします。

$ sudo apt install apache2

インストールが終わると自動起動しますが、CGIが動作するように設定します。

初期のindexページは消して、設定ファイルを書き換えます。

sudo sh -c "echo > /var/www/html/index.html"
sudo a2enmod cgid

sudo vi /etc/apache2/apache2.conf

AllowOverrideを有効にするように、以下の設定を末尾に追加します。

<Directory /var/www/html/>
    Options Indexes FollowSymLinks ExecCGI
    AllowOverride None
    Require all granted

    AddHandler cgi-script .cgi
</Directory>

再起動して動作確認します。

$ sudo systemctl restart apache2

と最初はCGIで作っていたのですが、Raspberry Pi Zero の性能だと、import cgi だけで、Python3 は1秒くらいCGI起動に時間がかかってしまいます。
IR送信の応答が悪かったので、FastCGI化します。

$ sudo apt install libapache2-mod-fcgid
$ sudo apt install python-flup

高輝度LEDの回路図

LEDの威力が小さいせいか、TVの近くでないと反応しないので、高輝度LEDで動くようにしました。

電子回路は素人なので調べつつこんな感じの回路にしました。

image.png

詳しくないので記事を見ながら適当に抵抗とかを変えて回路シミュレータ(Circuit JS)で確認。
ラズパイからの出力をClockにして、LED並列でならべてmAとVを確認して調整しました。

赤外線LEDは高輝度のL12170にしました。通常LEDが20mAくらいみたいなので、10倍強力。強そうです。

最初にR1の抵抗計算します。
順電圧 Typ. 1.45V とあるので、5V電源に繋げるので抵抗の電圧は 5 - 1.45 = 3.55V。
300mAが限界なので、200mAくらいを目標とすると、3.55V ÷ 0.2A = 17.75Ω。
ちょうどの抵抗はなかったので、20Ωの抵抗にしました。

このとき 3.55V ÷ 20Ω = 177.5mA が流れる計算。
0.630W ほど流れるので、それに耐える抵抗が必要です。
(最初に買った抵抗セットは1/4Wのものだったので買い足しました…)

これをラズパイの出力でON/OFFできるように間にトランジスタを入れます。
2SC1815をよく見かけるのですが、コレクタ電流150mAが最大で足りないので、適当に似た型番のを見て2SC1959を選びました。
(もっと良い物があるのかも…💦)

購入したトランジスタテスタではかったところ、hFE=434。GRなので200-400のはずなのでテスターの精度が悪いのかなぁ…?
と思いますが詳細はわからず。

スペック的にはhFE=200~400のはずなので、400近いのかもしれませんが謎です。。。
とりあえずhFE=200~400のどこでも大丈夫なように、200で計算すると、
177.5mA ÷ 200 = 0.89mA
くらいをベースに流れるようにすれば大丈夫そうです。

絶対最大規格のベース電流100mAを超えない限りは、大きい分には大丈夫なはず…の理解。小さすぎると C-E間に十分電流が流れなくなる…のだと思います。Curcuit JS でhFE変えてみるとそんな感じの動きなのであってるハズ。

ラズパイの出力をトランジスタのBに繋げて、1mA程度流れるようにしたい。
トランジスタのベースエミッタ間電圧が Typ 0.8V なので、ラズパイの出力 3.3V - 0.8V = 2.5V が抵抗R2の電圧。
2.5V ÷ 0.001A = 2500Ω なので、それより小さくて一番近い 2.2kΩの抵抗があったのでこれを選択。

次にベースコレクタ間の抵抗R3の計算。
コレクタ遮断電流ICBOは0.1uAとして、トランジスタが動作する最小電圧VBEは…データシート見ても書いてない。ので参考にしたサイトの記載通り0.1Vで計算。
とすると、ベースエミッタ間抵抗RBEは 0.1V ÷ 1uA = 0.1V ÷ 0.000001A = 100kΩ より小さければOK?
1kΩから10kΩの例が多いとあるので、10kΩにしておくことにしました。

最後にバイパスコンデンサを入れておいた方がいいようなので、47μFの積層コンデンサをGND/5Vの間に入れておきました。
Circuit JS でコンデンサを入れると「Capacitor loop with no resistance!」と言われてシミュレーションできなくなるので正しいのかいまいちわかりません💦

パスコンを入れる前と後で波形を見ると、確かに電源ラインの変動が抑えられているので、いれた方がいいようです。
なしと1つ入れたときは差がありましたが、3つ入れて見ても1つのときとほぼ変わらないようです。いっぱい繋げれば良いというものでも無いのかな?

パスコンなし:

DS1Z_QuickPrint1.png

パスコンあり:

DS1Z_QuickPrint2.png

青色が電源ラインです。

回路シミュレータ

参考記事

トランジスタのベースとGNDの間に抵抗を入れている人と入れていない人がいて、どっちが正しいのか調べたところ、基本入れる必要があって、トランジスタがBE間の抵抗内蔵であればなしかな?1kΩから10kΩあたりが多いらしいので適当に選びました。

コード

以下のようなコードになりました。

pigpio付属のirry.pyでは、デューティー比1/2で発行させていましたが、いろいろサイトを見ていると1/3でよいようなので、そこは修正しています。

# apt update
# apt install python3-pip
# pip3 install flup-py3

発光用コード
#!/usr/bin/python3

import time

import pigpio # http://abyz.co.uk/rpi/pigpio/python.html


import cgi
import urllib
from flup.server.fcgi import WSGIServer



# 出力に使うGPIO番号を設定
GPIO       = 17

FREQ       = 38.0
GAP_MS     = 100
GAP_S      = GAP_MS  / 1000.0
INTERVAL_MS = 300

T = 412

pi = pigpio.pi() # Connect to Pi.


def carrier(gpio, frequency, micros):
   """
   Generate carrier square wave.
   """
   wf = []
   cycle = 1000.0 / frequency
   cycles = int(round(micros/cycle))
   on = int(round(cycle / 3.0))
   sofar = 0
   for c in range(cycles):
      target = int(round((c+1)*cycle))
      sofar += on
      off = target - sofar
      sofar += off
      wf.append(pigpio.pulse(1<<gpio, 0, on))
      wf.append(pigpio.pulse(0, 1<<gpio, off))
   return wf

def send_ir(code):
    emit_time = time.time()

    marks_wid = {}
    spaces_wid = {}

    wave = [0]*len(code)

    for i in range(0, len(code)):
        ci = code[i]
        if i & 1: # Space
            if ci not in spaces_wid:
                pi.wave_add_generic([pigpio.pulse(0, 0, ci)])
                spaces_wid[ci] = pi.wave_create()
            wave[i] = spaces_wid[ci]
        else: # Mark
            if ci not in marks_wid:
                wf = carrier(GPIO, FREQ, ci)
                pi.wave_add_generic(wf)
                marks_wid[ci] = pi.wave_create()
            wave[i] = marks_wid[ci]

    delay = emit_time - time.time()

    if delay > 0.0:
        time.sleep(delay)

    pi.wave_chain(wave)

    while pi.wave_tx_busy():
        time.sleep(0.002)

    emit_time = time.time() + GAP_S

    for i in marks_wid:
        pi.wave_delete(marks_wid[i])

    marks_wid = {}

    for i in spaces_wid:
        pi.wave_delete(spaces_wid[i])

    spaces_wid = {}


def append_bit(code, hex):
    i = 0
    while(i < len(hex)):
        if(i + 2 <= len(hex)):
            encode = int(hex[i:i+2], 16)
            i += 2
            bit = f'{encode:08b}'
            bit = bit[::-1]
            encode_ir(code, bit)
        #   print(bit)
        elif(i + 1 <= len(hex)):
            encode = int(hex[i:i+1], 16)
            i += 1
            bit = f'{encode:04b}'
            bit = bit[::-1]
            encode_ir(code, bit)
        #   print(bit)
        else:
            return

def append_bit2(code, hex):
    i = 0
    while(i < len(hex)):
        if(i + 2 <= len(hex)):
            encode = int(hex[i:i+2], 16)
            i += 2
            bit = '{:08b}'.format(encode)
            bit = bit[::-1]
            encode_ir(code, bit)
        #   print(bit)
        elif(i + 1 <= len(hex)):
            encode = int(hex[i:i+1], 16)
            i += 1
            bit = '{:04b}'.format(encode)
            bit = bit[::-1]
            encode_ir(code, bit)
        #   print(bit)
        else:
            return

def encode_ir(code, bit):
    for i in range(len(bit)):
        if(bit[i] == "0"):
            code.append(T)
            code.append(T)
        else:
            code.append(T)
            code.append(T*3)

def aeha(funcid):
    if len(funcid) != 12:
        print("code length error.")
        exit(1)

    code = []

    # Leader
    code.append(T * 8)
    code.append(T * 4)

    # Customer Code (aa5a) Parity (f) Data0 (a) Data1 (12)
    append_bit(code, funcid)

    # Trailer
    code.append(T * 1)

    return code

def sharp_aquos(funcid):
    if len(funcid) != 3:
        print("code length error.")
        exit(1)

    # Customer Code (aa5a) Parity (f) Data0 (a) Data1 (12)
    parity = 8 ^ 1 ^ 2 ^ int(funcid[0], 16) ^ int(funcid[1], 16) ^ int(funcid[2], 16);
    return aeha("aa5a8f12" + funcid[1] + funcid[2] + '{:01x}'.format(parity) + funcid[0])

# funcid: customer code (16bit) + data (8bit)
def nec(funcid):
    if len(funcid) != 8:
        print("code length error.")
        exit(1)

    code = []

    # Leader
    code.append(T * 16)
    code.append(T * 8)


    append_bit(code, funcid)

    # Trailer
    code.append(T * 1)

    return code

def main(ircode):

    pi = pigpio.pi() # Connect to Pi.

    if not pi.connected:
       exit(0)

    # Playback.

    pi.set_mode(GPIO, pigpio.OUTPUT) # IR TX connected to this GPIO.

    pi.wave_add_new()

    sendcode = []

    for ir in ircode.split():

        (type, hexcode) = ir.split(":", 2);

        global T
        code = []
        if type == "SHARP_AQUOS":
            T = 412
            code = sharp_aquos(hexcode)
        elif type == "AEHA":
            T = 412
            code = aeha(hexcode)
        elif type == "NEC":
            T = 562
            code = nec(hexcode)
        elif type == "sleep":
            time.sleep(int(hexcode) / 1000.0)
        else:
            print("no code.")
            exit(1)

        send_ir(code)

        sendcode.append('{}'.format(code))

        time.sleep(INTERVAL_MS / 1000.0)

    pi.stop() # Disconnect from Pi.

    return sendcode;



def app(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])

    qs = environ['QUERY_STRING']
    parsed = urllib.parse.parse_qs(qs)

    sendcode = main(parsed['ircode'][0])

    yield 'ok ' + '{}'.format(sendcode)


WSGIServer(app).run()

タッチパネル側のセットアップ

Grafana セットアップ

unclutterをインストールしてマウスカーソルを隠す設定と、起動時に自動的にGrafanaが開くようにします。

Grafana 自体のインストールはこちらの記事と同じです。
セットアップ後、認証なしでみれるように auth.anonymous の設定をしておきます。

unclutterとブラウザの自動起動は以下のように。

$ sudo apt-get install unclutter
/etc/systemd/system/unclutter.service
[Unit]
Description=launch unclutter

[Service]
User=pi
Environment=DISPLAY=:0
ExecStart=unclutter -idle 0.1

[Install]
WantedBy=user@.service
/etc/systemd/system/open-browser.service
[Unit]
Description=launch chromium-browser

[Service]
User=pi
Environment=DISPLAY=:0
ExecStart=chromium-browser --kiosk http://192.168.7.186:3000/d/99hde9zgz/rimokon?orgId=1&kiosk&refresh=5s
(上記のURLは開きたいdashbordのURLにします)

[Install]
WantedBy=user@.service

有効にして再起動してテストすると、自動的にGrafanaが開くようになりました。

# systemctl daemon-reload
# systemctl enable unclutter.service
# systemctl enable open-browser.service

Grafanaパネルメニューの削除

Grafana上でボタンプラグインを入れてパネルを作っていますが、パネルのタイトルを空文字列にして隠しても、パネル上部をタップするとメニューが出てしまいます。
メニューが消せれば良いのですが、要望は結構あるようですが未実装のようです。

そこで、ダミーでパネルを作り、以下のようなスクリプトを埋め込みました。
パネルは、全て Panel Title 空文字列で作ります。(タイトル付けると消したときに余分な空白が出来ます。)

<script>
if(document.location.href.match("kiosk")) {
    $(".panel-title").remove();
}    
</script>

設定も変えておく必要があります。

/etc/grafana/grafana.ini
[panels]
# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
disable_sanitize_html = true

パネルを作る

適当にパネルに準備します。
音声認識で意図した動作しなかったときの確認用に、最後に使ったコマンドも表示します。
「プレステ」は「消して」と認識されることが多いようなので、それでもプレステ切り替えするようにしています。

image.png

音声操作・受付CGI

アレクサと違ってGoogleHomeであれば、IFTTTで任意のキーワードを追加して音声操作できることがわかったので、こちらを使うことにしました。キーワードを「テレビ」にしておくと、「OK Google テレビ○○○」でWebhookを呼び出す事が出来ます。

Webhookの受付用にApache2もインストールして、以下のようなコードを用意しました。

Grafanaのパネルも、この音声操作用のFCGIを呼び出すようにセットアップしています。

コマンド受付用コード
#!/usr/bin/python
# -*- coding: utf-8 -*-


import fcntl
import re

import cgi
import urlparse
import urllib
import urllib2
from flup.server.fcgi import WSGIServer



# 認証用のキー
APIKEY = "********"

# IRコード送信先
IR_CGI_URL = "http://192.168.7.162/ir.fcgi"

# IRコード

# テレビ電源
POWER_TV = "SHARP_AQUOS:116"

# テレビ電源
POWER_AMP = "NEC:7e812ad5"

# アンプ電源 + テレビ電源
POWER = "NEC:7e812ad5 SHARP_AQUOS:116"

# アンプ AV2
AV_TO_TUNER = "NEC:7a855629"

# アンプ AV5
AV_TO_SWITCH = "NEC:7a855f20"

# アンプ AV6
AV_TO_PS5 = "NEC:7a85621d"

# アンプ AV7
AV_TO_APPLE = "NEC:7a857609"

# アンプ VOL+
AMP_VOL_UP = "NEC:7a851ae5"
AMP_VOL_UP5 = " ".join([AMP_VOL_UP] * 5)
AMP_VOL_UP10 = " ".join([AMP_VOL_UP] * 10)

# アンプ VOL-
AMP_VOL_DOWN = "NEC:7a851be4"
AMP_VOL_DOWN5 = " ".join([AMP_VOL_DOWN] * 5)
AMP_VOL_DOWN10 = " ".join([AMP_VOL_DOWN] * 10)

# チューナー地上
TUNER_OTA = "AEHA:022080266acc"

# チューナーCATV
TUNER_CATV = "AEHA:022080266cca"

# チューナー 1
TUNER_1 = "AEHA:0220802660c6"

# チューナー 2
TUNER_2 = "AEHA:0220802661c7"

# チューナー 3
TUNER_3 = "AEHA:0220802662c4"

# チューナー 4
TUNER_4 = "AEHA:0220802663c5"

# チューナー 5
TUNER_5 = "AEHA:0220802664c2"




def send_ir(code):
    params = { "ircode": code }
    res = urllib2.urlopen('{}?{}'.format(IR_CGI_URL, urllib.urlencode(params)))
    body = res.read()


def main(key, command_text):

    # このファイルロックで同時にIR送信しない排他制御を兼ねる
    file = open("/home/mikage/usr/ir/cgilog/request.txt", 'a')
    fcntl.flock(file, fcntl.LOCK_EX)
    file.write(command_text + "\n")

    file_last = open("/var/www/html/last_request.txt", 'w')
    file_last.write(command_text + "\n")

    command_text = re.sub(r'\s', '', command_text)
#   file_last.write(":" + command_text + "\n")

    if re.search("テレビ電源", command_text):
        file_last.write("→テレビ電源\n")
        send_ir(POWER_TV)
    elif re.search("アンプ電源", command_text):
        file_last.write("→アンプ電源\n")
        send_ir(POWER_AMP)
    elif re.search("電源", command_text):
        file_last.write("→電源\n")
        send_ir(POWER)

    if re.search("スイッチ", command_text):
        file_last.write("→スイッチ\n")
        send_ir(AV_TO_SWITCH)

    if re.search(r'プレステ|消して', command_text):
        file_last.write("→プレステ\n")
        send_ir(AV_TO_PS5)

    if re.search("アップル", command_text):
        file_last.write("→アップル\n")
        send_ir(AV_TO_APPLE)

    if re.search("チューナー", command_text):
        file_last.write("→チューナー\n")
        send_ir(AV_TO_TUNER)


    if re.search(r'あげて|上げて|大きく', command_text):
        file_last.write("→音量+\n")
        send_ir(AMP_VOL_UP10)

    if re.search(r'さげて|下げて|小さく', command_text):
        file_last.write("→音量-\n")
        send_ir(AMP_VOL_DOWN10)

    if re.search("TBSニュース", command_text):
        file_last.write("→チューナー→CATV→2\n")
        send_ir(" ".join([AV_TO_TUNER, TUNER_CATV, TUNER_2]))
    elif re.search("ニュース", command_text):
        file_last.write("→チューナー→CATV→1\n")
        send_ir(" ".join([AV_TO_TUNER, TUNER_CATV, TUNER_1]))

    if re.search("ミュージックエア", command_text):
        file_last.write("→チューナー→CATV→3\n")
        send_ir(" ".join([AV_TO_TUNER, TUNER_CATV, TUNER_3]))

    if re.search("エムオン", command_text):
        file_last.write("→チューナー→CATV→4\n")
        send_ir(" ".join([AV_TO_TUNER, TUNER_CATV, TUNER_4]))

    if re.search("MTV", command_text):
        file_last.write("→チューナー→CATV→5\n")
        send_ir(" ".join([AV_TO_TUNER, TUNER_CATV, TUNER_5]))


    file.close()



def app(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])

    qs = environ['QUERY_STRING']
    parsed = urlparse.parse_qs(qs)
    yield 'ok'

    main(parsed['key'][0], parsed['text'][0])


WSGIServer(app).run()

IFTTT 側で、GoogleHome で Webhook を呼び出すように設定します。

image.png

image.png

image.png

ディスクリートコード(電源のON/OFF)

リモコンの電源ボタンはトグルなので、アレクサと連動して「テレビつけて」とかでON/OFFするなどは難しそうです。
でも内部的には電源ONのみ、OFFのみの機能も用意されているのでは…?
とおもってググってみると、ディスクリートコードといって、多くの機種では用意されているようです。

ところが、実際に試してみるとシャープ系TVで使えそうなコードもわたしのTVは無反応(>_<)
というわけで、残念ながらそこは諦めることにしました。

参考記事

LEDの強化

たまに反応しないことがあるので、もうちょっとLEDの発光量を増やせないか検討しました。

LEDのデータシートを見ると、デューティー比1/3=33%、キャリアが38kHzなので、1回26μsのうち1/3の間光るので、パルス幅は約9μs。グラフを読むと600mAくらいまではいけそうです。
ただ、周囲温度30度くらいはありそうなので、1割程度の余裕は必要そうです。

image.png

手元にある抵抗だと5.1Ωか10Ωですが、5.1だと600mA超えてしまうので、10Ωにかえることにしました。
また、LEDが結構指向性があるので、3つつけて広い範囲で動くようにします。

image.png

ケース加工スキルはないので、テープで適当に張り付けただけですが、LED3つに。
電流もあげたのでより強力になったはずです。

IMG_6127.JPEG

パスコン

注文した電解コンデンサセットが届いたので、一番大きいものをとりあえず1つつけてみました。

電解コンデンサつける前:

DS1Z_QuickPrint4.png

電解コンデンサつけた後:
DS1Z_QuickPrint5.png

というわけで少しノイズは減ったようですが、なくても動作に支障はなさそうな雰囲気です(たぶん)

1
2
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
1
2