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

自作ESP32スマートリモコンでスマートTV Hisense 40C35Rを操作

ホーム前へ次へ
ESP8266って?

自作ESP32スマートリモコンでスマートTV Hisense 40C35Rを操作

自作ESP32スマートリモコンでスマートTV Hisense 40C35Rを操作

2026/03/12

 Wi-Fi(wifi)モジュールESP8266/ESP32開発ボードを使ってAC100V含む赤外線リモコン対応家電を操作する、いわゆるスマートリモコンの自作や非IRリモコン家電を無線で遠隔操作できる、いわゆるスマートコンセント・スマートプラグを自作してみるシリーズ。

 今回は、15年経過で壊れたSHARP AQUOSから買い替えたスマートテレビHisense 40C35R用のスマートリモコンを久々に自作してみました。

 スマートテレビだけに音声操作もAmazon AlexaスキルやApple Homeに対応、内蔵のVIDAA VOICEなるものもあったりする模様なものの、自身は、Julius/Open JTalkな自作スマートスピーカーを使用しており、これも対応済み。

 また、Hisense 40C35Rも含め、スマートテレビのリモコンは、赤外線だけでなく、Bluetoothを介したリモコン操作もできる(ものも多い?)っぽいですが、今回の自作スマートリモコンは、赤外線版ということで。

実装した操作ボタン

スマートテレビHisense 40C35R用リモコンERF3C46H

 今回はリモコン(型式:ERF3C46H)上の物理ボタン全て実装してみました。

スケッチロジック変更

 尚、なぜか、以前の方法(≒スケッチのロジック)だとブラウザ操作時、URL入力欄に指定すればテレビを操作できるのに、[form input=submit]ボタンからだと、どうやってみても操作できなかった(赤外線LEDが点灯しなかった)ので、最初は、趣向を変えてブラウザから[form input=button ... onClick=""]ボタンのonClickイベントでURL+操作用パスを投げる方式に変更してみました。

 が、これに伴い、ブラウザURL欄入力や端末からcurl http://URL/[操作パス]だと変わらず1回の赤外線点灯が、ESP32を介すと1度の操作で点灯が2回となり、当初、2000としていたdelay値を500にしても操作が2度行われてしまう(電源みたいに1つでON/OFFだとONしてもOFF、OFFしてもONになってしまう)ので実質1回の送信に見せかけられるか?と、とりあえず、これを回避することができたdelay(50)で対処して...みたところ、2度は2度のようで電源含む多くは期待通りになるも音量やチャンネルのアップダウンなどは2度実行され、期待通りにならない...そりゃそうか...。

 他方、当初、トップページのindex.htmlあり・なしに関わらず実装できていたはずのものが、いつしか、index.htmlありでしか実装できなくなり、若干もやもやしていたこともあり、一瞬逆に思えるものの、server.on("/",handleroot)ではなく、server.on("/index.html",handleroot)としてindex.htmlなしでトップページを表示するように指定。

 これを逆にするとindex.htmlありのトップページに遷移する一方、ボタンを押下して実行すると、後述のように200 OKにしてもindex.htmlへリダイレクト後、302にしても、なぜか空白ページが表示されたりしてうまくいきませんでした...。

 更に操作用コマンド実行関数では、server.send(200, text/html, "ANY_PHRASE")とすると、ANY_PHRASEと表示されたページに遷移してしまうため(text/plainでも同様)、代わりに、server.sendHeader("Location", "/index.html")後、server.send(302, text/...に変更。

 ただ、結果、操作後、トップページにリダイレクトされるため、スクロールした先にある表示しきれない範囲にあるコマンドボタンだったとしても、そこに戻ることはなく、最上部からの表示圏内に遷移することに...。

 これも当初は、server.send(200...で、当然リダイレクトもされないので操作コマンドを押す前後で表示圏が変わることもなく、いけてたんですけどね...。

 ってか、Arduino IDEとか、ライブラリとか、どこか変更になった影響?でも、こうした現象についての情報もなさ気なので自身が何か見落として大ボケかましてるだけ?

 待てよ?自身の過去のスケッチが無茶苦茶で、当時、それをArduino IDEとか、ライブラリが善きに計らってくれてたけど、そこらへん厳格になって...とか、そういうこと?

 だとしたら、もしかして7〜8年前のスケッチ(indexOfで見つけたパスに応じてirsendするメソッドに投げる方法)のまま、単にこんな風にするだけ(自身は初見も仕様的には、最初からなのか、少なくともリンク先の通り、5年前には知られていた方法)で、いける?のかも?

 ということで、リンク先に倣ってserver.on("/", HTTP_POST, classification);の後でserver.on("/", handleroot);、リダイレクト先も"/"とすることで、index.htmlなしのトップページでテレビリモコンボタン群を表示、かつ、ボタン押下後もindex.htmlなしのトップページに戻りつつ、赤外線送信、赤外線の点灯も1回で操作できるようになりました。

 押下ボタンの位置に関わらず、リダイレクトで戻るのは、常にページ最上部であることに変わりはありませんが。

 そもそも、当初(7〜8年前)に、server.send("200",...で(何かの拍子に画面遷移することもあった一方、それは稀で)画面遷移することなく、irsend及び機器をリモコン操作できていたという方が、バグであって、本来、現在のように画面遷移してしまう方が、正常ですよね。

 つまり、曖昧(ファジー)に善きに計らっていたところが、厳格になって当然の挙動を示すようになっただけのこと。

 server.on("/", HTTP_POST,...やserver.on("/", ...も、以前、HTTP_POST(やHTTP_GET)といった第2引数が不要でも実行できてしまう曖昧さを排除、明示させるようにしただけのこと。

 以前、ルート指定を先にしてもいけていた一方、現時点では、操作コマンド用が先で、ルート指定が後でないと正常な挙動を示さないという点も厳格化の一貫で、前者だと「(最初にルート判定してしまうと、他の操作コマンド用パスも全て先頭文字は"/"だから判定されることなく、常にトップページに飛ぶだけになってしまう)」はずな部分を善きに計らっていたわけだから本来あるべき姿に修正されただけのことかと。

 もしかして、これは、今でもserver.on("/", handleroot);に一緒に書けば、良かったのか...?だとしてもhandlerootのindexOf判定に"/"も足す必要ある...?

 コピペなどで深くロジックを考えることもなく、以前は、動いていたからと思考停止して使いまわしていると、至極当然なことすら見落として、こういうところで躓くってことですね...なんとも、お恥ずかしい限り...反省...。

事前準備

 今回利用したライブラリもmarkszabo/IRremoteESP8266

 受信用はブレッドボードながら既に回路が組んだままになっていたESP8266ボードでバージョンは覚えていないものの、同ライブラリサンプルスケッチのIRrecvDump(だったはずで)、Hisense 40C35RのIRについては、NEC方式と認識され、irsend.sendNEC()関数を使いました。

 今回、送信用には、ESP32を使うのでESP8266WebServer.hではなく、デフォルトで入っているらしきWebServer.h、ESP8266mDNS.hに代えてESPmDNS.h、赤外線送信用にIRsend.hを使用。

 また、ここと同様、SPIFFSやLittleFSを使用する場合は、その準備も。

 尚、その際、Arduino IDE 1.x系を使っている場合、ボードマネージャなどでesp32 coreをアップデートしてしまうとesp32fs.jarやesp8266littlefs.jarがあって、それまで利用できていても元のバージョンに戻さないとSPIFFS/LittleFS/FATなどファイルシステムを使えなくなるので要注意。

 Arduino IDE 1.x系で[esptool not found]が表示されたのが、それらファイルシステムにアップロードしようとした時なら、まさにそれ。

回路

 送信用回路は、ESP32ボードのGPIO 4、抵抗220Ω、赤外線LEDアノード、赤外線LEDカソード、ESP32ボードのGND(ブレッドボード上で仮実装)。

 尚、赤外線LEDのアノード/カソードは、脚が長い方がアノードかと思いきや、モノによっては脚が長い方がカソードというケースもあるので注意。

 テレビ専用の送信機であり、到達距離もそこそこあって十分なので赤外線LEDは1つ、LEDの素性がわからないとは言え、抵抗値は何も考えておらず、適当。

 ただ、2ポートある内、これを接続していたテレビ内蔵のUSBポートから通電しなくなった原因は一体...?

 内蔵ポートは、もう1ポートあって、その後も通電確認はできているものの、念の為、壁コンセントからの複数口ある電源延長コードの1口にタップを付け、他1つとタコ足のコンセント+USB充電器に接続し、何時間も使ってみてますが、不具合もなく順調。

 あ、録画しないから良いけど、USBポートは、1つはUSB HDD用、もう1つは、ハブポートらしい、何も考えず挿してたけど通電しなくなったのは、HDD用なのかも?HDD用にハブのつもりでつなぐと耐えられないとか?

 適当とは言え、もっと抵抗値は低くても良いくらいじゃないかと思っていたのですが、気のせい?

 ESP32ボードもブレッドボードもジャンパワイヤも新品、ジャンパワイヤが外れて誤配線したわけでもない。

 そう言えば、ESP32ボードのGNDに挿したジャンパワイヤのメスヘッダが使い始めより、結構ユルユルになった(過去にもESP32ボードで何度か遭遇したことがあって不思議に思っている)のですが、熱で溶けてた?何かの折にGPIO4のヘッダピンを曲げてしまって戻したのですが、これら含め、どっかショートしてるとか、ESP32ボード側に起因?

 はたまた、新品で買って数日なテレビの内蔵USBポートがめちゃめちゃ弱っちぃ?個体差?珍現象?レア中のレア?

スケッチ

#include <WiFi.h>
#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <WebServer.h>
#include <ESPmDNS.h>
//#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <Arduino.h>
#include <FS.h>
#include <SPIFFS.h>
 
const char* path_root   = "/index.html";
 
#define BUFFER_SIZE &16384&
uint8_t buf[BUFFER_SIZE];
 
const char *ssid = "SSID";
const char *password = "PASSPHRASE";
 
IPAddress local_IP(192, 168, 1, 236);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
 
const char common_name[40] = "ANY_NAME";
const char *OTAName = common_name;
const char *mdnsName = common_name;
 
WebServer server (80);
 
const uint16_t led_pin = 4;
IRsend irsend(led_pin);
#define SendDeLayTime 50
 
void handleRoot() {
  Serial.println("Access");
 
  char message[20];
  String(server.arg(0)).toCharArray(message,20);
  server.send(200, "text/html", (char *)buf);
 
}
 
void classification() {
  if (server.arg(0).indexOf("電源") != -1) {
    power();
  }
...
  else if (server.arg(0).indexOf("ch4") != -1) {
    ch4();
  }
  else if (server.arg(0).indexOf("ch5") != -1) {
    ch5();
  }
  else if (server.arg(0).indexOf("ch6") != -1) {
    ch6();
  }
...
}
...
void power() {
  Serial.println("power");
  irsend.sendNEC(0xFDB04F,32);
  delay(SendDeLayTime);
  server.sendHeader("Location","/");
  server.send(302, "text/plain", "電源 ON/OFF");
}
 
...
 
void ch4() {
  Serial.println("4ch");
  irsend.sendNEC(0xFD20DF,32);
  delay(SendDeLayTime);
  server.sendHeader("Location","/");
  server.send(302, "text/plain", "4ch");
}
 
void ch5() {
  Serial.println("5ch");
  irsend.sendNEC(0xFDA05F,32);
  delay(SendDeLayTime);
  server.sendHeader("Location","/");
  server.send(302, "text/plain", "5ch");
}
 
void ch6() {
  Serial.println("6ch");
  irsend.sendNEC(0xFD609F,32);
  delay(SendDeLayTime);
  server.sendHeader("Location","/");
  server.send(302, "text/plain", "6ch");
}
 
...
 
void u_next_btn() {
  Serial.println("U-Next Button");
  irsend.sendNEC(0xFD1DE2,32);
  delay(SendDeLayTime);
  server.sendHeader("Location","/");
  server.send(302, "text/plain", "U-Next Button");
}
 
void handleNotFound() { // if the requested file or page doesn't exist, return a 404 not found error
  if (!handleFileRead(server.uri())) {        // check if the file exists in the flash memory (SPIFFS), if so, send it
    server.send(404, "text/plain", "404: File Not Found");
  }
}
 
bool handleFileRead(String path) { // send the right file to the client (if it exists)
  //Serial.println("handleFileRead: " + path);
  if (path.endsWith("/")) path += "index.html";          // If a folder is requested, send the index file
  String contentType = getContentType(path);             // Get the MIME type
  String pathWithGz = path + ".gz";
  if (SPIFFS.exists(pathWithGz) || SPIFFS.exists(path)) { // If the file exists, either as a compressed archive, or normal
    if (SPIFFS.exists(pathWithGz))                         // If there's a compressed version available
      path += ".gz";                                         // Use the compressed verion
    File file = SPIFFS.open(path, "r");                    // Open the file
    size_t sent = server.streamFile(file, contentType);    // Send it to the client
    file.close();                                          // Close the file again
    //Serial.println(String("\tSent file: ") + path);
    return true;
  }
  Serial.println(String("\tFile Not Found: ") + path);   // If the file doesn't exist, return false
  return false;
}
 
String getContentType(String filename) { // determine the filetype of a given filename, based on the extension
  if (filename.endsWith(".html")) return "text/html";
  else if (filename.endsWith(".css")) return "text/css";
  else if (filename.endsWith(".json")) return "text/css";
  else if (filename.endsWith(".js")) return "application/javascript";
  else if (filename.endsWith(".ico")) return "image/x-icon";
  else if (filename.endsWith(".png")) return "image/x-icon";
  else if (filename.endsWith(".gz")) return "application/x-gzip";
  return "text/plain";
}
 
void startOTA() { // Start the OTA service
  ArduinoOTA.setHostname(OTAName);
  //  ArduinoOTA.setPassword(OTAPassword);
 
  ArduinoOTA.onStart([]() {
    Serial.println("Start");
    /*
    // turn off the LEDs
    for (int i = 0; i < 6; i++) {
      digitalWrite(LED_BUILTIN, HIGH);
      digitalWrite(LED_BUILTIN, LOW);
    }
    */
  });
  ArduinoOTA.onEnd([]() {
    Serial.println("\r\nEnd");
  });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
  });
  ArduinoOTA.onError([](ota_error_t error) {
    Serial.printf("Error[%u]: ", error);
    if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
    else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
    else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
    else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
    else if (error == OTA_END_ERROR) Serial.println("End Failed");
  });
  ArduinoOTA.begin();
  Serial.println("OTA ready\r\n");
}
 
//
// https://github.com/zhouhan0126/WebServer-esp32/blob/master/examples/FSBrowser/FSBrowser.ino
//
void listDir(fs::FS &fs, const char * dirname, uint8_t levels) {
  //  Serial.printf("Listing directory: %s\n", dirname);
 
  File root = fs.open(dirname);
  if (!root) {
    Serial.println("Failed to open directory");
    return;
  }
  if (!root.isDirectory()) {
    Serial.println("Not a directory");
    return;
  }
 
  File file = root.openNextFile();
  while (file) {
    if (file.isDirectory()) {
      Serial.print("  DIR : ");
      Serial.println(file.name());
      if (levels) {
        listDir(fs, file.name(), levels - 1);
      }
    } else {
      Serial.print("  FILE: ");
      Serial.print(file.name());
      Serial.print("  SIZE: ");
      Serial.println(file.size());
    }
    file = root.openNextFile();
  }
}
 
void startSPIFFS() { // Start the SPIFFS and list all contents
  SPIFFS.begin();                             // Start the SPI Flash File System (SPIFFS)
  Serial.println("SPIFFS started. Contents:");
  {
    listDir(SPIFFS, "/", 0);
    /*
    Dir dir = SPIFFS.openDir("/");
    while (dir.next()) {                      // List the file system contents
      String fileName = dir.fileName();
      size_t fileSize = dir.fileSize();
      Serial.printf("\tFS File: %s, size: %s\r\n", fileName.c_str(), formatBytes(fileSize).c_str());
    }
    Serial.printf("\n");
    */
  }
}
 
void setup() {
  Serial.begin(115200);
  WiFi.disconnect();
 
  WiFi.config(local_IP, gateway, subnet);
 
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.println("");
 
  while ( WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(ssid);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
 
  irsend.begin();
  startOTA();
  startSPIFFS();
 
  if (!MDNS.begin("espshaquos")) {
    Serial.println("Error setting up MDNS responder!");
    while (1) {
      delay(1000);
    }
  }
  Serial.println("mDNS responder started");
 
  server.on("/", HTTP_POST, classification);
  server.on("/", handleRoot);
 
  server.onNotFound(handleNotFound);
 
  server.begin();
  Serial.println("HTTP server started");
 
  MDNS.addService("http", "tcp", 80);
}
 
void loop() {
  server.handleClient();
}

 IR送信用スケッチは、こんな感じ。

 尚、操作パネルとなるHTMLファイルから投げるinputのvalue値及び、これを受けるESP32のinoファイル内のindexOf判定で数値、テレビなら例えば、1ch〜11ch(ch1〜ch11)を使う場合は、要注意。

 1ch(ch1)を検索・評価する際、10chや11ch(ch10やch11)が該当しないよう、上から順に評価される(はずの)if-else if文を使用して、10chや11ch(ch10やch11)をch1(ch1)より前に置くか、10chや11ch(ch10やch11)をch10やch11(10chや11ch)として並びを変えるなどして判定ミスやエラーとなるのを回避する必要があるので注意。

操作パネル例とhtmlソース

テレビ用ESP32自作スマートリモコン操作パネル例

 操作パネルは、こんな感じ。

 これは、CSSで変にいじってますが、よりデフォルトに近い方が、より多くボタン表示やレイアウトできてよいでしょう。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>ESP32 TVリモコン</title>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<script>
function http_req(url) {
  var req = new XMLHttpRequest();
  req.open("GET", url, true);
  req.send();
}
</script>
</head>
<body>
<h1>テレビ</h1>
<form action="/" method="post">
<div>
<input type="button" name="home" value="メインメニュー" class="home" onClick="http_req(location.href='http://192.168.1.230')">
<input type="submit" name="power_btn" value="電源">
</div>
...
<div>
<input type="submit" name="4ch_btn" value="ch4">
<input type="submit" name="5ch_btn" value="ch5">
<input type="submit" name="6ch_btn" value="ch6">
</div>
<div>
...
</div>
<div>
<input type="submit" name="U_next_btn" value="U-NEXT">
</div>
</form>
</body>
</html>

 というわけでCSSを端折った版のSPIFFSに上げたdataフォルダ内のindex.htmlは、こんな感じ。

 inputタグのvalue値に1桁から2桁までの数字を使う場合、要注意

ホーム前へ次へ