🍺

電子ペーパーとカップ一杯の白湯: Chanmoroの卓上カレンダー開発物語

2023/12/04に公開

こんにちは!LARPAS 株式会社でプロダクトマネージャーをしている Chanmoro ともうします🙋‍♂️

このエントリーはエンジニア転職のLAPRAS/エンジニア採用のLAPRAS SCOUTを提供する、 LAPRAS のアドベントカレンダーの4日目のエントリーです!
https://qiita.com/advent-calendar/2023/lapras

寒くなってくると仕事中にも温かいものが飲みたくなってくるのでコーヒーや紅茶を飲んでいたのですが、最近は作るのが面倒になってきて白湯を飲むようになりました。意識の低さと高さがどちらも垣間見えますね。

ということで今日も白湯を飲みながら張り切って書いていきたいと思います!

ChatGPT さんからの応援コメント

技術と日常の完璧な融合:Chanmoroが開発した卓上カレンダーと白湯を楽しむ穏やかな瞬間。この記事は、電子ペーパーの可能性を探る旅と、シンプルな白湯がもたらす心地よさを巧みに描いています。ぜひこの創造的な物語に触れてみてください。#卓上カレンダー #白湯 #日常と技術

本記事のタイトルも ChatGPT さんに考えていただきました、ありがとうございました!

電子ペーパーってなんか面白そう!

以前にたまたまこちらの記事を見かけて、「電子ペーパー面白そう!何に使えるかわかんないけどこれで何か作ってみたい!!」と思いました。

https://zenn.dev/yutafujiwara/articles/2d568f168c2e65

何作るか決まってないけどいじってるうちになんか思いつくだろうと思い、この記事でも紹介されている WAVESHARE 社の電子ペーパーを勢いでポチりました。

https://www.amazon.co.jp/dp/B08FZ4ZK4L

AliExpress でも出品されているようだったのでもう少し安く買えるかもしれません。

いじってみる

電子ペーパーが届いてからはとりあえず使い方を覚えるためにいじりました。
WAVASHARE 社公式のドキュメントやサンプルコードがあるので、動かしてみる分には特に困ることもなくすんなり使えました。

https://zenn.dev/chanmoro/scraps/695f636a759e6a

ざっくりの使い方でいうと、電子ペーパーへの表示は表示したいものを画像で入力すればいいことがわかりました。

電子ペーパーのリフレッシュ問題

電子ペーパーというものの性質を全然知らなかったのですが、液晶のディスプレイと違って表示の切り替えに長く時間がかかることがわかりました。
どういうことかというと、表示を切り替える時にこのように画面全体が白黒にチカチカしながら切り替わります。

表示の切り替えにかかる時間はリフレッシュタイムと呼ばれているようで電子ペーパーの製品の仕様に書かれています。今回僕が買った電子ペーパーはリフレッシュタイムが5秒かかります。


※WAVESHARE社の製品ページより引用

今回使った電子ペーパーはこういった仕様があるので時計の秒数表示のようなリアルタイムでの描画が必要なものには向かないことがわかりました。

調べてみると製品によっては partial refresh という機能があり画面内の一部分だけ高速にリフレッシュすることができるようで、これが使える電子ペーパーではよりリアルタイムに近い描画が可能なようです。

https://www.youtube.com/watch?v=HJaVujn877Q

作るものを考える

とりあえず勢いでポチっていじってみたものの、さて何を作るか?となりました。
せっかくなら日々自然に使えるものがいいなあと思って自分が困っていることを考えました。

困っていること

仕事中に困っていることに想いを馳せるとミーティングの予定をよく忘れてるなあーと思いました。

Google カレンダーを常に開いてるんですが、画面を移動したり色々なページを開いているうちにすぐ見失ってしまって、気づくと Google カレンダーを開いているタブがたくさんある状態に良くなってました。
他にも作業に集中していると次のミーティングがあっても忘れることがよくあって、時間を過ぎてから slack でメンションされて気づくことが多々あります。

やりたいこと

卓上に Google カレンダーの予定を表示してパッと視界に入るようにしておけば便利かも?となりました。
画面上に Google カレンダーを開いておくだけだと結局探す手間がかかってしまうので、手元に置いておければ予定の確認が楽になりそうな予感がします。

卓上カレンダーを作ったぞ!

そんな経緯で Google カレンダーの予定を電子ペーパに表示する卓上カレンダーを作ることにしました。
先に作ったものをお見せします。

こんな感じで Google カレンダーに登録されているその日の予定が電子ペーパー上に表示されます。

カレンダーを使ってみていいところ

カレンダーを使い始めてだいたい半年ほど経ちますが非常に満足しています。
ざっといいところを列挙してみるとこんな感じです。

  • 自分の予定の確認のために Google カレンダーのタブを探す必要がなくなった
  • 電子ペーパーは光らないので紙のカレンダーのようにすごく自然にそこにある感じがして邪魔にならない
  • リフレッシュのチカチカが邪魔になりすぎない程度に時報として機能している
    • 次の予定が始まる時や新しい予定が追加された時に目で気づきやすく、控えめにプッシュ通知されている感じがする

どうやって作ったかのストーリー

さて、ここからは卓上カレンダーをどうやって作っていったかについてご紹介します。

画像をどう作るか問題

電子ペーパーを最初にいじってみた時に画像を渡せばそれが描画できることがわかっていたので、じゃあその画像をどう出力するか?が問題でした。
サンプルコードでは PIL を使って Python のコード上で図形を組み合わせていましたが、複雑な描画を作ろうとするとそれは明らかにツライ。

理想的には Web ページと同じように作りたい

できたら Webページを作るときのように、Webブラウザーで表示を確認しながら変更したら LiveReload でリアルタイムに表示に反映されるようにできたらすごく楽なのになーと思いましたが、そういうやり方で動的に画像を生成する仕組みが作れるのかが全くわかりませんでした。
よくあるやり方として Web ページとして作ってヘッドレスブラウザでスクリーンショットを取って画像に出力する方法を知ってはいましたが、ブラウザを使うのが何となく大袈裟すぎる作りな感じがしたのと、最終的にはラズパイZero上で動かしたなと考えていたのでスペック的にキツそうな予感がしてもっとシンプルにやれる方法はないのかなーとぼんやりと考えていました。
仕事じゃなくて完全に趣味のものですし、せっかく新しいものを作るので違うやり方でやってみたいですよね。

どうやったらいいかなーとやり方がすぐに思いつかないまま、気づけば3ヶ月くらい経っていました。

圧倒的ひらめき

そんな中、会社の同僚の @ryo_kawamata がこんなものを作っていました。

https://zenn.dev/ryo_kawamata/articles/6e161be042f3d1

弊社が提供している LAPRAS ポートフォリオに表示されているスコアをカード風の画像で GitHub の README ページに埋め込めるというものです。
ざっくりの仕組みとしては API から取得した値を元に動的に画像を生成 しているわけなので、もしかしたら参考にできるかも?と思いました。

そしてどうやって作ったか聞いてみたところ、 ベースの SVG を作ってその XML の中の数値の部分を直接書き換えている と教えてくれて、それを聞いて「あーなるほど!SVGでやればいいのか!!!」となりました。

SVG は超ざっくり言えば画像を XML で表現しているものなので、もしかしたら Web ページを作るのと同じエコシステムで開発できるかも?と着想を得ました。
HTML を描画するか XML を描画するかのフォーマットの違いしかきっとないはずだと。

動的な SVG の作り方

そこから色々調べたり試してみたところ最終的に Next.js を使って React で書けるじゃん! というところに辿り着きました。
元になる SVG から React でコンポーネントに分けて組み立てれば動的に画像を作ることができるようになります。SVG を作るだけであれば Next.js はいらないのですが、開発中にブラウザで表示を確認するために Next.js を使っています。

ひたすら開発

これまでの調査や圧倒的ひらめきによってざっくりとした実現イメージが頭の中に出来上がったわけですが、そこから手を動かすのはなかなか簡単なことではありません。
※もう出来上がったかのような気持ちになっているため

なのでまずはやる気が出るまで気長に待ちます。
しばらくすると「そろそろ作ってみるか」という気持ちになってきてとうとう重い腰を上げました。

とりあえずは実際にこのカレンダーを使える状態にすることを最優先にと考えていたので、作りの雑さとかは気にせずにとにかくまず使えるのを目指そうと開発を進めました。

バージョン1

最初に作ったものがこちらです。
とにかくベースの仕組みを作って動かすことを目標にしたのでなるべく機能を減らして、まずは予定を一覧で表示するだけのものにしました。

バージョン1の物足りないところ

次の予定やその後に控えている予定はパッと見れるようになったのですが、Google カレンダーを見る時と比べると次の予定が始まるまでに どれくらいの空き時間があるのかが一目で判断しにくい のが使いづらいなと感じました。
Google カレンダーのようなタイムテーブルの UI を作るのは結構面倒そうだったので最初は避けていたのですが、やっぱり欲しいなーとなりました。

バージョン2

そしてまたやる気が出るまで気長に待ちます。
どんな感じで実装したら作れそうかを頭の中でシミュレーションしたりしなかったりを少しづつ繰り返して、しばらくすると「そろそろ作ってみるか」という気持ちになってきたので、タイムテーブルの UI を作ってみました。
そうしてできたものがこちらです。

その日の予定がタイムテーブルの形式で表示されるようにしたのと、現在時刻を表す線を追加しました。

タイムテーブル UI を嗜む

タイムテーブルUIを実装してみるとこれがけっこう奥深いことがわかりました。
複数の予定の時間が被っている場合にどう表示するべきかを決めるのが結構難しい。

例えば Google カレンダー上にこんな感じで予定が入ってたとします。
(現実ではこんなカオスに予定が入り組むことはなかなかないと思いますが)

この表示と全く同じように再現するのはだいぶ難しいことがわかりました。これがどういう表示になってるかを言葉で説明できない。

簡単なロジックを考えて作ってみる

ということで Google カレンダーの完全再現が目的ではないのでそれは早々に諦めて、以下のような単純なロジックで実装することにしました。

  • 予定の開始時刻が他の予定に被ってない場合は一番横幅を広くする
  • 開始時刻が他の予定に被っている場合は開始時刻がより前にある予定の個数に応じて横幅を短くする
    • 自身より前に被ってる予定が1つある場合は 1/2、2つある場合は 1/3 というように横幅を短くしてます

そうするとこんな見た目になります。

Google カレンダーの見た目とはかなり異なりますが、実用上はまあこんなもんで十分だろうという感じです。
同じ時間帯にいくつも予定が被るのを考慮するととても大変なので、体感では最大でも3つ被るのを想定しておけば十分だろうと思ったので、表示上4つ被るのまで耐えられれば良いだろうと考えてそのあたりも端折っています。

Google カレンダーの UI の仕様一体どうなってるんでしょうね。僕は考えきれてないですが、もしかすると何らかのシンプルな計算式で表現できるのかもしれないですね。

バージョン2.1

現在稼働している最新版がこちらです。
「Next」の予定に3つ並べてもほぼ見てなかったため、シンプルに1つだけ表示するようにしました。

どう実装したかを見ていく

さて、ここからは卓上カレンダーの実装コードがどんな感じになっているかをかいつまんで見ていきましょう。
コードをリポジトリで公開すればいいんですけど、全体的にめちゃくちゃ雑コードになっていて参考にしづらいと思うのでコードは現時点では公開してません (ゴメンネ)

ベースの SVG を作る

元になる SVG は何らかのデザインツールを使えばよいので使い慣れている Sketch を使って作りました。
Sketch でデザインを作れば SVG として出力できるのでやりたいことが実現できます。
こんな感じで実現のイメージを作っていきます。

ここまでで最終的に出力したい SVG が作れたので、次にこの中身を動的に変える仕組みを作っていきます。

SVG の表示を確認するためのページを作る

最初は Sketch で出力した SVG をそのまま返すページを作ってブラウザで表示を確認しながらコンポーネントに切り出していけるようにしました。
これによってコンポーネントの中身を更新したら LiveReload でブラウザも更新されるので、通常の Web ページの開発と同じ体験で SVG を作っていくことができるようになります。

大元になる親コンポーネントを作って固定のテストデータを流して徐々に中身を動的に書き換えていきました。

import { ScheduleSvg } from "../components/v2/ScheduleSvg"

export default function Index() {
  const ongoing = { title: "進行中の予定", startTime: new Date("2023-04-13 11:00:00.0+0900"), endTime: new Date("2023-04-13 12:00:00.0+0900"), isEnded: false, isOnGoing: true, isAccepted: true };
  const schedules = [
    { title: "終わった予定", startTime: new Date("2023-04-13 10:15:00.0+0900"), endTime: new Date("2023-04-13 11:00:00.0+0900"), isEnded: true, isOnGoing: false, isAccepted: true },
    { title: "進行中の予定", startTime: new Date("2023-04-13 11:00:00.0+0900"), endTime: new Date("2023-04-13 12:00:00.0+0900"), isEnded: false, isOnGoing: true, isAccepted: true },
    { title: "短い予定", startTime: new Date("2023-04-13 12:45:00.0+0900"), endTime: new Date("2023-04-13 13:00:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "ながーい予定", startTime: new Date("2023-04-13 13:00:00.0+0900"), endTime: new Date("2023-04-13 17:30:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "重なってる予定", startTime: new Date("2023-04-13 14:00:00.0+0900"), endTime: new Date("2023-04-13 16:00:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "重なってる予定", startTime: new Date("2023-04-13 16:00:00.0+0900"), endTime: new Date("2023-04-13 17:00:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "重なってる予定", startTime: new Date("2023-04-13 16:00:00.0+0900"), endTime: new Date("2023-04-13 17:00:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "他の予定に被ってる予定", startTime: new Date("2023-04-13 18:00:00.0+0900"), endTime: new Date("2023-04-13 19:00:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "一部重なってる予定", startTime: new Date("2023-04-13 18:30:00.0+0900"), endTime: new Date("2023-04-13 20:30:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
    { title: "一部重なってる予定", startTime: new Date("2023-04-13 19:30:00.0+0900"), endTime: new Date("2023-04-13 21:30:00.0+0900"), isEnded: false, isOnGoing: false, isAccepted: true },
  ];
  return (
    <ScheduleSvg ongoing={ongoing} schedules={schedules} currentTime={new Date("2023-04-13 11:50:00.0+0900")} />
  )

}

SVG を React コンポーネントに分割する

Sketch で出力した SVG の XML を元に React コンポーネントに分けていきます。
ここは1枚の HTML から React コンポーネントを作っていくのとほぼ同じ感じでできました。

Web ページの場合は CSS で指定すれば要素を等間隔で並べたり小要素の大きさをいい感じに計算してくれますが、SVG の場合にはそういったブラウザのレンダリングエンジンがやっているような処理が使えないので、基本的に要素の大きさや位置などは固定値とする必要がありそうでした。
※僕がやり方を知らないだけかもしれないので違ったらすみません

今回の用途では電子ペーパーのサイズは固定なのでポジションや大きさに関わる数値は決めうちでいいんですが、その計算が必要になるのは地味に面倒でした。

例えば、タイムテーブルに表示される1つの予定を表すコンポーネントはこういう実装になっています。
高さや文字サイズを頑張って計算している努力の跡が見えますね。

import { splitTextByCharWidth } from "../../../libs/textProcessor";
import { ScheduleItemData } from "../../types/type";

type Props = {
    schedule: ScheduleItemData
}

const frameHeightSizeMap: { [key: number]: any } = {
    1: { fontSize: 16, titleYPosition: 15.5 },
    2: { fontSize: 16, titleYPosition: 15.5 },
    3: { fontSize: 20, titleYPosition: 21 },
};

export const ScheduleItem = (props: Props) => {
    const duration = props.schedule.endTime.getTime() - props.schedule.startTime.getTime();
    const durationMins = duration / (60 * 1000);

    // 15分単位での枠の高さを計算
    const quarteHourFrameHeight = 8.5;
    const quarteHourFrameCount = Math.ceil(durationMins / 15);
    const frameHeight = quarteHourFrameCount == 1 ? quarteHourFrameHeight * 2 : quarteHourFrameCount * quarteHourFrameHeight;

    // 枠の高さに応じてフォントサイズを変更
    const fontSize = frameHeightSizeMap[quarteHourFrameCount]?.fontSize || (20 - (3 - props.schedule.size) * 2);
    const titleYPosition = frameHeightSizeMap[quarteHourFrameCount]?.titleYPosition || 26 - (20 - fontSize) * 0.5;

    // 枠の色を決定
    const backgroundColor = props.schedule.isOnGoing ? "#000000" : "#FFFFFF";
    const titleColor = props.schedule.isEnded ? "#979797" : props.schedule.isOnGoing ? "#FFFFFF" : "#000000";
    const lineColor = props.schedule.isEnded ? "#979797" : props.schedule.isOnGoing ? "#FFFFFF" : "#000000";

    // 枠の幅を計算
    const frameWidth = props.schedule.size * 110 - 1;

    // 枠の幅に応じて表示する文字数を計算
    const baseMaxCharCount = quarteHourFrameCount <= 2 ? 36 : 30;
    const maxCharCount = baseMaxCharCount / (3 / props.schedule.size) + (3 - props.schedule.size);
    const text = splitTextByCharWidth(props.schedule.title, maxCharCount);
    const titleText = text.length > 1 ? text[0].slice(0, -1) + "…" : text[0];

    return (
        <>
            <g>
                <rect stroke={lineColor} fill={backgroundColor} x="0.5" y="0.5" width={frameWidth} height={frameHeight} rx="8"></rect>
                <text fontFamily="NotoSansMonoCJKjp-Regular, Noto Sans Mono CJK JP" fontSize={fontSize} fontWeight="normal" fill={titleColor}>
                    <tspan x="9" y={titleYPosition}>{titleText}</tspan>
                </text>
            </g>
        </>
    )
};

文字を折り返すための処理

予定のタイトルを表示する部分など、要素の横幅に収まるように文字を折り返すのを自前で処理を書く必要があります。

今回は文字の全角半角を判定しテキストを指定した横幅で分割する処理を作りました。1文字あたりの幅を固定として計算が楽に済むように最終的な画像の描画時には等幅フォントを使うようにしています。

この辺りは面倒でしたけど面白かったです。

function isSingleByteCharacter(c: string) {
    const charCode = c.charCodeAt(0);
    // The following ranges represent ASCII code points for single-byte characters
    // 0x0020: Single-byte space
    // 0x007E: Single-byte alphanumeric characters and symbols
    // 0xFF61 - 0xFF9F: Single-byte Katakana
    return (charCode >= 0x0020 && charCode <= 0x007E) || (charCode >= 0xFF61 && charCode <= 0xFF9F);
}

function textToCharWidthList(text: string): number[] {
    return Array.from(text).map((c) => isSingleByteCharacter(c) ? 1 : 2);
}

function getIndexWhenExceedThreshold(numbers: number[], threshold: number) {
    let sum = 0;
    for (let i = 0; i < numbers.length; i++) {
        sum += numbers[i];
        if (sum > threshold) {
            return i;
        }
    }
    return null;
}

export function splitTextByCharWidth(text: string, thresholdWidth: number): string[] {
    const index = getIndexWhenExceedThreshold(textToCharWidthList(text), thresholdWidth);
    if (!index) {
        return [text];
    }
    return [text.slice(0, index), ...splitTextByCharWidth(text.slice(index), thresholdWidth)];
}

Google カレンダーの API から予定を取得する

Google カレンダーから予定のデータを取得するコードは世にあまたのサンプルがあるので、一部を抜粋してざっと紹介するとこんな感じで使っています。
予定を取得する基点となる日時 baseDate を元に、対象の予定が終わっているか進行中かなどの判定もこの中でやっています。

async function listEvents(auth: any, baseDate: Date) {
    const calendar = google.calendar({ version: 'v3', auth });
    const from = dayjs(baseDate).startOf('day');
    const to = from.endOf('day');
    console.log(`from: ${from}, to: ${to}}`)

    const res = await calendar.events.list({
        calendarId: 'primary',
        timeMin: from.toISOString(),
        timeMax: to.toISOString(),
        maxResults: 250,
        singleEvents: true,
        orderBy: 'startTime',
    });
    const events = res.data.items;
    if (!events || events.length === 0) {
        console.log('No upcoming events found.');
        return [];
    }
    console.log(`Got ${events.length} events:`);
    events.map((event) => { console.log(`${event.start?.dateTime} - ${event.summary}`); });
    return events.filter((event) => { return event.summary && event.start?.dateTime && event.end?.dateTime });
}

export async function getSchedules(baseDate: Date) {
    const auth = await loadCredentials();
    const scheduleItems = (await listEvents(auth, baseDate)).map((event) => {
        return {
            title: event.summary!,
            startTime: new Date(event.start!.dateTime!),
            endTime: new Date(event.end!.dateTime!),
            isEnded: new Date(event.end!.dateTime!) <= baseDate,
            isOnGoing: new Date(event.start!.dateTime!) <= baseDate && baseDate < new Date(event.end!.dateTime!),
            isAccepted: event.attendees ? event.attendees.some((attendee) => { return attendee.self && attendee.responseStatus === 'accepted' }) : event.organizer?.self || null,
        }
    }).sort(sortByStartTimeAndDurationAndIsAccepted);

    // accepted かつ duration が短いものを表示する
    const ongoingSchedules = scheduleItems.filter((item) => { return item.isOnGoing }).sort(sortByDurationAndIsAccepted);
    const ongoing = ongoingSchedules[0] || null;

    const schedules = scheduleItems;
    return { ongoing, schedules };
};

電子ペーパーに表示する PNG 画像を生成する

電子ペーパーでは SVG をそのまま描画することはできなくて事前に画像として出力する必要があるので表示したい画像を PNG で出力しています。
Google カレンダーの API から予定のデータを取得して SVG を生成し PNG 画像として出力するところまでを1つのコマンドとして CLI から実行できるようにしました。

ここまで組み立ててきた React コンポーネントから renderToStaticMarkup を使って SVG を出力して、それを resvg-js を使って PNG 画像を生成しています。

import { renderToStaticMarkup } from 'react-dom/server';
import { ScheduleSvg } from './components/v2/ScheduleSvg';
import fs from "fs/promises";
import { Resvg } from '@resvg/resvg-js';
import { getSchedules } from './libs/getSchedules';

const main = async () => {
    const { ongoing, schedules } = await getSchedules(new Date());

    const svg = renderToStaticMarkup(<ScheduleSvg ongoing={ongoing} schedules={schedules} currentTime={new Date()} />);
    const opts = {
        background: 'rgb(255, 255, 255)',
        font: {
            fontFiles: ['./fonts/NotoSansMonoCJKjp-Regular.otf'],
            loadSystemFonts: false,
            defaultFontFamily: 'Noto Sans Mono CJK JP',
        },
    }
    const resvg = new Resvg(svg, opts)
    const pngData = resvg.render()
    const pngBuffer = pngData.asPng()

    await fs.writeFile('./schedule.png', pngBuffer);
}

main().catch((e) => {
    console.error(e);
    process.exitCode = 1;
});

https://github.com/yisibl/resvg-js

画像を電子ペーパーに表示する

画像を電子ペーパーに表示する処理は Python で実装しました。
引数で指定されたパスから画像を読み込んで電子ペーパーに描画する処理をやっています。
ほとんどの処理は Typescript に寄せたので Python 側でやってる処理は少ないです。

import argparse
import logging

from PIL import Image

from lib import epd7in5_V2

logging.basicConfig(level=logging.DEBUG)

def main(filename):
    logging.info("Start.")
    epd = epd7in5_V2.EPD()
    
    logging.info("Clear display.")
    epd.init()
    epd.Clear()

    logging.info(f"Render image file. {filename}")
    Himage = Image.open(filename)
    epd.display(epd.getbuffer(Himage))
    
    logging.info("Goto Sleep...")
    epd.sleep()

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('filename') 
    args = parser.parse_args()
    main(filename=args.filename)

カレンダー反映の処理を定期実行する

ここまでに作った2つのモジュールを bash スクリプトで繋げます。
余計なリフレッシュが動くのを防ぐために、出力された画像ファイルに差分がある場合のみ描画するようにしています。
このスクリプトを cron で1分ごとに実行するようにしました。

#!/bin/bash
set -eux

cd "$(dirname "$0")"

node dist/main.js

if diff -q schedule.png schedule.latest.png >/dev/null ; then
    echo "No differences"
    exit 0
fi

echo "Differences found"
echo "Copying schedule.png to schedule.latest.png"
cp schedule.png schedule.latest.png

echo "Refresh display"
python paper/main.py ./schedule.png

今後やってもいいかなーと思っていること

Raspberry Pi Zero で動かしたい

今回作った卓上カレンダーは本当はよりラズパイZeroを使おうと思って買ってたのですが、今のところラズパイ4を使って動かしています。
スペック的にラズパイ4の方が動作が速いので開発時はラズパイ4で動かしていて、いざラズパイZeroに載せ替えてみたらなんと動かない!という事態になったためです。

調べてみると resvg-js が動かないのが原因のようでした。ラズパイZero は CPU が古いので恐らくそのせいだと想像しています。
実は node.js も新しいバージョンのものは動かなかったのですがこちらの記事を参考に非公式ビルドを使うことで解決できました。

https://zenn.dev/mactkg/articles/5adc624787666c

なので SVG から PNG 画像を作成するところがラズパイZero で動かせるようにできれば解決できるだろうところまではわかっています。
この辺りは node.js よりも Python の方がライブラリが豊富なので画像の変換処理は Python に載せ替えた方がいいかなーと妄想しています。

でもまあとりあえず今動いているし、ラズパイZeroで動かさないと困る事情があるわけでもないので、しばらくやる気が出てくることはなさそうな予感がしています。。。

UI もうちょいなんとかできそう

UI の点でも、気合いを入れて作った右側のタイムテーブルに比べて左側の領域が活かしきれてないような気がしています。
予定の作成者や出席者だったり、meet が設定されているかなどの予定に関する情報を付け足してもいいかなーと思っています。
あとは予定が開始してからは Ongoing の予定はみる必要がないので、ここの情報量はもっと落として Next のところの情報量を増やしてもいいかもしれないなーとぼんやり考えています。

とはいえバージョン2.1の UI でだいぶ満足しているので、こちらについてもしばらくやる気が出てくることはなさそうな予感がしています。。。

まとめ

ということでこの記事では、楽しそう!と思った電子ペーパーを買ってみて何か使えないかを考えた結果その日の予定を表示する卓上カレンダーを作った!というお話を書きました。

今回カレンダーを作ってみたことで、面白そうな技術がまず先にあってそこへの好奇心から出発して、じゃあそれをどう使えるか?を次に考えてものを作る一連を経験できたなーと思います。
電子ペーパーをいじるはもちろんのこと、SVG の中身をいじったり画像を描画する処理などは普段は触れることがなかったので新しいことを知れました。

それでは皆さんも白湯を飲みつつ健康ライフをお過ごしください!

Discussion