1978年の最高裁判所判決では、「外国人は、在留の権利を与えられているものではなく、在留を許可するかどうかは法務大臣の裁量に任されている」という法理
マクリーン事件(最大判昭和53年10月4日)は、外国人の基本的人権の保障範囲と、在留期間更新における行政裁量について判断した最高裁判例です。日本の入管法の下における、重要かつ代表的な2つの法理
そのため、要件を形式的に満たしているからといって、当然に許可される「権利」として請求することは原則としてできません。
1978年の最高裁判所判決では、「外国人は、在留の権利を与えられているものではなく、在留を許可するかどうかは法務大臣の裁量に任されている」という法理
マクリーン事件(最大判昭和53年10月4日)は、外国人の基本的人権の保障範囲と、在留期間更新における行政裁量について判断した最高裁判例です。日本の入管法の下における、重要かつ代表的な2つの法理
親機 Raspberry Pi
ON(HIGH) になったら
子機 Raspberry Pi
[トグルスイッチ]
│
▼
[親機 Raspberry Pi]
GPIO入力監視
│
├─ eメール送信
│
└─ HTTP POST送信 → [子機 Raspberry Pi]
│
└─ GUIで画面表示
GPIO入力は例えば GPIO17 を使います。
3.3V ----[スイッチ]---- GPIO17 ----[10kΩ]---- GND
これで
になります。
※ 内部プルダウンを使わず、外付け抵抗にしたほうが分かりやすいです。
※ 配線ミス防止のため 5VをGPIOへ入れない でください。
親機でやることは3つです。
以下は Python の完成例です。
parent_monitor.pyimport 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()
子機は
を行います。
Tkinter を使うと軽く作れます。
child_display.pyimport 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()
親機と子機で 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
まず子機でGUI受信プログラム起動
python3 child_display.py
起動すると全画面GUIが開きます。
親機で監視プログラム起動
python3 parent_monitor.py
スイッチをONにすると
が動きます。
Gmailは通常パスワード直書きでは失敗しやすいです。
アプリパスワード を使うのが安全です。
親機コード内のここを設定します。
MAIL_FROM = "your_sender@gmail.com"
MAIL_TO = "your_receiver@example.com"
SMTP_USER = "your_sender@gmail.com"
SMTP_PASSWORD = "your_app_password"
親機の notify_child() の payload を変えれば、画面文言を自由にできます。
payload = {
"title": "異常通知",
"message": "装置Aの安全スイッチがONです。現場確認してください。",
"time": time.strftime('%Y-%m-%d %H:%M:%S'),
"host": HOSTNAME
}
今のコードは 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処理
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表示まで自動にするなら、デスクトップ自動ログイン環境で起動するのが簡単です。
トグルスイッチは切り替え時に細かく揺れることがあります。
このためコードでは
bouncetime=300
DEBOUNCE_SEC
RETRIGGER_GUARD_SEC
を入れています。
子機が落ちていると HTTP送信失敗します。
必要ならログ保存や再送処理を追加します。
ローカルLANだけでも、厳密にするなら子機側APIにトークンを付けます。
例:
headers = {"X-API-KEY": "abc123"}
requests.post(CHILD_PI_URL, json=payload, headers=headers)
ノイズが強い環境なら
最小で必要なのはこれです。
parent_monitor.py
child_display.py
だけ変更
今回の要件は、たとえばこう定義できます。
192.168.x.x に通知
そのまま実装可能です。
必要なら次に、あなたのIPアドレス・表示したい文字・メール送信先・使用GPIO番号に合わせて、そのままコピペで動く完成版コード を日本語コメント付きでまとめます。
送信失敗時に捨てない形へ広げます。親機で「イベント記録」「メール再送キュー」「子機通知再送キュー」を持たせるのが実運用では扱いやすいです。
できます。
親機側に次の3つを追加すると実運用しやすくなります。
以下は、前回の親機 parent_monitor.py を ログ保存+再送対応版 にした完成例です。
parent_monitor.pyimport 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()
ログはここへ出ます。
/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
失敗した通知は 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"
}
]
設定変更はここです。
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}")
これで子機がいつ受信したか残せます。
今は30秒おきです。もっと早くしたければ:
RETRY_INTERVAL_SEC = 10
今はログだけです。
必要なら failed_queue.json に保存できます。
今は一定時間ガードです。
「OFFになってから次のONだけ有効」にもできます。
子機IPを配列にして順番に送れば対応できます。
例:
CHILD_PI_URLS = [
"http://192.168.1.50:5000/alert",
"http://192.168.1.51:5000/alert"
]
ログは journalctl とファイル両方で見られるようにしておくと便利です。
sudo systemctl status parent_monitor.service
journalctl -u parent_monitor.service -f
実行
python3 parent_monitor.py
alert_queue.json に未送信が入ることを確認
この実装は 「送信失敗しても消えない」 ことを優先しています。
そのため、メールサーバ停止や子機停止があっても、復旧後に再送されます。
次に必要なら、子機側も含めたログ付き完成版一式 を、
parent_monitor.py と child_display.py の2ファイルに分けてそのままコピペできる形でまとめます。
以下に、そのままコピペで使える 完成版2ファイル をまとめます。
機能は次のとおりです。
親機 parent_monitor.py
子機 child_display.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()
#!/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()
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
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"
python3 child_display.py
python3 parent_monitor.py
/home/pi/parent_monitor.log
/home/pi/alert_queue.json
/home/pi/child_display.log
/home/pi/child_received_events.json
親機または別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が赤背景で更新されます。