LINEアプリからGoogle Homeを自在に操作!LIFF + Flaskでテキスト発話・音量調整UIを作る

未分類

「外出先でもLINEからGoogle Homeの音量を調整したい」「任意のメッセージを喋らせたい」——そんな要望に応えるため、LINEミニアプリ(LIFF)を使ってスマートフォンのLINEアプリ内からGoogle Homeを操作できるUIを作りました。
本記事では、LINE LIFF SDK・Fetch API・Flask バックエンドを組み合わせて、発話テキスト・話速・ピッチ・音量をスライダーでリアルタイムに調整できるコントローラーUIの実装方法を解説します。LIFFアプリはLINE内で動作するHTMLページであり、特別なアプリ開発は不要です。

なぜこの機能を作ったのか?

Google Homeはスマートスピーカーとして家族の通知や音楽再生に活用していますが、外出中に「夜中の通知が大音量すぎた」という問題が起きました。自宅に帰らないと音量を変えられないのは不便です。
LINEミニアプリ(LIFF)であれば、LINEアプリを開いた瞬間からGoogle Homeを操作できる専用UIが手元に現れます。追加のアプリインストールも不要で、家族のスマートフォンでも同じUIがすぐに使えます。
すでに構築済みのFlask API(Bearer認証)とNginxリバースプロキシ環境を活用することで、セキュアにAPIを呼び出せる構成を実現しています。

LINEからGoogle Homeの音量調整や発話を外出先からでも手軽に操作できるようにしたい!

実現したいこと

  • LINEアプリの中でGoogle Homeの操作UIが使える(アプリ追加インストール不要)
  • テキストを入力してGoogle Homeに発話させられる
  • 発話の話速・ピッチ・音量補正をスライダーでリアルタイムに調整できる
  • Google Homeのマスター音量を外出先からでも変更できる
  • LINE IDトークンで認証し、特定の家族のみが使えるプライベートアプリにする
  • 起動時に現在のGoogle Home音量を取得して自動表示する

この記事でわかること

  • LINE DevelopersでLIFFアプリを作成して公開URLに紐付ける方法
  • LIFF SDKを使ったLINEログイン・IDトークン取得の実装方法
  • LINE IDトークンをBearerヘッダーに付けてFlask APIを呼び出す方法
  • HTMLとVanilla JSだけで完結するスライダーUIの作り方
  • LIFFアプリのデバッグ手順(テスト用HTMLの活用)

必要な準備と用意するもの

ハードウェア

ハードウェア
  • 自宅サーバー(本記事では MINISFORUM NUCBox G3 Plus / Ubuntu Server 24.04)
  • Google Home(Nest Audio など)
  • スマートフォン(LINE がインストールされているもの)

ソフトウェア/サービス

ソフトウェア/サービス
  • LINEアカウント + LINE Developers(無料)
  • Nginx(インストール済み・HTTPS対応済み)
    • 参考記事:「NginxでHTTPSリバースプロキシを構築する」
  • Flask APIサーバー(構築済み・LINE認証対応済み)
    • 参考記事:「FlaskとNginxリバースプロキシで外部向けAPIを作る」
  • テキストエディタ(VS Code など)

完成イメージ

LINEアプリでLIFFのURLを開くと、LINE認証後に以下のコントローラーUIが表示されます。ダークテーマのカードUIで、上段が発話カード(Speak)・下段が音量カード(Volume)の2ブロック構成です。

Screenshot
カード名機能パラメーター
🗣 Speak(発話)テキストをGoogle Homeに発話させるメッセージ・話速(0.25〜4.00)・ピッチ(-20〜+20)・音量補正 dB(-96〜+16)
🔊 Volume(音量)Google Homeのマスター音量を設定する音量(0.00〜1.00)

起動時にFlask APIからGoogle Homeの現在音量を取得し、Volumeスライダーに自動反映します。LINEの認証が必要なため、許可されたLINEアカウント以外はアクセスできません。

システムの仕組み

リクエストの流れ

LINEアプリでLIFFを開いてからGoogle Homeが喋るまでの流れです。

① LINE アプリで LIFF URL(https://liff.line.me/xxxx)を開く
② LIFF SDK 初期化 → LINEログイン(未ログインの場合はLINE認証画面へ)
liff.getIDToken() で LINE ID トークン(JWT)を取得
GET /external/api/check_user にBearerトークンを付けてFlask APIを呼び出し → 認証確認
GET /external/api/googlehome_status で現在音量取得 → スライダーに反映
⑥ ボタン押下 → POST /external/api/speak_googlehome または POST /external/api/volume_set
⑦ Flask が Google Home に発話/音量変更リクエストを送信

LIFF の仕組み

項目内容
LIFF とはLINE Front-end Framework。LINEアプリ内でWebアプリを動かす仕組み
LIFF URLhttps://liff.line.me/{LIFF_ID} の形式。LINE Developersで発行
エンドポイント URL実際のHTMLファイルが置かれているURL(本記事では https://xxxx.tplinkdns.com/liff/speak_googlehome.html
ID トークンLINEログイン後に発行されるJWT。Flask APIの認証に使用

実装のポイント

authFetch でBearerトークンを自動付与する
fetch() をラップした authFetch() 関数を用意することで、全APIリクエストに自動でLINE IDトークンのAuthorizationヘッダーが付きます。個々のリクエストでヘッダー記述を繰り返さずに済み、コードがシンプルになります。

起動時に現在状態を取得してUIに反映する
LIFF 初期化と認証が完了した直後に loadStatus() を呼び出し、Google Homeの現在音量を取得してスライダーに反映します。毎回ゼロから設定し直す手間がなくなります。

スライダーの値をリアルタイムで表示する
document.addEventListener("input", ...) でスライダー操作をまとめて検知し、値を即座にバッジ表示します。イベントハンドラを複数書く必要がなく、スライダーが増えてもマップを更新するだけで対応できます。

LIFF はHTTPSでしか動作しない
LIFF の仕様として、エンドポイントURLは必ず https:// である必要があります。Let’s Encrypt証明書でHTTPS化したNginxが前提となります。ローカルのHTTP環境ではLINE認証が完了しません。

LINE ID トークンは有効期限があるため長期キャッシュ不可
liff.getIDToken() は毎回呼び出して最新のトークンを取得します。Flask側でもトークンのexpを確認して期限切れは拒否します。LIFFアプリを長時間開いたままにするとトークンが失効してAPIエラーになる場合があるため、エラー時はページをリロードするよう案内するのがベターです。

事前準備

① LINE DevelopersでLIFFアプリを作成する

LINE Developers コンソール(https://developers.line.biz/)にアクセスし、以下の手順でLIFFアプリを作成します。

  • LINE Developersコンソールにログイン
  • プロバイダーを選択(または新規作成)
  • 「LINEログイン」チャンネルを作成(既存のチャンネルがあればそれを使用)
  • チャンネルの「LIFF」タブを開き「追加」をクリック
  • 以下の設定でLIFFアプリを登録する
    • サイズ:Full(全画面表示)
    • エンドポイントURLhttps://xxxx.tplinkdns.com/liff/speak_googlehome.html
    • スコープopenidprofile にチェック
    • LIFFブラウザー間でIDトークンを共有する:オフ
  • 発行された LIFF ID(例:xxxxxxxxxx-xxxxxxxx)を控える

② Nginxのliffディレクトリにファイル配置の確認

LIFFアプリのHTMLファイルはNginxが配信する /liff/ パス配下に配置します。Nginx設定で /liff/ ロケーションが設定済みであることを確認します。


# /liff/ : LINEミニアプリ専用
location /liff/ {
    alias /srv/nginx/public/liff/;
    index index.html;

    # GET/HEAD のみ許可
    limit_except GET HEAD {
        deny all;
    }

    autoindex off;
    access_log /srv/nginx/log/liff_access.log detailed;
}

③ テスト用ページ(index.html)で認証を確認する

本番のLIFFアプリを作成する前に、まずシンプルなテストページでLINE認証とプロフィール取得が正常に動作するか確認します。


<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>LIFFユーザー取得</title>
</head>
<body>
  <h1>LIFFユーザー情報</h1>
  <p id="status">初期</p>
  <p id="userId"></p>
  <p id="displayName"></p>
  <img id="picture" width="100">

  <script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
  <script>
    document.addEventListener("DOMContentLoaded", async () => {
      const status = document.getElementById("status");
      try {
        status.innerText = "LIFF初期化中...";
        await liff.init({ liffId: "xxxxxxxxxx-xxxxxxxx" }); // テスト用LIFF ID

        if (!liff.isLoggedIn()) {
          liff.login();
          return;
        }

        const profile = await liff.getProfile();
        status.innerText = "ユーザー取得成功";
        document.getElementById("userId").innerText = "UserID: " + profile.userId;
        document.getElementById("displayName").innerText = "名前: " + profile.displayName;
        document.getElementById("picture").src = profile.pictureUrl;
      } catch (e) {
        status.innerText = "エラー: " + (e.message || JSON.stringify(e));
      }
    });
  </script>
</body>
</html>

https://xxxx.tplinkdns.com/liff/index.html にアクセスして、LINEアカウント名とプロフィール画像が表示されれば LIFF の基本設定は完了です。

実装方法

テスト用ページで動作確認ができたら、本番のコントローラーUI(speak_googlehome.html)を実装します。

① HTML構造とスタイル

ダークテーマのカードベースUIです。スライダーの値をリアルタイムでバッジ表示する .val-badge が特徴です。


<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Smart Home Pro</title>
<script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<style>
:root {
  --bg-color: #0f121a;       /* ページ背景(濃いネイビー) */
  --card-bg: #1c212c;        /* カード背景 */
  --accent-color: #4caf50;   /* アクセントカラー(グリーン) */
  --text-main: #ffffff;
  --text-sub: #94a3b8;
  --input-bg: #2d333f;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: var(--bg-color);
  color: var(--text-main);
}

.container { padding: 16px; max-width: 600px; margin: auto; padding-bottom: 40px; }

/* カード */
.card {
  background: var(--card-bg);
  border-radius: 20px;
  padding: 20px;
  margin-bottom: 20px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}

/* スライダーのラベル行(左: ラベル、右: 値バッジ) */
.label-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.label-row label { color: var(--text-sub); font-size: 14px; }
.val-badge {
  background: var(--accent-color);
  padding: 2px 10px;
  border-radius: 10px;
  font-size: 12px;
  font-weight: bold;
  min-width: 50px;
  text-align: center;
}

/* レンジスライダー */
input[type="range"] {
  width: 100%;
  height: 6px;
  appearance: none;
  -webkit-appearance: none;
  background: var(--input-bg);
  border-radius: 5px;
  outline: none;
}
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 24px;
  height: 24px;
  background: #fff;
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}

button {
  width: 100%;
  padding: 16px;
  border-radius: 16px;
  border: none;
  background: var(--accent-color);
  color: white;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
}

.status { margin-top: 12px; text-align: center; font-size: 14px; color: var(--accent-color); }

/* 初期化中のローディング画面 */
#loading {
  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
  background: var(--bg-color);
  display: flex; justify-content: center; align-items: center; z-index: 100;
}
</style>
</head>

<body>
<div id="loading">Connecting...</div>

<div class="container">
  <h2>Smart Home</h2>

  <!-- 発話カード -->
  <div class="card">
    <h3>Speak</h3>
    <input type="text" id="message" placeholder="しゃべらせたい内容を入力">

    <div class="control-group">
      <div class="label-row">
        <label>話速</label>
        <span class="val-badge" id="rateVal">1.00</span>
      </div>
      <input type="range" id="speaking_rate" min="0.25" max="4.00" step="0.05" value="1.00">
    </div>

    <div class="control-group">
      <div class="label-row">
        <label>ピッチ</label>
        <span class="val-badge" id="pitchVal">0.00</span>
      </div>
      <input type="range" id="pitch" min="-20.00" max="20.00" step="0.05" value="0.00">
    </div>

    <div class="control-group">
      <div class="label-row">
        <label>音量補正 (dB)</label>
        <span class="val-badge" id="gainVal">0.00</span>
      </div>
      <input type="range" id="gain" min="-96.00" max="16.00" step="0.05" value="0.00">
    </div>

    <button onclick="sendMessage()">発話する</button>
    <div class="status" id="status"></div>
  </div>

  <!-- 音量カード -->
  <div class="card">
    <h3>Volume</h3>
    <div class="control-group">
      <div class="label-row">
        <label>マスター音量</label>
        <span class="val-badge" id="volumeVal">0.50</span>
      </div>
      <input type="range" id="volume" min="0" max="1" step="0.05" value="0.50">
    </div>
    <button onclick="setVolume()" style="background: #3b82f6;">
      音量を設定する
    </button>
  </div>
</div>
</body>
</html>

② JavaScriptの実装

</body> の直前に以下のscriptタグを追加します。


const LIFF_ID  = "xxxxxxxxxx-xxxxxxxx";  // LINE DevelopersのLIFF ID
const API_BASE = "https://xxxx.tplinkdns.com/external/api";

// LINE IDトークンをBearerヘッダーに付けてfetchする共通関数
function authFetch(url, options = {}) {
  const idToken = liff.getIDToken();
  return fetch(url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + idToken,
      ...(options.headers || {})
    }
  });
}

// スライダー値を安全に取得する補助関数
function getFloat(id, def) {
  const el = document.getElementById(id);
  if (!el) return def;
  const v = parseFloat(el.value);
  return isNaN(v) ? def : v;
}

// 数値を小数点第二位で整形する補助関数
function formatFloat(val) {
  return parseFloat(val).toFixed(2);
}

// --- メイン初期化 ---
async function main() {
  try {
    await liff.init({ liffId: LIFF_ID });

    // 未ログインならLINE認証画面へリダイレクト
    if (!liff.isLoggedIn()) {
      liff.login();
      return;
    }

    // Flask APIで認証チェック(許可ユーザーでなければエラー表示して終了)
    const res = await authFetch(API_BASE + "/check_user");
    if (!res.ok) {
      document.body.innerHTML = "<div style='padding:20px'>認証失敗。アクセス権限がありません。</div>";
      return;
    }

    // 認証成功 → ローディング画面を非表示にしてGoogle Home状態を取得
    document.getElementById("loading").style.display = "none";
    await loadStatus();
  } catch (e) {
    document.getElementById("loading").innerText = "初期化エラーが発生しました";
  }
}

// Google Homeの現在状態を取得してUIに反映する
async function loadStatus() {
  try {
    const res = await authFetch(API_BASE + "/googlehome_status");
    const data = await res.json();
    if (data.status === "ok") {
      const vol = data.data.volume;
      document.getElementById("volume").value = vol;
      document.getElementById("volumeVal").innerText = formatFloat(vol);
    }
  } catch (e) {
    console.error("status error", e);
  }
}

// 発話する
async function sendMessage() {
  const message = document.getElementById("message").value;
  const status  = document.getElementById("status");
  if (!message) {
    status.innerText = "メッセージを入力してください";
    return;
  }
  status.innerText = "送信中...";
  try {
    const res = await authFetch(API_BASE + "/speak_googlehome", {
      method: "POST",
      body: JSON.stringify({
        message:       message,
        speaking_rate: getFloat("speaking_rate", 1.0),
        pitch:         getFloat("pitch", 0),
        volume_gain_db: getFloat("gain", 0)
      })
    });
    const data = await res.json();
    status.innerText = (res.ok && data.status === "ok")
      ? "送信成功"
      : "エラー: " + (data.reason || "unknown");
  } catch (e) {
    status.innerText = "通信エラーが発生しました";
  }
}

// 音量を設定する
async function setVolume() {
  const vol = getFloat("volume", 0.5);
  try {
    const res = await authFetch(API_BASE + "/volume_set", {
      method: "POST",
      body: JSON.stringify({ volume: vol })
    });
    const data = await res.json();
    if (res.ok && data.status === "ok") {
      document.getElementById("volumeVal").innerText = formatFloat(vol);
      alert("音量を更新しました: " + formatFloat(vol));
    } else {
      alert("失敗: " + (data.reason || "unknown"));
    }
  } catch (e) {
    alert("通信エラー");
  }
}

// スライダー操作でリアルタイムに値バッジを更新する(全スライダーをまとめて処理)
document.addEventListener("input", (e) => {
  const idMap = {
    'speaking_rate': 'rateVal',
    'pitch':         'pitchVal',
    'gain':          'gainVal',
    'volume':        'volumeVal'
  };
  if (idMap[e.target.id]) {
    document.getElementById(idMap[e.target.id]).innerText = formatFloat(e.target.value);
  }
});

main();

コードの解説

authFetch — 認証ヘッダーを自動付与するfetchラッパー

liff.getIDToken() は LIFF セッション中はいつでも呼べる関数で、LINE IDトークン(JWT文字列)を返します。authFetch() はこのトークンを毎回 Authorization: Bearer ... ヘッダーに付けて fetch() を呼び出すラッパーです。

引数内容
urlリクエスト先のURL
options通常の fetch() オプション(method, body など)。省略可

main — 初期化の流れ

ステップ処理失敗時の動作
liff.init()LIFF SDKをLIFF IDで初期化例外をキャッチして「初期化エラー」表示
liff.isLoggedIn()LINEログイン済みか確認未ログインなら liff.login() でLINE認証へリダイレクト
/check_user APILINEユーザーがFlaskの許可リストにあるか確認403/401なら「アクセス権限なし」を表示して処理中断
loadStatus()Google Home現在状態を取得してスライダーを初期化エラーはコンソールに出力(UIはデフォルト値のまま)

スライダーのリアルタイム表示

document.addEventListener("input", ...) でページ全体のinputイベントをまとめて受け取り、idMap でスライダーIDとバッジIDを対応付けます。スライダーが増えても idMap に1行追加するだけで対応できます。

スライダーIDバッジID範囲Flask APIパラメーター
speaking_raterateVal0.25〜4.00speaking_rate
pitchpitchVal-20.00〜20.00pitch
gaingainVal-96.00〜16.00 dBvolume_gain_db
volumevolumeVal0.00〜1.00volume

動作確認

① テスト用ページで認証を確認する

まず https://xxxx.tplinkdns.com/liff/index.html のLIFF URLを LINE アプリで開き、自分のLINEプロフィール(名前・アイコン)が表示されることを確認します。

② メインアプリで動作確認する

speak_googlehome.html のLIFF URLを LINE アプリで開き、以下を確認します。

  • 「Connecting…」表示後にUI画面が表示される(認証成功)
  • Volume スライダーに Google Home の現在音量が反映されている(loadStatus() 動作確認)
  • テキストを入力して「発話する」を押すと Google Home が喋る
  • 音量スライダーを動かして「音量を設定する」を押すと Google Home の音量が変わる
  • スライダーを動かすたびに値バッジがリアルタイムで更新される

③ トラブルシューティング

「Connecting…」のまま画面が変わらない
LIFF IDが正しいか確認します。LINE Developersコンソールで発行されたLIFF IDと、HTMLの LIFF_ID 定数が一致しているか確認します。また、エンドポイントURLが https:// で始まっているか(HTTPでは動作しません)も確認してください。

「認証失敗。アクセス権限がありません」と表示される
Flask APIの許可ユーザーリスト(LINE_USER_MAP)に自分のLINEユーザーIDが登録されていない可能性があります。テスト用ページ(index.html)で表示されるユーザーIDをコピーして LINE_USER_MAP に追加してください。

発話ボタンを押してもGoogle Homeが喋らない
Flask APIへのリクエストは届いているがGoogle Home側で失敗している可能性があります。journalctl -u smh-http-server -n 50 でFlaskのログを確認し、Google Homeのネットワーク接続状態も確認してください。

ブラウザ(PC)で開くとLINEログイン画面になる
これは正常な動作です。LIFFはLINEアプリのブラウザ内で動作させることを前提としています。PCのブラウザからアクセスするとLINEログインページにリダイレクトされますが、LINEアプリからQRコードや直接リンクでアクセスすると正常に動作します。

まとめ

本記事では、LIFF SDKを使ってLINEアプリ内でGoogle Homeを操作できるコントローラーUIを実装しました。

  • LINE DevelopersでLIFFアプリを作成し、NginxのHTTPS配信パスに紐付けた
  • authFetch() で全APIリクエストにLINE IDトークンを自動付与した
  • 起動時に loadStatus() でGoogle Homeの現在音量を取得してスライダーを初期化した
  • 発話APIに話速・ピッチ・音量補正(dB)のパラメーターをスライダーで調整できるUIを実装した
  • document.addEventListener("input", ...) で全スライダーのリアルタイム表示をまとめて処理した
  • 認証失敗・未ログイン・APIエラーを適切にハンドリングし、ユーザーに明確なフィードバックを表示した

このLIFFアプリを土台にして、SwitchBotのON/OFFや照明制御など他のスマートホーム機能もカードとして追加できます。LINEさえあれば家族全員が同じUIで自宅を操作できる、スマートホームの入口として活用してみてください。

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