(画像は過去に入力したデータを全て 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ケーブルでハマった