MIDI デバイスを Lightroom のコントローラとして使えるプラグインとして MIDI2LR というのがある。市販のMIDIコントローラを使ってLightroomの現像パラメータなどを可変できるというもの。

しかし意外と市販のMIDIコントローラでちょうどいいのがないという問題がある。インターフェイス上の制限から、最適なコントローラは単純なロータリーエンコーダとスイッチの組合せで、常にインクリメンタルな値を送るものとなる。

ちょうどいいのがないなら専用品を作れば良いので、まずブレッドボードレベルで実装してみる。

コード

ロータリーエンコーダを処理するコードがあるのでちょっと長く見えるが main は短い。mbed の USBMIDI ライブラリのおかげ。

LPC11U35 で動かすことを前提として、ロータリーエンコーダのA相・B相を、それぞれ P0_11, P0_12 に繋ぐコードになっている。時計周りのときB相が先行するコードなので、逆のロータリーエンコーダの場合は逆にする。(ロータリーエンコーダの位相方向って決まってないっぽい?)

ロータリーエンコーダのチャタリング対策も入れてる。ググってみるとあんまりやってるコードを見ないけど必要ないのかな?

#include "mbed.h"
#include "config.h"
#include "USBMIDI.h"

class QEI {
	static const int8_t INVALID = 2;
	static uint8_t decode(const uint8_t prev, const uint8_t curr) {
		/**
		 * 4bit decode table
		 *       bit3      bit2      bit1     bit0
		 * [ prev A ][ prev B ][ curr A ][ curr B]
		 *
		 */
		switch ( (prev << 2) | curr) {
			case 0b0000: return  0;
			case 0b0001: return  1;
			case 0b0010: return -1;
			case 0b0011: return  INVALID;
			case 0b0100: return -1;
			case 0b0101: return  0;
			case 0b0110: return  INVALID;
			case 0b0111: return  1;
			case 0b1000: return  1;
			case 0b1001: return  INVALID;
			case 0b1010: return  0;
			case 0b1011: return -1;
			case 0b1100: return  INVALID;
			case 0b1101: return -1;
			case 0b1110: return  1;
			case 0b1111: return  0;
		}
		return INVALID;
	}

	volatile uint8_t prev;
	Timeout timeout;

	void interruptSample() {
		uint8_t ok = sample();
		if (!ok) error = 1;
	}
	
	void interruptDelay() {
		// avoid chattering
		timeout.attach(callback(this, &QEI::interruptSample), delay);
	}
public:
	volatile int position;
	volatile uint8_t error;
	InterruptIn phaseA;
	InterruptIn phaseB;
	float delay;

	QEI(PinName _a, PinName _b, float _delay = 0.005) :
		prev(0),
		position(0),
		error(0),
		phaseA(_a),
		phaseB(_b),
		delay(_delay)
	{
		phaseA.mode(PullUp);
		phaseB.mode(PullUp);
	}

	void enableInterrupt() {
		phaseA.rise(callback(this, &QEI::interruptDelay));
		phaseA.fall(callback(this, &QEI::interruptDelay));
		phaseB.rise(callback(this, &QEI::interruptDelay));
		phaseB.fall(callback(this, &QEI::interruptDelay));
	}

	/**
	 * sample digital input and return ok
	 */
	uint8_t sample() {
		uint8_t curr = phaseA.read() << 1 | phaseB.read();
		int8_t incr = decode(prev, curr);
		prev = curr;

		if (incr == INVALID) {
			return 0;
		}

		position += incr;

		return 1;
	}
};

void show_message(MIDIMessage msg) {
	switch (msg.type()) {
		case MIDIMessage::NoteOnType:
			printf("NoteOn key:%d, velocity: %d, channel: %d\r\n", msg.key(), msg.velocity(), msg.channel());
			break;
		case MIDIMessage::NoteOffType:
			printf("NoteOff key:%d, velocity: %d, channel: %d\r\n", msg.key(), msg.velocity(), msg.channel());
			break;
		case MIDIMessage::ControlChangeType:
			printf("ControlChange controller: %d, data: %d\r\n", msg.controller(), msg.value());
			break;
		case MIDIMessage::PitchWheelType:
			printf("PitchWheel channel: %d, pitch: %d\r\n", msg.channel(), msg.pitch());
			break;
		default:
			printf("Another message\r\n");
	}
}


USBMIDI midi;
QEI encoder1(P0_11, P0_12);

int main() {
	printf("init\r\n");
	encoder1.enableInterrupt();

	midi.attach(show_message);
	while (1) {
		if (encoder1.position) {
			int8_t val = encoder1.position;
			printf("send CC 1 %d 0\r\n", val);
			midi.write(MIDIMessage::ControlChange(1, val, 0));
			encoder1.position = 0;
		}
	}
}

MIDI2LR の設定

MIDI2LR の設定画面を開いた状態でロータリーエンコーダを動かすと、以下のように表示されるので、ここでは Exposure (露出) に割り当てている。

さらにこのボタンの部分を右クリックして、Adject CC dialog を出し、CC Message Type を Two's complement に設定する。

備考

これですんなり動く。ロータリーエンコーダのA相・B相の仕様を確認しないと回転方向が逆になったりするのだけ注意が必要 (ソフトウェア側でなんとでもなるけど)

この調子でエンコーダーを増やしていけば実装上は良いことになる。しかしロータリーエンコーダ1つあたり2ピンのIOを使うので、実際は GPIO 拡張が必要になる気がする。

  1. トップ
  2. tech
  3. mbed USBMIDI で Lightroom 用の MIDI インターフェイスを作る

BLE Nano を使っていた自作キーボードだったが、Mac のアップデートとともにまた不安定になってしまい、使う気を失ってしまった (数時間ごとに再ペアリングが必要に)。直す気力もなくてしばらく放置していたが、そろそろ観念して USB 接続のキーボードに作りかえることとした。

作りかえるといっても、キーボードの部分は I2C GPIO のモジュールとして動くように作ってあるので、マイコンまわりを載せ変えて実装を書くだけになる。

ということで、タイトルの通り LPC11U35 を使ってキーボードを実装しなおした。

コード: https://github.com/cho45/keyboard-lpc11u35

mbed official の USBDevice

mbed official に存在するライブラリの USBDevice の中に USBKeyboard というのがある。USBHID を継承していて、レポートデスクリプタとかを予め設定してくれる便利クラスとなっている。これは LPC11U35 でもちゃんと動くので基本的にハマるようなところはない。

標準で便利なメソッドがいくつか定義されているが、実際にキーボードを作る場合はこれらは使わない。USBKeyboard に定義されているメソッドはデモ用と考えていいと思う。

普通に使う場合は、レポートデスクリプタ定義はそのまま使いつつ (特に変更する必要がないので)、USBHID#send() を直接呼んで HID_REPORT を自分で構成して送ることになる。

基本的なコード

#include "mbed.h"
#include "USBKeyboard.h"

#include "mcp23017.h"

#define REPORT_ID_KEYBOARD 1
#define REPORT_ID_VOLUME   3

USBKeyboard keyboard;
DigitalOut led(LED1);
DigitalIn key1(P0_4, PullUp);

int main() {
    HID_REPORT report;
    
    uint8_t modifier = 0;
    uint8_t usage = 0;

    report.data[0] = REPORT_ID_KEYBOARD;
    report.data[1] = modifier;
    report.data[2] = 0;
    report.data[3] = usage;
    report.data[4] = 0;
    report.data[5] = 0;
    report.data[6] = 0;
    report.data[7] = 0;
    report.data[8] = 0;
    report.length = 9;
   
    
    while(1) {
        bool isKey1Pressed = key1.read() == 0;
        if (isKey1Pressed) {
            if (report.data[3] != 0x04) {
                report.data[3] = 0x04 /* a */  ;
                keyboard.send(&report);
            }
        } else {
            if (report.data[3] != 0x00) {
                report.data[3] = 0x00;
                keyboard.send(&report);
            }
        }
        wait_ms(10);
    }
}

基本的にはこういう形です。USBKeyboard のメソッドではキーの「押しっぱなし」ができない実装なので、ちゃんとしたキーボードにするにはレポートを自分で管理する必要があります。実用的にはリポート内のキーの状態を管理するクラスが必要になるでしょう。

といっても、USBKeyboard をほとんど使わないようなら直接 USBHID を継承して MyUSBKeyboard を作ったほうがいい気がするので (レポート定義もいじれるようになるし)、実際はそうしています。

ハマりポイント

sleep() がうまくいかない

ハマることはないと書いたが、メインループで sleep() (Active Sleeep) を使ってデバイスを割り込み待ちにするコードを書いたところ、USBHID#send() が失敗するという状態になった。どうやらUSBの状態まで狂わすようだったのでビジーループに変えた。

なんでおかしくなるのか、実装を読んだりマニュアルを読んだりして調べてみてもよくわからない。USB のクロックは有効だし、USB まわりの電源も sleep で切れるようなものはないように思える。

割り込み用のピンのプルアップが弱い

内部プルアップ時の電流がスペックだと 50μA となっている。電源電圧 3.3V なら 66kΩ 相当のプルアップとなる。

I2C を 2kΩでプルアップ動かしていると、この内部プルアップのピンをかなり動かしてしまうようだった。とりあえずは大丈夫そうだったが、今後誤動作の原因となりそうだったので、こちらも同様に 2kΩ で外部プルアップとした。

その他くだらないハマり

  • 一部のLANケーブルと相性がなぜか悪い
  • キーボード側で断線
    • かなり細いパターンの部分が見えないレベルで断線しており、割込みがかからない状態であった
  • sleep をやめたことによるバグ
    • キー入力がないときは I2C バスをやすませるような動作にしていたが、条件判定用の数値が sleep をやめたことによりアンダーフローしていた。
  • 時々キーが二重入力される
    • USB の通信遅延?
    • DEBUG 用に printf してるのが同期出力なのが原因っぽいので、本番で使う場合は必ず全 printf をオフにするように

接続

キーボード左右+USBコントローラという構成になった。USBコントローラとキーボードの左右はLANケーブルで接続する。BLE 版だとコントローラー基板はキーボードの左に付属していて、左右のキーボード同士をLANケーブルで接続していたが、実際はこのように配線を変えても動くような構成にしていたので、回路自体はすんなり変更できた。

電池がなくなったり、コントローラ基板を別にしたことで、キーボード全体の座高を低く、かつフラットにすることができた。今まで地味にキー位置が高くて、キーボード前にクッションが必要だったんだけど、その必要がなくなった。

筐体の作りなおし

3Dプリンタを得たのでより剛性の高い筐体になった。

  1. トップ
  2. tech
  3. LPC11U35 でUSBキーボードを作る

NKRO (N-key Rollover) というのは、おおざっぱに言うとキーボードのキーを同時押ししたとき、いくつ認識するか?ということです。

NKRO の理想的には以下の挙動になります。

  • いくつキーを押しても全て同時に押されていることがコンピュータから認識される

これを NKRO として定義すると、全ての USB HID キーボードは NKRO ではありません。なぜなら、USB HID キーボードは修飾キーを除くと6キーまでしか押されていることを送信できないからです。

USB HID での NKRO

そうすると妥協した次点として以下のような仕様を NKRO とするほかありません。

  • 6キーまでは同時押しがコンピュータから認識される
  • 7キー目を押すと、最初に押したキーが離されたと認識される

完全な同時押しは6キーまでですが、入力をとりこぼすということはありません。USB HID で NKRO と呼ばれているものはおそらくこの挙動をすると思います。

まぁ実際のユースケースとして、そもそも7キー以上の同時押しは極めて稀です。ということでさらに妥協して以下のような実装も考えられます

  • 6キーまでは同時押しがコンピュータから認識される
  • 7キー目を押しても無視される

これはこれでほとんど問題ないでしょう。単純に 6KRO になります。押した順番という状態を持つ必要が減るのでファームウェアの実装は簡単になります (バグを少なくできます)。一方で、キーを離さないクセを持つ人がものすごい高速タイピングをした場合にはキーをとりこぼすかもしれません。

PS-2 キーボードではどうなのか?

PS-2 キーボードのプロトコルは単純で、シリアル通信で

  • キーを押すと Make 信号を発生させる
  • キーを離すと Break 信号を発生させる

というものです。つまり NKRO になるかはOS側の実装次第です。

ゲーマー向けのマザーボードには PS-2 端子がついていることが多いですが、これは

  • NKRO 対応のため
  • レイテンシ削減のため

であると思われます。USB はホストからのポーリングで成りたっているため、キーを押した瞬間にコンピュータにデータが送られてくるわけではなく、コンピュータ側からのポーリングを待つ必要があります。ポーリング間隔はデバイス側から通知され、最小で1msまで設定可能ですが、OS 側に最終決定権があり、例えば Windows では最小で 8ms (Low Speed) , 1ms (Full Speed), 0.125ms (High Speed) です。つまりこの時点で最大で0.125msの遅延が発生します。

PS-2 の場合、キーが押された瞬間に Make 信号を送るため、理論的にはこちらのほうが早いことになります。

  1. トップ
  2. tech
  3. USB キーボードでの NKRO

(画像は過去に入力したデータを全て Google Fit へ入力しなおした様子)

Fit API 全体の概念

単純にグローバルな「体重」に対して値を追加するみたいになっているわけではない。

各アプリケーション(サードパーティ含む)は自分用の「データソース」を作る。これはセンサーに対応する。例えば「体重計 HOGE-001」みたいなデータソース。このとき「体重 (com.google.weight)」とかデータの種類と「浮動小数点」とかデータ型の定義をしておく。

データソースの定義に従って、データソースにデータポイントを追加してく。例えば「体重は 69.95kg」みたいな。

こうしていくと、複数のデータソースから「体重」データがいくつもできることになる。

実は Google Fit の画面から体重を入力すると user_input というデフォルトで存在するデータソースにそのデータは蓄積される。一方で、自分で独自の「体重」のデータソースを作って追記することもできる。これによって、データソースごとに自分のデータにだけ責任を持つという形にすることができる。

これらの「体重」のデータは最終的に derived:com.google.weight:com.google.android.gms:merge_weight というデータソースに集計されて、Fit で表示されている。

あとアクティビティ(ランニング)に対応するセッションとかがあるけど、今回は使ってないので調べてない。

「体重」を記録する場合

なんらかの方法で体重情報を取得できるとして、それを Google Fit に保存したい場合を想定する。

全体の流れは以下の通り

  • Fitness API が有効な OAuth の設定をする
    • Developer Console で 「Fitness API」を有効にしたプロジェクトをつくり、「OAuth 2.0 クライアント ID」を作成しておく。
  • 対応するデータソースを作る (体重計を1つのセンサーとみなして)
  • データソースへ値を追記する

Perl での例をしめす。CLI アプリケーションとしての実装。oob でキーを入力するため初回のみインタラクティブな インターフェイスになっている。

use v5.12;
use LWP::Authen::OAuth2;
use Path::Class;
use JSON;
use HTTP::Request::Common qw(GET HEAD POST DELETE PATCH);
use DateTime;

use constant {
	CLIENT_ID => '',
	CLIENT_SECRET => '',
};


my $token_file = file('.token_file');
my $token_string = eval { $token_file->slurp } || '';

my $google =  LWP::Authen::OAuth2->new(
	client_id => CLIENT_ID(),
	client_secret => CLIENT_SECRET,
	service_provider => "Google",
	redirect_uri => "urn:ietf:wg:oauth:2.0:oob",

	save_tokens => sub {
		my ($token_string) = @_;
		my $fh = $token_file->openw;
		print $fh $token_string;
		close $fh;
	},
	save_tokens_args => [],
	token_string => $token_string,
);

unless ($google->token_string) {
	# 新規 OAuth 認証
	my $uri = $google->authorization_url(scope => join(' ',
		'https://www.googleapis.com/auth/fitness.body.read',
		'https://www.googleapis.com/auth/fitness.body.write',
	));
	printf "Access to authorization: %s\n", $uri;
	printf "Input authorization code: ";
	my $code = <>;
	chomp $code;
	$google->request_tokens(code => $code);
}

# データソース作成
# 既にある場合は 409 になる
my $res = $google->request(POST "https://www.googleapis.com/fitness/v1/users/me/dataSources", 
	Content_Type => "application/json;encoding=utf-8",
	Content => encode_json({
		"application" => {
			"name" => "foobar baz",
			"detailsUrl" => "http://example.com",
			"version" => "1",
		},
		"dataType" => {
			"name" => "com.google.weight",
			"field" => [
				{
					"name" => "weight",
					"format" => "floatPoint"
				}
			]
		},
		"dataStreamName" => "foobar",
		"type" => "raw",
		"device" => {
			"manufacturer" => "my",
			"model" => "foobar",
			"type" => "scale",
			"uid" => "1000001",
			"version" => "1.0"
		}
	})
);

# 409 の場合エラーメッセージをパースしてデータソースIDを取得している
my $datasourceid = undef;
if ($res->code == 409) {
	my $json = decode_json($res->decoded_content);
	($datasourceid) = ($json->{error}->{message} =~ /Data Source: ([^ ]+) already exists/);
} elsif ($res->code == 200) {
	my $json = decode_json($res->decoded_content);
	$datasourceid = $json->{dataStreamId};
} else {
	die "failed to request creating data source";
}

unless ($datasourceid) {
	die "cannnot retrieve or create datasource";
}


# 送信するデータポイント
my $data_points = [
	{ epoch => ..., weight => 69.4 },
	{ epoch => ..., weight => 69.4 },
	{ epoch => ..., weight => 69.4 },
];

my $minstarttime = min map { $_->{epoch} } @$data_points;
my $maxendtime = max map { $_->{epoch} } @$data_points;

# 追加するリクエストは PATCH
# https://developers.google.com/fit/rest/v1/reference/users/dataSources/datasets/patch
my $datasetid = sprintf("%s-%s", $minstarttime, $maxendtime);
my $res = $google->request(PATCH sprintf("https://www.googleapis.com/fitness/v1/users/me/dataSources/%s/datasets/%s", $datasourceid, $datasetid),
	Content_Type => 'application/json;encoding=utf-8',
	Content => encode_json({
		"dataSourceId" => $datasourceid,
		"minStartTimeNs" => $minstarttime * 1000 * 1000 * 1000,
		"maxEndTimeNs" => $maxendtime * 1000 * 1000 * 1000,
		"point" => [
			map {
				{
					"dataTypeName" => "com.google.weight",
					"originDataSourceId" => "",
					"startTimeNanos" => $_->{epoch} * 1000 * 1000 * 1000,
					"endTimeNanos" => $_->{epoch} * 1000 * 1000 * 1000,
					"value" => [
						{
							"fpVal" => $_->{weight},
						}
					]
				}
			} @$data_points
		]
	})
);
say $res->as_string;

データソースを削除するには

既存のデータポイントが残っていると削除できないため、以下の手順を踏む

  • GET dataPointChanges で全てのデータポイントを洗って、startTimeNanos の最小値、endTimeNanos の最大値をもとめる
  • DELETE datasets で 求めた startime-endtime を datasetid とする (既存データポイントを削除)
  • DELETE dataSources を行う

ただ、データポイントを削除しても deletedDataPoint に入るだけで、完全に消えるわけではない。データソースも、削除は通っても、再度作成を行うと、deletedDataPoint が含まれた古いデータが復活する。ここらへんの挙動はよくわからない。

コード例は以下の通り

my $page_token = "";
my $minstarttime = "inf";
my $maxendtime = 0;

# データポイントを走査してデータ範囲を確定させる
while (1) {
	infof("GET dataPointChanges with token %s", $page_token);
	my $res = $google->request(GET sprintf("https://www.googleapis.com/fitness/v1/users/me/dataSources/%s/dataPointChanges?%s", $datasourceid, $page_token));
	$res->code == 200 or die "failed to get dataPointChanges";
	my $json = decode_json($res->decoded_content);
	use Data::Dumper;
	warn Dumper $json ;
	@{ $json->{insertedDataPoint} } or last;
	
	$minstarttime = min $minstarttime, map {
		$_->{startTimeNanos}
	} @{ $json->{insertedDataPoint} };
	$maxendtime = max $maxendtime, map {
		$_->{endTimeNanos}
	} @{ $json->{insertedDataPoint} };

	$page_token = "pageToken=" . $json->{nextPageToken};
}

# 全範囲のデータポイントを削除する
if ($maxendtime) {
	my $datasetid = sprintf("%s-%s", $minstarttime, $maxendtime);
	infof("Deleting existing data points for this data source %s", $datasetid);
	my $res = $google->request(DELETE sprintf("https://www.googleapis.com/fitness/v1/users/me/dataSources/%s/datasets/%s", $datasourceid, $datasetid));
	say $res->as_string;
} else {
	infof("There are no data point");
}

# データソースを削除する
infof("Deleting this datasource");
my $res = $google->request(DELETE sprintf("https://www.googleapis.com/fitness/v1/users/me/dataSources/%s", $datasourceid));
say $res->as_string;

OMRON の Wi-FI 体重計

突然話は変わるがOMRON の Wi-FI 体重計を買ったのは失敗だなーと思っている。Bluetooth 体重計のほうがハックしやすいと思うからだ。Wi-Fi 経由で https でサービスと接続されているとサービス側の仕様変更やサービス終了の影響をうけてしまう。そして実際、オムロンはPC側のサービスを終了してしまった。

しかし BT 対応の体重計を買いなおすのも嫌なので、Android アプリが取得しているデータを普通にスクリプト (Perl) で取得できるようにして、Fit にインポートできるようにした。毎日動かせば常に Google Fit 側へデータが同期されるので、たとえ OMRON のサービスが終了しても、最悪データは失われない。リバースエンジニアリングしたので同期スクリプトの公開は控えるが、Google Fit のノウハウだけ記録しておく次第 (BT 体重計から Fit へ同期するアプリケーションなんかを書くときに役立つはずだ)。

Google はウェブの会社で、ユーザーデータの重要性はよくよく理解していると思われるので、サービス終了の際にエクスポートをちゃんと提供することが期待できる。一方でオムロンにそれは期待することはできない。PC版の閲覧サービス終了させてきたしね。

  1. トップ
  2. tech
  3. Google Fit の REST API で体重を自動入力する

自作キーボードのコネクタとして 8P8C を使っていて、市販のLANケーブルを流用しているのだけれど、特定のLANケーブルで動作せず悩んだ。

結局は表題の通り、8本のうち4本しか結線されていないLANケーブルだったのが原因だった。

こういうケーブルは「カテゴリー5相当」と言う類のものらしい。

カテゴリー6の時代に今更こんなケーブルは売ってないと思うが、古いLANケーブルには注意しましょう。普通にイーサネット的にはリンクアップはするので罠いです。

コネクタ流用でこんな罠があるとは思わなかった。

  1. トップ
  2. tech
  3. 4本しか結線されてないLANケーブルでハマった