Raspberry Piをバーコード在庫管理専用マシンとして運用する

当WEBサイトのメインコンテンツとなりつつあるバーコード在庫管理システムですが、これまでのシステムは利用の都度アプリとして起動させるものでした。私の中の理想形は常時バーコード入力を受け付け、それ単体で完結して在庫管理をおこなえる専用マシンです。

しかし、バーコード在庫管理専用機として市販のWindowsPCを使うのはオーバースペックで、コスパが悪すぎます。安めのミニPCであっても5~6万はします。自分でパーツを調達してきてもそれほどコストは変わらないでしょう。WindowsOSだけでも1万円の出費が確定です。

Windowsという足かせを取り払い、現在のテクノロジーで汎用OSがうごかせる一般人でも入手可能なできるだけ安いコンピューターといえばRaspberry Piがあげられます。本体だけなら4,000円も出せば手に入りOSは無料です。圧倒的コスパ。

今回はRaspberry Pi(以下ラズパイ)をバーコード在庫管理専用マシーンとして使えるようにします。

システム概要

システム概要図です。

ラズパイのPythonスクリプトがバーコードリーダーからの入力を処理してデータベースの在庫数の増減とログ記録をおこないます。

データベースはPostgreSQLを使用します。ご安心ください。もちろんExcelとしてダウンロードできるようにしてあります。

ラズパイ内にWEBサーバーが稼働しており、データベースの内容をWEBページとして配信しています。外部からブラウザを使用して在庫数を確認することができます。

ラズパイ本体は今回の構成を実現するための必要最小限の機能を有し、かつ抜群の省スペースが魅力のRaspberry Pi Zero WH GPIOピンヘッダー付きを使います。購入時の価格は3,000円弱です。投稿時点でのAmazon価格は4,000円弱となっております。

バーコードリーダーはAmazonで手に入る激安品でこれも3,000円弱で入手しました。その他インジケーターLEDやACアダプタなどを合わせても総額で1万円は超えていないはずです。

構成機器の全体像です。

デモ動画

実際のうごきを動画にしました。ラズパイ内ですでにシステムがうごいている状態です。音が出ます。

赤LEDが出庫、緑LEDが入庫モードであることを表します。ブラウザでラズパイ内のWEBサーバーへアクセスすることで視覚的に在庫リストの増減を確認できます。

スクリプト一式をダウンロードできますので、お試しください。

使用方法

運用にはモニターは必要ありませんが、準備段階では必要です。リモートデスクトップで普段使用しているPCから操作するのがよいでしょう。ラズパイは最新版のラズパイOSがインストール済みでPython3実行環境が構築されているものとします。

1.ラズパイにPythonの各モジュールをインストールします。

pip install requests
pip install psycopg2
pip install openpyxl
pip install git+https://github.com/Pithikos/python-websocket-server

websocket-serverは↑ではGitがインストールされていないと入りません。先にGitを入れるか面倒ならバージョンが古いのでトラブる可能性を承知で

pip install websocket-server

でインストールしてください。

なお、pipのバージョンによっては次のようなエラーが出てインストールできません。

システム環境にはpipじゃなくてLinuxのパッケージマネージャーを使えや!という警告です。在庫管理システム以外うごかすつもりはないので余計なお世話です。無視しましょう。

オプションで –break-system-packages を指定すると強制インストールできます。

pip install --break-system-packages requests

というようにします。

2.ラズパイにPostgreSQLデータベースをセットアップします。

手順は結構面倒くさいので、Google先生に聞いてください。親切に解説しているサイトがあります。私はこちらの通りやりました。

運用方針により設定は変わりますが、サンプルコードでうごかすには↑の解説を参考に

ラズパイのユーザー名:pi
PostgreSQLのユーザー名:pi
パスワード:raspberry
データベース名:raspi
対向認証解除

としてください。外部からの接続設定は必要ありません。

宗教上の理由でMySQLしか入れられないかたや、手っ取り早くSQLite(WEBサーバーからさわるのでおすすめしませんが)でやりたいというかたは、Google先生にご確認のうえ、セットアップをおこない以降をそれぞれに読み替えてください。ソースコードを一部変更する必要があります。

3.データベースにテーブルを作成します。

psqlでraspiデータベースに接続します。次のSQLを実行します。

create table stock
(
    name     text,
    code     text not null
        constraint stock_pk
            primary key,
    quantity integer
);

alter table stock
    owner to pi;
create table log
(
    code     text,
    mode     text,
    datetime timestamp
);

alter table log
    owner to pi;

4.stockテーブルに在庫管理物品を登録します。
PostgreSQLにはCSVをインポートするCOPYコマンドがあるので、CSVで作成しておいて一気にデータ投入が楽です。

CSVの構成は 商品名,バーコード値,数量 とします。改行コードはLF、文字コードはUTF-8(BOMなし)にすることをおすすめします。

COPYコマンドで指定するCSVファイルは、psql実行ユーザー(通常はpostgres)のアクセス権限がないとエラーになります。配置するフォルダに対してもアクセス権限が必要です。tmpフォルダとかに置いて、chmod 777 しておくとよいです。

5.このページのダウンロードボタンからスクリプト一式のzipファイルを入手します。

6.ダウンロードしたzipファイルを展開してラズパイに配置します。
サンプルのままうごかすにはユーザーpiのデスクトップにraspiというフォルダを作成して、そこへ配置します。

7.ステータス表示用LEDを接続します(任意)。
配線は次のようにします。入庫モードで緑、出庫モードで赤のLEDが点灯し、読み取りが成功すると点滅します。登録されていないコードを読むと交互に点滅します。やっつけでブレッドボードでやっていますが、長期で本格運用するのであればもうすこし見た目と耐久性を考慮した方法をおすすめします。

8.ラズパイをCLIで起動するように変更します。

設定 → Raspberry Piの設定 → システムタブ → ブート: をCLIにします。

9.ラズパイを再起動してCLIでコマンド待機状態にします。

10.バーコードリーダーを接続します。

11.カレントディレクトリをダウンロードしたスクリプトのあるディレクトリにします。

サンプル構成の場合は次のコマンドです。

cd /home/pi/Desktop/raspi/

12.pistockmanager.pyを実行します。

サンプル構成の場合は次のコマンドです。

python pistockmanager.py

実際の運用時にはモニターもキーボードも接続していない状態になるので、↑の起動コマンドを入力できないため、自動起動の設定をしたかったのですが、インターネッツで調べた方法をいろいろやってみてもうまくいかず(サービスとして起動していまい、バーコードリーダー入力を受け取れない)。

ここで私は重大な事実に気がつきます。「あっ、バーコードリーダーってキーボードじゃん」

コマンドをバーコードにして読みこんでしまえばいいのです。CODE128形式で作成した次のバーコードを順番に読むことでシステムが起動します。

スクリプト名にアンダースコアがなく読みにくいのはこれが理由です。CODE128ではアンダースコアが定義されていないのでご自分の環境に合わせてフォルダ構成などを変更する場合はご注意ください。

システムが起動すると、出庫モードで待機状態となり赤LEDが点灯します。

12.商品バーコードを読み取ります。

LEDが入出庫モードに応じ、入庫なら緑、出庫なら赤が点滅します。データベースに未登録のバーコードの場合は、赤と緑が交互に点滅します。

13.入出庫モードの切り替えには次のバーコードを読み込みます。

モードがトグリます。

14.在庫リストをブラウザで確認するには、ラズパイと同一ネットワークの端末から
http://ラズパイのローカルIPアドレス:8080
にアクセスします。

たとえば http://192.168.0.14:8080 などです。ポートを8080にしている深い意味はありません。ソースコードを変更して80にすればIPアドレスだけでいけます。

タイトル行の上に出庫モードなのか入庫モードなのかを表示します。

商品バーコードを読み取ると在庫リストの対象商品の行が一瞬黄色になり、数量が増減します。Raspberry Pi Zeroの性能は価格相応ですので、読み取りからリストに反映まで1~2秒かかります。

15.在庫リストをExcelブックとして取得するには、ラズパイと同一ネットワークの端末から
http://ラズパイのローカルIPアドレス:8080/get-excel

にアクセスします。

Excelブックがダウンロードされます。在庫リストとログがシートに分けて記録されています。

16.終了するには次のバーコードを読みます。

LEDが消灯したら、電源プラグを引っこ抜いても(たぶん)大丈夫です。私はいつもラズパイをシャットダウンさせずにいきなり電源を切っていますが今のところ問題なく使えています。

配布zipに入っているstartup_led.pyは実行すると10秒間LEDを両方点灯させるというスクリプトです。これをラズパイ立ち上げ時に自動起動に設定しておき、LEDが両方点灯したのち消灯したら起動が完了していると判断できるので、システム起動のバーコードを読む、という目的のために存在します。必要に応じてご利用ください。

解説

Pythonのソースコードです。メインの処理です。

import datetime
import json
import socket
import pathlib
import threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import requests
from websocket_server import WebsocketServer
import psycopg2 as pg
import openpyxl as excel
import gpio_manager as led


class OrenoServer:
    def __init__(self):
        # VueのhostをサーバーのIPアドレスに書き換え
        self.HOST = get_ip_addr()
        src_path = pathlib.Path('js/base.js')
        dst_path = pathlib.Path('js/main.js')
        js = src_path.read_text()
        dst_path.write_text(js.replace('**ipaddress**', self.HOST))

        self.HTTP_PORT = 8080
        self.WS_PORT = 8081
        self.mode = 'out'
        self.wss = WebsocketServer(host=self.HOST, port=self.WS_PORT)
        self.https = ThreadingHTTPServer((self.HOST, self.HTTP_PORT), HttpHandler)

    def start(self):
        threading.Thread(target=self.wss.run_forever).start()
        print(f'WebSocket server running -> ws://{self.HOST}:{self.WS_PORT}')
        threading.Thread(target=self.https.serve_forever).start()
        print(f'HTTP server running -> http://{self.HOST}:{self.HTTP_PORT}')

    def shutdown(self):
        self.wss.shutdown()
        self.https.shutdown()


class OrenoDataBase:
    def __init__(self):
        self.HOST = 'localhost'
        self.PORT = '5432'
        self.DB_NAME = 'raspi'
        self.USER = 'pi'
        self.PASSWORD = 'raspberry'
        self.conn = pg.connect(
            f'host={self.HOST} '
            f'port={self.PORT} '
            f'dbname={self.DB_NAME} '
            f'user={self.USER} '
            f'password={self.PASSWORD}'
        )
        self.cur = self.conn.cursor()

    def get(self):
        # 名称、コード、数量、highlightフラグ
        self.cur.execute('SELECT code,name,quantity,false FROM stock')
        return self.cur.fetchall()

    def set(self, code, mode):
        self.cur.execute('SELECT EXISTS(SELECT * FROM stock WHERE code = %s)',
                         (code,))
        code_exists = self.cur.fetchone()[0]
        
        if code_exists:
            self.cur.execute('INSERT INTO log (code,datetime,mode) VALUES (%s,%s,%s)',
                             (code, datetime.datetime.now(), mode))
            op = '+' if mode == 'in' else '-'
            self.cur.execute(f'UPDATE stock SET quantity = quantity {op} 1 WHERE code = %s',
                             (code,))
            self.conn.commit()
        return code_exists

    def export_excel(self):
        wb = excel.Workbook()
        ws_stock = wb.active
        ws_stock.title = '在庫一覧'
        sr = 1

        for row in self.get():
            if sr == 1:
                ws_stock.cell(row=sr, column=1).value = 'コード'
                ws_stock.cell(row=sr, column=2).value = '名称'
                ws_stock.cell(row=sr, column=3).value = '数量'
            sr += 1
            ws_stock.cell(row=sr, column=1).value = row[0]
            ws_stock.cell(row=sr, column=2).value = row[1]
            ws_stock.cell(row=sr, column=3).value = row[2]

        ws_log = wb.create_sheet(title='ログ')
        lr = 1

        self.cur.execute("SELECT code,mode,datetime FROM log")
        for row in self.cur.fetchall():
            if lr == 1:
                ws_log.cell(row=lr, column=1).value = 'コード'
                ws_log.cell(row=lr, column=2).value = 'モード'
                ws_log.cell(row=lr, column=3).value = '日時'
            lr += 1
            ws_log.cell(row=lr, column=1).value = row[0]
            ws_log.cell(row=lr, column=2).value = row[1]
            ws_log.cell(row=lr, column=3).value = row[2]

        wb.save('stock.xlsx')

    def close(self):
        self.cur.close()
        self.conn.close()


class HttpHandler(SimpleHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/get-excel':
            db = OrenoDataBase()
            db.export_excel()
            self.send_response(301)
            self.send_header('Location', '/stock.xlsx')
            self.end_headers()
            db.close()
        else:
            super().do_GET()

    def do_POST(self):
        db = OrenoDataBase()
        rows = db.get()
        self.send_response(200)
        self.send_header('Content-type', 'application/json')
        self.end_headers()
        response_body = json.dumps(rows, default=str)
        self.wfile.write(response_body.encode('utf-8'))
        db.close()


def main():
    exit_code = ('99999995', 'exit')
    toggle_mode_code = ('88888880', 'mode')
    led.setup()
    db = OrenoDataBase()
    sv = OrenoServer()
    sv.start()
    msg = {}
    led.turn_on(sv.mode)

    while True:
        code = input()

        if code in exit_code:
            break
        elif code in toggle_mode_code:
            sv.mode = 'out' if sv.mode == 'in' else 'in'
            msg['op'] = 'mode'
            msg['val'] = sv.mode
            led.turn_on(sv.mode)
        else:
            if db.set(code, sv.mode):
                led.accept(sv.mode)
            else:
                led.alert(sv.mode)
            msg['op'] = 'code'
            msg['val'] = code
            
        sv.wss.send_message_to_all(json.dumps(msg))

    led.turn_off()
    db.close()
    sv.shutdown()
    


def get_ip_addr():
    # ラズパイだとsocket.gethostbyname(socket.gethostname())ではとれない
    # 引用元 -> https://qiita.com/suzu12/items/b5c3d16aae55effb67c0
    connect_interface = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    connect_interface.connect(("8.8.8.8", 80))
    ip_addr = connect_interface.getsockname()[0]
    connect_interface.close()
    return ip_addr


if __name__ == '__main__':
    main()

メインロジックではサーバーを管理するOrenoServer、データベースを管理するOrenoDatabase、WEBサーバーのイベントハンドラーHttpHandlerの各クラスがあります。

OrenoServerクラスのイニシャライザでは、まず自身のIPアドレスをget_ip_addr関数で取得します。関数の中身はインターネッツで拾ってきたコードです。

在庫リストのWEBページを動的に制御するのはVue.jsです。これはドキュメントルートのjs/base.jsというファイルに書いてありますが、WEBサーバーにどのIPアドレスが割り当てられるかは(ラズパイのIPアドレスを固定していない限り)特定できないので、Vueアプリをスタートする前にサーバー(要するにラズパイ自身)のIPアドレスをVueスクリプトに書き込む必要があります。

そこで在庫リストのHTMLファイルではjs/main.jsというスクリプトを読み込むように書いておき、get_ip_addrで自身のIPアドレスを取得→base.jsファイルの文字列を取得→その中のプレースホルダーを自身のIPアドレスに書き換え→main.jsファイルとして書き出し→HTMLファイルでmain.jsを配信という処理をしています。
これによりIPアドレスがDHCPで割り振られてもいいようになっています。

続けて、HTTPサーバーとWebScoketサーバーのインスタンスを作成します。WebScoketを使ってバーコードリーダーで入力されたバーコードの値をVueアプリに渡します。

startメソッドではそれぞれのサーバーを永続モードでスタートさせます。shutdownメソッドではサーバーを停止します。

OrenoDatabaseクラスはデータベースの入出力を制御します。イニシャライザではデータベースとの接続を確立します。

getメソッドはstockテーブルの全件を返します。テーブルには存在しない4列目にfalseを決め打ちで入れているのは、Vue側で行をハイライトするときのフラグに使用するためです。

setメソッドでは引数で受け取ったコードとモードに応じてstockテーブルの数量の更新とlogテーブルへの書き込みをおこないます。

export_excelメソッドはその名の通り、データベースをまるごとExcelファイルとして書き出します。これをドキュメントルートに保存してブラウザからダウンロードできるようにしています。

closeメソッドでデータベース接続を切断します。

HttpHandlerクラスはWEBサーバーのイベントリスナーです。SimpleHTTPRequestHandlerクラスを継承しており、do_GETdo_POSTメソッドをオーバーライドして独自の処理を追加しています(正確にはdo_POSTメソッドはCGIHTTPRequestHandlerクラスが持っていて、どのクラスのどのメソッドを呼ぶかの判断はSimpleHTTPRequestHandlerのスーパークラスであるBaseHTTPRequestHandlerがやっているようです)。

具体的にはGETリクエストが/get-excelパスに来た場合にOrenoDatabaseexport_excelメソッドを呼び、データベースをExcelファイルとして出力してそれをレスポンスします。それ以外のパスへのリクエストは継承元クラスの処理が呼ばれ、パスに対応したドキュメントルートのファイルがレスポンスされます。

POSTリクエストがあると、OrenoDatabase経由でデータベースからstockテーブルの内容を取得し、JSONに変換してレスポンスします。

LED制御用Pythonスクリプトのソースコードは次の通りです。

import RPi.GPIO as GPIO
import time

LED_RED_PIN = 21
LED_GREEN_PIN = 20


def setup():
    GPIO.cleanup()
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(LED_RED_PIN, GPIO.OUT)
    GPIO.setup(LED_GREEN_PIN, GPIO.OUT)


def red_turn_on():
    GPIO.output(LED_RED_PIN, GPIO.HIGH)


def red_turn_off():
    GPIO.output(LED_RED_PIN, GPIO.LOW)


def red_blink():
    for i in range(2):
        red_turn_off()
        time.sleep(0.2)
        red_turn_on()
        time.sleep(0.2)


def green_turn_on():
    GPIO.output(LED_GREEN_PIN, GPIO.HIGH)


def green_turn_off():
    GPIO.output(LED_GREEN_PIN, GPIO.LOW)


def green_blink():
    for i in range(2):
        green_turn_off()
        time.sleep(0.2)
        green_turn_on()
        time.sleep(0.2)


def rg_blink():
    for i in range(2):
        green_turn_off()
        red_turn_on()
        time.sleep(0.2)
        green_turn_on()
        red_turn_off()
        time.sleep(0.2)


def turn_on(mode):
    if mode == 'out':
        red_turn_on()
        green_turn_off()
    elif mode == 'in':
        green_turn_on()
        red_turn_off()


def turn_off():
    GPIO.cleanup()


def alert(mode):
    if mode == 'out':
        rg_blink()
        green_turn_off()
        red_turn_on()
    elif mode == 'in':
        rg_blink()


def accept(mode):
    if mode == 'out':
        red_blink()
    elif mode == 'in':
        green_blink()

これはRPi.GPIOでピンのI/Oをコントロールしています。自分がわかりやすいように関数を細かく分けているだけで、中身は教科書通りのことしかやっていませんので省略。

在庫リストのWEBページのHTMLです。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>在庫管理システム</title>
    CDNからBootstrap5読み込み(コード省略)
    <style>
        .highlight {
            background-color: yellow;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="container-sm mt-5">
            <div class="rounded bg-success text-white text-center h2" v-if="mode === 'in'">
                入 庫
            </div>
            <div class="rounded bg-danger text-white text-center h2" v-else>
                出 庫
            </div>
            <table class="table">
                <thead>
                <tr>
                    <th>コード</th>
                    <th>名称</th>
                    <th>数量</th>
                </tr>
                </thead>
                <tbody>
                <tr v-for="item in items" v-bind:key="item[0]" 
                       v-bind:class="{ highlight: item[3] }">
                    <td>{{ item[0] }}</td>
                    <td>{{ item[1] }}</td>
                    <td>{{ item[2] }}</td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="js/main.js"></script>
</body>
</html>

Bootstrap5でレイアウト、Vue.jsでコントロールしています。特筆すべきところはないです。

在庫リストはページネーションを実装していません。大量に商品を登録するとスクロールが必要になります。BingAI先生にお願いすれば、ページネーションのコードを書いてくれると思います。ご自身で実装してください。

WEBページ制御用のVue.jsコードです。

Vue.createApp({
    data: function () {
        return {
            items: [],
            ws: null,
            mode: 'out',
            host: '**ipaddress**'
        }
    },
    methods: {
        updateItem(code) {
            const index = this.items.findIndex(function (item) {
                return item[0] === code
            })

            if (index > -1) {
                switch (this.mode) {
                    case 'in':
                        this.items[index][2] += 1
                        break
                    case 'out':
                        this.items[index][2] -= 1
                }
                this.highlightRow(index)
            }
        },
        highlightRow(index) {
            this.items[index][3] = true
            setTimeout(() => {
                this.items[index][3] = false
            }, 500)
        },
    },
    created: function () {
        let me = this
        this.ws = new WebSocket(`ws://${this.host}:8081`)

        this.ws.onmessage = function (e) {
            let msg = JSON.parse(e.data)

            switch (msg['op']) {
                case 'code':
                    me.updateItem(msg['val'])
                    break
                case 'mode':
                    me.mode = msg['val']
            }
        }

        let xhr = new XMLHttpRequest()
        xhr.open('POST', `http://${this.host}:8080`)
        xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
        xhr.send()

        xhr.onload = function () {
            if (xhr.status === 200) {
                let arr = JSON.parse(xhr.responseText)
                arr.forEach(function (item) {
                    me.items.push(item)
                })
            } else {
                console.error('Error: ' + xhr.statusText)
            }
        }
    }

}).mount('#app')

今までjQueryで満足していたのですが「どうもVue.jsってのが簡単便利らしいぞ」という噂を聞きつけ、今回のVue.jsのコードはBingAI先生にお願いして8割方書いていただきましたが、JSの変数とDOMの要素が勝手に連動するのが便利すぎて、今後はVueに乗り換え決定です。

VueアプリではHTTPサーバーからPOSTで在庫リストを取得して展開、WebScoketサーバーとの接続を確立し、メッセージで読み取ったバーコードをやりとりし、WEBページの在庫リストの数量増減と対象商品の行のハイライトをしています。VueアプリではあくまでWEBページの表示の変更だけを行い、データベースの操作はすべてPythonが(Vueアプリを経由せず)実行しています。

**ipaddress**の部分は前述の通り、実際の自身のIPアドレスに書き換えたのち配信されます。

全体を通して在庫リストの項目がマジックナンバー(item[0]とか)になってしまっているのがイマイチですが、確かPostgreSQLでSELECTした結果はデフォルトではタプルになっているので、それを辞書にしてから引き渡し、Vue側でまた展開するのが超絶面倒&無駄に感じたためだと記憶しています。

スクリプトを入手する

利用上のご注意

  • ダウンロードしたファイルを利用したことにより生じた結果については、利用者ご自身に責任を負っていただきます。
  • ご利用前に使用方法をご確認ください。
  • 当方は成果物の正確性について最善を尽くしますが保証はいたしません。
  • Windows11 Microsoft365 環境でのみ動作確認済み。

Downloadボタンを押下した時点で注意事項に同意したものとみなします。

raspi_bcr_stock_manager.zip

まとめ

概ね、私が思い描いていたシステムができました。やる気になれば在庫管理だけではなく、例えば備品の保管庫に設置してICカードリーダーもつないで社員証を読み取ったら保管庫の鍵があき、持ち出す備品のコードを読むと、誰がいつ何を持ち出したか記録するというようなシステムも構築できそうですね。

システムの安定性については1週間ほど連続稼働させて日に数回の読み取りをおこない、途中でフリーズすることなく動作しました。PythonのビルトインWEBサーバーはテスト用という位置づけなので、本来はApacheなどでWEBサーバーをうごかすべきなのでしょうが、そこまで手間をかける必要もないかなと。

今回はあまり活用しませんでしたが、ラズパイの最大の強みはGPIOで、ソフトウェアから物理的なうごきを制御できることだと思っています。スマート引きこもりホームシステムの構築にも利用できないかと考えております。

おわり。