風もなく気温もひくくないすばらしいおてんきのなかしずかにすごした小連休ふつかめでした.

昨晩は睡眠不足の状態で一日歩き回っておひるねもがまんしたしさすがにねむれるだろうと 2 時まえにお布団へはいったのですが一向にねむけがこず,3 時ごろようやくまどろみはじめたと思ったらまたすぐに目が覚めてしまったのであきらめて追加の魔法薬を飲みました.こういう日にかぎって縦方向のおとなりさんもしんじられないほどうるさい.なまじ人となりを知ってしまったぶん以前ほど強い怒りは湧きませんが,それにしたって生活リズムほんとうにどうなっているんだ.10 時半の起床もとうていこころよいとはいえないものでした.

起きてからは先日買ったアンティークのカップでコーヒーを飲んで,ここしばらく洗えていなかった寝具類を丸洗いしてからひさしぶりに魔導士として魔力をたかめるべくカワイイ・ダッシュボードの改造にとりかかりました.カワイイ・ダッシュボードとは時間から今日の総歩数までくらしにまつわるあらゆる値の表示をめざしてちまちまつくっているサイネージで,実体はたんなる Grafana ダッシュボードとそのデータを取り扱う Go の HTTP サーバです.今回の変更では先日スマート温湿度計の台数を増やしたのを反映するついでに歩数を Google Fit の API 経由でとるようにしてみました.

というのもこの歩数というのがいがいと曲者で,iOS のヘルスケアには HTTP API がないためこれまでは iPhone のショートカットで取得したヘルスケアの情報をいったん pixela へ送ってから pixela の API 経由で取り出すややこしい構成でなんとかしていました.しかしこれだと iPhone のロック中にデータを取得するのに毎回手動で許可する必要があって非常にめんどうなのと,ショートカットには定期実行の機能がないためあらかじめ指定したタイミング(毎日 20:00 とワークアウトを終了したタイミング)でしか値を更新できずリアルタイム性に欠けるという問題がありました.これらの問題を解消する方法がないかしらべてみたところ Google Fit を利用するという方法があるらしい.ヘルスケアのデータとも連携できますし,なにより Google Fit には API がある!

ということで Google Fit の API を試してみたのですが,たかが歩数ひとつ取り出すのにもだいぶめんどうでした.とりあえずコード見ていただくのが早いと思います:

package main import ( "context" "encoding/json" "errors" "net/http" "time" "log" "os" "github.com/gin-gonic/gin" "github.com/joho/godotenv" "github.com/nasa9084/go-switchbot" uuid "github.com/satori/go.uuid" "golang.org/x/oauth2" "golang.org/x/oauth2/google" fitness "google.golang.org/api/fitness/v1" "google.golang.org/api/option" ) type GetStepsResponse struct { Steps int `json:"steps"` } var REDIRECT_URI = "http://localhost:8080/token" var SCOPES = []string{ "https://www.googleapis.com/auth/fitness.activity.read", } var STATE = uuid.NewV4().String() func newCfg(clientId, clientSecret, redirectURI string, scopes []string) *oauth2.Config { if oauthConfig != nil { return oauthConfig } return &oauth2.Config{ ClientID: clientId, ClientSecret: clientSecret, RedirectURL: redirectURI, Scopes: scopes, Endpoint: google.Endpoint, } } func saveToken(token *oauth2.Token) error { d, err := json.MarshalIndent(token, "", " ") if err != nil { log.Printf("failed to marshal token to JSON: %s", err) return err } err = os.WriteFile("token.json", d, 0666) if err != nil { log.Printf("failed to write token.json: %s", err) return err } return nil } func loadToken() (*oauth2.Token, error) { b, err := os.ReadFile("token.json") if err != nil { log.Printf("failed to read token.json: %s", err) return nil, err } token := &oauth2.Token{} err = json.Unmarshal(b, token) if err != nil { log.Printf("failed to unmarshal token.json: %s", err) return nil, err } return token, nil } func getLogin(ctx context.Context) string { return oauthConfig.AuthCodeURL(STATE, oauth2.AccessTypeOffline) } func getToken(ctx context.Context, state, code string) (*oauth2.Token, error) { if state != STATE { log.Print("state value is not matched") return nil, errors.New("invalid request") } token, err := oauthConfig.Exchange(ctx, code) if err != nil { log.Printf("failed to exchange token: %s", err) return nil, err } err = saveToken(token) if err != nil { log.Printf("failed to save token: %s", err) return nil, err } return token, nil } func getSteps(ctx context.Context) (*GetStepsResponse, error) { token, err := loadToken() if err != nil { log.Printf("failed to load token: %s", err) return nil, err } client := oauthConfig.Client(ctx, token) svc, err := fitness.NewService(ctx, option.WithHTTPClient(client)) if err != nil { log.Printf("failed to instantinate NewService: %s", err.Error()) return nil, err } now := time.Now() startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) endTime := startTime.Add(time.Hour * 24) steps, err := svc.Users.Dataset.Aggregate("me", &fitness.AggregateRequest{ AggregateBy: []*fitness.AggregateBy{ {DataTypeName: "com.google.step_count.delta"}, }, BucketByTime: &fitness.BucketByTime{ Period: &fitness.BucketByTimePeriod{ Type: "day", Value: 1, TimeZoneId: "GMT", }, }, StartTimeMillis: startTime.UnixMilli(), EndTimeMillis: endTime.UnixMilli(), }).Do() if err != nil { log.Printf("failed to get raw data: %s", err) return nil, err } s := 0 for _, b := range steps.Bucket { for _, d := range b.Dataset { for _, p := range d.Point { for _, v := range p.Value { s += int(v.IntVal) } } } } return &GetStepsResponse{ Steps: s, }, nil } func main() { err := godotenv.Load("./.env") if err != nil { log.Fatalf("Error loading .env file: %s", err) } oauthConfig = newCfg(os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET"), REDIRECT_URI, SCOPES) r := gin.Default() r.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusFound, getLogin(c)) }) r.GET("/token", func(c *gin.Context) { code := c.Query("code") state := c.Query("state") _, err := getToken(c, state, code) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, "^o^") }) r.GET("/moe", func(c *gin.Context) { c.JSON(http.StatusOK, ";o;") }) r.GET("/health/steps", func(c *gin.Context) { res, err := getSteps(c) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, res) }) r.Run() }

まず Google Fit の API は他サービスとは違い OAuth2.0 での認可にしか対応しておらず,さらに OAuth でトークンもらうためには GCP でプロジェクト作って OAuth の同意画面から設定するところからやらないといけないのでだいぶめんどうでした.同意画面を勘で設定したらあとから許可するリダイレクト先が編集できず詰みかける場面がありつつ最終的にリダイレクト先を自分自身に設定してそのまま発行したトークンを保存して使うという実装にしましたが,これであっているのかはよくわかりません.とはいえ標準の OAuth2.0 の認可フローに準拠しているし一度トークン発行してしまえばあとは oauth.Exchange() で勝手にトークンリフレッシュまでやってくれるのでラクではあります.歩数とか消費カロリーなんてこの世でもっともどうでもいい情報のひとつなんだし API キーひとつでサクッとアクセスさせてくれと思わなくもないですがたぶんそうではないのでしょうね.

といった紆余曲折がありつつこれでようやく Google Fit の API にアクセスできると思ったら,今度は歩数を取り出すのもそれなりの苦労がありました.datapoint の集合である Dataset にたいして Service.Users.Dataset.Aggregate() で期間や種類を指定する Bucket という単位で集計操作が行えるのですが,結果のスキーマがドキュメントにあんまりちゃんと書かれていないしネストが深くて非常につらい.ループが四段になっている箇所については見なかったことにしてください.バケットの範囲が最大 24 時間という実装回数はたかがしれているでしょう……たぶん.

ともかくこれでようやくやりたいことができたので RasPi で動かそうと思ったらなぜか git pull できない!なんとストレージの空きがなくなってしまったらしい.さすがに 16GB だとなにをするにも足りないですね.キャッシュを消したりリブートしてみたりといろいろためしてみたもののいまいち有効打が打てず,そのうちまともにさえまともに動作しなくなってしまいました.しかたがないので手元の余っている MicroSD カードにディスクイメージをコピーしようと思って在庫を漁ったら 2GB とかのしょぼいものしか残っておらず,このあたりで魔力も底をついたので今日の修行は終わりです.結局カワイイ・ダッシュボードはうごかないままなのでぜんぜん意味がなかったですね,はやいところなんとかしないといけません.

あとはいつもどおりおさんぽにでかけたくらいです.飛び石でおやすみにするひとも多いとはいえいちおう平日なのですし,こういう日こそ遠出すべきだったのかもしれないなと考えながらもうすっかり歩き飽きてしまったおさんぽコースを一周し,パンやさんで食パンを買って帰りました.めずらしく無料のパンの耳が残っていたのがよかったです.晩は超銀河中華あん α がいたむまえに食べきろうと中華麺を焼いたら思ったよりも大盛りになってしまったので,景気付けにすこしだけげんきドリンクも飲みました.

せっかくのおやすみだし最後はカワイイ・チューンでおわかれです:

【天華百剣 -斬-】キャラソン『刀身をやさしくタップ ♡』試聴動画(4 月 17 日発売アルバム『百華繚乱』より) - YouTube

天華百剣という刀剣をモチーフにした美少女コンテンツの楽曲で,読んでいるボルゴウで Earth, Wind & Fire の September とマッシュアップしたバージョンが紹介されていたので知りました.しゃきしゃきしたカッティングのギターにあわいボーカルがからむかんじはたしかにファンクとリミックスするのもうなずけるカワイさです.なんというかこういう“らしい”サウンドを美少女や萌えとからめて再演する,もっとあけすけにいえば「〜っぽい感じで」といった具合に発注されたんだろうなというのがたやすく想像できるのが好きでたまらない.曲調のわりに BPM が高くてじゃっかんリミックス風味というか NightCore っぽささえあるのもいい感じです.

以上です.今日こそぐっすり寝たいのですがいったいどうしたらよいのでしょうか.どうもありがとうございました