\Amazon ポイントDEAL祭り開催中(22日まで!)/

【アドベントカレンダー2024】Next.js+Rust+Synology NAS with Dockerでお薬飲み忘れたらPhilips Hueがブチギレるアプリ作った

記事内に広告が含まれていることがあります。

近年、Merry ChristmasやHappy Christmasはそもそもクリスマスが宗教的な意味を持つことから「Happy holidays」と挨拶することが一般的になっているようです。

さあ皆さん、メリークリスマス!!!!!!

この記事はQiitaの「身の回りの困りごとを楽しく解決! by Works Human Intelligence Advent Calendar 2024」シリーズ2の投稿記事です、皆さん25日間お疲れ様でした!。

我が家には子どもが一人います。

元気にすくすく育っていますが、極軽度の発達障害(ADHD)の特性があり注意散漫、興味が惹かれれば今やっていることをすぐ忘れてしまうことが多々あります。

これで困ってしまうのが毎日定時に飲む必要がある薬、花粉の舌下治療をやっているので年単位で投薬が必要です。

電車に飛行機にマイクラにと好きなことだらけなので、「薬だよ~」と声をかけても何かに夢中で気づかない、気づいても薬がおいてある場所に行く途中に他に興味が移り忘れてしまうというのが日常です。

そこで「困っている事は技術で解決すればいいじゃん!」という事で今回の「お薬飲み忘れたらPhilips Hueがブチギレるアプリ」を作りました。

子どもが嫌にならず、それでいて興味を惹いてくれる方法はなんだろうと考えた時、IoTランプであるPhilips Hueを光らせれば良いのでは無いだろうかと考え採用しました。

スポンサーリンク
スポンサーリンク

作ったもの

お薬を飲み忘れるとPhilips Hueのライトが威嚇してきます。

Philips Hueには様々なタイプのライトがあり、我が家で所持しているのは

  • カラータイプ
  • ホワイトアンビアンスタイプ(色温度変化)
  • ホワイトタイプ(明るさのみ変化)

合計3種類、もちろんライトごとに設定が異なるのでどのライトを選んでも適切な設定ができるようになっています。

ユーザー画面

お薬の登録や飲んだ!という登録はWebアプリ上から。

後述しますがキッズモードという名前で大人用と子ども用を分け、子どもが使いやすいような工夫を行っています。

プラン詳細

どんな薬を飲んだり吸ったり貼ったりするのか、注意事項等をメモできるようにしてあります。

端末はリビングのアクセスしやすいところから

僕や妻は自分のスマホからアクセスすれば良いですが、子どもはまだ自分用のスマホを持っていないのでリビングの一角に古いiPadをおいてアクセスするようにしています。

正直ネットに繋がってブラウザが入ってればなんでも良いので、そのうちやっすいAndroidタブレットでも用意しようかなと考えています。

技術スタック

今回の技術周りのお話です。

事前にWebアプリ上で薬を飲む時間を登録しておき、後は薬を飲んだら同じアプリ上で飲んだという登録をするだけです。

もし飲んだという登録がなかった場合、指定したPhilips Hueのライトが点滅をしてお知らせをしてくれます。

技術スタック

  • Next.js
  • TypeScript
  • Prisma
  • Tailwind CSS
  • Rust
  • Docker
  • Synology NAS DS720+
  • Philips Hue

アーキテクチャ図

アーキテクチャ図

アーキテクチャ図を簡単に作ってみました。

Synology NAS DS720+上に2つのDockerコンテナがあり、Next.jsとRustの実行環境です。

データベース(SQLite)はホスト上のdbファイルをマウントして参照しており、Philips Hueへの直接的なAPI操作はRustが担っています(一部管理画面でライト状態を取得するためにNext.jsからもPhilips Hueの参照は行っていますが割愛しています)。

APIはPhilips Hueのブリッジを通して行われており、HTTPS通信が可能なAPI v2を使っています。

後述しますがSynology NASへの接続もSynology NASのDDNSを通して証明書を取得しHTTPS通信を行うので、全てがHTTPS通信で暗号化されています。

フロントエンド

  • TypeScript
  • Next.js
  • Prisma
  • Tailwind CSS

この辺りは(僕が感じている範囲で)割とモダンな組み合わせなんじゃないかなと考えて採用しました。

Next.js / TypeScript

Next.js v13からルーティング周りに大幅な変更があり、それまでのファイルベースルーティングからの変更をキャッチアップするところからスタートだったので少しスタートダッシュの時間はかかりましたが、書くコード自体に大幅な変更がある訳ではないので慣れでどうにかなりました。

元々Next.jsはちょっと触ったことがありますがそれも数回なのでほぼ素人レベルですが、Reactを使ってさえいれば開発自体はサクサクできますしお作法にさえ慣れてしまえばやはり支持されてるだけあって特に苦なく制作できますね。

Server Actionsも見知らぬエラーが出て解決に時間がかかりましたが、Next.jsの内部を覗くいい機会になったと思います。

それよりも今回苦労したのはHueライトの操作に必要な値の算出でした。

普段Web制作をしているとRGBやHEXは馴染みがありますが、Philips Hueのカラーライトは色をXY座標という方法で指定する必要があります(他の指定方法もありますが、これが一番表現力が良いらしい)。

困った時はChatGPT先生にまず聞いてみましょう!

xy座標についてChatGPTの回答

なるほど、おめーは一体何語喋ってんださっぱり分からん

とはいえxy座標とやらはRGBと明るさ(brightness)から算出することが可能とのことなのでChatGPTに泣きついて計算方法やロジックを色々教えてもらいました。

import Color from 'colorjs.io';

export const generateColorModelXy = (r: number, g: number, b: number, brightness: number): { x: number; y: number } => {
  // Color.jsを使用してRGBをXYZに変換
  const color = new Color('srgb', [r / 255, g / 255, b / 255]);
  const [X, Y_base, Z] = color.to('xyz').coords;

  // 明るさをスケール (0-1)
  const bri = Math.min(Math.max(brightness, 0), 100) / 100;

  // Y値に明るさを反映
  const Y = Y_base * bri;

  // x, yを計算
  const x = X / (X + Y + Z);
  const y = Y / (X + Y + Z);

  return { x: isNaN(x) ? 0 : x, y: isNaN(y) ? 0 : y };
};

こちらがRGB値とbrightness値からxy座標を出力するコードです。

Color.jsライブラリを使用してだいぶ面倒な計算を避けられました、今後もTypeScriptで色を扱う時は積極的に使っていきたいですね・・・!

const colors = {
  red: { name: '赤', kidsModeName: 'あか', rgb: '#FF0000' },
  green: { name: '緑', kidsModeName: 'みどり', rgb: '#00FF00' },
  blue: { name: '青', kidsModeName: 'あお', rgb: '#0000FF' },
  cyan: { name: 'シアン', kidsModeName: undefined, rgb: '#00FFFF' },
  magenta: { name: 'マゼンタ', kidsModeName: undefined, rgb: '#FF00FF' },
  yellow: { name: '黄色', kidsModeName: 'きいろ', rgb: '#FFFF00' },
  pink: { name: 'ピンク', kidsModeName: undefined, rgb: '#FFB6C1' },
  orange: { name: 'オレンジ', kidsModeName: undefined, rgb: '#FFA500' },
  lightBlue: { name: 'ライトブルー', kidsModeName: undefined, rgb: '#ADD8E6' },
  purple: { name: 'パープル', kidsModeName: undefined, rgb: '#800080' },
  turquoise: { name: 'ターコイズ', kidsModeName: undefined, rgb: '#40E0D0' },
  gold: { name: 'ゴールド', kidsModeName: undefined, rgb: '#FFD700' },
  lime: { name: 'ライム', kidsModeName: undefined, rgb: '#32CD32' },
  deepPink: { name: 'ディープピンク', kidsModeName: undefined, rgb: '#FF1493' },
  royalBlue: { name: 'ロイヤルブルー', kidsModeName: undefined, rgb: '#4169E1' },
  scarlet: { name: 'スカーレット', kidsModeName: undefined, rgb: '#FF2400' },
  warmWhite: { name: 'ウォームホワイト', kidsModeName: undefined, rgb: '#FFD2B3' },
  coolWhite: { name: 'クールホワイト', kidsModeName: undefined, rgb: '#E0FFFF' },
  candleLight: { name: 'キャンドルライト', kidsModeName: undefined, rgb: '#FFB199' },
  mintGreen: { name: 'ミントグリーン', kidsModeName: undefined, rgb: '#98FF98' },
} as const;

色の指定ですが、Philips Hueでは例えば黒色は指定できません。

そこで事前に上記のようにカラーパターンを制作し、色を決め打ってその中から選択してもらう方式を採用しました。

ユーザーに自由に選んで貰って値をフィルタリングする方法もあったんですが、本当は黒色が良いから選んだのに後になって「黒はライトで再現できないので赤にしといたよ」というのはユーザビリティ的にもよくないと考えたからです。

なので事前にRGBとは別にxy座標を計算してオブジェクトに加えておく選択肢もありましたが、今後色を追加したい時にいちいち計算をするのは面倒なので都度計算するようにしてあります。

そうすれば上記のオブジェクト内に色名とRGB値を突っ込むだけで利用できるようになるので後が楽になりますね。

ライトの色について、普段はディスプレイを通した色ばかりを相手にしていたのでデバイスが異なる色操作を学べてとても楽しかったです!

ちなみにRGB値はUI上でどんな色か確認するために使用しています。

通常明るさが0に近づければ近づくほど黒くなっていくんですが、人間がライトを見た時のイメージに使いように明るさが下げればどんどん白っぽく薄くなっていくように実装しています。

子どもが楽しめる工夫

今回主に使うのが現在6歳の就学前児なので、子どもが楽しめる工夫は何か無いかなと考えついたのが「薬を飲んだという登録をした時に画面上で何かアクションを起こす」でした。

そこで実装したのがこれ、タスクが完了するとうちの子大好きな新幹線や電車を走らせるアクションです。

const shinkansenColors: string[] = [
  '#007D40', // はやぶさ
  '#FFCC00', // ドクターイエロー
  '#0088CC', // のぞみ
  '#E60012', // こまち
  '#F8C300', // つばさ
  '#009BDB', // ひかり
  '#C724B1', // みずほ・さくら
  '#FFFFFF', // あさま
];
const trainColors = [
  '#9acd32', // 山手線
  '#ff4500', // 中央線快速
  '#ffd700', // 中央・総武線各停
  '#1e90ff', // 京浜東北線
  '#008000', // 埼京線)
  '#ff8c00', // 湘南新宿ライン
  '#00008b', // 横須賀線
  '#ff7f50', // 東海道線
  '#8b0000', // 武蔵野線
  '#ff0000', // 京葉線
  '#006400', // 常磐線
  '#4682b4', // 総武快速線
  '#ff1493', // 京王線
  '#9400d3', // 東武アーバンパークライン
];

こんな感じで子どもからよく聞く路線の色を事前定義しておいて、完了時にどの色になるか、shinkansenが呼ばれるかtrainが呼ばれるかいずれもランダムになるよう実装しました。

「今日は何が走ってくるかな?」という小さな楽しみですが、走ってくる電車をニコニコ見ているので案外このぐらい単純で良いのかも知れません笑。

結構やっつけでパパっと実装したので、もっと良いアイディアがあったら入れたいなと考えています。

ちなみに走ってくる新幹線/電車は「IFN FREE ICONS」のものを使用させて頂きました!

キッズモード

キッズモード時のルビ表示

こちらは子どもが使えるための工夫で、まだ未就学児にとって漢字だらけでは中々ハードルが高いのでキッズモードを導入しました。

キッズモードをオンにするとユーザーが入力した以外の全ての漢字にルビ(ふりがな)がつきます。

全てひらがなにするかちょっと迷ったんですが、どうせなら「この漢字はこう読むんだな!」を覚えるいいきっかけになればなと思いルビ表示を採用しました。

// Ruby.tsx
'use client';

import { useKidsMode } from '../_context/KidsModeContext';

type RubyProps = {
  kanji: string;
  ruby: string;
};

export const Ruby = ({ kanji, ruby }: RubyProps) => {
  const { isKidsMode } = useKidsMode();

  return (
    <>
      {isKidsMode ? (
        <ruby>
          {kanji}
          <rt>{ruby}</rt>
        </ruby>
      ) : (
        kanji
      )}
    </>
  );
};

こんな感じでRubyコンポーネントを定義して、全ての漢字に対して使用しています。

<Ruby kanji="登録中" ruby="とうろくちゅう" />

キッズモードがオンの時はルビ付きで、キッズモードがオフの時はルビ無しで出力しています。

ちなみに先程の新幹線や電車のアニメーションはキッズモード時限定で、大人が使う時は表示されません。

キッズモード時のタイトル

後地味にタイトルを書き換えています、教育に悪いからね。

PWA対応

今回アプリにPWAを実装し、スマホやタブレット上で子どもがシンプルに扱えるようにしました。

特にプッシュ通知等を採用している訳ではないんですが、タブ切り替えやブックマーク等色々なボタンがあるよりもPWAで極力シンプルなUIの方が気が散らないためです。

// 前略
import nextPWA from '@ducanh2912/next-pwa';

// 中略

const withPWA = nextPWA({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  workboxOptions: {
    skipWaiting: true,
    clientsClaim: true,
  },
});

const nextConfig: NextConfig = withPWA({
  // 中略
});

module.exports = nextConfig;

next-pwaは開発が止まってしまったshadowwalker/next-pwaではなく、フォークして開発が継続されているDuCanhGH/next-pwaを利用させて貰いました。

そのため一部の設定が違っています(skipWaitingworkboxOptions内で定義する等)。

なお執筆時の最新版(next v15.1.2)だとshadowwaker/next-pwaを使用する場合型定義のエラーが出てしまい対策が必要になってしまいます。

それとdisableregisterですが、disableを先に記述しないとprocess.env.NODE_ENVdevelopment時でもPWAのインストールを促されてしまいました(=registerが先だとそちらが優先されてしまう?)

この辺は詳しく見ていないのですが、もしかしたらフォークプロジェクトのお作法的なものかも知れません。

export const metadata: Metadata = {
  title,
  description: 'Generated by create next app',
  manifest: '/manifest.json',
  icons: {
    apple: '/icon512_maskable.png',
  },
};

manifest.jsonを作成し、それをapp/layout.tsxmetadata内に読み込ませています。

Next.jsのPWA化は全く知見がなかったので以下の記事が大変参考になりました!

Next.js14でPWA構築してみた – Qiita

Prisma

ORMはPrismaを採用。prisma generateで型定義が使用可能に。

今回は「使ってみたかった」という理由でORMにPrismaを採用しました。

本来はバックエンドであるRust側で用意した方が何かと都合が良いのかも知れませんが、TypeScriptプロジェクトで使うならめちゃくちゃ良いですね!

prisma generateをするだけでモデルが型として提供されるのは開発体験がすごく良かったです。

一方でPrismaでSQLiteを使う場合、enumに対応していなかったので少し工夫が必要でした。

これマジで対応してほしい・・・!

簡単なアプリなのでSQLiteを採用しましたが、MySQLやPostgreSQLを使った大規模なアプリでも一度採用してみたいと思う程度には開発体験の良さが素晴らしかったです。

model User {
  id           Int      @id @default(autoincrement())
  name         String   @unique
  lightId      String   @map("light_id")
  lightType    String   @map("light_type")
  registeredAt DateTime @default(now()) @map("registered_at")
  isDeleted    Boolean  @default(false) @map("is_deleted")
  plans        Plan[]
}

バックエンドのRustではスネークケースが推奨されてますしデータベースカラムとしてはやはりスネークケースの方が何かと良いのでスキーマファイル上で@mapを指定してスネークケース指定、フロント側ではlightIdregisteredAtのようにキャメルケースとして使い分けができるのですが、ここは他のORMのように一括指定できたら便利だなぐらいでしょうか(SequelizeやTypeORMでは一括指定できるっぽいので輸入してほしいなあ)。

Prisma studioも使い勝手がよく、全体的に今回体験して良かったです。

軽く調べているとMySQLやPostgreSQLでPrismaを使う場合それはそれで不満がある方が見受けられるので、一度ものは試しにそういうプロジェクトでも使ってみたいなと思っています。

ただしSQLiteで日時比較を行うのが辛い

これはプロジェクトの内容に依存するのですが、PrismaとSQLiteを使う時は日時比較が辛いです。

条件として「日時をPrismaでDatetime型で指定し、レコード取得時にgtelt等を使う」時です。

const tasks = await prisma.task.findMany({
  where: {
    date: {
      gte: new Date();
    },
  },
});

例えばこんなコード、この場合は条件比較が全くされずtaskテーブル内の全レコードが取得されてしまいます。

詳しい原因は分からないのですが、new Date()で生成される値は通常ISO 8601形式のはずです。

例えば2024-12-24T05:30:00.000Zのような形式です。

ところがPrismaの生成するパラメーターを出力してみると以下のような値が生成されていました。

Parameters: ["2024-12-18 00:00:00 UTC"]

2024-12-18 00:00:00 UTC部分がnew Date()の値です、これはISO 8601に似ている何かで、恐らくこの比較が失敗しているのでgte等を使った日時比較ができないんじゃないかな・・と予想しています。

ネット上には「Prisma+SQLiteを使って日時比較をする」という記事が沢山あり皆さんできているような書き方をされているのでこれが僕の環境に限ったものなのか、Prismaのバージョンでこうなってしまったのかの原因究明がまだできていません。

enumの件もそうですが、もしこれがPrismaとSQLiteにおける現象なら正直ちょっとつらいな・・・という印象です。

ちなみに解決策として、比較が必要なカラムは全てタイムスタンプを扱うようにしました。

model Task {
  id               Int       @id @default(autoincrement())
  planId           Int       @map("plan_id")
  date             Int       // dateはInt型を指定し、タイムスタンプを扱う
  isCompleted      Boolean   @default(false) @map("is_completed")
  completedAt      DateTime? @map("completed_at")
  lightActionCount Int       @map("light_action_count")

  plan Plan @relation(fields: [planId], references: [id])
}

本当は一部だけではなく全てタイムスタンプで統一するべきなんでしょうが、動けばとりあえず良いやで開発を進めていたので今後暇がある時にcompletedAtのようにDateTime型を指定している部分も変更していきたいなあと思っています。

Tailwind CSS

Tailwind CSSは正直かなり毛嫌いしていました。

export const Loading = () => {
  return (
    <div className="flex justify-center">
      <div className="size-10 animate-spin rounded-full border border-blue-400 border-t-transparent"></div>
    </div>
  );
};

な、長い・・・!!!!

と開発中もなんだかなあと思いながら使っていたんですが、これ一度慣れてしまえば爆速でUI組めるじゃんと気づいてからは開発前にあった抵抗感はだいぶ薄れました。

classを読んでいけばどんなスタイルが当たっているのか簡単に分かるので、CSSファイルを見たりCSS in JSのように定義している場所を見に行ったりという必要がない点も良い部分です。

const lightOnShadow = isStatus ? 'shadow-yellow-300' : '';

return (
  <li key={light.id} className="grid min-h-[134px]">
    <Link
      href={`/admin/lightInfo/${light.id}`}
      prefetch={false}
      className={`
        grid grid-cols-6 border-b-2 bg-gray-200 py-4 pb-2 shadow-md ${lightOnShadow}
      `}
    >

動的にスタイルを定義することもできますし、開発体験としては中々良いです。

ただし小さなコンポーネント単位ならめちゃくちゃ良いんですが、巨大なdiv等に何重もラップされているコードを一目で「なるほどこういう構成か」と判断するのはちょっと辛いかな・・・という印象です。

Tailwind CSSを使用すると頻繁にESLintに怒られがち。

それとモダンプログラミングでリンターを使わないのはあり得ないですが、Tailwindを使っていると頻繁にリンターが赤線を出してきて地味なストレスでした。

max-lenを120に設定していても頻繁に怒られるので、この辺りはやはりclass名が長くなる弊害ですね・・・。

<ul className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<div className="p-4 md:mx-auto md:grid md:w-full md:max-w-7xl md:grid-cols-2 md:gap-4">
  <div className="rounded-lg border bg-gray-50 p-4 md:col-span-1">
    <h2 className="mb-2 text-center text-lg">ユーザー情報</h2>
    <div className="grid grid-cols-2 [&>*]:text-center md:[&>*]:leading-loose">

レスポンシブ対応やら子セレクタやら疑似要素やらとどんどんやっているととんでもなく大変な印象です。

  • 慣れれば開発速度めちゃくちゃ早くなる
  • 思った以上にストレスが無い(けどリンターの指摘はうるさい)
  • でも後からリファクタリングしたり、(まだ意図が見えていない段階で)他人が書いたTailwind CSSはちょっと辛そう

開発前よりはだいぶ印象が良くなりました、特に開発速度に関してはいちいちCSSファイルを開いたりCSS定義のオブジェクトへ移動したりする必要が無いので小さな個人アプリでは積極的に利用したいと思います。

バックエンド

バックエンドと言ってますが実態はPhilips Hueに指示を出すのが主目的、いわゆるデータベースの書き込み等のAPIを提供するバックエンド的なものは実はNext.js側で実装してしまっています(Next.jsのServer Actions)。

なので今回のRustの立ち位置を正確に言うと、「cronで定期的に呼び出されるだけのコントローラー」的なものです。

正直全部Next.jsで実装しても良かったんですが、ちょっとしたCLIツール以外の動くものをRustで作ってみたかったというのが大きな選定理由です・・・!

動きとしては

  1. フロントで登録されたプランを元に毎日その日のtaskを登録
  2. taskのisCompletedをチェックしてfalse(=まだ薬飲んでない)ならライトに指示を出す

これだけです。

1はcronで毎日0時にその日のtask書き出し、2は1分間ごとにcronをぶん回しています。

[package]
name = "hue_medicine_notifier"
version = "0.1.0"
edition = "2021"

[dependencies]
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio-native-tls", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1.42.0", features = ["full"] }
reqwest = { version = "0.12.9", features = ["json"] }
serde_json = "1.0.133"
dotenv = "0.15"
futures = "0.3"

使っているクレートもメジャーなものばかりですし、プログラム的にも特段高度な何かをやっている訳ではない非常に小さなアプリです。

先述しましたが個人的には今までちょっとしたCLIを作る程度だったので新たな使い道ができて楽しくコードを書くことができました。

let users: Vec<User> = query_as!(
    User,
    r#"
    SELECT 
        id AS "id: i32",
        light_id AS "light_id: String",
        light_type AS "light_type: String"
    FROM User
    WHERE is_deleted = false
    ORDER BY id
    "#
)
.fetch_all(&pool)
.await?;

PrismaをORMに採用しているのでフロント側ではキャメルケース、Rustではスネークケースをストレス無く採用できたのは結構良かったところかなあと思っています。

rust-analyzer、ちょっとうるさすぎる・・・。

余談ですがせっかくRustをちゃんと書くようになったので、VSCodeの環境もこれを機にもうちょっと整えたいところです。

rust-analyzerめちゃくちゃ便利なんですかちょっとうるさすぎる。

実行環境

先述したようにDS720+上にDockerコンテナを建てて稼働させています。

Synology NAS DS720+

SynologyのNASは個人向けだとjシリーズ、valueシリーズ、plusシリーズの3ラインナップがありますがjシリーズを除いてDockerのGUIツールが利用できます。

GUIだとできる事は限られますが、ローカルでSSHで繋いであげればネイティブなdockerコマンドがそのまま使えるので実行環境としては申し分無いです。

NASという特性上、常時電源は入っていますからね!

とはいえスペック的に過剰な事はできないんですが、今回のようにちょっとしたWebアプリとバックグラウンドでAPI叩く何かをぶん回すぐらいなら必要十分です。

執筆現在は後続のDS723+が最新モデルで、僕のDS720+と比較してCPUがIntel CeleronからAMD Ryzenになったりメモリが最大6GBから最大32GBになったりと大幅にパワーアップしています。

年末の大型連休にNAS with Docker開発、めちゃくちゃおすすめです!

DS723+は2ベイモデル、4ベイモデルのDS923+もあります。

valueシリーズのDS223なら初期費用がだいぶ抑えられます、本当に簡単なコンテナしか動かさないならこっちで十分かも。

Dockerコンテナ

Dockerコンテナは2つのコンテナを組み合わせています。

両方とも同じSQLiteファイルをvolumesに指定しています。

先述した通りいわゆるデータベース処理という意味でのバックエンド処理はNext.jsで完結してしまっているので、両コンテナは「同じデータベースを参照しているだけ」であり直接的な結合はありません。

build.sh

今回フロントエンド側のコンテナ内でSQLiteの初期化(Prisma migrate)を実施している関係で、確実にコンテナビルドの順番を遵守する必要がありました。

  1. webコンテナ(Next.js)
  2. processorコンテナ(Rust)

そこで直接docker composeを叩くのではなく、シェルスクリプト経由でビルドをするようにしています。

#!/bin/bash

set -e

# Step 0: .envファイルチェック / db ディレクトリの存在確認または作成
if [ ! -f ./.env ]; then
  echo "Error: .env file not found in project root!"
  echo "Please create a .env file before building."
  exit 1
fi

if [ ! -d ./db ]; then
  echo "db directory does not exist. Creating it..."
  mkdir -p ./db
  echo "db directory created."
else
  echo "db directory already exists."
fi

# Step 1: web サービスのビルド
echo "Building web service..."
docker-compose build web --no-cache

# Step 2: web サービスの起動
echo "Starting web service..."
docker-compose up -d --force-recreate web

# Step 3: app.db の生成を待機
echo "Waiting for app.db to be generated..."
while [ ! -f ./db/app.db ]; do
  sleep 1
done

echo "app.db detected."

# Step 4: processor サービスのビルド
echo "Building processor service..."
docker-compose build processor

# Step 5: processor サービスの起動
echo "Starting processor service..."
docker-compose up -d --force-recreate processor

echo "All services have been successfully started."

ちなみにSynology NASはなぜかdocker composeコマンドが使えず、仕方なく現在はdocker composeのエイリアスになっているdocker-composeを使っています。

$ docker -v
Docker version 24.0.2, build 610b8d0
$ docker compose version
docker: 'compose' is not a docker command.
$ docker-compose -v
Docker Compose version v2.20.1-6047-g6817716

バージョン的には対応しているはずですが、この辺りは詳しく調べて対応したいなあと思っています。

compose.yaml
version: "3.9"

services:
  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    ports:
      - "52000:52000"
    environment:
      - NODE_ENV=production
      - BRIDGE_IP=${BRIDGE_IP}
      - BRIDGE_ID=${BRIDGE_ID}
      - ACCESS_TOKEN=${ACCESS_TOKEN}
      - DATABASE_URL=file:/data/app.db
      - CERT_PATH=${CERT_PATH}
    volumes:
      - ./db:/data

  processor:
    build:
      context: ./
      dockerfile: ./processor/Dockerfile
      args:
        - BRIDGE_IP=${BRIDGE_IP}
        - ACCESS_TOKEN=${ACCESS_TOKEN}
        - DATABASE_URL=file:/data/app.db
    volumes:
      - ./db:/data
    depends_on:
      - web
フロントエンド用コンテナ

フロントエンド側はNext.js用のコンテナでPrismaのビルドまでやっています。

FROM node:18-slim

RUN apt-get update && apt-get install -y \
    openssl \
    libssl-dev \
    && apt-get clean

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm install

COPY prisma ./prisma
RUN npx prisma generate --schema=prisma/schema.prisma

COPY . .

# 一時ディレクトリでデータベースを初期化
RUN mkdir /tmp-db && \
    DATABASE_URL="file:/tmp-db/app.db" npx prisma migrate deploy --schema=prisma/schema.prisma && \
    cp /tmp-db/app.db /app/init_app.db && \
    rm -r /tmp-db

# エントリーポイントスクリプトを作成
# SQLiteファイルの存在確認を行い、存在しない場合は初期化用のファイルをコピーする
RUN echo '#!/bin/sh\n\
if [ ! -f /data/app.db ]; then\n\
  echo "Initializing database...";\n\
  cp /app/init_app.db /data/app.db;\n\
fi\n\
exec "$@"' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh

RUN npm run build

ENTRYPOINT ["/app/entrypoint.sh"]

CMD ["npm", "start"]

肝はPrismaのmigrationです。

今回ローカルのdbディレクトリで永続化していますが、個人的にはdocker compose upだけでパパっと初期化してほしい・・・。

ですが永続化したvolumeにビルドプロセスで書き込むことはできないので一度tmp-dbディレクトリを作ってそこに向けてinit_app.dbという名前でmigrationします。

その後init_app.dbapp.dbという名前で/dataディレクトリにコピーして配置、これでRust側からもローカル側からも見えるようになります(/app/entrypoint.sh)。

もちろん/data/app.dbが元からある場合はmigrationを行わないのでローカルで事前に作成することも可能です。

バックエンド用コンテナ

Rust側はマルチステージビルドを採用しています。

rust-slimイメージを使ってビルド、その後debian-bookworm-slimイメージで稼働と行った形ですね。

# ステージ1: ビルドプロセス
FROM rust:1.83-slim AS builder

RUN apt-get update && apt-get install -y \
    pkg-config \
    libssl-dev \
    && apt-get clean

WORKDIR /app

RUN cargo install sqlx-cli --no-default-features --features sqlite

COPY ../db/app.db /data/app.db
ENV DATABASE_URL=sqlite:/data/app.db

COPY ./processor .

RUN cargo build --release --jobs $(nproc)

# ステージ2: 実行プロセス
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    cron \
    libssl-dev \
    && apt-get clean

WORKDIR /app

ARG DATABASE_URL BRIDGE_IP ACCESS_TOKEN

RUN echo "DATABASE_URL=${DATABASE_URL}" >> /etc/environment && \
    echo "BRIDGE_IP=${BRIDGE_IP}" >> /etc/environment && \
    echo "ACCESS_TOKEN=${ACCESS_TOKEN}" >> /etc/environment

COPY --from=builder /app/target/release/hue_medicine_notifier /app/hue_medicine_notifier
COPY --from=builder /app/target/release/scheduler /app/scheduler

RUN mkdir -p /var/log/cron && \
    echo "* * * * * . /etc/environment && /app/hue_medicine_notifier >> /var/log/cron/hue_medicine_notifier.log 2>&1" > /etc/cron.d/app_cron && \
    echo "0 0 * * * . /etc/environment && /app/scheduler >> /var/log/cron/scheduler.log 2>&1" >> /etc/cron.d/app_cron && \
    chmod 0644 /etc/cron.d/app_cron && \
    crontab /etc/cron.d/app_cron

CMD ["cron", "-f"]

コンパイル後はcronで実行します。

  • hue_medicine_notifier・・・データベースのtaskをチェックし、条件に合致したら該当するライトを操作
  • scheduler・・・毎日定時にplanをチェックし、条件に合致したらtaskを登録

システム上各ユーザーが1つのライトに割り当てられています(これはWebアプリ上でどのライトなのか任意に選ぶことが可能)。

そのためユーザーごとに処理を非同期で並列実行させており、例えば僕と子どもが二人とも薬を飲み忘れていたら2つのライトがブチギレる事になります。いやーこわい!

あんまりRustの並列に関して取り組んだことがなかったのでChatGPTに壁打ちしながら実装しましたが、さすがRustというかtokioというか、簡単に並列処理が書けていいですね!

リバーシプロキシ

通常自宅サーバーにサーバーを建てる場合別途ポート開放やら何やら必要になりますが、今回はSynology NASに用意されている外部公開用のURLに対してサブドメインを設定し、面倒なポート周りの設定をスルーしてアクセスできるようにしました。

これだけではよく分からないと思うので以下解説です!

まずSynology NASにはDDNSサービスがあり、GUIをちょっと操作するだけで簡単に無料で外部からURLアクセスできるようになります。

例えばhttps://<customName>.synology.me/のような形式で、customName部分は自由に決められます。

SynologyのNASには別途QuickConnectがありますが、今回はDDNSの方です。

通常上記のURLにアクセスするとSynology NASのOSであるDSMのGUIが表示されるんですが、リバーシプロキシを使ってsubdomain.example.synology.meというURLでアクセスするように設定することが可能です。

当然このサブドメイン用にもLet’s Encryptの証明書取得が可能なのでHTTPSアクセスもOKと至れり尽くせりです。

もちろん外部公開しているのでセキュリティ面がという話はありますが、ともかく簡単に公開できるのは開発者として手軽で本当に嬉しいですね!

Philips Hue

最後にシステムのコアとも言えるのがPhilips Hue、我が家はブリッジ1台とライトが19個の構成です。

主にカラーライト、ストリップライトの2つを使っており寝室等調光が必要ない場所にはホワイトモデル等を使用しています。

created by Rinker
フィリップスライティング(Philips Lighting)

Philips HueはAPIが公開されており、現在v2がリリースされています。

Philips Hueをお持ちの方はLAN内からhttps://discovery.meethue.comにアクセスするとIPアドレスが取得できると思います、ここで取得できるIPアドレスに向けて色々操作をする訳ですね。

APIはローカル内で操作できるローカルAPIと、インターネットを通じて操作できるクラウドAPIの2種類があります。

今回は家庭内でのみ使用するのでローカルAPIです。

ちなみにAPIを使用するにはブリッジが必要になります。

created by Rinker
フィリップスライティング(Philips Lighting)
Hue API v2

Hue API v2ではv1から様々な変更がありました。

  • HTTPS通信のみサポート
  • ヘッダーにhue-application-keyが必要
  • 値の名称変更(例:ctcolorTemperaturebribrightness
  • 値の範囲・種類変更(例:v1ではbrightnessが1~254だったのが、0~100のパーセンテージ)
  • API pathの変更

ネット上ではどちらかと言うとv1の情報が多く、v2の情報はそんなに存在していません。

そしてなぜかPhilips HueのAPIリファレンスは会員登録をしないと見れません(パブリックに公開してほしい・・・)。

前者はv1からの移行ガイドやクラウドAPIの使い方等が掲載されおり、後者がAPIリファレンスです。

ライトの操作に関しては次のように変更がありました。

const url = `http://${hueBridgeIp}/api/${accessToken}/lights/${lightId}/state`;
const body = {
  on: true,            // 点灯
  bri: 150,            // 明るさ、1~254
  xy: [0.3127, 0.329], // カラーライトの場合xy座標で色を指定(hue: 色相やsat: 彩度でも可)
};
const response = await fetch(url, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(body),
});

v1ではこのような方法でライトの状態変更を行っていました、v2では以下のような形です。

const url = `https://${hueBridgeIp}/clip/v2/resource/light/${lightId}`;
const body = {
  on: { on: true },
  dimming: { brightness: 100 }, // 0~100のパーセンテージ指定
  color: xy: { x: 0.3127, y: 0.329 },
};
const response = await fetch(url, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'hue-application-key': accessToken,
  },
  body: JSON.stringify(body),
});

accessTokenはURLではなくヘッダーにhue-application-keyとして指定する必要があります。

今回のアプリでは実際の操作はRust側で行っていますが、そんなに速度が重要になる訳ではないのでNode.jsやDeno等でTypeScript(JavaScript)で十分動かせます。

ライトの状態取得もAPIが充実して様々な情報を取得できるようになっています。

v1
{
  "5": {
    "state": {
      "on": true,
      "bri": 254,
      "hue": 39563,
      "sat": 15,
      "effect": "none",
      "xy": [
        0.3669,
        0.3706
      ],
      "ct": 230,
      "alert": "lselect",
      "colormode": "ct",
      "mode": "homeautomation",
      "reachable": true
    },
    "swupdate": {
      "state": "noupdates",
      "lastinstall": "2024-05-30T05:15:19"
    },
    "type": "Extended color light",
    "name": "Hue color lampデスク左",
    "modelid": "LCT010",
    "manufacturername": "Signify Netherlands B.V.",
    "productname": "Hue color lamp",
    "capabilities": {
      "certified": true,
      "control": {
        "mindimlevel": 1000,
        "maxlumen": 806,
        "colorgamuttype": "C",
        "colorgamut": [
          [
            0.6915,
            0.3083
          ],
          [
            0.17,
            0.7
          ],
          [
            0.1532,
            0.0475
          ]
        ],
        "ct": {
          "min": 153,
          "max": 500
        }
      },
      "streaming": {
        "renderer": true,
        "proxy": true
      }
    },
    "config": {
      "archetype": "sultanbulb",
      "function": "mixed",
      "direction": "omnidirectional",
      "startup": {
        "mode": "custom",
        "configured": true,
        "customsettings": {
          "bri": 254,
          "ct": 153
        }
      }
    },
    "uniqueid": "00:17:88:01:04:71:8a:17-0b",
    "swversion": "1.116.3",
    "swconfigid": "DDE4D5E9",
    "productid": "Philips-LCT010-1-A19ECLv4"
  },
}
v2
{
  "id": "8810b3b5-f6a8-4d06-9c40-649d1d3ce445",
  "id_v1": "/lights/5",
  "owner": {
    "rid": "9d1de9de-2029-42c2-a577-8dfad3a242b5",
    "rtype": "device"
  },
  "metadata": {
    "name": "Hue color lampデスク左",
    "archetype": "sultan_bulb",
    "function": "mixed"
  },
  "product_data": {
    "function": "mixed"
  },
  "identify": {},
  "service_id": 0,
  "on": {
    "on": true
  },
  "dimming": {
    "brightness": 100,
    "min_dim_level": 1
  },
  "dimming_delta": {},
  "color_temperature": {
    "mirek": 230,
    "mirek_valid": true,
    "mirek_schema": {
      "mirek_minimum": 153,
      "mirek_maximum": 500
    }
  },
  "color_temperature_delta": {},
  "color": {
    "xy": {
      "x": 0.3669,
      "y": 0.3706
    },
    "gamut": {
      "red": {
        "x": 0.6915,
        "y": 0.3083
      },
      "green": {
        "x": 0.17,
        "y": 0.7
      },
      "blue": {
        "x": 0.1532,
        "y": 0.0475
      }
    },
    "gamut_type": "C"
  },
  "dynamics": {
    "status": "none",
    "status_values": [
      "none",
      "dynamic_palette"
    ],
    "speed": 0,
    "speed_valid": false
  },
  "alert": {
    "action_values": [
      "breathe"
    ]
  },
  "signaling": {
    "signal_values": [
      "no_signal",
      "on_off",
      "on_off_color",
      "alternating"
    ]
  },
  "mode": "normal",
  "effects": {
    "status_values": [
      "no_effect",
      "candle",
      "fire",
      "prism",
      "sparkle",
      "opal",
      "glisten"
    ],
    "status": "no_effect",
    "effect_values": [
      "no_effect",
      "candle",
      "fire",
      "prism",
      "sparkle",
      "opal",
      "glisten"
    ]
  },
  "effects_v2": {
    "action": {
      "effect_values": [
        "no_effect",
        "candle",
        "fire",
        "prism",
        "sparkle",
        "opal",
        "glisten"
      ]
    },
    "status": {
      "effect": "no_effect",
      "effect_values": [
        "no_effect",
        "candle",
        "fire",
        "prism",
        "sparkle",
        "opal",
        "glisten"
      ]
    }
  },
  "powerup": {
    "preset": "custom",
    "configured": true,
    "on": {
      "mode": "on",
      "on": {
        "on": true
      }
    },
    "dimming": {
      "mode": "dimming",
      "dimming": {
        "brightness": 100
      }
    },
    "color": {
      "mode": "color_temperature",
      "color_temperature": {
        "mirek": 153
      }
    }
  },
  "type": "light"
}
証明書

今回苦労したのが、API v1ではhttpでのアクセスだったんですがv2になってhttpsでの接続に限られるようになりました。

調べるうちに最近のブリッジは第三認証局発行のCA証明書がありブリッジとのHTTPS認証はそれで行えるそう。

ところが古いブリッジは自己証明書のみで、Philips Hueのリファレンス曰く順次CA証明書に切り替わるらしいのですが少なくとも現時点で我が家では使えません。

しょうがないのでこの自己証明書を使って認証したいのですがなんとSAN値がありません。

# opensslコマンドで証明書情報を取得
# 下記の場合、subjectもissuerもPhilips Hueなので自己証明書
$ openssl s_client -connect <ブリッジのIPアドレス>:443
subject=C = NL, O = Philips Hue, CN = <ブリッジ固有のID>
issuer=C = NL, O = Philips Hue, CN = <ブリッジ固有のID>

$ openssl x509 -in cert/hue-bridge-cert.pem -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        # 中略
        # Philips Hueのブリッジの自己証明書にはSAN値が無いため、暗黙的にIssuerのCNが対象ドメイン
        Issuer: C = NL, O = Philips Hue, CN = <ブリッジ固有のID>

という事はアクセスをブリッジ固有のIDに対して行い、こちらで名前解決を行う必要があり少し面倒でした。

以下がそのコードです。

import https from 'https';
import fs from 'fs';

export const httpsAgent = () => {
  const bridgeIp = process.env.BRIDGE_IP;
  const bridgeId = process.env.BRIDGE_ID;
  const certPath = process.env.CERT_PATH;

  if (!bridgeIp || !bridgeId || !certPath) {
    throw new Error('環境変数が設定されていません');
  }

  return new https.Agent({
    ca: fs.readFileSync(certPath),
    rejectUnauthorized: true,
    checkServerIdentity: (hostname) => {
      if (hostname !== bridgeId) {
        throw new Error(`Hostname mismatch: expected "${bridgeId}" but got "${hostname}"`);
      }

      return undefined;
    },
    lookup: (hostname, options, callback) => {
      if (options?.all) {
        if (hostname === bridgeId) {
          return callback(null, [{ address: bridgeIp, family: 4 }]);
        }

        return callback(new Error(`Unknown hostname: ${hostname}`), []);
      } else {
        if (hostname === bridgeId) {
          return callback(null, bridgeIp, 4);
        }

        return callback(new Error(`Unknown hostname: ${hostname}`), '', 4);
      }
    },
  });
};

https.agentで証明書の読み込み、hostnamebridgeIdと一致するか確認します。

lookup関数内でbridgeIdbridgeIpに解決している、という流れです。

const response = await nodeFetch(endPoint, {
  method: 'GET',
  headers: {
    'hue-application-key': accessToken,
    'Content-Type': 'application/json',
  },
  agent: httpsAgent(),
});

後はこんな感じにfetch時のagentに渡してあげればOKです。

ちなみに直接実装を見てはいないのですがNext.jsのfetchはWeb APIのfetchのラッパーであり、Node.jsのfetchのラッパーではないのでカスタムエージェントが使えないようです(クライアントサイドのfetch)。

そのためnode-fetchimportしてカスタムエージェントを渡しています。

第三者認証のCA証明書が使えるとこんな面倒なことをしなくて良いので早くHue側の対応を期待したいですね、もしかしたらHeuブリッジの初期化で反映されるかもなーと思いつつ面倒でやっていません・・・笑。

気軽に技術でぶん殴ろう!

先述したように今回は子どもがいかに楽しく、薬の飲み忘れを防ぐことができるかが目的の開発でした。

主に家庭内でのみ使うので興味本位で今まで使ったことがない技術を採用してみましたが、現代はリファレンスの充実+生成AIのおかげでかなり楽に開発ができました。

Prisma+SQLiteのwhere条件における日時比較やPhilips Hueの認証周りは少し苦労しましたがほとんどサクサク開発できたかなあと思います。

ただ結構駆け込みというか「とりあえず動けば良いや」で作ったので、例えばNext.jsやTailwind CSS等のあまり経験が無いお作法的な部分なんかはかなり端折ってしまっています。

また、ライトのタイプが違う場合の変更の処理等はまだ実装していないので必要であれば今後実装する必要があります(カラータイプからホワイトタイプへの変更等)。

IoTライトはネット上を探してみても「天気予報の通知」だったり「未読メールがあれば」のような物が大半で、まだまだ可能性があるものだと思っています。

例えば聴覚障害がある方だったり、今回の僕のケースのように自分専用のデバイスを持っていない幼児~子どもの場合でも利用できます。

もっと色んな使い方があるはずなので、継続してキャッチアップして色々作っていきたいなあと考えています。

普段仕事でコードを書いているとそれがどう利益になるのかを追求しがちですが、こうやって「技術で何かを解決する」は本当に楽しく開発できるのでぜひ皆さんも身の回りの困ったを技術でぶん殴って行きましょう!!!!

スポンサーリンク
プログラミング
スポンサーリンク
\この記事いいね!と思ったらシェアしてね/
スポンサーリンク

コメント

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