Codexに反証させながら、ラズパイ在席管理のMAC変更問題を直した話

以前、ラズパイでオフィスの在席マップを作った記事を書きました。BLEのMACアドレスは頻繁に変わるため、Wi-Fiの接続情報を使う構成にしたものです。

当時は、SSIDごとのプライベートWi-Fiアドレスなら比較的安定すると考えていました。実際にしばらく動いていたので、その前提を深く疑っていませんでした。

ところが、出社している社員が9日間ずっと不在扱いになっていました。

今回はClaude Codeで原因を調べ、その調査結果をCodexに反証させながら修正しました。

前提はMACアドレスだけです

現在の在席管理は、2分ごとに次の情報を集めています。

  • arp-scan --localnet の結果
  • ルーターのARP情報
  • ルーターのDHCPログ

登録済みのMACアドレスが見つかれば在席です。最後の検出から6分を超えると不在にし、Slack通知とCSVの出退勤記録を更新します。

構成を簡略化すると、次のようになります。

ネットワークをスキャン
  ↓
検出したMACをconfig.jsonと照合
  ↓
status.jsonを更新
  ↓
Slack通知 / 出退勤CSV / 画面表示

IPアドレスやホスト名は補助情報として取得しますが、本人の識別には使っていません。

今回の問題
本人の識別を登録済みMACだけに依存しているため、MACアドレスが変わると、その時点で別の端末として扱われます。

9日間、不在になっていました

社員Aの登録MACは、8月17日の昼で検出が途切れていました。同じ頃から別の未登録MACが現れていましたが、在席管理側では両者を結び付けられません。

8月26日に本人が出社していることを確認し、初めて異常に気づきました。画面上では9日間、出社していないことになっていました。

さらに調べると、未登録MACを残す処理にも問題がありました。

当時の unregistered_macs.json は、毎回のスキャンで見つかった端末だけを使って作り直していました。一度スキャンから漏れると、最初に見つけた時刻が消えます。

そのため、実際には9日前から存在していたMACが、その日の朝に初めて現れたように見えていました。

想定と違っていた点
端末の識別が弱いだけでなく、原因調査に使う未登録MACの履歴まで弱くなっていました。

Claude Codeの調査を、そのまま採用しない

最初の調査と修正案はClaude Codeに任せました。

実コード、ルーターのログ、ラズパイ上の状態を横断して見る作業は量が多く、人が手で追うより始めやすいと感じたためです。

ただし、端末と人の紐付けには推測が含まれます。そこで、調査結果をまとめた検証用プロンプトを別に作りました。

冒頭には、次のような意図を明記しました。

同意を求めているのではなく、誤りや見落としを見つけてほしい。
実機やコードで確認できることは確認し、確認できない点は未確認とする。

自分の結論を補強してもらうのではなく、壊してもらうためのレビューです。

Codexにはコードだけでなく、読み取り専用でラズパイの実データも確認させました。

役割を分けた
最初のエージェントは原因を広く探し、次のエージェントは証拠の弱い箇所を重点的に見る。この分け方は良かったと思います。

稼働日の完全一致でも、本人とは限りませんでした

調査中、所有者不明のApple端末が見つかりました。この端末がネットワークに現れた11日間と、社員Aの登録端末が現れた11日間は完全に一致していました。

Jaccard係数にすると 1.00 です。片方だけが現れた日もありませんでした。

最初はかなり強い証拠に見えました。在席記録が欠けていた日まで一致していたため、単なるCSV上の偶然ではないとも考えました。

しかし、同じ日集合を持つ常設PCも見つかりました。

社員Aが出社する日はオフィス機器も動く、という共通要因があるためです。11日という件数も、本人を一意に決めるには十分ではありませんでした。

さらに、本人の端末で表示したWi-Fiアドレスは別の値でした。その値はルーターにも出ておらず、所有者不明の端末は接続を続けていました。

ここで、最初の推定を撤回しました。

今回の教訓
稼働日の一致は候補を絞る材料にはなります。ただし、本人確認の代わりにはなりません。数値が 1.00 でも、比較対象の作り方や共通要因を確認しないと誤判定につながります。

未登録MACをセッションとして残す

未登録MACの履歴は、1回のスキャン結果ではなく滞在セッションとして扱うように直しました。

UNREGISTERED_SESSION_GAP = timedelta(minutes=20)
UNREGISTERED_RETENTION = timedelta(days=7)

if record is None:
    record = {
        "first_seen": now,
        "session_start": now,
        "last_seen": now,
    }
elif now - last_seen > UNREGISTERED_SESSION_GAP:
    record["session_start"] = now

record["last_seen"] = now

20分以内に再検出した場合は同じ滞在とみなし、20分を超えて戻った場合は新しいセッションにします。履歴自体は7日間保持します。

そのうえで、未登録の状態が30分続いた端末だけをSlack通知の対象にしました。来客や一時的な接続まで通知しないためです。

レビュー後は、次の点も追加で直しました。

  • 20分の判定値をスキャナーと通知処理で共有する
  • 時計が巻き戻った場合や、未来の時刻が残った場合は記録を捨てる
  • 旧形式の履歴に session_start がなければ、移行時点から測り直す
  • 登録済みになったMACは未登録履歴と通知状態から外す
  • Slack送信に成功するたび状態を保存し、途中失敗時の再送範囲を狭める
  • 14時間を超える連続在席の状態を、日次リセットで消さない

変更後は回帰テストを9件作り、手元とラズパイの両方で実行しました。

時刻と状態ファイルを扱う処理は、通常系だけでは不安が残ります。今回は時計の逆行、旧データ、登録後の通知抑止までテストに含めました。

通知を先に動かしてしまいました

実装面とは別に、運用でも失敗しました。

未登録MACの通知先を管理者用チャンネルへ分けるか決めないまま、cronを有効にしました。除外設定と保留設定も揃っておらず、意図しない通知が4件送られました。

内訳には、社内のIoT機器、通知対象外にする予定だったiPhone、判定を保留していた端末が含まれていました。

保留状態を書き込む直前にcronが動き、数秒差で送られたものもありました。

コード上の30分待機は入れていましたが、リリース時の待機は入れていませんでした。

リリース手順で不足していたこと
新しい通知処理は、まず dry-run で候補をログへ出し、送信先を管理者向けに限定し、除外リストを確認してからcronを有効にする順番がよかったと思います。

通知は送信後に取り消せません。判定ロジックより先に、誤送信した場合の戻し方を考える必要がありました。

修正直後に、別の2人が消えました

ここまで直した直後、社員Bが画面から消えました。本人の端末はオフィスにあります。

登録済みMACが見えなくなった時刻と、未登録の MacBookAir が現れた時刻は一致していました。そのまま新しいMACとして登録できそうでしたが、今回は止めました。

数時間後、元の登録MACが再び現れ、候補側は同じタイミングで消えました。

同じ端末が別の接続経路を使った可能性はありますが、ログだけでは確定できません。候補は登録せず、誤って記録された退勤だけをバックアップ後に取り消しました。

その後、社員Aも画面から消えました。

こちらは本人から現在のWi-Fiアドレスを確認できました。旧MACが途切れた直後から同じ端末名で継続検出されていたため、既存のMACを残したまま追加登録しました。

すでにCSVには退勤が記録され、Slackにも通知されていました。イベントCSV、日次集計、通知状態を個別にバックアップし、該当する退勤だけを削除しました。

Slackへ送信済みのメッセージは取り消せないため、その点は残りました。

ケース 得られた証拠 対応
社員B 旧MAC消失と候補MAC出現の時刻が一致。ただし本人確認なし 新MACは登録せず、誤った退勤記録のみ修正
社員A 本人の端末画面で現在のWi-Fiアドレスを確認 既存MACを残したまま新MACを追加登録

同じ日に似た事象が2件起きましたが、対応は分かれました。

ログ上の相関しかない端末は登録しない。本人が確認した端末は登録する。

この差を曖昧にしないことが、今回の修正では一番大事だったと思います。

ホスト名では解決できませんでした

ルーターからDHCPホスト名も取得するようにしました。ただし、これは表示と候補提示にだけ使っています。

実際のネットワークには、MacMacBookAiriPhoneWatch のような名前が複数ありました。ホスト名が空の端末もあります。

端末名が同じでも人は違います。

端末名を本人識別に使うと、MAC変更の問題を別の曖昧な識別子へ移すだけでした。

そのため、現在は次の運用にしています。

  1. 未登録MACが30分滞在したら候補として通知する
  2. 最近検出されなくなった人を候補に添える
  3. ホスト名と時刻の一致で調査対象を絞る
  4. 最後は本人の端末画面で確認する

構成プロファイルの配布や802.1Xも検討しました。ただ、15人、30台ほどの環境に対しては、現時点では管理コストが大きいと判断しています。

一方で、MACと人を手で結び付け続ける限り、同じ問題はまた起きます。

今後の方向性
在席情報を業務記録として使い続けるなら、本人が認証したうえで端末を登録する仕組みや、端末管理基盤から安定した識別子を受け取る構成へ進める必要がありそうです。

所感

今回、エージェントを2つ使って良かったのは、調査量を減らせたことより、最初の推論を撤回しやすくなったことでした。

自分で11日分の一致を見つけると、その説明を正しいと思いたくなります。別のエージェントに反証を求めると、常設PCという反例や、本人確認が抜けていることを正面から扱えました。

一方で、実機を見られるエージェントでも、端末の持ち主までは確定できません。

ARP、DHCP、ホスト名、出社日の一致は、すべて状況証拠です。最後に端末を手に取って確認する部分は、人の仕事として残りました。

在席マップは、誰がいるかを緩く見るために作ったものでした。そこから出退勤CSVとSlack通知を増やしたことで、誤判定の影響も大きくなっています。

運用上の線引き
この仕組みは厳密な勤怠管理に使えるものではありません。MACアドレスやネットワーク観測を中心とした在席推定であり、誤判定が起こり得ることを前提に扱う必要があります。

次は未登録MAC通知に dry-run を追加し、送信先を管理者向けに分離します。

あわせて、本人確認済みか、ログからの推定に留まるかを設定側でも区別できるようにする予定です。

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

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

エンジニアと話してみる

関連リンク

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