気の向くままに辿るIT/ICT/IoT
IoT・電子工作

市販スマート家電用ブラウザ操作パネルと操作スクリプトを自作

ホーム前へ次へ
スマート家電って?

市販スマート家電用ブラウザ操作パネルと操作スクリプトを自作

市販スマート家電用ブラウザ操作パネルと操作スクリプトを自作

2023/06/03

 当記事は、スマートな加湿ストリーマ空気清浄機を更にスマート化からスクリプト類の転載含め、編集したものです。

パソコンでも使える自作ブラウザ操作パネル/ダイキン加湿ストリーマ空気清浄機MCK70Y用

 任意のスマート家電用にブラウザによる操作パネルと自作Julius/Open JTalkスマートスピーカー機能からも操作できるスクリプトを自作してみた話。

 この操作パネルなら、パソコンでもスマホやタブレットでも使えるので、なんならスマホ専用アプリも要りませんし、Wi-Fiのみで済み、クラウドを経由する必要もなく、LAN内で完結させることができます。

 送信コマンドを確認するにあたり、専用アプリもいりますが。

 また、自作pythonスクリプトは、自身はラズパイスマートスピーカー専用機や2台のパソコンに入れた同機能から音声操作できるようにと作りましたが、引数を渡すだけでスマート家電を操作できます。

 これも連携しているAIスマートスピーカーに、そもそもスマートピーカーに限らず、他用途でも使えます。

 「任意のスマート家電用に」と書きましたが、ここで紹介した空気清浄機用のパネルやスクリプトをそのまま使えるという意味ではなく、この手順を踏むことで、全てとは言わないまでも、たいていのスマート家電において汎用的に操作コマンドやデータ取得コマンドを得ることができるという意味です。

 スマート家電と言えばというほど、スマホ専用アプリか、あって市販AIスマートスピーカーから操作するものが大半。

 それはそれとしてもパソコンからも操作パネルで、自作のJulius/Open JTalk製スマートスピーカーでも音声で操作したいよねってことで。

 尚、ここで掲載した自作ブラウザ操作パネルや自作スクリプト等々は我が家初の最初からスマート家電であるダイキン加湿ストリーマ空気清浄機で操作コード含め、実際に使っているもの、そのままです。

 このスマート家電がもつセンサー機能による温度・湿度、センサー値(PM2.5/ホコリ/ニオイ)は、自作ブラウザ操作パネル上でも画面をリロードすることなく、当該部分だけをリアルタイム表示(というか例では、ほぼほぼ1分ごとに更新)できるようにしてあります。

 ちなみに、この空気清浄機の専用アプリ、とあるバージョンから会員登録、ログイン、クラウド経由が必須となったとのことでしたが、それ以前の旧バージョンのアプリを使い続けることで、これらを回避し、LAN内で登録から操作まで全て完結、外からはVPNで代替しています。

 ブラウザパネルができてしまった後は、スマホでもそれを使えば、以後、アプリのバージョンも関係ありませんから、今回の場合だと旧バージョンのアプリはアンインストールしても良いんですけどね。

 自動バージョンアップを回避するためのGoogle Play Storeの自動更新を停止しなくても済みますし。

前提

 Webサーバが必要となり、これ(パソコンに建てるならパソコン)が起動中のみ利用できます。

 IPを固定でき、かつ、オリジンポリシーを回避できたり、スマート家電側のCORSステータスを変更できる場合は、サーバは不要ですが...。

 たいていはいけると思いますが、全てのスマート家電、IoT家電が、ここで述べる方法で操作可能となる保証はありません。

システム・動作環境

$ uname -a
Linux * 5.15.92-v8-CONFIG_PSI-y #1 SMP PREEMPT Sun May 14 08:43:54 JST 2023 aarch64 GNU/Linux
$ firefox-esr -v
Mozilla Firefox 102.11.0esr
$ chromium --version
Chromium 114.0.5735.90 built on Debian 11.7, running on Debian 11.7
$

 検証環境については、クライアントマシンは、ARM64/ARMv8 Raspberry Pi 400/Raspberry Pi OS 64bit( + 512GB SSD + 1TB HDD + Wi-Fiマウス + 19インチ液晶モニタ)、基本、Firefox、たまにChromiumで検証。

 ちなみにX11|Xorgではなく、Wayland版のデスクトップ上でWaydroidを使うに当たり、必要なカーネルオプションを有効にしたオリジナルカーネルを使っています。

$ uname -a
Linux * 6.1.21-v7+ #1642 SMP Mon Apr 3 17:20:52 BST 2023 armv7l GNU/Linux
$

 サーバは、ARM64/armv7l Raspberry Pi 3B+、OSはDebian Linux系Raspberry Pi OS 32bitです。

 何度か入れ替えたOS、64bitにしていたとばかり思っていたのですが、32ビットでした。

 尚、このサーバは、常時ではなく、必要都度起動させて運用しています。

 よってサーバを起動していない場合に備え、すっかりメインとなったクライアントとしたRaspberry Pi 400パソコンにも同操作パネルを入れ、自動起動させて利用できるようにしました。

 滅多に起動することもなくなったサブとなって久しいdynabookには、入れていませんが、起動頻度が上がれば、入れておいても良いかなとは思っています。

概要

 まず、スマホにスマート家電専用のアプリをインストール、操作できるようにしておきます。

 更にIOT家電をAPIハックして、Siriから簡単に操作する方法無料プロキシツール「mitmproxy」を使ってみよう - セットアップ方法とセキュリティエンジニアおすすめの設定などを参照の上、パソコンなどにプロキシアプリをインストール・起動させ、パソコンとスマホを同一SSIDのWi-Fiに接続します。

 続いて前者のリンク先の通り、スマホ側で当該Wi-FiのSSIDに「手動」で「プロキシを追加」、パソコンのプロキシアプリで設定したIPアドレスとポートを設定。

 概要ながら、ネット接続できない場合などポートと完了後もmitproxy要スクロールな点に注意

 この状態でスマホ専用アプリから操作することでパソコンのプロキシアプリで各種操作コマンドやセンサー値取得コマンド等を一通り確認し、控えておきます。

 控えた後は、もう必要ないのでスマホで手動に設定したプロキシ設定は「なし」に設定し直し、パソコン側のプロキシアプリも停止します。

 パソコンなどにWebサーバを建てブラウザをクライアントとして任意のサーバスクリプトとクライアントスクリプトでリアルタイムにデータを送受信できる状態にします。

 任意のサーバスクリプト上でスマート家電のIPアドレスを取得、先ほど控えたスマート家電の各種操作用コマンドパスと併せて、たいていのスマート家電にはあるであろう現在の運転情報、必要ならセンサー情報等のパスとパラメータを取得、クライアントスクリプト側に送信します。

 このデータを元に画面表示するなどクライアントスクリプト側で善きに計らいます。

 画面表示に時間がかかるようならCSSなどでフェードインさせるなどして対処します。

Webサーバが必要となった理由

 自身はESP32ボードで非スマート家電を続々とスマート化していますが、Wi-Fi経由で操作できるもの(今回の場合、スマート家電上に)は、Webサーバがあり、そのIPアドレスに対してGETやPOSTなどの方法でデータを送受信することで操作するものが一般的かと思います。

 そんな中、スマート家電においては、次のようにIPアドレスの固定や特定、CORSステータスの変更ができないという2つの点でスマート家電が備えているであろうWebサーバとは別にWebサーバが必要と判断しました。

 1つめのIPアドレス特定については、多くのスマート家電においては、どうもIPアドレスを固定する方法がないものの方が多い模様です。

 iPhoneのように容易にmDNSを使えれば良いのですが、Androidでは素直に使えません、仮にiPhoneを使うとかAndroidを使わないにしても、もう1つの課題があります。

 他方、ルーターの機能で「特定の」IPアドレスを固定できればよいのですが、今回、自身の環境のように、これができないケースもあります(し、できても、もう1つ課題が...)。

 そうなると仮にIPが変更になっても対応できるようにする...そうするには、都度、IPアドレスを調べる必要が出てきます。

 IPアドレスを調べるにも対象が複数あるとどれがそうなのかを特定する必要が生じます。

 よってMACアドレスなら世界で一意のはずなので、これを元にIPを特定することにしました。

 しかし、ブラウザ用スクリプトからは、Wi-Fi環境内にある全てのMACアドレスからIPアドレスを調べるということはできません。

 2つめのCORSステータスの変更ができない件。

 自身は、ESP32ボードなどで家電や照明、カーテンなどをスマート化しており、この壁にぶつかったことはありませんでした。

 が、このケースを例にざっくり言えば、ESP32側で受信する相手を選別・特定できる仕組みがオリジンポリシーと言われるものでAjaxの登場により、これを許容できるように作られた仕組みがCORS/Cross-Origin Resource Sharing(クロスオリジンリソース共有/オリジン間リソース共有)とのこと。

 オリジンポリシーにより悪意ある送信者が受信者から情報を漏洩させるような悪事を防ぐことができる素晴らしい仕組みとのこと。

 当然のごとく?どうやらスマート家電でも専用アプリ以外からは拒否している模様。

 CORSのステータスを誰もが勝手に変更できては、セキュリティの意味をなしませんから、他からのアクセスを許容するようには変更できないわけです。

 これは、往々にして情報漏えい防止策の側面が強く、データ送信は拒否されず、データの受信に対して選別されるブラウザにおける対策です。

 つまり、ブラウザからスマート家電への操作コマンドは送信できますが、運転情報やセンサー情報の取得などは、スマート家電側によって許可されていないとできないことになります。

 とは言え、これは、ブラウザからはという話であり、情報取得については、ブラウザスクリプト等ではなく、サーバスクリプトなどOS上で利用するスクリプトやアプリなら制約なく、受信できます。

 というわけでWebサーバ側のスクリプトで「IPを特定」、スマート家電から「情報を取得」し、それをクライアントであるブラウザ側のスクリプトに送信すれば、前述の2つの課題をクリアできるというわけです。

 総数にもよるとは言え、IPアドレスを割り出すには多少の差はあれ、時間はかかりますが。

 逆に言うと、せめてIPアドレスの固定さえできれば、または、mDNSでアクセスできれば、時間はかからないのですが。

操作パネル用スクリプト

 今回、サーバサイドスクリプトには、Node.js、クライアントサイドスクリプトには、JavaScriptを使うことにしました。

 というか、Node.jsはスクリプト言語やプログラミング言語ではなく、JavaScriptと極めて親和性が高いOS上でJavaScript/TypeScript/ECMAScript等が動く実行環境とのこと。

 自身は、Node.jsを使うのは、ほぼ初めて、JavaScriptは使ったことはありますが、変遷が激しく、例えば、オブジェクト指向になる前後の明確な違いとか、イベントとか、コールバック関数とか、正直あやふやな感じ。

 また、HTTPと併せてリアルタイム(に限りなく近い)通信には、いくつかあるようですが、WebSocket(Socket.io)を使うことにしました。

 WebsocketはESP32ボードでJavaScriptと併せて使ったことがありますが、Node.jsとはだいぶ勝手が違い、JavaScriptの他の部分が、あやふやだったこともあり、更に、ややこしさを感じました。

 それでも当初、PythonフレームワークのFlaskで始めてみたら、WebsocketやJavaScriptとの絡みが分かりづらく、もしやNode.jsなら...と乗り換えた次第。

 不慣れにつき、お作法通り、定石通りできているのか自信はありませんが、期待通り、動いているので良いかなと...。

 それにしても、できてみれば、そこそこ苦戦した割には、なんともステップ数の少ないこと...IT/IoTすごし。

Node.jsでサーバ側スクリプト

$ sudo apt install -y nodejs npm
...
$

 まずは、Node.jsのインストール。

 自身のようにホストがLinuxならリポジトリから、または、本家Node.jsダウンロードページからダウンロードしてインストールします。

 続いてNode.js内のモジュール管理ができるパッケージマネージャnpmのインストール。

 とは言え、npmはNode.jsをインストールした際に一緒にインストールされることが多いでしょう。

 そうでなければ、リポジトリなどからインストールします。

 使い方は、今回に関しては、npm install(npm uninstall)、npm init、npm list、npm search程度で十分ですが、端末からのバージョン確認は、node、npmにそれぞれ、--version|-vをつければOK、npmの最新版へのアップグレードはnpm install -g npm@latestなどとします。

$ cd any/path/to
any/path/to $ npm init
... package.json ...
any/path/to $

 プロジェクトごとのルートとすべく、任意のディレクトリを用意します。

 次にnpm initとするようで対話形式で応答した結果としてpackages.jsonというファイルができます。

 このpackage.json、なくても良さげと思いきや、作成後、npm install --save(実際は--saveはなくても)した時に自動的に[dependencies](依存関係)としてインストールしたものが追記されたりします。

 依存関係だけならnpm listでも参照できますが、このファイルとの関連は未調査。

 ちなみに新たなプロジェクトを作る場合、既存のプロジェクトディレクトリをコピーするのはやめておいた方が賢明なようです。

 例えば、package.jsonやnpm listが反映されなかったりしますので。

 同じ環境を作る場合は...と思いきや、あなたがnpm installをしてはいけない時によれば、その場合もちょっとした注意が必要なようです。

any/path/to $ npm install socket.io
any/path/to $ npm install path
any/path/to $ npm install node-cron
any/path/to $ npm list
...
any/path/to $ cat package.json
...
any/path/to $

 ソースを書いてからというケースもありますが、必要となるモジュールをnpmでインストールしておきます。

 と思ったら、socket.ioモジュールとnode-cronモジュール、pathモジュール(pathモジュールは入ってるかも)以外は、Node.jsをインストールした時点で同梱されていました。

 package.jsonやnpm list結果にもバージョン付きのインストールしたパッケージが反映されているはずです。

const http = require('http');
const fs = require('fs');
const path = require('path');
const server = http.createServer(requestListener);
const { exec } = require('child_process');
const socketIO = require('socket.io');
const cron = require('node-cron');
 
// httpサーバ起動
server.listen((process.env.PORT || 5000), function () {
 console.log((process.env.PORT || 5000) + 'でサーバ起動中');
});
 
// サーバーにリクエストがあった際に実行される関数
function requestListener(request, response) {
 // リクエストがあったファイル
 const requestURL = request.url;
 // リクエストのあったファイルの拡張子を取得
 const extensionName = path.extname(requestURL);
 // ファイルの拡張子に応じてルーティング処理
 switch (extensionName) {
  case '.html':
   readFileHandler(requestURL, 'text/html', false, response);
   break;
  case '.css':
   readFileHandler(requestURL, 'text/css', false, response);
   break;
  case '.js':
  case '.ts':
   readFileHandler(requestURL, 'text/javascript', false, response);
   break;
  case '.png':
   readFileHandler(requestURL, 'image/png', true, response);
   break;
  case '.jpg':
   readFileHandler(requestURL, 'image/jpeg', true, response);
   break;
  case '.gif':
   readFileHandler(requestURL, 'image/gif', true, response);
   break;
  default:
   // どこにも該当しない場合は、index.htmlを読み込む
   readFileHandler('/index.html', 'text/html', false, response);
   break;
 }
}
 
// ファイル読み込み
function readFileHandler(fileName, contentType, isBinary, response) {
 // エンコードの設定
 const encoding = !isBinary ? 'utf8' : 'binary';
 const filePath = __dirname + fileName;
 
 fs.exists(filePath, function (exits) {
  if (exits) {
   fs.readFile(filePath, {encoding: encoding}, function (error, data) {
    if (error) {
     response.statusCode = 500;
     response.end('Internal Server Error');
    } else {
     response.statusCode = 200;
     response.setHeader('Content-Type', contentType);
     if (!isBinary) {
      response.end(data);
     }
     else {
      response.end(data, 'binary');
     }
    }
   });
  } else {
   // ファイルが存在しない場合
   response.statusCode = 400;
   response.end('400 Error');
  }
 });
}
 
//const io = socketIO.listen(server); <= 某バージョンから[.listen]は消滅
const io = socketIO(server);
 
// サーバーへのアクセスを監視
// アクセス時、コールバック関数実行
io.sockets.on('connection', function (socket) {
 var params = "";
 var sensors = "";
 var options = "";
 var ipaddr = "";
 
 // MACアドレスからIPアドレス抽出にshellを利用
 exec("sudo arp-scan -I eth0 192.168.0.0/24 | awk \'($2 == \"**:**:**:**:**:**\"){print $1}\'", (err, stdout, stderr) => {
  if (err) { console.log(err); }
  var param_path = "";
  var sensor_path = "";
  var preaddr = stdout.split('\n');
  ipaddr = preaddr.toString().replace(/,/,"");
  new Promise((resolve) => {
   setTimeout(() => {
    // 空気清浄機「運転状況」取得アドレス
    param_path = 'http://' + ipaddr + '/cleaner/get_control_info';
    sensor_path = 'http://' + ipaddr + '/cleaner/get_sensor_info';
    console.log("param_path : " + param_path);
    console.log("sensor_path : " + sensor_path);
    resolve();
   }, 5000);
  }).then(() => {
   var get_params_cmd = 'curl ' + '"' + param_path + '"';
   exec(get_params_cmd, (err, stdout, stderr) => {
    if (err) { console.log(err); }
    params = stdout.split('\n');
      params = params.toString().replace(/ret=OK,/, ""); 
      params = params.toString().replace(/,/g, '&');
    console.log("params : " + params);
   });
   var get_sensors_cmd = 'curl ' + '"' + sensor_path + '"';
   exec(get_sensors_cmd, (err, stdout, stderr) => {
    if (err) { console.log(err); }
    console.log("get sensor : " + stdout);
    sensors = stdout.split('\n');
      sensors = stdout.toString().replace(/ret=OK,/, ""); 
    console.log("sensors : " + sensors);
   });
   console.log("sensors1 : " + sensors);
   setTimeout(() => {
    socket.emit('acinfo', { params: params, sensors: sensors, url: ipaddr });
   }, 5000);
  });
 });
});
 
cron.schedule('* * * * *', () => {
 console.log('1分ごと');
 var params = "";
 var sensors = "";
 var options = "";
 var ipaddr = "";
 
 // MACアドレスからIPアドレス抽出にshellを利用
 exec("sudo arp-scan -I eth0 192.168.1.0/24 | awk \'($2 == \"**:**:**:**:**:**\"){print $1}\'", (err, stdout, stderr) => {
  if (err) { console.log(err); }
  var sensor_path = "";
  var preaddr = stdout.split('\n');
  ipaddr = preaddr.toString().replace(/,/,"");
  new Promise((resolve) => {
   setTimeout(() => {
    sensor_path = 'http://' + ipaddr + '/cleaner/get_sensor_info';
    console.log("sensor_path : " + sensor_path);
    resolve();
   }, 5000);
  }).then(() => {
   var get_sensors_cmd = 'curl ' + '"' + sensor_path + '"';
   exec(get_sensors_cmd, (err, stdout, stderr) => {
    if (err) { console.log(err); }
    console.log("get sensor : " + stdout);
    sensors = stdout.split('\n');
      sensors = stdout.toString().replace(/ret=OK,/, ""); 
      //sensors = sensors.toString().replace(/,/g, '&');
    console.log("sensors : " + sensors);
   });
   console.log("sensors1 : " + sensors);
   setTimeout(() => {
    //socket.emit('sensor_info', { info : sensors });
    io.emit('sensor_info', { sensors: sensors });
   }, 5000);
  });
 });
});

 今回は、141223_nodejs_socketio/SocketIOTest/server.jsを参考に作成したものを仮にserver.jsとします。

 const server = http.createServer(requestListener);のデフォルトがそうなのか、IPアドレスはLAN内のアドレス範囲でもlocalhost|127.0.0.1でもOKでした。

 また、server.listen((process.env.PORT || 5000), function () {});でポート番号を指定しています。

 よってlocalhost:5000192.168.1.1:5000などとすれば、server.jsのあるディレクトリにあるindex.htmlが表示されます。

any/path/to $ ls
css index.html js node_modules package-lock.json package.json server.js
any/path/to $ ls css
css.css
any/path/to $ ls js
main.js
any/path/to $

 クライアント側のJavaScriptは、同ディレクトリの下のjs/ディレクトリ、同様にCSSは、css/ディレクトリ以下に配置しています。

 尤もこれは、pathモジュールやfsモジュール、requestListener関数と、そこから呼ばれるreadFileHandler関数のおかげで、index.htmlで指定する時に、当該プロジェクト内であることを前提にパスを示しさえすれば、どこでも良いようです。

 node_module/ディレクトリ、package-lock.jsonは自動的に作成されます。

 Webサーバが必要となった理由であるスマート家電のIPアドレスの取得と運転情報の取得には、外部コマンド(shell)を実行できるexecコマンドを使いました。

 前者については、nodejsでやれる方法があるのか探す以前にその方が手っ取り早いと思ってarp-scanとawk、後者については、サーバサイドでも影響を受けるのか、requestだと取得できなかったのでcurl。

 arp-scanにはsudo(管理者権限)がいるので当該ユーザーがパスワードなしでsudoを実行できるようにするなど善きに計らっておく必要があります。

 ネットワークアドレスとアドレス範囲、MACアドレスを変更、クライアント側も後述のようにすれば、MCK70Y他、後述の対象機器を持っていれば、そのまま操作できるはずです。

 初期表示や操作時に使う情報として操作パラメータ、センサー値、スマート家電のIPアドレスを、クライアントからのコールバックありとしてsocket.emit()でクライアント側でも同名を元にデータを取得することになる任意のacinfoという名前空間でブラウザ側スクリプトにJson形式({ key:val })で送信しています。

 また、この例では、1分ごとにセンサー値を更新すべく、node-cronを使って、サーバからクライアントに一方的に送信、コールバックがないものとしてio.emit()でクライアント側でも同名を元にデータを取得することになる任意のsensor_infoという名前空間でブラウザ側スクリプトにJson形式({ key:val })で送信しています。

 尚、ここでは、クライアントはブラウザを指します。

any/path/to $ node server.js
any/path/to $

 node.jsのメインアプリを拡張子.jsで作成、実行は、node *.jsとします。

クライアントとなるブラウザ側のJavaScript

// グローバル変数
// IPアドレス+操作パス+GETパラメータ
fullarg = "";
 
function http_req(url) {
 var req = new XMLHttpRequest();
 req.open("GET", url, true);
 req.send();
}
 
var socket = io.connect(location.origin);
console.log("location.origin : " + location.origin);
socket.on("connection", function(socket) {
 console.dir(socket, { depth: null });
 console.log("socket : " + socket);
});
 
socket.on("acinfo", function(info) {
 console.log("info['params'] : " + info['params']);
 console.log("info['sensors'] : " + info['sensors']);
 console.log("info['url'] : " + info['url']);
 
  var input_mode = document.querySelectorAll("input[name=mode]");
  for(var element of input_mode) {
    element.checked = false;
  }
  var input_air_vol = document.querySelectorAll("input[name=airvol]");
  for(var element of input_air_vol) {
    element.checked = false;
  }
  document.getElementById('humi_off').checked = true;
 
 var sensors = info['sensors'];
 var list_data = 'http://' + info['url'];
 var params = info['params'];
 var pow = params.toString().match(/pow=(\d)/);
 var mode = params.toString().match(/mode=(\d)/);
 var airvol = params.toString().match(/airvol=(\d)/);
 var humd = params.toString().match(/humd=(\d)/);
 
 var htemp = sensors.toString().match(/htemp=(\d*.\d)/);
 var hhum = sensors.toString().match(/hhum=(\d*)/);
 var pm25 = sensors.toString().match(/pm25=(\d)/);
 var dust = sensors.toString().match(/dust=(\d)/);
 var odor = sensors.toString().match(/odor=(\d)/);
 document.getElementById('htemp').insertAdjacentText('afterbegin', htemp[1]);
 document.getElementById('hhum').insertAdjacentText('afterbegin', hhum[1]);
 document.getElementById('pm25').insertAdjacentText('afterbegin', pm25[1]);
 document.getElementById('dust').insertAdjacentText('afterbegin', dust[1]);
 document.getElementById('odor').insertAdjacentText('afterbegin', odor[1]);
 
 if( mode[1] != 4 && mode[1] != 1){
   document.getElementById('humi_off').disabled = false;
   document.getElementById('humi_low').disabled = false;
   document.getElementById('humi_standard').disabled = false;
   document.getElementById('humi_high').disabled = false;
 
  document.getElementById('humi_auto').checked = false;
 } else {
   document.getElementById('humi_off').disabled = true;
   document.getElementById('humi_low').disabled = true;
   document.getElementById('humi_standard').disabled = true;
   document.getElementById('humi_high').disabled = true;
 
  document.getElementById('humi_auto').checked = true;
 }
  document.getElementById('humi_auto').disabled = true;
 
 if (pow[1] == 0) {
  document.getElementsByName('power')[1].checked = true;
 } else if (pow[0] != 0) {
  document.getElementsByName('power')[0].checked = true;
 }
 if (airvol[1] == 0) {
  document.getElementsByName('mode')[mode[1]].checked = true;
 } else if (airvol[1] != 0) {
  document.getElementsByName('mode')[mode[1]].checked = false;
 }
 if (mode[1] == 0 && airvol[1] != 0) {
  document.getElementsByName('airvol')[airvol[1]].checked = true;
 }
 document.getElementsByName('humd')[humd[1]].checked = true;
});
 
window.addEventListener('DOMContentLoaded', function(){
 socket.on("acinfo", function(info) {
  var list_data = 'http://' + info['url']; 
  var params = info['params'];
  var pow = params.toString().match(/pow=(\d)/);
  var mode = params.toString().match(/mode=(\d)/);
  var airvol = params.toString().match(/airvol=(\d)/);
  var humd = params.toString().match(/humd=(\d)/);
 
  if( mode[1] != 4 && mode[1] != 1){
    document.getElementById('humi_off').disabled = false;
    document.getElementById('humi_low').disabled = false;
    document.getElementById('humi_standard').disabled = false;
    document.getElementById('humi_high').disabled = false;
 
   document.getElementById('humi_auto').checked = false;
  } else {
    document.getElementById('humi_off').disabled = true;
    document.getElementById('humi_low').disabled = true;
    document.getElementById('humi_standard').disabled = true;
    document.getElementById('humi_high').disabled = true;
 
   document.getElementById('humi_auto').checked = true;
  }
   document.getElementById('humi_auto').disabled = true;
 
  var input_power = document.querySelectorAll("input[name=power]");
  for(var element of input_power) {
   element.addEventListener('change',function(){
    if( this.checked ) {
     getpath = list_data + this.value;
     if ( this.value.includes('pow=0') ) {
      var input_mode = document.querySelectorAll("input[name=mode]");
      for(var element of input_mode) {
       element.checked = false;
      }
      var input_air_vol = document.querySelectorAll("input[name=airvol]");
      for(var element of input_air_vol) {
       element.checked = false;
      }
      document.getElementById('humi_off').checked = true;
     } else if ( this.value.includes('pow=1') ) {
      if (airvol[1] == 0) {
       document.getElementsByName('mode')[mode[1]].checked = true;
      } else if (airvol[1] != 0) {
       document.getElementsByName('mode')[mode[1]].checked = false;
      }
      if (mode[1] == 0) {
       document.getElementsByName('airvol')[airvol[1]].checked = true;
      } else if (mode[1] != 0) {
       document.getElementsByName('airvol')[airvol[1]].checked = false;
      }
      document.getElementsByName('humd')[humd[1]].checked = true;
     }
     var fullarg = getpath + '&' + mode[0] + '&' + airvol[0] + '&' + humd[0];
     console.log("fullarg power : " + fullarg);
     http_req(fullarg);
    }
   });
  }
 });
});
 
window.addEventListener('DOMContentLoaded', function(){
 socket.on("acinfo", function(info) {
  var list_data = 'http://' + info['url']; 
  var params = info['params'];
  var pow = params.toString().match(/pow=(\d)/);
  var mode = params.toString().match(/mode=(\d)/);
  var airvol = params.toString().match(/airvol=(\d)/);
  var humd = params.toString().match(/humd=(\d)/);
 
  var input_mode = document.querySelectorAll("input[name=mode]");
  for(var element of input_mode) {
   element.addEventListener('change',function(){
    if( this.checked ) {
     var input_airvol = document.querySelectorAll("input[name=airvol]");
     for(var element of input_airvol) {
      element.checked = false;
     }
     var getpath = list_data + this.value;
     var input_humd = document.querySelectorAll("input[name=humd]");
     for(var element of input_humd) {
      if( element.checked ) {
       fullarg = getpath + '&' + element.value;
       var mode_mode_now = fullarg.toString().match(/mode=(\d)/);
       if (mode_mode_now[1] != 4 && mode_mode_now[1] != 1) {
        var mode_humd_now = fullarg.toString().match(/humd=(\d)/);
        if (mode_humd_now === null || typeof mode_humd_now === "undefined" || humd[1] != mode_humd_now[1]) {
         if (mode_humd_now[1] != 4) {
          fullarg = getpath + '&' + mode_humd_now[0]; 
          document.getElementsByName('humd')[mode_humd_now[1]].checked = true;
         } else {
          fullarg = getpath + '&' + "humd=0"; 
          document.getElementsByName('humd')[0].checked = true;
         }
        } else {
         if (mode_humd_now[1] != 4) {
          fullarg = getpath + '&' + humd[0]; 
          document.getElementsByName('humd')[humd[1]].checked = true;
         } else {
          fullarg = getpath + '&' + "humd=0"; 
          document.getElementsByName('humd')[0].checked = true;
         }
        }
       } else {
        fullarg = getpath + '&' + "humd=4";
        document.getElementById('humi_auto').checked = true;
       }
       document.getElementById('humi_auto').disabled = true;
       console.log("fullarg mode : " + fullarg);
       http_req(fullarg);
       document.getElementById('poweron').checked = true;
      }
     }
    }
   });
  }
 });
});
 
window.addEventListener('DOMContentLoaded', function(){
 socket.on("acinfo", function(info) {
  var list_data = 'http://' + info['url']; 
  var params = info['params'];
  var pow = params.toString().match(/pow=(\d)/);
  var mode = params.toString().match(/mode=(\d)/);
  var airvol = params.toString().match(/airvol=(\d)/);
  var humd = params.toString().match(/humd=(\d)/);
 
  var input_airvol = document.querySelectorAll("input[name=airvol]");
  for(var element of input_airvol) {
   element.addEventListener('change',function(){
    if( this.checked ) {
     var getpath = list_data + this.value;
     var input_mode = document.querySelectorAll("input[name=mode]");
     for(var element of input_mode) {
      element.checked = false;
     }
     var input_humd = document.querySelectorAll("input[name=humd]");
     for(var element of input_humd) {
      if (element.checked) {
       fullarg = getpath + '&' + element.value;
       var airvol_mode_now = fullarg.toString().match(/mode=(\d)/);
       if (airvol_mode_now[1] != 4 && airvol_mode_now[1] != 1) {
        var airvol_humd_now = fullarg.toString().match(/humd=(\d)/);
        if (airvol_humd_now === null || typeof airvol_humd_now === "undefined" || humd[1] != airvol_humd_now[1]) {
         if (airvol_humd_now[1] != 4) {
          fullarg = getpath + '&' + airvol_humd_now[0]; 
          document.getElementsByName('humd')[airvol_humd_now[1]].checked = true;
         } else {
          fullarg = getpath + '&' + "humd=0"; 
          document.getElementsByName('humd')[0].checked = true;
         }
        } else {
         if (airvol_humd_now[1] != 4) {
          fullarg = getpath + '&' + humd[0]; 
          document.getElementsByName('humd')[humd[1]].checked = true;
         } else {
          fullarg = getpath + '&' + "humd=0"; 
          document.getElementsByName('humd')[0].checked = true;
         }
        }
       } else {
        fullarg = getpath + '&' + "humd=4";
        document.getElementById('humi_auto').checked = true;
       }
       document.getElementById('humi_auto').disabled = true;
      }
     }
     document.getElementById('humi_auto').disabled = true;
     console.log("fullarg airvol : " + fullarg);
     http_req(fullarg);
     document.getElementById('poweron').checked = true;
    }
   });
  }
 });
});
 
window.addEventListener('DOMContentLoaded', function(){
 socket.on("acinfo", function(info) {
  var list_data = 'http://' + info['url']; 
  var params = info['params'];
  var pow = params.toString().match(/pow=(\d)/);
  var mode = params.toString().match(/mode=(\d)/);
  var airvol = params.toString().match(/airvol=(\d)/);
  var humd = params.toString().match(/humd=(\d)/);
 
  var input_humd = document.querySelectorAll("input[name=humd]");
  for(var element of input_humd) {
   element.addEventListener('change',function(){
    if( this.checked ) {
     if (fullarg != "") {
      document.getElementById('humi_auto').disabled = true;
      var getpath = list_data + '/cleaner/set_control_info'
      var humd_humd_now = fullarg.toString().match(/humd=(\d)/);
      if (humd_humd_now !== null || humd_humd_now !== "undefined" || humd_humd_now[1] != this.value[1]) {
       fullarg = fullarg.toString().replace(/humd=[0-9]/, this.value); 
      } else {
       fullarg = fullarg.toString().replace(/humd=[0-9]/, humd_humd_now[0]); 
      }
     } else {
      var getpath = list_data + "/cleaner/set_control_info";
      params = params.toString().replace(/humd=[0-9]/, this.value); 
      fullarg = getpath + '?' + params;
      if (pow[1] == 0) {
       fullarg = fullarg.toString().replace(/pow=[0-9]/, "pow=1"); 
       document.getElementsByName('power')[0].checked = true;
      }
     }
     console.log("fullarg humd : " + fullarg);
     http_req(fullarg);
    }
   });
  }
 });
});

 クライアント側として、まずは、141223_nodejs_socketio/SocketIOTest/js/main.jsを参考にJavaScript。

 初期値と操作時にサーバ側からsocket.emit()で指定した任意のacinfoという名前空間で受信。

 Json形式なのでクライアント側関数の引数として任意に名付けたinfo['']の恰好で取得。

 ['']にはサーバ側で指定したkey/キー名(params,sensors,url)を指定しています。

 今回、サーバ側から運転情報のパラメータ、スマート家電によるセンサー値とスマート家電のIPアドレスを受信。

 これを元にブラウザ上に表示する操作パネルの状態を設定しつつ、操作しています。

 尚、今回サンプルの空気清浄機については、ON/OFF以外、操作パラメータは、常に4つ渡す必要あり。

クライアントとなるブラウザ側のHTML

<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="/css/css.css">
<script src="/socket.io/socket.io.js"></script>
<script src="/js/main.js"></script>
<title>DAIKIN加湿ストリーマ空気清浄機MCK70Y</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
</head>
<body>
<div id="disappear">しばらくお待ちください...</div>
<div id="appear">
<div style="margin:auto ;padding:auto ;text-align:center ;width:100% ;">
 
<div class="centertxt">【電源】</div>
<form method="post" id="onoff_form">
<table style="width:98%">
<tr><td style="text-align:center">
<div class="btnOnOff">
<input id="poweron" type="radio" name="power" value="/cleaner/set_control_info?pow=1">
<label for="poweron">ON</label>
<input id="poweroff" type="radio" name="power" value="/cleaner/set_control_info?pow=0">
<label for="poweroff">OFF</label>
</div>
</td></tr>
</table>
</form>
 
<div id="infoblock">
<div id ="sensor">気温: <span id="htemp"></span> 度 湿度: <span id="hhum"></span> % PM2.5: <span id="pm25"></span> ホコリ: <span id="dust"></span> ニオイ: <span id="odor"></span></div>
<div class="tblset cetertxt">DAIKIN 加湿ストリーマ空気清浄機MCK70Y-W</div>
</div>
 
 
<div class="radioblock">
<div class="centertxt">【モード】</div>
<form method="post" id="mode_form">
<table class="tblset">
<tr><td class="tdset">
<div class="radioLayout">
<input id="auto_wind" type="radio" name="mode" value="/cleaner/set_control_info?pow=1&mode=0&airvol=0">
<br><label for="auto_wind">風量自動</label>
</div>
<div class="radioLayout">
<input id="reliance" type="radio" name="mode" value="/cleaner/set_control_info?pow=1&mode=1&airvol=0">
<br><label for="reliance">おまかせ</label>
</div>
</td></tr>
<tr><td>
<div class="radioLayout">
<input id="power_save" type="radio" name="mode" value="/cleaner/set_control_info?pow=1&mode=2&airvol=0">
<br><label for="power_save">節電</label>
</div>
<div class="radioLayout">
<input id="pollen" type="radio" name="mode" value="/cleaner/set_control_info?pow=1&mode=3&airvol=0">
<br><label for="pollen">花粉</label>
</div>
</td></tr>
<tr><td>
<div class="radioLayout">
<input id="throat_skin" type="radio" name="mode" value="/cleaner/set_control_info?pow=1&mode=4&airvol=0">
<br><label for="throat_skin">のど・はだ</label>
</div>
<div class="radioLayout">
<input id="circulator" type="radio" name="mode" value="/cleaner/set_control_info?pow=1&mode=5&airvol=0">
<br><label for="circulator">サーキュレーター</label>
<div class="clear_pos"></div>
</td></tr>
</table>
</form>
</div>
 
<div class="radioblock">
<div class="centertxt">【風量】</div>
<form method="post" id="wind_form">
<table class="tblset">
<tr><td class="tdset">
<div class="invisble" style="width:40% ;display :none ;">
<input id="windauto_air" type="radio" name="airvol" value="/cleaner/set_control_info?pow=1&mode=0&airvol=0">
<br><label for="windauto_air">風量自動用</label>
</div>
</td></tr>
<tr><td>
<div class="radioLayout">
<input id="reliance" type="radio" name="airvol" value="/cleaner/set_control_info?pow=1&mode=0&airvol=1">
<br><label for="reliance">しずか</label>
</div>
<div class="radioLayout">
<input id="weak" type="radio" name="airvol" value="/cleaner/set_control_info?pow=1&mode=0&airvol=2">
<br><label for="weak">弱</label>
</div>
<div class="clear_pos"></div>
</td></tr>
<tr><td>
<div class="radioLayout">
<input id="standard" type="radio" name="airvol" value="/cleaner/set_control_info?pow=1&mode=0&airvol=3">
<br><label for="standard">標準</label>
</div>
<div class="invisble" style="display :none ;">
<input id="no_use_air" type="radio" name="airvol" value="/cleaner/set_control_info?pow=1&mode=0&airvol=4">
<br><label for="windauto_air">欠番</label>
</div>
<div class="radioLayout">
<input id="turbo" type="radio" name="airvol" value="/cleaner/set_control_info?pow=1&mode=0&airvol=5">
<br><label for="turbo">ターボ</label>
</div>
<div class="clear_pos"></div>
</td></tr>
</table>
</form>
</div>
 
<div class="radioblock">
<div class="centertxt">【加湿】</div>
<form method="post" id="humi_form">
<table class="tblset">
<tr><td class="tdset">
<input id="humi_off" type="radio" name="humd" value="humd=0" checked="checked">
<br><label for="humi_off">OFF</label>
</td></tr>
<tr><td>
<div class="radioLayout">
<input id="humi_low" type="radio" name="humd" value="humd=1">
<br><label for="humi_low">ひかえめ</label>
</div>
<div class="radioLayout">
<input id="humi_standard" type="radio" name="humd" value="humd=2">
<br><label for="humi_standard">標準</label>
</div>
<div class="clear_pos"></div>
</td></tr>
<tr><td>
<div class="radioLayout">
<input id="humi_high" type="radio" name="humd" value="humd=3">
<br><label for="humi_high">高め</label>
</div>
<div class="radioLayout">
<input id="humi_auto" type="radio" name="humd" value="humd=4" disabled="disabled" readonly="readonly">
<br><label for="humi_auto">加湿自動</label>
</div>
<div class="clear_pos"></div>
</td></tr>
</table>
</form>
</div>
<div class="clear_pos"></div>
<div><input type=button name=tomain id=tomainmenu value="メインメニュー" onclick="http_req(location.href='http://192.168.0.230')"></div>
 
</div>
</div>
 <script src="https://code.jquery.com/jquery-3.7.0.js" integrity="sha256-JlqSTELeR4TLqP0OG9dxM7yDPqX1ox/HfgiSLBj8+kM=" crossorigin="anonymous"></script>
 <script>
  $( () => {
   const socket = io();
   socket.on('sensor_info', (info) => {
    console.log("受信開始");
    var sensors = info['sensors'];
    console.dir("sensors : " + sensors);
    console.log("sensors : " + sensors);
    var htemp = sensors.toString().match(/htemp=(\d*.\d)/);
    var hhum = sensors.toString().match(/hhum=(\d*)/);
    var pm25 = sensors.toString().match(/pm25=(\d)/);
    var dust = sensors.toString().match(/dust=(\d)/);
    var odor = sensors.toString().match(/odor=(\d)/);
    $('#htemp').html(htemp[1]);
    $('#hhum').html(hhum[1]);
    $('#pm25').html(pm25[1]);
    $('#dust').html(dust[1]);
    $('#odor').html(odor[1]);
    console.log("データセット完了 : " + htemp[0] + ' | ' + hhum[0] + ' | ' + pm25[0] + ' | ' + dust[0] + ' | ' + odor[0]);
   });
  });
 </script>
</body>
</html>

 続いてHTML。

 単なる操作パネル。

 サーバーサイドのserver.jsがあるディレクトリをルートとするということなのか、もしくは、相対パスではなく絶対パスとするということなのか、index.htmlでパス指定する際は、[/js/*.js]、[/css/*.css]など先頭に[/]が必要です。

 各種センサー値を表示することにし、画面をリロードせずに、これら値、一部分のみを定期的に更新すべく、body内でJQueryを使っています。

 サーバ側スクリプトから定期的に一方通行で送られてくるセンサー値のみを受信すべく、io.emit()で指定した任意のsensor_infoという名前空間で受信。

 Json形式なのでクライアント側関数の引数として任意に名付けたinfo['']の恰好で取得。

 ['']にはサーバ側で指定したkey/キー名(sensors)を指定しています。

 尚、モバイル対応を考えるとON/OFF以外は、CSSでというより、そもそもラジオボタンじゃなく、使用する要素について再考の余地ありかなと。

クライアントとなるブラウザ側のCSS

body {
width: 90% ;
background-color: #fffffffff;
font-family: Arial, Helvetica, Sans-Serif; Color: #000088;
}
form {
margin:10px ;
padding:10px ;
width: 100% ;
}
form input {
margin:2px ;
padding:2px ;
width: 40% ;
height:100px ;
background-color:blue ;
color:yellow ;
font-size:110% ;
}
h1 { font-size: 80% ; }
.clear_pos {
clear:both ;
}
 
#appear {
  opacity: 0;
  animation: appear 1s ease 10s 1 normal forwards running;
}
 
#disappear {
  opacity: 1;
  animation: disappear 10s linear forwards;
}
 
@keyframes appear {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}
 
@keyframes disappear {
  100% {
    opacity: 0;
  }
}
 
#infoblock
{
  margin:5px ;
  padding: 5px;
  width:98% ;
  font-size:80% ;
  color:grey ;
}
 
#sensor
{
  margin:2px ;
  padding:2px ;
}
 
.invisible
{
  display: none ;
}
 
.radioblock
{
  margin:1px ;
  padding:1px ;
  width:28% ;
  float:none ;
}
 
.radioLayout
{
  width:40% ;
  float:none ;
}
 
.centertxt
{
  text-decoration: center ;
}
 
.tblset
{
  margin:3px ;
  padding:3px ;
}
 
.tdset
{
  width:40% ;
}
 
/* === ボタンを表示するエリア ============================== */
.btnOnOff {
  position       : relative;      /* 親要素が基点       */
  margin   : auto;    /* 中央寄せ     */
  width    : 160px;         /* ボタンの横幅       */
  height   : 60px;    /* ボタンの高さ       */
}
 
/* === ラジオボタン ======================================== */
.btnOnOff input[type="radio"] {
  display  : none;      /* チェックボックス非表示 */
}
 
/* === ラジオボタンのラベル(標準) ======================== */
.btnOnOff label {
  display  : block;         /* ボックス要素に変更 */
  position       : absolute;      /* 親要素からの相対位置*/
  top      : 0;       /* 標準表示位置(上)   */
  bottom   : 0;       /* 標準表示位置(下)   */
  left     : 0;       /* 標準表示位置(左)   */
  right    : 0;       /* 標準表示位置(右)   */
  text-align     : center;        /* 文字位置は中央     */
  line-height    : 60px;    /* 1行の高さ(中央寄せ)*/
  font-size      : 18pt;    /* 文字サイズ   */
  font-weight    : bold;    /* 太字         */
  border   : 2px solid #cccccc;      /* 枠線(一旦四方向)   */
}
 
/* === ON側のラジオボタンのラベル(標準) ================== */
.btnOnOff #poweron + label {
  right    : 50%;     /* 右端を中央に変更   */
  border-radius  : 6px 0 0 6px;   /* 角丸(左側の上下)   */
  background     : #eeeeee;    /* 背景         */
  color    : #666666;    /* 文字色       */
  border-right   : none;    /* 枠線の右側を消す   */
}
 
/* === ON側のラジオボタンのラベル(ONのとき) ============== */
.btnOnOff #poweron:checked +label {
          /* 背景グラデーション */
  background     : linear-gradient(180deg, #00b359, #006633, #00b359);
  color    : #ffffff;    /* 文字色       */
  text-shadow    : 1px 1px 1px #333333;    /* 文字に影を付ける   */
}
 
/* === OFF側のラジオボタンのラベル(標準) ================ */
.btnOnOff #poweroff + label {
  left     : 50%;     /* 左端を中央に変更   */
  border-radius  : 0 6px 6px 0;   /* 角丸(右側の上下)   */
  background     : #eeeeee;    /* 背景         */
  color    : #666666;    /* 文字色       */
  border-left    : none;    /* 枠線の左側を消す   */
}
 
/* === OFF側のラジオボタンのラベル(OFFのとき) ============= */
.btnOnOff #poweroff:checked +label {
          /* 背景グラデーション */
  background     : linear-gradient(175deg, #cccccc, #999999, #cccccc);
  color    : #ffffff;    /* 文字色       */
  text-shadow    : 1px 1px 1px #333333;    /* 文字に影を付ける   */
}

 そしてCSS。

 ON/OFFは、ON/OFFスイッチをCSSのみで実装の「グラデーションのスイッチボタン」をまんま頂戴しました。

    ↑をポイント、再生ボタンで再生できます。

 前述のようにIPアドレスの取得に一定の時間がかかります。

 そこでHTML表示自体を遅延させることにしました。

 ついては、namnium1125氏のコードと徐々にフェードイン、フェードアウトさせるアニメーションサンプルを参照。

 その上で即表示される「しばらくお待ちください...」メッセージがフェードアウトすると同時に最新の運転状態が反映された操作パネルがフェードインするように設定しました。

注意事項

 最初にページを開いた時、温度や湿度、他センサー値が表示されていない、運転中なのに該当するモードや風量が選択されていないといった場合は、[F5]などで画面更新を。

 そうなる原因は基本2つ、1つはWebサーバ起動直後、IPアドレス取得ほか、まだ準備ができてない段階で操作パネルを開いた、2つめは、そもそもIPアドレス取得にかける時間が微妙なためと思われます。

 前者は、開発時以外(後述の自動起動後)は起こり得ない、後者であれば、Promise関数のTimeout時間をもう少し長めにとることで対処できます(これに伴い、延長する時間によってはCSSでメッセージのフェードアウト・画面のフェードイン時間を延長する必要もありますが)。

 また、クライアント側JavaScriptにおいて2度手間なロジックを書いている部分がある気はしているものの、正常に機能していることもあり、そのままにしてあります(が、気が向いたら修正します)。

 あと、これは何れも気のせいかもしれませんが...

 何れかのみなら問題ないものの、スマホとパソコンから交互に操作した時、ボタンが機能しないとか、少しおかしな挙動を示すことがあるような?

 サンプルとした空気清浄機については、会員登録すれば、複数のスマホから空気清浄機を操作できるとあるので、今回、会員登録やログイン、クラウドも経由せずに使っているとは言え、もしそうなら、自身の作りの問題かと。

 尤も自作操作パネルでは、スマホとPC両方で同時に同操作パネルを開いている場合、一方での操作時に他方のパネルを自動更新するようには作っていないので、そのせいかもしれませんが。

 メーカーが複数のスマホでも操作できるといっているケースも同時に開いた操作パネル上の話ではなく、別々にならということであれば同じですが、公式アプリでこれを試してみたことがないので実際のところは、わかりません。

 もし、そうなったら、メインメニューから、もしくは、改めてブラウザ上でアドレスとポートを指定すれば、正常動作するかと。

 再現するものなのか含め、しばらく使いながら正確な状況を把握できればと思ってはいますが。

 尚、サンプルとした空気清浄機において加湿の「自動」は、「おまかせ」モードと「のど・はだ」モード専用で手動設定不可、これらコースを選択時、自動的に「自動」がチェックされ、これらコースからそれ以外のコースや風量を選択した場合のみ、加湿が自動的に「OFF」となり、その後は、加湿のモードを任意に設定することができるという仕様になっています。

 加湿の自動OFFについては、なぜか、この状況においてのみ、「おまかせ」コースや「のど・はだ」コース選択前の値を取得できなかったからですが、公式アプリでは、これらコースを選択する前の加湿モードに移行できるので、自身が何か勘違いしているだけのようですが。

 ちなみに同一の名前空間でサーバとクライアント間においてデータ送受信する際、サーバ側のkey-valueのkey名とクライアント側で受け取る変数名を同じにしてしまうと無駄にハマります。

Webサーバの自動起動

 2台それぞれのPCに、メインのパソコンに...とも思いましたが、これらの他、都度起動の既存のラズパイサーバにもWebサーバを置くことに。

 ラズパイサーバを起動している場合は、自作スマートホームブラウザ操作パネルから、ラズパイサーバを起動してない場合は、デスクトップにショートカットを置いてfirefox-esrにlocalhost:*を渡す恰好で。

$ cd path/to/place
index.html server.js css/ js/
$ ls js
main.js
$ ls css
css.css
$

 そのラズパイサーバにnode.jsプロジェクトを移行、このラズパイサーバが起動した時にNode.js Webサーバを自動起動させます。

# /etc/systemd/system/mck70y_control.service
[Unit]
Description=DAIKIN Air Cleaner MCK70Y Control Panel service
Wants=network-online.target
After=network-online.target
 
[Service]
Type=oneshot
ExecStart=/bin/su - USER /bin/sh -c "/home/USER/.aircleaner_start.sh"
RemainAfterExit=yes
 
[Install]
WantedBy=multi-user.target

 ラズパイサーバ起動時に自動起動させるべく、systemdのサービスファイルを作成、ファイル名はもちろん任意。

 実行ファイルをホームディレクトリに置いた例、USERやパス、ファイル名は適宜変更。

 動いているし、たぶん、こんな感じで良いんじゃないかと。

# ~/.aircleaner_start.sh
 
#!/bin/bash
 
node /home/USER/path/to/place/server.js &

 /bin/sh -cとしたからか、shellスクリプトじゃないとうまくいかなかったのでラッパスクリプト。

 サーバ側スクリプトをバックグラウンド起動。

 隠しファイルとして(.付けて)みた例、ファイル名は任意ですが、当然、systemdファイル内の[ExecStart]で指定の名称と合わせる必要あり。

$ chmod +x ~/.aircleaner_start.sh
$ sudo systemctl start mck70y_control.service
$ sudo systemctl enable mck70y_control.service
$

 ラッパスクリプトに実行権限を。

 でサービス開始と次回起動時、自動起動設定。

自作スマートスピーカー機能でも使える汎用スマート家電操作用スクリプト

 一方、専用機としてのラズパイや2台のパソコンに入れた自作Julius/Open JTalkスマートスピーカー機能は、これらがコンピュータなので、こんなまどろっこしいことをしなくともWebサーバも要らず、pythonスクリプトだけで良いので簡単です。

#!/bin/env python
 
import sys
import requests
import logging
logging.getLogger("scapy.runtime").setLevel(logging.ERROR) # Warning 抑制
from scapy.all import srp1, Ether, ARP
 
def scan(macaddr: str, nwaddr: str) -> str:
  ans = srp1(Ether(dst=macaddr) / ARP(pdst=nwaddr), timeout=1, verbose=0)
  if not ans:
    return ""
  return ans.psrc
 
ipaddr = scan("**:**:**:**:**:**", "192.168.1.0/24")
print('ipaddr',ipaddr)
 
args = sys.argv
if len(args) == 1:
  print('An argument is necessary.')
  exit()
arg = args[1]
 
# 空気清浄機のIPアドレス
url = 'http://' + ipaddr
 
# 基本情報取得
basic_info = '/common/basic_info'
# センサー情報取得
get_sensor = '/cleaner/get_sensor_info'
 
# 運転情報取得
get_control = '/cleaner/get_control_info'
# 操作設定
set_control = '/cleaner/set_control_info'
 
# 実行パラメータセット
param_on = {'pow': 1}
 
param_off = {'pow': 0}
 
param_auto = {'pow': 1,
       'mode': 0,
       'airvol': 0,
       'humd': 0,
        }
 
param_silent = {'pow': 1,
        'mode': 0,
        'airvol': 1,
        'humd': 0,
          }
 
param_weak = {'pow': 1,
       'mode': 0,
       'airvol': 2,
       'humd': 0,
        }
 
param_standard = {'pow': 1,
         'mode': 0,
         'airvol': 3,
         'humd': 0,
           }
 
param_turbo = {'pow': 1,
        'mode': 0,
        'airvol': 5,
        'humd': 0,
         }
 
param_reliance = {'pow': 1,
         'mode': 1,
         'airvol': 0,
         'humd': 0,
           }
 
param_save_power = {'pow': 1,
          'mode': 2,
          'airvol': 0,
          'humd': 0,
            }
 
param_pollen = {'pow': 1,
        'mode': 3,
        'airvol': 0,
        'humd': 0,
          }
 
param_throat_skin = {'pow': 1,
           'mode': 4,
           'airvol': 0,
           'humd': 4,
             }
 
param_circulator = {'pow': 1,
          'mode': 5,
          'airvol': 0,
          'humd': 0,
            }
 
param_silent_hum_1 = {'pow': 1,
           'mode': 0,
           'airvol': 1,
           'humd': 1,
             }
 
param_weak_hum_1 = {'pow': 1,
          'mode': 0,
          'airvol': 2,
          'humd': 1,
            }
 
param_standard_hum_1 = {'pow': 1,
            'mode': 0,
            'airvol': 3,
            'humd': 1,
               }
 
param_turbo_hum_1 = {'pow': 1,
           'mode': 0,
           'airvol': 5,
           'humd': 1,
             }
 
param_save_power_hum_1 = {'pow': 1,
             'mode': 2,
             'airvol': 0,
             'humd': 1,
                }
 
param_pollen_hum_1 = {'pow': 1,
           'mode': 3,
           'airvol': 0,
           'humd': 1,
             }
 
param_circulator_hum_1 = {'pow': 1,
             'mode': 5,
             'airvol': 0,
             'humd': 1,
                }
 
param_silent_hum_2 = {'pow': 1,
           'mode': 0,
           'airvol': 1,
           'humd': 2,
             }
 
param_weak_hum_2 = {'pow': 1,
          'mode': 0,
          'airvol': 2,
          'humd': 2,
            }
 
param_standard_hum_2 = {'pow': 1,
            'mode': 0,
            'airvol': 3,
            'humd': 2,
               }
 
param_turbo_hum_2 = {'pow': 1,
           'mode': 0,
           'airvol': 5,
           'humd': 2,
             }
 
param_save_power_hum_2 = {'pow': 1,
             'mode': 2,
             'airvol': 0,
             'humd': 2,
                }
 
param_pollen_hum_2 = {'pow': 1,
           'mode': 3,
           'airvol': 0,
           'humd': 2,
             }
 
param_circulator_hum_2 = {'pow': 1,
             'mode': 5,
             'airvol': 0,
             'humd': 2,
                }
 
param_silent_hum_3 = {'pow': 1,
           'mode': 0,
           'airvol': 1,
           'humd': 3,
             }
 
param_weak_hum_3 = {'pow': 1,
          'mode': 0,
          'airvol': 2,
          'humd': 3,
            }
 
param_standard_hum_3 = {'pow': 1,
            'mode': 0,
            'airvol': 3,
            'humd': 3,
               }
 
param_turbo_hum_3 = {'pow': 1,
           'mode': 0,
           'airvol': 5,
           'humd': 3,
             }
 
param_save_power_hum_3 = {'pow': 1,
             'mode': 2,
             'airvol': 0,
             'humd': 3,
                }
 
param_pollen_hum_3 = {'pow': 1,
           'mode': 3,
           'airvol': 0,
           'humd': 3,
             }
 
param_circulator_hum_3 = {'pow': 1,
             'mode': 5,
             'airvol': 0,
             'humd': 3,
                }
 
def control(params):
  return requests.get(url + set_control, params)
 
print('param: ', arg)
 
if arg == 'on':
  res = control(param_on)
elif arg == 'off':
  res = control(param_off)
elif arg == 'silent':
  res = control(param_silent)
elif arg == 'weak':
  res = control(param_weak)
elif arg == 'standard':
  res = control(param_standard)
elif arg == 'turbo':
  res = control(param_turbo)
elif arg == 'save_power':
  res = control(param_save_power)
elif arg == 'pollen':
  res = control(param_pollen)
elif arg == 'circulator':
  res = control(param_circulator)
elif arg == 'silent_hum_1':
  res = control(param_silent_hum_1)
elif arg == 'weak_hum_1':
  res = control(param_weak_hum_1)
elif arg == 'standard_hum_1':
  res = control(param_standard_hum_1)
elif arg == 'turbo_hum_1':
  res = control(param_turbo_hum_1)
elif arg == 'save_power_hum_1':
  res = control(param_save_power_hum_1)
elif arg == 'pollen_hum_1':
  res = control(param_pollen_hum_1)
elif arg == 'circulator_hum_1':
  res = control(param_circulator_hum_1)
elif arg == 'silent_hum_2':
  res = control(param_silent_hum_2)
elif arg == 'weak_hum_2':
  res = control(param_weak_hum_2)
elif arg == 'standard_hum_2':
  res = control(param_standard_hum_2)
elif arg == 'turbo_hum_2':
  res = control(param_turbo_hum_2)
elif arg == 'save_power_hum_2':
  res = control(param_save_power_hum_2)
elif arg == 'pollen_hum_2':
  res = control(param_pollen_hum_2)
elif arg == 'circulator_hum_2':
  res = control(param_circulator_hum_2)
elif arg == 'silent_hum_3':
  res = control(param_silent_hum_3)
elif arg == 'weak_hum_3':
  res = control(param_weak_hum_3)
elif arg == 'standard_hum_3':
  res = control(param_standard_hum_3)
elif arg == 'turbo_hum_3':
  res = control(param_turbo_hum_3)
elif arg == 'save_power_hum_3':
  res = control(param_save_power_hum_3)
elif arg == 'pollen_hum_3':
  res = control(param_pollen_hum_3)
elif arg == 'circulator_hum_3':
  res = control(param_circulator_hum_3)
else:
  text = 'nothing'
 
if arg != 'nothing':
  text = res.text
 
print('res: ', text)

 このスクリプトを自作スマートスピーカーの仕組みに取り込む必要はありますが。

 と言っても既存の自作スマートスピーカーのスクリプトにおいて操作する音声に符合する条件に、それぞれ、このスクリプトと引数を指定する程度。

 これは、PythonでMAC AddressからIP Addressを調べるスクリプトとakiraseto / daikinCleanerのスクリプトの合作で、ほんのちょっとだけ手を加えたものです。

 操作パネルでは取得したget_sensorの他、basic_infoなどこのスクリプト版では使用していない行もありますが、音声操作でスマート家電をONした際などに、これらの値を読み上げるのもありかもしれませんね。

 ipaddr = scan()行のMACアドレスとネットワークアドレス+アドレス範囲だけ環境に合わせて変えれば、そのまま使えるはずです。

 ファイル名にif-elif-else文のargにあたる各フレーズを1つ引数として渡すだけ。

 端末上から実行する場合、結果、IPアドレス、引数、通ればret=OKの戻り値を表示します。

 同時に前述の操作パネルと同様に実際のコマンドパスを使っているので後述の対象となるダイキン加湿ストリーマ空気清浄機があってWi-FiとpythonスクリプトでMACアドレスとネットワークアドレスを指定し、Pythonを実行できる環境が整っていれば、実際に作動します。

 ただ、arpするのに管理者権限(sudo)がいる...、そりゃそっか。

 結果、スマホ・タブレットに限らず、パソコンからでもブラウザ版操作パネルから、ラズパイやパソコンに入れたJulius/Open JTalkスマートスピーカー機能からでも音声操作できるようになりました。

備考

 ここで掲載しているスクリプト類は、ダイキン加湿ストリーマ空気清浄機MCK70Yをサンプルとしており、操作コマンドも実際のもの、参照リンク先等も併せて考えると、少なくともリンク先に書いた機種(ACK|TCK|MCK 70 W|X|Y|Z)で実際に操作可能と思われます。

ホーム前へ次へ