ラボ内のレジを作った話
この記事も例によって 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>
簡易的ではありますが,レジもどきの完成です.