ラボ内のレジを作った話

この記事も例によって 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>

簡易的ではありますが,レジもどきの完成です.

参考

関連する記事
コメント
トラックバック
ページトップへ