**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 サブネットルーター構成は、
家庭内ネットワークを大幅に便利にしてくれる強力な方法でした。


Raspberry Pi 4:SDカードを毎週自動バックアップし、障害時にPCから復元する方法(備忘録)

Raspberry Pi 4:SDカードを毎週自動バックアップし、障害時にPCから復元する方法(備忘録)

Raspberry Pi の SDカードは突然壊れることがあります。
実際に、サーバー運用中に ext4 が破損して起動不能になったため、
「毎週自動バックアップ → 障害時は PC から復元」できる仕組みを構築しました。

この記事では、
バックアップの仕組み・自動化・復元方法(初心者向け)
をまとめています。


1. バックアップの全体構成

  • バックアップ先:外付けHDD(ELECOM)
  • 保存場所:/mnt/elecom/Backup
  • バックアップ方式:dd + pigz(高速圧縮)
  • 実行タイミング:毎週日曜 3:00
  • 保存世代:最新12世代のみ保持
  • 復元方法:Windows PC で SDカードに書き戻す

2. バックアップフォルダの作成

ELECOM の HDD にバックアップ用フォルダを作成します。

sudo mkdir -p /mnt/elecom/Backup
sudo chown -R tomtomst:tomtomst /mnt/elecom/Backup
sudo chmod 775 /mnt/elecom/Backup

3. pigz(高速 gzip)をインストール

sudo apt install -y pigz

4. 自動バックアップスクリプト

/usr/local/bin/backup_sd.sh を作成します。

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

内容:

#!/bin/bash

# バックアップ保存先
TARGET_DIR="/mnt/elecom/Backup"

# 日付付きファイル名
DATE=$(date +%Y%m%d)
IMG_FILE="$TARGET_DIR/rpi_backup_$DATE.img.gz"

# ELECOM がマウントされている場合はアンマウント
if mountpoint -q /mnt/elecom; then
    sudo umount /mnt/elecom
fi

# 再マウント(fstab に設定されている前提)
sudo mount /mnt/elecom

# バックアップ開始
dd if=/dev/mmcblk0 bs=4M status=progress | pigz > "$IMG_FILE"

# ログ記録
echo "$(date): Backup completed: $IMG_FILE" >> /var/log/sd_backup.log

# ★ 最新12世代だけ残し、それ以外は自動削除 ★
ls -1t $TARGET_DIR/rpi_backup_*.img.gz | tail -n +13 | while read OLD_FILE; do
    rm -f "$OLD_FILE"
    echo "$(date): Deleted old backup: $OLD_FILE" >> /var/log/sd_backup.log
done

保存して終了(Ctrl+O → Enter → Ctrl+X)。

実行権限を付与:

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

5. 毎週1回自動バックアップ(cron設定)

sudo crontab -e

以下を追加:

0 3 * * 0 /usr/local/bin/backup_sd.sh

これで 毎週日曜 3:00 に自動バックアップが実行されます。


6. バックアップファイルの例

rpi_backup_20260802.img.gz
rpi_backup_20260809.img.gz
...
(最新12個だけ残る)

7. Windows PC から SDカードを復元する方法(初心者向け)

バックアップした .img.gz を使って、
壊れた Raspberry Pi を完全復元する手順です。


7-1. 必要なもの


7-2. バックアップファイルを PC にコピー

Windows のエクスプローラーで Raspberry Pi にアクセス:

\\raspberrypi\backup

または IP アドレス:

\\192.168.x.x\backup

最新のバックアップファイルを PC にコピーします。

例:

rpi_backup_20260802.img.gz

7-3. gzip を解凍して .img にする

Windows では 7-Zip が簡単です。


7-4. Raspberry Pi Imager で SDカードに書き込む

  1. Raspberry Pi Imager を起動
  2. 「OSを選ぶ」→「カスタムイメージを使用」
  3. 解凍した .img を選択
  4. 書き込み先 SDカードを選択
  5. 「書き込む」を押す

これだけで 完全復元できます。


7-5. Raspberry Pi を起動する

書き込みが終わった SDカードを Raspberry Pi に挿して起動すると、
バックアップ時点の状態に完全復元されています。

  • OS設定
  • サーバー設定
  • cron
  • Webサービス
  • ファイル構成

すべてバックアップ時点のままです。


8. まとめ

  • SDカードは壊れやすいので 定期バックアップは必須
  • ELECOM の HDD に 毎週自動バックアップ
  • 最新12世代だけ保持して容量を節約
  • 障害時は Windows PC で SDカードに書き戻すだけで復旧
  • Raspberry Pi Imager の「カスタムイメージ」が便利

追記:amba共有の設定例と fstab の最適化(Raspberry Pi 4 バックアップ運用)

Raspberry Pi の SDカードを毎週自動バックアップし、
外付けHDD(ELECOM)に保存する運用では、

  • PCからバックアップファイルにアクセスできること(Samba共有)
  • バックアップ先のHDDが毎回確実にマウントされること(fstab最適化)

がとても重要です。

ここではその設定をまとめます。


1. Samba共有の設定例(BackupフォルダをPCから見えるようにする)

ELECOM の HDD に作成した /mnt/elecom/Backup
Windows / macOS からアクセスできるようにします。


1-1. Samba設定ファイルを編集

sudo nano /etc/samba/smb.conf

一番下に以下を追加します:

[backup]
   path = /mnt/elecom/Backup
   browseable = yes
   writable = yes
   create mask = 0664
   directory mask = 0775
   valid users = tomtomst

保存して Samba を再起動:

sudo systemctl restart smbd

1-2. Windows からアクセスする方法

エクスプローラーのアドレス欄に入力:

\\raspberrypi\backup

または IP 指定:

\\192.168.x.x\backup

1-3. macOS からアクセスする方法

Finder → 移動 → サーバへ接続:

smb://raspberrypi/backup

1-4. これでできること

  • バックアップファイル(.img.gz)を PC から確認
  • 障害時に PC へコピーして復元作業ができる
  • Raspberry Pi 側のバックアップ運用がより安全になる

2. fstab の最適化(ELECOM HDD を毎回確実にマウントする)

バックアップスクリプトは毎週自動で動きます。
そのため、バックアップ先の ELECOM HDD が 確実に /mnt/elecom にマウントされていることが重要です。

fstab を最適化すると:

  • 再起動後も自動でマウント
  • デバイス名(sda → sdb)が変わっても安全
  • I/O負荷が減り、HDDの寿命が伸びる
  • cron バックアップが確実に成功する

というメリットがあります。


2-1. HDD の UUID を確認

lsblk -f

例:

sda1 ext4  UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

この UUID をコピーします。


2-2. fstab を編集

sudo nano /etc/fstab

一番下に追加:

UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /mnt/elecom ext4 defaults,noatime 0 2

オプションの意味

  • defaults
    標準の安全な設定
  • noatime
    読み込み時にアクセス時刻を更新しない
    → HDDの負荷が減る
    → バックアップ時のI/Oが安定する
  • 0 2
    ext4 の標準設定(fsck の順番)

2-3. 設定をテスト

sudo mount -a

エラーがなければ成功。


2-4. 再起動して確認

sudo reboot

再起動後:

lsblk -f

/mnt/elecom にマウントされていれば OK。


3. まとめ(Samba + fstab 最適化の効果)

✔ PCからバックアップファイルにアクセスできる

→ 障害時の復元がスムーズ

✔ HDDが毎回確実にマウントされる

→ cron バックアップが失敗しない

✔ I/O負荷が減り、HDDの寿命が伸びる

→ 長期運用に向いている

✔ バックアップ運用が「完全自動化」される

→ 手動操作ゼロで毎週バックアップが取れる


📘 Raspberry Pi で Samba と WebDAV を再構築した備忘録

📘 Raspberry Pi で Samba と WebDAV を再構築した備忘録

― 既存 TOSHIBA(disk_01)+新規 ELECOM(disk_02)構成 ―

はじめに

Raspberry Pi 4 に接続している外付け HDD を使って、
Samba によるファイル共有環境の拡張と、
WebDAV によるスマホ同期環境の追加を行ったときの備忘録。

今回の作業は、USBハブ経由で複数の HDD を接続していた際に
SDカードが破損するトラブルが発端となった。
その原因調査から、Samba の再構築、WebDAV の追加まで一通り整えたので記録しておく。


1. USBポートが不安定に見えた件(実際は USBハブが原因)

● 発端

新規で追加した ELECOM の HDD を ext4 にフォーマットしようとしたところ、
SDカードが破損して Raspberry Pi が起動不能に。

当初は「Raspberry Pi 4 の USBポートが不安定なのでは?」と疑った。

● 実際の原因

使用していた USBハブが 電源供給なし(バスパワー)タイプで、
複数の HDD を同時に接続していたため 電力不足が発生。

フォーマットのような大量書き込み処理で電圧が落ち、
SDカード側に I/O エラーが波及して破損したと考えられる。

● 調査結果

lsusb -t の結果:

  • ELECOM → 5000M(USB3.0)
  • LOGITEC → 5000M(USB3.0)
  • LaCie → 480M(USB2.0)

ELECOM と LOGITEC は USB3.0 で正常動作していたため、
Raspberry Pi 4 の USBポートは正常と判明。

LaCie は内部の USBブリッジが USB2.0 の可能性が高い。

● 対処

  • HDD を USBハブ経由ではなく Raspberry Pi に直接接続
  • ext4 フォーマットが正常に完了
  • 以降は安定動作

● 教訓

  • Raspberry Pi に HDD を複数接続する場合、
    バスパワーの USBハブは絶対に使わない。
  • セルフパワーの USBハブか、直接接続が必須。

2. ストレージ構成と自動マウント設定

● ディスク構成

  • TOSHIBA → disk_01(従来使用していた Samba 用ディスク)
  • ELECOM → disk_02(今回追加した Samba 用ディスク)
    • Samba の容量増加用
    • WebDAV の保存先
    • スマホ同期のメイン領域

● ext4 でフォーマット

ELECOM を ext4 でフォーマットし直した。

● UUID の確認

sudo blkid

● /etc/fstab に登録(再起動しても自動マウント)

sudo nano /etc/fstab
UUID=798d0fc5-67f4-4549-941e-84ba09c8ebf2 /mnt/toshiba ext4 defaults 0 2
UUID=90bdad69-a038-4b79-8129-6d752db35adc /mnt/elecom ext4 defaults 0 2

● マウントのフォルダ作成

sudo mkdir -p /mnt/toshiba
sudo mkdir -p /mnt/elecom

● マウントテスト

sudo mount -a

これで 再起動後も自動マウントされる安定したストレージ環境になった。


3. Samba の再構築(disk_01 / disk_02)

● Sambaのインストール

sudo apt update
sudo apt install samba samba-common-bin -y

● 共有フォルダの作成

sudo mkdir -p /mnt/toshiba/samba
sudo mkdir -p /mnt/elecom/samba
sudo chown -R tomtomst:users /mnt/toshiba/samba /mnt/elecom/samba
sudo chmod -R 775 /mnt/toshiba/samba /mnt/elecom/samba

● Samba ユーザー

sudo smbpasswd -a tomtomst
sudo smbpasswd -a family

● smb.conf の共有設定

sudo nano /etc/samba/smb.conf
[disk_01]
   path = /mnt/toshiba/samba
   browseable = yes
   writable = yes
   valid users = tomtomst family
   force user = tomtomst
   guest ok = no

[disk_02]
   path = /mnt/elecom/samba
   browseable = yes
   writable = yes
   valid users = tomtomst family
   force user = tomtomst
   guest ok = no

● guest アクセスの無効化

[global]
map to guest = never

● 再起動

sudo systemctl restart smbd
sudo systemctl restart nmbd

4. WebDAV の構築(ELECOM=disk_02)

● Apache2 と WebDAV モジュール

sudo apt install apache2 -y
sudo a2enmod dav
sudo a2enmod dav_fs
sudo systemctl restart apache2

● WebDAV 用ディレクトリ

sudo mkdir -p /mnt/elecom/webdav
sudo chown -R www-data:www-data /mnt/elecom/webdav
sudo chmod -R 775 /mnt/elecom/webdav

● WebDAV サイト設定(webdav.conf)

sudo nano /etc/apache2/sites-available/webdav.conf
<VirtualHost *:80>
    ServerName raspberrypi

    Alias /webdav /mnt/elecom/webdav

    <Directory /mnt/elecom/webdav>
        DAV On
        Options Indexes FollowSymLinks
        AllowOverride None

        AuthType Basic
        AuthName "WebDAV"
        AuthUserFile /etc/apache2/webdav.passwd
        Require valid-user
    </Directory>
</VirtualHost>

● WebDAV 認証ユーザー

sudo apt install apache2-utils -y
sudo htpasswd -c /etc/apache2/webdav.passwd tomtomst
sudo htpasswd /etc/apache2/webdav.passwd family

● サイト有効化

sudo a2ensite webdav
sudo systemctl reload apache2

5. 今回の構成のポイントまとめ

  • USBハブの電力不足が SDカード破損の原因だった
  • Raspberry Pi 4 の USBポートは正常
  • TOSHIBA(disk_01)=従来の Samba ディスク
  • ELECOM(disk_02)=新規追加ディスク(Samba+WebDAV)
  • ext4 フォーマット+UUID+fstab で自動マウントを実現
  • Samba と WebDAV が同じ物理ディスク上で共存
  • スマホ(Obsidian など)との同期が可能になった

おわりに

今回の作業では、USBハブの電力不足という予想外のトラブルから始まり、
ストレージの再構築、Samba の拡張、WebDAV の追加まで一通り整えることができた。

特に、USBハブ問題と自動マウント設定は忘れがちなポイントなので、
備忘録として残しておく価値がある。

今後は WebDAV を使ったスマホ同期や、
rsync によるバックアップ運用などを進めていく予定。


Raspberry Pi Zero 2W × WiFi_IOT_Sensor × はてなブログ 毎日自動投稿システムを構築した話(cron暴走の解決まで)

■ 背景

以前、Raspberry Pi Pico 2W に接続した各種センサの値を Google スプレッドシートへ定期送信するツール 「WiFi_IOT_Sensor」 を作成した。 (参考:2025/03/29 記事) https://tomtomst.hatenablog.com/entry/2025/03/29/231107

また、先日サブブログの方で、Python からはてなブログへ自動投稿する仕組みを紹介した。 (参考:2026/07/05 記事) https://tomtomst.hateblo.jp/entry/2026/07/05/124247

今回はこの 2つのツールを組み合わせて、毎日定時にベランダのセンサデータを自動投稿する仕組みを Raspberry Pi Zero 2W 上に構築した。


■ 今回の目的

  • ベランダに設置した WiFi_IOT_Sensor のデータを

  • 毎日 0:00 に

  • Raspberry Pi Zero 2W が自動で取得し
  • グラフ生成・統計処理を行い
  • サブブログへ自動投稿する

完全自動の「ベランダ環境日報ブログ」を作ることが今回のゴール。


■ 自動投稿プログラムの構成

今回作成した自動投稿プログラム(blog_daily.py)は、以下の流れで動作する。

① Google スプレッドシートから前日のデータを取得 WiFi_IOT_Sensor が 30 分ごとに送信している

  • 温度
  • 湿度
  • 気圧
  • 照度

の 4 種類を、指定日の 0:00〜23:59 の範囲で読み出す。

② データ解析 - 最高温度 / 最低温度 - 最高照度 / 最低照度 - 気圧の上昇・下降判定 - 日の出 / 日の入り時刻の計算 などを自動で算出。

③ グラフ生成 Pillow を使って、 温度・湿度・気圧・照度の 4 種類の折れ線グラフを PNG で生成。

④ はてなブログへ投稿 タイトルは自動生成:

2026年07月19日 ベランダのセンサデータ

本文には - 統計情報 - グラフ画像 - 日の出・日の入り などを Markdown 形式で挿入し、 Hatena Blog API 経由で投稿する。


■ Raspberry Pi Zero 2W で定時実行する方法(cron)

Zero で毎晩 0:00 に自動実行するため、cron を使用。

crontab の設定

LANG=ja_JP.UTF-8
HOME=/home/tomtomst
PATH=/home/tomtomst/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games

@reboot cd /home/tomtomst/Documents/python ; python3 ./weather_clock_main.py > /home/tomtomst/Desktop/exec-error.log 2>&1

0 0 * * * /usr/bin/python3 /home/tomtomst/Documents/python/blog_daily.py > /home/tomtomst/Desktop/blog_daily.log 2>&1

ポイント

  • HOME=/home/tomtomst を入れないと Google 認証が失敗する

  • PATH= の「= の前にスペース」を入れると cron が壊れる

  • Zero は非力なので「毎分実行」は絶対に避ける

  • ログ出力を指定しておくとトラブル時に原因が分かりやすい


■ 苦労した点(今回のハイライト)

今回の構築で最も苦労したのは cron の暴走。

● 壊れた crontab 行が残り続ける

編集したつもりでも、内部的に壊れた行が残っており、 blog_daily.py が 毎分複数回起動する状態に。

● Zero が高負荷でフリーズ

  • CPU 1コア
  • RAM 512MB の Zero では、毎分の Google 認証+画像生成は完全にオーバーキル。

● syslog が大量の cron ログで埋まる

blog_daily.py が 1 分間に 2〜3 回起動し続け、syslog が数千行の cron ログで埋まる。

● 解決方法

最終的に

crontab -r

で crontab を完全削除し、 正しい設定を入れ直すことで解決した。

■ 実行結果

実際に自動投稿された記事のレスポンスは以下の通り:

Status: 200
<title>2026年07月19日 ベランダのセンサデータ</title>

毎晩 0:00 に、 ベランダの環境データが自動でブログに投稿されるようになった。

tomtomst.hateblo.jp


■ まとめ

  • WiFi_IOT_Sensor(Pico 2W)
  • Raspberry Pi Zero 2W
  • Google スプレッドシート
  • Hatena Blog API
  • cron

これらを組み合わせることで、 完全自動の「ベランダ環境日報ブログ」が完成した。

今回のトラブルシューティング(cron 暴走)は大変だったが、 結果として Zero の挙動や cron の癖を深く理解できた。

今後は - グラフの見やすさ改善 - 天気 API の追加 - 画像のコラージュ化 なども検討していきたい。


■ プログラムソース

  • ファイル名:blog_daily.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import gspread
from google.oauth2.service_account import Credentials
import datetime

import os
import sys

# blog_tool_v04.py を読み込む
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import blog_tool_v04 as bt


# ================================
# Google スプレッドシート読み込み
# ================================
def load_sensor_data():
    # 新しい認証スコープ(google-auth 用)
    scope = [
        "https://www.googleapis.com/auth/spreadsheets",
        "https://www.googleapis.com/auth/drive"
    ]

    # 認証ファイルのパス
    cred_path = "/home/tomtomst/Documents/python/credentials.json"

    # 新しい認証方式(Zero で確実に動く)
    credentials = Credentials.from_service_account_file(
        cred_path,
        scopes=scope
    )
    gc = gspread.authorize(credentials)

    # スプレッドシート名とシート名
    sh = gc.open("ベランダのセンサ")
    ws = sh.worksheet("Pico2W_01")

    # 全データ取得
    records = ws.get_all_records(
        expected_headers=["日時", "室温", "湿度", "気圧", "照度"]
    )

    # pandas を使わずに DataFrame を作る(Zero 最適化)
    # → pandas は Zero では重すぎて使えないため
    data = []
    for r in records:
        data.append({
#            "timestamp": datetime.datetime.strptime(r["日時"], "%Y-%m-%d %H:%M:%S"),
            "timestamp": datetime.datetime.strptime(r["日時"], "%Y/%m/%d %H:%M:%S"),
            "temp": float(r["室温"]),
            "humid": float(r["湿度"]),
            "pressure": float(r["気圧"]),
            "lux": float(r["照度"])
        })

    # 前日のデータ抽出
    today = datetime.datetime.now()
    target_date = today - datetime.timedelta(days=1)

    start = target_date.replace(hour=0, minute=0, second=0)
    end   = target_date.replace(hour=23, minute=59, second=59)

    df_day = [d for d in data if start <= d["timestamp"] <= end]

    return df_day, target_date


# ================================
# 日の出 / 日の入り(照度から判定)
# ================================
def detect_sun_times(df):
    threshold = 100

    # 日の出:閾値を初めて超えた時刻
    sunrise = None
    for row in df:
        if row["lux"] > threshold:
            sunrise = row["timestamp"]
            break

    # 日の入り:午後のデータだけを見る
    sunset = None
    for row in df:
        # 午後(12:00以降)だけを対象にする
        if row["timestamp"].hour >= 12 and row["lux"] < threshold:
            sunset = row["timestamp"]
            break

    return sunrise, sunset


# ================================
# 統計値計算
# ================================
def calc_stats(df):
    temps = [d["temp"] for d in df]
    humids = [d["humid"] for d in df]
    pressures = [d["pressure"] for d in df]
    luxes = [d["lux"] for d in df]

    max_temp = max(temps)
    min_temp = min(temps)
    max_lux  = max(luxes)
    min_lux  = min(luxes)

    pressure_trend = "上昇" if pressures[-1] > pressures[0] else "下降"

    return max_temp, min_temp, max_lux, min_lux, pressure_trend

# ================================
# グラフ生成
# ================================

from PIL import Image, ImageDraw
import numpy as np
from PIL import ImageFont

def create_light_graph(df, col, target_date):
    width, height = 800, 300
    img = Image.new("RGB", (width, height), "white")
    draw = ImageDraw.Draw(img)

    # フォント(Zero でも確実に存在する)
    font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 24)

    values = np.array([d[col] for d in df], dtype=float)
    if len(values) == 0:
        return None

    v_min, v_max = values.min(), values.max()
    norm = (values - v_min) / (v_max - v_min + 1e-6)

    xs = np.linspace(50, width - 50, len(values))
    ys = height - (norm * (height - 50)) - 25

    for i in range(len(xs) - 1):
        draw.line((xs[i], ys[i], xs[i+1], ys[i+1]), fill="blue", width=2)

    # ★フォント付きで大きく描画
    draw.text((10, 10), f"{target_date.strftime('%Y-%m-%d')} {col}", fill="black", font=font)

    base_dir = os.path.dirname(os.path.abspath(__file__))
    img_dir = os.path.join(base_dir, "blog_images")
    os.makedirs(img_dir, exist_ok=True)

    filename = os.path.join(img_dir, f"{col}.png")
    img.save(filename)

    return filename


def create_graphs(df, target_date):
    for col in ["temp", "humid", "pressure", "lux"]:
        create_light_graph(df, col, target_date)

# ================================
# Markdown 本文生成
# ================================
def make_markdown(date, sunrise, sunset, stats):
    max_temp, min_temp, max_lux, min_lux, pressure_trend = stats

    sunrise_str = sunrise.strftime("%H:%M") if sunrise else "判定不可"
    sunset_str  = sunset.strftime("%H:%M") if sunset else "判定不可"

    md = f"""# {date.strftime('%Y年%m月%d日')} ベランダのセンサデータ

## 日の出・日の入り(照度センサ判定)
- 日の出:{sunrise_str}
- 日の入り:{sunset_str}

## 統計値
- 最高温度:{max_temp} ℃
- 最低温度:{min_temp} ℃
- 最高照度:{max_lux} lx
- 最低照度:{min_lux} lx
- 気圧:{pressure_trend}

## グラフ
![temp](blog_images/temp.png)
![humid](blog_images/humid.png)
![pressure](blog_images/pressure.png)
![lux](blog_images/lux.png)
"""

    return md


# ================================
# メイン処理
# ================================
def main():
    df, target_date = load_sensor_data()

    sunrise, sunset = detect_sun_times(df)
    stats = calc_stats(df)

    create_graphs(df, target_date)

    md_text = make_markdown(target_date, sunrise, sunset, stats)

    # 一時 Markdown ファイルを作成
    base_dir = os.path.dirname(os.path.abspath(__file__))
    md_path = os.path.join(base_dir, "daily_post.md")

    with open(md_path, "w", encoding="utf-8") as f:
        f.write(md_text)

    # Hatena Blog のサブブログへ投稿
    bt.post_or_update(bt.BLOGS["sub"]["blog_id"], md_path, "sub")


if __name__ == "__main__":
    main()

🌞 ミニソーラーパネルで駆動時間を延ばせるか試した(再登校)

🌞 ミニソーラーパネルで駆動時間を延ばせるか試した(再登校)

前回の記事では、Raspberry Pi Pico2W の電源を on/off する回路を追加し、
バッテリー駆動時間を約 1 ヵ月に延ばせたことを紹介しました。

👉 前回の記事
https://tomtomst.hatenablog.com/entry/2026/01/10/220415

今回はその続きとして、
ミニソーラーパネルを取り付けて、さらに駆動時間を延ばせるかを検証した結果をまとめます。


1. 前回の結果(ソーラーパネルなし)

2000mAh のバッテリーで 約 1 ヵ月動作することを確認。

● バッテリー駆動のみの結果


2. 今回の目的:ソーラーパネルで充電しながら動作させる

「もっと長く動かしたい」ということで、
ミニソーラーパネルを追加して充電しながら動作させることを試しました。

● 購入したミニソーラーパネル

Amazon: https://www.amazon.co.jp/dp/B0BPWTVH13?ref=ppx_yo2ov_dt_b_fed_asin_title

  • 定格電圧:5.5V
  • 電流:60mA
  • サイズ:60mm × 60mm

日光が当たれば 5V / 60mA 程度の発電が期待できる仕様です。


3. 接続構成

ソーラーパネルは、充電モジュールの USB 端子側に接続するだけで OK。

● 接続図


4. 作成した WiFi-IoT デバイス

● 外観

● 内部


5. 実験結果①:直射日光が当たる場合(約 60 日動作)

3 月頃、ベランダの 直射日光が 1 時間ほど当たる場所に設置して測定。

● 測定結果(直射日光あり)

  • 青線:バッテリー残量
  • 赤線:照度センサの値
  • 約 60 日動作 → ソーラーパネルの効果がしっかり出た

ただし、4月下旬には日照時間が 20 分程度に減少し、発電量も低下。


6. 実験結果②:直射日光がほぼ当たらない場合(約 1 ヵ月)

5 月に再度測定したところ、設置場所にはほとんど直射日光が当たらず、
バッテリー駆動時間は約 1 ヵ月と、ソーラーパネルなしとほぼ同じ結果に。

● 測定結果(直射日光なし)


7. 分かったこと

① 直射日光が当たらないと発電効果はほぼゼロ

→ ソーラーパネルは 直射日光が当たる場所に設置する必要がある

② 直射日光が当たると温湿度センサの値が大きく乱れる

→ 温湿度センサは 直射日光を避けて配置する必要がある


8. 温湿度センサの挙動

● 直射日光が当たる場合

  • 温度:急上昇
  • 湿度:急低下
    → センサが日光の熱を受けてしまい、外気とは異なる値になる

● 直射日光が当たらない場合

自然な外気の変化を測定できている。


9. 結論:私のベランダではソーラーパネルは実用的ではなかった

  • 年間を通して 直射日光が当たる場所がほぼない
  • 一方で、直射日光が当たらない場所は安定して存在する
  • よって、ミニソーラーパネルによる延命は 現実的ではない

● 今後の方針

ミニソーラーパネルは諦めて、
測定間隔を 10 分 → 30 分に延ばすことで
バッテリーを 2〜3 ヵ月持たせる方向に切り替えます。


以上、ミニソーラーパネルを使ったバッテリー延命の検証でした。

⚡ PIR センサー × Pico:人感で電源オンして即お天気時計を表示する仕組みを追加

前回のブログ(Raspberry Pi Pico で LCD 表示を高速化! - tomtomst【電子工作 DIY】

の高速 LCD 描画に続き、今回は人感で自動起動する電源制御を実装しました。


はじめに

前回の記事では、Raspberry Pi Pico の LCD 表示を高速化し、起動後すぐにお天気時計を描画できるようになりました。

今回はさらに一歩進めて、PIR センサーで人を検出したときだけ Pico の電源をオンにする仕組みを追加しました。

この仕組みは、以前のブログ(2026/01/10)で紹介した 「RTC の INT_SQW 割り込みで Pico の電源をオンにする回路」 に追加する形で実装しています。

Raspberry Pi Pico2Wバッテリ駆動(電源off→RTC起動) - tomtomst【電子工作 DIY】

これにより、

  • 普段は Pico の電源を完全にオフ
  • PIR が人を検出した瞬間だけ Pico を起動
  • 起動後すぐに高速 LCD 描画でお天気時計を表示

という、よりスマートで省電力な動作が実現できました。


PIR センサーで人を検出して Pico を起動したい

今回使用している PIR センサーは、人を検出すると High(VSYS:リチウムイオンバッテリ電圧)を出力します。

Pico の電源がオフの間も PIR センサーはバッテリから給電されているため、OUT ピンの High レベルは 3.3V ではなく VSYS(約 3.7〜4.2V) になります。

一方、以前の記事で紹介した電源オン回路では、Power_SW ラインが Low になると Pico の電源がオンする仕組みです。

つまり、

  • PIR センサー:検出 → High(VSYS)
  • 電源オン回路:Low でオン

という“逆の動作”になっているため、信号を反転する回路が必要になります。


信号を反転するための回路を追加

赤の点線で囲んだ部分の回路を今回追加しました。

● PIR センサーの OUT 信号

PIR の OUT ピンは、

  • High:人を検出(VSYS)
  • Low:未検出

という動作で、これを R8(100kΩ)を介して MOSFET(2N7000)のゲートに入力します。

接続したPIRセンサは下記です。

Amazon.co.jp: Youmile 5個 PIRモーションセンサ小型 SR602 動きセンサー 探知器モジュール 赤外線感知モジュール感覚スイッチ 高い感受性 デュポンケーブル付き for Arduinoの電子DIYのため : DIY・工具・ガーデン

抵抗値で検出時間(Highの時間)を変更できるようですが、今回はデフォルトの1.5秒のままで使用します。

LCD表示時間(Picoの電源off)は、PicoのPythonプログラムの方で延長することにしました。


● 2N7000 による信号反転

2N7000 はゲート耐圧に余裕があり、VSYS(4V 前後)でも問題なく動作します。

動作は次の通りです。

  • PIR が High(VSYS) → MOSFET がオン → ドレインが GND に落ちて Low
  • PIR が Low → MOSFET がオフ → ドレインは High のまま

この反転された Low が Power_SW に入力されることで、Pico の電源がオンになります。


実際の動作の流れ

今回の仕組みは、次のように動作します。

  1. 人が近づく
  2. PIR センサーが High(VSYS)を出力
  3. MOSFET がオンになり Power_SW が Low に落ちる
  4. Pico の電源がオン
  5. 起動後すぐに高速 LCD 描画でお天気時計を表示
  6. 30秒にPicoの電源オフ(Pythonプログラムでoff制御)

動作がとてもスムーズで、まるで人感センサー付きのスマートディスプレイのようです。

 

プログラム

PIRセンサで電源onしてから30秒間電源on状態を維持するためのプログラムです。

・タイマのタイムアウトで電源offする部分


#電源offするスレッド
def power_off(timer):
    #global Power_keep
    print("Power_keep を 0 にします(電源OFF処理)")
    time.sleep(0.2)
    Power_keep.value(0)

・タイマを起動する部分


timer = Timer(-1)
timer.init(period=30000, mode=Timer.ONE_SHOT, callback=power_off)
print("30秒後に電源OFF処理を実行します")

実装してみて感じたこと

  • 起動の速さと省電力の両立ができた
  • 必要なときだけ動作するので無駄がない
  • PIR の誤検知や環境による感度の違いは今後の改善ポイント
  • 電源オン後のスリープ制御も試してみたい

今回の改良で、日常的に使えるガジェットとしての完成度が一段上がりました。


まとめ

今回の追加回路によって、PIR センサーで人を検出したタイミングで Pico を自動起動し、起動後すぐにお天気時計を表示できるようになりました。
高速 LCD 描画と組み合わせることで、「検出 → 起動 → 表示」の流れがとてもスムーズになり、日常的に使えるガジェットとしての完成度が一段上がったと感じています。

今後はスリープ制御や電源管理まわりも含めて、さらに使い勝手を良くしていく予定です。

Raspberry Pi Pico で LCD 表示を高速化!

 

📘 Raspberry Pi Pico で LCD 表示を高速化!
― 1bit フォント生成と Renderer クラスによる高速描画 ―

Raspberry Pi Pico で LCD に大きなフォントを描画すると、 起動直後の描画がワンテンポ遅い という問題がありました。

今回、この問題を根本から解決するために、 フォントを 1bit 化し、JSON + BIN 形式で扱う新方式 を導入し、さらに フォント描画を Renderer クラスに統合 することで、描画処理を大幅に高速化しました。

結果として、 起動直後の描画がほぼ即時に! という大きな改善が得られました。

この記事では、

  • 前回方式の問題点
  • 今回の改善内容
  • 1bit フォント生成プログラム
  • Renderer クラスによる描画処理
  • 実際の表示例

をまとめて紹介します。


🟥 1. 前回方式の問題点

前回は .c 形式のフォントデータを XglcdFont で読み込んでいました。

Raspberry pi pico 2 WでLCD表示 - tomtomst【電子工作 DIY】

■ 問題:起動直後の描画が遅い

特に 32px〜72px の大きなフォントでは、 起動後 0.5〜1秒ほど描画が遅れる という現象が発生していました。

■ 原因:フォントファイルの読み込みが重い

  • .c フォントはサイズが大きい
  • 起動時に毎回ファイル I/O が発生
  • RAM 展開も重い
  • 結果として描画開始が遅延

🟦 2. 今回の改善方針

✔ 改善方針

  • フォントを 1bit 化し、JSON + BIN に分離して高速ロードする
  • 描画処理を Renderer クラスに統合し、LCD クラスをシンプル化する

✔ この方式のメリット

  • 1bit 化でデータサイズが大幅に縮小
  • JSON(メタ情報)+ BIN(ビットマップ)で高速読み込み
  • 起動直後の描画がほぼ即時
  • 可変幅フォントに対応
  • 記号位置補正も柔軟に可能
  • 描画処理が Renderer に集約され、保守性が向上

🟩 3. 図解:新しいフォント方式の全体像

■ フォント生成の流れ(PC 側)

■ Pico 側の描画の流れ

 

🟧 4. 1bit フォント生成プログラム(PC 側)

以下が今回作成したフォント生成スクリプトです。

・「FONT_SIZE = 72」とところで、フォントのサイズを指定しています。

・「CHARS = "%.0123456789:℃"」のところで変換したい文字を指定しています。

 

from PIL import Image, ImageFont, ImageDraw
import json

FONT_PATH = r"C:\Windows\Fonts\meiryo.ttc"
FONT_SIZE = 72
CHARS = "%.0123456789:℃"

# 記号位置補正(必要に応じて調整)
ADJUST = {
    ":": (+1, +5),
}

def render_char(ch, font, ascent):
    img = Image.new("L", (200, 200), 0)
    draw = ImageDraw.Draw(img)

    draw.text((0, 0), ch, 255, font=font)

    bbox = img.getbbox()
    if bbox is None:
        return None, 0

    img = img.crop(bbox)
    top = bbox[1]
    y_offset = top - ascent

    img = img.point(lambda p: 255 if p > 128 else 0, mode="1")

    return img, y_offset


def img_to_1bit_bytes(img):
    w, h = img.size
    data = bytearray()
    for y in range(h):
        byte = 0
        bit_count = 0
        for x in range(w):
            pixel = img.getpixel((x, y))
            if pixel == 255:
                byte |= (1 << bit_count)
            bit_count += 1
            if bit_count == 8:
                data.append(byte)
                byte = 0
                bit_count = 0
        if bit_count != 0:
            data.append(byte)
    return data


font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
ascent, descent = font.getmetrics()

font_meta = {"chars": {}}
all_bytes = bytearray()
offset = 0

for ch in CHARS:
    img, y_offset = render_char(ch, font, ascent)
    if img is None:
        continue

    w, h = img.size
    bytes_ = img_to_1bit_bytes(img)

    font_meta["chars"][ch] = {
        "width": w,
        "height": h,
        "y_offset": y_offset,
        "data_offset": offset,
        "data_length": len(bytes_),
    }

    offset += len(bytes_)
    all_bytes.extend(bytes_)

file_name = "font_" + str(FONT_SIZE) + "_1bit"
with open(file_name+".json", "w", encoding="utf-8") as f:
    json.dump(font_meta, f, ensure_ascii=False, indent=2)

with open(file_name+".bin" , "wb") as f:
    f.write(all_bytes)

print("生成完了:",file_name)

🟨 5. Pico 側:フォントデータ読み込み(XglcdFontVar)

フォントデータは JSON + BIN を読み込み、XglcdFontVar が保持します。

class XglcdFontVar:
    def __init__(self, meta, data):
        self.meta = meta
        self.data = data

    def get_letter(self, ch):
        info = self.meta["chars"][ch]
        bitmap = self.data[info["data_offset"]: info["data_offset"] + info["data_length"]]
        return bitmap, info["width"], info["height"], info["y_offset"]

読み込み関数:

import ujson

def font_read(font_file):
    with open(font_file+".json") as f:
        meta = ujson.load(f)
    with open(font_file+".bin", "rb") as f:
        data = bytearray(f.read())
    return XglcdFontVar(meta, data)

font72 = font_read("fonts/font_72_1bit")
font48 = font_read("fonts/font_48_1bit")
font32 = font_read("fonts/font_32_1bit")
font16 = font_read("fonts/font_16_1bit")

🟦 6. Renderer クラス:描画処理を一元化

フォント描画処理を Renderer に統合しました。

class FontRenderer:
    def __init__(self, lcd, font, font_size, char_width_map=None):
        self.lcd = lcd
        self.font = font
        self.font_size = font_size
        self.char_width_map = char_width_map or {}

    def draw_letter(self, x, baseline_y, ch, color, background=0,
                    landscape=False, rotate_180=False):

        bitmap, w, h, y_offset = self.font.get_letter(ch)
        bytes_per_row = (w + 7) // 8
        buf = bytearray(w * h * 2)

        src_index = 0
        dst_index = 0

        for yy in range(h):
            bit_mask = 1
            for xx in range(w):

                pixel = color if (bitmap[src_index] & bit_mask) else background

                buf[dst_index] = (pixel >> 8) & 0xFF
                buf[dst_index + 1] = pixel & 0xFF
                dst_index += 2

                bit_mask <<= 1
                if bit_mask == 0x100:
                    bit_mask = 1
                    src_index += 1

            src_index = (yy + 1) * bytes_per_row

        if rotate_180:
            new_buf = bytearray(len(buf))
            num_pixels = len(buf) // 2
            for i in range(num_pixels):
                new_idx = (num_pixels - 1 - i) * 2
                old_idx = i * 2
                new_buf[new_idx], new_buf[new_idx + 1] = buf[old_idx], buf[old_idx + 1]
            buf = new_buf

        draw_y = baseline_y + y_offset

        self.lcd.fill_rectangle(x, draw_y, self.font_size, self.font_size, background)

        if landscape:
            draw_y -= w
            self.lcd.block(x, draw_y, x + h - 1, draw_y + w - 1, buf)
        else:
            self.lcd.block(x, draw_y, x + w - 1, draw_y + h - 1, buf)

        return w, h

    def draw_text(self, x, baseline_y, text, color, spacing=0):
        cursor_x = x
        default_width = int(self.font_size / 2)

        for ch in text:
            w, h = self.draw_letter(cursor_x, baseline_y, ch, color)
            w2 = self.char_width_map.get(ch, default_width)
            cursor_x += w2 + spacing

🟩 7. 実際の描画例

# Renderer の準備
font72_renderer = FontRenderer(display, font72, 72, char_width_map={":": 18})
font32_renderer = FontRenderer(display, font32, 32)

# 年月日・時刻の取得
weekday, weekdays, now_day, now_time, now_hour = get_now_data()

# 年月日表示(32px)
font32_renderer.draw_text(0, 32, now_day, 0xFFFF)

# 時刻表示(72px)
font72_renderer.draw_text(8, 120, now_time, 0xFFFF, spacing=3)

🟫 8. 改善の効果

項目 改善前 改善後
起動直後の描画開始 1〜2秒遅れる ほぼ即時
フォントデータサイズ 大きい 1bit 化で大幅削減
描画速度 やや遅い 高速
可変幅フォント 非対応 対応
記号位置補正 難しい JSON で柔軟に管理
コード構造 分散 Renderer に集約され保守性向上

🟩 9. まとめ

今回の改善で、 フォント生成 → 軽量化 → 高速描画 → クラス統合 という一連の流れが完成しました。

  • 1bit フォントでデータサイズを削減
  • JSON + BIN で高速ロード
  • 可変幅フォント&記号補正に対応
  • Renderer クラスで描画処理を一元化
  • 起動直後の LCD 描画が劇的に高速化

以前の起動 動作

 

修正後の起動 動作