Raspberry Piオーディオ:音を鳴らしているか検出したい

今更ながら最近、Raspberry Piを触っていました。

Raspberry Pi zero whに、pHatボードのDACを取り付けて、アンプと繋げて音楽を楽しむということをしていました。

MPDで音楽を再生したりとか、Airplayレシーバーにしたりとか、色々試していたのですが一点苦労したとことが・・。それは「音を出しているときはアンプをオンにしたい、音が出なくなったらアンプをオフにしたい。自動で」ということでした。

アンプをRaspberry Piから操作する方法は、赤外線を使うなりRS232ポートやLAN経由で操作するなり、アンプに応じて色々方法はあると思うので割愛しますが、問題は「音が出ているか判定する部分」。もちろん音を出す方法が一種類ならば対応は簡単そうですが、いろいろなソースから音を出すことを考えると、できるだけ根っこの部分で判定をしたいです。

それで悪戦苦闘の末いきついたのが、「DACボードと通信しているGPIOの生データを読んで判定する」という方法です。あまりいないような気はするのですが、もしかしたら同じようなことをしたいと思っているかたがいるかもしれないので、方法について、記載してみます。

基本的な考え方は、DACボードに信号を送って音を出しているので、音がでていないときは信号のやりとりがない=GPIOのピンの各電圧は一定、音を出しているときは信号のやりとりがあるのでGPIOのピンの電圧に変化があるはず、、です。

まずは、音を出しているときに変化があるGPIOのピンを見つける必要があります。

コマンドラインで、

gpio readall

とうつことで、各ピンので夏をみることができますので、音を出している状態で何度もコマンドを打って変化しているピンを見つけます(原始的)

そのうえで、そのピンの電圧を監視するプログラムを書きます。

C言語でメモリをマッピングして読み込む形になります。

まずは、GPIOの値が、物理メモリ空間のどこにマッピングされているか仕様書をみて調べます。

Raspberry Pi hardware - Raspberry Pi Documentation

から、Raspberry pi zeroならばBCM2835みたいなので、BCM2835のPeripheral specificationをみてみます。

この仕様書を読み解くのに少し苦労したのですが、まずは90ページ目あたりの表をみると

0x7E200034 GPLEV0 GPIO Pin Level

0x7E200038 GPLEV1 GPIO Pin Level

このあたりをみれば良さそうです。96ページの説明を見ると各GPIOのピンの電圧がビットごとに入っているみたいですね。

で、問題は0x7E200034って何と言うところなのですが・・・。

ここで仕様書のページ6 BCM2835 ARM Periphreをみてみます。

この、0x7E200034というようなアドレスはこの表の

VC CPU Bus Addressにあたるようです。

0x7E000000からがARM Physical Addressの0x20000000から割り振られるようです。

つまり、0x7E200034は、ARM Physical Addressの0x20200034にあたるようです。

ここからはC言語的な話ですが、ARM Physical Addresは直接アクセスできず、mmapマッピングしてあげる必要があります。

以下、サンプルプログラムになります。GPIOの21番のピンを監視しています。無音状態から音がなったことを検知すると、/home/pi/on.shを実行します。また、30秒以上無音(ピンに変化なし)が続くと、/home/pi/off.shを実行します。

注意ですが、このプログラムを実行するにはルート権限が必要です。起動時にかならず実行されるようにしておくことで、やりたかったことが実現できるようになりました。

#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>

#define DEVMEM "/dev/mem"

int main() {
  int count;
  int fd;
  volatile unsigned char *iomap;
  fd = open(DEVMEM, O_RDONLY);
  if( fd < 0 )
  {
     printf("Failed Open Mem\n");
     exit(1);
  }
  iomap = mmap(0, 0xb0, PROT_READ, MAP_SHARED, fd, 0x20200000);
  if(iomap == MAP_FAILED) {
    printf("mmap failed\n");
    exit(1);
  }  

  int state = -1;
  int seqcount = 0;
  while(1)
  {
     int bit = 1 & ((*(unsigned int*)(iomap + 0x34)) >> 21);
     if( bit )
     {
       if( state != 1 )
       {
          system("sh /home/pi/on.sh");
       }
       state = 1;
       seqcount = 0;
     }
     else
     {
        seqcount++;
        if( seqcount >= 100 * 30 && state != 0) // 30s
        {
          system("sh /home/pi/off.sh");
          state = 0;
        }
     }
     usleep(10000);
  }
}    

続・SONY BRAVIAをコマンドラインで制御したい

以前、Android TVになる前のBRAVIAコマンドラインでコントロールした記事を書きました。

SONY BRAVIAをコマンドラインから制御したい - shimobepapaの日記

その後、予期せぬ事態(液晶の物理破損)が発生して、テレビ購入後4年未満で新型のBRAVIAに買い換えることになってしまいました。

ということで、Android TVである最新のBRAVIAで以前書いた方法が有効かどうか調べてみました。以下、65x9000fでの検証結果です。

なお、前提として一度「Video & TV SideView」のリモコン操作でBRAVIAを操作しないとBRAVIA側の設定が正しくされていない可能性があります。初めてアプリで電源を入れる操作をしたときに、「BRAVIA側でアプリから電源を入れることができるように設定しますか?」というようなことをきかれたことがありました。

もし、下に書いた方法でうまくいかない場合、「Video & TV SideView」のリモコン機能を試していただくとうまくいく可能性があると思います。

 

まず、結論から言うと、「だいたいプロトコルは一緒だけど、以前書いた方法だと微妙に動かない」という感じでした。

ダメだった点は以下の二つ

  • Wake on LANがきかない
  • curlコマンドでxmlをPOSTする場合、書いた方法ではエラーになる

 

このうちWake on LANについては、「リモコンでできる操作」で電源オンの信号を送ることで対応できます。

問題は2つめの方なのですが、おそらくBRAVIA側で使用しているライブラリが変わった関係でエラーチェックが厳しくなったためと思われます。

具体的には、Headerに

soapaction:"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"

がないといけないようです。

そのため、リモコンのコマンドを送るcurlコマンドは以下の通りになります。

curl -d '<?xml version=\"1.0\"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:X_SendIRCC xmlns:u="urn:schemas-sony-com:service:IRCC:1"><IRCCCode>(コマンド)</IRCCCode></u:X_SendIRCC></s:Body></s:Envelope>' -b cookie.txt -H 'soapaction:"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"' http://(BRAVIAIPアドレス)/sony/IRCC

こうすることで、無事動作ができました。

また、電源オンのコマンドは、

AAAAAQAAAAEAAAAuAw==

になります。

 

その他、認証まわりの部分は以前の記事と同じ方法でいけましたので、以前の記事とあわせて参照ください。

MacからWindows Phoneのテザリングをオンにする

Windows 10 Mobileには、MS-TCCという、bluetoothでリモートからテザリングWindows phone用語的にはホットスポット)をオンにする機能があります。Windows 10 Mobileのスマホをカバンの中にいれたまま、ノートPCからスマホテザリングをオンにできて便利、という機能です。
ただこちら、基本的にはWindows 10のPCからでないと使えないのですが、MS-TCCプロトコルは公開されています。
そこで、Mac OSから、Windows 10 Mobileのテザリングをオンにするツールを自作してみました。
Macbook + Windows phoneなんて組み合わせで使っている人いない気はしますが・・)
以下のリンクからDLできます。
MS-TCC for Mac

上記appを起動するとステータスバーに「H」というマークが追加されます。すでにテザリング済みのWindows 10 mobile端末があれば、Hをクリックすると表示されます。
f:id:shimobepapa:20170124002658p:plain
テザリングをオンにしたい端末を選択することで、テザリングがオンになります。
(処理の結果は、メッセージボックスで表示されます)

ソースコードについて

このツールですが、swift初心者がswiftを勉強しながら書いたのですが、Mac osでswiftでBluetoothをさわっているコードの例が少なく苦労しました。参考に、UI部分以外のソースコードはりつけておきます。

import Foundation
import IOBluetooth

struct MSTCCResult{
    var targetdevice: IOBluetoothDevice? = nil
    var iserror: Bool = false
    var errorMessage: String? = nil
    
    // 成功した時のssidの情報
    var ssid: String? = nil
    var passcode: String? = nil
}

class MSTCCManager: NSObject, IOBluetoothRFCOMMChannelDelegate, IOBluetoothDeviceAsyncCallbacks
{
    var channel: IOBluetoothRFCOMMChannel?
    var isopen: Bool
    var writebuffer: NSMutableData
    var callback: ((MSTCCResult) -> Void)?
    var targetdevice: IOBluetoothDevice?
    
    override init()
    {
        self.channel = IOBluetoothRFCOMMChannel()
        isopen  = false
        writebuffer = NSMutableData(length: 16)!
        callback = nil
        targetdevice = nil
        super.init()
    }
    
    func listMSTCCDevice() -> Array<IOBluetoothDevice>{
        var ans = Array<IOBluetoothDevice>()
        for i in IOBluetoothDevice.pairedDevices() {
            let device = i as! IOBluetoothDevice
            let uuid : [UInt8] = [0x23, 0x2e, 0x51, 0xd8, 0x91, 0xff, 0x4c, 0x24, 0xac, 0x0f, 0x9e, 0xe0, 0x55, 0xda, 0x30, 0xa5]
            let spduuid = IOBluetoothSDPUUID(bytes: uuid, length: 16)
            
            let sr = device.getServiceRecord(for: spduuid)
            if( sr != nil )
            {
                ans.append(device)
            }
        }
        return ans
    }
    
    func connect(devicename: String, callback: ((MSTCCResult) -> Void)? = nil) -> Bool{
        for i in IOBluetoothDevice.pairedDevices() {
            let device = i as! IOBluetoothDevice
            if (device.name == devicename) {
                return connect(device: device, callback: callback)
            }
        }
        simpleerror("the tarfet device is not paired")
        return false
    }
    
    func connect(device: IOBluetoothDevice, callback: ((MSTCCResult) -> Void)? = nil) -> Bool{
        if( self.isopen )
        {
            simpleerror( "Error: in operating", callback: callback )
            return false;
        }
        let ret1 = device.openConnection()
        if( ret1 == kIOReturnSuccess)
        {
            self.isopen  = true
            self.callback = callback
            self.targetdevice = device
            
            device.performSDPQuery(self)
        }
        else
        {
            simpleerror("open connect failed. code = \(ret1)", callback: callback)
        }
        
        return self.isopen
        
    }
    
    func sdpQueryComplete(_ device: IOBluetoothDevice!,
                          status: IOReturn)
    {
        // SPDの情報更新完了
        if( status != kIOReturnSuccess )
        {
            simpleerror("SPD Query Failed. code = \(status)")
            device.closeConnection()
            self.isopen = false
            return
        }
        
        // MS-TCCのGUID
        let uuid : [UInt8] = [0x23, 0x2e, 0x51, 0xd8, 0x91, 0xff, 0x4c, 0x24, 0xac, 0x0f, 0x9e, 0xe0, 0x55, 0xda, 0x30, 0xa5]
        let spduuid = IOBluetoothSDPUUID(bytes: uuid, length: 16)
        
        // MS-TCCのサービス取得
        let sr = device.getServiceRecord(for: spduuid)
        
        if( sr == nil )
        {
            // 更新したらMS-TCCのサービスが見つからなかった
            simpleerror("This Device doesn't support MS-TCC now.")
            device.closeConnection()
            self.isopen = false
            return
        }
        var rfcommid = BluetoothRFCOMMChannelID()
        sr?.getRFCOMMChannelID(&rfcommid)
        let ret2 = device.openRFCOMMChannelAsync( &channel, withChannelID: rfcommid, delegate: self)
        if( ret2 != kIOReturnSuccess)
        {
            simpleerror("open RFCOMM Channel Error. code = \(ret2)")
            device.closeConnection()
            self.isopen = false
            return
        }
    }
    
    func rfcommChannelData(_ rfcommChannel: IOBluetoothRFCOMMChannel!,
                           data dataPointer: UnsafeMutableRawPointer!,
                           length dataLength: Int)
    {
        let buf = UnsafeBufferPointer(start: dataPointer.assumingMemoryBound(to: UInt8.self), count: dataLength)
        let datas = Array(buf)
        var index = 0
        if( datas[0] == 2 )
        {
            var result = MSTCCResult()
            result.iserror = false
            result.errorMessage = ""
            
            index = 3
            while UInt8(index) < datas[2] {
                
                if( datas[index] == 2 )
                {
                    // SSID
                    result.ssid = getStringFromArray( datas, start: index + 3, length: Int(datas[index + 2]))
                    //                    System.Text.Encoding.ASCII.GetString()
                }
                else if( datas[index] == 4 )
                {
                    result.passcode = getStringFromArray( datas, start: index + 3, length: Int(datas[index + 2]))
                    // passphrase
                }
                index = index + Int(datas[index + 2]) + 3
            }
            
            if( self.callback != nil )
            {
                callback!(result)
            }
            self.isopen = false
        }
        else
        {
            simpleerror("MS-TCC Error!\(datas[0])") // todo
        }
        channel!.close()
        self.targetdevice!.closeConnection()
    }
    
    func rfcommChannelWriteComplete(_ rfcommChannel: IOBluetoothRFCOMMChannel!,
                                    refcon: UnsafeMutableRawPointer!,
                                    status error: IOReturn)
    {
        if( error != kIOReturnSuccess)
        {
            simpleerror("Failed to send data. code = \(error)")
            self.targetdevice!.closeConnection()
            self.isopen = false
            return
        }
    }

    func rfcommChannelOpenComplete(_ rfcommChannel: IOBluetoothRFCOMMChannel!,
                                   status error: IOReturn)
    {
        if( error != kIOReturnSuccess)
        {
            simpleerror("open RFCOMM Channel Error. code = \(error)")
            self.targetdevice!.closeConnection()
            self.isopen = false
            return
        }
        if( channel!.isOpen() )
        {
            let cmdata = Data(bytes: [1,0,0])
            writebuffer.setData(cmdata)
            let ret3 = channel!.writeAsync(writebuffer.mutableBytes, length: 3, refcon : nil)
            if( ret3 != kIOReturnSuccess)
            {
                simpleerror("Failed to send data. code = \(ret3)")
                self.targetdevice!.closeConnection()
                self.isopen = false
                return
            }
        }
        else
        {
            // 多分こないはず
            simpleerror("Channel is not opened")
            self.targetdevice!.closeConnection()
            self.isopen = false
            return
        }
    }
    
    func simpleerror(_ errormessage: String, callback: ((MSTCCResult) -> Void)? = nil )
    {
        if( callback != nil)
        {
            var result = MSTCCResult()
            result.iserror = true
            result.errorMessage = errormessage
            callback!( result )
        }
        else if( self.callback != nil )
        {
            var result = MSTCCResult()
            result.iserror = true
            result.errorMessage = errormessage
            self.callback!( result )
            self.callback = nil
        }
    }
    
    func getStringFromArray(_ datas: Array<UInt8>, start: Int, length: Int) -> String
    {
        let strarray = datas[start..<(start+length)]
//        strarray.append(0)
        var byteBuffer = [UInt8]()
        strarray.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
            byteBuffer += bytes
        }
        let str = String(bytes: byteBuffer, encoding: String.Encoding.ascii)
        return str!
    }
    
    func connectionComplete(_ device: IOBluetoothDevice!,
                            status: IOReturn)
    {
    }
    
    func remoteNameRequestComplete(_ device: IOBluetoothDevice!,
                                   status: IOReturn)
    {
    }
}

WPJ40-10をモバイルルーター用途として使った感想

auからmineoにMNPで移行して通信代も下がり、2年縛りなどからも解放され、ハッピーなこの頃です。

ただmacbookを最近持ち運んだりするのでネット環境が欲しいのですが、auiPhoneではmineo的にテザリングができません。そこでもう一つSIMを契約してモバイルルータ用として使いたいなと思い始めました。mineoで追加でデータ専用プランを契約しても、パケットをシェアできるので余った分は本回線にチャージできて、1回線で容量を増やすのと総額がほとんどかわらないので。

それでモバイルルーターを探そうかと思ったのですが、最低でも1万円以上はしてくる代物。それだったらテザリング専用のスマホをもう一台買ってもいいのではないか、その方がいろいろ楽しそうと思って、スマホを見ていました。そうすると、NTT-X StoreでWPJ40-10 というWindow 10 mobileのスマホが5000円弱で売っていました。前置きが長かったですが、値段につられてテザリング用として購入したWPJ40-10をあくまでモバイルルーターとして見たときの感想を書いていきます。

売れ筋モバイルルータとスペックを比較

価格.comSIMフリーモバイルルーターで1位(2017/1/21現在)になっているAterm MR04LNと比較して見ます。なお、MR04LNは出張に行く時に会社から借りたりしてよく使っています。

項目 WPJ40-10 Aterm MR04LN
サイズ 62.8 × 124.5 × 9.9mm 63 × 111 × 11mm
重さ 136.8g 111g
対応LTEバンド 1/3/8/19 1/3/8/11/18/19/21/17
WiFi IEEE802.11 b/g/n IEEE802.11 ac/n/a/b/g
SIMスロット microSIM microSIM*2
バッテリー容量 1800mAh 2,300mAh
連続待ち受け時間 最大約150時間 休止状態時 約250時間
ウェイティング時 約30時間

まず大きさはほぼ違いがありません。 重さはややWPJ40-10の方が重いですが、手にとって使うものではないので、持ち運びだけを考えればほとんどきになる違いではありません。

対応バンドですが、MR04LNの方が広くカバーしています。ただ、docomo系を使う場合はWPJ40-10もプラチナバンド含め主要なバンドは抑えていますので、困ることはないと思います。

WiFIは11acなどに対応していません。ただ、モバイルルータとして使う場合、速度のボトルネックは基本LTEになるので、WiFiの実行速度的にはacでなくても影響はほとんどないのではないかと思います。使用していてきになることはありません

SIMは1枚しかさせませんので、DSDSをしたい人には当然むきません。自分的には困りません。

バッテリー容量は小さめです。ただのちに述べますがバッテリーがすぐに減って使いにくいということはありません

実際につかってみてどう?

だいたい一週間ですが、通勤途中や昼休みにテザリングする目的で持ち歩いて見ました。 印象としては、バッテリーは十分持つなぁというところです。WiFiテザリングをしたままにしても(Macbookがスリープしながら繋がっている状態)、朝から夜まではなんとか持ちました。 Bluetoothのみにすれば1日は十分余裕です。

スマホと考えると液晶は悪いですが、モバイルルータと比較したら画面は(当然ですが)格段によいです。タッチパネルの使用感もよいです。

一方でレスポンスにはちょっとストレスを感じます。 画面が消えている状態で電源ボタンをおして画面がつくまで、1秒くらいかかってしまい「あれ、ボタン押し損ねたかな」ともう一度押してしまうとことがよくありました。ここは減点ポイントです。(起動してからの操作レスポンスは良好です)。ただモバイルルーターと考えれば(以下略)

初期設定という意味では、モバイルルータよりも手がかかります。スマホの設定をしなければいけないですから・・。 mineoの情報はもともと入っていたので、SIMをさして、mineoを選択するだけで無事に繋がりました。 ただ、Windowsアップデートが時間がかかります。まあiOSでも通る道なので、スマホと考えれば仕方ないですが・・

着せ替えカバー(色違い)、液晶保護シートが製品に付属しているのはポイント高いです(特に保護フィルム)。

便利機能

Windows 10 mobileにはMS-TTCという機能があります。これは、Windows PCなどからBluetoothでペアリングしたWindows 10 mobileのテザリングをオンにすることができる機能です。つまり、普段はテザリングをオフにして持ち歩いておいて、カバンからスマホを出さなくても必要に応じてPCからテザリングをオンにできるという優れものです。

ただ、Windows 10には標準機能として搭載されているのですが、Macには当然そのような機能はありません。。。自分はMacbook使っているので恩恵は受けれないかと思いつつ、MS-TTCはオープンなプロトコルらしく、仕様が公開されていました。

「自分で実装すればMacからでもできるんでは!?」と思って試したところ、無事MacからWPJ40-10のテザリングをオンにすることができました!これで普段テザリングオフで持ち運べるようになったのでかなり便利に。この辺りはまた後日まとめます・・。

結論

もちろんモバイルルーターに特化したMR04LNなんかと比べてしまうと、スペック的には落ちる点は多いですが、普通に使う分には十分モバイルルーターがわりとして使えてます。docomoだったら十分電波拾いますし、バッテリーも十分もちます。

しかも、Windows 10 mobileが触れるというおまけ付き。モバイルルーター用途だけでも十分すぎるほどお買い得だと思います。

観賞用に4k動画をfullHDに変換

4Kテレビを購入したもののなかなか4Kのコンテンツがなかったということもあり、子供の成長を動画で撮るために4Kビデオカメラを購入しました。

ただ、いざ4K動画を4Kテレビで見ようとすると、ビデオを直接テレビ繋いだりしないと表示できず、かなり取り回しが悪いです。NASに4K動画を置いておいてそれをDLNAでテレビで閲覧ということは自分の環境ではできませんでした。

そこで、4K動画は念のため保存しておくとして、観賞用にFullHDに解像度を落とした動画を用意しておくことにしました。基本NASに置きDLNAで鑑賞することを念頭に置いているので、ファイル名から中身がわかると取り回しやすいので、ファイル名を撮影日時に変更することにします。

以下、ASUSTOR NAS AS3102Tで変換した際の手順の覚書になります(詳しい解説は省略)。ASUSTOR NASではデフォルトでffmpegが入っているようで、バージョンは2.8.3のようです。

まず4K動画をASUSTOR NASのフォルダに全部放り込みます。

その後sloginでASUSTOR NASにログインして、対象のフォルダに移動して、以下のようなシェルスクリプトのファイルを作成します。

timestamp.sh

#/bin/sh

ffmpeg -i $1 2>&1 | grep -B 1 "Video" | grep "creation_time" | sed -e "s/^.* \([0-9]*\)-\([0-9]*\)-\([0-9]*\) \([0-9]*\):\([0-9]*\):\([0-9]*\).*$/\1\2\3\4\5\6/"

 

conv.sh

for file in `\find . -name "*.MP4" -maxdepth 1 -type f`; do

    tstamp=`./timestamp.sh $file`

    if [ ! -e $1/$tstamp.mp4 ]; then

        echo $1/$tstamp.mp4

        ffmpeg -i $file -vf scale=-1:1080 -acodec aac -strict -2 $1/$tstamp.mp4

    fi

done

timestamp.shは、指定された動画の撮影日時をyyyymmddhhmmssの形式で出力するスクルプトになります。ffmpegで表示される動画の情報を利用しています。

conv.shは、カレントフォルダ内の拡張しがMP4のファイル全部を、変換するスクリプトです。引数で指定したフォルダに、撮影日時.mp4という形式で出力します。ただし、すでに同名の出力ファイルがある場合変換済みとみなして出力しません。

このうえで、

nohup conv.sh (出力フォルダ) > log.txt &

といったコマンドを打つと変換がはじまりますので、ログアウトして変換が終わるのを待ちます(結構な時間がかかります)

変換後のファイルをUPnP Media Serverで公開することで無事テレビから閲覧できるようになりました。

SONY BRAVIAをコマンドラインから制御したい

最新のBRAVIAで検証したところ、一部下の通りだとうまくいかないケースがありました。

続・SONY BRAVIAをコマンドラインで制御したい - shimobepapaの日記

こちらの記事もあわせて確認ください。

 

自宅ではSONYBRAVIAを使用しているのですが、さすがSonyだけあってスマホ用アプリも充実をしていて、スマホをテレビのリモコンがわりに操作できたりします。

ただ、例えばhomebridgeと組み合わせてSiriからテレビを操作したかったり、バッチファイルの中に組み込んでテレビを操作したかったりすると、コマンドラインからテレビを制御したいなと思うことがままあります。そのための方法を調べてみました。

調べ方

SONY製アプリ(TVSideView)のパケットをキャプチャしてWiresharkで解析する

結果

以下、BRAVIA KD-65X8500Bで検証した結果です。

まず、電源オン操作は、Wake on Lanの仕組みを使用しているようです。テレビのmacアドレスを調べたうえで、linux環境ならば、ether-wake(etherwake)コマンドを使用するとテレビをつけることができます。

電源オン以外の操作は、パケットを解析すると、httpプロトコルBRAVIAと通信していることがわかります。

まず命令を送る前に認証のためのアクセスをして、認証コードみたいなものをクッキーに読み込んでいるようです。

その上で認証コードをクッキーにいれた状態で、HTTPのPUTで命令を送っています。

またはじめて認証コードを取得する際には、テレビ側に四桁の数字が表示されるので、その値をアプリで入力してテレビにアプリを登録する作業が入ります。4桁の数字はBasic認証のパスワードとして送っているようです。(こうすることで、テレビにClientIDが保存されているようです)

コマンドラインから操作するということで、今回はcurlコマンドでBraviaを操作できるようにすることをめざします。

テレビのIPアドレスを固定にしておき、curlをインストールした状態で、以下のようなコマンドを打っていきます。

認証用クッキー保存

curl -d '{"method":"actRegister","params":[{"clientid":"任意のID(例 nas:20161217)","nickname":"テレビ側に登録するニックネーム(例:nas)"},[{"function":"WOL","value":"no"}]],"id":任意の数字,"version":"1.0"}' -c cookie.txt http://(BraviaIPアドレス)/sony/accessControl

上記コマンドをいれると、はじめて指定したclientidでアクセスする場合は、テレビ側にニックネームと4桁の数字が表示されると思います。(このとき通信結果としては401が返ってきているようです)
この場合は、テレビに表示されている数字をBasic認証のパスワードにして、上記コマンドを再度送ります。curlコマンドでは以下のようになります

curl -d '{"method":"actRegister","params":[{"clientid":"任意のID","nickname":"テレビ側に登録するニックネーム"},[{"function":"WOL","value":"no"}]],"id":任意の数字,"version":"1.0"}' -u :四桁の数字 -c cookie.txt http://(BraviaIPアドレス)/sony/accessControl

例:

curl -d '{"method":"actRegister","params":[{"clientid":"nas:20161216","nickname":"nas"},[{"function":"WOL","value":"no"}]],"id":8,"version":"1.0"}' -u :1234 -c cookie.txt http://192.168.0.100/sony/accessControl

その上で、以下のコマンドを実行することで、テレビを操作できます

通常のリモコンでできる操作
curl -d '<?xml version=\"1.0\"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:X_SendIRCC xmlns:u="urn:schemas-sony-com:service:IRCC:1"><IRCCCode>(コマンド)</IRCCCode></u:X_SendIRCC></s:Body></s:Envelope>' -b cookie.txt http://(BRAVIAIPアドレス)/sony/IRCC
その他操作
curl -d '{"method":"setPlayContent","params":[{"uri":"(別表のリスト)"}],"id":10,"version":"1.0"}' -b cookie.txt http://(BRAVIAIPアドレス)/sony/avContent

なにかxmlだったりjsonだったり不思議な感じですが、コマンド、uriに入るものは以下を参照してください

通常のリモコンでできるコマンドの例
電源オフ AAAAAQAAAAEAAAAvAw==
Volume up AAAAAQAAAAEAAAASAw==
Volume Down AAAAAQAAAAEAAAATAw==
チャンネル+ AAAAAQAAAAEAAAAQAw==
チャンネルー AAAAAQAAAAEAAAARAw==
AAAAAQAAAAEAAAAAAw==
AAAAAQAAAAEAAAABAw==
AAAAAQAAAAEAAAACAw==
... ...
12 AAAAAQAAAAEAAAALAw==
その他操作のuriの例
uri 説明
extInput:hdmi?port=1 HDMI1に入力切り替え
extInput:hdmi?port=2 HDMI2に入力切り替え
extInput:hdmi?port=3 HDMI3に入力切り替え
extInput:hdmi?port=4 HDMI4に入力切り替え
tv: tvに入力切り替え
extInput:widi?port=1 スクリーンミラーリング

グローバルIPアドレスがないけどNASを外部に公開したい

現在住んでいるマンションには無料で使える(管理費に含まれている)ネット環境があり、各部屋まで回線が来ているのですが、グローバルIPアドレスがもらえません。グローバルIPアドレスをもらえるようなオプションもなく、お金を払えばいいというわけでもないです。出先とかでNAS自宅サーバ)のデータを取得したりいじって遊びたいとか思ってもそのままではできないので、多少のお金を払うことで実現することにしました。

VPSを借りる

VPSというのは、バーチャル・プライベート・サーバの略で、仮想的な専用サーバをレンタルできるサービスです。専用サーバというと高いイメージがあったのですが、月500円くらいで借りることができます。

VPSならDTI|月額467円(税抜) メモリ1GB|ServersMan@VPS

基本的に自分の用途でしたら、一番安いプランで十分でした。安いプランでも固定IPアドレスをもらうことができます。

このVPSサーバにVPNサーバを立ててNASVPNクライアントとして接続することで、外部からVPSサーバに経由でNASにアクセスできるようにします。 

VPSVPNサーバを設定する。

まずは、VPSの設定をします。

ServersMan@VPNでは、初期に入れるOSを選択することが可能ですが、今回はUbuntu 14.04を入れました。契約するとあっという間にセットアップが終わって、SSHでログイン出来るようになりますした。すごい。

UbuntuへのOpenVPNのインストールは公式ドキュメントに沿って進めます。

How To | OpenVPN.JP

apt-getでインストール可能です。必要そうなペッケージも同時にインストールします。

sudo apt-get install openvpn libssl-dev openssl easy-rsa

次に、サーバの認証局・証明書の設定をします。

OpenVPNの認証方式ですが

  • 静的鍵方式
  • パスワード認証
  • 認証書認証
  • 二要素認証

という4種類があります。基本的には、下に行くほどセキュリティは高まる一方、設定は面倒になります。認証書証明の設定を紹介するサイトが多いですが、プライベートな使用ではそこまでセキュリティを高めなくていいと思われますので、今回はパスワード認証を採用します。

OpenVPNの証明書について

パスワード認証を採用します、サーバ側の証明書については用意しておきます。ASUSTOR NASだとサーバ側の証明書を設定しないとうまくいかなかったので(正確には、認証局の情報がないとエラーとして弾かれてしまった)、、、

サーバ側の証明書の目的はサーバのなりすまし防止です。「自分のサーバと思って接続したOpenVPNのサーバが実は悪意を持ったサーバでパスワードなどを抜かれてしまった・・」というようなことを防ぐためです。

設定することは以下の通りです。

  • サーバにプライベート認証局を作る
  • プライベート認証局の証明書をクライアントとシェア
  • サーバの証明書をプライベート認証局に発行してもらう

ちなみに、プライベートな認証局の証明書でなりすましを防げる仕組みについて・・。接続時には、以下の手順を踏んでなりすましサーバでないことをクライアントで確認します。

  1. サーバからサーバ証明書を受け取る
  2. サーバ証明書が、プライベート認証局の証明書で署名されてものかを、事前にシェアされたプライベート認証局の証明書を使って確認
  3. サーバ証明書に含まれている公開鍵情報を元にランダムな数値を暗号化してサーバに送信。サーバはサーバ証明書とセットの秘密鍵を元にランダムな数値を複合かしてクライアントに送り返す。
  4. クライアントは正しい数値が送り返されることで、サーバが秘密鍵を持っていることを確認できるので、なりすましではないことを確認できる。

事前に認証局の証明書を信頼できる方法で受け取っておくことで認証局の信頼性は担保できますので、プライベートな認証局でも十分ということになります。

認証局の証明書とサーバ証明書の発行

まず、認証局の作成です。OpenVPNが配置されているディレクトリのeasy-rsaサブディレクトリに入れるのがいいようです。

/etc/openvpn/の下にサブディレクトリを以下のコマンドでつくります。

sudo make-cadir /etc/openvpn/easy-rsa

easy-rsaフォルダにある、varsというファイルを書き換えます。

(viを使うなら、sudo vi /etc/openvpn/easy-rsa/vars)

export KEY_COUNTRY="(国)"
export KEY_PROVINCE="(都市)"
export KEY_CITY="(街)"
export KEY_ORG="(組織名)"
export KEY_EMAIL="(メールアドレス)"
export KEY_OU="(組織単位らしいので適当に)"

あたりを適切な情報に修正します。これらの情報は、認証局の証明書に埋め込まれます。

その上でvi /etc/openvpn/easy-rsaに移動し、以下のコマンドを実行して、証明局を初期化、認証局の証明書(ca.crt)を発行します。

source vars
./clean-all
./build-dh
./pkitool --initca

次にサーバ用の証明書を発行します。

./pkitool --server server
./build-key-server server

 

easy-rsaフォルダに

ca.crt

ca.key

server.crt

server.key

というファイルができていると思いますので、これらは、

/etc/openvpn/

にコピーしておきます。(後ほど設定ファイルからこれらのファイルを参照するので、設定ファイルで絶対パスを書いておけば、別のフォルダにおいても問題ないはずです)

OpenVPNサーバ設定ファイル

次に、サーバ側の設定ファイルを記述します。

基本的には、

https://www.openvpn.jp/document/how-to/#SampleConfigurationFiles

を元に編集します。

 

今回は、次のような構成ファイルを作成して、server.confとして保存します。

上で書いたようにパスワード認証方式になります。

# 使用するポート
port 1194

# TCPUDP か
proto udp

# ブリッジモードを使用する必要がなければtunを選択
dev tun

# 認証局の証明書、および、サーバの証明書+秘密鍵
ca ca.crt
cert server.crt
key server.key # これが秘密鍵になるので公開しない

# Diffie hellman parameters.作成したものを指定
dh dh2048.pem

# VPNで使用するアドレス空間
# サーバは先頭のアドレス(10.8.0.1)が選ばれるようです
server 10.8.0.0 255.255.255.0

# サーバに接続してきたクライアントに対し
# 10.8.0.*のアドレスへのアクセスはVPNを通すように、設定を送る
push "route 10.8.0.0 255.255.255.0"

# クライアント同士も通信できるようにする
client-to-client

# 10秒ごとpingを送り120秒反応がなければ切断と判断
keepalive 10 120

# 暗号形式を指定。クライアントと合わせる必要がある
cipher AES-256-CBC # AES

# 圧縮を有効にする。クライアントと合わせる必要がある
comp-lzo

# 切断が発生したときの再接続などのためにクライアントと両方に以下設定しておくといいみたい
persist-key
persist-tun

# 現在のステータスを出力してくれる
status openvpn-status.log

# ログの出力先。
log-append /var/log/openvpn.log
# ログの出力レベル。数が多いほどいっぱい出力される。0〜9
verb 4

# パスワード認証するための設定。ログインユーザ名とパスワード名をそのままVPNのユーザ名とパスワードに使う
plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so login
# クライアントの証明書を不要にする
client-cert-not-required

これを/etc/opnevpn/フォルダにsercer.confという名前で保存します。

これで、OSのユーザアカウントとパスワードで接続できるようになります。NASから接続するためのアカウントとして、 OSのアカウントを作っておくようにします。基本的には一つのアカウントからは同時に1つしかVPNに接続できません。

クライアント設定ファイル

次に、クライアントの設定ファイルを用意します。

ASUSTOR NASむけに設定ファイルを使用する場合、認証局の証明書(ca.crt)は設定ファイルからファイル名を指定する形式ではなく、<CA>タグとして設定ファイル中に、証明書の中身を記述する必要があるようです。

 

# クライアント用の設定
client

# サーバと合わせる
dev tun
proto udp
persist-key
persist-tun
comp-lzo
cipher AES-256-CBC

# サーバのアドレス+ポート
remote 12.34.56.77 1194

# ノートPCなど常時接続していない環境向け設定。ホストに繋げられなくても諦めず何度も試す
resolv-retry infinite

# 特定のポートと紐づけるか。通常nobindでOKみたい
nobind

#以下、ca.crtの中身(ca.crtはテキストファイルなのでエディタで開いて貼り付け)
<CA>
-----BEGIN CERTIFICATE-----
MI(略)

(略)

(略)xoMsdw=
-----END CERTIFICATE-----
</CA>

# パスワード認証する
auth-user-pass

# Set log file verbosity.
verb 3
# ログの出力先
log-append /var/log/openvpn.log
# Silence repeating messages ;mute 20

これをVPN Clientに読み込ませて接続することになります。

ASUSTOR NAS AS3102Tでの設定

ASUSTOR NASにはデフォルトでOpenVPNの機能が入っています。

Webの管理ページの「設定」から「VPN」を選び、「追加」をクリックします。

接続タイプとして「OpenVPN」を選び、「OpenVPNプロバイダーから構成ファイルをインポートする」を選択します。

名前は、識別用なので自由に設定します。 アカウント、パスワードは、先ほど設定したNAS用のアカウントのものをいれ、「ファイルをインポート」で、先ほど作成したクライアント用の設定ファイルを指定してください。

これで設定は完了です。

接続をクリックして、接続できることを確認します。

NASsshでログインして、pingで10.8.0.1(VPNサーバ)へ通れば無事成功です。うまくいかない場合は、クライアント、サーバ双方のログを見ながら解決していくことになります。

 

上記の設定で、VPSsshでログインし、そこからさらにNASにアクセすれば、一応外部からNASへアクセス可能になりました。

ただ、例えばVPSのアドレスへhttpで(80番ポートに)接続したら、VPSではなくNASのレスポンスを返したい、といったこをするためには、ルータのポートフォワーディングみたいなことを、VPSサーバで行う必要があります。

そのためにはまず、NASが接続した時のVPN側アドレスを固定にしないと不便です。

OpenVPNでパスワード認証で接続した時、特定ユーザに固定IPを付与する方法

VPNサーバ側の設定変更が必要になります。

OpenVPNの機能として、クライアントのcommon nameに応じて固定IPを割り振ることができます。ただ、そのままの設定ではユーザ名がcommon nameにならないので、server.confに次の一行を追加します、

username-as-common-name

その上で、次の一行も追加します。

client-config-dir ccd

クライアントごとの設定がccdに入っていますよ、という意味になります。

クライアントごとの設定ファイルの場所として

/etc/openvpn/ccd

が指定されたととになりますので、このフォルダを作成し、ここにユーザ名をファイル名にしたテキストファイル(ユーザ名がnasならば、nasという名前のファイル)を作成します。

そのファイルには、例えばIPアドレスを10.8.0.100に指定したい場合、

ifconfig-push 10.8.0.100 10.8.0.99

というように記述します。10.8.0.99は別になんでもいいのですが、tunの場合、インターフェースに別のIPアドレスが必要になりますので、インターフェースのIPも別途指定する必要があるためこのような記述になります。

こうして、OpenVPNサーバを

service openvpn restart

などとして再起動し、クライアントから再度接続すれば10.8.0.100が割り振られるようになります。

 

VPSの特定のポートにきた通信を、特定IPの別マシンにフォワードする方法

ルータのポートフォワーディングみたいなことをVPSで行う方法です

iptablesを使うことになります。

iptablesは詳細な説明は避けます。すみません。

例えば、VPS(仮にグローバルIPを12.34.56.78とします. VPN側は10.8.0.1)の8080番ポートにきたものをNAS(10.8.0.100)の80番ポートに流す場合、次のようなコマンドを打ちます。

iptables -t nat -A PREROUTING -m tcp -p tcp --dst 12.34.56.78 --dport 8080 -j DNAT --to-destination 10.8.0.100:80

iptables -t nat -A POSTROUTING -m tcp -p tcp --dst 10.8.0.100 --dport 80 -j SNAT --to-source 10.8.0.1

一行目が、8080番ポートにきた通信を、NASの80番ポートに流す設定

二行目が、その通信の送り元を10.8.0.1(VPSVPNアドレス)にするという設定です

また、そもそものiptablesの設定として、基本ポートへの接続を受け付けない設定になっていることがあります。

iptables -L

とうって、INPUT /OUPUTなどがpolicy ACCEPTになっていることを確認します。

もし、意図してDROPしている場合は、特定のポートを受信できるようにしておく必要があります。

こうすることで、 ルータのポートフォワーヂングのような形で、VPSの特定ポートへのアクセスをNASに渡すことができるようになります。

 

こうすることで、NASのWebサーバを外部に公開するといったことが可能になります!