ラズパイでオフィスの「在席マップ」を作ってみた

オフィスの棚を整理していたら、Raspberry Pi 4が出てきました。たぶん何年か前に誰かが買って、何かのPoCで使って、そのまま忘れ去られたやつ。電源アダプタとSDカードはあるけど、何が入っていたかはもう誰も覚えていない・・・。せっかくなので何かに使えないか考えてみました。

リモートワーク混在の環境だと「今日、誰がオフィスにいるの?」が地味にわからない。Slackで毎回聞くのも面倒だし、出社管理ツールを入れるほどでもない。ラズパイとBLEスキャンで、ゆるく解決できないかなと。

やりたいこと

  • 社員のスマホのBluetoothを検知して「誰がオフィスにいるか」を把握する
  • 7インチディスプレイに在席マップを表示する
  • Slackにも通知できるようにする

完璧な位置トラッキングではなく、「いる or いない」レベルでいいです。

用意したもの

  • Raspberry Pi 4(4GB)
  • 7インチタッチディスプレイ(公式のやつ、約1万円)
  • microSDカード 32GB
  • 電源アダプタ(5V 3A)

ラズパイ4にはBluetooth 5.0が内蔵されているので、USBドングルは不要です。ここは地味にありがたい。

OSのセットアップ

Raspberry Pi OS Liteを入れました。GUIは使わず、ディスプレイ表示はブラウザのキオスクモードでやります。

# Raspberry Pi Imagerでイメージを焼く
# SSH有効化とWi-Fi設定はImagerの詳細設定で済ませておく

起動したらまずアップデート。

sudo apt update && sudo apt upgrade -y

BLEスキャンの仕組み

ざっくり言うと、BLE(Bluetooth Low Energy)のアドバタイズパケットをスキャンして、登録済みのデバイスが近くにあるかを判定します。

ここで最初にハマったのが、スマホのBLEアドレスの扱い。iOSもAndroidも、プライバシー保護のためにBLEのMACアドレスをランダムに変えるんですよね。なので、単純にMACアドレスで端末を識別する方法は使えない。

MACアドレスランダム化の問題

これ、調べるまで知らなかったんですが、最近のスマホはBLEのアドバタイズで使うMACアドレスを15分くらいで変えます。つまり、さっきまで検知できていた端末が突然消えて、別のアドレスで再出現する。

対策としては2つ考えました。

  1. 社員に専用のBLEビーコンを持ってもらう(MACアドレス固定)
  2. Wi-Fiの接続情報を使う(DHCPリースを見る)

結局、2番のWi-Fi方式をメインにして、BLEはサブにしました。Wi-Fiに繋がっているデバイスのMACアドレスは固定(iOSのプライベートアドレスはネットワークごとに固定)なので、こっちのほうが安定します。

Wi-Fiベースの在席検知

ルーターのDHCPリーステーブルを見るか、arp-scanで同一ネットワーク上のデバイスを探します。

sudo apt install arp-scan -y
import subprocess
import json
from datetime import datetime

# 登録デバイス(事前に社員のMACアドレスを登録)
REGISTERED_DEVICES = {
    "aa:bb:cc:dd:ee:01": "田中",
    "aa:bb:cc:dd:ee:02": "鈴木",
    "aa:bb:cc:dd:ee:03": "佐藤",
}

def scan_network():
    """arp-scanでネットワーク上のデバイスを検出"""
    result = subprocess.run(
        ["sudo", "arp-scan", "--localnet", "--plain"],
        capture_output=True, text=True
    )

    found = {}
    for line in result.stdout.strip().split("\n"):
        parts = line.split("\t")
        if len(parts) >= 2:
            ip, mac = parts[0], parts[1].lower()
            if mac in REGISTERED_DEVICES:
                found[mac] = {
                    "name": REGISTERED_DEVICES[mac],
                    "ip": ip,
                    "last_seen": datetime.now().isoformat()
                }
    return found

if __name__ == "__main__":
    presence = scan_network()
    for mac, info in presence.items():
        print(f"{info['name']}: 在席 ({info['ip']})")

arp-scanはroot権限が必要なので、cronで動かすときはsudoの設定が要ります。

# /etc/sudoers.d/arp-scan
pi ALL=(ALL) NOPASSWD: /usr/sbin/arp-scan

スキャン間隔の話

最初5分間隔でスキャンしていたんですが、これだとスマホがスリープに入ったときに検知できないことがある。ARPテーブルからも消える。

で、1分間隔にしたら今度はネットワークに負荷がかかる。arp-scanは実質的にARP要求をブロードキャストで投げまくるので、あまり頻繁にやるとネットワーク管理者に怒られます。

落としどころとして、2分間隔のスキャン+「最後に検知してから10分以内は在席扱い」にしました。退勤した瞬間にマップから消えなくてもいい。ゆるい在席情報なので。

from datetime import datetime, timedelta

PRESENCE_TIMEOUT = timedelta(minutes=10)

def is_present(device_info):
    last_seen = datetime.fromisoformat(device_info["last_seen"])
    return datetime.now() - last_seen < PRESENCE_TIMEOUT

BLEスキャンの補助的な利用

Wi-Fiがメインですが、BLEも併用しています。bluepyを使います。

sudo apt install libglib2.0-dev -y
pip install bluepy
from bluepy.btle import Scanner, DefaultDelegate

class ScanDelegate(DefaultDelegate):
    def __init__(self):
        DefaultDelegate.__init__(self)

scanner = Scanner().withDelegate(ScanDelegate())

def ble_scan(duration=5):
    """BLEデバイスをスキャンして既知のデバイスを返す"""
    devices = scanner.scan(duration)
    found = []
    for dev in devices:
        # デバイス名で判定(MACはランダム化されるため)
        for (adtype, desc, value) in dev.getScanData():
            if desc == "Complete Local Name" and value in KNOWN_DEVICE_NAMES:
                found.append({
                    "name": value,
                    "rssi": dev.rssi,
                })
    return found

BLEのほうはデバイス名(Complete Local Name)で判定しています。MACアドレスが変わっても、デバイス名は変わらないことが多いので。ただし、デバイス名を常にアドバタイズしているとは限らないので、検知率はWi-Fiより低い。あくまで補助です。

Webダッシュボードの表示

FlaskでシンプルなWebアプリを作って、ディスプレイにキオスクモードで表示します。

pip install flask
from flask import Flask, render_template_string
import json

app = Flask(__name__)

TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="refresh" content="30">
    <style>
        body {
            font-family: sans-serif;
            background: #1a1a2e;
            color: #eee;
            padding: 20px;
        }
        h1 { font-size: 1.5em; color: #e94560; }
        .grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
            gap: 12px;
            margin-top: 20px;
        }
        .person {
            background: #16213e;
            border-radius: 8px;
            padding: 16px;
            text-align: center;
        }
        .person.present {
            border: 2px solid #0f3460;
            background: #0f3460;
        }
        .person.absent {
            border: 2px solid #333;
            opacity: 0.4;
        }
        .status {
            font-size: 2em;
            margin-bottom: 4px;
        }
        .name { font-size: 0.9em; }
        .time { font-size: 0.7em; color: #888; margin-top: 4px; }
    </style>
</head>
<body>
    <h1>Office Presence Map</h1>
    <div class="grid">
        {% for person in people %}
        <div class="person {{ 'present' if person.present else 'absent' }}">
            <div class="status">{{ '🟢' if person.present else '⚫' }}</div>
            <div class="name">{{ person.name }}</div>
            <div class="time">{{ person.last_seen if person.present else '' }}</div>
        </div>
        {% endfor %}
    </div>
</body>
</html>
"""

@app.route("/")
def index():
    with open("/home/pi/presence/status.json") as f:
        data = json.load(f)
    return render_template_string(TEMPLATE, people=data)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

ディスプレイにはChromiumのキオスクモードで表示します。

sudo apt install chromium-browser -y

# 自動起動用の設定
# /etc/xdg/lxsession/LXDE-pi/autostart に追加
@chromium-browser --kiosk --noerrdialogs --incognito http://localhost:8080

30秒ごとに自動リフレッシュしているので、WebSocketとかは使っていません。シンプルが正義。

Slack通知

朝の出社時と退勤時にSlackに通知を飛ばすようにしました。全員の出入りを逐一通知すると邪魔なので、日に2回のサマリーだけ。

import requests

SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/xxx/yyy/zzz"

def post_summary(present_names):
    if not present_names:
        text = "現在オフィスに誰もいません"
    else:
        text = f"現在オフィスにいる人: {', '.join(present_names)}"

    requests.post(SLACK_WEBHOOK_URL, json={"text": text})

cronで朝10時と夕方18時に実行しています。

# crontab -e
0 10,18 * * 1-5 /home/pi/presence/notify_slack.py

全体の構成をまとめる

presence/
├── scanner.py          # arp-scan + BLEスキャン(2分ごとにcron実行)
├── status.json         # 現在の在席状況(scannerが更新)
├── server.py           # Flask Webアプリ
├── notify_slack.py     # Slack通知スクリプト
└── config.json         # 登録デバイス情報

systemdでFlaskサーバーを自動起動にします。

# /etc/systemd/system/presence.service
[Unit]
Description=Office Presence Map
After=network.target

[Service]
User=pi
WorkingDirectory=/home/pi/presence
ExecStart=/usr/bin/python3 server.py
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl enable presence
sudo systemctl start presence

ハマったところ

1. iOSのプライベートWi-Fiアドレス

iOS 14以降、Wi-Fi接続時にプライベートアドレスを使うのがデフォルトになっています。これ自体はネットワークごとに固定なので大丈夫なんですが、ユーザーがプライベートアドレスをリセットすると変わる。

社員に「プライベートアドレスはリセットしないでね」とお願いするか、変わったら再登録する運用にしています。ここはちょっと泥臭い。

2. arp-scanのパーミッション

cronからarp-scanを動かすときに、sudoersの設定を忘れていてスキャンが空振りしていた。ログを見るまで気づかなくて、30分くらいハマった。/var/log/syslogはちゃんと見ましょう。

3. ディスプレイのスリープ

ラズパイのディスプレイは放っておくとスリープに入ります。常時表示したいなら無効にする必要がある。

# /etc/xdg/lxsession/LXDE-pi/autostart に追加
@xset s off
@xset -dpms
@xset s noblank

これを忘れると、肝心なときに画面が真っ暗で意味がない。

プライバシーの話

これ、一番大事な話かもしれません。社員のスマホのMACアドレスを収集して在席を追跡するので、必ず全員の同意が必要です。

うちでは以下のルールにしました。

  • 参加は任意。拒否しても何のペナルティもない
  • 収集するのはMACアドレスと在席/不在の情報だけ
  • ログは7日間で自動削除
  • 在席マップはオフィス内のディスプレイにのみ表示(社外からは見えない)
  • いつでも登録解除できる
重要: 正直、全員が快く参加してくれるかは微妙です。「監視されている感じがする」という人もいるだろうし、それは正当な感覚だと思います。強制はダメ。絶対に。

精度はどうか

1週間くらい運用してみた結果。

  • 検知率: だいたい90%くらい。たまにスマホがWi-Fiから切断されていて検知漏れする
  • 誤検知: ほぼなし。登録済みMACアドレスでの判定なので、知らない端末が出ることはない
  • 遅延: 最大で12分くらい(スキャン間隔2分 + タイムアウト10分)。退勤してから在席表示が消えるまでの時間

完璧ではないですが、「今日オフィスに誰がいるか」をざっくり把握する用途なら十分です。厳密な勤怠管理には使えないし、使うべきでもない。

所感

作ってみて思ったのは、技術的に難しい部分はほとんどないということ。arp-scanとFlaskとcronがわかれば作れます。

難しいのは運用面。MACアドレスの登録・管理、プライバシーへの配慮、「監視ツール」にならないためのルール設計。技術より人間の問題のほうがずっと大きい。

あと、最初はBLEスキャンで全部やろうとしていたんですが、MACアドレスランダム化の壁にぶつかって方針転換しました。事前にもう少し調べておけばよかった。ここが一番の学び。

次にやりたいこと
次はフロアの簡易マップを作って、席の位置まで表示できるようにしたいですね。ラズパイをフロアに複数台置いて、三角測量で位置推定…は流石にやりすぎか。

ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。

  • 選考ではありません
  • 履歴書不要
  • 技術の話が中心
  • 所要時間30分程度
  • オンラインOK

エンジニアと話してみる

関連リンク

AI・クラウド・データ分析のご相談はネクスト株式会社までお問い合わせください。