全スクリプトで使い回せる!Bootstrap × smarthomelog × InfluxWriter で作るPythonスマートホームの共通基盤設計

InfluxDB

スマートホームの自動化スクリプトが増えるにつれて、「設定ファイルの読み込み」「ログ出力」「InfluxDBへのデータ保存」「エラーハンドリング」といった処理をスクリプトごとに書き直す手間が大きくなっていきます。
本記事では、このプロジェクトで全スクリプト共通で使用している3つのライブラリ(BootstrapsmarthomelogInfluxWriter)の設計と、スクリプトを常駐プロセスとして安定稼働させるための systemd サービス化 のノウハウを解説します。

なぜこの機能を作ったのか?

スマートホームのスクリプトが10本・20本と増えていくと、設定ファイルのパスをハードコードしていたり、エラーハンドリングが各スクリプトでバラバラだったりといった問題が出てきます。
「1つの設定変更が全スクリプトに波及してしまう」「どのスクリプトでエラーが起きたか把握しにくい」といった問題を解決するため、プロジェクト全体で使い回せる共通基盤ライブラリを設計しました。

スクリプトを増やすたびに同じコードを書くのをやめたい!共通化して保守をラクにしたい!

実現したいこと

  • どのフォルダから実行しても PROJECT_ROOT を自動検出して設定ファイルを読み込む(Bootstrap)
  • ログ出力をファイルと InfluxDB に同時に行い、Grafana でエラーを可視化できる(smarthomelog)
  • 各スクリプトからの InfluxDB への書き込みを共通メソッドに集約する(InfluxWriter)
  • 全スクリプトを systemd サービスとして登録し、起動時の自動実行・クラッシュ時の自動再起動を実現する

この記事でわかること

  • プロジェクトルートを自動検出する Bootstrap クラスの設計と使い方
  • ログをファイルと InfluxDB に同時記録する smarthomelog の設計
  • InfluxDB への書き込みを内部 Web API 経由で行うアーキテクチャの考え方
  • 全スクリプト共通の冒頭ボイラープレートの意味と役割
  • systemd サービスファイルの書き方・デプロイ手順・運用コマンド

必要な準備と用意するもの

ハードウェア
  • Linux 搭載サーバー(Raspberry Pi / NUCBOX など)
ソフトウェア/サービス
  • Python 3(標準ライブラリのみ:pathlib / sys / os / json / datetime / traceback)
  • InfluxDB v2(稼働済み)
  • systemd(Linux 標準。Raspberry Pi OS / Ubuntu 等に含まれる)

システム全体の構成

フォルダ構成

プロジェクトは以下のような構成になっています。

smarthome/                         ← PROJECT_ROOT(ここを自動検出)
├── config/
│   ├── settings.json              ← 全スクリプト共通の設定ファイル
│   └── services/                  ← systemd サービスファイル置き場
│       ├── smh-rain-forecast-alert.service
│       ├── smh-process-status-record.service
│       └── ...(全サービス分)
├── libs/
│   ├── bootstrap.py               ← ① PROJECT_ROOT検出・設定読込・sys.path設定
│   ├── smarthomelog.py            ← ② ログ出力(ファイル+InfluxDB)
│   └── influx/
│       └── v2/
│           ├── client.py          ← ③ InfluxDB への HTTP API クライアント
│           └── writer.py          ← ④ measurement 別の書き込みメソッド集
└── core/
    ├── alert/
    │   └── rain_forecast/
    │       └── rain_forecast_alert.py   ← 個別スクリプト(共通基盤を使う側)
    └── record/
        └── raspi/
            └── process_status_record.py

全スクリプト共通のボイラープレート

プロジェクト内の全 Python スクリプトは冒頭に以下の決まり文句(ボイラープレート)を記述します。

# -*- coding: utf-8 -*-
__group__ = '情報記録'           # Grafanaでのグループ名(任意)
__description__ = 'このスクリプトの説明'  # Grafanaでの説明(任意)
# ------ 全Pythonプログラム共通の宣言 ----------------------------
from pathlib import Path
import sys
# プロジェクトのルートフォルダを取得
PROJECT_ROOT = Path(__file__).resolve()
while PROJECT_ROOT.name != "smarthome":
    if PROJECT_ROOT.parent == PROJECT_ROOT:
        raise RuntimeError("smarthome project root not found")
    PROJECT_ROOT = PROJECT_ROOT.parent
# Bootstrapをimportするためだけにプロジェクトルートをパスに追加
sys.path.insert(0, str(PROJECT_ROOT))
# BootstrapでPathの設定と設定ファイルの取得
from libs.bootstrap import Bootstrap
bootstrap = Bootstrap(__file__)   # Bootstrapをインスタンス化
settings = bootstrap.settings    # 設定ファイルを取得
# ------------------------------------------------------------
# このファイル固有のインポート(以下にスクリプト独自の import を書く)

ボイラープレートの役割は「Bootstrap を import できる状態を作るための最小限の準備」です。一度 Bootstrap をインスタンス化すれば、以降は settings 変数から全設定にアクセスでき、libs/ 以下のライブラリもすべて import できる状態になります。

① Bootstrap クラス(libs/bootstrap.py)

役割と設計思想

Bootstrap はスクリプト起動時に必要な「環境セットアップ」をすべて担う初期化クラスです。どのフォルダからスクリプトを実行しても、常に同じ環境が整います。

  • PROJECT_ROOT 自動検出config/settings.json を持つ親ディレクトリをさかのぼって探す
  • sys.path 設定libs/interfaces/outbound/ を自動追加
  • 共通設定の読み込みconfig/settings.json を読み込んで settings プロパティで返す

Bootstrap の実装

from pathlib import Path
import sys
import os
import json
from typing import Iterable

class Bootstrap:
    def __init__(self, entry_file: str):
        self.entry_file: Path = Path(entry_file).resolve()
        self.project_root: Path = self._detect_project_root()
        self.settings: dict = self._load_common_settings()
        self._setup_base_sys_path()

    def _detect_project_root(self) -> Path:
        """config/settings.json を持つディレクトリをさかのぼって PROJECT_ROOT とする"""
        start_dir = self.entry_file.parent.parent  # 1つ上から開始

        for parent in [start_dir, *start_dir.parents]:
            if (parent / "config" / "settings.json").exists():
                return parent

        raise RuntimeError("PROJECT_ROOT not found (config/settings.json not found)")

    def _add_sys_paths(self, paths: Iterable[Path]):
        for p in paths:
            p = Path(p).resolve()
            p_str = str(p)
            if p_str not in sys.path:
                sys.path.insert(0, p_str)

    def _setup_base_sys_path(self):
        """libs/ と interfaces/outbound/ を sys.path に追加"""
        paths = [
            self.project_root / "libs",
            self.project_root / "interfaces" / "outbound",
        ]
        self._add_sys_paths(paths)

    def _load_common_settings(self) -> dict:
        """PROJECT_ROOT/config/settings.json を読む"""
        settings_path = self.project_root / "config" / "settings.json"
        if not settings_path.exists():
            raise FileNotFoundError(f"settings.json not found: {settings_path}")
        with settings_path.open("r", encoding="utf-8") as f:
            return json.load(f)

    def add_sys_path(self, *paths: Path):
        """スクリプト固有の sys.path 追加(個別スクリプトから呼び出す)"""
        self._add_sys_paths(paths)

    def chdir_local(self):
        """実行スクリプトのあるディレクトリに cwd を変更する"""
        os.chdir(self.entry_file.parent)

    def load_local_settings(self, filename: str = "config/settings.json") -> dict:
        """実行スクリプトと同じディレクトリにあるローカル設定を読む(なければ空dict)"""
        local_path = self.entry_file.parent / filename
        if not local_path.exists():
            return {}
        with local_path.open("r", encoding="utf-8") as f:
            return json.load(f)

    @property
    def local_dir(self) -> Path:
        return self.entry_file.parent

PROJECT_ROOT 検出の仕組み

config/settings.json が存在するディレクトリを PROJECT_ROOT とみなして、呼び出し元の1つ上から親ディレクトリをさかのぼって探します。スクリプトのファイル自体ではなく「1つ上から」開始するのは、libs/ 直下のスクリプトが自分のディレクトリで検索を始めてしまわないようにするためです。

パスのハードコードが不要になる
スクリプトをどのフォルダに置いても PROJECT_ROOT を自動検出するため、/home/<username>/projects/smarthome/config/settings.json のようなフルパスをコードに書く必要がありません。ホームディレクトリ名が変わっても、プロジェクトを別のサーバーに移行しても、設定変更なしで動作します。

② smarthomelog(libs/smarthomelog.py)

役割と設計思想

smarthomelog はログ出力を「テキストファイルへの書き込み」と「InfluxDB への記録」の両方を同時に行うライブラリです。InfluxDB に記録することで Grafana でエラー発生状況をダッシュボードから確認できます。

LogType クラス:ログファイルのパスを一元管理

ログファイルのパスはすべて settings.json から読み込んで LogType クラスのクラス変数として定義しています。これにより、パスを変更したいときは settings.json を1か所直すだけで全スクリプトに反映されます。

class LogType:
    SUCCESS           = settings['log']['path']['success']
    SYSTEM_ERROR      = settings['log']['path']['system_error']
    APPLICATION_ERROR = settings['log']['path']['application_error']
    GOOGLE_LIVING     = settings['log']['path']['google_living']
    LINE              = settings['log']['path']['line']
    # ...(その他のログパス)

Log クラス:ファイル書き込み+InfluxDB 同時記録

class Log:
    def __init__(self, program_path: str, logtype: LogType):
        self.log_path    = logtype
        self.program_path = program_path
        self.logtype_name = [k for k, v in LogType.__dict__.items() if v == logtype][0]
        self.influx_writer = influxwriter.Writer()

    def write(self, message: str, program_path: str = None, use_separator: bool = False):
        if self.logtype_name == 'SUCCESS':
            return   # SUCCESSはファイルに書かない(InfluxDBのみ)

        now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S.%f')
        filename = os.path.basename(program_path) if program_path else ''
        log_message = (filename + ' ' if filename else '') + str(message)

        with open(self.log_path, 'a') as f:
            f.write(now + ' ' + log_message + '\n')

        # APPLICATION_ERROR は InfluxDB にも記録
        if self.logtype_name == 'APPLICATION_ERROR':
            self.influx_writer.insert_execution_status_log(
                self.program_path, influxwriter.LogStatusType.APPLICATION_ERROR, log_message
            )

    def write_error(self, exc_info):
        """システムエラーをファイル+InfluxDB に記録(トレースバック付き)"""
        now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S.%f')
        t, v, tb = exc_info
        messages = traceback.format_exception(t, v, tb)
        with open(self.log_path, 'a') as f:
            f.write(now + ' ' + self.program_path + '\n')
            for msg in messages:
                f.write(now + ' ' + msg + '\n')

        self.influx_writer.insert_execution_status_log(
            self.program_path, influxwriter.LogStatusType.SYSTEM_ERROR, str(messages)
        )

    def get_seconds_since_the_file_was_updated(self) -> int:
        """最終ログ更新からの経過秒数を返す(死活監視に利用)"""
        if not os.path.exists(self.log_path):
            return 999999999
        diff = datetime.datetime.now() - datetime.datetime.fromtimestamp(
            os.path.getmtime(self.log_path)
        )
        return int(diff.seconds)

get_seconds_since_the_file_was_updated() の活用
このメソッドは死活監視に活用されています。各スクリプトは正常動作時に SUCCESS ログを定期的に書き込みます。別の監視スクリプトがこのメソッドで「最終更新から何秒経過したか」をチェックし、一定時間更新がなければ「スクリプトが止まっている」と判定してアラートを発出します。

各スクリプトでの使い方

import smarthomelog

# インスタンス作成(どのログファイルに書くかを LogType で指定)
success_log = smarthomelog.Log(__file__, smarthomelog.LogType.SUCCESS)
app_err_log = smarthomelog.Log(__file__, smarthomelog.LogType.APPLICATION_ERROR)

try:
    # ... メイン処理 ...

    # 正常終了時
    success_log.write('処理が正常に終了しました。', __file__)

except Exception as e:
    # システムエラー時(トレースバック付きで記録)
    log = smarthomelog.Log(__file__, smarthomelog.LogType.SYSTEM_ERROR)
    log.write_error(sys.exc_info())

③ InfluxDB クライアント設計(libs/influx/v2/)

アーキテクチャ:直接接続ではなく内部 Web API 経由

このプロジェクトの InfluxDB クライアントは、InfluxDB SDK を直接使用するのではなく、サーバー上で動いている内部 Web API(Flask)を経由してデータを書き込む設計になっています。

【各 Python スクリプト】
 ↓ HTTP POST
【内部 Web API(Flask / http://192.168.xx.xx/internal/influx)】
 ↓ InfluxDB Python Client SDK
【InfluxDB v2】

内部 API 経由にするメリット
InfluxDB の接続先・認証情報を1か所(Web API 側)にまとめられます。将来 InfluxDB を別サーバーに移行したり、認証方式が変わったりしても、各スクリプトを修正する必要がありません。

client.py:HTTP API 経由の書き込み

import requests

API_URL      = 'http://192.168.xx.xx/internal/influx'
INFLUX_BUCKET = settings.get('influxdb2').get("bucket")

def write_dict(measurement: str, tags: dict, fields: dict, time: str = None):
    """1レコードを InfluxDB に書き込む"""
    record = {
        "measurement": measurement,
        "tags": tags,
        "fields": fields,
    }
    if time:
        record["time"] = time
    resp = requests.post(f"{API_URL}/write", json=record)
    return resp.status_code, resp.text

def write_dict_batch(measurement: str, tags_list: list, fields_list: list, time_list: list = None):
    """複数レコードを一括で InfluxDB に書き込む"""
    if len(tags_list) != len(fields_list):
        raise ValueError("tags_list と fields_list は同じ長さが必要です")

    payloads = {
        "measurement": measurement,
        "tags_list": tags_list,
        "fields_list": fields_list,
        "time_list": time_list
    }
    return write_batch(payloads)

writer.py:measurement 別の書き込みメソッド集

client.py がインフラ層(「どうやって書き込むか」)なのに対し、writer.py はアプリ層(「何を書き込むか」)の役割を担います。各 measurement に対応した insert_* メソッドを実装し、各スクリプトはこのメソッドを呼ぶだけでよい設計にしています。

class LogStatusType:
    SUCCESS           = 'SUCCESS'
    APPLICATION_ERROR = 'APPLICATION_ERROR'
    SYSTEM_ERROR      = 'SYSTEM_ERROR'

class Writer:
    def __init__(self):
        self.conn = influxclient   # client モジュールをそのまま使用

    def insert_execution_log(self, program_path: str, message: str):
        """スクリプトの実行開始ログを記録"""
        measurement = 'execution_log'
        tags   = {'program_path': program_path}
        fields = {'message': message,
                  'timestamp': datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')}
        self.conn.write_dict(measurement, tags, fields)

    def insert_execution_status_log(self, program_path: str, status: LogStatusType, message: str):
        """スクリプトの実行状態(正常/エラー)を記録"""
        measurement = 'execution_status_log'
        tags   = {'program_path': program_path, 'status': status}
        fields = {'message': message,
                  'timestamp': datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')}
        self.conn.write_dict(measurement, tags, fields)

    def insert_nginx_log(self, log_type: str, message: str):
        """NGINX ログを記録"""
        # ... (各 measurement 対応のメソッドが並ぶ)

    def insert_nucbox_system_log(self, hostname: str, fields: dict):
        """NUCBOX のシステム情報を記録"""
        # ...

④ 全スクリプト共通の try/except パターン

すべてのスクリプトは以下の try/except パターンを採用しています。このパターンにより、どんなエラーが発生しても必ずログに残ります。

# ボイラープレート(省略)...

try:
    # ① InfluxDB 接続・ログインスタンス生成
    influx = influxwriter.Writer()
    success_log = smarthomelog.Log(__file__, smarthomelog.LogType.SUCCESS)
    app_err_log = smarthomelog.Log(__file__, smarthomelog.LogType.APPLICATION_ERROR)

    # ② 実行開始をInfluxDBに記録
    influx.insert_execution_log(__file__, 'Start!')

    # ③ メイン処理
    while True:
        # ... 処理内容 ...

        # ④ 正常終了ログ
        success_log.write('処理が正常に終了しました。', __file__)
        time.sleep(5)

except Exception as e:
    # ⑤ システムエラー:トレースバック付きでファイル+InfluxDB に記録
    log = smarthomelog.Log(__file__, smarthomelog.LogType.SYSTEM_ERROR)
    log.write_error(sys.exc_info())

try ブロックの外に except だけを置いているのは意図的な設計です。systemd の Restart=always と組み合わせることで、想定外のエラーでスクリプトが落ちても自動で再起動されます。エラーログさえ残れば、再起動後に原因を調べられます。

⑤ systemd サービス化

サービスファイルの設計

プロジェクト内の全サービスファイルは config/services/ フォルダで一元管理しています。全サービスで共通のパターンは以下の通りです。

[Unit]
Description=Weather Alert
After=nginx.service influxdb.service webapi.service
Wants=nginx.service influxdb.service webapi.service

[Service]
ExecStart=/home/<username>/Projects/venv/bin/python \
    /home/<username>/Projects/smarthome/core/alert/rain_forecast/rain_forecast_alert.py
Restart=always
RestartSec=60
Type=simple
User=<username>

[Install]
WantedBy=multi-user.target

各設定項目の意味

  • After / Wants:nginx・influxdb・webapi(内部 API)が起動してから開始する。依存関係の順序を保証
  • ExecStart:venv の Python を明示的に指定。システム Python と仮想環境のライブラリを混在させない
  • Restart=always:スクリプトが何らかの理由で終了したとき(エラー・クラッシュ問わず)自動再起動
  • RestartSec=60:再起動まで60秒待つ。無限ループエラーで連続クラッシュするのを防ぐ
  • Type=simple:プロセスが起動したらすぐ「起動完了」とみなす(常駐プロセス向け)
  • User:root ではなく一般ユーザーで実行することで権限を最小化

サービスのデプロイ手順

全サービスファイルを一括でコピーしてから、各サービスを有効化します。

# 全サービスファイルを /etc/systemd/system/ にコピー
sudo cp /home/<username>/Projects/smarthome/config/services/* /etc/systemd/system/

# systemd に新しいサービスファイルを認識させる
sudo systemctl daemon-reload

# 各サービスを有効化・起動(例)
sudo systemctl enable smh-rain-forecast-alert
sudo systemctl start  smh-rain-forecast-alert

よく使う運用コマンド一覧

# 状態確認(直近のログも表示される)
sudo systemctl status smh-rain-forecast-alert

# 起動 / 停止 / 再起動
sudo systemctl start   smh-rain-forecast-alert
sudo systemctl stop    smh-rain-forecast-alert
sudo systemctl restart smh-rain-forecast-alert

# 自動起動の有効化 / 無効化
sudo systemctl enable  smh-rain-forecast-alert
sudo systemctl disable smh-rain-forecast-alert

# サービスファイルを編集した後は必ず daemon-reload
sudo systemctl daemon-reload

# 全サービスの状態を一覧表示(smh- 始まりのサービスだけ絞り込む)
systemctl list-units --type=service | grep smh-

サービスのログを確認する(journalctl)

# リアルタイムでログを流す(-f = follow)
sudo journalctl -u smh-rain-forecast-alert -f

# 直近100行だけ表示
sudo journalctl -u smh-rain-forecast-alert -n 100

動作確認

Bootstrap の動作確認

from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve()
while PROJECT_ROOT.name != "smarthome":
    PROJECT_ROOT = PROJECT_ROOT.parent
sys.path.insert(0, str(PROJECT_ROOT))

from libs.bootstrap import Bootstrap
bootstrap = Bootstrap(__file__)
print(bootstrap.project_root)   # → /home//Projects/smarthome
print(bootstrap.settings.keys()) # → settings.json のキー一覧

新規スクリプトの作成チェックリスト

  • 冒頭ボイラープレートを貼り付けて bootstrap.project_root が正しいか確認
  • smarthomelog.Log インスタンスを作成して write() が動作するか確認
  • influxwriter.Writer().insert_execution_log(__file__, 'Test!') で InfluxDB にデータが入るか確認
  • config/services/ にサービスファイルを追加し、daemon-reload → enable → start の手順でサービス化
  • systemctl statusActive: active (running) を確認

まとめ

本記事では、スマートホームプロジェクト全体で共通して使われる3つのライブラリと systemd サービス化の仕組みを解説しました。

  • Bootstrap が PROJECT_ROOT・sys.path・設定ファイル読み込みを自動化することで、スクリプトの場所に依存しない移植性の高い設計が実現できる
  • smarthomelog がファイルと InfluxDB に同時にログを記録することで、Grafana でのエラー可視化と死活監視が可能になる
  • InfluxDB への書き込みを内部 Web API 経由にすることで、接続先・認証情報の変更が1か所で完結する疎結合な設計になる
  • systemd の Restart=always で「クラッシュしても自動復旧」が保証され、24時間稼働のスマートホームシステムの信頼性が大幅に向上する
  • 全スクリプトが同じボイラープレート・try/except パターン・サービスファイル構造に従うことで、新しいスクリプトを追加するコストが最小化されている

この共通基盤があることで、個別のスマートホーム機能(雨の予報アラート・地震アラート・デバイス監視など)の実装に集中でき、インフラ的な処理を繰り返し書く必要がなくなります。スマートホームのスクリプトを増やしていく際の土台として、ぜひ参考にしてみてください。

タイトルとURLをコピーしました