SANDFISH FACTORY

技術ブログです。python・vuejsを愛でる日々について綴ります

TypeScript+rollup.js+EmotionでReact開発できる環境を作ってみた

お久しぶりです。
前回のブログ投稿から、日が経ってしまいました。

ここ最近は個人的に作ってみたいサービスのイメージが湧いたので、AWS CDK(Python)で遊んでいたんですが、フロントエンドまでイメージが広がりました。

これまではVue.jsを使っていたんですが、折角なんで、きちんと触ったことのないReactでやってみようと思いつきました。
前回のブログ投稿からだいぶ日が経ってしまうとは、この時は思ってもいませんでした。

Motivation

Vue.jsにはVue CLIがあり、ReactにもCreate React Appというとても便利なCLIがあります。
今更ながらゼロからReact開発を学ぶので、CLIを利用しない環境構築をしてきちんと仕組みを理解してみようと思いました。
いっそのこと欲張りに全部盛りにしてやってみよう!!!(←ここが日が経った理由)
ということで

  • TypeScriptを使ってみよう
  • Babelとwebpackを使わないで実現してみよう
  • 公式のライブラリを前提に組み上げてみよう

という前提条件を課して進めることをMotivationとしました。

Babelとwebpack以外にしてみようと考えた理由は以下の通りです。

ツール 理由
Babel TypeScriptを利用するのでコンパイルはTypeScriptに任せたい
webpack 多機能なので軽量なバンドルの仕組みにしたい

今回は勉強も兼ねているので、少しだけセオリーから外れた構成で進めてみました。

開発環境の要素を考える

Reactの公式ドキュメントを読み進めながら構成を考えました。
この時点で決めていたのはTypeScriptを利用することだけだったので、検討する要素は以下の2点でした。

スタイルの定義の仕方

CSS とスタイルの使用をみる限り、クラス属性を利用した制御について書かれているのみで、React はスタイルがどのように定義されているかには関心を持ちません。と記載されていました。
コンポーネント単位でCSSは定義したいと考えていたので、サードパーティのライブラリの選定が必要になりました。

利用するバンドラ・コンパイラを決める

ゼロからツールチェインを作成するに記載されています。
パッケージマネージャはnpmとしたので、バンドラ、コンパイラの選定が必要になりました。

構成を検討する

  • バンドラはどれを利用するか
    • Parcel
    • browserify
    • rollup.js
  • CSSはどのライブラリを利用するか
    • CSS Modules
    • Styled Components
    • Emotion

なお、公式ドキュメントに記載のあったParcelはバンドルした結果のファイル名が制御できないので、今回の想定からは外しました。
CSSのライブラリはいくつか調査しましたが、Styled ComponentsはHTML要素(例えばbuttonなど)にまで影響を与える実装の仕方だったので、今回の想定からは外しました。
これらの要素で開発環境の構成を再検討しました。

実現方法 理由
browserify+CSS Modules NG
CSS Modulesのbrowserify公式プラグインがhighly experimentalのステータスのまま
rollup.js+CSS Modules NG
CSS Modulesのrollup公式プラグインが存在しない
rollup.js+Emotion OK
JavaScriptcssを定義するので、rollup公式プラグインのnode_modulesの解決プラグインで対応できる

実際に環境構築を行い、前提条件を満たすことができる構成はrollup.js、Emotionの組み合わせかな?と思ったので、一旦こちらの構成で進めることとしました。

環境構築手順

TypeScript、rollup.js、Emotionの構成で開発環境を構築してみたいと思います。

appディレクトリを作成

まずはappディレクトリを作成します。
ソースコードはsrcディレクトリ、ビルドした結果はpublicディレクトリに配置します。

mkdir react-app
cd react-app
npm init -y
mkdir src
mkdir -p public/scripts

ディレクトリを作成したら、npm initでpackage.jsonの最低限の設定を行います。

必要なパッケージのインストール

次に、React、TypeScript、rollup.js、Emotionに必要なパッケージのインストールを行います。

npm i -D react react-dom @types/react @types/react-dom
npm i -D typescript tslib
npm i -D rollup
npm i -D @rollup/plugin-typescript @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-replace
npm i -D @emotion/react

TypeScriptと一緒にtslibをインストールしています。これはrollup.jsのTypeScriptプラグインに必要なためです。

rollup.jsは4つプラグインをインストールしています。

プラグイン 理由
@rollup/plugin-typescript TypeScriptを利用するため
@rollup/plugin-commonjs CommonJSのモジュールをES6に変換するため
@rollup/plugin-node-resolve ローカルのnode_modulesにあるサードパーティのライブラリをバンドルするため
@rollup/plugin-replace バンドルする際に対象文字列を置換するため

@rollup/plugin-replaceは追加で入れたプラグインで、バンドル時のエラーで、process.env.NODE_ENVが解釈できないとエラーを解決するために追加しました。
プラグインREADMEをみる限り、最も多い利用するケースのようです。

tsconfigの設定

次に、tsconfigの設定を行います。

node_modules/.bin/tsc --init

を実行するとrootディレクトリ直下にtsconfig.jsonが生成されます。
下記の内容は動くための最低限の設定です。

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es5",
    "module": "ES2015",
    "lib": [
      "es2015",
      "dom"
    ],
    "allowJs": true,
    "jsx": "react",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./public/scripts",
    "rootDir": "./src",
    "removeComments": true,

    /* Strict Type-Checking Options */
    "strict": true,

    /* Module Resolution Options */
    "moduleResolution": "node",
    "esModuleInterop": true,
    
    /* Advanced Options */
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [
    "./src/**/*"
  ]
}

rollup.config.jsの設定

次に、rollup.jsの設定を行います。
rootディレクトリ直下にrollup.config.jsを作成し、バンドルするための設定を定義します。

  • intput:entryポイントとなるファイルを指定
  • output:バンドルした結果の出力先を指定
  • plugins:利用するプラグインを指定
import typescript from '@rollup/plugin-typescript';
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import replace from '@rollup/plugin-replace';

export default {
    input: 'src/App.tsx',
    output: [
        {
            file: 'public/scripts/bundle.js',
            format: "cjs",
            sourcemap: true
        },
        {
            file: 'public/scripts/bundle.es.js',
            format: "esm",
            sourcemap: true
        }
    ],
    plugins: [
        replace({
            // alternatively, one could pass process.env.NODE_ENV or 'development` to stringify
            'process.env.NODE_ENV': JSON.stringify('development')
        }),
        commonjs(),
        resolve(),
        typescript()
    ],
};

動作確認

最後に動作確認を行うためのコードを定義します。
ディレクトリ構成と各種ファイルは以下のようにします。

f:id:sandfish03:20210126210841p:plain
ディレクトリ構成

index.html

index.htmlは以下の通りです。

<!doctype html>
<html>

<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>

<body>
    <div id="app">
    </div>
    <script type="text/javascript" src="./scripts/bundle.js"></script>
</body>

</html>

Welcome.tsx

Welcome.tsxは以下の通りです。
Emotion.jsはcss属性を利用するのですが、公式ドキュメントに2つの方法が記載されています。

  • Babel Preset
  • JSX Pragma

今回はBabelを利用しないようにしているので、JSX Pragmaを利用しました。

/* @jsx jsx */
import React from "react";
import { css, jsx } from '@emotion/react';

interface WelcomeProps {
    name: string
}
const style = css`
  color: hotpink;
`;

export default class Welcome extends React.Component<WelcomeProps, {}> {
    render() {
        return <div css={style}>Hello, {this.props.name}</div>;
    }
}

App.tsx

entryファイルとなるApp.tsxは以下の通りです。

import React from 'react';
import ReactDOM from 'react-dom';
import Welcome from "./components/atoms/Welcome";

ReactDOM.render(
    <Welcome name="React" />,
    document.getElementById('app')
);

これらの設定が終わったら、ビルドを実行します。

package.jsonのscriptsにbuildコマンドとして「rollup -c」を追加します。

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "rollup -c"
  }

追加できたら「npm run build」を実行してください。rollup.config.jsに定義した出力先にバンドルされたファイルが生成されます。
index.htmlを開くと以下の画面が表示されます。

f:id:sandfish03:20210126212235p:plain
動作確認

無事、ビルドが成功しました!!!

まとめ

ひとまず、Reactで書いたコンポーネントをビルドしてブラウザで表示することができるようになりました。
設定周りの考慮など、まだ足りていない部分があるかもしれませんが、分かったらUPDATEしていきたいと思います。

当初の想定では去年の12月にはこの記事を投稿しているはずだったんですが、だいぶ手間取りました。。。
私のフロントエンド周りの知識が不足していることが分かってよかったです。

ようやく、本来のReactでのフロントエンド開発を行う下地ができたので、進めていきたいと思います。
ではでは。

PyCon JP 2020に参加してきました。

今回、初めてPyCon JP 2020にてトーク採択という良いチャンスをもらい、
初めてづくしで疲れてしまいました。
オンラインパーティの中締めで失礼して寝ようかなと思ったけど
ふとブログに感想書いてみようと頭をよぎったので、
若干の酔いに任せて、まとまってないけど書いてみました。
Speakerとしての感想 は当日に書いた感想です。

セッションとかの感想は後日、アップデートします。

各セッションの感想

個人的には色んなラインナップのあるセッションだったなあと感じました。
Webアプリ開発やDeepLearningのセッションはベースとしてありながらも、それ以外のセッションがあったので、個人的にはとても楽しかったです。
去年の初参加はWebアプリ開発、 DeepLearning系に参加したので、今回はそれ以外に参加してみようと思いました。

1日目

個人的な本命は、Django + SQLAlchemy: シンプルWayでした。Django-ORMで苦しんだことがあるので、とても良かったです。説明いただいた構成が無理なく進める上では良いんだろうなと感じました。もう一度資料を見直してきちんと理解したいと思います。
予想を裏切られるくらい面白かったのは、Pythonではじめるソフトウェア無線でした。奥深い世界と発表者の方がめちゃくちゃ詳しくて圧倒されました。

2日目

事前準備、個人リハーサルしていて、セッションは見れませんでした。。。
今週の土日に見直そうと思っています。

基調講演の感想

基調講演:芝世弐氏

将棋の話はとても面白かったです。

Youtube

あそこまで飄々とされて結果が出ていると、脱帽という感じでした。
ただ、ゴールに向かって必要なことを淡々と進めている印象を受けました。
研究職の方と仕事をしたことがありますが、その時と同じ印象で、自分が見ている次元より上の次元でモノゴト捉えているんだよなーと改めて感じました。

Keynote: Mr. Rich Jones

私が15年間エンジニアとして、いくつかのカンファレンスでキーノートを聞いてきましたが、トークの構成や使う言葉を含めてNo1のトークだったと思います。

Youtube

これをライブで体験できたのは良かった。
シビックテックに対して関わりたいとStay Home中に感じたので、刺さるものが多かったです。
State-lessはそういうことかーーって思いました。
(バランスを取りたがる私としては、この話は惹かれつつ、踏み込みすぎるのは厳しいという"はざま"にいます)

とりあえず、色んな人に見て欲しいので、Facebookやチームの人に共有してきました。

Speakerとしての感想

PyCon JPには去年、初めて参加して色んな気遣いのあるカンファレンスだなーと感じ、普段仕事ではPython使ってないけど、今回のカンファレンスに参加してみようと思いました。

普段、トークを聴く側の参加者でしかなかった私ですが、今回トークする側にまわったことで裏側を見ることができ、さらに色んなことに気遣いがあるなーと感じました。

  • リハーサルなどカンファレンスまでの準備
  • 当日のトークセッションをスムーズに行うためのオペレーション
  • キーノートの同日通訳はとても最高でした!

当然、改善すべきことはあるかもしれませんし、これが全てOKかというとそうではないとは思いますが(私の見えないところには何かあったかもしれない) 技術カンファレンスの裏側を支えるスタッフの方々は今取れる最善をとって進めていただいたと感じました。

PyCon JPだけではなく、いろんな技術カンファレンスがあって、それぞれに裏方のスタッフの方がいると思います。
今回、初めてトークする側に回って、一層、スタッフの方々に対する敬意の気持ちしかないなと思いました。
この辺りはスタッフなどコミュニティを支える活動をしていない私だからの感想かもしれませんが、こういった貢献の仕方もあるんだなと改めて感じました。

なんかもう、コミュニティやカンファレンスを運営している人たちすげーな。
自分のトークに対する感想とかよりも、それが一番の感想になっちゃいました。

PyCon JPを支えているスタッフの方々、全てに感謝いたします。

ではでは。

toioをpythonで動かす:番外編(型で学んだこと)

はじめに

前回は「toioをpythonで動かす:BLE操作」を行いました。
今回はちょっと番外編です。

これまでtoio.jsのコードを読みながら、これってpythonで書くとどうなるんだ?と考えながら書いていました。
私自身pythonAPIドキュメントを全て読んでいたわけではなかったので、割と新しい発見がありました。

型で学んだこと

Union型/Optional型

toio.jsのコードはtypescriptで書かれています。
その中で以下のようなコードが出てきました。

export type DataType =
  | {
      buffer: Uint8Array
      data: PositionIdInfo
      dataType: 'id:position-id'
    }
  | {
      buffer: Uint8Array
      data: StandardIdInfo
      dataType: 'id:standard-id'
    }
  | {
      buffer: Uint8Array
      dataType: 'id:position-id-missed'
    }
  | {
      buffer: Uint8Array
      dataType: 'id:standard-id-missed'
    }

これは { buffer: Uint8Array … } のそれぞれを型とし、DataTypeにはいずれかの型が入ることを示しています。
これをpythonで実現しようとするとtype hintのUnion型で対応することになります。

typing --- 型ヒントのサポート — Python 3.8.3 ドキュメント

pythonでも匿名クラスはありますが、ここで匿名クラスにしてコードが読みづらかったので、別途クラスを定義しました。

from typing import Union
from toiopy.data import (
  PositionIdType,
  StandardIdType,
  IdMissedType
)

class Hoge:
  DataType: Union[
    PositionIdType,
    StandardIdType,
    IdMissedType
  ]

これでPositionIdType、StandardIdType、IdMissedTypeのいずれかの型が入るDataTypeとなります。

また、似たケースでNullを許容する場合もあります。

private timer: NodeJS.Timer | null = null

この場合、Union型ではなく、Optional型を利用することで同様のことが実現できます。(pythonなのでNullではなく、Noneですが)

typing --- 型ヒントのサポート — Python 3.8.3 ドキュメント

from typing import Optional
from toiopy.data import TImer

class Fuga:
  timer: Optional[TImer] = None

typescriptにはinterfaceという概念があり、完全に同じ振る舞いに書き換えることはできないですが、代替することができました。

Enumの機能API

export enum Note {
  C0 = 0, CS0,  D0,  DS0,  E0,  F0,  FS0,  G0, GS0, A0, AS0, B0,
  C1,     CS1,  D1,  DS1,  E1,  F1,  FS1,  G1, GS1, A1, AS1, B1,
  C2,     CS2,  D2,  DS2,  E2,  F2,  FS2,  G2, GS2, A2, AS2, B2,
  C3,     CS3,  D3,  DS3,  E3,  F3,  FS3,  G3, GS3, A3, AS3, B3,
  C4,     CS4,  D4,  DS4,  E4,  F4,  FS4,  G4, GS4, A4, AS4, B4,
  C5,     CS5,  D5,  DS5,  E5,  F5,  FS5,  G5, GS5, A5, AS5, B5,
  C6,     CS6,  D6,  DS6,  E6,  F6,  FS6,  G6, GS6, A6, AS6, B6,
  C7,     CS7,  D7,  DS7,  E7,  F7,  FS7,  G7, GS7, A7, AS7, B7,
  C8,     CS8,  D8,  DS8,  E8,  F8,  FS8,  G8, GS8, A8, AS8, B8,
  C9,     CS9,  D9,  DS9,  E9,  F9,  FS9,  G9, GS9, A9, AS9, B9,
  C10,    CS10, D10, DS10, E10, F10, FS10, G10,
  NO_SOUND
}

このコードをみて、OH...ってなりました。
pythonだとインデントが必要になるので、これ全部変換するのか。。。。

ありました。解決する方法が。

Enumに機能APIがあり、文字列からEnum型を定義することが可能です。
enum --- 列挙型のサポート — Python 3.8.3 ドキュメント

Animal = Enum('Animal', 'ANT BEE CAT DOG')

class Animal(Enum):
     ANT
     BEE
     CAT
     DOG

は同義になります。
また、C0=0となっているので、同様に開始する要素の値を設定することもできます。(defaultは1になります)

これらを踏まえてtoio.jsで定義されているEnumpythonで実現した内容は以下の通りです。

names = """
C0,CS0,D0,DS0,E0,F0,FS0,G0,GS0,A0,AS0,B0,
C1,CS1,D1,DS1,E1,F1,FS1,G1,GS1,A1,AS1,B1,
C2,CS2,D2,DS2,E2,F2,FS2,G2,GS2,A2,AS2,B2,
C3,CS3,D3,DS3,E3,F3,FS3,G3,GS3,A3,AS3,B3,
C4,CS4,D4,DS4,E4,F4,FS4,G4,GS4,A4,AS4,B4,
C5,CS5,D5,DS5,E5,F5,FS5,G5,GS5,A5,AS5,B5,
C6,CS6,D6,DS6,E6,F6,FS6,G6,GS6,A6,AS6,B6,
C7,CS7,D7,DS7,E7,F7,FS7,G7,GS7,A7,AS7,B7,
C8,CS8,D8,DS8,E8,F8,FS8,G8,GS8,A8,AS8,B8,
C9,CS9,D9,DS9,E9,F9,FS9,G9,GS9,A9,AS9,B9,
C10,CS10,D10,DS10,E10,F10,FS10,G10,
NO_SOUND
"""
Note = Enum(
    "Note",
    names,
    module=__name__,
    qualname="toiopy.data.Note",
    start=0,
)

Enumの機能APIの定義は以下の通りです。

Enum(
  value='NewEnumName',
  names=<...>, *, 
  module='...', 
  qualname='...', 
  type=<mixed-in class>, 
  start=1
)

namesの指定の仕方はいくつかあり、空白またはカンマで区切った文字列でも構いません。

'RED GREEN BLUE' | 'RED,GREEN,BLUE' | 'RED, GREEN, BLUE'

または名前のイテレータで指定もできます:

['RED', 'GREEN', 'BLUE']

または (名前, 値) のペアのイテレータでも指定できます:

[('CYAN', 4), ('MAGENTA', 5), ('YELLOW', 6)]

またはマッピングでも指定できます:

{'CHARTREUSE': 7, 'SEA_GREEN': 11, 'ROSEMARY': 42}

利用シーンは限定的かもしれませんが、Enumには機能APIがあると頭の片隅に置いておいて良さそうです。

mypy

ここまで、typescriptの型の表現をpythonで実現するにはということで話してきましたが、type hintは、あくまでヒントであって実行時には影響を与えません。(文法エラーで失敗するわけではない)
ここで、型のチェックはmypyを使って型の状態を静的にチェックします。

インストール

導入するのは簡単です。 こちらのコマンドを実行してください。
これまでと同様にvenvをactivateしてから実行してください。

pip install mypy

型チェックする

これで型チェックが可能となります。

mypy <ディレクトリ名>

実際に私自身が作成したクラスなどは問題なく型チェックができたのですが、pipなどでinstallしたモジュールでエラーが出る場合があります。
それは型定義ファイルがないから発生するのですが、その場合はmypy.iniを作成することで対応可能です。

[mypy-Adafruit_BluefruitLE.*]
ignore_missing_imports = True

この場合は、BLE操作でinstallしたAdafruit_BluefruitLEに対する型チェックを行わないようにしています。

また、一部だけ型チェックを無効化にする方法もあります。
無視したいコードの行末尾に#type: ignoreのコメントをつけます。

from typing import Optional
from toiopy.data import TImer

class Fuga:
  timer = None #type: ignore

本来はこういった使い方はあまり良くないとは思いますが、必要になったケースもありました。
この投稿にも書いたEnumの機能APIです。
私が使っていたバージョンでは機能APIを用いるとうまく対応されておらず、エラーになるので、このような対応をしました。

# mypyの不具合で機能APIのEnumはtype ignoreとする
Note = Enum(  # type: ignore
    "Note",
    names,
    module=__name__,
    qualname="toiopy.data.Note",
    start=0,
)

ちなみに、まだ試していないですが、mypyのEnum機能APIの対応を行うIssueはclosedに変わりました。

Support functional API for Enum creation · Issue #2306 · python/mypy · GitHub

mypyのバージョンをあげたら解決するかもしれません。

まとめ

typescriptからpythonに置き換えることで型について学ぶことが多かったです。

  • Union型/Optional型
  • 文字列やリストなどから列挙型のEnumを定義する機能API
  • mypyを使った型チェックと例外対応の仕方

エンジニアとしての始まりがJavaなどの静的型言語から始めた人間としては、型がある方が安心してしまうのは習性のようなものかなと思いました。

toioをpythonで動かす:BLE操作

はじめに

前回は「toioをpythonで動かす:バイナリデータ操作」を行いました。
今回から本丸のBLE操作を行って、toioを制御したいと思います。

BLE入門

BLEはBluetooth Low Energyの略で、Bluetoothを用いて通信を行う規格のことをです。
toioではキューブとの通信は Bluetooth® 標準規格 Ver. 4.2で行っているとのことです。
通信概要 · toio™コア キューブ 技術仕様

BLEにはセントラル(Central)とペリフェラル(Peripheral)の2種類の役割があります。
通信のホストとなるデバイスをセントラル、何かしらの情報や機能を提供するデバイスペリフェラルと呼びます。
今回の実装は、toioのキューブがペリフェラルで、pythonで実装しようとしているのがセントラルの部分です。

f:id:sandfish03:20200509150817p:plain

BLEは規格なので、やり取りするための手順やデータ構造は定まっていて、GATT(Generic attribute profile:汎用アトリビュートプロファイル)と呼ばれています。
このGATTはサービス(Service)とキャラクタリスティック(Characteristic)から構成されています。

toioのキューブが提供するサービスには8つのキャラクタリスティックが存在します。
サービス、キャラクタリスティックはUUIDで識別して操作することとなります。

  • ID Information / 読み取りセンサー
  • Sensor Information / モーションセンサー
  • Button Information / ボタン
  • Battery Information / バッテリー
  • Motor Control / モーター
  • Light Control / ランプ
  • Sound Control / サウンド
  • Configuration / 設定

BLE操作ライブラリの導入

pythonでBLEを操作するライブラリは存在しています。少し調べた中では以下の2つが検索して見つかりました。

リンクも張りましたが、参考にしたスライドが最初分かりやすかったので、Adafruitを用いることにしました。
windows環境の方はbleakを用いて試してみてください。番外編として後ほど記事を投稿します。

環境構築

まずは必要なライブラリをpipでインストールします。前の記事で書いた通り、venvを有効にして(activate)おいてください。

Macを利用する場合の前提条件にpyobjcをインストールすることと書いてあるので、まずそちらを実行します。

pip install pyobjc

次にAdafruitをインストールしますが、github上のコードをcloneしてからsetup.pyを実行します。

git clone https://github.com/adafruit/Adafruit_Python_BluefruitLE.git
cd Adafruit_Python_BluefruitLE
sudo python setup.py install

サンプルコードを配置するディレクトリを作成して環境構築は完了です。

cd ..
mkdir bleapp
cd bleapp
touch main.py

toioに接続する下準備を行う

Adafruit_BluefruitLEを利用する上で下準備となる実装を行います。

import Adafruit_BluefruitLE

def main():
    # ここにメインの処理を実装します

if __name__ == '__main__':
  provider = Adafruit_BluefruitLE.get_provider()
  provider.initialize()
  provider.run_mainloop_with(main)

まずは、Adafruit_BluefruitLEからproviderを取得します。
取得したproviderを初期化して、run_mainloop_withを呼び出すところまではこの順番の通りに実施して下さい。
Adafruit_BluefruitLEのライブラリがBLEを操作するために必要な準備を行ってくれています。

  • BLEはOSのネイティブライブラリを呼び出すため、OS毎に異なります。Macはcorebluetooth、linuxはbluez_dbusです。
  • get_providerで取得したproviderはOS毎のライブラリの差分を吸収してBLE操作を行います。

toioをスキャンする

動かすための下準備のコードを実装したら、次はメインの実装を行います。

def main():
  provider.clear_cached_data()
  adapter = provider.get_default_adapter()
  adapter.power_on()
  
  # adapterがスキャンを始める
  adapter.start_scan()

  # adapterがスキャンしている間にペリフェラルを探す
  # (deviceという名前になってます)
  device = provider.find_device()

  # 取得できたら、スキャンを止める
  adapter.stop_scan()

provider経由でadapterを取得します。
Adafruit_BluefruitLEのadapterはペリフェラルをスキャンする機能を提供しています。
これはあくまでAdafruit_BluefruitLEでの役割なので、BLE操作に必ずadapterという名前のモジュールがあるわけではないです。

adapterでスキャンを開始している間、provider経由でペリフェラル(メソッド名はdeviceですが)を探します。
ペリフェラルが見つかったら、不要なのでスキャンを止めます。

toioを動かす

ペリフェラルを取得できたら、接続して実際に動かす実装を行います。
行数が多いので順番に解説します。

まず、取得したペリフェラル(=device)に接続します。
接続した後、sleepで3秒程待ちます。

device.connect()  
time.sleep(3)

Macの場合、内部ではcorebluetoothを利用して操作しているのですが、接続した後に必要な情報は同期で返却されないので、3秒待っています。 (3秒が妥当な数値ではないので適宜直してみて下さい)

次に、ペリフェラルから実際に動かすために必要なキャラクタリスティックを取得します。

device.discover([service_uuid], [motor_uuid])  
service = device.find_service(service_uuid)  
chara = service.find_characteristic(motor_uuid)  

uuidはtoioの技術仕様に書かれています。

最後に、toioを動かすためにペリフェラルに動かすために必要情報を書き込みます。
書き込む内容はtoioの技術仕様を参考にしました。

toio.github.io

  l_direction = 1
  r_direction = 1
  l_power = 100
  r_power = 100
  duration = 100

  bdl = [
    2, 1, l_direction, l_power, 2, 
    r_direction, r_power, duration
  ]
  size = len(bdl)
  byte_data = pack('B'*size, *bdl)

  chara.write_value(byte_data)
  time.sleep(5)
  device.disconnect()

前回の記事でも書きましたバイナリデータの書き込みも、早くもここで活用します。
書き込んだあと、実際に動作させるため5秒待ちます。
(すぐdisconnectを呼び出すとtoioが動く前に処理が終わってしまいます)

一番最後にdisconnectを呼び出して、ペリフェラルとの接続を閉じます。

実際に動かしてみる

これまでに書いたコードを実際に動作させてみます。
当たり前ですが、MacBluetoothは有効にしておいて下さい。(私は最初、有効するの忘れてて、なんで動かないのか10分くらい戸惑いました)

python main.py

実際に動くので、toioは床などの広い場所においてから実行して下さい。

まとめ

ひとまず、BLE操作をpythonで行うことができました。
技術仕様が公開されているので、この仕様とtoio.jsのコードを読めば、ある程度、理解することは可能でした。

試しに動かすためのコードはこちらに書いておきます。

from uuid import UUID
import time
from struct import pack

import Adafruit_BluefruitLE

def main():
    service_uuid = UUID('10b201005b3b45719508cf3efcd7bbae')
    motor_uuid = UUID('10b201025b3b45719508cf3efcd7bbae')
    provider.clear_cached_data()
    adapter = provider.get_default_adapter()
    adapter.power_on()

    adapter.start_scan()
    device = provider.find_device()
    adapter.stop_scan()

    device.connect()
    time.sleep(3)

    device.discover([service_uuid], [motor_uuid])
    service = device.find_service(service_uuid)
    chara = service.find_characteristic(motor_uuid)

    l_direction = 1
    r_direction = 1

    l_power = 100
    r_power = 100

    duration = 100

    bdl = [2, 1,
    l_direction, l_power, 2,
    r_direction, r_power, duration]
    size = len(bdl)
    byte_data = pack('B'*size, *bdl)

    chara.write_value(byte_data)
    time.sleep(5)

    device.disconnect()

if __name__ == '__main__':
    provider = Adafruit_BluefruitLE.get_provider()
    provider.initialize()
    provider.run_mainloop_with(main)

最初はBLE操作の実装の仕方が分からなかったので、Adafruit_BluefruitLEのコードも読んで何となく構造は分かったのですが、その辺りは余裕があったらブログに書きたいと思います。

次回は、少し寄り道で、mypy、flake8、blackを導入して試した話を書きます。

今回のそもそもの目的はtoio.jsのコードをpythonに置き換えることです。
toio.jsはtypescriptで書かれていて、型安全になっているのでmypyを入れてみました。
ついでではありますが、flake8とblackも入れて試してみたので、その辺りを書ければと思います。

toioをpythonで動かす:バイナリデータ操作

はじめに

前回から「toioをpythonで動かす」を始めました。
toio.jsのソースコードを見ると、BLE操作を行うため、バイナリデータの読み書きを行っています。
今回はjavascriptで書かれているコードをpythonで実現したいと思います。

pythonでバイナリデータを扱う

pythonでバイナリデータを扱う場合はbytesかbytearrayを用います。

組み込み型 — Python 3.8.3 ドキュメント

bytes オブジェクトは不変なオブジェクトで、bytearray オブジェクトは可変なオブジェクトです。
不変と可変を理解するために下のコードを試してみました。

>>> from struct import pack_into
>>> 
>>> data = bytes(b'\x01\x02')
>>> data
b'\x01\x02'
>>> pack_into("B", data, 1, 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: argument must be read-write bytes-like object, not bytes
>>> 
>>> data = bytearray(b'\x01\02')
>>> data
bytearray(b'\x01\x02')
>>> 
>>> pack_into("B", data, 1, 5)
>>> data
bytearray(b'\x01\x05')

pythonでバイナリデータを操作する場合はstructを用います。

struct --- バイト列をパックされたバイナリデータとして解釈する — Python 3.8.3 ドキュメント

bytesオブジェクトをstruct.pack_intoを用いて書き換えようとするとTypeErrorが発生します。
bytearrayオブジェクトの場合は書き換えが可能で、bytearray(b'\x01\x02')がbytearray(b'\x01\x05')に書き換わっています。
このことからbytesが不変なオブジェクトで、bytearrayが可変なオブジェクトであることがわかると思います。

バイナリデータを読み込む

一例ですが、toio.jsを見てみるとこのような形で実装されています。

const type = buffer.readUInt8(0)
const standardId  = buffer.readUInt32LE(1)
const angle = buffer.readUInt16LE(5)

readUInt8メソッドは、bufferに対して指定されたオフセットの位置から符号なし8ビット整数を読み取っています。

pythonでバイナリデータを読み込む場合は以下のメソッドで対応できます。

  • unpack(format, buffer)
  • unpack_from(format, buffer, offset=0)

formatは書式指定文字を指定します。

8bit符号なし整数はunsigned charの整数なので"B"を指定します。
上記の例ではoffsetを指定しているのでunpack_fromを用います。

from struct import unpack_from
data = bytes(b'\x01\x02\x03\x04\x05\x06\x07')
type_data = unpack_from("B", data, 0)[0]

2バイト、4バイトの読み込みは書式指定文字を変更することで行います。
また、javascriptでのソースコードはリトルエンディアンで読み込みを行っているので、バイトオーダーを指定する文字(<)を指定します。

standardId = unpack_from("<I", data, 1)[0]
angle = unpack_from("<H", data, 5)[0]

バイナリデータを書き込む

書き込みも同じような形で実装されています。

const buffer = Buffer.alloc(3)
buffer.writeUInt8(4, 0)
buffer.writeUInt16LE(5, 1)

ただし、書き込む前に書き込む領域を割り当てる必要があります。
allocメソッドは書き込み領域を割り当て、writeUInt8メソッドは、bufferに対して指定されたオフセットの位置から符号なし8ビット整数、16ビット整数を書き込んでいます。

pythonでバイナリデータを書き込む場合は以下のメソッドで対応できます。

  • pack(format, v1, v2, ...)
  • pack_into(format, buffer, offset, v1, v2, ...)

python用の formatは読み込みと同じように書式指定文字を指定します。
2バイトの書き込みは書式指定文字を指定し、バイトオーダーを指定する文字(<)を指定します。

from struct import pack_into

class Buffer:
  def __init__(self, byte_data: bytearray):
    self._byte_data = byte_data
    self.bytelength = len(byte_data)

  @classmethod
  def alloc(cls, size: int):
    return cls(bytearray(size))

  def write_uint8(self, value: int, offset: int):
    return pack_into("B", self._byte_data, offset, value)

  def write_uint16le(self, value: int, offset: int):
    return pack_into("<H", self._byte_data, offset, value

buffer = Buffer.alloc(3)
buffer.write_uint8(4, 0)
buffer.write_uint16le(12, 1)

バイナリデータを操作するためのBufferクラスを定義し、 alloc、writeメソッドを定義しました。

まとめ

今回、バイナリデータの割り当て、読み込み、書き込みを行うためBufferクラスを定義しました。
toioに対するBLE操作はこのクラスを用いて行います。

class Buffer:
  def __init__(self, byte_data: bytearray):
    self._byte_data = byte_data
    # 8bit符号なし整数(unsigned char)
    self.bytelength = len(byte_data)

  @property
  def byte_data(self):
    return bytearray(self._byte_data)

  @classmethod
  def from_data(cls, data_array: List):
    size = len(data_array)
    byte_data = pack("B" * size, *data_array)
    return cls(bytearray(byte_data))

  def read_uint8(self, offset: int):
    # unpackした結果はtupleになっている
    return unpack_from("B", self._byte_data, offset)[0]

  def read_uint16le(self, offset: int):
    # unpackした結果はtupleになっている
    return unpack_from("<H", self._byte_data, offset)[0]

  def read_uint32le(self, offset: int):
    # unpackした結果はtupleになっている
    return unpack_from("<I", self._byte_data, offset)[0]

  def write_uint8(self, value: int, offset: int):
    return pack_into("B", self._byte_data, offset, value)

  def write_uint16le(self, value: int, offset: int):
    return pack_into("<H", self._byte_data, offset, value)

  def to_str(
    self, 
    encoding: str = "utf-8", 
    start: int = 0, 
    end: Optional[int] = None
  ):

    if end is None:
      end = self.bytelength

    return self._byte_data[start:end].decode(encoding)

  @classmethod
  def alloc(cls, size: int):
    return cls(bytearray(size))

次回はtoioのBLE操作を行いたいと思います。

toioをpythonで動かす

toio とは

コロナの影響で外出を控えているので家にいる時間がだいぶ増えました。
手元にtoioがあったんですが、開発用ライブラリを公開するまで待とうと思ってだいぶ塩漬けになってました。
今回、ちょうど良かったのでtoioを使っていろいろ遊んでみようと思います。

toioはSony Interactive Entertainmentが開発した教育用ビデオゲームコンソールです。
キューブ型のロボットとモノクロディスプレイ付きのドッキングステーション、コントローラーがあり、ソフトウェア(カートリッジ)を切り替えることで様々な遊びを行うことができます。

詳しくは公式サイトをみてください。

toio.io

toioの技術仕様

githubを見てみるとjavascriptのライブラリは公開されています。

github.com

技術仕様も公開されているので、仕組みを理解するためにpythonに書き換えてみることにしました。

モチベーション

toio.jsで公開されているライブラリと限りなく同じ振る舞いを行うpythonのライブラリを実装します。 ブログ記事は複数回に分かれます。各記事は以下のリンクから見てください。

  1. toioをpythonで動かす:バイナリデータ操作
  2. toioをpythonで動かす:BLE操作
  3. toioをpythonで動かす:番外編:型で学んだこと

環境

今回は以下の環境で試しました。

それでは、開発環境を構築したいと思います。

pythonをインストールする

人によってはpythonのバージョンを分けたい方がいればpyenvをインストールしてください。
Catalinaだと問題があった気がするのですが、だいぶ前なので忘れてしまいました。。。。

Home · pyenv/pyenv Wiki · GitHub

特に問題がない方はそのまま、pythonをインストールしてください。

仮想環境を作る

各種ライブラリは他の開発に影響を与えたくないので、venvを用います。

cd workspace  # 好きな場所で良いです。
mkdir toiopy  # 仮想環境のルートディレクトリです
cd toiopy
python -m venv venv

仮想環境を有効にする

仮想環境を作ったら、有効にします。 toiopyデイレクトリ直下にvenvというディレクトリがいるはずです。

source venv/bin/activate
which python
python -V

pythonのパスを確認したら、venvの下にあるpythonのパスに切り替わっているはずです。

github

絶賛開発中ですが、少し動くようになったコードはこちらです。

github.com

それでは、次回の記事から実際にコードを書いて理解したことを投稿していきます。

Pythonで動的にクラスロードして実行してみた

今、個人的な開発でオープンデータを取集して解析するコード書いています。

オープンデータで公開しているファイルの書式がバラバラでparserを多く書くことになりそうなんで、pythonで動的なクラスロードの仕組みを調べました。
(parserを追加するごとに呼び出しもとをコードを修正するのは大変なので)

実現したいこと

  • parserは所定のディレクトリに配置されたら、動的に読み込まれる対象となる
  • 読み込まれるモジュールはparserの基底クラスを継承している
  • 基底クラスの特定メソッドを呼び出して処理を実行する

Pythonで動的にクラスロードして実行

前提条件

以下の環境で試しました。

抽象クラスとabstract method

検索してみると抽象基底クラス(Abstract Base Class:ABC)というものが準備されているとのことです。

abc --- 抽象基底クラス — Python 3.7.6 ドキュメント

以下の構成でファイルを作りました。

f:id:sandfish03:20200224150134p:plain

  • main.py:実行モジュールです
  • parserディレクト
    • base_parser.py:抽象基底クラスです
    • bunkyoku_parser.py:baser_parserクラスを継承した文京区用パーサークラスです
    • komaeshi_parser.py:baser_parserクラスを継承した狛江市用パーサークラスです

抽象基底クラス

# base_parser.py
from abc import ABCMeta, abstractmethod

class BaseParser(metaclass=ABCMeta):
    @abstractmethod
    def parse(self):
        pass

まず、抽象基底クラスはABCMetaを継承します。
具象クラスに実装させたいメソッドにはabstractmethodデコレータを追加します。このメソッドに実装を定義できないので、passを定義します。
これでBaseParserクラスを継承したクラスは必ずparseメソッドを実装することになります。

具象クラス

試しにbunkyoku_parser.pyにabstractmethodを実装しない状態でインスタンス化するとエラーになります。

# bunkyoku_parser.py
from parser.base_parser import BaseParser

class BunkyokuParser(BaseParser):
    def echo(self):
        print("echo bunkyoku")

f:id:sandfish03:20200224152143p:plain

echoメソッドではなく、parseメソッドに変えてからBunkyokuParserクラスをインスタンス化するとエラーにならず、正常に実行できました。

# bunkyoku_parser.py
from parser.base_parser import BaseParser

class BunkyokuParser(BaseParser):
    def parse(self):
        print("echo bunkyoku")

f:id:sandfish03:20200224153031p:plain:w400

importlibで動的にモジュールを読み込む

main.pyに動的にモジュールを呼び出すコードを定義します。

# main.py
import os
import importlib

def parse():
    package_name = 'parser'
    parser_dir = os.listdir(package_name)
    skip_class_name = ['__init__.py', 'base_parser.py']

    for file_name in parser_dir:
        if file_name.endswith('py') and \
                file_name not in skip_class_name:
            module_name = os.path.splitext(file_name)[0]
            classpath = package_name + '.' + module_name
            parser = importlib.import_module(classpath)

if __name__ == '__main__':
    parse()

parserパッケージ配下の一覧をos.listdir(usrsrc) で取得します。
skip_class_nameにはクラスロード対象外のファイル名を定義しておきます。

package_name = 'parser'
parser_dir = os.listdir(package_name)
skip_class_name = ['__init__.py', 'base_parser.py']

上記で取得した一覧から「拡張子がpy」かつ「skip_class_nameに含まれていないファイル」を処理対象とします。

for file_name in parser_dir:
    if file_name.endswith('py') and \
            file_name not in skip_class_name:

処理対象のファイル名から拡張子を除いたものをモジュール名としてclasspathを生成します。(例:bunkyoku_parser.py → parser.bunkyoku_parser)
生成したclasspathをもとにimportlib.import_moduleでモジュールを読み込みます。

module_name = os.path.splitext(file_name)[0]
classpath = package_name + '.' + module_name
parser = importlib.import_module(classpath)

これでモジュールを読み込むことができました。

動的に読み込んだモジュールから特定のメソッドを呼び出す

モジュールを読み込むことが出来たので、モジュールの中に含まれているクラスを呼び出して実行します。
読み込んだモジュールの中に含まれるクラス名は読み込んだ時点で分からないので、inspect経由でオブジェクトの情報を取得します。

inspect --- 活動中のオブジェクトの情報を取得する — Python 3.7.6 ドキュメント

parser = importlib.import_module(classpath)
clazz_list = inspect.getmembers(parser, inspect.isclass)

for clazz in clazz_list:
    if clazz[0] != 'BaseParser':
        instance = clazz[1]()
        instance.parse()

import_moduleで読み込んだモジュールからクラス一覧を取得します。

parser = importlib.import_module(classpath)
clazz_list = inspect.getmembers(parser, inspect.isclass)

取得したクラス一覧から抽象基底クラスであるBaseParserは除外して、それ以外のクラスのインスタンスを生成します。
ここで登場するクラスはBaseParserクラスを継承しているので、必ずparseメソッドが存在します。 最後にinstance.parse()を実行することで動的にロードしたクラスの特定メソッドを実行することが出来ます。

for clazz in clazz_list:
    if clazz[0] != 'BaseParser':
        instance = clazz[1]()
        instance.parse()

実行した結果はこちらです。 f:id:sandfish03:20200224181812p:plain

各モジュールに含まれるBaseParserクラスを継承したクラスが実装したparseメソッドが実行されています。

まとめ

ひとまず、Pythonで動的にクラスロードすることができました。
本来であれば、BaseParserクラスを継承しているか、継承していない場合のエラーハンドリングのチェックが必要ですが、今回の実装では行っていません。

ソースコードはこちらのRepositoryに配置しておきました。

github.com