KAIKETSU Developer's Diary

株式会社KAIKETSU エンジニアチームのブログです

kaggleやってみた

背景

データ分析を学んでみようということで社内エンジニアチームでkaggleをやってみようという話になったので軽く触ってみたことを書きます。

準備

始めるに当たってまず準備したのはkaggleのサイトへのユーザー登録と、Pythonとその周りの機械学習ライブラリのセットアップです。

$ brew install python3 pip3
$ pip3 install pandas numpy sklearn

ユーザー登録は以下のサイトからそれっぽいところをクリックしていけばできます。英語のサイトですがFacebook連携もできるので意外と楽。

Kaggle: Your Home for Data Science

とりあえずやってみる

あまり書くことがないのでやったことを一通り書いてみたいと思います。やったのはタイタニックと呼ばれる一番有名っぽいデータセット

Titanic: Machine Learning from Disaster | Kaggle

Dataと書いてあるタブから、トレーニングデータとテストデータをダウンロードします。

決定木を使う

まずは決定木を使ってやってみます。

データはcsv形式で降ってきているはずなのでpandasでよしなに処理して作成した決定木モデルに流し込みます。

import pandas as pd
import numpy as np
from sklearn import tree

train = pd.read_csv("train.csv")
test = pd.read_csv("test.csv")

# 欠損しているデータを埋める
train["Age"] = train["Age"].fillna(train["Age"].median())
train["Embarked"] = train["Embarked"].fillna("S")
train["Sex"][train["Sex"] == "male"] = 0
train["Sex"][train["Sex"] == "female"] = 1
train["Embarked"][train["Embarked"] == "S" ] = 0
train["Embarked"][train["Embarked"] == "C" ] = 1
train["Embarked"][train["Embarked"] == "Q"] = 2

test["Age"] = test["Age"].fillna(test["Age"].median())
test["Sex"][test["Sex"] == "male"] = 0
test["Sex"][test["Sex"] == "female"] = 1
test["Embarked"][test["Embarked"] == "S"] = 0
test["Embarked"][test["Embarked"] == "C"] = 1
test["Embarked"][test["Embarked"] == "Q"] = 2
test.Fare[152] = test.Fare.median()

target = train["Survived"].values
# 追加となった項目も含めて予測モデルで使う値を取り出す
features_two = train[["Pclass","Age","Sex","Fare", "SibSp", "Parch", "Embarked"]].values
# 決定木の作成とアーギュメントの設定
max_depth = 10
min_samples_split = 5
my_tree_two = tree.DecisionTreeClassifier(max_depth = max_depth, min_samples_split = min_samples_split, random_state = 1)
my_tree_two = my_tree_two.fit(features_two, target)

# tsetから使う項目の値を取り出す
test_features_2 = test[["Pclass","Age", "Sex", "Fare", "SibSp", "Parch", "Embarked"]].values
# 決定木を使って予測をしてCSVへ書き出す
my_prediction_tree_two = my_tree_two.predict(test_features_2)
PassengerId = np.array(test["PassengerId"]).astype(int)
my_solution_tree_two = pd.DataFrame(my_prediction_tree_two, PassengerId, columns = ["Survived"])
my_solution_tree_two.to_csv("my_tree.csv", index_label = ["PassengerId"])

Submit Predictionを押して解答のCSVをサブミットしてみたところ、正答率は75%程度でした。

トレーニングデータの中身を見てみたところ、年齢欄の欠損が多かったので、年齢を説明変数から削除して再度予測してみた結果、正答率が76%と微増しました。

ランダムフォレストを使う

ソースコードを軽くいじって、予測モデルにランダムフォレストを使ってみます。

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier

train = pd.read_csv("train.csv")
test = pd.read_csv("test.csv")


train["Age"] = train["Age"].fillna(train["Age"].median())
train["Embarked"] = train["Embarked"].fillna("S")
train["Sex"][train["Sex"] == "male"] = 0
train["Sex"][train["Sex"] == "female"] = 1
train["Embarked"][train["Embarked"] == "S" ] = 0
train["Embarked"][train["Embarked"] == "C" ] = 1
train["Embarked"][train["Embarked"] == "Q"] = 2

test["Age"] = test["Age"].fillna(test["Age"].median())
test["Sex"][test["Sex"] == "male"] = 0
test["Sex"][test["Sex"] == "female"] = 1
test["Embarked"][test["Embarked"] == "S"] = 0
test["Embarked"][test["Embarked"] == "C"] = 1
test["Embarked"][test["Embarked"] == "Q"] = 2
test.Fare[152] = test.Fare.median()

target = train["Survived"].values
features = train[["Pclass","Sex","Fare", "SibSp", "Parch", "Embarked"]].values

clf = RandomForestClassifier()
clf.fit(features, target)

test_features = test[["Pclass", "Sex", "Fare", "SibSp", "Parch", "Embarked"]].values

test_pred = clf.predict(test_features)
PassengerId = np.array(test["PassengerId"]).astype(int)
my_solution = pd.DataFrame(test_pred, PassengerId, columns = ["Survived"])
my_solution.to_csv("rf.csv", index_label = ["PassengerId"])

解答を提出しましたが、正答率は75%程度でした。

SVMを使う

今度は予測モデルにサポートベクターマシンを利用してみます。

import pandas as pd
import numpy as np
from sklearn import svm

train = pd.read_csv("train.csv")
test = pd.read_csv("test.csv")


train["Age"] = train["Age"].fillna(train["Age"].median())
train["Embarked"] = train["Embarked"].fillna("S")
train["Sex"][train["Sex"] == "male"] = 0
train["Sex"][train["Sex"] == "female"] = 1
train["Embarked"][train["Embarked"] == "S" ] = 0
train["Embarked"][train["Embarked"] == "C" ] = 1
train["Embarked"][train["Embarked"] == "Q"] = 2

test["Age"] = test["Age"].fillna(test["Age"].median())
test["Sex"][test["Sex"] == "male"] = 0
test["Sex"][test["Sex"] == "female"] = 1
test["Embarked"][test["Embarked"] == "S"] = 0
test["Embarked"][test["Embarked"] == "C"] = 1
test["Embarked"][test["Embarked"] == "Q"] = 2
test.Fare[152] = test.Fare.median()

target = train["Survived"].values
features = train[["Pclass","Age","Sex","Fare", "SibSp", "Parch", "Embarked"]].values

clf = svm.SVC()
clf.fit(features, target)

test_features = test[["Pclass","Age", "Sex", "Fare", "SibSp", "Parch", "Embarked"]].values

test_pred = clf.predict(test_features)
PassengerId = np.array(test["PassengerId"]).astype(int)
my_solution = pd.DataFrame(test_pred, PassengerId, columns = ["Survived"])
my_solution.to_csv("svm.csv", index_label = ["PassengerId"])

こちらの正答率はなんと60%台でした。。。

まとめ

kaggleのタイタニックデータセットを軽くいじってみましたが、なかなか正答率が上がらず大変です。このデータセットに限って言えば、モデルよりはデータの前処理が大変に感じました。少し触れましたが、年齢欄や乗船地の欠損が多かったですし、それらをどのように扱えば高い正答率が得られるかというのは、経験に基づくところがあるのではないかと思います。

CentOS7 で Bitzeny のマイニングをする

背景

会社で余らせているサーバーがいくつかあったので仮想通貨をマイニングしようということになりました。費用がペイするかどうかも不明ですが、やってみようと思います。

マイニング

マイニングについては説明が大変なのと、何よりすでにわかりやすく説明してある記事が無数に存在しているので個々では特に解説することはしませんな

[仮想通貨 マイニング とは][検索]

さて、マイニング対象の仮想通貨ですがBitzenyを選択しています。今回は、余っているサーバーをつかってCPUマイニングを行うということで、GPUやASICへの耐性が高くCPUでも十分に稼げそうな仮想通貨を選択しました。GPUやASIC耐性があるゆえに価値が上がらないというのは十分にありそうですね・・・・

環境構築

アカウント作成

CPUで十分に稼げるとはいえ、一人で掘るには力不足ですから、プールマイニングをします。プールマイニングとは、みんなで協力して正解ハッシュを探索し、正解が見つかった際には正解者にかかわらずハッシュの計算力に応じて報酬を分配するマイニング団体のことです。LAPoolというマイニングプールを使用します。

LA Bitzeny Pool - Home

上記のサイトにアクセスしてアカウントを作成しましょう。

f:id:kk2dev:20180322150547p:plain:w300

ログインできたらワーカーを作成します。ワーカー名やパスワードは適当で構いませんが、コマンドラインでヒストリに残る使い方をするのでバレてもかまわないものにするのがいいでしょう(普段使ってるものは絶対につかわないように)

マイナーのインストール

マイニングツールのインストールをします。今回使用するのはcpuminerというもののをフォークしてBitzeny (yescriptという方式) に対応したものです。

パッと見たところ、Linux向けのバイナリがみつからなかったので、ソースからビルドすることにしました。

まずは必要なパッケージをインストールします。

$ yum -y install git autoconf libcurl-devel jansson-devel openssl openssl-devel gcc gawk screen

つぎに、ソースをダウンロードします。

$ git clone https://github.com/bitzeny/cpuminer.git
$ cd cpuminer

最後にバイナリをビルドします。

$ ./configure CFLAGS="-O3 -march=native -funroll-loops -fomit-frame-pointer"
$ make

ほってみる

実際に掘ってみましょう

$ ./minerd -a yescrypt -o stratum+tcp://jp.lapool.me:3014 -u {ユーザー名}.{ワーカー名} -p {ワーカーのパスワード}

以下のような表示が出ればオッケーです

[2018-03-22 15:54:52] accepted: 1/1 (100.00%), 2.06 khash/s (yay!!!)

裏で動かすためにscreenでセッションを作成しましょう。

$ screen -AmdS znyminer ./minerd -a yescrypt -o stratum+tcp://jp.lapool.me:3014 -u {ユーザー名}.{ワーカー名} -p {ワーカーのパスワード}

まとめ

余っているサーバーでとりあえずBitzenyをほってみました。収益などは十分に時間が経ってからこの記事に追記しようとおもっています。

便利なSlackの使い方 (基本編)

背景

前回の記事の冒頭でも触れたとおり、社内にSlackが導入されたのでちょっとした便利な使い方を紹介していきたいと思います。

Slack

Slackはビジネス向けチャットアプリという位置づけでリリースされたアプリケーションでいまやITスタートアップ界隈では必須となりつつあるツールとなっています。*1

最近になって日本語版がリリースされ、テレビコマーシャルやタクシー・電車向け広告などにもなっているのでさらに利用者は広がることになるでしょう。

プロフィールの設定

具体的なチャットの使い方の前にまずはプロフィールの設定方法から紹介していきたいと思います。

画像はPC英語版のものですがスマートフォン向けアプリでもUIの配置はだいたい変わらないので随時置き換えて見てください。

まずは下記の写真の赤枠で囲われた部分を順にクリックしていきます。

f:id:kk2dev:20180209134832p:plain

f:id:kk2dev:20180207153332p:plain

f:id:kk2dev:20180207154201p:plain

下の画像がプロフィールの編集画面になります。

それぞれ、Full nameには自分の名前、Display nameはSlackに実際に表示される名前を設定します。What I doでは自分がそのチームで何をしているか(ここでは社内でどんな仕事をしているか)を入力します。アイコンはProfile photoをクリックすることで変えられるようになるので変更しておきましょう。

f:id:kk2dev:20180207155245p:plain

チャットの使い方

基本的な機能はLINEなどのチャットツールと同じように利用できますがSlackでは更にいろいろな機能が用意されているので紹介します。

いろいろなチャットコマンド

以下のように記述することでいろいろなチャットメッセージを送信することができます。コードブロックはバッククオート(JIS配列であればShift+@)で入力できます。

画像は実際の表示例です。

説明 表示
*hoge* 太字 hoge
~hoge~ 取り消し線 hoge
_hoge_ イタリック hoge
`hoge` コードブロック hoge
```hoge``` 複数行コードブロック
> hoge 引用
>>> hoge 複数行引用

f:id:kk2dev:20180207164749p:plain:w300

f:id:kk2dev:20180207173334p:plain:w300

f:id:kk2dev:20180207173137p:plain:w300

f:id:kk2dev:20180207173003p:plain:w300

f:id:kk2dev:20180207173032p:plain:w300

投稿にリアクションをつける

何か連絡に対していちいち返信を行うのは面倒ですよね。そんなときに使える方法です。

f:id:kk2dev:20180207224831p:plain

赤枠で囲ったボタンを押すとリアクションをつけることが可能です。(絵文字のパレットがでてきます)

Slackの絵文字にはすべて文字列が設定されていて:[絵文字の名前]:とすることで設定されている絵文字を投稿できます。例えば:+1:とすると👍の絵文字が表示されます。また、+:[絵文字の名前]:とすると直前の投稿にリアクションを行うことが可能です。

メールやソースコードを貼り付ける

メールやソースコードをチャットに貼り付けたい場面があるかもしれませんが、そのまま貼り付けてしまうといろいろなチャットが流れてしまったり、その後のチャットで流されてしまったりといろいろ面倒なことが起こります。

そんなときにはsnippetを利用しましょう。

f:id:kk2dev:20180209131706p:plain:w600

上の画像のようにチャットのプラスボタンからCode or text snippetを選択します。

f:id:kk2dev:20180209131918p:plain:w600

左上にテキストの名前を入力、右上ではテキストのタイプを指定します。メールであればPlain Textを選択、ソースコードであればそのプログラミング言語を選択すると良いでしょう。

f:id:kk2dev:20180209132147p:plain:w600

Create Snippetをクリックするとチャットに貼り付けられます。貼り付けたSnippetはShared Fileから閲覧できるため、チャットで流されてもスムーズに閲覧できます。

スレッドを作成する

あるチャンネルで1つのことに議論をすすめると、それまで何を話していたかわからなくなってしまうことがあります。そんなときに有効なのがスレッドです。

Slackでいうスレッドは2ちゃんねるなどの掲示板でいうスレッドとは違い、Twitterのリプライ機能に近いです。

f:id:kk2dev:20180209133441p:plain:w400

上の画像の吹き出しボタンをクリックするとスレッドタブが表示されます。

f:id:kk2dev:20180209133924p:plain:w400

コメント欄に文字を入力することでスレッドを作成することができます。

チャンネルを作成する

Slackでは話題をチャンネルという単位で分割します。議論がごちゃまぜになったりチャットが流れたりしないための手段となっています。

チャンネルを作成する目安としては、今後もその話題についての議論が続く可能性があるとき、くらいが良いと思います。

f:id:kk2dev:20180209134945p:plain:w600

チャンネル一覧の右にあるプラスボタンをクリックします。

f:id:kk2dev:20180209135204p:plain

Nameにはチャンネル名を入力します。簡潔になにを話すチャンネルかがわかるように名前を設定しましょう。

Purposeには実際に話す内容を1センテンス程度で入力します。(任意)

Send invites toには招待したいメンバーを入力します。こちらも任意で、あとから招待することも可能です。また、Publicチャンネルであれば他のメンバーが自分から参加することも可能です。

一番上の項目をクリックすることでチャンネルをPrivateに設定することも可能で、Privateにすると招待した人しか参加できなくなります。*2


まとめ

今回はSlackに用意されている基本的な機能を紹介しました。次回はSlackで利用できる色々な拡張機能について紹介をしたいと思っています。

是非実践してみてください!!

*1:諸説あります

*2:チームオーナーと管理者は閲覧することが可能です。

出退勤管理システムをSlackに移植した

背景

社内Slackが導入されたので下記記事で紹介した出退勤管理システムをSlackに移植することにしました。

dev-kk2.hatenablog.com

dev-kk2.hatenablog.com

実装

従来のLINEの機能は残しつつ機能追加という形で実装を行うことにします。

Slackの設定

incoming webhookとoutgoing webhookを設定する。それぞれカードがタッチされた時と確認を行うときのための機能です。

incoming webhook

f:id:kk2dev:20180202181856p:plain

上記のWebhook URLに以下の形式のJSONをPOSTします。

{
    "text": "message"
}

outgoing webhook

f:id:kk2dev:20180202182501p:plain

上記のURL(s)の欄にエンドポイントのURLを記入する。複数行に記述することで複数の宛先に送ることが可能です。

Trigger Word(s)には検出したい文字列を設定します。こちらにも複数の条件を設定することが可能で、カンマ区切りで行います。

受け取ったリクエストに対してResponse bodyに以下のJSONを設定することで投稿できます。

{
    "text": "message"
}

ソースの追記

以下のようなコードを追記しました。

main.cr nfcpush()の末尾に追記

HTTP::Client.post(Params::SLACK_WEBHOOK_URL,
                  headers: HTTP::Headers{
                    "Content-Type" => "application/json",
                  }

ICカードのタッチに発火してSlackのincoming webhookに投げます。

def whoishere(request : HTTP::Request, list : Hash)
  list = list.select {|k, v| v == true}
  text = list.size != 0 ? "#{list.size}人がオフィスにいます\n" : "オフィスには誰もいません"
  cardNames = Response::CardNames.from_json(File.read(Params::PATH_TO_CARDNAMES_JSON)).cardNames
  list.map {|k, v| cardNames.select {|c| c.idm == k}.size != 0 ? cardNames.select {|c| c.idm == k}[0].name : "未登録のユーザー"}.each {|e| text += "#{e} "}
  return {200, "{\"text\": \"#{text}\"}"}
end

Slackのoutgoing webhookに発火してResponseを返します。

まとめ

無事Slackと連携することができました。

出退勤管理システムのAPI endpointをCrystalに置き換える

背景

前回の記事でDjangoNFCリーダーを用いた簡単な出退勤管理システムを紹介しました。

dev-kk2.hatenablog.com

しばらく動作させていたところ、Raspberry piのCPU使用率が天井に張り付いてしまっていました。(動作がめちゃくちゃ重くなった)

CPU使用率は常時50%となっていましたが、熱対策のためのスロットリングが働いて50%が天井に設定されていると考えられます。実際本体をさわってみると長時間は触っていられないくらいの温度になっていました・・・

対策

各プロセスのCPU使用率を見てみるとDjangoが30%、NFCリーダーのプログラムが15~20%、ngrokとmysqlが数%、といった感じになっていました。*1

DjangoNFCリーダーのプログラムを両方移行したかったのですが、NFCリーダーについてはnfcpy以外にまともに使えるライブラリがないのでひとまずDjangoをCrystalに置き換えてみることにします。さらにmysqlを利用するほどのことでもないと思い、これもjsonファイルに置き換えました。

CrystalにはARMアーキテクチャ向けコンパイラは用意されていませんが、x86_64環境でクロスコンパイルをすることにしました。

環境構築

まず、手元のMacbookにCrystalのコンパイラをインストールします。

$ brew install crystal

また、Raspberry piにはLLVM3.8がインストールしてあり、この環境でCrystalの実行形式ファイルをリンクするには1.0系のopensslが必要だったため、これもインストールしました。

$ sudo apt install libssl1.0.2

プログラム

製作したプログラムはGithubに公開しています。

github.com

Crystalというプログラミング言語はマイナーではありますが最近とある界隈で話題になっていて*2日本語の記事も少しずつ増えているので探してみてください。

Crystalは静的型付き言語なので、JSONのような型が不明なデータをあつかうのが意外と大変です。CrsytalではJSON#parseによってJSON::Anyというクラスに変換してからif文とis_a?メソッドをつかって型を決定していく方法と、JSON#mappingによって予めJSONの型を指定しておく方法があります。今回はLINEのAPIドキュメントによってあらかじめJSONの型がわかるようになっているのでJSON#mappingを書いておきます。

json_map.cr

require "json"

module Response
  class Result
    JSON.mapping({
      events: Array(Event),
    })
  end

  class Event
    JSON.mapping({
      type: String,
      timestamp: Int64,
      source: Source,
      replyToken: { type: String, nilable: true },
      message: { type: Message, nilable: true },
    })
  end

  class Source
    JSON.mapping({
      type: String,
      userId: { type: String, nilable: true },
      groupId: { type: String, nilable: true },
      roomId: { type: String, nilable: true },
    })
  end

  class Message
    JSON.mapping({
      id: String,
      type: String,
      text: { type: String, nilable: true },
      fileName: { type: String, nilable: true },
      fileSize: { type: String, nilable: true },
      title: { type: String, nilable: true },
      address: { type: String, nilable: true },
      latitude: { type: Float64, nilable: true },
      longitude: { type: Float64, nilable: true },
      packageId: { type: String, nilable: true },
      stickerId: { type: String, nilable: true },
    })
  end

  class Reply
    JSON.mapping({
      replyToken: String,
      messages: Array(ReplyMessage),
    })

    def initialize(@replyToken : String, @messages : Array(ReplyMessage))
    end
  end

  class Push
    JSON.mapping({
      to: String,
      messages: Array(ReplyMessage),
    })

    def initialize(@to : String, @messages : Array(ReplyMessage))
    end
  end

  class ReplyMessage
    JSON.mapping({
      type: String,
      text: { type: String, nilable: true },
      fileName: { type: String, nilable: true },
      fileSize: { type: String, nilable: true },
      title: { type: String, nilable: true },
      address: { type: String, nilable: true },
      latitude: { type: Float64, nilable: true },
      longitude: { type: Float64, nilable: true },
      packageId: { type: String, nilable: true },
      stickerId: { type: String, nilable: true },
    })
    def initialize(@type : String)
    end
    def add_text(@text : String)
      self
    end
  end

  class Cardid
    JSON.mapping({
      cardid: String,
    })
  end

  class CardNames
    JSON.mapping({
      cardNames: Array(CardName),
    })
  end

  class CardName
    JSON.mapping({
      idm: String,
      name: String,
    })
  end
end

上記のソースがJSONのパースの仕方を記述したものです。含まれていないかもしれない値にはnilableというクラス変数にtrueを代入して置かなければなりません。

さらに、nilである可能性がある値を利用する場合にはnilでないことが保証されなければなりません。

以下が利用例になります。

event = Response::Result.from_json(body.gets_to_end).events[0]
message = event.message
if message.is_a?(Response::Message)
    puts "message event!"
    groupId = event.source.groupId
    roomId = event.source.roomId
    if groupId.is_a?(String)
      puts "Group ID:#{groupId}"
      text = message.text
      replyToken = event.replyToken
      if text.is_a?(String) && replyToken.is_a?(String)
        replyMessage(replyToken, text, list)
      end
    elsif source.roomId.is_a?(String)
      puts "Room ID:#{roomId}"
    else
      puts "User ID:#{event.source.userId}"
    end
  else
    puts "other event!"
  end

結果

DjangoMySQLを停止した結果、CPU使用率が17,8%程度まで抑えられました。Herokuにデプロイするという選択肢もありましたが、最近Crystalを主に書いていたのと、Raspberry piでCrystalのプログラムを動かしてみたかったのでこの選択肢を選んでいます。

NFCリーダーについても、未だ高めのCPU使用率になっているので、他の選択肢があるなら随時置き換えていきたいと考えています。

*1:さすがPython

*2:諸説あります

Raspberry piとNFCリーダーで出退勤管理システムをつくった

背景

先日下記の記事を投稿しましたが、その後ICカードを使った出退勤管理システムをつくったので色々と書き留めておきたいと思います。

NFCリーダーのセットアップは下記で紹介いたしました。

dev-kk2.hatenablog.com

環境構築

以下、環境構築です。

LINE Messaging API

出退勤管理を通知するにあたってLINEのMessaging APIを使用することにしました。基本的にこちら(Messaging API)から指示通りに進んで行けば登録が可能ですが、今回はPUSH MESSAGEを使用したいため、Developer Trialのアカウントを取得しました。下記のブログを参考にさせていただいております。

uzulla.hateblo.jp

Django

LINE Messaging APIのWebhookリクエストの受け取りやPUSH MESSAGEの送信のためにPythonのWebフレームワークであるDjangoを利用しました。nfcpyがPython2系のみの対応だったためDjangoもPython2系の古いバージョンを使用しています。JSONをさばいたりHTTPリクエストを吐いたりするためにjsonとrequestsパッケージもインストールします。

$ sudo pip install django==1.11 json requests

導入から動作テストまではだいたい以下の記事を参考にさせていただいております。

qiita.com

Herokuにデプロイせずに以下で紹介するngrokでHTTPSフォワーディングにして稼働させることにしました。*1

上記は、今後構成を変更するかもしれない。

ngrok

LINE Messaging APIHTTPSのみのサポートのため、HTTPからHTTPSにトンネリングができるアプリケーションを導入します。Djangoのデフォルトのポート番号は8000ですからコマンドライン引数に8000を与えて起動しておきます。

$ wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip
$ unzip ngrok-stable-linux-arm.zip
$ ./ngrok http 8000

f:id:kk2dev:20180109170256p:plain これでlocalhost:8000が表示されたURLにフォワーディングされています。

mysql

ICカードIDmと利用者の名前を紐付けるためにmysqlを導入しました。もっと楽にできる方法はありそう。*2

アプリケーション作成

シュッとコードを書いて動くようにします。

linebot/urls.py

"""linebot URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/1.11/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.conf.urls import url, include
    2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^bot/', include('bot.urls')),
]

bot/urls.py

from django.conf.urls import url

from . import views

urlpatterns = [
    url(r'^$', views.index, name="index"),
    url(r'^callback', views.callback),
    url(r'^nfcpush', views.nfcpush),
]

bot/views.py

# -*- encoding: utf-8 -*-

from django.shortcuts import render
from django.http import HttpResponse
import json
import requests
import mysql.connector

REPLY_ENDPOINT = 'https://api.line.me/v2/bot/message/reply'
PUSH_ENDPOINT = 'https://api.line.me/v2/bot/message/push'
ACCESS_TOKEN = '[ACCESS_TOKEN]'
HEADER = {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + ACCESS_TOKEN
}
PUSH_GROUP = "[GROUP_ID]"

user_states = {}

def index(request):
    return HttpResponse("This is bot api.")

def callback(request):
    reply = ""
    request_json = json.loads(request.body.decode('utf-8'))
    print(json.dumps(request_json))
    for e in request_json['events']:
        reply_token = e['replyToken']
        message_type = e['message']['type']

        if message_type == 'text':
            text = e['message']['text']
            reply += reply_text(reply_token, text)
    return HttpResponse(reply)

def reply_text(reply_token, text):
    if text.find(u"[検出したい文字列]") != -1:
        count = 0
        users = []
        for k, v in user_states.items():
            if v == True:
                count += 1
                users.append(k)

        reply_text = str(count) + u"人がオフィスにいます\n"

        dbcon = mysql.connector.connect(
            database="pi",
            user="root",
            password="[password]",
            host="localhost"
        )

        for u in users:
            dbcur = dbcon.cursor()
            sql = "select name from cardinfo where id='" + u + "'"
            dbcur.execute(sql)
            row = dbcur.fetchone()
            if row == None:
                reply_text += u"未登録のユーザー "
            else:
                reply_text += row[0] + " "

    payload = {
          "replyToken":reply_token,
          "messages":[
                {
                    "type":"text",
                    "text": reply_text
                }
            ]
    }

    requests.post(REPLY_ENDPOINT, headers=HEADER, data=json.dumps(payload))
    return reply_text

def nfcpush(request):
    request_json = json.loads(request.body.decode('utf-8'))
    dbcon = mysql.connector.connect(
        database="pi",
        user="root",
        password="[password]",
        host="localhost"
    )

    dbcur = dbcon.cursor()
    sql = "select name from cardinfo where id='" + request_json['cardid'] + "'"
    dbcur.execute(sql)
    row = dbcur.fetchone()

    text = u"さんが"
    print(request_json['cardid'] in user_states)
    if not request_json['cardid'] in user_states:
        user_states[request_json['cardid']] = True
        text += u"出勤しました。"
    else:
        if user_states[request_json['cardid']]:
            text += u"退勤しました。"
            user_states[request_json['cardid']] = False
        else:
            text += u"出勤しました。"
            user_states[request_json['cardid']] = True

    name = ""
    if row == None:
        name = u"未登録のユーザー"
    else:
        name = row[0]

    payload = {
        "to":PUSH_GROUP,
        "messages":[
            {
                "type":"text",
                "text": name + text
            }
        ]
    }
    requests.post(PUSH_ENDPOINT, headers=HEADER, data=json.dumps(payload))
    return HttpResponse("OK")

すべての処理は bot/views.py に記述してあります。 callback(request) ではLINE Messaging APIのWebhookリクエストを捌きます。送られてきたテキストの先頭が任意の文字列であった場合に現在オフィスにいる人数と人の名前を返信します。

nfcpush(request) ではNFCリーダーで読んだIDm入りのJSONを捌きます。DBでIDmから名前を確認し、未出勤であるなら"出勤しました"、出勤済であるなら"退勤しました"というPUSH MESSAGEを設定してあるグループに投稿するようにしてあります。

NFCリーダーからICカードを読み取ってリクエストを投げるスクリプトは以下です。

nfcpush.py

import binascii
import nfc
import json
import requests
HEADER = {
    "Content-Type": "application/json",
    "Authorization": "Bearer"
}
class MyCardReader(object):

    def on_connect(self, tag):
        print("touched")
        self.idm = binascii.hexlify(tag.idm)
        payload = {
            "cardid":self.idm
        }
        requests.post("http://localhost:8000/bot/nfcpush", headers=HEADER, data=json.dumps(payload))
        return True

    def read_id(self):
        clf = nfc.ContactlessFrontend('usb')
        try:
            clf.connect(rdwr={'on-connect': self.on_connect})
        finally:
            clf.close()

if __name__ == '__main__':
    cr = MyCardReader()
    while True:
        print("touch card:")
        cr.read_id()
        idm = cr.idm
        print("released")
        print(cr.idm)

また、DBへの名前の登録は以下のスクリプトで行っています。

register.py

import binascii
import nfc
import json
import mysql.connector

class MyCardReader(object):

    def on_connect(self, tag):
        print("touched")
        self.idm = binascii.hexlify(tag.idm)
        return True

    def read_id(self):
        clf = nfc.ContactlessFrontend('usb')
        try:
            clf.connect(rdwr={'on-connect': self.on_connect})
        finally:
            clf.close()

if __name__ == '__main__':
    print("enter your name:")
    input_line1 = raw_input()
    cr = MyCardReader()
    print("touch card:")
    cr.read_id()
    idm = cr.idm
    dbcon = mysql.connector.connect(
        database="pi",
        user="root",
        password="[password]",
        host="localhost"
    )
    dbcur = dbcon.cursor()
    sql = "INSERT INTO cardinfo (id, name) values ('" + idm + "', '" + input_line1 + "')"
    dbcur.execute(sql)
    dbcon.commit()
    print("released")
    print(cr.idm)

実行

うまく動作しました!!!

まとめ

名前の登録をするときは、 nfcpush.py をいちいち止めないと行けないのが面倒。登録はいまはCUIのみなのでグラフィカルにいい感じにやりたい。

そもそも、ICカードをタッチするのが面倒なのでカメラで顔認識とかしてうまいことやりたい。

*1:いま考えるとnfcpyだけ分割してDjangoはPython3系でHerokuにデプロイして動かすほうが賢いかもしれない。

*2:アホだったのでmysqlでやったけどDjangoからSQLiteとかを使ったほうが絶対に楽。なぜそうしなかったのか...

Raspberry PiでNFCタグを読み込む

背景

出退勤システムが作りたかったのでNFCタグリーダをもってきた。

いろいろインストール

必要なものはPython2系とそのpipくらい。

$ sudo apt install python python-pip

nfcpyをpipで入れる

$ sudo pip install -U nfcpy

サンプルのリポジトリをクローンしてみる

$ git clone https://github.com/nfcpy/nfcpy.git
$ cd nfcpy

実行してみる

$ sudo python examples/tagtool.py show
No handlers could be found for logger "nfc.llcp.sec"
[nfc.clf] searching for reader on path usb
[nfc.clf] using SONY RC-S380/P NFC Port-100 v1.11 at usb:001:005
** waiting for a tag **
Type3Tag 'FeliCa Standard (RC-S???)' ID=**************** PMM=**************** SYS=0003

IDmだけ取れればいいので、サンプルを参考に何度も取れるように改良する。

# read_idm.py
import binascii
import nfc

class MyCardReader(object):
    def on_connect(self, tag):
        print "touched"
        self.idm = binascii.hexlify(tag.idm)
        return True

    def read_id(self):
        clf = nfc.ContactlessFrontend('usb')
        try:
            clf.connect(rdwr={'on-connect': self.on_connect})
        finally:
            clf.close()

if __name__ == '__main__':
    cr = MyCardReader()
    while True:
        print "touch card:"
        cr.read_id()
        print "released"
        print cr.idm

実行する

$ sudo read_idm.py
No handlers could be found for logger "nfc.llcp.sec"
touch card:
touched
released
0114************
touch card:
touched
released
0114************
touch card:

まとめ

なんとかWebアプリかなんかと連携していい感じに表示できるようにしたいところ。