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

どんな分野でもまず「温度感」をつかむのが大事だと思っていて、そもそも「温度感」って何かというと、どの部分の神経を使うか、熱量を使って考えて、どこの手を抜くかという話です。「コツ」を少し具体的に表現して「温度感」といっている、というとちょっと身も蓋もないのですが。

3Dプリンタのモデル設計の温度感

ここでいう 3D プリンタは普及している FDM (Fused Deposition Modeling・熱溶解積層法) の話です。FDM は基本的に X/Y 精度が悪く、Z精度はそれに比べると高いという特徴があります。

具体的には X/Y については±0.5mmぐらいで、Zは±0.1mmで考えます。特に X/Y の精度の悪さは設計難易度にかかってきます。同一条件で出力したパーツを組合せる分にはともかく、他の規格品、例えばナットや、あるいは三脚ネジのようなものを出したいときは、だいたいの場合は何度かプリントして現物あわせをする必要があるのです。

±0.5mm これはキャリブレーションしてフィラメント量を調節していてもなお生じる誤差と考えたほうが良くて、キャリブレーションしていなければもっと精度が出ません。

動くすきまを確保したい場合

基本的に動く面すべてに 0.5mm のトレランスをもうけます。つまり引き出しみたいなものなら、1mm 小さい立体を入れるようにします。

ぴったり圧入する場合

パズルのピースのようにハメこむ場合は、0.25mmのトレランスをもうけます。四角に四角をハメる場合 0.5mm 小さい立体になります。しかし実際には素直にうまくいくことは少ないでしょう。絶対に失敗しない方法は 0.2mm 程度のトレランスで、出力してから削ることです。

圧入して固定するような場合、これはかなり難しいですが、形状で工夫すると楽になります。設計上は、内接多角形の柱と同一内径の穴をモデリングして無理矢理ハメこむのが楽です。

  1. トップ
  2. tech
  3. 3Dプリンタで出力するモデル設計の温度感

しばらくすると忘れてしまうので、現状のフローをまとめておく。

  1. pcb2gcode の millproject
  2. AutoLeveller は基本的には常時使うほうが結果的に早い。DIP しか使わないような電源回路では使わなくてもなんとかなるけど使ったほうが安心。
  3. 基板外形のカッターは 1.5mm が適当。1mm だと 500mm/min でフィードするとちょいちょい折れてストレス
  4. パターン切削は30度0.1mmのカッター

概要

  • KiCAD からの出力
  • pcb2gcode の設定
  • machinekit (LinuxCNC) での操作

KiCAD でのデザインルール

0.25mm 幅で削るので、これが限界。0.05mm でもトレランスをとれれば安定しやすい。

  • 最小配線幅は 0.25mm が限界。できれば 0.3mm のほうが安定。
  • クリアランスも 0.25mm が限界。できれば 0.3mm ぐらいにしたい。

KiCAD からのエクスポート

- 切削する銅箔層 (F.Cu or B.Cu) と Edge.Cut をガーバーに出力する
- ドリルファイルを出力する

pcb2gcode

以下のような前提で gcode 化する

  • 30° 先端0.1mm のVカッターを掘る用に使う
  • φ0.8mm のエンドミルを全ての穴開けに使う
  • φ1.5mm のエンドミルかφ1mmで2枚刃を外形カットに使う
  • 基板の厚さは 0.8mm か 1.6mm を使う。基板の歪みを矯正できるぶん、0.8mm のほうが細かいパターンは削りやすく感じる。

先端0.1mmのエンドミルといっても、実際買ってみると綺麗に 0.1mm になっていることはすくない。というかすぐ先端が折れる。計算するときは 0.15〜0.20mm として扱う。

切削速度を落とせばφ0.8mmで穴開け〜外形カットまで可能で、ツール取り替えの手間が減らせるはずだけど、0.8mm のミルは高価であんまり折りたくないので、別途φ1.5mmのミルを使っている。これは今後 0.8mm にするかも。あんまり太いミルで外形カットすると切削抵抗が大きくなり、両面テープが剥れてずれる可能性もあるので注意。

# Use standard mm
metric=true
metricoutput=1

front=tachometer-B_Cu.gbr
#back=
outline=tachometer-Edge_Cuts.gbr
drill=tachometer.drl

front-output=front.gcode
back-output=back.gcode
outline-output=outline.gcode
drill-output=drill.gcode
milldrill-output=milldrill.gcode

# https://github.com/chrysn-pull-requests/pcb2gcode/blob/graphical-documentation/man/options.svg
zwork=-0.1
zsafe=1
mill-feed=220
mill-speed=10000
mill-vertfeed=150
mill-diameters=0.22
milling-overlap=25%
isolation-width=0.5

zdrill=-1.7
zmilldrill=-1.7
zchange=20
drill-feed=100
drill-speed=10000
min-milldrill-hole-diameter=0
milldrill-diameter=0.8

# 外形カット時のミル直径
cutter-diameter=1
# PCB板厚+0.1mm
zcut=-1.7
cut-feed=500
cut-speed=10000
cut-infeed=1
cut-side=front
# mirror-axis=51.575mm

optimise=true
zero-start=true

pcb2gcode 2.0.0 で動くように変更

pcb2gcode して生成 gcode をデスクトップとリモートの Machinekit に転送する。デスクトップにコピーしているのは CAMotics で読みこませて確認するときに便利なので。

  • mirror-axis を基板サイズの半分に設定すること
    • これやらないと back 面がマイナスからはじまってしまう
    • 一度実行して表示される width を元に書きかえる必要がある
~/ghq/github.com/pcb2gcode/pcb2gcode/pcb2gcode && cp *.gcode ~/Desktop/ && scp *.gcode machinekit@192.168.0.240:gcode

確認

  • CAMotics を起動して、すべてのファイルを読みこむ。
  • Workpiece を以下のように設定して再描画
    • X: xxx/0
    • Y: xxx/0
    • Z: 1.6/-1.6

段取り

  • 事前に捨て板の面出しをする (φ6mm ぐらいのミルで加工面全てを 0.2mm ほどさらう)
    • AutoLeveller 使うなら毎回やる必要はない。
  • ニチバン ナイスタック 透明タイプ (材質がセロハンのもの) を使って切削基板を捨て板に固定する
  • このときしっかり全面を押さえつけて捨て板に接着する (加工時の圧力で高さが変わらないように)

AutoLeveller

  • AutoLeveller に切削対象 gcode を読みこませる
  • Probe Clearance は 0.5〜1、Z Safe Height は 1、Z feed は 50 にする
  • create probe file only にチェックを入れて、Create Levelled Gcode ボタンを押すと、ALProbeback.ngc ができる
  • ALProbeback.ngc を machinekit に読みこませる
  • 一旦 Probe を行い、Z を Touch Off する (原点を設定する)
  • 実行して基板全体を Probe する。
  • .ini と同じディレクトリに RawProbeLog.txt ができているので、ローカルに転送する
    • gcode 用のディレクトリに RawProbeLog.txt への symlink を貼っておくと便利
  • RawProbeLog.txt を AutoLeveller に読みこませる
  • create probe file only にチェックをはずして Create Levelled Gcode を押す
  • ALback.ngc ができるので転送する

drill や外形カットなどは面倒なので AutoLeveller はかけず、0.1mm 余計に掘る。

https://lowreal.net/2016/10/19/1

AutoLeveller の起動

java -cp ~/ghq/bitbucket.org/daedelus1982/autoleveller/out/artifacts/autoleveller_jar/autoleveller.jar  com.cncsoftwaretools.autoleveller.Autoleveller

生成 gcode の転送

scp ~/Desktop/*.ngc machinekit@192.168.0.240:gcode 

プローブログを手元に転送

scp machinekit@192.168.0.240:gcode/RawProbeLog.txt ~/Desktop/

切削手順

基板パターン

  • ALback.ngc を machinekit に読みこませる
  • 実行すると Probe をつけろと言われるので、つけて Resume
  • Probe後、Probe をはずせと言われるので、はずして Resume
  • 切削がはじまる

ドリル

  • エンドミルをφ0.8mmに交換
  • Probe を行い、Z Touch Off
  • Feed Override を 10% ぐらいに下げる (折れ対策。pcb2gcode が mill 時と milldrill 時でフィードレートが変えられないので)
  • drill.gcode を読みこんで実行

外形

  • エンドミルをφ1.5mmに交換
  • Probe を行い、Z Touch Off
  • outline.gcode を読みこんで実行

備考

pcb2gcode の --al 系オプションは使わないの?

ある程度規模が大きくなると machinekit に読みこませたときに非常に時間がかかってしまうので、その時間を使うぐらいなら AutoLeveller を使ったほうがよいと考える。時間がかかるのはプレビューのために gcode を一通り仮想的に実行しているためだと思う。

また、--al のオプションは Probe → 切削 がひとつの gcode で行われるため、リトライすることができない。途中でミルが折れたりすると交換してやりなおすということができなくなる。

エンドミルの不具合

基板切削をはじめた直後に削った銅がけばだつようだったらミル先端が既に折れている可能性がある。そのまますすめてもうまくいかないので新しいのに変えること

または確認しても折れていないようなら、ミルの突き出し量を見直すこと。ぎりぎりまでマシンに指しこんでいないと、同様にけばだつことがある。

  1. トップ
  2. tech
  3. KiCAD + Machinekit (LinuxCNC) で切削して基板をつくるときの手順

秋月でLPC11U35 が乗っているボードを買った。うっかり3枚買っていた。EA LPC11U35 QuickStart Board
と互換のもの。

RAM や Flash が少なめだけど以下の点で魅力的なボード

  • mbed 対応
  • 850円と安価
  • 単体で USB 経由で書きこめる
    • デバッグするならシリアルは別途繋ぐ必要あるけど
  • 別のチップに対して USB SWD インターフェイスになれる

コード的な備考を先に書いておく

  • LED1 〜 LED4 は全部 P0_7 のエイリアスになっている。
  • USBTX/USBRX はサポートされない

Lチカのコードは何の変哲もない。

#include "mbed.h"

DigitalOut led(LED1);
// Serial serial(UART_TX, UART_RX);

int main() {
	for (;;) {
		led = 1;
		wait(0.5);
		led = 0;
		wait(0.5);
	}
}

ビルドするも動かない

platformio でビルドしたのを書きこんでもさっぱり動かず、再度 CRP DISABLED というボリュームがマウントされてしまう。オンラインコンパイラでは動くので、手元の環境の問題であることはわかったが、なかなか原因がわからなかった。

結局 platformio は mbed OS 5 の環境でビルドしようとするが、LPC11U35 では RAM が足りず起動できないようだ。

実際、mbed の公式を見て mbed OS 5 系に対応するボードをリストにすると (そういうことができることにはじめて気付いたが)、LPC11U35 は出てこない。

platformio でフレームワークのバージョンを指定してビルドする方法がどう調べてもわからなかった。

mbed cli を使う

そういうことで、いろいろ試したけど、あきらめて platformio のことは忘れましょう。

ARM 公式で提供されている mbed-cli をいれるのが今のところは最良のようです。

インストール

まず Python のツールなので python が必要なのと、pip も必要です。ここでは

  • python は macOS のシステムに入っているものを使う
  • pip はグローバルに入れる
  • mbed-cli もグローバルに入れる
  • ほかだいたいの依存モジュールはユーザー領域にいれる (pip --user)
    • 既存の setuptools をアップグレードしようとするのだが OS 保護に守られて root でも上書きできないので、基本的に --user で入れたほうが良い。

という感じでいきます。

curl https://bootstrap.pypa.io/get-pip.py | sudo python
sudo pip install mbed-cli IntelHex

mbed-cli には GCC_ARM のツールチェーンを入れてくれる機能はなく、前もって arm-none-eabi-gcc とかを入れておく必要がある。https://launchpad.net/gcc-arm-embedded とかを入れてパスを通しておく。

もしくは、platformio で既に入れてあるのがある場合は以下のようにしてパスを通せば使える。

export PATH=$HOME/.platformio/packages/toolchain-gccarmnoneeabi/bin:$PATH

プロジェクト作成

今回使う LPC11U35 は mbed OS 5 未対応のため、mbed 2 (いろいろ別名がある。mbed library / mbed classic など) を使うようにプロジェクトを作る。--mbedlib を指定すると mbed 2 環境になる。

sketch はプロジェクト名。

mbed new sketch --mbedlib
cd sketch
vim main.cpp
mbed deploy
pip install --upgrade --ignore-installed --user -r .temp/tools/requirements.txt
mbed compile -t GCC_ARM -m LPC11U35_401
# LPC11U35 を USB かきこみモードにして
cp ./BUILD/LPC11U35_401/GCC_ARM/sketch2.bin /Volumes/CRP\ DISABLD/firmware.bin 
#リセットボタンで動く

しかし mbed-cli は凶悪で、プロジェクトディレクトリ以下に 2GB ぐらいファイルをコピーしてくる (mbed 2 の全ソースコード)。

自分は複数端末 (ノートとデスクトップ) で開発する関係で Dropbox のディレクトリで同期させておくことが多いのだが、これだと死ねる。つらい。

ピン名など

回路図

見比べると結構違う。

  • Q1 の違い。秋月版はFET
  • 3.3V レギュレータの違い
  • リセットICの違い
  • USB 入力の保護の違い

~/.platformio/packages/framework-mbed/targets/TARGET_NXP/TARGET_LPC11UXX/TARGET_LPC11U35_401 以下にある。

https://os.mbed.com/users/mbed_official/code/mbed-dev/file/57724642e740/targets/TARGET_NXP/TARGET_LPC11UXX/

  1. トップ
  2. tech
  3. LPC11U35 の mbed ローカル開発環境構築

LM1972 デジタルボリューム | tech - 氾濫原 の続きで、Arduino ではなくmbed環境での実装。

LPC11U35 で試した。AD1 にBカーブボリュームをつけてこれによって 78dBから0dbまでアッテネーションレベルをかえる。

#include "mbed.h"

// mosi miso sck
//SPI spi(P0_9, P0_8, P0_10);
//DigitalOut cs(P0_11);

class LM1972 {
    SPI spi;
    DigitalOut cs;
    const uint8_t daisy_chain;

public:
    static const uint8_t MUTE = 0xff;

    // uint16_t to 1dB step attenuation value
    static inline uint8_t volumeToAttenuation(const uint16_t v) {
        // volume 0%   -> 78dB (126)
        // volume 100% -> 0dB (0)
        const uint8_t dB =  ((uint32_t)(1023 - v) * 78 / 1023);
        uint8_t att = 0;
        if (dB < 48) {
            att = 2 * dB;
        } else {
            att = 96 + dB - 48;
        }
        return att;
    }

    static inline uint8_t volumeToAttenuation(const float v) {
        // volume 0%   -> 78dB (126)
        // volume 100% -> 0dB (0)
        const uint8_t dB =  78 * v;
        uint8_t att = 0;
        if (dB < 48) {
            att = 2 * dB;
        } else {
            att = 96 + dB - 48;
        }
        return att;
    }

    LM1972(
            PinName _mosi,
            PinName _miso,
            PinName _sck,
            PinName _cs,
            uint8_t _daisy_chain = 1
          ) :
        spi(_mosi, _miso, _sck),
        cs(_cs),
        daisy_chain(_daisy_chain)
    {
        spi.format(8, 0);
        spi.frequency(1e6);
        cs = 1;
    }

    inline void setAttenuation(const uint8_t channel, const uint8_t v) {
        cs = 0;
        // >150ns
        wait_us(2);
        spi.write(channel);
        spi.write(v);
        // >150ns
        wait_us(2);
        cs = 1;
    }

    uint8_t setVolume(const float v) {
        const uint8_t att = volumeToAttenuation(v);
        for (int i = 0; i < daisy_chain; i++) {
            setAttenuation(0, att);
            setAttenuation(1, att);
        }
        return att;
    }

    uint8_t setMute() {
        for (int i = 0; i < daisy_chain; i++) {
            setAttenuation(0, MUTE);
            setAttenuation(1, MUTE);
        }
        return MUTE;
    }
};

DigitalOut led(LED1);
Serial serial(UART_TX, UART_RX);
AnalogIn pot(P0_12); // AD1

LM1972 volume(P0_9, P0_8, P0_10, P0_11);

int main() {
    serial.baud(9600);
    serial.printf("init\r\n");

    volume.setMute();

    for (;;) {
        led = 1;
        wait(0.5);
        led = 0;
        wait(0.5);

        float val = pot.read();
        serial.printf("pot val = %d\r\n", (int)(val * 100));
        uint8_t att = volume.setVolume(val);
        serial.printf("set att = %x\r\n", att);
    }
}