ラズパイでリモコンの赤外線を受信する

ラズパイを使って外出先からエアコンを操作したい。まずはエアコンのリモコンから出ている赤外線を知る必要がある。

赤外線の受信モジュールを使ってリモコンから出ている信号を調べよう。

▼ラズパイでエアコンを操作したい記事全容

https://synrock-tech.com/hardware/single_board/ac-controller

前提知識

さあやるぞ、と意気込んだはいいものの電子工作に関してなんの知識もない。

ブレッドボードやら赤外線の受信モジュールやら抵抗やらは2,000円程度で調達できたので、壊してしまうくらいイジれるのだがラズパイがお釈迦になってしまうのはやや困る。

という訳で地道に知識を積み上げていくことにする。

トライアルアンドエラーで突き進んでいく、が僕のモットーである。動作した内容を記載しているが、内容に関しては一切の責任を負わないので、ラズパイの回線がショートしても文句は言わないでほしい。

▼ラズパイのGPIOを調べる ラズパイの40本あるGPIOのピンの役割についてざっと習得。

https://synrock-tech.com/hardware/single_board/raspberry-pi-gpio

20200726-1-1.png

▼pinoutコマンドの出力結果、()内の数字は物理番号

  3V3  (1) (2)  5V    
 GPIO2  (3) (4)  5V    
 GPIO3  (5) (6)  GND   
 GPIO4  (7) (8)  GPIO14
   GND  (9) (10) GPIO15
GPIO17 (11) (12) GPIO18
GPIO27 (13) (14) GND   
GPIO22 (15) (16) GPIO23
   3V3 (17) (18) GPIO24
GPIO10 (19) (20) GND   
 GPIO9 (21) (22) GPIO25
GPIO11 (23) (24) GPIO8 
   GND (25) (26) GPIO7 
 GPIO0 (27) (28) GPIO1 
 GPIO5 (29) (30) GND   
 GPIO6 (31) (32) GPIO12
GPIO13 (33) (34) GND   
GPIO19 (35) (36) GPIO16
GPIO26 (37) (38) GPIO20
   GND (39) (40) GPIO21

配線

赤外線の受信は非常に簡単で、赤外線リモコン受信モジュールをラズパイに繋ぐだけである。

▼赤外線リモコン受信モジュール

20200726-1-2.jpg

▼3本の脚の説明

20200726-1-3.png

http://akizukidenshi.com/download/OSRB38C9AA.pdf

3本でている脚は左から順番にOUTPUT, GRAUND, VCCらしい。VCCはどうもVoltage Collectorの略であり、この端子は通常電源に接続する。

OUTPUTをGPIO17(物理番号11)にGRAUNDをその隣のGND(9)にVCCは5V(2)に接続する。

▼配線図 raspberry pi 3になっているのはご愛嬌。あと、いい赤外線受信モジュールの図がなかったのでブレッドボードの上から4列目にピンを刺しているように見えるが、上から3列連続で刺しているものと読み替えていただきたい。

20200726-1-4.png

▼写真で見るとこんな感じ

20200726-1-5.jpg

▼ラズパイ側はこんな感じ。冷却ファンのコードも刺さっている。

20200726-1-6.jpg

▼全体。ブレッドボードに抵抗なども刺しているがここでは関係ない。

20200726-1-7.jpg

なんて簡単なんだろうか。小学生の夏休みの自由工作よりも簡単である。

赤外線受信実行

ハード面の準備ができたので、ソフト面を用意していこう。

pigpioのインストール

pi@raspberrypi:/ $ sudo apt-get update
pi@raspberrypi:/ $ sudo apt-get upgrade
pi@raspberrypi:/ $ sudo apt-get install pigpio python3-pigpio

インストールができたら、systemctlのコマンドでラズパイの電源が入った際に起動するように登録し、pigpioを起動させます。

pi@raspberrypi:/ $ sudo systemctl enable pigpiod.service
pi@raspberrypi:/ $ sudo systemctl start pigpiod

起動ができたら、ステータスの確認を行う。

pi@raspberrypi:~ $ sudo systemctl status pigpiod
● pigpiod.service - Daemon required to control GPIO pins via pigpio
   Loaded: loaded (/lib/systemd/system/pigpiod.service; enabled; vendor preset: enabled)
   Active: active (running)

active: active(running)になっていればヨシ!

GPIOの設定

さっきOUTPUTを突き刺したGPIO17(物理番号11)をinput(r)でpull up(u)に設定する。※ラズパイ側から見ればinputだ。

pi@raspberrypi:~ $ echo 'm 17 r pud 17 u' > /dev/pigpio

赤外線送受信のスクリプトのダウンロード

IR Record and Playback(http://abyz.me.uk/rpi/pigpio/examples.html#Python_irrp_py)をダウンロードする。

▼wgetでzipをダウンロードし、unzipでzipを解凍する。

pi@raspberrypi:/ $ sudo wget http://abyz.me.uk/rpi/pigpio/code/irrp_py.zip -O ダウンロード/irrp.zip
pi@raspberrypi:/ $ sudo unzip -d ダウンロード/irrp ダウンロード/irrp.zip

zipファイルが解凍できたら、pythonファイルが入っている。中身を確認する。

pi@raspberrypi:/ $ cat ダウンロード/irrp/irrp.py 
#!/usr/bin/env python

# irrp.py
# 2015-12-21
# Public Domain

"""
A utility to record and then playback IR remote control codes.

To record use

./irrp.py -r -g4 -fcodes 1 2 3 4 5 6

where

-r record
-g the GPIO connected to the IR receiver
-f the file to store the codes

and 1 2 3 4 5 6 is a list of codes to record.

To playback use

./irrp.py -p -g17 -fcodes 2 3 4

where

-p playback
-g the GPIO connected to the IR transmitter
-f the file storing the codes to transmit

and 2 3 4 is a list of codes to transmit.

OPTIONS

-r record
-p playback
-g GPIO (receiver for record, transmitter for playback)
-f file

id1 id2 id3 list of ids to record or transmit

RECORD

--glitch     ignore edges shorter than glitch microseconds, default 100 us
--post       expect post milliseconds of silence after code, default 15 ms
--pre        expect pre milliseconds of silence before code, default 200 ms
--short      reject codes with less than short pulses, default 10
--tolerance  consider pulses the same if within tolerance percent, default 15
--no-confirm don't require a code to be repeated during record

TRANSMIT

--freq       IR carrier frequency, default 38 kHz
--gap        gap in milliseconds between transmitted codes, default 100 ms
"""

import time
import json
import os
import argparse

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

p = argparse.ArgumentParser()

g = p.add_mutually_exclusive_group(required=True)
g.add_argument("-p", "--play",   help="play keys",   action="store_true")
g.add_argument("-r", "--record", help="record keys", action="store_true")

p.add_argument("-g", "--gpio", help="GPIO for RX/TX", required=True, type=int)
p.add_argument("-f", "--file", help="Filename",       required=True)

p.add_argument('id', nargs='+', type=str, help='IR codes')

p.add_argument("--freq",      help="frequency kHz",   type=float, default=38.0)

p.add_argument("--gap",       help="key gap ms",        type=int, default=100)
p.add_argument("--glitch",    help="glitch us",         type=int, default=100)
p.add_argument("--post",      help="postamble ms",      type=int, default=15)
p.add_argument("--pre",       help="preamble ms",       type=int, default=200)
p.add_argument("--short",     help="short code length", type=int, default=10)
p.add_argument("--tolerance", help="tolerance percent", type=int, default=15)

p.add_argument("-v", "--verbose", help="Be verbose",     action="store_true")
p.add_argument("--no-confirm", help="No confirm needed", action="store_true")

args = p.parse_args()

GPIO       = args.gpio
FILE       = args.file
GLITCH     = args.glitch
PRE_MS     = args.pre
POST_MS    = args.post
FREQ       = args.freq
VERBOSE    = args.verbose
SHORT      = args.short
GAP_MS     = args.gap
NO_CONFIRM = args.no_confirm
TOLERANCE  = args.tolerance

POST_US    = POST_MS * 1000
PRE_US     = PRE_MS  * 1000
GAP_S      = GAP_MS  / 1000.0
CONFIRM    = not NO_CONFIRM
TOLER_MIN =  (100 - TOLERANCE) / 100.0
TOLER_MAX =  (100 + TOLERANCE) / 100.0

last_tick = 0
in_code = False
code = []
fetching_code = False

def backup(f):
   """
   f -> f.bak -> f.bak1 -> f.bak2
   """
   try:
      os.rename(os.path.realpath(f)+".bak1", os.path.realpath(f)+".bak2")
   except:
      pass

   try:
      os.rename(os.path.realpath(f)+".bak", os.path.realpath(f)+".bak1")
   except:
      pass

   try:
      os.rename(os.path.realpath(f), os.path.realpath(f)+".bak")
   except:
      pass

def carrier(gpio, frequency, micros):
   """
   Generate carrier square wave.
   """
   wf = []
   cycle = 1000.0 / frequency
   cycles = int(round(micros/cycle))
   on = int(round(cycle / 2.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 normalise(c):
   """
   Typically a code will be made up of two or three distinct
   marks (carrier) and spaces (no carrier) of different lengths.

   Because of transmission and reception errors those pulses
   which should all be x micros long will have a variance around x.

   This function identifies the distinct pulses and takes the
   average of the lengths making up each distinct pulse.  Marks
   and spaces are processed separately.

   This makes the eventual generation of waves much more efficient.

   Input

     M    S   M   S   M   S   M    S   M    S   M
   9000 4500 600 540 620 560 590 1660 620 1690 615

   Distinct marks

   9000                average 9000
   600 620 590 620 615 average  609

   Distinct spaces

   4500                average 4500
   540 560             average  550
   1660 1690           average 1675

   Output

     M    S   M   S   M   S   M    S   M    S   M
   9000 4500 609 550 609 550 609 1675 609 1675 609
   """
   if VERBOSE:
      print("before normalise", c)
   entries = len(c)
   p = [0]*entries # Set all entries not processed.
   for i in range(entries):
      if not p[i]: # Not processed?
         v = c[i]
         tot = v
         similar = 1.0

         # Find all pulses with similar lengths to the start pulse.
         for j in range(i+2, entries, 2):
            if not p[j]: # Unprocessed.
               if (c[j]*TOLER_MIN) < v < (c[j]*TOLER_MAX): # Similar.
                  tot = tot + c[j]
                  similar += 1.0

         # Calculate the average pulse length.
         newv = round(tot / similar, 2)
         c[i] = newv

         # Set all similar pulses to the average value.
         for j in range(i+2, entries, 2):
            if not p[j]: # Unprocessed.
               if (c[j]*TOLER_MIN) < v < (c[j]*TOLER_MAX): # Similar.
                  c[j] = newv
                  p[j] = 1

   if VERBOSE:
      print("after normalise", c)

def compare(p1, p2):
   """
   Check that both recodings correspond in pulse length to within
   TOLERANCE%.  If they do average the two recordings pulse lengths.

   Input

        M    S   M   S   M   S   M    S   M    S   M
   1: 9000 4500 600 560 600 560 600 1700 600 1700 600
   2: 9020 4570 590 550 590 550 590 1640 590 1640 590

   Output

   A: 9010 4535 595 555 595 555 595 1670 595 1670 595
   """
   if len(p1) != len(p2):
      return False

   for i in range(len(p1)):
      v = p1[i] / p2[i]
      if (v < TOLER_MIN) or (v > TOLER_MAX):
         return False

   for i in range(len(p1)):
       p1[i] = int(round((p1[i]+p2[i])/2.0))

   if VERBOSE:
      print("after compare", p1)

   return True

def tidy_mark_space(records, base):

   ms = {}

   # Find all the unique marks (base=0) or spaces (base=1)
   # and count the number of times they appear,

   for rec in records:
      rl = len(records[rec])
      for i in range(base, rl, 2):
         if records[rec][i] in ms:
            ms[records[rec][i]] += 1
         else:
            ms[records[rec][i]] = 1

   if VERBOSE:
      print("t_m_s A", ms)

   v = None

   for plen in sorted(ms):

      # Now go through in order, shortest first, and collapse
      # pulses which are the same within a tolerance to the
      # same value.  The value is the weighted average of the
      # occurences.
      #
      # E.g. 500x20 550x30 600x30  1000x10 1100x10  1700x5 1750x5
      #
      # becomes 556(x80) 1050(x20) 1725(x10)
      #       
      if v == None:
         e = [plen]
         v = plen
         tot = plen * ms[plen]
         similar = ms[plen]

      elif plen < (v*TOLER_MAX):
         e.append(plen)
         tot += (plen * ms[plen])
         similar += ms[plen]

      else:
         v = int(round(tot/float(similar)))
         # set all previous to v
         for i in e:
            ms[i] = v
         e = [plen]
         v = plen
         tot = plen * ms[plen]
         similar = ms[plen]

   v = int(round(tot/float(similar)))
   # set all previous to v
   for i in e:
      ms[i] = v

   if VERBOSE:
      print("t_m_s B", ms)

   for rec in records:
      rl = len(records[rec])
      for i in range(base, rl, 2):
         records[rec][i] = ms[records[rec][i]]

def tidy(records):

   tidy_mark_space(records, 0) # Marks.

   tidy_mark_space(records, 1) # Spaces.

def end_of_code():
   global code, fetching_code
   if len(code) > SHORT:
      normalise(code)
      fetching_code = False
   else:
      code = []
      print("Short code, probably a repeat, try again")

def cbf(gpio, level, tick):

   global last_tick, in_code, code, fetching_code

   if level != pigpio.TIMEOUT:

      edge = pigpio.tickDiff(last_tick, tick)
      last_tick = tick

      if fetching_code:

         if (edge > PRE_US) and (not in_code): # Start of a code.
            in_code = True
            pi.set_watchdog(GPIO, POST_MS) # Start watchdog.

         elif (edge > POST_US) and in_code: # End of a code.
            in_code = False
            pi.set_watchdog(GPIO, 0) # Cancel watchdog.
            end_of_code()

         elif in_code:
            code.append(edge)

   else:
      pi.set_watchdog(GPIO, 0) # Cancel watchdog.
      if in_code:
         in_code = False
         end_of_code()

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

if not pi.connected:
   exit(0)

if args.record: # Record.

   try:
      f = open(FILE, "r")
      records = json.load(f)
      f.close()
   except:
      records = {}

   pi.set_mode(GPIO, pigpio.INPUT) # IR RX connected to this GPIO.

   pi.set_glitch_filter(GPIO, GLITCH) # Ignore glitches.

   cb = pi.callback(GPIO, pigpio.EITHER_EDGE, cbf)

   # Process each id

   print("Recording")
   for arg in args.id:
      print("Press key for '{}'".format(arg))
      code = []
      fetching_code = True
      while fetching_code:
         time.sleep(0.1)
      print("Okay")
      time.sleep(0.5)

      if CONFIRM:
         press_1 = code[:]
         done = False

         tries = 0
         while not done:
            print("Press key for '{}' to confirm".format(arg))
            code = []
            fetching_code = True
            while fetching_code:
               time.sleep(0.1)
            press_2 = code[:]
            the_same = compare(press_1, press_2)
            if the_same:
               done = True
               records[arg] = press_1[:]
               print("Okay")
               time.sleep(0.5)
            else:
               tries += 1
               if tries <= 3:
                  print("No match")
               else:
                  print("Giving up on key '{}'".format(arg))
                  done = True
               time.sleep(0.5)
      else: # No confirm.
         records[arg] = code[:]

   pi.set_glitch_filter(GPIO, 0) # Cancel glitch filter.
   pi.set_watchdog(GPIO, 0) # Cancel watchdog.

   tidy(records)

   backup(FILE)

   f = open(FILE, "w")
   f.write(json.dumps(records, sort_keys=True).replace("],", "],\n")+"\n")
   f.close()

else: # Playback.

   try:
      f = open(FILE, "r")
   except:
      print("Can't open: {}".format(FILE))
      exit(0)

   records = json.load(f)

   f.close()

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

   pi.wave_add_new()

   emit_time = time.time()

   if VERBOSE:
      print("Playing")

   for arg in args.id:
      if arg in records:

         code = records[arg]

         # Create wave

         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)

         if VERBOSE:
            print("key " + arg)

         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 = {}
      else:
         print("Id {} not found".format(arg))

pi.stop() # Disconnect from Pi.

赤外線受信のスクリプト実行

では早速赤外線の受信を実行していこう。

super userにしておく。

pi@raspberrypi:~ $ sudo su
pi@raspberrypi:~ $ python ダウンロード/irrp/irrp.py -r -g17 -f ac_controller AC_cooling:on --no-confirm --post 100
Recording
Press key for 'AC_cooling:on'

press key for 'xx'と聞かれたら、赤外線リモコン受信モジュールに向かってリモコンのボタンを押す。

受信できた場合、以下のように表示される。

※ラズパイに刺すピンが一つづつずれていて初めはうんともすんとも言わなかった……暑さで頭がおかしくなる前に冷房を聞かせることが急務である。

Okay

内容を確認する。登録された内容はirrp.pyと同じディレクトリ内に作成されている。

pi@raspberrypi:~ $ cat ダウンロード/irrp/ac_controller 
{"AC_cooling:on": [3788, 1890, 445, 551, 445, 1470, 445, 551, 445, 1470, 380, 551, 445, 1470, 445, 551, 445, 1470, 445, 551, 445, 1470, 445, 551, 380, 1470, 445, 1470, 445, 551, 380, 1470, 445, 551, 445, 1470, 445, 1470, 380, 1470, 380, 1470, 380, 551, 380, 551, 380, 1470, 445, 1470, 445, 551, 445, 551, 445, 551, 380, 551, 380, 1470, 380, 551, 380, 551, 445, 551, 380, 551, 380, 1470, 380, 551, 380, 1470, 380, 551, 380, 551, 380, 551, 380, 551, 380, 1470, 380, 551, 380, 551, 380, 551, 380, 1470, 445, 551, 380, 551, 380, 551, 380, 551, 445, 1470, 380, 551, 380, 551, 380, 551, 380, 1470, 380, 551, 380, 551, 380, 551, 380, 551, 380, 1470, 380, 551, 380, 1470, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 1470, 445, 1470, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 1470, 380, 551, 380, 1470, 445, 1470, 380, 551, 380, 551, 380, 1470, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 551, 380, 1470, 445, 1470, 380, 551, 380, 1470, 380, 1470, 380, 1470, 380, 1470, 380, 551, 380, 551, 380, 551, 380, 1470, 445, 1470, 445, 551, 445, 1470, 445]}

中身の数字はさっぱり意味不明だが、どうやら自宅のエアコンのリモコンの冷房ボタンから出てきた赤外線らしい。

列挙すると一気に登録ができるので同じようにどんどん登録する。

pi@raspberrypi:~ $ python ダウンロード/irrp/irrp.py -r -g17 -f ac_controller  AC_heating:on AC:off AC:raise AC:lower --no-confirm --post 100
Recording
Press key for 'AC_heating:on'
Okay
Press key for 'AC:off'
Okay
Press key for 'AC:raise'
Okay
Press key for 'AC:lower'
Okay

どうでもいいけれど、エアコンの冷房のスイッチと暖房のスイッチの赤外線は結構似ている。

20200726-1-8.png

左が冷房。右が暖房。

まとめ

エアコンのリモコンのあらゆるボタンを登録していき、それを発信することができれば、リモコンの代替となるわけである。

次回はこれを送信して、エアコンをつけてみたい。