2026年5月23日土曜日

在留許可は外国籍の人に対して例外的に解除するする「処分」

 


1978年の最高裁判所判決では、「外国人は、在留の権利を与えられているものではなく、在留を許可するかどうかは法務大臣の裁量に任されている」という法理


マクリーン事件(最大判昭和53年10月4日)は、外国人の基本的人権の保障範囲と、在留期間更新における行政裁量について判断した最高裁判例です。日本の入管法の下における、重要かつ代表的な2つの法理



在留許可は、外国人がその国に滞在するための「権利の付与」ではなく、本来は認められていない入国・滞在を主権国家が主権的判断(裁量)によって例外的に解除する「恩恵的な処分」であると解釈されています。
法理の詳細は以下の通り
1. 根拠法理:国家の主権と「許可」の性質
  • マクリーン事件最高裁判決(昭和53年10月4日)
    日本の最高裁判所は、外国人の在留について以下のような基本法理を示しています。
    • 在留の自由は保障されない:憲法上、外国人は日本に入国する権利や、引き続き日本に滞在する権利(在留権)を保障されていない。
    • 恩恵的な措置:外国人が日本に在留することは、本来、国家の主権に属する事項であり、出入国管理法(入管法)などの定める一定の条件を満たす場合に、法務大臣がその裁量によって例外的に与える「恩恵的な措置」である。 [12]
2. 「権利」ではなく「反射的利益」や「法的利益」
法的な性質として、外国人が持っているのは「適法に滞在できる権利」ではなく、適法な要件を満たした上で許可を求めて申請する「法律上の利益(反射的利益)」であるとされます。
そのため、法務大臣が基準を満たしていると判断して初めて在留資格(ビザ)が与えられます。 [12]
3. 法務大臣の広範な裁量
入管法における在留の許可(更新、変更)、そして不法滞在者に対する「在留特別許可」に至るまで、処分を下すかどうかは法務大臣の自由裁量に大きく委ねられています。
そのため、要件を形式的に満たしているからといって、当然に許可される「権利」として請求することは原則としてできません。


2026年4月21日火曜日

GPIO でトグル監視と子機へHTTP通知 w/ emailも

 

親機 Raspberry Pi

  • GPIOでトグルスイッチを監視
  • ON(HIGH) になったら
    • eメール送信
    • 子機 Raspberry Pi のローカルIPへHTTP送信

子機 Raspberry Pi

  • 親機からのHTTPを受信
  • GUI画面にメッセージを表示

全体構成

[トグルスイッチ]


[親機 Raspberry Pi]
GPIO入力監視

├─ eメール送信

└─ HTTP POST送信 → [子機 Raspberry Pi]

└─ GUIで画面表示

1. 配線例

GPIO入力は例えば GPIO17 を使います。

配線

  • スイッチ片側 → 3.3V
  • スイッチもう片側 → GPIO17
  • GPIO17 → 10kΩでGNDへプルダウン

イメージ

3.3V ----[スイッチ]---- GPIO17 ----[10kΩ]---- GND

これで

  • スイッチOFF → LOW
  • スイッチON → HIGH

になります。

※ 内部プルダウンを使わず、外付け抵抗にしたほうが分かりやすいです。
※ 配線ミス防止のため 5VをGPIOへ入れない でください。


2. 親機側プログラム

親機でやることは3つです。

  • GPIO監視
  • eメール送信
  • 子機へ通知

以下は Python の完成例です。

親機 parent_monitor.py

import time
import socket
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

import requests
import RPi.GPIO as GPIO

# ========= 設定 =========
GPIO_PIN = 17

CHILD_PI_URL = "http://192.168.1.50:5000/alert" # 子機のIP
MAIL_FROM = "your_sender@gmail.com"
MAIL_TO = "your_receiver@example.com"
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
SMTP_USER = "your_sender@gmail.com"
SMTP_PASSWORD = "your_app_password" # Gmailならアプリパスワード推奨

HOSTNAME = socket.gethostname()

# チャタリング対策と連続送信防止
DEBOUNCE_SEC = 0.2
RETRIGGER_GUARD_SEC = 5.0

last_trigger_time = 0


def send_email():
subject = f"[Alert] Raspberry Pi Switch ON ({HOSTNAME})"
body = f"""トグルスイッチがONになりました。

Host: {HOSTNAME}
Time: {time.strftime('%Y-%m-%d %H:%M:%S')}
"""

msg = MIMEMultipart()
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))

with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(MAIL_FROM, MAIL_TO, msg.as_string())


def notify_child():
payload = {
"title": "警報",
"message": "親機のトグルスイッチがONになりました",
"time": time.strftime('%Y-%m-%d %H:%M:%S'),
"host": HOSTNAME
}

response = requests.post(CHILD_PI_URL, json=payload, timeout=5)
response.raise_for_status()


def on_switch_on(channel):
global last_trigger_time

now = time.time()
if now - last_trigger_time < RETRIGGER_GUARD_SEC:
return

# 念のため少し待って状態確定
time.sleep(DEBOUNCE_SEC)
if GPIO.input(GPIO_PIN) == GPIO.HIGH:
print("Switch ON detected")

try:
send_email()
print("Email sent")
except Exception as e:
print(f"Email send error: {e}")

try:
notify_child()
print("Child notified")
except Exception as e:
print(f"Child notify error: {e}")

last_trigger_time = now


def main():
GPIO.setmode(GPIO.BCM)
GPIO.setup(GPIO_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)

GPIO.add_event_detect(GPIO_PIN, GPIO.RISING, callback=on_switch_on, bouncetime=300)

print("Monitoring started. Waiting for switch ON...")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Stopped by user")
finally:
GPIO.cleanup()


if __name__ == "__main__":
main()

3. 子機側プログラム

子機は

  • HTTP受信サーバ
  • GUI表示

を行います。

Tkinter を使うと軽く作れます。

子機 child_display.py

import threading
import tkinter as tk
from flask import Flask, request, jsonify

app = Flask(__name__)

root = tk.Tk()
root.title("Alert Display")
root.attributes("-fullscreen", True)
root.configure(bg="black")

title_label = tk.Label(
root,
text="待機中",
font=("Arial", 36, "bold"),
fg="white",
bg="black"
)
title_label.pack(pady=40)

message_label = tk.Label(
root,
text="",
font=("Arial", 28),
fg="yellow",
bg="black",
wraplength=1000,
justify="center"
)
message_label.pack(pady=30)

time_label = tk.Label(
root,
text="",
font=("Arial", 20),
fg="lightgreen",
bg="black"
)
time_label.pack(pady=20)


def update_gui(title, message, alert_time):
title_label.config(text=title)
message_label.config(text=message)
time_label.config(text=alert_time)
root.configure(bg="darkred")
title_label.configure(bg="darkred")
message_label.configure(bg="darkred")
time_label.configure(bg="darkred")


@app.route("/alert", methods=["POST"])
def alert():
data = request.get_json(force=True)

title = data.get("title", "通知")
message = data.get("message", "メッセージなし")
alert_time = data.get("time", "")

root.after(0, update_gui, title, message, alert_time)

return jsonify({"status": "ok"}), 200


def run_flask():
app.run(host="0.0.0.0", port=5000)


def on_key(event):
# Escキーで終了
if event.keysym == "Escape":
root.destroy()


root.bind("<Key>", on_key)

threading.Thread(target=run_flask, daemon=True).start()
root.mainloop()

4. 必要パッケージ

親機と子機で Python パッケージを入れます。

親機

sudo apt update
sudo apt install -y python3-pip
pip3 install requests RPi.GPIO

子機

sudo apt update
sudo apt install -y python3-pip python3-tk
pip3 install flask

5. 動作手順

子機

まず子機でGUI受信プログラム起動

python3 child_display.py

起動すると全画面GUIが開きます。


親機

親機で監視プログラム起動

python3 parent_monitor.py

スイッチをONにすると

  • メール送信
  • 子機画面にメッセージ表示

が動きます。


6. Gmailで送る場合

Gmailは通常パスワード直書きでは失敗しやすいです。
アプリパスワード を使うのが安全です。

親機コード内のここを設定します。

MAIL_FROM = "your_sender@gmail.com"
MAIL_TO = "your_receiver@example.com"
SMTP_USER = "your_sender@gmail.com"
SMTP_PASSWORD = "your_app_password"

7. 子機に表示する文言を自由に変える

親機の notify_child() の payload を変えれば、画面文言を自由にできます。

payload = {
"title": "異常通知",
"message": "装置Aの安全スイッチがONです。現場確認してください。",
"time": time.strftime('%Y-%m-%d %H:%M:%S'),
"host": HOSTNAME
}

8. OFFでも動作させたい場合

今のコードは ON時のみ です。
OFFも通知したいなら RISING だけでなく BOTH にして、状態判定を入れます。

例:

GPIO.add_event_detect(GPIO_PIN, GPIO.BOTH, callback=on_switch_change, bouncetime=300)

そして

def on_switch_change(channel):
state = GPIO.input(GPIO_PIN)
if state == GPIO.HIGH:
# ON処理
else:
# OFF処理

9. 電源投入時に自動起動したい場合

systemd でサービス化すると実運用向きです。

親機サービス例

/etc/systemd/system/parent_monitor.service

[Unit]
Description=Parent Raspberry Pi GPIO Monitor
After=network.target

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

[Install]
WantedBy=multi-user.target

有効化

sudo systemctl daemon-reload
sudo systemctl enable parent_monitor.service
sudo systemctl start parent_monitor.service

子機も同様に child_display.py を自動起動できます。
GUI表示まで自動にするなら、デスクトップ自動ログイン環境で起動するのが簡単です。


10. 実運用での注意点

1) チャタリング対策

トグルスイッチは切り替え時に細かく揺れることがあります。
このためコードでは

  • bouncetime=300
  • DEBOUNCE_SEC
  • RETRIGGER_GUARD_SEC

を入れています。

2) ネットワーク断対策

子機が落ちていると HTTP送信失敗します。
必要ならログ保存や再送処理を追加します。

3) 認証

ローカルLANだけでも、厳密にするなら子機側APIにトークンを付けます。

例:

headers = {"X-API-KEY": "abc123"}
requests.post(CHILD_PI_URL, json=payload, headers=headers)

4) GPIO保護

ノイズが強い環境なら

  • シールド線
  • フォトカプラ
  • RCフィルタ
    も検討します。

11. すぐ試せる最小構成

最小で必要なのはこれです。

  • 親機
    • parent_monitor.py
  • 子機
    • child_display.py
  • 親機コード内の
    • GPIO番号
    • 子機IP
    • メール設定

だけ変更


12. あなたの要件に合わせた完成イメージ

今回の要件は、たとえばこう定義できます。

  • 親機GPIOにトグルスイッチ接続
  • スイッチONで
    • 管理者へメール送信
    • 子機 192.168.x.x に通知
  • 子機画面へ大きく
    • 「異常発生」
    • 「親機のトグルスイッチがON」
    • 発生時刻
      を表示

そのまま実装可能です。


必要なら次に、あなたのIPアドレス・表示したい文字・メール送信先・使用GPIO番号に合わせて、そのままコピペで動く完成版コード を日本語コメント付きでまとめます。

ログ保存や再送処理を追加してください

送信失敗時に捨てない形へ広げます。親機で「イベント記録」「メール再送キュー」「子機通知再送キュー」を持たせるのが実運用では扱いやすいです。

できます。
親機側に次の3つを追加すると実運用しやすくなります。

  • ログ保存
    いつONになったか、メール送信成功/失敗、子機通知成功/失敗をファイルへ記録
  • 再送処理
    失敗したメール通知・子機通知をキューに保存
  • バックグラウンド再送
    一定間隔で未送信キューを再試行

以下は、前回の親機 parent_monitor.pyログ保存+再送対応版 にした完成例です。


親機 改良版 parent_monitor.py

import os
import time
import json
import socket
import logging
import threading
import smtplib
from logging.handlers import RotatingFileHandler
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

import requests
import RPi.GPIO as GPIO

# =========================
# 設定
# =========================
GPIO_PIN = 17

CHILD_PI_URL = "http://192.168.1.50:5000/alert"

MAIL_FROM = "your_sender@gmail.com"
MAIL_TO = "your_receiver@example.com"
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
SMTP_USER = "your_sender@gmail.com"
SMTP_PASSWORD = "your_app_password"

HOSTNAME = socket.gethostname()

DEBOUNCE_SEC = 0.2
RETRIGGER_GUARD_SEC = 5.0

RETRY_INTERVAL_SEC = 30 # 再送間隔
MAX_RETRY_COUNT = 20 # 最大再送回数
QUEUE_FILE = "/home/pi/alert_queue.json"
LOG_FILE = "/home/pi/parent_monitor.log"

last_trigger_time = 0
queue_lock = threading.Lock()

# =========================
# ログ設定
# =========================
logger = logging.getLogger("parent_monitor")
logger.setLevel(logging.INFO)

formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s"
)

file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=1024 * 1024,
backupCount=5,
encoding="utf-8"
)
file_handler.setFormatter(formatter)

console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)

logger.addHandler(file_handler)
logger.addHandler(console_handler)


# =========================
# キュー管理
# =========================
def load_queue():
if not os.path.exists(QUEUE_FILE):
return []

try:
with open(QUEUE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
return data
return []
except Exception as e:
logger.error(f"キュー読込失敗: {e}")
return []


def save_queue(queue):
tmp_file = QUEUE_FILE + ".tmp"
try:
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(queue, f, ensure_ascii=False, indent=2)
os.replace(tmp_file, QUEUE_FILE)
except Exception as e:
logger.error(f"キュー保存失敗: {e}")


def enqueue_retry(task_type, payload):
with queue_lock:
queue = load_queue()
queue.append({
"task_type": task_type, # "email" or "child_notify"
"payload": payload,
"retry_count": 0,
"last_error": "",
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"last_try_at": ""
})
save_queue(queue)

logger.warning(f"再送キュー追加: task_type={task_type}")


def update_queue(new_queue):
with queue_lock:
save_queue(new_queue)


# =========================
# 送信処理
# =========================
def send_email(subject, body):
msg = MIMEMultipart()
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))

with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=20) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(MAIL_FROM, MAIL_TO, msg.as_string())


def notify_child(payload):
response = requests.post(CHILD_PI_URL, json=payload, timeout=5)
response.raise_for_status()


# =========================
# アラート生成
# =========================
def build_alert_data():
alert_time = time.strftime("%Y-%m-%d %H:%M:%S")

email_subject = f"[Alert] Raspberry Pi Switch ON ({HOSTNAME})"
email_body = (
"トグルスイッチがONになりました。\n\n"
f"Host: {HOSTNAME}\n"
f"Time: {alert_time}\n"
)

child_payload = {
"title": "警報",
"message": "親機のトグルスイッチがONになりました",
"time": alert_time,
"host": HOSTNAME
}

return {
"email_subject": email_subject,
"email_body": email_body,
"child_payload": child_payload
}


# =========================
# 即時送信 + 失敗時再送キュー
# =========================
def process_alert():
alert = build_alert_data()

logger.info("アラート発生: スイッチON")

# 1. メール送信
try:
send_email(alert["email_subject"], alert["email_body"])
logger.info("メール送信成功")
except Exception as e:
logger.error(f"メール送信失敗: {e}")
enqueue_retry("email", {
"subject": alert["email_subject"],
"body": alert["email_body"]
})

# 2. 子機通知
try:
notify_child(alert["child_payload"])
logger.info("子機通知成功")
except Exception as e:
logger.error(f"子機通知失敗: {e}")
enqueue_retry("child_notify", alert["child_payload"])


# =========================
# GPIOコールバック
# =========================
def on_switch_on(channel):
global last_trigger_time

now = time.time()
if now - last_trigger_time < RETRIGGER_GUARD_SEC:
logger.info("短時間連続トリガーのため無視")
return

time.sleep(DEBOUNCE_SEC)

if GPIO.input(GPIO_PIN) == GPIO.HIGH:
logger.info("GPIO HIGH検出")
process_alert()
last_trigger_time = now


# =========================
# 再送ワーカー
# =========================
def retry_worker():
while True:
time.sleep(RETRY_INTERVAL_SEC)

with queue_lock:
queue = load_queue()

if not queue:
continue

logger.info(f"再送処理開始: {len(queue)}件")

remaining = []

for item in queue:
task_type = item.get("task_type")
payload = item.get("payload", {})
retry_count = item.get("retry_count", 0)

if retry_count >= MAX_RETRY_COUNT:
logger.error(
f"最大再送回数超過のため破棄: task_type={task_type}, payload={payload}"
)
continue

try:
if task_type == "email":
send_email(payload["subject"], payload["body"])
logger.info(f"再送成功: email retry_count={retry_count + 1}")

elif task_type == "child_notify":
notify_child(payload)
logger.info(f"再送成功: child_notify retry_count={retry_count + 1}")

else:
logger.error(f"不明なtask_type: {task_type}")

except Exception as e:
item["retry_count"] = retry_count + 1
item["last_error"] = str(e)
item["last_try_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
remaining.append(item)

logger.warning(
f"再送失敗: task_type={task_type}, retry_count={item['retry_count']}, error={e}"
)

update_queue(remaining)


# =========================
# メイン
# =========================
def main():
logger.info("親機監視プログラム起動")

GPIO.setmode(GPIO.BCM)
GPIO.setup(GPIO_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.add_event_detect(GPIO_PIN, GPIO.RISING, callback=on_switch_on, bouncetime=300)

retry_thread = threading.Thread(target=retry_worker, daemon=True)
retry_thread.start()

logger.info("GPIO監視開始")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("ユーザー操作で停止")
finally:
GPIO.cleanup()
logger.info("GPIOクリーンアップ完了")


if __name__ == "__main__":
main()

追加された機能

1. ログ保存

ログはここへ出ます。

/home/pi/parent_monitor.log

記録例:

2026-04-21 10:00:01 [INFO] GPIO HIGH検出
2026-04-21 10:00:01 [INFO] アラート発生: スイッチON
2026-04-21 10:00:02 [ERROR] メール送信失敗: ...
2026-04-21 10:00:02 [WARNING] 再送キュー追加: task_type=email
2026-04-21 10:00:02 [INFO] 子機通知成功

ログ確認:

tail -f /home/pi/parent_monitor.log

2. 再送キュー保存

失敗した通知は JSON で保存されます。

/home/pi/alert_queue.json

保存例:

[
{
"task_type": "email",
"payload": {
"subject": "[Alert] Raspberry Pi Switch ON (raspberrypi)",
"body": "トグルスイッチがONになりました。\n\nHost: raspberrypi\nTime: 2026-04-21 10:00:01\n"
},
"retry_count": 3,
"last_error": "timed out",
"created_at": "2026-04-21 10:00:02",
"last_try_at": "2026-04-21 10:01:30"
}
]

3. 自動再送

  • 30秒ごとに再送
  • 最大20回まで再試行
  • 成功したものはキューから削除

設定変更はここです。

RETRY_INTERVAL_SEC = 30
MAX_RETRY_COUNT = 20

子機側にもログを入れる版

子機でも受信記録を残したいなら、child_display.py に logging を足せます。

子機 改良ポイント

以下を追加します。

import logging
from logging.handlers import RotatingFileHandler

初期化:

LOG_FILE = "/home/pi/child_display.log"

logger = logging.getLogger("child_display")
logger.setLevel(logging.INFO)

formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")

file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=1024 * 1024,
backupCount=3,
encoding="utf-8"
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)

alert() 内で:

logger.info(f"通知受信: {data}")

これで子機がいつ受信したか残せます。


さらに実用的にする改善案

1. ネットワーク復旧後すぐ再送したい

今は30秒おきです。もっと早くしたければ:

RETRY_INTERVAL_SEC = 10

2. 再送回数超過を別ファイルへ退避

今はログだけです。
必要なら failed_queue.json に保存できます。

3. スイッチON中の連続通知を防ぐ

今は一定時間ガードです。
「OFFになってから次のONだけ有効」にもできます。

4. 子機複数台対応

子機IPを配列にして順番に送れば対応できます。

例:

CHILD_PI_URLS = [
"http://192.168.1.50:5000/alert",
"http://192.168.1.51:5000/alert"
]

systemdで動かす場合の確認ポイント

ログは journalctl とファイル両方で見られるようにしておくと便利です。

sudo systemctl status parent_monitor.service
journalctl -u parent_monitor.service -f

すぐ試す手順

  1. 親機で新コードを保存
  2. 実行

    python3 parent_monitor.py
  3. LANを一時的に切る、または子機を止める
  4. スイッチON
  5. alert_queue.json に未送信が入ることを確認
  6. LAN復旧 or 子機起動
  7. 自動再送で成功し、キューから消えることを確認

補足

この実装は 「送信失敗しても消えない」 ことを優先しています。
そのため、メールサーバ停止や子機停止があっても、復旧後に再送されます。

次に必要なら、子機側も含めたログ付き完成版一式 を、
parent_monitor.pychild_display.py の2ファイルに分けてそのままコピペできる形でまとめます。

子機側も含めたログ付き完成版一式 を、 parent_monitor.py と child_display.py の2ファイルに分けてそのままコピペできる形でまとめてください

以下に、そのままコピペで使える 完成版2ファイル をまとめます。
機能は次のとおりです。

親機 parent_monitor.py

  • GPIOでトグルスイッチ監視
  • ON(HIGH)でイベント発生
  • メール送信
  • 子機へHTTP通知
  • ログ保存
  • 失敗時は再送キューへ保存
  • 定期的に自動再送

子機 child_display.py

  • 親機からHTTP通知を受信
  • GUI全画面表示
  • ログ保存
  • 受信内容を画面に反映
  • GUI上で簡単なクリア操作あり

1. parent_monitor.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import time
import json
import socket
import logging
import threading
import smtplib
from logging.handlers import RotatingFileHandler
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

import requests
import RPi.GPIO as GPIO

# =========================================================
# 設定
# =========================================================

# GPIO
GPIO_PIN = 17

# 子機 Raspberry Pi の通知先
CHILD_PI_URLS = [
"http://192.168.1.50:5000/alert",
# "http://192.168.1.51:5000/alert", # 必要なら追加
]

# メール設定
MAIL_FROM = "your_sender@gmail.com"
MAIL_TO = "your_receiver@example.com"
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
SMTP_USER = "your_sender@gmail.com"
SMTP_PASSWORD = "your_app_password"

# 動作設定
DEBOUNCE_SEC = 0.2
RETRIGGER_GUARD_SEC = 5.0
RETRY_INTERVAL_SEC = 30
MAX_RETRY_COUNT = 20
REQUEST_TIMEOUT_SEC = 5

# 保存ファイル
BASE_DIR = "/home/pi"
LOG_FILE = os.path.join(BASE_DIR, "parent_monitor.log")
QUEUE_FILE = os.path.join(BASE_DIR, "alert_queue.json")

HOSTNAME = socket.gethostname()

# =========================================================
# グローバル
# =========================================================
last_trigger_time = 0
queue_lock = threading.Lock()

# =========================================================
# ログ設定
# =========================================================
logger = logging.getLogger("parent_monitor")
logger.setLevel(logging.INFO)
logger.propagate = False

if not logger.handlers:
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")

file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=1024 * 1024,
backupCount=5,
encoding="utf-8"
)
file_handler.setFormatter(formatter)

console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)

logger.addHandler(file_handler)
logger.addHandler(console_handler)


# =========================================================
# キュー管理
# =========================================================
def load_queue():
if not os.path.exists(QUEUE_FILE):
return []

try:
with open(QUEUE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
return data
logger.warning("キューファイル形式が不正のため初期化します")
return []
except Exception as e:
logger.error(f"キュー読込失敗: {e}")
return []


def save_queue(queue):
tmp_file = QUEUE_FILE + ".tmp"
try:
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(queue, f, ensure_ascii=False, indent=2)
os.replace(tmp_file, QUEUE_FILE)
except Exception as e:
logger.error(f"キュー保存失敗: {e}")


def enqueue_retry(task_type, payload, destination=""):
task = {
"task_type": task_type, # "email" or "child_notify"
"payload": payload,
"destination": destination,
"retry_count": 0,
"last_error": "",
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"last_try_at": ""
}

with queue_lock:
queue = load_queue()
queue.append(task)
save_queue(queue)

logger.warning(f"再送キュー追加: task_type={task_type}, destination={destination}")


def replace_queue(new_queue):
with queue_lock:
save_queue(new_queue)


# =========================================================
# 送信処理
# =========================================================
def send_email(subject, body):
msg = MIMEMultipart()
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))

with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=20) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(MAIL_FROM, MAIL_TO, msg.as_string())


def notify_child(url, payload):
response = requests.post(url, json=payload, timeout=REQUEST_TIMEOUT_SEC)
response.raise_for_status()


# =========================================================
# アラート内容生成
# =========================================================
def build_alert_data():
alert_time = time.strftime("%Y-%m-%d %H:%M:%S")

email_subject = f"[Alert] Raspberry Pi Switch ON ({HOSTNAME})"
email_body = (
"トグルスイッチがONになりました。\n\n"
f"Host: {HOSTNAME}\n"
f"Time: {alert_time}\n"
f"GPIO: {GPIO_PIN}\n"
)

child_payload = {
"title": "警報",
"message": "親機のトグルスイッチがONになりました",
"time": alert_time,
"host": HOSTNAME,
"gpio": GPIO_PIN
}

return {
"email_subject": email_subject,
"email_body": email_body,
"child_payload": child_payload
}


# =========================================================
# アラート処理
# =========================================================
def process_alert():
alert = build_alert_data()
logger.info("アラート発生: スイッチON")

# メール送信
try:
send_email(alert["email_subject"], alert["email_body"])
logger.info("メール送信成功")
except Exception as e:
logger.error(f"メール送信失敗: {e}")
enqueue_retry(
task_type="email",
payload={
"subject": alert["email_subject"],
"body": alert["email_body"]
},
destination=MAIL_TO
)

# 子機通知
for url in CHILD_PI_URLS:
try:
notify_child(url, alert["child_payload"])
logger.info(f"子機通知成功: {url}")
except Exception as e:
logger.error(f"子機通知失敗: {url}, error={e}")
enqueue_retry(
task_type="child_notify",
payload=alert["child_payload"],
destination=url
)


# =========================================================
# GPIOコールバック
# =========================================================
def on_switch_on(channel):
global last_trigger_time

now = time.time()
if now - last_trigger_time < RETRIGGER_GUARD_SEC:
logger.info("短時間の連続トリガーのため無視")
return

time.sleep(DEBOUNCE_SEC)

if GPIO.input(GPIO_PIN) == GPIO.HIGH:
logger.info("GPIO HIGH検出")
process_alert()
last_trigger_time = now


# =========================================================
# 再送ワーカー
# =========================================================
def retry_worker():
while True:
time.sleep(RETRY_INTERVAL_SEC)

with queue_lock:
queue = load_queue()

if not queue:
continue

logger.info(f"再送処理開始: {len(queue)}件")
remaining = []

for item in queue:
task_type = item.get("task_type")
payload = item.get("payload", {})
destination = item.get("destination", "")
retry_count = item.get("retry_count", 0)

if retry_count >= MAX_RETRY_COUNT:
logger.error(
f"最大再送回数超過のため破棄: task_type={task_type}, destination={destination}"
)
continue

try:
if task_type == "email":
send_email(payload["subject"], payload["body"])
logger.info(
f"再送成功: task_type=email, retry_count={retry_count + 1}, destination={destination}"
)

elif task_type == "child_notify":
notify_child(destination, payload)
logger.info(
f"再送成功: task_type=child_notify, retry_count={retry_count + 1}, destination={destination}"
)

else:
logger.error(f"不明なtask_type: {task_type}")
continue

except Exception as e:
item["retry_count"] = retry_count + 1
item["last_error"] = str(e)
item["last_try_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
remaining.append(item)

logger.warning(
f"再送失敗: task_type={task_type}, retry_count={item['retry_count']}, "
f"destination={destination}, error={e}"
)

replace_queue(remaining)


# =========================================================
# 初期化
# =========================================================
def initialize_gpio():
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(GPIO_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.add_event_detect(
GPIO_PIN,
GPIO.RISING,
callback=on_switch_on,
bouncetime=300
)


# =========================================================
# メイン
# =========================================================
def main():
logger.info("========================================")
logger.info("親機監視プログラム起動")
logger.info(f"HOSTNAME={HOSTNAME}")
logger.info(f"GPIO_PIN={GPIO_PIN}")
logger.info(f"CHILD_PI_URLS={CHILD_PI_URLS}")

initialize_gpio()

thread = threading.Thread(target=retry_worker, daemon=True)
thread.start()

logger.info("GPIO監視開始")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("ユーザー操作で停止")
finally:
GPIO.cleanup()
logger.info("GPIOクリーンアップ完了")


if __name__ == "__main__":
main()

2. child_display.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import json
import queue
import threading
import logging
from logging.handlers import RotatingFileHandler

import tkinter as tk
from flask import Flask, request, jsonify

# =========================================================
# 設定
# =========================================================
BASE_DIR = "/home/pi"
LOG_FILE = os.path.join(BASE_DIR, "child_display.log")
RECEIVED_LOG_JSON = os.path.join(BASE_DIR, "child_received_events.json")

LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 5000

WINDOW_TITLE = "Alert Display"

# =========================================================
# ログ設定
# =========================================================
logger = logging.getLogger("child_display")
logger.setLevel(logging.INFO)
logger.propagate = False

if not logger.handlers:
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")

file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=1024 * 1024,
backupCount=5,
encoding="utf-8"
)
file_handler.setFormatter(formatter)

console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)

logger.addHandler(file_handler)
logger.addHandler(console_handler)

# =========================================================
# 受信データ保存
# =========================================================
def append_received_event(data):
events = []

if os.path.exists(RECEIVED_LOG_JSON):
try:
with open(RECEIVED_LOG_JSON, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, list):
events = loaded
except Exception as e:
logger.error(f"受信JSON読込失敗: {e}")

events.append(data)

# 古いデータが増えすぎないように末尾200件だけ保存
events = events[-200:]

tmp_file = RECEIVED_LOG_JSON + ".tmp"
try:
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(events, f, ensure_ascii=False, indent=2)
os.replace(tmp_file, RECEIVED_LOG_JSON)
except Exception as e:
logger.error(f"受信JSON保存失敗: {e}")

# =========================================================
# Flask
# =========================================================
app = Flask(__name__)
gui_queue = queue.Queue()

@app.route("/alert", methods=["POST"])
def alert():
try:
data = request.get_json(force=True)

if not isinstance(data, dict):
logger.warning("不正なJSON受信")
return jsonify({"status": "error", "message": "invalid json"}), 400

logger.info(f"通知受信: {data}")
append_received_event(data)
gui_queue.put(data)

return jsonify({"status": "ok"}), 200

except Exception as e:
logger.exception(f"通知処理失敗: {e}")
return jsonify({"status": "error", "message": str(e)}), 500


@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"}), 200


def run_flask():
logger.info(f"HTTP受信開始: http://{LISTEN_HOST}:{LISTEN_PORT}")
app.run(host=LISTEN_HOST, port=LISTEN_PORT, debug=False, use_reloader=False)


# =========================================================
# Tkinter GUI
# =========================================================
class AlertDisplayApp:
def __init__(self, root):
self.root = root
self.root.title(WINDOW_TITLE)
self.root.attributes("-fullscreen", True)
self.root.configure(bg="black")

self.default_bg = "black"
self.alert_bg = "darkred"

self.frame = tk.Frame(self.root, bg=self.default_bg)
self.frame.pack(fill="both", expand=True)

self.title_label = tk.Label(
self.frame,
text="待機中",
font=("Arial", 40, "bold"),
fg="white",
bg=self.default_bg
)
self.title_label.pack(pady=40)

self.message_label = tk.Label(
self.frame,
text="通知を待っています",
font=("Arial", 28),
fg="yellow",
bg=self.default_bg,
wraplength=1200,
justify="center"
)
self.message_label.pack(pady=30)

self.info_label = tk.Label(
self.frame,
text="",
font=("Arial", 20),
fg="lightgreen",
bg=self.default_bg,
justify="center"
)
self.info_label.pack(pady=20)

self.help_label = tk.Label(
self.frame,
text="C: 画面クリア Esc: 終了",
font=("Arial", 18),
fg="lightgray",
bg=self.default_bg
)
self.help_label.pack(side="bottom", pady=20)

self.root.bind("<Escape>", self.on_escape)
self.root.bind("<c>", self.on_clear)
self.root.bind("<C>", self.on_clear)

self.poll_gui_queue()

def set_bg_all(self, color):
self.root.configure(bg=color)
self.frame.configure(bg=color)
self.title_label.configure(bg=color)
self.message_label.configure(bg=color)
self.info_label.configure(bg=color)
self.help_label.configure(bg=color)

def update_alert(self, data):
title = data.get("title", "通知")
message = data.get("message", "メッセージなし")
alert_time = data.get("time", "")
host = data.get("host", "")
gpio = data.get("gpio", "")

info_lines = []
if alert_time:
info_lines.append(f"時刻: {alert_time}")
if host:
info_lines.append(f"送信元: {host}")
if gpio != "":
info_lines.append(f"GPIO: {gpio}")

self.title_label.config(text=title)
self.message_label.config(text=message)
self.info_label.config(text="\n".join(info_lines))
self.set_bg_all(self.alert_bg)

logger.info("GUI更新完了")

def clear_screen(self, event=None):
self.title_label.config(text="待機中")
self.message_label.config(text="通知を待っています")
self.info_label.config(text="")
self.set_bg_all(self.default_bg)
logger.info("画面クリア")

def on_clear(self, event=None):
self.clear_screen()

def on_escape(self, event=None):
logger.info("GUI終了")
self.root.destroy()

def poll_gui_queue(self):
try:
while True:
data = gui_queue.get_nowait()
self.update_alert(data)
except queue.Empty:
pass

self.root.after(200, self.poll_gui_queue)


# =========================================================
# メイン
# =========================================================
def main():
logger.info("========================================")
logger.info("子機表示プログラム起動")

flask_thread = threading.Thread(target=run_flask, daemon=True)
flask_thread.start()

root = tk.Tk()
app_ui = AlertDisplayApp(root)

try:
root.mainloop()
except KeyboardInterrupt:
logger.info("ユーザー操作で停止")
finally:
logger.info("子機表示プログラム終了")


if __name__ == "__main__":
main()

3. インストール

親機

sudo apt update
sudo apt install -y python3-pip
pip3 install requests RPi.GPIO

子機

sudo apt update
sudo apt install -y python3-pip python3-tk
pip3 install flask

4. 変更が必要な箇所

親機 parent_monitor.py

最低でもここを変更してください。

GPIO_PIN = 17
CHILD_PI_URLS = [
"http://192.168.1.50:5000/alert",
]
MAIL_FROM = "your_sender@gmail.com"
MAIL_TO = "your_receiver@example.com"
SMTP_USER = "your_sender@gmail.com"
SMTP_PASSWORD = "your_app_password"

5. 起動方法

子機を先に起動

python3 child_display.py

親機を起動

python3 parent_monitor.py

6. 保存されるログ

親機

  • 通常ログ
    /home/pi/parent_monitor.log
  • 再送キュー
    /home/pi/alert_queue.json

子機

  • 通常ログ
    /home/pi/child_display.log
  • 受信履歴JSON
    /home/pi/child_received_events.json

7. 動作確認

子機の疎通確認

親機または別PCから:

curl http://192.168.1.50:5000/health

{"status":"ok"} が返れば受信待機できています。

手動通知テスト

curl -X POST http://192.168.1.50:5000/alert \
-H "Content-Type: application/json" \
-d '{"title":"テスト通知","message":"手動送信テストです","time":"2026-04-21 12:00:00","host":"test-pi","gpio":17}'

これで子機GUIが赤背景で更新されます。


8. 運用メモ

  • スイッチONで即送信
  • 失敗時は再送キューへ保存
  • 30秒ごとに自動再送
  • 最大20回まで再試行
  • 子機は受信内容をログにもJSONにも保存