カラー電子ペーパーを使った個人ダッシューボード作成 2024 その2

## 概要

以下の記事のアップデート版です

https://qiita.com/aoirohn/items/5b52b4509eca75b7e85c

前回、カラー電子ペーパーを使ったダッシュボードを作成しましたが、急いで作ったこともあり個人的に納得の行く完成度ではなかったのと、記事が雑だったため全体的に見直して再作成しました

完成品はこちらです。2024年にふさわしいダッシュボードになった気がします

![photo_stand.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/fe0a4f4d-0d53-ef8b-d753-9e19b1377130.jpeg)

リポジトリはこちら

https://github.com/aoirohn/epdash

### 構成

プロジェクトの構成・処理としては次のとおりです

![diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/63419944-df56-18e0-429a-f03e25797dcb.png)

1. 表示するダッシュボードをRemixを使ったWebページとして作成し、ホストします
2. 作成したダッシュボードをplaywrightを利用して画像として取得します
3. ディザリングを行います
4. Raspberry Piにて画像を取得し、電子ペーパーに書き込みます


:::note info
前回はClient側でPlaywrightを立ち上げ、pythonの`Pillow`ライブラリを利用してディザリングを行っていましたが、今回はそれらの処理をServer側に移し、Clientの処理を電子ペーパーへの書き込みのみに簡略化しました。これによりRaspberry Pi Zeroでも安定して稼働できるようになりました。
:::


## 利用したツール等
### ハードウェア
- Waveshare 7.3inch e-Paper HAT (F)
  - 7色描画可能な電子ペーパー
- Raspberry pi
  - Raspberry pi Zero 2 WH もしくはRaspberry pi 4/5 を想定しています
  - 本記事ではRaspberry pi Zero 2 WHを利用します
- 適当なサーバー（Raspberry pi Zero 2を利用する場合）
  - 本記事ではServerとClientは分けていますが、Raspberry Pi 4/5等を利用すれば1台のRaspberry Piで動作可能です



### ライブラリ・ソフトウェア
#### javscript / typescript
- bun
    - ダッシュボードプロジェクトの開発・パッケージ管理に使います
- remix
    - ダッシュボードのUIを作るフレームワークです
- pm2 
    - サーバーの永続化に使います
- playwright
  - サーバーでホストしたダッシュボードのスクリーンショットを取得するのに使います

#### python
- uv
  - pythonのパッケージ管理に使います

#### その他
- mise
  - python, nodeのバージョン管理に利用しています



## サーバーのセットアップ

### システムライブラリ・パッケージのインストール
ダッシュボードのサーバー用に以下のパッケージをインストールします
- mise
- bun
- node
- pm2
- playwright

```sh
# miseのインストール
# https://mise.jdx.dev/getting-started.htmlを参照してください
curl https://mise.run | sh

# bunのインストール
mise use -g bun@latest
# node
mise use -g node@latest

# pm2のインストール
bun add -g pm2

# playwrightと依存パッケージのインストール
cd <project dir>
bun add playwright
bunx playwright install-deps
```


### ダッシュボード画面の作成
remixを利用してダッシュボード画面を作成します。
電子ペーパーのサイズに合わせて800 x 480 pxでダッシュボード画面を作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/4e125a9e-cbff-ebcc-7257-8b36b3eb1ece.png)

前回同様、以下のサービスのAPIを利用しています
- [OpenWeatherMap](https://openweathermap.org/)
    - 天気予報の取得
- [GoogleCalenderAPI](https://developers.google.com/calendar?hl=ja)
    - カレンダーイベントの取得
    - こちら[^1]の記事を参考にAPIキーを使った認証を利用しています
- [今日は何の日API](https://note.com/sooz/n/naffb68c7f53b)
  - 右上の記念日の表示に利用

[^1]:[GoogleカレンダーのAPIを使ってWebサイトに表示させる方法](https://www.re-d.jp/news/article/9567/)



### 画像の取得

今回はClient側のpythonではなく、サーバー側で自分のホストしているページのSSを取得します。

今回は`/image.png`にアクセスすると`/dashboard`のスクリーンショットを取得するようにします

```tsx:[image.png].tsx
import type { LoaderFunction } from "@remix-run/node";
import { chromium } from "playwright";

const CHROMIUM_FLAGS = [
  "--disable-gpu",
  "--disable-dev-shm-usage",
  "--disable-setuid-sandbox",
  "--no-first-run",
  "--no-sandbox",
  "--no-zygote",
  "--single-process",
  "--disable-audio-output",
  "--disable-background-timer-throttling",
  "--disable-backgrounding-occluded-windows",
  "--disable-breakpad",
  "--disable-extensions",
  "--disable-sync",
  "--disable-translate",
  // '--virtual-time-budget=10000'
];


export const takeScreenshot = async (url: string) => {
  const browser = await chromium.launch({ headless: true, args: CHROMIUM_FLAGS });

  const context = await browser.newContext({
    viewport: { width: 800, height: 480 },
    deviceScaleFactor: 2,
  });

  const page = await context.newPage();
  const port = process.env.SERVER_PORT || 3000;
  await page.goto(`http://localhost:${port}/dashboard`);

  const screenshot = await page.screenshot();
  await browser.close();

  return screenshot;
};


export const loader: LoaderFunction = async ({ request }) => {
  const port = process.env.SERVER_PORT || 3000;
  const url = `http://localhost:${port}/dashboard`;
  
  img = await takeScreenshot(url);

  return new Response(img, {
    headers: {
      "Content-Type": "image/png",
    },
  });
};

```

:::note info
前回同様、CHROMIUM_FLAGSを設定して軽量化しています。
また、`device_scale_factor=2`を設定し、縦横2倍の解像度でスクリーンショットを取得します。大きめの画像を利用することで後に縮小&ディザリング処理をしたとき、より綺麗な画像が得られます
:::


### 画像の縮小&ディザリング処理

前回とは異なり、今回はbun (or node.js) で画像の縮小とディザリング処理を行います。

`Pillow`と同様、Floyd-Steinberg法でディザリング処理をしようと思いましたがいい感じのライブラリがなかったので自作しました (~~ChatGPTに書いてもらいました~~)

```ts:dithering.ts

import { Jimp, type JimpInstance, JimpMime } from "jimp";

type Color = { r: number; g: number; b: number };

const hexToRgb = (hex: string): Color => {
  const bigint = Number.parseInt(hex.replace("#", ""), 16);
  return {
    r: (bigint >> 16) & 255,
    g: (bigint >> 8) & 255,
    b: bigint & 255,
  };
};

// 最も近い色をパレットから探す
const findClosestColor = (color: Color, palette: Color[]): Color => {
  let closestColor = palette[0];
  let minDistance = Number.POSITIVE_INFINITY;

  for (const paletteColor of palette) {
    const distance =
      (color.r - paletteColor.r) ** 2 + (color.g - paletteColor.g) ** 2 + (color.b - paletteColor.b) ** 2;

    if (distance < minDistance) {
      minDistance = distance;
      closestColor = paletteColor;
    }
  }

  return closestColor;
};

// Floyd-Steinberg法でディザリング
const floydSteinbergDithering = (image: any, palette: Color[]) => {
  const width = image.bitmap.width;
  const height = image.bitmap.height;

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const idx = (width * y + x) * 4;

      // 現在のピクセルの色を取得
      const oldPixel = {
        r: image.bitmap.data[idx],
        g: image.bitmap.data[idx + 1],
        b: image.bitmap.data[idx + 2],
      };

      // 最も近い色をパレットから選択
      const newPixel = findClosestColor(oldPixel, palette);

      // エラーを計算
      const error = {
        r: oldPixel.r - newPixel.r,
        g: oldPixel.g - newPixel.g,
        b: oldPixel.b - newPixel.b,
      };

      // 現在のピクセルを更新
      image.bitmap.data[idx] = newPixel.r;
      image.bitmap.data[idx + 1] = newPixel.g;
      image.bitmap.data[idx + 2] = newPixel.b;

      // エラーを周囲のピクセルに分配
      const distributeError = (dx: number, dy: number, factor: number) => {
        const nx = x + dx;
        const ny = y + dy;
        if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
          const nIdx = (width * ny + nx) * 4;

          // ****** ① ****** //
          image.bitmap.data[nIdx] = Math.min(255, Math.max(0, image.bitmap.data[nIdx] + Math.round(error.r * factor)));
          image.bitmap.data[nIdx + 1] = Math.min(
            255,
            Math.max(0, image.bitmap.data[nIdx + 1] + Math.round(error.g * factor)),
          );
          image.bitmap.data[nIdx + 2] = Math.min(
            255,
            Math.max(0, image.bitmap.data[nIdx + 2] + Math.round(error.b * factor)),
          );
        }
      };

      distributeError(1, 0, 7 / 16); // 右
      distributeError(-1, 1, 3 / 16); // 左下
      distributeError(0, 1, 5 / 16); // 下
      distributeError(1, 1, 1 / 16); // 右下
    }
  }
};

// バッファから読み取り、バッファとして返す処理
export const ditherImageBuffer = async (inputBuffer: Buffer, paletteHex: string[]) => {
  try {
    const image = await Jimp.read(inputBuffer);

    // 画像を縦横それぞれ半分に縮小
    image.resize({ w: image.bitmap.width / 2, h: image.bitmap.height / 2 });

    // パレットをRGBに変換
    const palette = paletteHex.map(hexToRgb);

    // ディザリング処理
    floydSteinbergDithering(image, palette);

    // バッファとして出力（PNG形式）
    const outputBuffer = await image.getBuffer(JimpMime.png);
    return outputBuffer;
  } catch (err) {
    console.error("Error processing image:", err);
    throw err;
  }
};

```

:::note warn
コード中の①の部分について、ChatGPTのコピペだとBufferの値がオーバーフローしたため、`Math.min(255, Math.max(0, value));`のように0 ~ 255にクリッピングしていますが、正しいやり方なのかどうかは調べてません
画像見た感じでは問題ない気がしています
:::


上記コードを使って、`/dithered-image.png`にアクセスするとディザリング処理した画像を返すようにします

```tsx:[dithered-image.png].tsx
import type { LoaderFunction } from "@remix-run/node";
import DCache from "~/features/cache/cache";
import { ditherImageBuffer } from "~/features/dithering/dithering";
import { takeScreenshot } from "~/features/screenshot/takeScreenshot";

export const loader: LoaderFunction = async ({ request }) => {
  const port = process.env.SERVER_PORT || 3000;
  const url = `http://localhost:${port}/dashboard`;
  const ss = await takeScreenshot(url);

  const palette = [ // Waveshare 7.3inch e-Paper HAT (F)のカラーパレット
    "#FF0000", // Red
    "#00FF00", // Green
    "#0000FF", // Blue
    "#FFFF00", // Yellow
    "#FF8000", // Orange
    "#000000", // Black
    "#FFFFFF", // White
  ]; 
  const dithredImg = await ditherImageBuffer(ss, palette);

  return new Response(dithredImg, {
    headers: {
      "Content-Type": "image/png",
    },
  });
};

```

`/dithered-image.png`にアクセスすると以下のような画像が取得できます

![dithered-image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/f9ea3be5-c69e-7b06-e5b0-5e931aa55a40.png)


### pm2でサーバーの起動&永続化
画面が作成できたらダッシュボードのサーバーを永続化します。

必要に応じてpm2のconfigを作成します。今回はサーバーを3000番ポートで公開します。
```js:pm2.config.cjs
module.exports = {
  apps: [
    {
      name: "dashboard",
      script: "remix-serve",
      args: "build/server/index.js",
      autorestart: true,
      restart: "on-failure",
      error_file: "/dev/null",
      out_file: "/dev/null",

      env: { PORT: 3000, NODE_ENV: "production" },
      node_args: "--env-file .env",
    },
  ],
};

```

設定ファイルを作ったらpm2でサーバーを起動します

```sh:shell
bun run build # ビルド

pm2 start pm2.config.cjs
pm2 startup

# 次のようなスタートアップの設定用スクリプトが表示されるのでそれをコピー&ペーストして実行
# [PM2] To setup the Startup Script, copy/paste the following command:
# sudo env PATH=$PATH:/home/<username>/.nvm/versions/node/v22.12.0/bin /home/<username>/.bun/install/global/node_modules/pm2/bin/pm2 startup systemd -u <username> --hp /home/<username>

pm2 save # 設定保存
```


設定が終わったら一度再起動し、サーバーが永続化されていることを確認します。
```sh:shell
pm2 ls
# ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
# │ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
# ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
# │ 0  │ dashboard          │ fork     │ 0    │ online    │ 0%       │ 165.2mb  │
# └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
```

最後に、PCからもアクセスし接続できることを確認します。


## Raspberry pi のセットアップ

Raspberry pi Imagerを利用してマウス、キーボード、モニタなしでセットアップします。
sshでアクセスできるようにしておきます。

セットアップ手順はこちら[^2]の記事を参考にしました。
初回のセットアップが完了したら、電子ペーパーを使うための設定、ライブラリをインストールします

[^2]:[Raspberry Pi Zero(W, WH, 2 W)のセットアップ](https://qiita.com/hishi/items/8bdfd9d72fa8fe2e7573)


### vimのインストール（任意）
vim-tinyに慣れていないのでvimをインストールします。vim以外を使う場合は適宜読み替えてください
```sh:shell
sudo apt-get --purge remove vim-common vim-tiny
sudo apt-get install vim
```

### spi通信の有効化

`/boot/firmware/config.txt`を編集しspi通信を有効化します

```bash
sudo vim /boot/firmware/config.txt
```

```diff:/boot/firmware/config.txt
- #dtparam=spi=on
+ dtparam=spi=on
```

保存したらraspberrypiを再起動します


### システムライブラリ・パッケージのインストール
ダッシュボードへの描画用に以下のパッケージをインストールします
- swig
- liblgpio-dev
- mise
- uv
- (python)
    - uvで作った仮想環境を使うなら不要

```sh:shell
# swig, liblgpio-devのインストール
sudo apt-get install swig liblgpio-dev

# miseのインストール
# https://mise.jdx.dev/getting-started.htmlを参照してください
curl https://mise.run | sh

# uvのインストール
mise use -g uv@latest

# pythonのインストール
mise use -g python@latest
```

### 電子ペーパーへの書き込み

電子ペーパーへの書き込みには[waveshare-epaper](https://pypi.org/project/waveshare-epaper/)パッケージが利用できます。

```py
from PIL import Image
import epaper


def draw(model: str, img: Image.Image):
    try:
        epd = epaper.epaper(model).EPD()
        epd.init()
        epd.display(epd.getbuffer(img))
        epd.sleep()

    except IOError as e:
        print(e)

    except KeyboardInterrupt:
        epaper.epaper(model).epdconfig.module_exit(cleanup=True)
        exit()


def clear(model: str):
    try:
        epd = epaper.epaper(model).EPD()
        epd.init()
        epd.Clear()
        epd.sleep()

    except IOError as e:
        print(e)

    except KeyboardInterrupt:
        epaper.epaper(model).epdconfig.module_exit(cleanup=True)
        exit()

```


サンプルプロジェクトの場合は`packages/drawer`を使って画像を取得し電子ペーパーへの書き込みまで実行できます。こちらはgpioピンを使ったspi通信等を行うためRaspberry piでのみライブラリのインストール&実行が可能です。

実行できない場合は前述した以下のライブラリがインストールされていることを確認してください
- swig
- lgpio-dev


```sh:shell
cd epdash/packages/drawer
uv sync
echo "DASHBOARD_URL=\"http://<server ip address>:<port number>/dithered-image.png\"" > .env

uv run draw-dashboard.py # 電子ペーパーへの書き込み
uv run clear.py # 電子ペーパーの画像クリア
```

書き込んだ画像がこちら

![raspi_and_epaper.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/f1276812-5d17-d7ae-2f2f-7b1e07008932.jpeg)


### cronを設定

動作確認ができたらcronで毎日0時あたりに電子ペーパーを更新します

```sh:shell
crontab -e
```

```sh:cron設定
PATH=<'echo $PATH' したものと同じPATHを設定>

0 0 * * * cd <path to project dir> && uv run draw-calendar.py
```

cronが動かない場合はcronの実行ログやPATHの設定を確認してください。
参考：cronの実行ログは以下のコマンドで確認できます

```sh
sudo journalctl -f -u cron
```

## 見た目を良くする

最後に、適当なフォトスタンドに入れて完成です
大きさ的には2Lサイズのフォトスタンドであれば入りますが、少し小さいので厚紙等を切って枠を作るといい感じになります
~~厚紙の切り口が雑~~

![photo_stand.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/fe0a4f4d-0d53-ef8b-d753-9e19b1377130.jpeg)

邪魔なケーブルやRaspberry Piは裏側に止めておきます
完全に固定すると作業したいとき面倒なので今回はマジックテープで固定し、いつでも外せるようにしました

![back.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256100/1c372a73-2220-c24e-364c-8cabd3a80536.jpeg)

フォトスタンド、厚紙、マジックテープ全部ダイソーで買いました

これで電源を投入すれば完成です！


## まとめ

[前回](https://qiita.com/aoirohn/items/5b52b4509eca75b7e85c)の記事を全体的にアップデートし、納得行く出来の電子ペーパーダッシューボードが作成できました。

結構シンプルな構成にできたと思ってます。node.jsでのFloyd-Steinbergディザリングは~~バグがあったものの~~ChatGPTが実装してくれたので助かりました。


### 今後の改善点
Server側にPlaywrightを持ってきたので、それを活かしてデータ取得したいです
特に、普段のタスク管理や習慣トラッカーに[TickTick](https://ticktick.com/)を使っているのですが、API v2が一般公開されておらず習慣トラッカーのデータをAPI経由で取得できないので、Playwrightを使ってWebスクレイピングで取得して表示すると楽しそうです