Breakout(2) ロータリーエンコーダとマウス

PyGameで作ってみたブロック崩し、ロータリーエンコーダのプログラムと組み合わせて、パドルコントローラ風に遊べるバージョンも作ってみた。

動くには動いたのだが、どうもあまり動きが滑らかではない。やはりPythonだけでロータリーエンコーダの検出をやらせるのは無理があるのだろうか?GPIOまわりのアクセスをC言語で直接叩くようにすればもっと高速に取り込むことができて、滑らかに動かせると思うのだが、そこまで突っ込んでやってしまうと遊びの範疇からずれてしまう気がするので、今のところはここまでにしておく。

その代わりに、マウス入力で遊べるバージョンも作成。こっちはレスポンスも良く、ゲームとしてかなり遊べる状態になった。

ソースコードは長くなるので、ロータリーエンコーダ版とマウス版の両方をまとめてGoogle Driveに置いた。ここからダウンロードできる。Breakout_m.pyがマウス版。

マウスを入力デバイスとした場合、デフォルトではマウスポインタがウィンドウを外れるとパドルが動かなくなってしまうため、それではゲームにならない。PyGameはマウスポインタ(マウスカーソル)が非表示で、なおかつ pygame.event.set_grab がTrueに設定されている時は、常にマウスイベントが送られてくるようになっている。

その代わり他のプログラムにマウスやキーボードイベントが送られなくなってしまうので、注意が必要だ。プログラムを終了するためにウィンドウの[閉じる]をクリックする事もできなくなるので、QUITイベントで終了する代わりにエスケープキーによって終了するように変更した。

    pygame.mouse.set_visible(False)
    pygame.event.set_grab(True)
    mx, my = pygame.mouse.get_rel()
    while True:
        clock.tick(60)
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN and event.key == K_ESCAPE:
                pygame.mixer.quit()
                pygame.quit()
                sys.exit()
        mx, my = pygame.mouse.get_rel()
        paddle.rect.left = paddle.rect.left + mx

サンプルのつもりで作ってみたゲームだが、マウス版は意外と遊べたので、もう少し手を加えればちゃんとしたゲームになりそう。時間があればやってみるかも知れないが、どうせならオリジナリティのあるゲームも作ってみたい。良いアイデアが思い付けばの話だけど。

Breakout

Raspberry Piにロータリーエンコーダを接続して値を読み取る事はできたので、これを何かに応用したいと考えた。真っ先に思い付いたのは「ブロック崩し」のパドルコントローラとしての応用で、そもそも秋月でロータリーエンコーダを購入したのは、Arduinoのゲームコントローラに使えないか?と考えての事。

結局Arduinoで使うことは無かったが、Raspberry Piならゲーム作成用のライブラリも存在しているので、試しに作ってみる事にした。実を言うと前回の「PyGameでサウンド再生」はその過程で引っかかった事なのだ。

まずはロータリーエンコーダを使わずに、キーボードで操作できるバージョンを作ってみた。プログラムは以下の通り。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
import pygame
from pygame.locals import *

screen_rect = Rect(0, 0, 480, 640)
white = 255, 255, 255
black = 0, 0, 0
flip = ((10, 10, 10, 10,  8,  8,  8,  8,
          8,  8,  8,  8,  8,  8,  8,  8,
          8,  8,  8,  8,  8,  8,  6,  6,
          6,  6,  6,  6,  4,  4,  4,  4),
        ( 4,  4,  4,  4,  8,  8,  8,  8,
          8,  8,  8,  8,  8,  8,  8,  8,
          8,  8,  8,  8,  8,  8,  8,  8,
          8,  8,  8,  8, 10, 10, 10, 10))

class Block(pygame.sprite.Sprite):
    def __init__(self, filename, x, y):
        pygame.sprite.Sprite.__init__(self)
        self.image = pygame.image.load(filename).convert()
        ckey = self.image.get_at((0,0))
        self.image.set_colorkey(ckey, RLEACCEL)
        w = self.image.get_width()
        h = self.image.get_height()
        self.rect = Rect(x * 48 + 1, y * 16 + 64, w, h)
    def update(self, ball):
        pass
    def draw(self):
        screen.blit(self.image, self.rect)

class Ball(pygame.sprite.Sprite):
    def __init__(self, filename):
        pygame.sprite.Sprite.__init__(self)
        self.image = pygame.image.load(filename).convert()
        ckey = self.image.get_at((0,0))
        self.image.set_colorkey(ckey, RLEACCEL)
        w = self.image.get_width()
        h = self.image.get_height()
        self.rect = Rect(10, 200, w, h)
        self.speed = [8, 8]
    def update(self):
        self.rect.move_ip(self.speed)
        if self.rect.left <= 0 or self.rect.right > screen_rect.width:
            self.speed[0] = -self.speed[0]
        if self.rect.top <= 0:
            self.speed[1] = -self.speed[1]
        self.rect = self.rect.clamp(screen_rect)
    def draw(self):
        screen.blit(self.image, self.rect)
    def reset(self):
        self.rect = Rect(10, 200, self.rect.width, self.rect.height)
        self.speed = [8, 8]

class Paddle(pygame.sprite.Sprite):
    def __init__(self, filename):
        pygame.sprite.Sprite.__init__(self)
        self.image = pygame.image.load(filename).convert()
        ckey = self.image.get_at((0,0))
        self.image.set_colorkey(ckey, RLEACCEL)
        w = self.image.get_width()
        h = self.image.get_height()
        self.rect = Rect(208, 592, w, h)
        self.speed = [0, 0]
    def update(self):
        self.rect.move_ip(self.speed)
        self.rect = self.rect.clamp(screen_rect)
    def draw(self):
        screen.blit(self.image, self.rect)

def set_block(blocks):
    for y in range(0, 8):
        for x in range(0, 10):
            if y < 2:
                block = Block("block_red.png", x, y)
            elif y < 4:
                block = Block("block_orange.png", x, y)
            elif y < 6:
                block = Block("block_green.png", x, y)
            elif y < 8:
                block = Block("block_yellow.png", x, y)
            blocks.add(block)

if __name__ == '__main__':
    pygame.mixer.pre_init(frequency = 44100, size = -16, channels = 2, buffer = 1024)
    pygame.init()
    screen = pygame.display.set_mode(screen_rect.size)
    #bg = pygame.image.load("breakout_bg.png").convert()
    pygame.event.set_allowed([QUIT, KEYDOWN, KEYUP])
    font = pygame.font.Font(None, 40)
    ball = Ball("ball.png")
    paddle = Paddle("paddle.png")
    sound01 = pygame.mixer.Sound('se01.wav')
    sound02 = pygame.mixer.Sound('se02.wav')
    blocks = pygame.sprite.RenderUpdates()
    set_block(blocks)
    clock = pygame.time.Clock()
    pmove = [0, 0]
    state = 0
    bleft = 3
    tick = pygame.time.get_ticks()
    while True:
        clock.tick(60)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.mixer.quit()
                pygame.quit()
                sys.exit()
            elif event.type == KEYDOWN:
                if event.key == K_LEFT:
                    pmove[0] = -8
                elif event.key == K_RIGHT:
                    pmove[1] = 8
            elif event.type == KEYUP:
                if event.key == K_LEFT:
                    pmove[0] = 0
                elif event.key == K_RIGHT:
                    pmove[1] = 0
        paddle.speed = [pmove[0] + pmove[1], 0]
        screen.fill(black)
        #screen.blit(bg, screen_rect)
        paddle.update()
        if state == 1:
            ball.update()
            if pygame.sprite.spritecollideany(ball, blocks):    # Hit Block?
                sound01.play()
                for block in blocks:
                    if pygame.sprite.collide_rect(ball, block):
                        clip = block.rect.clip(ball.rect)
                        if(clip.height > clip.width):
                            ball.speed[0] = -ball.speed[0]
                        else:
                            ball.speed[1] = -ball.speed[1]
                        blocks.remove(block)
            if pygame.sprite.collide_rect(ball, paddle):        # Hit Paddle?
                sound02.play()
                ball.speed[1] = -ball.speed[1]
                clip = paddle.rect.clip(ball.rect)
                p = clip.left - paddle.rect.left + clip.width / 2
                if p >= 32:
                    p = 63 - p
                vx = flip[0][p]
                if ball.speed[0] < 0:
                    vx = -vx
                ball.speed[0] = vx
                ball.speed[1] = -flip[1][p]
            ball.draw()
        blocks.draw(screen)
        paddle.draw()
        if state == 0:
            text = font.render('Ready!', True, (255, 255, 255))
            screen.blit(text, (190, 300))
            if tick + 2000 < pygame.time.get_ticks():
                state = 1
        elif state == 1:
            if ball.rect.bottom >= screen_rect.height:
                ball.reset()
                bleft = bleft - 1
                if bleft > 0:
                    state = 2
                else:
                    state = 3
                tick = pygame.time.get_ticks()
        elif state == 2:
            msg = "Ball Left : " + str(bleft)
            text = font.render(msg, True, (255, 255, 255))
            screen.blit(text, (175, 300))
            if tick + 2000 < pygame.time.get_ticks():
                state = 1
        elif state == 3:
            text = font.render('GAME OVER', True, (255, 255, 255))
            screen.blit(text, (160, 300))
            if tick + 2000 < pygame.time.get_ticks():
                pygame.mixer.quit()
                pygame.quit()
                sys.exit()
        if not blocks and ball.rect.top > 320:
            set_block(blocks)   # All Blocks Clear
        pygame.display.update()

動かす為には画像ファイルとサウンドファイルも必要なので、ソースコードと必要なファイルはまとめてGoogle Driveへ置いた。ここにアクセスして、画面左上の「ファイル」をクリックしてメニューを開き、「ダウンロード」を選択。

GoogleDrive

解凍してRaspberry Piにすべてのファイルを転送したら

$ python Breakout.py

で起動する。
操作はカーソルキーの左右のみ。
Breakout_cap

PyGameでゲームを作るのは初めてなので、あちこち無駄があったり良くない使い方があるような気がするが、大目に見て欲しい。Python2.7とPyGameがインストールされていれば、Windowsでも動作するはず(MacOSは未確認)。
最初は背景画像も表示するようにしていたのだが、ボールが見えにくくなるだけだったのでコメントアウトして外してある。良さそうな画像があったら差し替えてみても面白いかも。ブロックとボール、パドルも単なる画像なので差し替え可能。

次はこれを元に、ロータリーエンコーダーでパドルを操作できるバージョンを作ってみる予定。

PyGameでサウンド再生

RaspberryPiでPythonを使ってサウンドを再生してみようと思い、調べてみるとPyGameに含まれるサウンド機能を使うのが簡単そうだったので試してみたのだが、意外と手間取ってしまった。

テスト用にフリーの音源サイトからダウンロードしたwavファイルを、Raspbianの最新版にはデフォルトで入っているaplayで再生してみると、特に問題無く再生される。この時にwavファイルのビットやレートも表示されるので、それに合わせてテストプログラムを作ってみた。

aplay

import pygame
from pygame.locals import *

pygame.mixer.init(frequency = 22050, size = 8, channels = 1, buffer = 1024)
sound = pygame.mixer.Sound("se.wav")
sound.play()
try:
    while True:
        pass
except KeyboardInterrupt:
    pygame.mixer.quit()

ところがこれでは全く再生されない。音声はHDMI経由でモニタのスピーカーから出るようにしてあって、ボリュームを最大に上げると一瞬ノイズのような音が聞こえるのだが、それっきり。

色々と試してみると、再生しようとするwavファイルとは異なる周波数に初期化すると再生される事が分かったのだが、なぜそうなるのかは不明。以下のプログラムなら正しく再生される。

import pygame
from pygame.locals import *

pygame.mixer.init(frequency = 44100, size = -16, channels = 2, buffer = 1024)
sound = pygame.mixer.Sound("se.wav")
sound.play()
try:
    while True:
        pass
except KeyboardInterrupt:
    pygame.mixer.quit()

PyGameのサウンド再生はwavファイルだけではなく、mp3ファイルをエンドレスで再生する機能もあって、wavファイルと同時に再生できるのでその機能を使ってこんなプログラムを作ってみた。デフォルトで入っているmp3を再生しながら、キーボードの1~4を押した時に対応するwavを再生するだけ。PyGameでウィンドウを作り、そこに文字を表示させるのも一緒に試してみた。

#!/usr/bin/env python

import sys, pygame
from pygame.locals import *

if __name__ == "__main__":
    pygame.init()
    pygame.mixer.quit()
    pygame.mixer.init(frequency = 48000, size = -16, channels = 2, buffer = 1024)
    pygame.mixer.music.load("/usr/share/scratch/Media/Sounds/Music Loops/Xylo1.mp3")
    p1 = pygame.mixer.Sound("/usr/share/scratch/Media/Sounds/Percussion/Gong.wav")
    p2 = pygame.mixer.Sound("/usr/share/scratch/Media/Sounds/Percussion/HandClap.wav")
    p3 = pygame.mixer.Sound("/usr/share/scratch/Media/Sounds/Percussion/CymbalCrash.wav")
    p4 = pygame.mixer.Sound("/usr/share/scratch/Media/Sounds/Percussion/DrumBuzz.wav")
    size = width, height = 200, 200
    screen = pygame.display.set_mode(size)
    font = pygame.font.Font(None, 40)
    text = font.render('Sound Test', True, (255, 255, 255))
    screen.blit(text, (20, 80))
    pygame.display.update()
    pygame.event.set_allowed([QUIT, KEYDOWN])
    pygame.mixer.music.play(-1)
    try:
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.mixer.quit()
                    pygame.quit()
                    sys.exit()
                if event.type == KEYDOWN:
                    if event.key == K_1:
                        p1.stop()
                        p1.play()
                    elif event.key == K_2:
                        p2.stop()
                        p2.play()
                    elif event.key == K_3:
                        p3.stop()
                        p3.play()
                    elif event.key == K_4:
                        p4.stop()
                        p4.play()
    except KeyboardInterrupt:
        pygame.mixer.quit()
        pygame.quit
        sys.exit()

デスクトップ画面にウィンドウを表示するので、これはリモートでは実行できない。VNCのリモートデスクトップでなら動かせるが、サウンドがHDMIモニタに出力されるのでVNCでは意味がない。
soundtstPyGameは初めて使ってみたのだが、昔のBASICパソコン時代のようなゲームなら割と簡単に作れそうだ。Pythonがあまり速くないので今風のゲームは無理だろうけど、チュートリアルサイトを見るとスーパーファミコン時代のゲームなら作成可能という説明があって、確かにそんな感じがする。

time.clock()

前回試したロータリーエンコーダのプログラム、起動してしばらくはちゃんと動くのだが、30分ぐらい放置すると動かなくなってしまう事が分かった。

ソースコードを机上で確認すると、チャタリング除去に使った time.clock() の戻り値が増加せずに減少していたとすれば、発生している症状と合致する。最初はタイマの値がオーバーフローを起こしているのでは?と思ったのだが、その場合は50日間近く継続して動かさないと発生しないはずで、30分ぐらいでオーバーフローするはずがない。

どうも分からないので、以下のようなテストプログラムを組んで走らせてみた。time.clock()がオーバーフローを起こしているなら、戻り値がマイナスになってプログラムが停止するはず。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import time

if __name__ == "__main__":
    print time.ctime()
    try:
        count = 1000
        while True:
            tc = time.clock()
            tt = time.time()
            if(tc < 0):
                print tc, tt
                print time.ctime()
                break
            count = count - 1
            if count <= 0:
                print tc, tt
                count = 1000
            for i in range(1, 10000):
                pass
    except KeyboardInterrupt:
        print '\nbreak'

結果はこうなった。驚いた事に、本当にtime.clock()がマイナスの値を返している。エポック秒を返すtime.time()は正しい値を返しているので、time.clock()だけおかしい理由が分からない。

$ python TimeTest.py
Sat Mar 16 17:38:15 2013
10.28 1363423105.5
20.35 1363423115.61
30.42 1363423125.71
40.49 1363423135.83
50.55 1363423145.93
60.63 1363423156.05
70.69 1363423166.15
80.76 1363423176.26
90.83 1363423186.37
100.9 1363423196.47

中略

2053.65 1363425156.1
2063.71 1363425166.19
2073.77 1363425176.29
2083.83 1363425186.38
2093.89 1363425196.47
2103.95 1363425206.56
2114.01 1363425216.65
2124.07 1363425226.75
2134.13 1363425236.84
2144.2 1363425246.94
-2147.477296 1363425250.24
Sat Mar 16 18:14:10 2013

マイナスになった瞬間の値は -2147.477296 で、これは32bit整数型の最大値 2147483647 と似た値なのが非常に気になる。time.clock()はプロセッサクロックタイムを返す関数なので、Pythonの問題ではなくRaspberry Piのハードウェアに特有の問題という気がする。

それとも何か根本的に勘違いしているのだろうか?

ロータリーエンコーダ

RPi.GPIOを使った外部機器からの入力。押しボタンスイッチの他に何か無いか?と思って探すとArduinoで使ったロータリーエンコーダが出てきたので、これを試してみる。
秋月で取り扱っている2相出力のエンコーダで、ツマミを回すとA相、B相の各端子からパルスが出力される。パルスはA相とB相でずれて出力されるようになっていて、回転方向によってずれかたが異なるから、それでどちらに回されたのかを知ることができる。

このロータリーエンコーダはクリックのある場所で停止するようになっていて、停止位置では端子がどこにも接続されていない開放状態となるので、抵抗でプルアップしておく必要がある。ブレッドボードで使い勝手が良いように、小型ユニバーサル基板を使ってプルアップ抵抗を予め付けておいた。

R0011719回転方向の検出はA相、B相の状態にそれぞれ番号を付けておき、状態が変化した時に新しい番号と以前の番号を比較して、増加していれば右回転(C.W)、減少していれば左回転(C.C.W)と判断する。この時、3→0と0→3は別にチェックしておく。

rotary_chart

プログラムは以下の通り。
プッシュスイッチの時と同じように変化点からのディレイでチャタリングを除去しているが、ディレイを長くするとツマミを高速で回した時に取りこぼしが発生してしまうので、時間を短くしてある。
プッシュスイッチよりはチャタリングの発生時間が短いらしく、これでも問題はなかった。ちなみに時間を計る為に使用している time.clock() はPythonのバージョン3.3で取り除かれてしまったので、3.3で試す時は別の時間計測関数を使う必要がある。 ※

※2013/03/16追記 : time.clock()が正しい値を返さない場合がある(原因不明)事が分かったので、time.time()に変更

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import RPi.GPIO as GPIO
import time

class pin:
    def __init__(self, port):
        self.port = port
        self.dt = time.time()
        GPIO.setup(self.port, GPIO.IN, GPIO.PUD_OFF)
        self.stat = GPIO.input(self.port)
    def get(self):
        stat = GPIO.input(self.port)
        dt = time.time()
        if stat != self.stat and dt > self.dt + 0.01:
            self.stat = stat
            self.dt = dt
        return self.stat        

class rotary:
    def __init__(self, port_a, port_b, val, lo, hi):
        self.ports = [pin(port_a), pin(port_b)]
        self.val = val
        self.lo = lo
        self.hi = hi
        self.stat = 0
        self.sa = self.ports[0].get()
        self.sb = self.ports[1].get()
    def get(self):
        sa = self.ports[0].get()
        sb = self.ports[1].get()
        if sa != self.sa or sb != self.sb:
            self.sa = sa
            self.sb = sb
            if sa == GPIO.HIGH:
                if sb == GPIO.HIGH:
                    stat = 0    # H/H
                else:
                    stat = 3    # H/L
            else:
                if sb == GPIO.HIGH:
                    stat = 1    # L/H
                else:
                    stat = 2    # L/L
            if stat - self.stat == 1 or self.stat - stat == 3:    # RIGHT
                if self.val < self.hi:
                    self.val = self.val + 1
            elif self.stat - stat == 1 or stat - self.stat == 3:  # LEFT
                if self.val > self.lo:
                    self.val = self.val - 1
            self.stat = stat
        return self.val

if __name__ == "__main__":
    GPIO.setmode(GPIO.BCM)
    val = 25
    re = rotary(24, 25, val, 0, 50)
    try:
        while True:
            v = re.get()
            if(val != v):
                print '%02d' % v,'-' * (v - 1),'*','-' * (51 - v)
                val = v
    except KeyboardInterrupt:
        print '\nbreak'
        GPIO.cleanup()

ルート権限でプログラムを実行し、ロータリーエンコーダのつまみを左右に動かすと。以下のように表示されるはず。
rotary_out

チャタリング除去時間を短くしても、高速でつまみを回すと取りこぼしが発生する場合があったので、Pythonではこれ以上は難しいかも知れない。C言語を使って直接GPIOにアクセスすればもっと確実にデータを取得できるが、高速で回した時に1パルス分の取りこぼしも許されない用途というのはあまり考えられないし、その場合は別な手段を考えるべきだろう。

チャタリング除去

RPi.GPIOで add_event_detect を使うと入力ポートの変化点を検出できるので便利なのだが、接点式スイッチは押された瞬間に細かくOn/Offが変化するチャタリング現象が発生するため、それもすべて検出されてしまい都合が悪い。

チャタリング除去は抵抗やコンデンサを使って回路的に行う方法と、ソフトウェア的に行う方法があって、ここではソフトウェア的に除去する方法を試してみる。

プログラムは以下の通り

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import RPi.GPIO as GPIO
import time

class button:
    def __init__(self, port, stat = GPIO.LOW, pud = GPIO.PUD_OFF):
        self.port = port
        self.stat = stat
        self.detect = False
        self.dt = time.time()
        GPIO.setup(self.port, GPIO.IN, pud)
    def get(self):
        stat = GPIO.input(self.port)
        dt = time.time()
        if stat != self.stat and dt > self.dt + 0.02:
            self.stat = stat
            self.dt = dt
            self.detect = True
        return self.stat        
    def check(self):
        stat = self.get()
        detect = self.detect
        self.detect = False
        return stat,detect

if __name__ == "__main__":
    GPIO.setmode(GPIO.BCM)
    btns = [button(17, GPIO.LOW, GPIO.PUD_DOWN),
            button(22, GPIO.LOW, GPIO.PUD_DOWN)]
    try:
        while True:
            for btn in btns:
                stat,detect = btn.check()
                if(detect):
                    if(stat == GPIO.LOW):
                        print btn.port,'FALLING'
                    else:
                        print btn.port,'RISING'
    except KeyboardInterrupt:
        print '\nbreak'
        GPIO.cleanup()

除去する方法は色々と考えられるが、最初の変化点を検出してから一定時間は変化をチェックしない単純な方法でやってみた。時間の経過はCPUクロックタイムをカウントする time.clock() を使用。 ※
どのぐらい待てば確実にチャタリング除去できるのかは良く分からなかったので、実測して値を設定したが、場合によっては調整する必要があるかも知れない。

※2013/03/16追記 : time.clock()が正しい値を返さない場合がある(原因不明)事が分かったので、time.time()に変更

GPIOコールバック処理

RPi.GPIOはルート権限が必要なため、CGIで使うには不便なのでWiringPiを試してみたが、それ以外の用途でならapt-getで簡単にインストールできるし、有用性は高い。

現在進行形で開発中のパッケージなので、バージョンが上がると関数が追加されたり無くなったりと変化が大きく注意が必要ではあるが、Raspberry PiでGPIO入出力を行う場合はこちらが標準になるのだろう。

ただし、RPi.GPIOのプロジェクトページにも書いてある通りPythonはガベージコレクションが発生した場合に遅延が生じる可能性があり、そもそもリアルタイム性をあまり考慮しないLinuxカーネル下で動作するので、クリチカルなタイミングを必要とする処理には向かないことを覚えておく必要がある。
そういう用途にはArduinoの出番だ。

RPi.GPIOの最新バージョンは0.5.0aで、入力ポートの変化によるコールバック処理が追加されている。これは入力ポートが変化した時に定義したコールバック関数を呼び出す機能で、別スレッドで処理されるから用途によっては便利。

GPIOポート17(11番ピン)に押しボタンスイッチをつなぎ、押した時に+3.3Vに通電するようにする。間違えて+5Vに通電してしまうとRaspberry Piが壊れる可能性があるので注意。以前スイッチをつないだときはプルダウン抵抗で押していない時はGNDになるようにしたが、今回は内蔵のプルアップ/プルダウン機能を試すので何もつながない。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import RPi.GPIO as GPIO
import time

def event_callback1():
    print 'event_callback1'

if __name__ == "__main__":
    print 'GPIO Version',GPIO.VERSION
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) # GPIO17 Pin11
    GPIO.add_event_detect(17, GPIO.RISING)
    GPIO.add_event_callback(17, event_callback1)
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print '\nbreak'
        GPIO.cleanup()

プログラムを走らせてボタンを押すと、押した時だけコールバック関数が呼び出される。押した時ではなく離した時に呼び出したい場合は、GPIO.add_event_detectでGPIO.FALLING、両方で呼び出したい場合はGPIO.BOTHを指定すれば良い。

ここまでは良いのだが、スイッチを増やしてそれぞれにコールバック処理を行いたい場合は上手く行かない。以下のようにGPIO22にスイッチを追加して同じようにコールバック関数を定義しても、全く呼び出されないのだ。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import RPi.GPIO as GPIO
import time

def event_callback1():
    print 'event_callback1'
def event_callback2():
    print 'event_callback2'

if __name__ == "__main__":
    print 'GPIO Version',GPIO.VERSION
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) # GPIO17 Pin11
    GPIO.setup(22, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) # GPIO22 Pin15
    GPIO.add_event_detect(17, GPIO.RISING)
    GPIO.add_event_detect(22, GPIO.RISING)
    GPIO.add_event_callback(17, event_callback1)
    GPIO.add_event_callback(22, event_callback2)
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print '\nbreak'
        GPIO.cleanup()

どうやらRPi.GPIOのコールバック処理用スレッドは、ひとつのポートに対してだけしか有効ではないらしい。その代わりGPIO.add_event_callbackを複数回呼び出してひとつのポートに対して複数のコールバック処理を定義することはできる。
でもこれはあまり意味がないような気がする。ひとつにまとめてしまえば良いわけだし。何か意味があるのだろうか?

リモート機器制御

WiringPiを使ってCGI経由のGPIOポート制御はできたので、これを以前に試したMotionアプリによるWebカメラ配信と組み合わせれば、遠隔地の機器をブラウザでOn/Offさせて、その結果をリアルタイムにブラウザで確認する事ができる。

実はこれがやりたくてWebカメラやCGIの実験をあれこれと行ってきたのだ。

WebカメラWiringPi(2) CGI編で書いた内容に従って、ブラウザでWebカメラの画像の表示と、CGIでLEDのOn/Offが行える状態にようになっていれば、あとはそれを組み合わせれば良いのでそれほど面倒ではない。
HTMLファイルにWebカメラの動画表示と、その下にインラインフレームでLED制御のCGIを実行できるように記述する。

<html>
<head>
<style type="text/css">
    body { 
    text-align:center;
    background-color:#ffffff;
    }
    .container {
    width:100%;
    }
    .motion {
    border: 0;
    width: auto;
    height: auto;
    }
    .clear {
    clear:both;
    }
</style>
</head>
<body>
<h1>WebCam View</h1>
<a href="http://192.168.1.9:8081"><img class="motion" src="http://192.168.1.9:8081" /></a>
<iframe src="/cgi-bin/wiringcgi.py" name="cgiframe" width="100%" frameborder="0"></ iframe> ※
</body>
</html>

(※blogの制限でiframeタグを記述すると消されてしまうので、閉じるタグに余分なスペースを入れてある)

このHTMLファイルをcgi-binフォルダの上の階層にあるフォルダに保存したら

$ sudo mkdir -p /var/run/motion
$ sudo motion
$ gpio export 23 out
$ gpio export 24 out
$ gpio export 25 out
$ python -m CGIHTTPServer

でMotionとCGIサーバを起動。

ブラウザから”[Raspberry PiのIPアドレス]:8000/motion.html”にアクセスすれば、以下のようにWebカメラの画像とLEDのOn/Offボタンが表示されるはずだ。

webled

ブラウザをクライアントとしてLEDをOn/Offさせるだけなら、Arduino+ネットワークシールドでも可能だが、Webカメラ配信まで自力でやろうと思ったらかなり困難だろう。
もちろんArduinoにはArduinoの良さがあるし、部品を買ってきて自分で安価に作れてしまうのは非常に大きなメリットなので、用は適材適所。

WiringPi(2) CGI編

PythonのGPIOモジュールはルート権限でしか扱えず、Pythonの内蔵CGIサーバ機能はルート権限でスクリプトを実行できないので困ってしまい、ユーザ権限でもGPIOにアクセスできるWiringPiを試してみた。

事前にポートをexportしておく必要はあるが、これならCGIでも使えそうなことが分かった。と言うことで次は実際にRaspberry PiのGPIOに接続したLEDをOn/OffするCGIを作ってみる。

LED一個ではあまり面白くないので、今回はLEDを三個に増やしてGPIOの23~25に接続。ピンヘッダの番号で言うと16、18、22番になる。ピン番号の対応はこのページにある表が分かりやすい。コピーして保存しておくと良いだろう。

ブレッドボード上の配線はこんな感じ。スイッチは今回使わない。

ledbread

LEDのマイナス極(カソード)を電流制限抵抗(470Ω)経由でRaspberry Piのポートに接続してあるので、ポートに0を出力すると点灯、1を出力すると消灯する。一般的なOn/Offのイメージとは逆になるが、ピンに出力できる電流には制限があるので吸い込み電流で点灯させたほうが安全。

LED三個ぐらいなら吐き出し電流でも問題にならないが、負荷が大きくなると出力電圧が下がってしまう場合があるからだ。

WiringPiを使ったCGIプログラムは以下の通り

#!/usr/bin/env python
# -*- coding:utf-8 -*-

# Prepare:
#   gpio export 23 out
#   gpio export 24 out
#   gpio export 25 out

import cgi
import wiringpi

def imgbtn(no, onoff):
    img = ['../swon.jpg', '../swoff.jpg']
    name = ['L1', 'L2', 'L3']
    sw = ['ON', 'OFF']
    print '<input type="image" src="'+img[onoff]+'"',
    print 'style="border:none; background-color: #ffffff"', 
    print 'name="'+name[no]+sw[onoff]+'"',
    print 'value="submit" />'

if __name__ == '__main__':
    io = wiringpi.GPIO(wiringpi.GPIO.WPI_MODE_SYS)
    pins = [23, 24, 25]
    name = ['L1OFF', 'L2OFF', 'L3OFF', 'L1ON', 'L2ON', 'L3ON']
    hilo = [io.LOW, io.HIGH]    # HIGH:off,LOW:on
    [io.pinMode(pin,io.OUTPUT) for pin in pins]
    form = cgi.FieldStorage()
    for i in range(6):
        if form.getfirst(name[i]):
            no = i % 3
            onoff = i / 3
            io.digitalWrite(pins[no], hilo[onoff])
            break
    # html
    print \
'''Content-type: text/html; charset=UTF-8\n
<html>
<head>
<title>CGI Test</title>
</head>
<body bgcolor=#ffffff>
<div style="text-align:center">
<h1>LED Remote On/Off</h1>
<form action="wiringcgi.py" method="post">
''',
    for i in range(3):
        imgbtn(i, io.digitalRead(pins[i]))
    print \
'''</form>
</div>
</body>
</html>
'''

せっかくなのでフリー素材で適当なボタン画像を探してきて使った。
swon swoff

この画像ファイルとスクリプトを、以下のように配置する

.
├── cgi-bin
│   └── wiringcgi.py
├── swoff.jpg
└── swon.jpg

画像ファイルもスクリプトと同じ場所に置きたかったのだが、Pythonの内蔵CGIサーバはcgi-binフォルダにPythonスクリプト以外は置くことができないので、上の階層に置いた。

スクリプトファイル wiringcgi.py には、chmodコマンドで実行属性を付けておく。

$ chmod 755 wiringcgi.py

画像ファイルを置いたフォルダで、以下のようにポートのexportとCGIサーバの起動を行う

$ gpio export 23 out
$ gpio export 24 out
$ gpio export 25 out
$ python -m CGIHTTPServer

これで準備完了。ブラウザからRaspberry PiのIPアドレスと、スクリプトファイル名を http://192.168.1.9:8000/cgi-bin/wiringcgi.py と直接指定してアクセスすれば、以下のように表示されるはず。

ledremote

ボタンをクリックするとLEDがOn/Offされて、同時にボタン画像が切り替わればOK。

CGIスクリプトはプログラムがHTMLファイルを動的に生成する形になるので少しややこしいが、最終的にクライアントへ送信されるHTMLファイルは以下のような形になる。

<html>
<head>
<title>CGI Test</title>
</head>
<body bgcolor=#ffffff>
<div style="text-align:center">
<h1>LED Remote On/Off</h1>
<form action="wiringcgi.py" method="post">
<input type="image" src="../swoff.jpg" style="border:none; background-color: #ffffff" name="L1OFF" value="submit" />
<input type="image" src="../swoff.jpg" style="border:none; background-color: #ffffff" name="L2OFF" value="submit" />
<input type="image" src="../swoff.jpg" style="border:none; background-color: #ffffff" name="L3OFF" value="submit" />
</form>
</div>
</body>
</html>

スクリプトは出力ポートの現在値を読み取って、対応するボタンを持つHTMLファイルを生成。ブラウザで画面上のボタンがクリックされると、その情報をパラメータとして同じスクリプトが再度実行される。スクリプトはパラメータに従ってLEDのOn/Offを行い、またHTMLファイルを生成。
という手順で処理が続いていく事になる。

LEDが制御できれば、これを赤外線リモコンに替えてエアコンのリモート制御等も、理論的には可能になるはずだ。実際にやるとしたら他にも色々と考慮する必要があるが、何もない所から始めるよりもとりあえず動かせる状態にして、そこから考えるほうが近道。

WiringPi

RaspberryPiでPythonを使ってGPIOの入出力を行う場合、GPIOモジュールをimportすれば手軽に使うことができるのだが、GIPOモジュールが実際にアクセスする /dev/mem デバイスはルート権限でしか開くことができない。

そのため、GPIOモジュールをimportするプログラムはルート権限で実行しないと、以下のようなエラーが出てしまう。

RPi.GPIO.SetupException: No access to /dev/mem.  Try running as root!

その場合はpythonを起動する時にsudoを付けて実行すれば良いのだが、CGIとしてPythonスクリプトを実行しようと思った場合は、ちょっと困ったことになる。CGIとして実行されるスクリプトは、デフォルトではルート権限にならないのだ。

ルート権限としてCGIを実行する方法はいくつかあるが、それを試す前にルート権限無しでGPIOを使用する方法を探してみると、WiringPiモジュールが見つかった。

apt-getではインストールできないようなので、Raspberry Piの作業用フォルダに移動してから、このページに従って以下のようにインストールする

$ sudo apt-get install python-dev
$ git clone https://github.com/WiringPi/WiringPi-Python.git
$ cd WiringPi-Python
$ git submodule update –init
$ sudo python setup.py install

インストールにはpython-setuptoolsが必要なので、最後のインストールでエラーが出た場合は以下のようにpython-setuptoolsを入れてから再度実行

$ sudo apt-get install python-setuptools

これでルート権限無しでGPIOが使えるはずなのだが、サイトのサンプルを参考にして作った以下のプログラムを実行しても、LEDが全く点滅しない。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import wiringpi
import time

if __name__ == '__main__':
    io = wiringpi.GPIO(wiringpi.GPIO.WPI_MODE_SYS)
    io.pinMode(23,io.OUTPUT)
    io.digitalWrite(23,io.HIGH)
    while True:
        io.digitalWrite(23,io.LOW)  # on
        time.sleep(1)
        io.digitalWrite(23,io.HIGH) # off
        time.sleep(1)

LEDはGPIO4に470Ωの電流制限抵抗を介して、以下のように接続してある。GPIOのピン番号はちょっと分かり難くて、GPIO4は実際にはピンヘッダの16番になる。
しかもWiringPiはBCMチップの番号でアクセスするので、プログラム上は23番になってしまう。このへんはもう少しなんとかして欲しかった。

gpioled

プログラムが動かなかった理由は単純で、 WiringPiはルート権限無しでGPIOを使用する場合は事前にツールを使ってポートの設定を行っておかなければならなかったのだ。WiringPiと一緒にインストールされているgpioコマンドを使って、端末から以下のように設定する

$ gpio export 23 out
$ gpio exports
GPIO Pins exported:
23: out  0  none

この設定を行ってからプログラムを再度実行すると、今度はLEDの点滅が始まる。
いちいちポートの設定を行わないと使えないのは不便なので、WiringPiでは初期化の時に

wiringpi.GPIO(wiringpi.GPIO.WPI_MODE_SYS)

ではなく

wiringpi.GPIO(wiringpi.GPIO.WPI_MODE_GPIO)

と指定する事でexportを行わなくてもGPIOを使用できるようになる。ただし、この場合は内部的にGPIOモジュールと同じ事を行っているらしく、ルート権限が必要になってしまう。

つまり、wiringpi.GPIO.WPI_MODE_SYS で初期化した場合はルート権限が不要だが、プログラムの実行前に設定が必要になる。wiringpi.GPIO.WPI_MODE_GPIO で初期化した場合は実行前の設定は不要だが、ルート権限が必要になる。

という事のようだ。ちょっとややこしいので、このあたりも将来的に整理される事を期待したい。