「Raspberry Pi Pico 2 Wでつくる高速表示お天気時計:SDカード活用とフォント最適化の記録」

「Raspberry Pi Pico 2 Wでつくる高速表示お天気時計:SDカード活用とフォント最適化の記録」


0.使用環境

  • Raspberry Pi Pico 2 W with RP2350
  • MicroPython version='v1.27.0 on 2025-12-09
  • LCD:240x320 2.8インチ ili9341

    1. 導入:なぜいま、お天気時計を改良するのか?

  • 現状の課題: 以前作成したお天気時計において、「時刻更新時に文字の幅が変わって表示がガタつく」という微かな違和感があった。
  • 目指す姿: 人が近づいた時だけ素早く起動し、一瞬で現在時刻と天気を表示する「スマートな時計」を作りたい。

2. 課題①:フォントのガタつきを解消(固定幅フォントの導入)

  • 現象: 可変幅フォントを使用していたため、数字の「1」と「8」で横幅が異なり、更新時に座標がずれる。
  • 解決策:
    • 時計表示用に固定幅フォントを作成し、描画ロジックを最適化。
    • 更新時に不要なゴミが表示されないよう、背景塗りつぶしと描画のタイミングを制御。

3. 課題②:起動の高速化とメモリ節約(SDカードの活用)

  • 背景: バッテリー駆動のため、不要なときは電源OFF、人が来たらONという運用にしたい。起動後の描画を待たせないためには?
  • 実装:
    • サイズが大きいフォントデータやアイコン画像を、フラッシュメモリではなくSDカードへ外出し。
    • 必要なデータを必要な分だけダイレクトに読み出すことで、プログラム領域を圧迫せず、起動直後のレスポンスを確保。
    • os ライブラリとバイナリ読み込みを活用した、高速な描画システムの構築。

4. 本題:フォント・描画システムの最適化(技術的ポイント)

  • データ構造の工夫:
    • LCD Image Converter で作成した.cファイルを.binに変換。
    • 文字とインデックスの対応を JSON で管理し、辞書引きを高速化。
  • 高速化の秘訣:
    • ピクセル単位の描画ではなく、RAWバイナリを一括転送することで描画時間を劇的に短縮(前回の遅延を解消)。
    • SPIクロックの向上とファイルハンドルを保持する運用。

4-1.フォントデータの作成方法

  • LCD Image Converterで作成
    • インストール:About project | LCD Image Converter
    • 使い方
      • 起動画面

      • 初期設定と「文字幅固定」の設定

      • MSゴシック 16x16に設定

      • 16x16固定に設定

        • 固定設定:Monospacedに設定
        • 作成する文字列:デフォルトは半角英数字記号
        • 文字の追加:右上(「Source Font」の右側)の窓に追加する。
        • 文字サイズ:デフォルトはW1 H1。 16フォントを選択しても8x16サイズになっている場合がある。 16x16サイズしたい場合はW/16 H/16とする。
        • デフォルトは半角文字が入っているので、16x16にすると半角文字は隙間が空いてしまいます。この場合は全角文字に換えておいた方が良いです。

        OKをクリックするとFontというデータが作成されます

      • フォントの出力 「File」→「Convert]で、Font.cというファイルが出力されます。


4-2.フォントデータの変換:高速化のための「下準備」

LCD Image Converterが出力した.cファイルは、人間が読める「ソースコード」の状態です。これをそのままPicoで読み込むと、マイコン側で複雑な計算(解釈)が必要になり、表示までの時間がかかってしまいます。

そこで、「あらかじめマイコンが直接読み込める形式(バイナリ)」に変換しておくことで、処理を極限まで省く工夫をしました。

【変換のイメージ:レストランの料理】 * .cファイル(ソースコード)の状態: 材料がテーブルに散らばっている状態。マイコンが食べる前に、一つずつ並べて調理(計算)しなくてはなりません。 * .binファイル(変換後)の状態: 必要な料理がすべてお皿に盛り付けられた状態。マイコンはそのまま「食べる(表示する)」だけで済みます。

この「下準備」を済ませることで、起動した瞬間にパッと画面が表示される、あの軽快なレスポンスが実現できました。

【具体的な変換手順】 1. Pythonスクリプトによる抽出: .cファイルの中から、実際の点情報(ドットのON/OFF)が書かれた部分だけを抜き出します。 2. バイナリデータ化(.bin): 抜き出したデータを「1バイトの列」としてファイルに書き出します。 3. インデックス管理(.json): 「どの文字がどこにあるか」という場所情報を別ファイル(JSON)で保存します。これにより、マイコンは「〇〇という文字が欲しければ、ファイルの中の◯◯バイト目を見ればいい」と迷わずアクセスできるようになります。

これにより、Picoの限られたメモリを消費することなく、巨大な漢字フォントセットであっても一瞬で表示できるようになりました。

[変換プログラム:「.c」ファイル→「.json」ファイル] 「.c」ファイルからバイナリの「.json」ファイルへ変換するpythonプログラムを下記に示します。 PCで下記プログラム実行し事前に.jsonファイル(フォントデータのインデックスを記載したファイル)を作成しておきます。 このインデックスを使用することで、フォントデータ内の表示したいフォントを探す時間が短縮できるので、高速に表示できるようになります。

import re
import json

def generate_font_map_clean(c_file_path, json_output_path):
    with open(c_file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # 1. image_data で始まる行だけを対象にする(tImage は無視)
    # image_data_Font..._0xXX を探す
    pattern = r"static const uint8_t (image_data_.*?_0x([0-9a-fA-F]+))"
    matches = re.findall(pattern, content)
    
    # 2. 辞書を作成(インデックスを 0 からの連番にする)
    char_map = {}
    for i, match in enumerate(matches):
        full_name, hex_code = match
        char_val = int(hex_code, 16)
        
        # 制御文字などはchr()変換できない場合があるためスキップや保護
        try:
            char = chr(char_val)
            # 既に登録済みならスキップ(重複対策)
            if char not in char_map:
                char_map[char] = i 
        except:
            continue
        
    with open(json_output_path, 'w', encoding='utf-8') as f:
        json.dump(char_map, f, ensure_ascii=False, indent=4)
    
    print(f"抽出完了: {len(char_map)} 文字登録しました。")

font_name = "Font_time"

# 実行
generate_font_map_clean(font_name + '.c', font_name + '_map.json')

[変換プログラム:「.c」ファイル→「.bin」ファイル] 「.c」ファイルからバイナリの「.bin」ファイルへ変換するpythonプログラムを下記に示します。 PCで下記プログラム実行し事前に.binファイルを作成しておきます。データをバイナリ化することで、データサイズを抑え、LCDへの表示を高速化することができます。

import re

def convert_c_to_raw(c_file_path, output_bin_path, char_color, bg_color):
    # RGB565変換関数
    # 0xFFFF(白)を想定した簡易版
    # 0xR G B → 0xRRRRRGGGGGGBBBBB に変換
    def to_rgb565(color):
        # color が 0xFFFF のような場合、そのまま2バイトに
        return color.to_bytes(2, 'big')

    char_color_bytes = to_rgb565(char_color)
    bg_color_bytes = to_rgb565(bg_color)

    with open(c_file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # 修正: image_data_... の後の { から } までの間で、0x で始まる数値のみを抽出
    # ASCIIアートコメント "//" があっても無視するようにします
    pattern = r"static const uint8_t image_data_.*?\[.*?\] = \{(.*?)\};"
    matches = re.findall(pattern, content, re.DOTALL)

    with open(output_bin_path, 'wb') as f_out:
        for match in matches:
            # 0x で始まる文字列だけを抽出する
            hex_strings = re.findall(r"0x[0-9a-fA-F]{2}", match)
            
            for hex_str in hex_strings:
                byte_val = int(hex_str, 16)
                # 1バイト(8ビット)を8画素に展開
                for i in range(8):
                    # ビットが立っていれば文字色
                    # --- 修正後 ---
                    if (byte_val >> (7 - i)) & 1:
                        f_out.write(bg_color_bytes)   # 反転させるので背景色
                    else:
                        f_out.write(char_color_bytes) # 反転させるので文字色
    print(f"変換完了: {output_bin_path}")


font_name = 'Font_8x16'

# 実行例(白文字・黒背景にする場合)
# 色は0xFFFF(白)や0x0000(黒)など
convert_c_to_raw(font_name + '.c', font_name + '_raw.bin', 0xFFFF, 0x0000)

作成した「.bin」と「.json」ファイルはPico2Wへ接続するSDカードへ保存しておきます。


4-3. 描画を爆速にする「LCD専用データ変換」の工夫

単にフォントデータをバイナリ(.bin)にするだけでは、十分な高速化は達成できません。実は、LCDに表示する直前の形にデータを加工して保存しておくという、もう一歩踏み込んだ工夫をしています。

【何をしたのか?】 通常、LCD Image Converterが出力するデータは、PCやプログラムが扱いやすい「汎用的な並び順」になっています。しかし、これをそのままLCDに送ると、Pico側で「回転処理」や「バイトの並び替え(ピクセル変換)」という計算が発生し、それが描画のボトルネック(遅延)になります。

そこで、「LCDに送るデータそのもの」を変換スクリプト側で生成することにしました。

  1. LCDの「色データ形式(RGB565)」に変換済みのビット列を保存
    • 通常は描画時に「1bitの情報(点があるか)」を見てから「16bitの色情報」を計算しますが、これを変換段階で済ませておきます。
  2. LCDのメモリマップに直結する並び順で保持
    • LCDには「左上から右下へ」と特定の順番でデータを送り込む必要があります。.binファイルの中身を、LCDが「はい、受け取ったそのままの順番で画面に塗るよ!」と処理できる順番(RAWバイナリ形式)に並び替えて保存しました。

【結果:マイコンはただ「流し込む」だけ】 この工夫により、Picoの役割は「SDカードからデータを読み取って、そのまま通信路(SPI)へ流す」という単なる宅配便の仕事に徹することができるようになりました。 複雑な計算が一切不要になったため、以前とは比較にならない「待ち時間ゼロ」の高速表示が実現したのです。


4-3.フォントデータの表示:Pico2W側の処理

初期設定 - LCDの表示用ライブラリは以前書いたブログ記事と同じ「ili9341」をしようします。 - フォントデータはSDカードへ格納したのでSDカードの設定もここで行っています。 - LCDとSDカードは同じSPI0を使用しています。 - フォントデータをSDカードから読み出せるようにSDカードを「/SD」とマウントしておきます。

import ili9341
from machine import Pin,SPI
import os
from sdcard import SDCard

#SPI設定 SPI_0はLCD用、SPI_1はタッチセンサ、その他用
spi_0 = SPI(0, baudrate=40000000, sck=Pin(18), mosi=Pin(19))
#display = ili9341.Display(spi_0, dc=Pin(17), cs=Pin(16), rst=Pin(20),rotation=270)
display = ili9341.Display(spi_0, dc=Pin(17), cs=Pin(16), rst=Pin(20),rotation=90, width=320, height=240)

display.clear()  # 画面をクリアする
#display.fill_rectangle(0, 0, 320, 240, ili9341.color565(0, 0, 0))  # 黒色で塗りつぶす

SD_spi = SPI(0, baudrate=10000000, polarity=0, phase=0, 
                 sck=Pin(6), mosi=Pin(7), miso=Pin(4))
SD_cs = Pin(5, Pin.OUT)
sd = SDCard(SD_spi, SD_cs)
    
# 念のため一度アンマウントして、確実にマウントし直す
os.mount(sd, '/sd')

今回作成した表示プログラム - 実際使用しているのはフォント初期化「(init()」と高速描画「display_string_fast()」です。

class RawFontRenderer:
    def __init__(self, lcd, raw_font_file, width, height):
        self.lcd = lcd
        self.font_file = raw_font_file
        self.w = width
        self.h = height
        # RAWデータは1ピクセル2バイトなので、1文字のサイズは (w * h * 2) バイト
        self.bytes_per_char = width * height * 2
        # 初期化時にファイルを開きっぱなしにする
        self.f = open(raw_font_file, 'rb') 

    def display_char(self, x, y, char_index):
        # 1. 範囲指定(テストのために最小限に)
        # set_windowがうまく動いていない可能性があるので、直接コマンドを叩いてみる
        self.lcd.write_cmd(0x2A) # Column Address Set
        self.lcd.write_data(x.to_bytes(2, 'big') + (x + self.w - 1).to_bytes(2, 'big'))
        self.lcd.write_cmd(0x2B) # Page Address Set
        self.lcd.write_data(y.to_bytes(2, 'big') + (y + self.h - 1).to_bytes(2, 'big'))
        
        # 2. 書き込み
        self.lcd.write_cmd(0x2C) # RAM Write
        
        # ファイルの存在とインデックスの計算チェック
        self.f.seek(char_index * self.bytes_per_char)
        bytes_left = self.bytes_per_char
        
        #while bytes_left > 0:
        #    chunk = self.f.read(min(bytes_left, 512))
        #    self.lcd.write_data(chunk)
        #    bytes_left -= len(chunk)
        
        # 512バイトずつではなく、一気に1文字分全データを書き込む方が速い
        # ただしRAM不足に注意。32x72=4608バイト(約4.5KB)なので、全読みでOK!
        data = self.f.read(self.bytes_per_char)
        self.lcd.write_data(data)

    def display_string(self, text, start_x, y, char_map, spacing=2):
        """
        連続した文字列を高速表示する
        text: 表示したい文字列
        start_x: 開始X座標
        y: 開始Y座標
        char_map: 文字とインデックスの辞書
        spacing: 文字間隔
        """
        current_x = start_x
        for char in text:
            idx = char_map.get(char)
            if idx is not None:
                # 1文字ずつ爆速描画
                self.display_char(current_x, y, idx)
                current_x += (self.w + spacing)
            
            # 画面右端を超えないようにチェック
            if current_x + self.w > 320:
                break

    def display_string_fast(self, text, start_x, y, char_map, spacing=0):
            cursor_x = start_x
            for char in text:
                idx = char_map.get(char)
                if idx is None: continue
                
                # 保持しているファイルポインタを使用
                self.f.seek(idx * self.bytes_per_char)
                glyph = self.f.read(self.bytes_per_char)
                
                # LCDに一括転送
                self.lcd.block(cursor_x, y, cursor_x + self.w - 1, y + self.h - 1, glyph)
                
                cursor_x += (self.w + spacing)

フォントの初期化 - LCDアクセスのSPI設定が、「display」、フォントデータが「 '/sd/Font_8x16_raw.bin'」、フォントサイズが8x16「 8, 16」の場合は下記の用の初期化します。  RawFontRenderer(display, '/sd/Font_8x16_raw.bin', 8, 16)

#--------------------------------------------------------------
# 各サイズのレンダラーを作成
renderer_8x16_raw  = RawFontRenderer(display, '/sd/Font_8x16_raw.bin', 8, 16)
renderer_24x48_raw = RawFontRenderer(display, '/sd/Font_24x48_raw.bin', 24, 48)
renderer_32x32_raw = RawFontRenderer(display, '/sd/Pico2W_32x32_Font_raw.bin', 32, 32)
renderer_48x48_raw = RawFontRenderer(display, '/sd/Pico2W_48x48_Font_raw.bin', 48, 48)
renderer_32x72_raw = RawFontRenderer(display, '/sd/Font_time_raw.bin', 32, 72)

import ujson
def load_char_map(json_path):
    with open(json_path, 'r', encoding='utf-8') as f:
        return ujson.load(f)
  • フォントのインデックス(マップ―データ)は下記のように定義します
import ujson
def load_char_map(json_path):
    with open(json_path, 'r', encoding='utf-8') as f:
        return ujson.load(f)

# フォントマップデータ設定
map_8x16  = load_char_map('/sd/Font_8x16_map.json')
map_24x48 = load_char_map('/sd/Font_24x48_map.json')
map_32x32 = load_char_map('/sd/Pico2W_32x32_Font_map.json')
map_48x48 = load_char_map('/sd/Pico2W_48x48_Font_map.json')
map_32x72 = load_char_map('/sd/Font_time_map.json')

フォントの表示例

    #年月日表示
    renderer_8x16_raw.display_string_fast(now_day, 8, 8, map_8x16,spacing=-0)
    #バージョン表示
    renderer_8x16_raw.display_string_fast(version, 32*8+8 , 0, map_8x16)
    #時刻表示
    renderer_32x72_raw.display_string_fast(now_time, 8, 32, map_32x72)

    #温度の表示
    renderer_24x48_raw.display_string_fast(temp, 32*5+24, 16+4, map_24x48,spacing=-3)
    renderer_48x48_raw.display_string_fast("℃", 32*5+12+24*4, 16+4, map_48x48)
    
    #湿度の表示
    renderer_24x48_raw.display_string_fast(hum, 32*5+24, 16+48, map_24x48,spacing=-3)
    renderer_48x48_raw.display_string_fast("%",32*5+12+24*4, 16+48, map_48x48)

5. 成果:Pico 2 Wで実現した「待ち時間ゼロ」の体験

  • 実際に動作している様子(写真や動画があればベスト)。
  • 電源OFFから時刻が表示されるまでの爆速感。
  • 漢字表記にも対応したことで、情報量が増えより見やすくなったUI。

前期の表示速度(1bitフォント、フラッシュに格納)

表示速度(推測):1文字あたり約40ms

今回の表示速度1(Rawフォント、SDカードに格納)

表示速度(推測):1文字あたり約100ms

今回の表示速度2(Rawフォント、フラッシュに格納)

表示速度(推測):1文字あたり約15ms


6. まとめ

【実験結果:描画速度】 1. 1bitフラッシュ (40ms): 「CPUの計算力不足」 → 計算処理が重く、描画に時間がかかっていた。

  1. Rawフラッシュ (15ms): 「通信速度の限界」 → 描画データを「LCD直結形式」に変換することで、CPU負荷をゼロにし、約2.7倍の高速化に成功。

  2. Raw SDカード (100ms): 「ストレージの物理的応答速度」 → 大容量化のためにSDカードへデータを外出ししたが、外部ストレージ特有の「読み出し待ち」が発生。

以下の表を記事内に挿入すると、技術的な比較が一目でわかります。

フォント構成 転送形式 置場所 描画速度 評価
旧(1bit) 計算型 フラッシュ 40ms 計算が重い
新(Raw) 直接転送 フラッシュ 15ms 最高速
新(Raw) 直接転送 SDカード 100ms 大容量だが低速

結論:

項目 LCD表示速度 フラッシュ容量
Raw化(計算を捨てる) 爆速(15ms) 圧迫
SDカード化(ストレージ外出し) 低速(100ms) 節約

「計算を捨てる(Raw化)」ことで表示は爆速になるが、「データの置き場所(ストレージ)」が速度を決める。リアルタイムな時計にはフラッシュ、大容量な漢字にはSDカードという「適材適所」の設計が重要である。

今回の実験を通じて、『速度』を求めるならフラッシュ直結、『拡張性(大容量)』を求めるならSDカードという、物理的な制約を逆手に取った設計思想が重要だと学びました。現在は速度を優先してフラッシュ版で運用していますが、今後はSDカード版をさらに突き詰め、よく使うデータだけをRAMにキャッシュする『ハイブリッド運用』を目指したいと思います。

Raspberry Pi Pico 2 W (RP2350) でSDカードを読み書きする方法

Raspberry Pi Pico 2 W (RP2350) でSDカードを読み書きする方法

はじめに

Raspberry Pi Pico 2 W(RP2350)でSDカードを使いたいと考え、MicroPythonで実装を試みました。以前はなかなかうまくいかず苦戦していましたが、最新のMicroPythonファームウェア(v1.29.0)と、外部ライブラリ sdcard.py を組み合わせることで、ついに安定したアクセスに成功しました。

備忘録として、成功した接続方法とコードを共有します。

1. 必要なもの

SDカードモジュール Pico 2 W ピン 備考
VCC 3.3V
GND GND
MISO (DO) GP4 SPI0 RX
MOSI (DI) GP7 SPI0 TX
SCK (CLK) GP6 SPI0 SCK
CS GP5 任意のGPIO

接続した状態

3. 実装手順

ライブラリの準備

RP2350用のMicroPythonには machine.SDCard が含まれていない場合があるため、公式の sdcard.py を使用します。

  1. micropython-lib (sdcard.py) からコードをコピー。
  2. Pico 2 Wのファイルシステムに sdcard.py として保存。

テストコード

以下のコードでマウントと読み書きのテストを行います。

import machine
import os
from sdcard import SDCard # 自分で保存したライブラリを読み込む

# SPI0の設定
# MISO(RX)をGP4に指定
spi = machine.SPI(0, 
                  baudrate=1000000, 
                  polarity=0, 
                  phase=0, 
                  sck=machine.Pin(6), 
                  mosi=machine.Pin(7), 
                  miso=machine.Pin(4)) # GP4を指定

cs = machine.Pin(5, machine.Pin.OUT)

# SDカードの初期化
try:
    # SDCardクラスの引数にspiとcsを渡す
    sd = SDCard(spi, cs)
    
    # マウントポイントを作成してマウント
    os.mount(sd, '/sd')
    print("SDカードのマウントに成功しました!")
    
    # ファイル書き込みテスト
    with open('/sd/test.txt', 'w') as f:
        f.write('Hello, Raspberry Pi Pico 2 W!')
    print("ファイル書き込み成功")
    
    # ファイル読み込みテスト
    with open('/sd/test.txt', 'r') as f:
        content = f.read()
        print("読み込み結果:", content)
        
except Exception as e:
    print("エラーが発生しました:", e)
    print("ヒント: 配線を確認し、SDカードがFAT32でフォーマットされているか確認してください。")

補足: もし通信が不安定な場合は、baudrate を 100000 (100kHz) に下げて試してみてください。安定して動作するようになったら、このコードのように 1000000 (1MHz) 以上に引き上げるのがおすすめです。

実行結果

4. 成功のポイント

  • ファームウェアの更新: MicroPython v1.29.0 を使用することで、RP2350のSPI制御が安定しました。
  • ライブラリの活用: machine.SDCard が見つからない場合は、無理に標準機能にこだわらず、sdcard.py を直接インポートするのが確実です。
  • 通信速度: 初期化時は 100000 (100kHz) で行い、成功後に 1000000 (1MHz) 以上へ引き上げると安定します。

まとめ

RP2350は新しいチップですが、ライブラリを適切に配置することで、従来のPicoと同様にSDカードをストレージとして活用できることが確認できました。これでデータロガーやファイルサーバーなど、活用の幅が大きく広がります!

Raspberry Pi Pico 2 Wで実践!バッテリ低下をDiscordに自動通知する見守りセンサーシステムの作り方

Raspberry Pi Pico 2 Wで実践!バッテリ低下をDiscordに自動通知する見守りセンサーシステムの作り方

遠隔地に設置したIoTデバイスを長期間運用する際、最も怖いのが突然のバッテリ切れによるシステム停止です。今回は、実際の稼働データをもとに測定ばらつきやしきい値を検証し、Raspberry Pi Pico 2 WとFRAM(不揮発性メモリ)を組み合わせて「バッテリ低下の自動通知システム」を構築しました。その設計思想と具体的な実装手順をご紹介します。

実測データから決定した電圧しきい値の仕様

実際にシステムを試運転させて実測データを集めたところ、電圧の測定値にはわずかな「測定ばらつき」が生じることが分かりました。このばらつきのせいで、まだバッテリがあるのに誤って警告が出てしまったり、逆に気づくのが遅れたりするのを防ぐため、実測値をもとに以下の仕様を固めました。

  • 警告しきい値(< 2.60V): 直近3回の平均値がこの値を下回った場合、バッテリ低下と判定して警告を発報します。
  • 復旧しきい値(≥ 2.80V): 充電や自然回復によって安全な電圧まで戻ったことを確認する基準値です。

システムを支える3つのこだわり機能

実運用でのストレスを無くすため、プログラムには次の仕組みを取り入れています。

  • 3回平均による「測定ばらつき」対策: 1回ごとの一時的なブレに惑わされないよう、直近3回の平均値で判定を行います。
  • 6時間ロックアウト機能: 一度警告を出したあと、短時間に何度も通知が飛んでスマートフォンが通知だらけになるスパムを防ぎます。
  • 復帰時の自動解除: 電圧が正常値($2.80/text{V}$以上)に回復したタイミングで、ロックアウト状態を自動でリセット。次にまたバッテリが低下した際、すぐに再通知されるようにしています。

Discord Webhookの準備手順

今回のシステムでは、手軽にスマホへプッシュ通知を送るために「Discord」を利用します。初心者の方に向けて、アカウント作成から通知用URL(Webhook URL)の取得までの流れを解説します。

1. Discordアカウントの作成

  • パソコンまたはスマートフォンのブラウザから Discord公式サイト にアクセスし、無料のアカウントを作成します。アプリ版をインストールしておくとスマホでの通知受取に便利です。

2. 通知用サーバーの作成

  • Discordアプリを開き、左側のメニューにある「+」ボタン(サーバーを追加)を押し、「オリジナルの作成」から自分専用のサーバー(例: 「IoT見守り用」など)を作ります。
  • サーバー内にテキストチャンネル(例: #sensor-log など)を新しく作成します。

3. Webhook URLの取得

  • 作成したテキストチャンネル名の右側にある歯車マーク(チャンネルの編集)をクリックします。
  • メニューの「連携サービス」(または「統合」)を選択し、「ウェブフックを作成」をクリックします。
  • 表示されたウェブフックの名前を確認し、「Webhook URLをコピー」ボタンを押します。このコピーしたURLが、Pico 2 Wからメッセージを飛ばすための「宛先住所」になります。

実装コードと解説

実際にPico 2 W上で動かしている、バッテリ監視とDiscord通知を行う核のプログラムです。

import urequests
import json

# 設定項目
DISCORD_WEBHOOK_URL = "取得したWebhook URLをここに貼り付けます"
VOLT_WARN_THRESH = 2.60   # 警告しきい値
VOLT_RECOV_THRESH = 2.80  # 復旧しきい値
NOTIFICATION_INTERVAL = 6 * 3600  # 6時間(秒)

FRAM_ADDR_VOLT_HISTORY = 0x0020  # 電圧履歴保存用アドレス
FRAM_ADDR_LAST_NOTIFY = 0x0030   # 前回通知時刻保存用アドレス

def send_discord_notification(message):
    """Discordへ確実に通知を送信する関数"""
    headers = {"Content-Type": "application/json"}
    payload = json.dumps({"content": message}).encode('utf-8')
    
    try:
        response = urequests.post(DISCORD_WEBHOOK_URL, data=payload, headers=headers)
        response.close()
        print("→ Discord通知送信成功")
    except Exception as e:
        # MicroPython特有のSSL切断検知エラーをバイパス
        if "-29312" in str(e) or "MBEDTLS_ERR_SSL_CONN_EOF" in str(e):
            print("→ 通知送信成功(SSL切断検知をバイパス)")
        else:
            print(f"送信エラー: {e}")

def check_and_handle_battery(current_vsys, current_epoch_time, fram_driver):
    """バッテリ電圧の平均化、しきい値判定、ロックアウト制御を行う関数"""
    volt_history = []
    try:
        stored_data = fram_driver.read(FRAM_ADDR_VOLT_HISTORY, 6)
        if stored_data and len(stored_data) == 6:
            for i in range(0, 6, 2):
                mv = int.from_bytes(stored_data[i:i+2], 'big')
                if mv > 0:
                    volt_history.append(mv)
    except:
        volt_history = []

    # 現在の電圧を追加し、直近3回分を保持
    current_mv = int(current_vsys * 1000)
    volt_history.append(current_mv)
    if len(volt_history) > 3:
        volt_history.pop(0)
    
    # FRAMに履歴を保存
    buffer = bytearray()
    for mv in volt_history:
        buffer.extend(mv.to_bytes(2, 'big'))
    while len(buffer) < 6:
        buffer.extend((0).to_bytes(2, 'big'))
    fram_driver.write(FRAM_ADDR_VOLT_HISTORY, list(buffer))

    # 3回平均の算出
    avg_vsys = (sum(volt_history) / len(volt_history)) / 1000.0
    print(f"現在電圧: {current_vsys:.2f}V / 3回平均: {avg_vsys:.2f}V")

    # 前回通知時刻の確認
    try:
        last_time_bytes = fram_driver.read(FRAM_ADDR_LAST_NOTIFY, 4)
        last_notify_time = int.from_bytes(last_time_bytes, 'big')
    except:
        last_notify_time = 0

    elapsed_time = current_epoch_time - last_notify_time

    # 判定ロジック
    if avg_vsys < VOLT_WARN_THRESH:
        # 初回または6時間経過していれば通知
        if last_notify_time == 0 or elapsed_time >= NOTIFICATION_INTERVAL:
            send_discord_notification(f"【警告】Pico2Wバッテリ低下: 平均 {avg_vsys:.2f}V (交換が必要です)")
            time_bytes = int(current_epoch_time).to_bytes(4, 'big')
            fram_driver.write(FRAM_ADDR_LAST_NOTIFY, list(time_bytes))
        else:
            print(f"→ バッテリ低下ですが、6時間ロックアウト中です(経過: {elapsed_time/3600:.1f}時間)")
            
    elif avg_vsys >= VOLT_RECOV_THRESH:
        # 電圧が復帰したらロックアウトを自動解除
        if last_notify_time > 0:
            print("→ バッテリ電圧が正常値に復帰しました。ロックアウトを解除します。")
            fram_driver.write(FRAM_ADDR_LAST_NOTIFY, [0, 0, 0, 0])

コードのポイント解説

  1. 履歴の保持(FRAM): Picoが一度スリープや電源オフで切れてもデータを維持できるよう、外部のFRAMチップに直近3回分の電圧を保存しています。
  2. 安全なHTTP送信: MicroPythonの urequests を用いつつ、SSL通信特有の切断エラーをハンドリングして確実にメッセージが届くように工夫しています。

実測データに基づいてしきい値を詰め、スパム防止や自動復帰の仕組みを入れたことで、非常に安定した運用ができるようになりました。遠隔地のセンサー管理にお悩みの方は、ぜひ参考にしてみてください!

【Raspberry Pi Zero 2 W】SDカード破損に備える!毎日深夜0:30にSambaへ超高速差分バックアップする自動化スクリプト

【Raspberry Pi Zero 2 W】SDカード破損に備える!毎日深夜0:30にSambaへ超高速差分バックアップする自動化スクリプト

--- Samba共有エラーを回避する安定版---

Raspberry Pi Zero 2 W(以下、Zero 2 W)を運用していて、「突然SDカードが壊れた」「Thonnyで書いていたPythonプログラムや設定ファイルが消えてしまった」というトラブルを経験したことはありませんか?

Zero 2 Wは非常にコンパクトで便利ですが、Wi-Fi経由の通信になるため、SDカードを丸ごとイメージ化するような重いバックアップはネットワークや本体に大きな負荷がかかります。

前回、ローカルのSamba(ファイルサーバー)へ差分バックアップする仕組みを紹介しましたが、運用していく中でWindows共有(Samba)の仕様による細かいエラー(コード23や特殊なシステムファイルの非対応など)に直面しました。 今回は、それらのエラーを完全に回避し、ログまでスッキリ美しく整えた「完全安定版」の自動バックアップ構築手順をお届けします。


1. システム構成と全体設計

今回のバックアップ環境の設計は以下の通りです。

  • バックアップ元(Zero 2 W): /(システム全体。ただし、一時ファイルや仮想ディレクトリ、競合するシステムパーツは除外)

  • バックアップ先(Samba): //raspberrypi/backup/Zero2W_02

  • 使用ツール:

  • cifs-utils(Sambaマウント用:mfsymlinksオプションでシンボリックリンク完全対応)

  • rsync(差分バックアップ用:--ignore-errorsで軽微な属性エラーを完全にクリア)

  • cron(rootによる自動化)

  • 実行時間: 毎日 00:30

💡 なぜ「深夜0:30」なのか?

自宅サーバーで複数のバックアップを運用する場合、実行時間が重なるとネットワークやディスクの処理(I/O)が競合してシステムが重くなってしまいます。 そのため、他のバックアップスケジュールと重ならないよう、時間をずらして設計しています。

  • 00:30:Zero 2 W の Sambaバックアップ(今回設定)

  • 03:00:SDカードのバックアップ(03:15頃終了)

  • 03:30:メインサーバーからクラウドへのバックアップ


2. 構築手順

ステップ1:必要なパッケージのインストールとフォルダ作成

Zero 2 Wのターミナルを開き、以下のコマンドを実行します。

# 必要なパッケージのインストール
sudo apt update
sudo apt install cifs-utils rsync -y

# マウント先フォルダの作成
sudo mkdir -p /mnt/samba_backup

ステップ2:バックアップスクリプトの作成(エラー完全対策版)

自動処理を行うシェルスクリプトを作成します。

sudo nano /usr/local/bin/backup_to_samba.sh

開いたエディタに以下の内容を貼り付けます。 ※ あなたのSambaユーザー名 と あなたのSambaパスワード は、ご自身の環境に合わせて書き換えてください。

#!/bin/bash

# ==========================================
# 設定項目
# ==========================================
BACKUP_SRC="/"
MOUNT_POINT="/mnt/samba_backup"
SAMBA_SHARE="//raspberrypi/backup/Zero2W_02"
SAMBA_USER="あなたのSambaユーザー名"
SAMBA_PASS="あなたのSambaパスワード"
LOG_FILE="/var/log/zero2w_backup.log"

echo "=== Backup started at $(date) ===" >> "$LOG_FILE"

# 1. マウントポイントの作成(念のため)
mkdir -p "$MOUNT_POINT"

# 2. Sambaをマウント(mfsymlinksオプションでシンボリックリンクを完璧に維持)
if ! mountpoint -q "$MOUNT_POINT"; then
    echo "Mounting Samba share..." >> "$LOG_FILE"
    mount -t cifs -o username="$SAMBA_USER",password="$SAMBA_PASS",vers=3.0,iocharset=utf8,mfsymlinks,uid=1000,gid=1000 "$SAMBA_SHARE" "$MOUNT_POINT" >> "$LOG_FILE" 2>&1
    if [ $? -ne 0 ]; then
        echo "ERROR: Failed to mount Samba share. Backup aborted." >> "$LOG_FILE"
        exit 1
    fi
fi

# 3. rsyncによる差分バックアップ(Samba非対応ファイルやキャッシュを完全に除外)
echo "Running rsync..." >> "$LOG_FILE"
rsync -a --delete --ignore-errors \
  --exclude="/dev/*" \
  --exclude="/proc/*" \
  --exclude="/sys/*" \
  --exclude="/tmp/*" \
  --exclude="/var/tmp/*" \
  --exclude="/var/log/journal" \
  --exclude="/home/*/.cache/*" \
  --exclude="/usr/share/perl/*/pod/" \
  --exclude="/run/*" \
  --exclude="/mnt/*" \
  --exclude="/media/*" \
  --exclude="/lost+found" \
  "$BACKUP_SRC" "$MOUNT_POINT/" >> "$LOG_FILE" 2>&1

if [ $? -eq 0 ]; then
    echo "Backup completed successfully." >> "$LOG_FILE"
else
    echo "WARNING: Backup completed with some errors." >> "$LOG_FILE"
fi

# 4. 安全のためアンマウント
echo "Unmounting Samba share..." >> "$LOG_FILE"
umount "$MOUNT_POINT" >> "$LOG_FILE" 2>&1

echo "=== Backup ended at $(date) ===" >> "$LOG_FILE"

保存して閉じるには、Ctrl+O ➡ Enter ➡ Ctrl+X の順にキーを押します。

スクリプトに実行権限を与えます。

sudo chmod +x /usr/local/bin/backup_to_samba.sh

ステップ3:手動での動作テスト

一度、手動でスクリプトを実行して正しくSambaにバックアップされるか確認します。

sudo /usr/local/bin/backup_to_samba.sh

ログファイル /var/log/zero2w_backup.log を確認し、Backup completed successfully. と出力されていれば大成功です!

ステップ4:cronによる自動化(毎日 00:30)

Raspberry Pi OS (Bookworm) でも確実に動作させるため、rootのcrontabに登録します。

sudo crontab -e

ファイルの最下行に以下を追加します。

30 0 * * * /bin/bash /usr/local/bin/backup_to_samba.sh > /dev/null 2>&1

保存して閉じれば、設定完了です! これで毎日深夜 00:30 に自動的に差分バックアップが走り、エラーや余計な警告を一切出すことなく綺麗に同期されます。


3. 万が一、SDカードが壊れたときの復旧方法

このバックアップは「ファイル単位」でそのまま保存されているため、復旧作業は非常にシンプルです。

  1. 新しいSDカードに、Raspberry Pi OSをクリーンインストールする。

  2. Sambaサーバーから、消えてしまったプログラムや必要な設定ファイルを新しいSDカードに書き戻す。

OS丸ごとのイメージ復元と違い、必要なファイルだけをサクッと書き戻せるため、復旧時間も大幅に短縮できます。


まとめ

今回のブラッシュアップにより、Samba共有特有のメタデータ制限や特殊文字エラーを完全に回避し、「エラーゼロ・低負荷・超高速・全自動」の理想的なバックアップ環境が完成しました。 SDカードの突然のトラブルに備えて、ぜひこの安定版を取り入れてみてください!これまでの試行錯誤で得られた知見(Sambaのシンボリックリンク対応、不要なシステムファイルやPerlドキュメントの除外、クリーンな終了処理)をすべて反映した、アップデート版のブログ記事全文を作成しました。そのままコピーして既存の記事と置き換えることで、トラブルフリーな決定版として公開できます。

お天気時計の作成(その11:気象庁API移行とエラー対応・完全版)

お天気時計の作成(その11:気象庁API移行とエラー対応・完全版)

これまで「お天気時計」のデータ取得には、livedoor天気の互換APIである「つくみじま(Tsukumijima)氏の天気予報API」を利用させていただいていました。 しかし、APIへのアクセスが不調となったため、今回は気象庁(JMA)のJSON APIから直接データを取得する方式へライブラリ(jma_weather_get.py)を書き換えて更新を行いました。

以前作成したコードとの互換性を保ちつつ、画像取得エラーの防止や表示テキストの最適化を行いましたので、備忘録としてまとめます。


1. 移行にあたって対応した課題

気象庁APIへの移行時に直面した主な課題と、今回の修正で対応したポイントです。

  • 表示テキストの長文化(203などの天気コード対応)
  • 気象庁APIの生テキスト(raw_weather_text)を取得すると「くもり のはじめ頃 雨 所により...」のような極めて長い文字列が返ってくる場合があります。全天気コード(100〜400番台)を辞書(code_map)で網羅し、「曇時々雨」といった短縮表現へ確実に変換するよう対応しました。

  • 特定の天気用アイコンが存在せず404エラーになる

  • 気象庁の画像サーバーには一部の天気コード(例: 111 や 211 など)に対応するSVG画像が存在しません。基本コード(例: 101 や 201)へマッピングを差し替える安全策を組み込みました。

  • **降水確率取得時の UnboundLocalError**

  • 時刻判定ループで変数 cor が未定義のまま参照されるエラーを防ぐため、関数の冒頭で初期値を設定し例外処理を強化しました。

  • **メインプログラム側の ValueError: invalid literal for int()**

  • アイコンURLの末尾から天気コード(数値)を抽出する際、URLフォーマットの差異で空文字となり数値変換エラーが発生していました。返却する weather_url のフォーマットを統一して解消しました。

2. 修正後のPythonライブラリ (lib/jma_weather_get.py)

上記すべての課題に対応し、エラー落ちを防ぐガード処理を追加した最新のライブラリコードです。

import datetime
import os
import re
import urllib.request
import cairosvg
from PIL import Image
import requests


def weather_get():
    # 変数の初期化(エラー時の未定義を確実に防止)
    cor = "0%"
    weather = "不明"
    weather_url = "https://www.jma.go.jp/bosai/forecast/img/100.svg"

    # 140000 = 神奈川県(横浜)の気象庁エリアコード
    area_code = "140000"
    url = f"https://www.jma.go.jp/bosai/forecast/data/forecast/{area_code}.json"

    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/120.0.0.0 Safari/537.36"
        )
    }

    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
    except requests.exceptions.RequestException as e:
        print("Error:{}".format(e))
        return cor, weather, weather_url

    else:
        weather_json = response.json()

        # --- 1. 天気と天気コードの取得と変換 ---
        area_weather = weather_json[0]["timeSeries"][0]["areas"][0]
        raw_weather_text = area_weather["weathers"][0]
        raw_code = str(area_weather["weatherCodes"][0]).strip()

        # コードに基づく綺麗な天気名の変換マッピング(全コード網羅版)
        code_map = {
            # 晴れベース (100番台)
            "100": "晴れ",
            "101": "晴時々曇",
            "102": "晴一時雨",
            "103": "晴時々雨",
            "104": "晴一時雪",
            "105": "晴時々雪",
            "106": "晴一時雨か雪",
            "107": "晴時々雨か雪",
            "108": "晴一時雨か雷雨",
            "110": "晴後曇",
            "111": "晴後時々雨",
            "112": "晴後雨",
            "113": "晴後時々雪",
            "114": "晴後雪",
            "115": "晴後雨か雪",
            "116": "晴後雨か雷雨",
            "117": "晴後雷雨",
            "118": "晴後雨か雪",
            "119": "晴後雨か雷雨",
            "120": "晴後雨",
            "121": "晴後雨",
            "122": "晴後雨",
            "123": "晴後山沿い雷雨",
            "124": "晴後山沿い雪",
            "125": "晴後雷雨",
            "126": "晴後雨か雪",
            "127": "晴後雨か雷雨",
            "128": "晴後雨か雪",
            "130": "朝のうち霧後晴",
            "131": "晴明け方一時雨",
            "132": "晴朝晩一時雨",
            "140": "晴時々雨か雪",
            "160": "晴一時雪か雨",
            "170": "晴時々雪か雨",
            "181": "晴後特記事項なし",
            # 曇りベース (200番台)
            "200": "曇り",
            "201": "曇時々晴",
            "202": "曇一時雨",
            "203": "曇時々雨",
            "204": "曇一時雪",
            "205": "曇時々雪",
            "206": "曇一時雨か雪",
            "207": "曇時々雨か雪",
            "208": "曇一時雨か雷雨",
            "209": "霧",
            "210": "曇後晴",
            "211": "曇後時々雨",
            "212": "曇後雨",
            "213": "曇後時々雪",
            "214": "曇後雪",
            "215": "曇後雨か雪",
            "216": "曇後雨か雷雨",
            "217": "曇後雷雨",
            "218": "曇後雨か雪",
            "219": "曇後雨か雷雨",
            "220": "曇後雨",
            "221": "曇後雨",
            "222": "曇後雨",
            "223": "曇後日中時々晴",
            "224": "曇後昼頃から雨",
            "225": "曇後夕方から雨",
            "226": "曇後夜雨",
            "228": "曇後雪",
            "229": "曇後雪",
            "230": "曇後雪",
            "231": "曇海上海岸霧かモヤ",
            "240": "曇時々雨か雪",
            "250": "曇時々雨か雷雨",
            "260": "曇一時雪か雨",
            "270": "曇時々雪か雨",
            "281": "曇後特記事項なし",
            # 雨ベース (300番台)
            "300": "雨",
            "301": "雨時々晴",
            "302": "雨時々止む",
            "303": "雨時々雪",
            "304": "雨か雪",
            "306": "大雨",
            "308": "雨か雷雨",
            "309": "雨一時雪",
            "311": "雨後晴",
            "313": "雨後曇",
            "314": "雨後時々雪",
            "315": "雨後雪",
            "316": "雨後雨か雪",
            "317": "雨後雷雨",
            "320": "朝のうち雨後晴",
            "321": "朝のうち雨後曇",
            "322": "雨後雪か雨",
            "323": "雨後昼頃から晴",
            "324": "雨後夕方から晴",
            "325": "雨後夜は晴",
            "326": "雨後雪",
            "327": "雨後雪",
            "328": "雨一時雪",
            "329": "雨一時雪",
            "340": "雪か雨",
            "350": "雨か雷雨",
            "361": "一部に雨",
            "371": "一部に雨か雪",
            # 雪ベース (400番台)
            "400": "雪",
            "401": "雪時々晴",
            "402": "雪時々止む",
            "403": "雪時々雨",
            "405": "大雪",
            "406": "風雪強い",
            "407": "暴風雪",
            "409": "雪一時雨",
            "411": "雪後晴",
            "413": "雪後曇",
            "414": "雪後雨",
            "420": "朝のうち雪後晴",
            "421": "朝のうち雪後曇",
            "422": "雪後雨か雪",
            "423": "雪後雨",
            "425": "雪後晴",
            "426": "雪後曇",
            "427": "雪後雨",
            "450": "大雪",
        }

        if raw_code in code_map:
            weather = code_map[raw_code]
        elif raw_code.startswith("1"):
            weather = "晴れ"
        elif raw_code.startswith("2"):
            weather = "曇り"
        elif raw_code.startswith("3"):
            weather = "雨"
        elif raw_code.startswith("4"):
            weather = "雪"
        else:
            weather = "不明"

        # --- 2. 安全なアイコンURLの生成 (404対策) ---
        icon_map = {
            "111": "101",
            "112": "110",
            "211": "201",
            "311": "301",
        }
        safe_code = (
            icon_map.get(raw_code, raw_code[0] + "00")
            if len(raw_code) == 3
            else "100"
        )
        weather_url = (
            f"https://www.jma.go.jp/bosai/forecast/img/{safe_code}.svg"
        )

        os.makedirs("./icon", exist_ok=True)

        # --- 3. アイコン画像のダウンロード ---
        try:
            req = urllib.request.Request(weather_url, headers=headers)
            with urllib.request.urlopen(req) as res_img:
                img_data = res_img.read()
                with open("./icon/temp.svg", "wb") as f:
                    f.write(img_data)
                with open("./icon/" + weather_url[-7:], "wb") as f:
                    f.write(img_data)

            # --- 4. SVG -> PNG 変換 ---
            os.environ["LC_CTYPE"] = "ja_JP.UTF-8"
            svg = "./icon/temp.svg"
            png = "./icon/temp.png"

            cairosvg.svg2png(url=svg, write_to=png)
            img = Image.open(png)
        except Exception as img_err:
            print("Image Processing Error:{}".format(img_err))

        # --- 5. 現在の時間帯の降水確率を取得 ---
        try:
            pop_series = weather_json[0]["timeSeries"][1]
            pop_times = pop_series["timeDefines"]
            pop_values = pop_series["areas"][0]["pops"]

            now_utc = datetime.datetime.now(datetime.timezone.utc)

            if pop_values:
                cor = f"{pop_values[0]}%"

            for i, time_str in enumerate(pop_times):
                target_time = datetime.datetime.fromisoformat(time_str)
                if now_utc >= target_time and i < len(pop_values):
                    cor = f"{pop_values[i]}%"
        except Exception as pop_err:
            print("POP Processing Error:{}".format(pop_err))

        print("現在の降水確率 {}".format(cor))
        print("today is  {}".format(weather))

    return cor, weather, weather_url

3. Raspberry Pi Zero 起動時自動実行の設定(復習)

本体側のプログラム変更に伴い、自動起動の設定も改めて確認を行いました。 以前投稿した「お天気時計の作成(その5:自動起動)」と同様に、cron を使って起動時に自動実行されるよう設定しています。

設定手順の復習:

crontab -e

設定内容:

@reboot sleep 30 && python3 /home/tomtomst2/python_p/weather_clock_v10.py > /home/tomtomst2/weather.log 2>&1
  • ネットワークが確立するまでの時間を確保するために sleep 30 を挟んでいます。
  • エラー発生時の追跡用として weather.log への標準出力・エラー出力のリダイレクトを設定しています。

4. まとめ

サードパーティ製APIの停止に伴い気象庁公式APIへと移行しましたが、データ構造やレスポンスの違いから初期移行時にはいくつか予期せぬエラーが発生しました。

今回の改修により、以下の改善が実現できました。

  • 表示の安定化: 天気コードの完全網羅とフォールバック処理により、長い文章で画面表示が崩れる問題を解消。
  • 堅牢性の向上: 画像取得エラー(404)や変数の未定義エラーを安全にバイパスする例外処理を実装。
  • 運用の自動化: cron による起動時自動実行とログ出力環境を整備し、メンテナンスフリーでの運用を復活。

気象庁公式APIは外部サービスの存続リスクに左右されにくいため、長期的な運用においても非常に安心感があります。自作お天気時計の動作でお困りの方の参考になれば幸いです。

【Raspberry Pi】ObsidianのノートをInfiniCLOUDへ自動バックアップ!rclone+cron設定手順

【Raspberry Pi】ObsidianのノートをInfiniCLOUDへ自動バックアップ!rclone+cron設定手順

Raspberry Piのローカルディスク(ELECOM HDD)にあるObsidianVaultを、InfiniCLOUDへ毎日自動で世代バックアップする環境を構築しました。

Raspberry Pi OS Bookwormにおけるcronの仕様変更や、WebDAV・DNS周りのトラブルを乗り越えて安定動作するようになったので、手順とハマりポイントをまとめます。


1. 目的

Raspberry Piに接続された外付けHDD(ELECOM HDD)内のObsidianデータを、クラウドストレージ(InfiniCLOUD)へ毎夜自動で同期・世代バックアップします。

2. 使用するツール

  • rclone: WebDAVに対応した高機能ファイル同期ツール
  • cron(root権限): 定時自動実行用タスクスケジューラ

3. 事前確認(準備)

作業をスムーズに進めるため、事前に以下の接続情報とローカルパスを確認しておきます。

① InfiniCLOUDの接続情報を確認

  1. InfiniCLOUD のマイページにログインします。
  2. 「外部アプリ接続」 セクションを開き、「外部アプリ接続を許可する」にチェックを入れます。
  3. 表示される以下の情報を手元にメモ(またはコピー)しておきます。
    • WebDAV接続URL(例: https://xxxx.infini-cloud.net/dav/)
    • 接続ID
    • アプリパスワード(※管理画面のログイン用パスワードではなく、ここで再発行・表示される英数字)

② ObsidianVaultのローカルパスを確認

WebDAVサーバー(Apache)の設定ファイルを確認し、ObsidianVaultの公開ディレクトリ(実体)がローカルディスクのどこにあるかを特定します。

1. ApacheのWebDAV設定ファイルを確認:

sudo cat /etc/apache2/sites-available/webdav.conf
  • 設定内の Alias や DocumentRoot の記述から、WebDAVの公開パスを確認します。

2. マウント状態とディレクトリの存在を確認:

# 外付けHDDなどのマウント位置を確認
df -h | grep /mnt

# 該当ディレクトリが存在するか確認
ls -ld /mnt/elecom/webdav/ObsidianVault
  • 本環境の例: /mnt/elecom/webdav/ObsidianVault (ext4フォーマットの外付けHDD上)

4. rcloneの設定

rcloneのインストール

まだインストールしていない場合は、Raspberry Piで以下を実行します。

sudo apt update
sudo apt install rclone -y

接続設定の追加

以下のコマンドでインタラクティブ設定を開始します。

rclone config

対話形式で以下のように設定を選択・入力します。

  • New remote: infini(任意の名前)
  • Storage type: WebDAV
  • Vendor: Nextcloud(InfiniCLOUDと互換性あり)
  • URL: 先ほど確認した WebDAV接続URL
  • User: 先ほど確認した 接続ID
  • Pass: 先ほど確認した アプリパスワード

5. バックアップ構成

  • バックアップ元(ローカル)
/mnt/elecom/webdav/ObsidianVault
  • バックアップ先(最新版データ)
infini:ObsidianBackup
  • バックアップ先(世代管理データ)
infini:ObsidianBackupHistory/YYYY-MM-DD

6. バックアップコマンド(手動テスト)

まずは手動で同期コマンドを実行し、正常に転送できるか確認します。

rclone sync /mnt/elecom/webdav/ObsidianVault \
  infini:ObsidianBackup \
  --backup-dir infini:ObsidianBackupHistory/$(date +%Y-%m-%d) \
  --verbose

7. cronによる自動化(root権限で登録)

【重要】Raspberry Pi OS Bookwormの注意点 Bookwormでは一般的なユーザーのcron(crontab -e)が実行されない挙動が発生するため、sudo crontab -e(root権限)に登録するのが最も確実で安定します。

cronの設定手順

  1. rootのcrontabを開きます。
sudo crontab -e
  1. 以下の設定(環境変数および実行スケジュール)を追記します。
HOME=/home/tomtomst
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

30 3 * * * /usr/bin/rclone sync /mnt/elecom/webdav/ObsidianVault infini:ObsidianBackup --backup-dir infini:ObsidianBackupHistory/$(date +\%Y-\%m-\%d) --verbose >> /var/log/rclone_backup.log 2>&1

💡 実行時刻を「03:30」に設定した理由

  • 毎週日曜日 03:00 に動作するSDカードバックアップ処理が 03:15 頃に完了するため。
  • ディスクI/Oの重なりや競合を避けるために 03:30 に設定しています。

8. 復元方法(リストア)

万が一ローカルデータが破損・消失した場合は、以下のコマンドで特定日付の世代データから復元可能です。

rclone copy infini:ObsidianBackupHistory/<日付>/ /mnt/elecom/webdav/ObsidianVault

9. 今回の構築でハマった点と改善ポイント

構築の過程で直面した問題と、その対策まとめです。

1. ネットワーク・DNS周りの通信不安定

  • 原因: TailscaleのDNS自動書き換えやNetworkManagerによる resolv.conf の上書き、不要なIPv6の有効化が干渉。
  • 対策: DNS書き換えと上書きを抑制し、IPv6を無効化することでWebDAV通信が劇的に安定しました。

2. Raspberry Pi OS (Bookworm) でユーザーcronが動かない

  • 原因: OSの仕様変更により、通常のユーザー権限でのcron実行が不安定に。
  • 対策: sudo crontab -e による root cron に移行。ログ出力(/var/log/rclone_backup.log)を設定して動作を保証。

3. 同期エンジンとバックアップの完全分離

  • メリット: Obsidianの端末間同期にはコミュニティプラグインの 「Sync Engine」 を使用し、バックアップ処理(rclone)とは完全に分離しました。これにより、同期エラーや誤消去が発生してもクラウド上の世代バックアップから安全に復元できます。

まとめ

DNS・WebDAV・rclone・cronといった複数の設定を最適化することで、「完全自動・世代管理付き・堅牢」 なObsidianバックアップ環境が完成しました。

特に 「Bookwormでのroot cron利用」 と 「事前準備でのInfiniCLOUD接続情報・パスの確定」 は、同じ環境で構築する際に知っておくと大幅な時間短縮になるポイントです!

**Raspberry Pi と Tailscaleで「自宅 LAN 全体」を外部から安全にアクセスできるようにした手順まとめ**

Raspberry Pi と Tailscaleで「自宅 LAN 全体」を外部から安全にアクセスできるようにした手順まとめ

自宅の Raspberry Pi を使って、外出先から 自宅 LAN 内のすべての機器(NAS・PC・プリンターなど) に安全にアクセスできる環境を構築しました。

これまでは Raspberry Pi のサーバーだけ外部アクセスできる状態でしたが、
今回の設定によって 自宅 LAN 全体が “どこからでも使えるプライベートネットワーク” に進化しました。

この記事では、
Tailscale のインストール → サブネットルーター化 → LAN 全体への外部アクセス
までの流れをまとめます。

この記事を参考にすれば、あなたの自宅 LAN も安全に外部アクセスできるようになります。


1. Raspberry Pi に Tailscale をインストールする

まず Raspberry Pi に Tailscale を導入します。

■ インストールコマンド

curl -fsSL https://tailscale.com/install.sh | sh

■ 起動(ログイン)

sudo tailscale up

ブラウザが開くので、Google / Microsoft / GitHub などでログインします。

■ Pi の Tailscale IP を確認

tailscale ip

例:

100.100.xx.xx

この IP は外出先から Raspberry Pi にアクセスするための専用アドレスです。


2. スマホ・PCにも Tailscale をインストール

Tailscale は複数端末を同じネットワークに参加させることで機能します。

■ Windows / Mac

https://tailscale.com/download

■ iPhone / Android

App Store / Google Play で「Tailscale」を検索

ログインすると Raspberry Pi が一覧に表示されます。

一覧が表示されない場合は「Machines」を選択すると表示されます。


3. Raspberry Pi を「サブネットルーター」にする(最重要)

市販の NAS やプリンターなどは Tailscale をインストールできません。
そこで Raspberry Pi を 自宅 LAN のゲートウェイにします。

■ 自宅 LAN のネットワーク範囲を公開する

sudo tailscale up --advertise-routes=192.168.2.0/24

(自宅 LAN が 192.168.2.xxx の場合)


4. Tailscale 管理画面でルートを承認する

ブラウザで Tailscale 管理画面を開きます。

raspberrypiの個所ををクリックすると管理画面が表示されます。

■ Machines → Raspberry Pi を選択

「Subnets」に次のような表示が出ます:

  • 192.168.2.0/24(Awaiting Approval)

「Review」を押し、

「192.168.2.0/24」にチェックを入れて「Save」を押すと承認されます。

これで Tailscale 側の設定は完了です。


5. Raspberry Pi の IP フォワーディングを有効化する(必須)

サブネットルーターとして動作するには、
Raspberry Pi が LAN のパケットを転送できる必要があります。

■ 一時的に有効化

sudo sysctl -w net.ipv4.ip_forward=1

これで即座に NAS へアクセスできるようになります。

■ 永続化(再起動しても有効にする)

sudo nano /etc/sysctl.conf

ファイル内に次の行を追加:

net.ipv4.ip_forward=1

保存後、反映:

sudo sysctl -p

確認:

cat /proc/sys/net/ipv4/ip_forward

→ 1 と表示されれば永続化成功


6. 外出先から自宅 LAN 内の機器にアクセスできるようになった

ここまで設定すると、外出先から次のようにアクセスできます。

■ NAS へアクセス

\\192.168.2.50
http://192.168.2.50

■ 自宅 PC の共有フォルダへアクセス

\\192.168.2.10

■ ルーター管理画面へアクセス

http://192.168.2.1

■ Raspberry Pi の WebDAV / Samba / VNC もそのまま利用可能

自宅 LAN 全体が外出先から安全に使えるようになります。


今回の設定でできるようになったこと

  • Raspberry Pi だけでなく 自宅 LAN 全体に外出先からアクセス可能
  • NAS のファイルを外出先で直接扱える
  • 自宅 PC の共有フォルダにアクセスできる
  • ルーターやプリンターなど LAN 内の機器を遠隔管理できる
  • ポート開放不要で安全(ゼロトラストネットワーク)
  • 有線 LAN を優先しつつ、Wi‑Fi に自動フェイルオーバー

これは家庭内ネットワークの利便性を大きく向上させる構成でした。


まとめ:Raspberry Pi × Tailscale は家庭LANの最強アップグレード

今回の構成によって、
自宅 LAN 全体を外出先から安全に使える環境が完成しました。

特に便利なのは、

  • NAS のファイルを外出先で直接扱える
  • 自宅 PC の共有フォルダにアクセスできる
  • 自宅のネットワーク機器をすべて遠隔管理できる
  • しかもポート開放不要で安全

という点です。

Raspberry Pi を使った Tailscale サブネットルーター構成は、
家庭内ネットワークを大幅に便利にしてくれる強力な方法でした。