家族の予定は基本的に共有カレンダーで管理しているが「みんなが見れる用」として食卓近くに卓上アナログカレンダーが置いてあり、何年もの間、二重管理になっていた。ずっと気になってはいつつも、まぁ運用できてるし、単にカレンダー表示するだけでもまぁまぁ面倒なのでそのままだったが、この度 Raspberry Pi でサイネージを作ることにした。

構成はシンプルで、15インチの 1080p モニタと、余っていた Raspberry Pi 3 B (WiFi付き)

CUIUIC モバイルモニター 15.6インチ ポータブルモニター モバイルディスプレイ 1920x1080FHD IPS液晶パネル 400Nits 100%広色域 Tpye-C/mini HDMI/スピーカー内蔵 PS4/PS5/XBOX/Switch/PC/Macなど対応 保護カバー付き - CUIUIC

CUIUIC

4.0 / 5.0

できること

  • Google Calendar の表示 (ほぼ Google Calendar互換の表示をする。複数日とかも)
  • 天気予報 (気象庁のXMLから取得している)
  • 時計 (西暦・和暦・日付曜日時間)

10分ごとに内容を更新する。

https://github.com/cho45/my-sinage

モニタ解像度

Raspberry Pi のほとんどは 1080p までしかスムーズに出力できないので、あまり高精細なディスプレイを買ってはいけない。1920x1280 のモバイイルディスプレイをいくつか持っているけど、モニタとの相性もあるのか、まともに表示させることができなかった。素直になってください。1080pのモニタを買えばいいです。(2日ぐらい無駄にした)

15インチのモニタは微妙にデカいけど、カレンダーをフルで表示させるならこのぐらい大きくてもいいかなという印象。捨てるときのこと考えるとあんまり大きいもの買いたくないのよね……

モニタの固定

「モバイルモニタ」カテゴリのものは固定方法が考えられてない。VESAマウント? そんなものはない。ただのスレートです。

ということで3Dプリンタでスタンドを作り、棚板にビス止めすることにした。安定。

ウェブアプリ

認証情報を保持してカレンダーの画面を出すウェブサーバを立てている。これは Raspberry Pi 上ではなく、QNAP 上の Container Station で docker image を動かしている。QNAP はうちだとIP アドレス固定で使っているので管理しやすい。

ディスプレイが繋がっているサイネージ用の Raspberry Pi はブラウザでURLを開くだけ。

スマフォとかから管理画面を開いて強制リロードのシグナルを送ったりできるようにしてある。ウェブアプリ側を更新したときに呼ぶ用。

開発

ほぼほぼ Claude Code にやらせた。ウェブアプリ、ちょいちょいクソコードは書くものの、特にクライアントサイドを書かせるのは割とすんなりやってくれる。

サーバサイドはなんか結構ガイドしないとちゃんとやってくれない。

夜中は消灯

cron で 02:00 に画面を消灯、06:00 に点灯という制御を入れてある。

最近の Raspberry Pi OS (bookworm) は labwc というWayland コンポジタ実装が使われているが、あんまりこれの情報がないのでめんどくさい。

XDG_RUNTIME_DIR=/run/user/1000
 
 # 02:00 にオフ
 0 2 * * * /usr/bin/wlr-randr --output HDMI-A-1 --off
 # 06:00 にオン
 0 6 * * * /usr/bin/wlr-randr --output HDMI-A-1 --on --mode 1920x1080

この手のものはいかに運用を楽にするかというのが問題。「うまく動かない」場合にメンテできるのが自分しかいないからだ。

  1. トップ
  2. tech
  3. おうちサイネージ (食卓カレンダー)

昨今の QNAP には Container Station というコンテナイメージを実行するためのオフィシャルなアプリケーションが提供されている。これを使えば特にQNAP用というわけではないコンテナイメージも自由に動かせて嬉しい。

例えば VOICEVOX のAPIを自宅内で自由に使えたらな~ と思うと、サーバとして常時動いていて、CPU的にも比較的遊びのある QNAP で動かせると嬉しい。HTTP リクエストさえできれば自宅内の Raspberry PI だろうがなんだろうが低スペPCからも音声合成ができる。

https://hub.docker.com/r/voicevox/voicevox_engine

Docker Hub にオフィシャルなイメージがあるので、これを pull する

Create Container で Publish New Port して 50021 を指定する。これで 192.168.5.250:50021 でアクセス可能になる。これだけ

 curl -X 'GET' \
   'http://192.168.0.177:50021/speakers' \
   -H 'accept: application/json'
   
   [
     {
       "name": "四国めたん",
       "speaker_uuid": "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff",
       "styles": [
         {
           "name": "ノーマル",
           "id": 2,
           "type": "talk"
         },
         {
           "name": "あまあま",
           "id": 0,
           "type": "talk"
         },
         {
           "name": "ツンツン",
           "id": 6,
           "type": "talk"
         },
         {
           "name": "セクシー",
           "id": 4,
           "type": "talk"
         },
         {
           "name": "ささやき",
           "id": 36,
           "type": "talk"
         },
         {
           "name": "ヒソヒソ",
           "id": 37,
           "type": "talk"
         }
       ],
       "version": "0.15.9",
       "supported_features": {
         "permitted_synthesis_morphing": "SELF_ONLY"
       }
     },
     {
         "name": "ずんだもん",
         "speaker_uuid": "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9",
         "styles": [
           {
             "name": "ノーマル",
             "id": 3,
             "type": "talk"
           },
           {
             "name": "あまあま",
             "id": 1,
             "type": "talk"
           },
           {
             "name": "ツンツン",
             "id": 7,
             "type": "talk"
           },
           {
             "name": "セクシー",
             "id": 5,
             "type": "talk"
           },
           {
             "name": "ささやき",
             "id": 22,
             "type": "talk"
           },
           {
             "name": "ヒソヒソ",
             "id": 38,
             "type": "talk"
           },
           {
             "name": "ヘロヘロ",
             "id": 75,
             "type": "talk"
           },
           {
             "name": "なみだめ",
             "id": 76,
             "type": "talk"
           }
         ],
         "version": "0.15.9",
         "supported_features": {
           "permitted_synthesis_morphing": "SELF_ONLY"
         }
       },
  1. トップ
  2. tech
  3. QNAP を VOICEVOX 音声合成サーバにする

こういうのが500円で売っている。なんとなく買ってみた。

設定ツール (MINI KeyBoard.exe) が Google Drive で共有されて、そのQRコードがマニュアルにはってある。どう考えても実行するのは怖いので、まずは解析して設定ツールを自分で書くところからはじめる。

まぁそもそも、信頼できないデバイスをUSBで繋ぐなというのが正論だと思うが……

ILSpyで解析

MINI KeyBoard.exe は .NET アプリのようなので、ILSpy でデコンパイルできる。変なコードはとりあえず含まれていない。たぶん HID Report でなんかしてるだろうから、デコンパイルしたやつを Claude Code に読ませてプロトコルを解析してもらった。といってもそんなに精度高く解析はしてくれないので、結局自分でかなり勘を働かせて指示してやる必要があったけど……

WebHIDで再実装

ソフトは複数のミニキーボードに対応するものっぽいが、自分が持ってるのだけ設定できるツールを WebHID を使って作ってみた。というか Claude Code に作ってもらった。これに関しては完全に指示しかしてないはず。

https://github.com/cho45/webhid-mini-keyboard-configurator

WebHIDのセキュリティ……

Windows の Chrome / Edge で WebHID つかって動くことは確認済みなのだが、残念なことに動かないことがある。Chrome は WebHID に制限をかけていて、汎用キーボードっぽいものとかに対してはレポートの送受信ができないようになっている。この制限にひっかかったりひっかからなかったりする。

開発しはじめて動くまではまったくひっかからなかったのだが、できてからいろいろ試しはじめたらエラーが出たり出なかったりするように。Edge だと動いたり、しばらくたつと動かなかったり。かなりおかしな挙動をする。わけわかんない。

WebHID で作れば変なソフトインストールしないですむやん、というのが嬉しいポイントだけど、こんな挙動だと使えない。こまったねえ

関係ありそうなgithub project

カスタムファームウェア

そもそもファームから変えちゃおうってやつ

  1. トップ
  2. tech
  3. Aliexpress で買ったミニキーボードを WebHID で設定する

UHS-II 対応で USB 3.2 Gen1 接続し、最大 200MB/s ぐらいの読み込み速度が出るはずなのに、速度が出ないときの原因

40MB/s ぐらいで止まる

USB2.0 が 480Mbps(60MB/s)なので、実効速度40MB/s程度ぐらいで止まりうる。

原因

  • USB Type-A をゆっくりさしこむと USB2.0 で接続されてしまうことがある
    • 3.0 用の接点が奥にあるため
  • ケーブルが USB3.0 に対応していない (無結線)
    • 充電用ケーブルはダメです。柔らかいケーブルはだいたいダメです
  • ケーブルのコネクタとデバイスの相性が悪い (接触不良)
    • デバイス側コネクタが劣化してたりするとありうる
  • ケーブルの品質が低い
    • ケーブル品質が低すぎてUSB3.0のネゴシエーションに失敗すると USB2.0 にフォールバックします。

試してみること

  • Type-A なら一気に刺しこむ
  • Type-C で接続する
  • ケーブルをいろいろ変える
  • デバイスを変えてみる

90MB/s ぐらいで止まる

SD カードが UHS-I動作(104MB/s理論値)している可能性がある

原因

  • カードリーダーが非対応
  • カードリーダーの接触不良
  • カードの接点不良

試してみること

  • カードリーダーを変える
  • 接点復活剤をかけてみる
  1. トップ
  2. tech
  3. SDカードリーダーの速度が出ないときの挙動チェックリスト

5Gbps / 10Gbps 対応の通信用ケーブルは購入したら必ずベンチをとるべき。

というのも粗悪品であっても再送制御などで実行速度が下がるだけで通信できることが多いため、気付きにくいから。

1GB/s で読める SSD なのに、400MB/s でしか読めないということが起こりうる (10Gbps でリンクできず、5Gbpsにフォールバックしている)。一見動いてしまうので気付きにくいが実行速度が半分以下なのは相当信号状態が悪い。まずできるだけ短くて信頼できるメーカーの通信ケーブルを買うべき。

ケーブルやコネクタの見た目がよくても騙されてはいけない。うちでいろいろ検証した結果、エレコムの見た目しょぼいケーブルのほうが一番まともだった。

エレコム USBケーブル Type C (USB A to USB C) 1.0m USB3.1認証品 3A出力 最大10Gbps ブラック USB3-AC10NBK - エレコム(ELECOM)

エレコム(ELECOM)

5.0 / 5.0

スループットの目安

  • USB 2.0 480Mbps ~35-40MB/s
  • USB 3.2 Gen1 5Gbps ~400-450MB/s
  • USB 3.2 Gen2 10Gbps ~800-1000MB/s
  • USB 3.2 Gen2x2 20Gbps ~1600-1700MB/s

USB3はLTSSM(Link Training and Status State Machine)のトレーニングプロセスでリンクスピードを決めている。ここで10Gbpsでリンクできる品質にないと判断されると下の規格にフォールバックする。

10Gbps 以上は特に物理的に厳しい

1m以上で 10Gbps を謳っているものはまず嘘なので買うべきではない。10Gbps のパッシブな「延長ケーブル」もありえない。

  1. トップ
  2. tech
  3. 粗悪品のUSBケーブルが多いので買ったら必ずベンチをとるべき

https://www.uwe-sieber.de/usbtreeview_e.html USB Device Tree Viewer を使うのが早い。Windows 標準機能ではわからない。クソ (macOS のあなたは「システム情報」→「ハードウェア」→「USB」でわかります。良かったですね)

USB Device Tree Viewer の表示もそれほど分かりやすいとはいえない。以下が頭に入ってないといけない。

  • Low Speed = 1.5 Mbps (USB 1.0+)
  • Full Speed = 12 Mbps (USB 1.1+)
  • High Speed = 480 Mbps (USB 2.0)
  • SuperSpeed = 5 Gbps (USB 3.0 = USB 3.1 Gen1 = USB 3.2 Gen1)
  • SuperSpeed+ = 10 Gbps (USB 3.1 Gen2 = USB 3.2 Gen2)

アイコンにSがついてればSuperSpeed以上、HはHigh Speed、F は Full Speed

そして USB3系と USB2系は完全に別レイヤーで二重になっているという点を理解している必要がある。例えば USB3 のハブを繋いだら、必ず USB3 のハブと USB2 のハブが2つ表示され、ポートもそれぞれ別々に表示される。


3.2 Gen 2 (10Gbps) のハブ

       ========================== Summary =========================
 Vendor ID                : 0x05E3 (Genesys Logic, Inc.)
 Product ID               : 0x0625
 USB Version              : 3.2 Gen 2
 Port maximum Speed       : SuperSpeedPlus or higher
 Device maximum Speed     : SuperSpeedPlus or higher
 Device Connection Speed  : SuperSpeedPlus or higher
 Self powered             : yes
 Demanded Current         : 0 mA
 Used Endpoints           : 2

USB 2.0 のハブ (ただし USB3.0 のハブと物理的にポートを共有している)

       ========================== Summary =========================
 Vendor ID                : 0x1397 (Behringer Spezielle Studiotechnik GmbH)
 Product ID               : 0x0507
 USB Version              : 2.0
 Port maximum Speed       : High-Speed (Companion Port 1-20-4 is doing the SuperSpeed and SuperSpeedPlus or higher)
 Device maximum Speed     : High-Speed
 Device Connection Speed  : High-Speed
 Self powered             : no
 Demanded Current         : 500 mA
 Used Endpoints           : 1

USB 2.0 でポートスピードと接続スピードが違う場合

USB 3系と USB 2系は完全に分離しているので、Port maximum Speed が SuperSpeed で Device Connection Speed が Full-Speed ということは起こらない。

       ========================== Summary =========================
 Vendor ID                : 0x045E (Microsoft Corporation)
 Product ID               : 0x07A5
 USB Version              : 2.0 -> but Device is Full-Speed only
 Port maximum Speed       : High-Speed
 Device maximum Speed     : Full-Speed
 Device Connection Speed  : Full-Speed
 Self powered             : no
 Demanded Current         : 100 mA
 Used Endpoints           : 4
  1. トップ
  2. tech
  3. WindowsでのUSBリンクスピード

文字スクロール動画ジェネレーターというのを作った。

別のプロジェクトでffmpeg.wasmを使ってて面白かったので、もうちょっとわかりやすい応用を作ってみようという感じ。

Twitter はほぼそのまま再生されるけど、 Bluesky だとフレームレートが低くなっちゃうっぽい?

MediaRecorder vs ffmpeg.wasm

このジェネレータでは使ってないが、MediaRecorderというブラウザでcanvasを録画する標準機能がある。面白い機能なんだけどいくつか問題がある

  • 出力フォーマットがブラウザ依存かつ限られる
    • Chrome だと vp9+webm とか
    • ダウンロードして編集ソフトに持っていくと読みこめなかったりする
  • フレームレート可変になる
    • これもブラウザ依存だけど、canvas の dirty さを監視してて dirty なフレームしか記録しないようだ
    • ダウンロードして編集ソフトに持っていくと読みこめなかったりする
  • リアルタイムでしか収録できない
    • n秒録画するためにはn秒レンダリングして待つ必要がある
  • 尻切れ
    • なんかよくわからないが stop() したタイミングでうまく canvas を読んでくれない? みたい

ということで面白いけど使いにくい機能になっている。

「ダウンロードして編集ソフトに持っていくと読みこめなかったりする」を解決するために、MediaRecorder で録画したファイルを ffmpeg.wasm に読みこませてトランスコードする、という方針をとったりしていたけど、だったら直接 ffmpeg.wasm にフレーム画像全部渡せばいいとなり、MediaRecorder を使うのをやめてしまった。

MediaRecorder の使い道はストリーミングとかなんだろう。

  1. トップ
  2. tech
  3. 文字スクロール動画ジェネレーター

ただの時報

https://play-morse.lowreal.net/jiho.html

むかーーし作った時報のコードを発掘した。今なら Voicevox つけたらちゃんと喋る時報にできるぞと思ったのでガっとやった。正午だけ特殊なのでレアです。

短波JJYの再現

https://play-morse.lowreal.net/vhf-jjy.html

VHF JJYの再現スクリプトのほうも時報音声を入れるようにした。こっちは10分ごとにしか喋らないのでレアです。

Wikipedia の LDPC の項だけ読むとそんな難しくなさそうに見えるけど、実際に実装しようと思うと、むちゃくちゃ難しい。

H行列 (パリティ検査行列) が難しい

LDPC符号の設計では、性能に優れたパリティ検査行列(H行列)を用意するのがまず難しい。ここにいろんな要素がある。

  • 正則(regular)か(各ノードの次数が一定)
  • 構造的な性質(たとえば巡回構造をもつ QC-LDPC など)を持つか (復号効率に影響する)
  • 系統的符号化 が可能な構造(systematic) を持つか(これは生成行列側の問題)
  • girth(ループの最小長): Tannerグラフにおいて短いサイクルは復号性能を劣化させるため、girthが大きい方が望ましい。

数学的素養がないと難しすぎる。そして自力で設計するのは考えたくない。

Protographが難しい

そんなH行列、例え1つ設計できたとしても、任意のデータ長・符号化率を持つH行列をいくつも作ろうと思うと何倍も大変になってしまう。

そこで Protograph (原始グラフ) という性質の良い原型となるような小さなグラフをまず設計し、それを何らかの方法で拡大して任意の大きさのH行列を得るという設計方法がある。

パンクチャリングが難しい

パンクチャリングは性質の良いH行列を使うために,多少の計算効率を犠牲にしつつ柔軟な送信ビット長/符号化率を得るための方法といえる

良い性質の Protograph を得られたとしても、それを拡大するプロセスには制約がある。整数倍の行列しか作れないとか。

そうなると求めるデータ長・符号化率ぴったりの行列というのは結局作れない。

これを解決するのがパンクチャリング。符号化率の低い(0.4など)のProtographを拡大し、欲しい符号化率 (0.5など)になるまでパリティビットの一部を送信しないことで、ちょうどいいサイズを作れる。

ぴったりよりも大きい行列を使うので計算コストは増えるが、高性能なH行列設計を流用できるという利点がある。

エンコードもデコードも難しい

エンコードは既知の操作するだけやろ? という気持ちを打ち砕く。どっちも難しい。なんならデコードのほうが簡単かもしれない

  • エンコードは生成行列(G行列)を得る必要がある。任意のH行列に対してG行列を導出するのが難しい。
  • デコードには、確率伝播(Belief Propagation)アルゴリズムに基づく Sum-Product 法や、近似的な Min-Sum 法などがある。どちらもTannerグラフ上での繰り返し計算を必要とし、実装や収束性で難しい
  1. トップ
  2. tech
  3. LDPC難しすぎる

performance.now() が monotonic (単調増加) なことを利用すると、システム時計の変化を比較的高精度に得られるなと思ったので、以下のようなClockMonitorクラスを作ってみた。

// ClockMonitor: システム時計の大幅な変更(NTP補正・手動変更等)を検知し、イベントを発行するクラス
// WebAudioやperformance.now()はmonotonicな経過時間だが、絶対時刻(Date.now())はシステム時計依存でジャンプすることがある
// そのため、performance.timeOrigin+performance.now()で絶対時刻を計算している場合、
// システム時計が変化しても自動で補正されない(ズレたままになる)
// このクラスは、定期的にDate.now()とperformance.timeOrigin+performance.now()の差分を監視し、
// 一定以上の差分が発生した場合に"clockchange"イベントを発行することで、
// 利用側がoffset等を補正できるようにする
class ClockMonitor extends EventTarget {
	constructor({ threshold = 2000, interval = 1000 } = {}) {
		super();
		this.threshold = threshold; // 何ms以上の差分で検知するか
		this.interval = interval;   // 監視間隔(ms)
		this.offset = performance.timeOrigin || 0; // performance.now()の起点(初期化時の絶対時刻)
		this._timer = null;
	}

	start() {
		if (this._timer) return;
		this._timer = setInterval(() => {
			const perfNow = performance.now();
			const now = Date.now();
			// 現在の絶対時刻の期待値(初期offset+経過時間)
			const expected = this.offset + perfNow;
			const diff = now - expected;
			// threshold以上の差分が出たらシステム時計変更とみなす
			if (Math.abs(diff) > this.threshold) {
				// offsetを補正し、イベント発行
				this.offset += diff;
				this.dispatchEvent(new CustomEvent("clockchange", {
					detail: { offset: this.offset, diff }
				}));
			}
		}, this.interval);
	}

	stop() {
		if (this._timer) {
			clearInterval(this._timer);
			this._timer = null;
		}
	}
}

他の方法

Date.now() を単に保持しておいて比較することでも、過去への遡りは検出できる。が、未来へ進むのは検出できない (ただのタイマーの遅れと区別できない)

問題点

問題点: performance.now() は monotonic ではあるがスリープで時間の連続性が失われることがある
仕様上は連続することになっているが、一部の環境のブラウザだけ。

  1. トップ
  2. tech
  3. JSでシステム時計の変化(時刻変更、NTP同期)を検知する

micro-template.js という2012年に作った embed JS 的なテンプレート処理ライブラリがある。コピペできるぐらい小さくて、早いことがコンセプト。

完全に放置してたけど、ちょっと手を入れはじめたらいろいろやりたくなってしまったので、だいぶ改修をいれてしまった。

  1. トップ
  2. tech
  3. micro-template.js を13年ぶりにいろいろいじった

いきなりトランスパイルの環境作って常時ビルドツールを動かして開発するのがいまいち性にあわず、いまだにそういうことをしないようにしてる。小さいプロジェクトだと管理が面倒くさい。

Vue3 はなんかいろいろさらに面倒になっており、公式のクイックスタートが全然クイックじゃねーよと思っていた。さらにクイックな方法を試行錯誤してたけど、現状の最小限サンプルを作っておくことにした。追記: 公式にも最小限サンプルあるわ……

最近のブラウザは ESM の import に対応しており、これ前提なら変なこと(ビルド)しなくても、このままに動く。ただブラウザで開くだけで良い。開発開始には十分だし、余計なことを考える必要はない。

一応ファイルを分けてるけど、app.js の内容は index.html に埋めこんでも良い。

「バニラJSだと面倒くさいUI状態があるが、いっぱいビルドとかはしたくない」みたいなことは多い。

以下は template 要素を使っている。template 要素を div 要素にして createApp に template を指定しなくても、mount("#app") でもいける。けど、template じゃない場合、table の td tr などで要素が消滅してハマることがある。

// app.js
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
createApp({
	data() {
		return {
			counter: 0,
		}
	},
	mounted() {
		console.log('App mounted');
	},
	template: document.querySelector('#app').innerHTML
}).mount(document.body);
<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Hello</title>
	<script type="module" src="./app.js" defer></script>
</head>
<body>
	<template id="app">
		<h1>Hello</h1>
		<button @click="counter++">Click me</button>
		<p>Counter: {{ counter }}</p>
	</template>
</body>
</html>

petite-vue はメンテされてるのだろうか。どこかのタイミングでVue3の機能やらビルドをしたくなったときのことを考えると Vue3 そのまま使ってもいいと思う。

  1. トップ
  2. tech
  3. 一瞬で開発開始するための Vue3 無トランスパイル環境