Inside of LOVOT

GROOVE X 技術ブログ

LOVOTのお出迎え機能の中国向け対応について

こんにちは、APPチームの黒田です。 この記事は GROOVE X Advent Calendar 2023 の24日目の記事です。
LOVOTは2023年より中国での販売を開始しました。今回は、それに関連してLOVOTアプリの中国向け対応、中でも特に位置情報を使った機能の対応について紹介します。

アプリで中国向け対応が必要な機能

アプリの中国対応と聞いてすぐ思い浮かぶことと言えば、Googleのサービスが使えない、ということが挙げられますね。
LOVOTアプリでは、主にLOVOTのお出迎え機能の実現のため、位置情報を取得したりGeofenceの機能を利用する必要がありますが、Androidで位置情報取得といえば、通常、Google Play services の一部である Location API を利用することになります。
我々がよく見るおなじみの位置情報サービスの設定画面、こちらは Google Play services の機能によって実現されているという訳です。

おなじみの画面

さて、これが中国本土向けのAndroid端末だとどうなるかと言えば、端末によってはこの画面がある場合もあるし、無い場合もある、ということになっているようです。
GMS(Google Mobile Service)がインストールされた端末であれば Google Play が利用可能、また Xiaomi 端末にて Basic Google services の設定をオンにすることでGoolge位置情報サービスが有効化されるものもあったりします。

みなれない設定(Xiaomi端末)

なんにせよ、中国本土においては Google の Location API が利用できるという前提で開発ができない、ということになりますので、位置情報サービスが利用できるよう、別の手段を用意しなければなりません。
(なおiOSの場合は、何もしなくても日本国内向けアプリで実装した位置情報取得や Geofence の機能がそのまま使えます。素晴らしい。)

その他、FCM(Firebase Cloud Messaging)が使えない、Firestoreも使えない、Firebase Authentication を直接利用することも出来ない、電話番号によるログインがほぼ必須、などといった問題もあり、それぞれ対応が必要でしたが、以下、この記事では位置情報サービスの利用に話題を絞ります。

中国本土での位置情報サービスの利用

中国本土で位置情報サービスを使いたい、となると、中国で地図サービスを提供している企業がが開発者向けに用意している SDK を利用するのだろう、ということになります。
地図サービスの主なプロバイダーとして、百度地图(Baidu)高德地图(Amap)腾讯地图(Tencent) などがありますが、これらはどれも開発者向け SDK を提供しています。

それらの中から目的に合ったものを選んで利用すれば良さそうですね、という訳で、LOVOTアプリでは Baidu の SDK を利用することとなりました。
今回の記事では位置情報や Geofence の話題に絞っていますが、アプリでの地図データの取得にも Baidu のサービスを利用しています。中国での地図アプリのシェアは高德地图と百度地图が2大巨頭ですので、Baidu の地図を利用しておけばユーザーにとって馴染みのある表示にできる、ということもありますし、クオリティーにも信頼がおけそうです。
Baidu でなく高德地图の利用を検討することも可能でしたが、(今回の記事ではサービスの利用申請に関しては触れませんが)各サービスの利用のため開発者登録を行うためには、企業認証の申請を通すため中国法人側で書類を用意したりと手続きもやや面倒ですし時間もかかります。そういったこともあり、Baidu の SDK が必要な機能を満たしていること、また費用面でも高德地图と同等であるということが確認できたため、そのまま Baidu の SDK を利用しています。

座標系について

ここから実際の SDK の利用について説明したいのですが、その前に中国本土内での位置情報の座標系について触れておきます。
我々が普段地図で見ている緯度経度、開発者であれば Location API で取得したり、Maps API で地図データを取得するのに利用している緯度経度の情報は、WGS-84 と呼ばれる座標系における値となっています。
正直、私もそのようなことは意識したこともなかったのですが、ここで中国本土においては GCJ-02 と呼ばれる別の座標系が採用されている、ということが問題になります。
WGS-84 と GCJ-02 の相互変換アルゴリズムは公式には公開されていないため、簡単に言えば、中国本土内にてある位置の地図を取得しようと思えば、中国国内で提供されているサービスを使わざるを得ない、ということになります。
(例えば、中国本土でどうしても Google Maps API を利用したければ、中国のクラウドサービス上に Google Maps API へアクセスするProxyを実装する、といった荒業も可能かもしれませんが、それで取得できたマップは正しくないので意味がない、ということになります。)
座標系の問題も、Baidu などの SDK を利用せざるを得ない理由の一つという訳です。

左: Google Mapで取得した地図(赤丸が中心点)
右: Baiduで取得した同じ座標の地図(赤丸がGoogle Mapでの中心点)

さて、Baidu の SDK により位置情報を取得した際は、座標系も一緒に返されるようになっています。ここで、必ずしも GCJ-02 で取得できるとは限らない、というのもポイントです。SDK を利用して位置情報の取得をテストしてみると、WGS-84 と GCJ-02、どちらで取得されることもある、ということが分かります。
Geofence の登録時にも座標系を一緒に登録できるようになっており、SDK 内では座標系の相互変換が可能なため、WGS-84 と GCJ-02、どちらで取得された位置情報であっても、正しく Geofence が動作する、ということになっているようです。
(実は、更に BD-09 などという Baidu 独自の座標系もあるのですが、ともかく Baidu の SDK を利用している限りこれ以上気にすることはありません)

SDKの導入

SDKの導入については、ドキュメントにコード例も記載されているので、基本的にそれに従って実装すれば良い、となります。
とはいえ実際に実装してみると上手く動かない、ということもあると多々思いますので、以下、LOVOTアプリの場合のコード例とともに、幾つかポイントについて説明しておきたいと思います。
前提として、LOVOTのお出迎え機能がオンの間は、常にスマホの Geofence のイベントを監視していたいため、Foreground Service を起動するようにしています。以下のコード例は、そのサービスのコードからの関連部分を抜粋し編集したものであり、実際のコードとは異なります。
なお、LOVOTアプリは Unity で作られているため、これらの機能は Unity のネイティブプラグインとして実装されています。

public class LocationUpdateService extends Service {
    private GeoFenceClient geofenceClient;
    private LocationClient locationClient;
    private LocationUpdateCallback currentCallback;
    private GeofenceBroadcastReceiver geofenceBroadcastReceiver;

    interface OnLocationResultListener {
        void onReceiveLocation(BDLocation location);
    }

    public class LocationUpdateCallback extends BDAbstractLocationListener {
        OnLocationResultListener listener;

        public void Initialize(OnLocationResultListener listener) {
            this.listener = listener;
        }

        @Override
        public void onReceiveLocation(BDLocation location) {
            listener.onReceiveLocation(location);
        }
    }

    @Override
    public void onCreate() {
        // LocationClient や GeoFenceClient の初期化を行う
        // 国内向けアプリでは FusedLocationProviderClient の取得のみを行っていたところ

        super.onCreate();

        try {
            LocationClient.setAgreePrivacy(true);
            locationClient = new LocationClient(this);
        } catch (Exception e) {}
            return;
        }

        LocationClientOption option = new LocationClientOption();
        option.setLocationMode(LocationClientOption.LocationMode.Hight_Accuracy);
        option.setCoorType("gcj02");
        option.setFirstLocType(LocationClientOption.FirstLocType.ACCURACY_IN_FIRST_LOC);
        option.setScanSpan(60 * 1000);
        option.setOpenGnss(true);
        option.setLocationNotify(true);
        option.setIgnoreKillProcess(true);
        option.SetIgnoreCacheException(false);
        option.setWifiCacheTimeOut(60 * 1000);
        option.setEnableSimulateGnss(false);
        option.setNeedNewVersionRgc(true);
        locationClient.setLocOption(option);

        IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
        // Geofence イベントの通知を受ける GeofenceBroadcastReceiver は別途実装しておく
        filter.addAction(GeofenceBroadcastReceiver.ACTION_PROCESS_UPDATES);
        geofenceBroadcastReceiver = new GeofenceBroadcastReceiver();
        registerReceiver(geofenceBroadcastReceiver, filter);

        geofenceClient = new GeoFenceClient(this);
        geofenceClient.setActivateAction(GEOFENCE_IN_OUT);
        geofenceClient.createPendingIntent(GeofenceBroadcastReceiver.ACTION_PROCESS_UPDATES);
        // 条件が満たされれば事実上無限に Geofence を発火させるようにしておく
        geofenceClient.setTriggerCount(100000, 100000, 0);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();

        if (locationClient != null) {
            locationClient.stop();
        }
        if (geofenceBroadcastReceiver != null) {
            try {
                unregisterReceiver(geofenceBroadcastReceiver);
            } catch (Exception ex) {}
        }
        if (geofenceClient != null) {
            geofenceClient.removeGeoFence();
        }
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent.getAction().equals(START_ACTION)) {
            start(intent, startId);
            return START_REDELIVER_INTENT;
        } else {
            stop(intent, startId);
            return START_NOT_STICKY;
        }
    }

    private void start(Intent intent, int startId) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForegroundWithNotification();
        }
        startGeofencing();
        startLocationUpdates();
    }

    private void stop(Intent intent, int startId) {
        if (geofenceClient != null) {
            // removeGeoFence() で削除するのではなく Geofence 登録を残したまま pause & resume で制御するようにしている
            geofenceClient.pauseGeoFence();
        }
        stopLocationUpdates();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            // onStartCommand 後、5秒以内に startForeground が呼ばれないとANRとなる
            startForegroundWithNotification();
            stopForeground(true);
        }

    }

    private void startForegroundWithNotification() {
        Notification notification = createNotification(this);
        startForeground(1, notification);
    }

    public Notification createNotification(Context context) {
        // Notification を生成して返す
    }

    private void startLocationUpdates() {
        if (currentCallback != null) {
            stopLocationUpdates();
        }

        // 実際にはここでGPSオンの確認や、位置情報取得パーミッションのチェックを行う

        final GeoFenceClient geofenceClient = this.geofenceClient;
        final LocationUpdateCallback callback = new LocationUpdateCallback();
        OnLocationResultListener listener = new OnLocationResultListener() {
            @Override
            public void onReceiveLocation(BDLocation location) {
                int locType = location.getLocType();
                // https://mapopen-pub-androidsdk.cdn.bcebos.com/location/doc/v9.4.0/constant-values.html
                // TypeGpsLocation 61
                // TypeNetWorkLocation 161
                // TypeOffLineLocation 66
                boolean isSucceeded = locType == BDLocation.TypeGpsLocation || locType == BDLocation.TypeNetWorkLocation || locType == BDLocation.TypeOffLineLocation;
                if (isSucceeded) {
                    // 適宜ログ出力するなど
                }

                // pause 状態になっていることがあったので、resume するようにしている
                if (geofenceClient.isPause()) {
                    geofenceClient.resumeGeoFence();
                }
            }
        };
        callback.Initialize(listener);
        currentCallback = callback;

        locationClient.registerLocationListener(callback);
        locationClient.start();
        geofenceClient.resumeGeoFence();

    }

    private void startGeofencing() {
        // SharedPreferences に保存済みの設定データ取得し、設定に従って Geofence を登録するようにしている
        // (実際には複数の Geofence 設定が存在する場合があるのでそれぞれ登録)
        // また、削除されていた設定に対応するGeofenceの解除も行うようにしている

        JSONObject config = getGeofenceConfig();
        final double latitude = config.getDouble("Latitude");
        final double longitude = config.getDouble("Longitude");
        final float radius = 100F;

        final GeoFenceClient geofenceClient = this.geofenceClient;
        GeoFenceListener fenceListener = new GeoFenceListener() {
            @Override
            public void onGeoFenceCreateFinished(List<GeoFence> geoFenceList, int errorCode, String customId) {
                geofenceClient.setGeoFenceListener(null);
                if (errorCode == GeoFence.ADDGEOFENCE_SUCCESS) {
                    // 適宜ログ出力するなど
                }
            }
        };

        geoFenceClient.setGeoFenceListener(fenceListener);
        DPoint centerPoint = new DPoint(latitude, longitude);
        geoFenceClient.addGeoFence(centerPoint, GeoFenceClient.WGS84, radius, customId);
    }

    private void stopLocationUpdates() {
        if (locationClient == null || currentCallback == null) {
            return;
        }
        locationClient.stop();
        locationClient.unRegisterLocationListener(currentCallback);
        currentCallback = null;
    }
}

イベント受信側については、BroadcastReceiver で受け Intent の Action や Extra にイベント種別など必要な情報が格納されている、という形なので、Google の Geofencing API を利用する場合と同じような実装となり、特に困ることはないでしょう。

以下、コード内のコメントでは説明できていないポイントについて補足しておきます。

登録した Geofence は、GeoFenceClient オブジェクトが破棄されれば無効となる

当然といえば当然なのですが、国内向けに Google の GeofencingClient を利用していた際は、一度 Geofence を登録すればアプリ側でその生存期間を気にする必要はなかったのですが、Baidu の GeoFenceClient の場合はオブジェクト破棄とともに Geofence が無効となるため、Foregroud Service内の処理側で必要に応じて毎回登録処理を行うようにしています。

LocationClient による位置情報取得を開始していないと Geofence のイベントは発火しない

公式ドキュメントには GeoFenceClient に Geofence を登録すれば内部的に位置情報取得も行われるというような記載があるのですが、それだと全く Geofence イベントが発火しませんでした。よって、明示的に LocationClient の開始を行っておく必要がありました。

Geofence イベントの発火には制限回数が必要

GeoFenceClient が生きている限り Geofence は有効なのですが、初期化時に制限回数をセットする必要があり、実質無限となるように十分大きな数をセットしています。セットしない場合、全くイベントが発火しなくなる現象が発生し、また無制限を指定するような方法は無さそうでした。

位置情報取得を止めない

国内向けのLOVOTアプリでは、状況に応じて位置情報の取得頻度を下げるような処理を行っているのですが、Baidu の LocationClient では位置情報取得の中断/再開処理をアプリ側で制御していると Geofence イベントが極端に発火しづらくなってしまいます。LocationClient の初期化時に位置情報取得の最低間隔を指定できるので、それによって制御するに留めるようにしています。

動作確認

アプリの開発は全て日本国内で行っており、位置情報を利用する機能について、実際に中国で動作させたときの挙動は、最終的には中国国内で確認する必要があります。
これには、中国側のスタッフや中国出張中のメンバーの手を借りることとなりました。
出張メンバーは中国語が分からないため、中国スマホを別のスマホのGoogleアプリで撮影して翻訳しながら作業する、といった技を編み出していたようです。こういった技を駆使しながら中国の街中をうろうろしていてもらっていたかと思うと、感謝しかありません。

スマホを撮影する出張メンバー

最後に

実は、アプリの中国対応をするにあたって何と言っても困るのは、中国語の情報しかない、ということだったりするかも知れません。
Baidu の SDK の開発者ドキュメントも英語版はありません。検索しようにもどう検索すれば良いかが分からなかったり、関連するワードで検索したとしても見つかる情報が少なかったり。もちろん、ChatGPTも正しいことは教えてくれませんでした。普段、大量の日本語や英語の情報に助けられて開発していることを痛感します...。
中国向けに位置情報アプリの対応を行う、といった機会はあまり多くはないかも知れませんが、今回の記事がいつか誰かの役に立てば幸いです。

Google翻訳されたドキュメント。コードなども微妙に翻訳されてしまうあるある。どうにかする良い方法あるんでしょうか


さて、GROOVE X では、一緒にアプリを開発してくれる方も募集中です。
Unity といえばゲームやVRなどの開発が殆どかと思いますが、それらとは一味違う開発の経験が可能です。大量の3Dオブジェクトのリアルタイム制御、美麗なグラフィック、というような開発要素はほぼ無いのですが、アプリと連携するクラウド側やLOVOT内で動作するサービスの開発、ネイティブの機能を利用するためのプラグイン開発などに興味のある方がいらっしゃれば、是非よろしくお願いします。

recruit.jobcan.jp

フィールドを越えると そこはフィールドだった

こんにちは。 GROOVE X フットサル部所属の id: numa-gx です。

テックネタではありませんが、GROOVE Xでコロナ前から3年以上も継続している活動を紹介したいと思います!

昼休みにフットサルやってます

フットサル部では、オフィスの近くにあるコートで昼休みを返上して活動しています!

週1回のペースで12時過ぎから約30分ほどプレイしています。

今週の木曜日の様子

男女関係なくわいわいやってます

フットサル部の危機

長く続けているフットサル部ですが、ある程度時間が経過すると、毎回一定以上の人数を集めて開催することが難しくなっていました。

参加者が集まらない原因としては、

  1. 新しいメンバーを誘っても数回参加して定着しない
  2. メンバーの転職などによって参加できなくなる
  3. 気候の影響(夏場の暑さなど)

などがありました。

社内での周知や新入社員への声かけを積極的にしていましたが、参加者は徐々に減っていき、開催が難しい時期が続いていました。

参加者を求める悲痛な叫び

社内の有志の活動でよくあるパターンですね…。

人数が少ないと参加者の負荷があがって更に参加者が減る悪循環…

おもわぬ仲間の登場

そんな状況でしたが、 「退職したメンバーの転職先で募集すればいいのでは?」 というアイデアが救世主となりました。

実は、奇跡的にもGROOVE X が入っているオフィスの別の階の会社に転職したメンバーがいたので、声をかけたところ参加してくれることに!

フットサル仲間が一気に2倍近くに増えました(わーい)

さらに、参加交渉のハードルが下がったおかげで、その場に居合わせた野生のフットボーラーにも声をかけ、時々参加してもらえています。

フットサルスケジューラーの誕生

週1で開催していましたが、何曜日に開催するかは参加者の都合を社内Slackで相談しながら決めていました。 ところが、社外のフットサル仲間が増えた影響によりスケジュールの調整が難しくなってしまいました。

そこで、Slack Connect を使って社外のメンバーにもSlackに入ってもらって連絡できるようにしています。

slackをEnterprise Gridに変更した話 - Inside of LOVOT で紹介したように弊社はSlackをものすごく使ってます!)

翌週の開催日を決めてくれる翼くんBot。投票が多い曜日に開催されます。

フットサルをやってよかったこと

仕事関係ないよねと思われるかもしれませんが、やっていてよかったことが色々あったのでまとめてみます。

  1. 社内の普段仕事で関わらない人と関わって話がしやすくなる
  2. ほど良い運動で健康に良い
  3. 出社して仕事する動機づけになる
  4. 知り合いが増える

仕事に関係するところでは、職場で普段関わりがない人と話がしやすくなるため、社内のイベントで会ったときなどにコミュニケーションがスムーズになります。「最近どうですか」みたいなことも話しやすいです。

また退社後や休日に運動する時間を取りづらい人にとっては、昼休みを使って運動する機会が得られるのでとても助かってます。

さらに、会社の方針にもよりますが、出社して仕事をして欲しい場合、フットサルがあることで出社日を作りやすくなっています。 特に私はソフトウェアエンジニアでリモートでも仕事はできますが、出社する動機付けになっています。

最後に、フットサルを通じて知り合いが増えるのもありがたいことです。特に社外の人とも仕事関係なく話をしたり交流できるのは貴重な機会になってます。感謝。

仲間募集

というわけで、GROOVE Xではフットサルを一緒にやってくれる仲間も募集しています。 平日の昼間に日本橋浜町あたりに行けるよという方いらっしゃれば気軽にお声がけください。

ボールは友達!

ついでに弊社に興味を持っていただけたら、応募もよろしくおねがいします!

recruit.jobcan.jp

『ゲームさんぽ』でロボットデザインについて(好き勝手に)語りました!

はじめまして!『Inside of LOVOT』初登場 CDO(チーフデザインオフィサー)の 根津 です! 年の瀬も迫り、GROOVE X AdventCalendar 2023 も、ついに22回目となりましたね。。

少し前の話になるのですが、今年の7月、ライブドアニュースさんの YouTube『ゲームさんぽ』で、「ロボット開発者に、ゲームに登場するロボットのデザインについて、いろいろと語ってほしい!」というご依頼をいただいて、『Atomic Heart』というゲームを題材に、好き勝手に語ってきました。ひとりでは心もとないということで、超強力助っ人 アニメーターの中里ひろきさんにもご出演をお願いし、さらに、LOVOT「らむね」も一緒に出演することになったため、撮影スタジオはとても賑やかな絵面になりました。

撮影前準備中!

正直なところ、アトミックハートというゲームのことは、この時に初めて知ったのですが、なかなかに考察意欲をそそる興味深いゲームでした。未来のような、ちょっと昔のような、不思議な世界(「1955年の架空のソビエト連邦」という設定)を舞台に、個性豊かなたくさんのロボットたちが登場します。司会のヤマグチクエストさんが、テンポよくゲーム世界を案内してくださるので、中里さんと楽しいトークをしているうちに、あっという間に時間がすぎていきました。

特に、ロボットデザインでは避けて通ることのできない「不気味の谷」や、「カワイイの正体って何なんだろう?」といった考察は、話しながらいろいろと新しい発見があって、とても有意義でした。LOVOTで最も大切にしていることと言っても過言ではない「生命感」がどこに宿るのか、それが宿っていないロボットや、ちょっと惜しいロボットたちを題材に、その形状やしぐさなど、様々な角度から考察することで、「やっぱりLOVOTはすごいな!」と再認識しました。また、普段、漠然と感じていることを改めて言語化してみることの価値や、考えをぶつけあえる相手のありがたさを痛感する機会にもなりました。(中里さん、ヤマグチクエストさんに感謝なのです!)

スタッフのみなさんも興味シンシン!

自分で言うのもなんですが、いま見てもなかなか面白い内容じゃないかと思いますので、まだの方は、年末年始にでもぜひご覧になってみてください!

↓ ↓ ↓ 各動画はコチラから! ↓ ↓ ↓

ライブドアニュース『ゲームさんぽ』【Atomic Heart】

第1回【どこ見てるの…?】ロボットの愛らしさは「目」と「健気さ」で決まる

www.youtube.com

第2回【感無量】ロボットが反乱を起こしたら感動するかも…?ロボット開発者たちの本音

www.youtube.com

第3回【不気味の谷】ロボットの顔面崩壊シーンはなぜ恐ろしい?開発者たちに見てもらった

www.youtube.com

PythonとGoとRustで複数のHTTPリクエストを同時にやってみた

こんにちは!ソフトウェアチームのエンジニアのふくだです。
この記事は、GROOVE X Advent Calendar 2023の21日目の記事です。

qiita.com

並行処理が大好きです

私は並行処理が大好きです。ただとても難しいです。

私はPythonの asyncio という非同期処理の標準ライブラリ、その中でも最近のわかりやすく使いやすくなった高レベルAPIで好きになった新参者で、日々勉強しています。

弊社GROOVE Xには、さまざまな技術を持ったエンジニアが活躍しています。ソフトウェア領域であれば、CやC++、Go、PythonやRust、TypeScriptなどさまざまなプログラミング言語が使われています。*1

そうなんです。並行処理といえば!と言われている言語が使われているではありませんか!そう、GoとRustです。 ただ、私はGoもRustもあまり書いたことがありません。今回の記事では、PythonのコードをベースにGoとRustでたくさんのHTTPリクエストを行う処理を書いてみました。

簡単な仕様

今回のコードの簡単な仕様は以下の2つです。

  • 特定のエンドポイントに対して複数のHTTPリクエストを同時に行い出力する
  • エラー処理を書く

そんなことしないって?確かにしませんね。ただみなさんのお使いのプロダクトなどに置き換えてみてください。 DBアクセスや外部APIの実行だったり、I/Oの塊だったりしませんか?弊社LOVOTもセンサー情報をたくさんあるため、I/Oがたくさんです。

実行環境

試した環境としては、以下のとおりです。

  • MacBook Air M2

また、外部の公開されているAPIを利用するため、コードをお試しいただく場合には、 計画的 にお願いします。 今回利用するのはPokemon APIです。

pokeapi.co

書いてみた

まずはPython

各種バージョンは以下のとおりです。

  • Python 3.12.0
  • HTTPX 0.25.2
  • trio 0.23.2

Python コード

asyncio版

import asyncio
from dataclasses import dataclass
from time import time

import httpx

URL = "https://pokeapi.co/api/v2/pokemon/"


@dataclass(slots=True)
class PokemonResponse:
    """PokemonResponse はAPIレスポンスのデータクラス"""
    name: str


async def fetch_pokemon(client: httpx.AsyncClient, num: int) -> PokemonResponse:
    """fetch_pokemon は指定された番号のポケモンの名前を取得"""
    response = await client.get(f"{URL}{num}")
    data = response.json()
    return PokemonResponse(name=data["name"])


async def main():
    start = time()
    try:
        async with httpx.AsyncClient() as client:
            async with asyncio.TaskGroup() as tg:
                tasks: [asyncio.Task] = [
                    tg.create_task(fetch_pokemon(client, number))
                    for number in range(1, 2)
                ]
        pokemon_names = [task.result().name for task in tasks]
        print(pokemon_names)
        print(f"Time: {time() - start}s")
    except* BaseException as e:
        print(f"Error: {e.exceptions}")


if __name__ == "__main__":
    asyncio.run(main())

Trio版

弊社では、意思決定エンジンであるneodmにTrioを利用しています。そのため、Trioでも書いてみましょう。

from dataclasses import dataclass
from time import time

import trio
import httpx

URL = "https://pokeapi.co/api/v2/pokemon/"


@dataclass(slots=True)
class PokemonResponse:
    """PokemonResponse はAPIレスポンスのデータクラス"""
    name: str


async def fetch_pokemon(client: httpx.AsyncClient, num: int, results: list) -> None:
    """fetch_pokemon は指定された番号のポケモンの名前を取得"""
    response = await client.get(f"https://pokeapi.co/api/v2/pokemon/{num}")
    pokemon = response.json()
    results.append(PokemonResponse(name=data["name"]))


async def main():
    start = time()
    results = []
    try:
        async with httpx.AsyncClient() as client:
            async with trio.open_nursery() as nursery:
                for number in range(1, 151):
                    nursery.start_soon(fetch_pokemon, client, number, results)
        print(results)
        print(f"Time: {time() - start}s")
    except* BaseException as e:
        print(f"Error: {e.exceptions}")


if __name__ == "__main__":
    trio.run(main)

Python 実行結果

それぞれ実行してみます。

$ python asyncio_pokemon.py
['bulbasaur', 'ivysaur', 'venusaur', ...
Time: 0.9101917743682861s

$ python trio_pokemon.py
['moltres', 'kabuto', 'magikarp', ...
Time: 0.8739862442016602s

時間をおいて3回実行してみました。結果は次のとおりです。

Time: 0.7011699676513672s
Time: 1.0189759731292725s
Time: 0.5281181335449219s

続いてGo

各種バージョンは以下のとおりです。

  • Go 1.21.1

Go のコード

Goのコードです。goroutinesとchannelを使用して、複数のHTTPリクエストを同時に実行します。

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "sync"
    "time"
)

const baseURL = "https://pokeapi.co/api/v2/pokemon/"

// PokemonResponse はAPIレスポンスの構造体
type PokemonResponse struct {
    Name string `json:"name"`
}

// fetchPokemon は指定された番号のポケモンの名前を取得
func fetchPokemon(wg *sync.WaitGroup, number int, results chan<- string, errors chan<- error) {
    defer wg.Done()
    url := fmt.Sprintf("%s%d", baseURL, number)

    resp, err := http.Get(url)
    if err != nil {
        errors <- err
        return
    }
    defer resp.Body.Close()

    var pokemon PokemonResponse
    if err := json.NewDecoder(resp.Body).Decode(&pokemon); err != nil {
        errors <- err
        return
    }

    results <- pokemon.Name
}

func main() {
    var wg sync.WaitGroup
    results := make(chan string, 150)
    errors := make(chan error, 150)

    start := time.Now()

    for i := 1; i <= 150; i++ {
        wg.Add(1)
        go fetchPokemon(&wg, i, results, errors)
    }

    wg.Wait()
    close(results)
    close(errors)

    for err := range errors {
        fmt.Println("Error:", err)
    }

    for result := range results {
        fmt.Println(result)
    }

    fmt.Printf("Time: %v\n", time.Since(start))
}

Go 実行結果

それぞれ実行してみます。

$ go run pokemon.go
starmie
clefable
geodude
haunter
...
Time: 1.124114875s

時間をおいて3回実行してみました。結果は次のとおりです。

Time: 1.124114875s
Time: 381.089875ms
Time: 199.999333ms

最後にRust

各種バージョンは以下のとおりです。

  • Rust 1.74.1
  • reqwest 0.11
  • tokio 1

Rust のコード

Rustのコードです。HTTPクライアントのreqwestクレートと並行処理を行うtokioを利用します。

/// `PokemonResponse`は、PokeAPIからの応答を表す構造体
#[derive(Deserialize, Debug)]
struct PokemonResponse {
    name: String,
}

#[tokio::main]
async fn main() {
    let start = std::time::Instant::now();

    let client = reqwest::Client::new();
    let mut handles = vec![];

    for number in 1..=150 {
        let client = client.clone();
        handles.push(tokio::spawn(
            async move { fetch_pokemon(&client, number).await },
        ));
    }

    let mut pokemons = Vec::new();
    for handle in handles {
        match handle.await {
            Ok(Ok(pokemon)) => pokemons.push(pokemon.name),
            Ok(Err(e)) => eprintln!("Error: {}", e),
            Err(e) => eprintln!("Error: {:?}", e),
        }
    }

    println!("{:?}", pokemons);
    println!("Time: {:?}", start.elapsed());
}

/// fetchPokemon は指定された番号のポケモンの名前を取得
async fn fetch_pokemon(client: &reqwest::Client, number: u32) -> Result<PokemonResponse, reqwest::Error> {
    let url = format!("https://pokeapi.co/api/v2/pokemon/{}", number);
    let resp = client.get(&url).send().await?;
    let pokemon = resp.json::<PokemonResponse>().await?;
    Ok(pokemon)
}

Cargo.tomlは以下の通りです。

[package]
name = "poke"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11.22", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Rust 実行結果

それぞれ実行してみます。

$ cargo run --release
❯ cargo run --release
    Finished release [optimized] target(s) in 0.16s
     Running `target/release/poke`
["bulbasaur", "ivysaur", ...
Time: 1.5475795s

時間をおいて3回実行してみました。結果は次のとおりです。

Time: 1.412308208s
Time: 1.120167541s
Time: 1.589511041s

結果

速度としては、次のとおりの結果でした。気にはなるのですが、速度については処理系がマルチコアが使えるか、とか、ネットワークの状況もあるかと思うので、あくまでご参考で :pray:

|Python|約0.749秒| |Go|約0.568秒| |*Rust|約1.374秒|

感想

それよりも気になるのは、それぞれの言語の特徴ですね。気になるところを箇条書きにしてみました。

  • Go/Rustでやっぱり型をカッチリできるのはいい
    • Pythonでかっちりしたい場合には、Pydantic の出番
  • Goの sync.WaitGroup 書きやすい
    • Add() Done() Wait() でのハンドリング。async/awaitを採用していないのもおもしろい!
    • errgroup も気になる〜
  • Pythonのwalrus演算子 := が、Goでは型推論 (walrusはセイウチ。横から見るとセイウチの顔っぽく見える)
  • Rustのコンパイラ優しい ありがたい
  • Rustのキモは tokio::spawn での非同期タスクの生成と async move
    • 特に move での所有権をクロージャに移動して安全に使うところとか
      • タスクの生成は、Pythonの create_task のイメージ
  • Rustの .await?; ?って!?
    • エラーのハンドリングのためのもの
      • Result型 はOkとErrを持つ。 ? でハンドリングする
        • これもおもしろい〜!

な、印象を持ちました。みなさまはどうでしょうか?普段使っていない言語での似たコードはとても刺激的でした。

まとめ

個人的にはGoのファイル名、pokemon.goで優勝ですね。 それぞれの言語にそれぞれの特徴があってとてもおもしろかったです。コードを書いてみて、それぞれのフォーマッターやコードスタイルも気になりました!また色々な比較がかけそうですね。今回は紹介できませんでしたが、もちろんJavaScriptでも書けますし、C++にも std::async があったりC++ 20から co_await が追加になったりという情報もキャッチアップでき、まだまだ広がる並行処理の世界にワクワクが止まりません!

また弊社Slackには、トゲピーやタマザラシ、モルペコがいます!ぜひ会いにきてください!

わちゃついて!とお願いしたら、わちゃついてくれたやさしいポケモンのみなさん

*1:詳しくは、アドベントカレンダー1日目の LOVOTech Night 初開催報告!! - Inside of LOVOT をご参照ください。

ふるまい開発を支える可視化フレームワーク "pura"

こんにちは!ふるまいチームのエンジニア、市川です! 私は主にLOVOTのふるまい開発*1をしています。

LOVOTには半天球カメラ、4個のマイクアレイ、照度センサー、 温度カメラ、タッチセンサー、測距センサーなど、多数のセンサーが搭載されています*2。 それらのセンサー値や、画像認識・音声認識・自己位置推定の結果など、様々な情報を使ってLOVOTに意思決定させています。 LOVOTがどんな情報を得ていて、どのような意思決定をしているかをリアルタイムにモニタリングできるとデバッグが捗ります。 そこでそれらを可視化できるフレームワークとして"pura"というライブラリを使用しています。 puraは弊社が開発したpythonで利用可能なライブラリで、オープンソースで公開しています。 すごく使いやすいので、今回puraについて紹介させてください。

github.com ※ puraが提供しているのはブラウザに絵を描く汎用的な機能であり、LOVOTの情報を描くようなLOVOTに特化した機能は提供していません

可視化例

実際にpuraで描画している様子をいくつかご紹介します。 詳細な説明は割愛させて下さい。雰囲気だけでも伝われば幸いです。

しっぽの位置

慣性センサーから推定されるLOVOTの傾き

メロディの認識状況

LOVOTは周囲の音から、メロディらしきものを識別します。

まぶたの開閉状況

深度カメラの認識状況

最初に見えているのは足で、その後に崖を発見しています。

赤外線発光位置の推定状況

点が推定発光位置(大きな点は統計的に見た推定位置)で、点群が動いているのはLOVOTが動いているためです。

経路計画の状況

puraの導入方法

puraの導入方法について簡単にご説明します。 繰り返しになりますが、puraが提供しているのはブラウザに絵を描く汎用的な機能であり、LOVOTと接続して、LOVOTのデータを見ることができるわけでありません。あしからず!

下記の手順でinstallできます。

git clone https://github.com/groove-x/pura.git
cd pura
pip install -e .

サンプルが用意されており、下記で実行できます。

python examples/web_view_example.py

http://localhost:8080/で下記の画面が出れば成功です。

左上のリストボックスからチャンネルを切り替えることができます。 下記のように、マウス等の入力を受け付ける事も可能です。

web_view_example.pyのコードを見れば分かりますが、非常にシンプルに実装できます。 サンプルコードを更に短くしたものを下記に書いておきます。

import anyio
from pura import WebViewServer, WebViewMixin

# 1. WebViewMixinを継承したクラスを用意する
class Hello(WebViewMixin):
    def __init__(self):
        # 2. ウィンドウサイズやフレームレートを定義
        super().__init__(webview_size=(320, 240), webview_frame_rate=20)

    # 3. draw()に描画内容を記載
    def draw(self, ctx):
        # 背景の塗りつぶし
        ctx.background(200)
        # 色の指示
        ctx.fill(128, 0, 255)
         # マウスの位置に"hello"を描画
        ctx.text("hello", ctx.mouseX, ctx.mouseY)

async def async_main():
    # 4. 非同期にするためTaskGroupのブロックを作る(anyioではなくasyncioやtrioを使っても良いです)
    async with anyio.create_task_group() as tg:
        # 5. WebViewServerを用意してserve
        server = WebViewServer()
        await tg.start(server.serve, "Web view example", 'localhost', 8080)
        # 6. WebViewMixinを継承した独自のクラスをインスタンス化してserveメソッドを呼ぶ
        #     (チャンネルの数だけ下記のように呼べばチャンネルが増えます)
        tg.start_soon(Hello().webview.serve, server)

if __name__ == '__main__':
    anyio.run(async_main)

puraはProcessing APIのサブセットでコーディングされているため、どのようなAPIが用意されているかはProcessing API側のドキュメントを読むのが良さそうです(pura側にはあまり整ったドキュメントがなさそう)。

https://py.processing.org/reference/

puraで具体的にサポートされているAPIは直接下記コードを見れば分かります。

pura/src/pura/_web_view.py at c767f24e6ab260e2565d7e77b797a55cb3965a03 · groove-x/pura · GitHub

その他

弊社のJohn BelmonteさんがPyCon US 2021で紹介したようです。 動画があるため良かったらこちらもご覧ください。

さいごに

一緒にLOVOTを作ってくれる仲間を募集しています。 上記のようなデバッグ環境があり、ロボット開発経験がなくても取っつきやすいと思います。ぜひ一緒に開発しましょう! 様々な分野で募集しているため、是非下記リンクをご確認下さい。

recruit.jobcan.jp

*1:ふるまいに関しては次の記事を参照ください: tech.groove-x.com

*2:下記で紹介しています: lovot.life

非英語話者の海外登壇(EuroPython 2023/PyCon TW 2023)

こんにちは!ソフトウェアチームのエンジニアのふくだです。
この記事は、GROOVE X Advent Calendar 2023の19日目の記事です。

qiita.com

英語をほとんどしゃべれない私ですが、海外のカンファレンスで登壇する機会を得ました。そのチャレンジの様子と、どうやって乗り越えたのか、をお伝えする記事になります。

参加したカンファレンスって

今回参加したのは、Pythonというプログラミング言語のカンファレンスです。Pythonのカンファレンスはは世界中で行われています。

世界中で行われているPyCon。こちらより引用 Join us at PyCon

また、 先日日本でもPyCon APAC 2023が開催され、弊社はスポンサーをさせていただきました!

PyCon APAC 2023でのスポンサーブースの様子はこちらの記事をご参照ください。 tech.groove-x.com

私の登壇したカンファレンス

今回私が登壇したのは、ヨーロッパの各地で年1回開催されているEuroPython 2023と台湾で行われているPyCon TW 2023です。プロポーザルを出し、採択いただき登壇することになりました。

名称 開催場所 開催期間
EuroPython 2023 チェコ・プラハ 7月19日⁠~21日
PyCon TW 台湾・台北 9月1日⁠~2日

どちらのカンファレンスもとてもとても楽しかったです。ここでその話をすると長くなってしまいますので、カンファレンスの様子については、gihyo.jp さんの以下の記事をご参考ください。

日本からひとりで参加した「EuroPython 2023」スピーカー体験レポート | gihyo.jp

https://gihyo.jp/assets/images/article/2023/12/road2euro-python23/002.png

PyCon TW 2023 カンファレンスレポート | gihyo.jp

https://gihyo.jp/assets/images/article/2023/09/pycon-tw2023/007.png

どれくらい英語しゃべれないの?

相当しゃべれないです。辞書を片手にゆっくり読むことはできるのですが、会話となると全く出てこなくなってしまいます。私の所属するふるまいチームには英語ネイティブのスーパーエンジニアがいます。その方は日本語をほとんどしゃべれません。ですが、その方に、日本語を話させてしまうくらいしゃべれないのです。

朝会の様子

非英語話者の海外登壇

どうやって乗り切ったの?トーク編

テクノロジーを頼って乗り越えました。自分の発表の準備や、他のセッションの聞き方について紹介します。

自分の発表(スピーキング)

私はトークをする時、必ず台本(原稿・トークスクリプト)を用意します。おおよそ以下の流れです。

  1. トークの見出しを決める
  2. 大枠の内容を埋める
  3. 台本を用意する
  4. 発表の練習をする
  5. 練習でだめだったところを修正する

3~5を繰り返し行って、クオリティをあげていくのが理想です。またその中で時間調整も行っていきます。

そして今回は英語です。まずは日本語で作った資料と台本を英語に翻訳していきます。翻訳で苦労したのは、「英語として意味が通じるか」といった点です。 また、日本語の時点でも資料・台本に荒さがあるため、日本語を修正→英語翻訳を修正、といった日本語でやる場合の倍の労力もかかりました。

資料と原稿はこんな感じです。MacのKeynoteを使っています。

常に手元に英語の先生がいてくれると嬉しい状況でした。つまらないことも聞きたい。そしてそう、2023年の私たちには手元にあの先生がいますね。今回翻訳にあたり、2つのWebサービスにとてもお世話になりました。

本当にささいいな言い回しをたくさん確認しました。ありがとうChatGPTさん

もちろん、社内で練習することも可能です!ですが、今回バタバタしすぎて、社内での練習はできませんでした。。。

本番の資料、ビデオは公開されておりますので、ご興味ある方は是非そちらからご覧ください(笑ってください。。)。

他の方の発表(リスニング)

普段英語圏の方のトークを見る時は、動画配信がほとんどです。YouTubeであれば、字幕機能を活用しています。ただし、オフラインだとそうはいきません。また英語圏のため、日本語への同時翻訳などもちろんありません。

はじめは、Google翻訳の音声入力を活用しようと思ったのですが、長時間の音声入力とその翻訳となると、うまく使いこなせず、内容が入ってきませんでした。 そこで、次のサービスを利用しました。

Euro Pythonに参加した時はOtterを、PyCon TWの時はOtterとMicrosoft Translatorを利用しました。 どちらもリアルタイム文字起こしが可能です。スマートフォンでアプリを起動し音声入力を行い、リアルタイムでPCのWebサイトで確認できる、というものです。

Otterは翻訳はしてくれないのですが、リアルタイムで英語の文字起こしができるため、サクサク内容をキャッチアップすることができました。

EuroPython 2日目のソフィー・ウィルソン氏のKeynote

Microsoft Translatorは翻訳もできます。PyCon TWでは、中国語でのトークも多かったため、非常に助かりました。

どうやって乗り切ったの?コミュニケーション編

イベント中や現地のコミュニケーションは、定型文とGoogle翻訳で乗り切りました。

また、初対面のエンジニア同士のコミュニケーションは、とても気軽に行うことができました。「どこからきたの?」「どんな仕事をしているの?」といった内容で始まることが多かったです。話をしていると、人種がちがって、バックボーンも国も違っても、同世代のエンジニアである以上、似たような仕事やマインドセットになるのかな、といったこと気づけたのもとてもおもしろかったです。「Django*1で社内システムをやっている」「マネジメントはしたくない」「今のポジションに割と満足している」「家族との時間を大切にしたい」というような話をされていて感じたところでした。

ドイツでデータ分析をやっている方々

Euro Pythonの開催地であったチェコ・プラハは観光地であることもあり、チェコ語ももちろん町中にありましたが、英語も同じくらいあります。また、若い人を中心に英語をしゃべることができます。チェコ語しかしゃべれないスーパーの店員さんともGoogle翻訳で難なくコミュニケーションをとることができました。

連日通ったプラハのレストランにて。英語がとてもお上手だった

また今年のPyCon TWは、日本からの参加者が非常に多く、現地での参加は総勢18名でした。これはこれでとても心強く、英語が話せる方を頼って行動をしてしまいました。お世話になった皆様本当にありがとうございました!

https://gihyo.jp/assets/images/article/2023/09/pycon-tw2023/018.png

海外カンファレンス、英語必須・・・?

英語があまり上手でなくても、海外カンファレンスを楽しむことができることを体験してきました。もちろんしゃべれるに越したことはありませんが、なくても大丈夫です。私が訪れた都市が観光地であったこと、また異なる国からの参加者の多いカンファレンスだったこともありますが、多くの人の優しさ・温かさに助けられた海外カンファレンスへの参加でした。

たくさん写真を載せたいのですが、多すぎてまとめられず...かわりにきれいなプラハの景色をどうぞ。

まとめ

弊社には英語ネイティブのメンバーが開発チームを中心に在籍しており、会社全体としても英語話者は非常に多いと感じています。私も怠けることなく、英語の鍛錬を行いたい!

英語でのコミュニケーションに挑戦したいあなた!また海外カンファレンスを目指したいあなたも、興味ある方はぜひこちらからお気軽にどうぞ!カジュアルにぜひ1度お話ししましょう〜!

recruit.jobcan.jp

おまけ

PyCon TWの会場にジョブボード(会社やプロダクトの紹介を自由に書き込めるボード)があり、そこにLOVOTを描きました。私の書いたLOVOTがこちら!

.........

上手くなりたい。。。心底思いました。。。この記事を読んで練習します!

tech.groove-x.com

趣味で仕事用のキーボードを自作してみた

はじめに

この記事は、GROOVE X Advent Calendar 2023の18日目の記事です。

こんにちは。
ファームウェアチームに所属しているf-sakashitaと申します。

LOVOTのファームウェア開発については過去記事をご覧頂ければと思います。

tech.groove-x.com tech.groove-x.com

今日はLOVOTの開発話からは脱線し、私が普段仕事で使用している自作キーボードについてご紹介します。

※本記事の内容は弊社より製造・販売している製品とは一切関係なく、個人が趣味で開発したものです。
※本記事を参考にした製作物等や、本記事に記載されている他社サービスに関し、当社は一切の責任を負いかねます。

毎日使うキーボード

皆さんは普段仕事や家庭ではどのようなキーボードを使っていますか?
エンジニア界隈だと、メカニカル方式や静電容量方式といったキースイッチを採用した打ち心地・打鍵音・耐久性の優れたキーボードを使用されている方も多いと思います。
弊社でもそのようなキーボードを使用しているエンジニアの方達がいらっしゃり、オフィスでは毎日のようにLOVOTの可愛らしい声と爽快な打鍵音が聞こえてきます。

さて、普段キーボードを操作するにあたって、「このキー絶対にいらないなぁ・・・」と思ったことはないでしょうか?
例えば「Insert」です。
押したことに気づかず文章が上書きされてCtrl-Zで元に戻すといった、無駄にイラっとする経験はありませんか?

また、自分好みのキー配列や、楽な体勢で打てるキーボードが欲しいと思われている方もいらっしゃると思います。

勿論、市販のキーボードでこれらの要望をある程度満足することは可能です。
しかし、購入後に「少し自分には合わない…」「やはりこのキーも欲しかった…」と思うこともあるでしょう。

そこでオススメしたいのが自作キーボードです。
自分が望むキーボードは、自分で作っちゃえば良いのです。
そして、この世にはキーボードを自作するための様々な部品やサービスが存在します。

自作したキーボードの紹介

これが私が普段仕事で使用している自作キーボードです。

自作したキーボード

ケース、PCB、ファームウェアと全て自分で設計開発しました。
スペックは下記の通りです。

キーキャップ Akko Silent
(一部家に余っていたものも使用しています)
キースイッチ Gateron G Pro 3.0 Silver (70個)
Durock Silent Linear Dolphin (9個)
配列、キー数 US配列、79個
hotswap 対応
インターフェース USB (Type-Cコネクタ)
サイズ 縦130mm x 横297 mm x 高さ23 mm
(高さはキーキャップ・スイッチは含めない)
質量 650 g
製作費 4万円程度

なぜ作ったのか

私はこのキーボードを製作する前に、下記の2つの自作キーボードを製作しました。

過去に製作したキーボード

これらはKBDfans社が出しているDZ60とKBD75のPCBを使用しました。

しかし、普段仕事でファームウェアをデバッグする際にFnキーをよく使用するため、FnキーのないDZ60で組んだキーボードは使いにくい問題がありました。
また、KBD75で組んだキーボードは右端列のHomeやPage up/downのキーを普段使うことがなく、誤って押してしまうこともあるため、邪魔でした。

そのため、次に自作するキーボードは自分が普段使いする必要最低限のキーで構成したものにしたく、下記の要求仕様としました。

  1. US配列
  2. fnキー、カーソルキー有り
  3. ページアップやダウン、insertといった普段使用しないキーは無し
  4. メカニカルキーボード&Hotswap対応

理想とするキー配列

最初はこの要求仕様を満たす市販の自作キーボードキットやPCBを探したのですが、全く見つかりませんでした。

こうなると残された道は全て自作するしかありませんでした。
(0から作ってみたかったというのもあります)
それではどのように製作したのかをご紹介します。

ケース

私はハードウェアの設計は本職ではなく完全に初心者です。またキーボードのケースを設計開発した経験もありません。
そんな初心者でも手が出しやすく低コストで作れるアクリルサンド構成でケースを製作しました。

製作したキーボードのアクリルサンドの構成は下記の通りです。

アクリルサンド構成

設計はFusion 360を使用し、アクリル版の加工には遊舎工房様のアクリル加工サービスを使用しました。

  • キープレート用: 押出クリア, 450x300, 1.5mm, 1枚
  • ボトムプレート用: 押出クリア, 450x300, 3.0mm, 1枚
  • チルトプレート用: 押出クリア / 300x70 / 5mm, 1枚

そのほかネジやスペーサ、スタビライザーなどの部品を合わせて、製作費は15,000円程度となりました。

Fusion360で設計
製作したアクリル板

PCB

設計したPCBはUSBコネクタ、マイコン、キーマトリクス回路といった必要最低限のものを実装した程度です。
回路設計も本職ではなく、また安く済ませたかったというのもあるので、このような構成としました。

設計にはKicad v6を使用し、基板の製造および部品実装の大半はJLCPCBで発注しました。
JLCPCBでの製造費を抑えるため、基板製造数は最小の5枚、うち2枚を部品実装し、運送はOCSの最安プランを選びました。
家にあった部品を一部実装してるため概算となりますが総額は13000円ほどでした。

JLCPCBから届いたPCB

ファームウェア

一般的に自作キーボードと言えばQMK firmwareを使うことが多いです。
しかし、私はファームウェアエンジニアの端くれですので、自分で作った回路のファームウェアは自分で作ったほうが早くデバッグも楽だと考え、あえてQMK firmwareは使用しませんでした。

今回採用したマイコンはSTM32G0シリーズのため、統合開発環境にはSTM32CubeIDEを使用し、HALを使用してUSB HID部分を実装しました。
ツールやライブラリのおかげで、さほど時間をかけずにファームウェアを実装することができました。
キーマトリクス処理部は自分で書く必要があるとはいえ、CubeMX上でCustom HID設定し、コード生成後はHID report descriptorの記述と送信用関数を呼び出すだけで済みました。
下記はUSB HIDの追加実装したコードを抜粋したものです。

// usbd_custom_hid_if.c
/** Usb HID report descriptor. */
__ALIGN_BEGIN static uint8_t CUSTOM_HID_ReportDesc_FS[USBD_CUSTOM_HID_REPORT_DESC_SIZE] __ALIGN_END =
{
  /* USER CODE BEGIN 0 */
    0x05, 0x01, // USAGE_PAGE (Generic Desktop)
    0x09, 0x06, // USAGE (Keyboard)
    0xa1, 0x01, // COLLECTION (Application)
    0x05, 0x07, // USAGE_PAGE (Keyboard)

    0x19, 0xe0, // USAGE_MINIMUM (Keyboard LeftControl)
    0x29, 0xe7, // USAGE_MAXIMUM (Keyboard Right GUI)
    0x15, 0x00, // LOGICAL_MINIMUM (0)
    0x25, 0x01, // LOGICAL_MAXIMUM (1)
    0x75, 0x01, // REPORT_SIZE (1)
    0x95, 0x08, // REPORT_COUNT (8)
    0x81, 0x02, // INPUT (Data,Var,Abs) //1 byte

    0x19, 0x00, // USAGE_MINIMUM (Reserved (no event indicated))
    0x29, 0x65, // USAGE_MAXIMUM (Keyboard Application)
    0x15, 0x00, // LOGICAL_MINIMUM (0)
    0x25, 0x65, // LOGICAL_MAXIMUM (101)
    0x95, 0x07, // REPORT_COUNT (7)
    0x75, 0x08, // REPORT_SIZE (8)
    0x81, 0x00, // INPUT (Data,Ary,Abs) //7 bytes
  /* USER CODE END 0 */
  0xC0    /*     END_COLLECTION                */
};
// main.c
/* ref : https://www.usb.org/sites/default/files/hid1_11.pdf */
#define KEY_BUF_NUM 6
typedef struct{
  union{
    struct{
      uint8_t ctrl_l  : 1;
      uint8_t shift_l : 1;
      uint8_t alt_l   : 1;
      uint8_t gui_l   : 1;
      uint8_t ctrl_r  : 1;
      uint8_t shift_r : 1;
      uint8_t alt_r   : 1;
      uint8_t gui_r   : 1;
    }map;
    uint8_t u8;
  }modifiers;
  uint8_t reserved;
  uint8_t key[KEY_BUF_NUM];
}KeyboardHID_t;

int main(void)
{
  /* HAL, Clock, GPIO等の諸々の初期化は省略 */
  
  MX_USB_Device_Init();
  KeyboardHID_t key_data;
  
  while(1){
      /* key matrix処理は省略 */
      
      /* HIDデータ送信 */
      USBD_CUSTOM_HID_SendReport(&hUsbDeviceFS, (uint8_t *)&key_data, sizeof(KeyboardHID_t));
      
      HAL_Delay(10);    
  }

さて、肝心のキーマトリクス処理に関する課題として、メカニカルスイッチを使用する場合はチャタリングという問題が付き纏ってきます。
チャタリングというのはキーを押下した際にスイッチの接点が短時間にぶつかる・離れるを繰り返す現象です。
この問題を対策しないと、キーを1回しか押してないつもりが複数回押されたと判定されてしまい、操作性が低下します。
今回自作したものでキーを押下したときの信号を測定したところ、波形の通りチャタリングが発生していました。

チャタリングの様子
  そのため、以下のようにチャタリング対策を実装しました。この実装後は快適にタイピングできるようになりました。
実装したチャタリング回避処理のフローチャート

作ってみた感想

自作キーボードといっても、人によってどこまで取り組むのかが変わります。
個人的には下記の段階があると考えています。

  1. 市販のキーボードやキットを購入し、キーキャップやスイッチ、キーの割り当てを自分好みにカスタマイズする
  2. 自分好みの配列になるようにケースやPCBを作る
  3. 2に加えてファームウェアも自分で作る

今回初めて3にトライしましたが、「案外それらしいものができちゃうんだな」と思いました。
0からキーボードを設計・開発することの敷居が下がり、良い経験となりました。

ただ、やはりハードウェアの設計は難しく、今作では組立しづらい、PCBの固定が甘く撓む、などの問題が見つかっています。
また、透明のアクリル版を使ったケースはPCBが見えるのは好きなのですが、サイドの空いている隙間からゴミが入りやすく、汚れが気になる点もあります。

次回作ではこれらの問題解消も踏まえ、ケース製作は3Dプリンタ品や金属切削品にトライしたいと思います。
また、PCB・ファームウェア面でも面白味に欠けるものになってしまったので、色々なデバイスを搭載したいと思います。

おわりに

今回は仕事用に開発したキーボードについてご紹介させていただきました。
キーボードは業務を効率良く進める上で毎日触れる重要な道具ですので、皆さんも理想のキーボードの姿を御検討してみてはいかがでしょうか?

GROOVE Xでは様々な領域のエンジニアを募集していますので、ご興味があれば是非ご検討ください。

recruit.jobcan.jp