ラボ内のレジを作った話
この記事も例によって Muroran Institute of Technology Advent Calendar 2018 8日目に捧げます.
だいぶ埋まってきた.
年内あと幾つ記事が書けるだろうかと1人実家で書いています.
さて,今回はラボ内のアレ向けにレジを作った話です.
デバイス
みんな大好き,Raspberry Pi 3 Model B を用います.
いい感じのケース と,タッチディスプレイ も用意します.
あとは micro SDカードとバーコードリーダ,適当にサーバ用にPCを用意しました.
# TODO: 本体の写真を貼る
OS のセットアップ
Raspberry Pi は【ヘッドレス】Raspberry Pi 3 セットアップ for macOS にしたがって,Raspbian をインストールします.
サーバとする PC には arch linux を入れて,Docker & Docker Compose をインストールしましょう.
プログラムの作成
Python3 で開発を行います.
本体向けには API を叩くための requests と GUI 作成用に Kivy をインストールします.
記憶では,当時は少し Kivy のインストールに癖があったように思いますが,今はどうでしょうか?
Raspberry Pi はまだ簡単にセットアップできたと思いますが.
日本語表示用に事前に IPA フォントもダウンロードして解凍しておきましょう.
</p>
#!/usr/bin/env python3
#-*- coding: utf-8 -*-
from kivy.app import App
from kivy.lang import Builder
from kivy.core.window import Window
from kivy.graphics import *
from kivy.adapters.dictadapter import DictAdapter
from kivy.adapters.listadapter import ListAdapter
from kivy.uix.boxlayout import BoxLayout
from kivy.properties import ObjectProperty, ListProperty
from kivy.uix.listview import ListItemButton, ListItemLabel, \
CompositeListItem, ListView
from kivy.uix.gridlayout import GridLayout
from kivy.uix.textinput import TextInput
from kivy.uix.popup import Popup
from kivy.uix.button import Button
from kivy.uix.modalview import ModalView
from kivy.core.text import LabelBase, DEFAULT_FONT
from kivy.resources import resource_add_path
from kivy.config import ConfigParser
from kivy.config import Config
from kivy.uix.label import Label
from functools import partial
import requests
import yaml
import re
from pathlib import Path
base_path = Path('/home/pi/raspy-register')
# デフォルトに使用するフォントを変更する
resource_add_path(base_path / 'fonts')
LabelBase.register(DEFAULT_FONT, 'ipagp.ttf') #日本語が使用できるように日本語フォントを指定する
config = {}
with open(base_path / "config.yml") as rf:
config = yaml.load(rf)
class RegisterForm(BoxLayout):
register_item_list_data = []
enter = ""
def __init__(self, **kwargs):
super(RegisterForm, self).__init__(**kwargs)
args_converter = lambda row_index, rec: \
{
'text': rec['barcode'],
'size_hint_y': None,
'height': 100,
'cls_dicts': [
{
'cls': ListItemLabel,
'kwargs': {
'text': rec['name'],
'is_representing_cls': True,
'size_hint_x': '15'
}
},
{
'cls': ListItemLabel,
'kwargs': {
'text': f"¥{rec['price']}",
'size_hint_x': '5'
}
},
{
'cls': ListItemButton,
'id': 'lib_subtraction',
'kwargs': {
'text': "-",
'size_hint_x': '2',
'on_release': lambda btn_text: self.item_count_subtraction(rec, row_index),
},
},
{
'cls': ListItemLabel,
'kwargs': {
'text': f"{rec['count']}",
'size_hint_x': '3'
}
},
{
'cls': ListItemButton,
'kwargs': {
'text': "+",
'size_hint_x': '2',
'on_release': lambda btn_text: self.item_count_addition(rec, row_index),
}
},
{
'cls': ListItemButton,
'kwargs': {
'text': "x",
'size_hint_x': '1',
'on_release': lambda btn_text: self.item_delete(rec, row_index),
}
}
]
}
register_list_adapter = \
ListAdapter(
data=self.register_item_list_data,
args_converter=args_converter,
selection_mode='none',
cls=CompositeListItem)
self.register_item_list.adapter = register_list_adapter
self.reload_total_cost()
self.keyboard = Window.request_keyboard(self.keyboardClosed, self)
self.keyboard.bind(on_key_down=self.keyboardDown)
def list_clear(self, *args, **kwargs):
self.register_item_list_data = []
self.update_list()
def update_list(self, set_focus=True):
self.register_item_list.adapter.data.clear()
self.register_item_list.adapter.data.extend(self.register_item_list_data)
self.register_item_list._trigger_reset_populate()
self.reload_total_cost()
def item_count_subtraction(self, rec, row_index):
if self.register_item_list_data[row_index]['count'] > 0:
self.register_item_list_data[row_index]['count']\
= self.register_item_list_data[row_index]['count'] - 1
self.update_list()
def item_count_addition(self, rec, row_index):
self.register_item_list_data[row_index]['count']\
= self.register_item_list_data[row_index]['count'] + 1
self.update_list()
def item_delete(self, rec, row_index):
del(self.register_item_list_data[row_index])
self.update_list()
def reload_total_cost(self):
self.cost = sum([i['price'] * i['count'] for i in self.register_item_list_data])
self.total.text = f"[b]{self.cost}円[/b]"
def on_enter(self, text):
if text.isdigit():
d = [i for i, d in enumerate(self.register_item_list_data)\
if d['barcode'] == int(text)]
if len(d) > 0:
self.register_item_list_data[d[0]]['count']\
= self.register_item_list_data[d[0]]['count'] + 1
else:
r = requests.get(f'{config["api"]["scheme"]}://{config["api"]["host_name"]}/price/{text}')
if r.status_code == 200:
res = r.json()
if res['result']:
if res['data']['price'] == -1:
mv = ModalView(size_hint_x=.8, size_hint_y=None, height="180dp")
bl = BoxLayout(orientation="vertical", size_hint_y=None, height="180dp")
bl.add_widget(Label(text="販売していません", height="100dp", size_hint_y=None))
bl.add_widget(Button(text="閉じる", height="80dp", size_hint_y=None, on_release=lambda b: mv.dismiss()))
mv.add_widget(bl)
mv.open()
else:
self.register_item_list_data.append(
{
'barcode': res['data']['janCode'],
'name': res['data']['name'],
'count': 1,
'price': res['data']['price']
}
)
elif r.status_code == 404:
res = r.json()
if res['error'] and res['error'] == 'Not found':
print(f"Not fount: {text}")
r = requests.post('https://slack.com/api/chat.postMessage', params={
'token': config["slack_app"]["Bot_User_OAuth_Access_Token"],
'channel': config["slack_app"]["Channel_ID"],
'text': f"Item Not Found. janCode: {text}\nhttps://www.google.co.jp/search?q={text}"
})
mv = ModalView(size_hint_x=.8, size_hint_y=None, height="180dp")
bl = BoxLayout(orientation="vertical", size_hint_y=None, height="180dp")
bl.add_widget(Label(text="未登録のバーコードです", height="100dp", size_hint_y=None))
bl.add_widget(Button(text="閉じる", height="80dp", size_hint_y=None, on_release=lambda b: mv.dismiss()))
mv.add_widget(bl)
mv.open()
self.update_list(False)
def to_check(self):
if self.cost > 0:
popup = ChooseCheckMethod(self, self.cost, size_hint_y=None, height="160dp")
popup.open()
else:
self.list_clear()
def cancel(self):
self.register_item_list_data = []
self.update_list()
def keyboardDown(self, keyboard, keycode, text, modifiers):
if re.match('[0-9]', keycode[1]):
self.enter = f"{self.enter}{keycode[1]}"
elif keycode[0] == 13: #enter
self.on_enter(self.enter)
self.enter=""
print("enter:", self.enter)
def keyboardClosed(self):
print("called keyboardClosed")
class ChooseCheckMethod(Popup):
def __init__(self, parent_form, total, **kwargs):
self.parent_form = parent_form
self.total = total
super(ChooseCheckMethod, self).__init__(**kwargs)
self.title = '支払い方法を選択'
content = BoxLayout(orientation="vertical", size_hint_y=None)
footer = BoxLayout(size_hint_y=None)
footer.add_widget(Button(text='戻る', size_hint_y=None, height="40dp", on_release=lambda button:self.dismiss()))
content.add_widget(Button(text='現金', size_hint_y=None, height="40dp", on_release=lambda button: self.to_cachWindow()))
content.add_widget(footer)
self.content = content
def to_cachWindow(self):
cw = CashWindow(self.parent_form, self.total)
cw.open()
self.dismiss()
class CashWindow(Popup):
money = 0
def __init__(self, parent_form, total, **kwargs):
self.parent_form = parent_form
self.total = total
super(CashWindow, self).__init__(**kwargs)
self.title = '預り金入力'
content = BoxLayout(size_hint_y=1)
panel = BoxLayout(size_hint_x=1, padding=50, orientation="vertical")
cost = BoxLayout()
pay = BoxLayout()
cost.add_widget(Label(
text="合計: ",
font_size='32sp',
height='40dp',
pos_hint={'top': .8},
size_hint_y=None,
))
cost.add_widget(Label(
text=f"{total}円",
font_size='32sp',
height='40dp',
pos_hint={'top': .8},
size_hint_y=None,
))
panel.add_widget(cost)
lb = Label(
text="お預り: ",
font_size='32sp',
height='40dp',
pos_hint={'top': .8},
size_hint_y=None,
)
pay.add_widget(lb)
ti = TextInput(
input_type='number',
multiline=False,
font_size='32sp',
height='40dp',
pos_hint={'top': .8},
size_hint_y=None,
hint_text="数値入力",
readonly=True
)
self.ti = ti
pay.add_widget(ti)
panel.add_widget(pay)
keyboard = GridLayout(cols=4,size_hint_x=None, width="320dp")
keyboard.add_widget(Button(text='7', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='8', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='9', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='0', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='4', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='5', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='6', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='←', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_bs)))
keyboard.add_widget(Button(text='1', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='2', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='3', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_key_down)))
keyboard.add_widget(Button(text='確定', size_hint=(None, 1), width="80dp", height="120dp", on_release=partial(self.on_enter)))
content.add_widget(panel)
content.add_widget(keyboard)
self.content = content
def on_key_down(self, button):
self.money = self.money * 10 + int(button.text)
self.ti.text = f"{self.money}"
def on_enter(self, button):
if self.total > self.money:
mv = ModalView(size_hint_x=.8, size_hint_y=None, height="180dp")
bl = BoxLayout(orientation="vertical", size_hint_y=None, height="180dp")
bl.add_widget(Label(text="不足しています", height="100dp", size_hint_y=None))
bl.add_widget(Button(text="閉じる", height="80dp", size_hint_y=None, on_release=lambda b: mv.dismiss()))
mv.add_widget(bl)
mv.open()
else:
self.dismiss()
mv = ModalView(size_hint_x=.8, size_hint_y=None, height="180dp")
bl = BoxLayout(orientation="vertical", size_hint_y=None, height="180dp")
bl.add_widget(Label(text=f"お釣り: {self.money - self.total}円", height="100dp", size_hint_y=None))
bl.add_widget(Button(text="閉じる", height="80dp", size_hint_y=None, on_release=lambda b: mv.dismiss()))
mv.add_widget(bl)
mv.bind(on_dismiss=self.parent_form.list_clear)
mv.open()
def on_clear(self, button):
self.ti.text=""
self.money = 0
pass
def on_bs(self, button):
self.money = self.money // 10
self.ti.text = f"{self.money}"
class RegisterApp(App):
def build(self):
return RegisterForm()
if __name__ == '__main__':
RegisterApp().run()
<p>
未登録のバーコードがあれば,slack に通知するようにもなっています.
Kivy ファイル:
</p>
#: import main main
<RegisterForm>
# 一覧画面のレイアウト
orientation: "vertical"
bl: bl
total: total
register_item_list: register_item_list
BoxLayout:
id: bl
orientation: "vertical"
ActionBar:
use_separator: True
ActionView:
ActionPrevious:
title: "登録"
with_previous: False
ListView:
id: register_item_list
scroll_type: ['bars', 'content']
scroll_wheel_distance: dp(114)
bar_width: dp(10)
BoxLayout:
height: "80dp"
size_hint_y: None
Label:
text: "[b]合計: [/b]"
font_size: '20sp'
markup: True
Label:
id: total
text: "[b]xxx円[/b]"
font_size: '20sp'
markup: True
BoxLayout:
height: "40dp"
size_hint_y: None
Button:
text: "キャンセル"
size_hint_x: 2
on_release: root.cancel()
Button:
text: "会計へ"
size_hint_x: 3
on_release: root.to_check()
<p>
開発時の PC から持ってきたので,もしかしたら動かないかもですが,エラーを根気よく対処すれば動くはずです.
これとは別に,用意したサーバには価格を保存しておくデータベースサーバと価格を取得する API サーバを用意します.
データベースは今回,PostgreSQL を用いました.
API サーバは Python3 の flask と peewee を用いて開発しました.
</p>
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from flask import Flask, jsonify, abort, make_response, request
import peewee as pe
import random
import json
import datetime
import yaml
config = {}
with open("config.yml") as rf:
config = yaml.load(rf)
db = pe.PostgresqlDatabase(
config['database']['db_name'],
host=config['database']['host'],
port=config['database']['port'],
user=config['database']['user'],
password=config['database']['password']
)
# 商品
class Item(pe.Model):
janCode = pe.BigIntegerField(primary_key = True)
name = pe.TextField()
class Meta:
database = db
db_table = 'items'
# 価格
class Price(pe.Model):
janCode = pe.ForeignKeyField(Item)
startDateTime = pe.DateTimeField(default=datetime.datetime.now)
price = pe.IntegerField()
class Meta:
database = db
db_table = 'prices'
primary_key = pe.CompositeKey('janCode', 'startDateTime')
api = Flask(__name__)
# 価格の取得
@api.route('/price/<string:janCode>', methods=['GET'])
def get_price(janCode):
try:
price = Price.select()\
.where((Price.janCode == janCode) & (Price.startDateTime <= datetime.datetime.now()))\
.order_by(Price.startDateTime.desc()).limit(1).get()
except Price.DoesNotExist:
abort(404)
result = {
"result":True,
"data":{
"janCode":price.janCode.janCode,
"name": price.janCode.name,
"price": price.price,
}
}
return make_response(jsonify(result))
# 価格リストの取得
@api.route('/price', methods=['GET'])
def get_price_list():
try:
price = Price.select()\
.where((Price.startDateTime <= datetime.datetime.now()))\
.order_by(Price.startDateTime.desc())
except Price.DoesNotExist:
abort(404)
arr = []
for item in price:
arr.append({
"janCode":item.janCode.janCode,
"name":item.janCode.name,
"price":item.price,
"startDateTime":item.startDateTime,
})
result = {
"result":True,
"data": arr
}
return make_response(jsonify(result))
# 全アイテム取得
@api.route('/items', methods=['GET'])
def get_items():
try:
items = Item.select()
except Item.DoesNotExist:
abort(404)
arr = []
for item in items:
arr.append({
"itemId":item.janCode,
"name":item.name,
})
result = {
"result":True,
"data":arr
}
return make_response(jsonify(result))
@api.errorhandler(404)
def not_found(error):
return make_response(jsonify({'error': 'Not found'}), 404)
if __name__ == '__main__':
db.create_tables([Item, Price], True)
api.run(host=config['app']['host'], port=config['app']['port'])
<p>
簡易的ではありますが,レジもどきの完成です.