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

自作スマートカーテン/自動開閉タイマー付き無線電動カーテン

ホーム前へ次へ
ESP8266って?

自作スマートカーテン/自動開閉タイマー付き無線電動カーテン

自作スマートカーテン/自動開閉タイマー付き無線電動カーテン

2020/04/30

 Wi-Fi(wifi)モジュールESP8266/ESP32の内、ESP32開発ボードとステッピングモータ28BYJ-48を使って自動開閉タイマー付きの無線で開閉できる電動カーテン(スマートカーテン)を作ってみるページ。

 先日、作った後付けできる無線電動化カーテン ESP8266・ESP32/WebSocketにブラウザからデフォルトまたは、別途時間指定も可能な自動開閉タイマーを追加したもの。

 と思いましたが、今回は、WebSocketもMQTTも使い(使え)ませんでした

 もちろん、スマホ、タブレット、PCなどのブラウザから、また、自作ラズパイ/Julius/Open JTalkスマートスピーカー同スマートスピーカー機能を搭載したPCからの音声操作もできます。

 今回、カーテンは、1枚もので同時に両サイドに向かってそれぞれ閉じ、両サイドからそれぞれ内側に開く(真ん中で束ねる)ようにしました。

 ただ、これは、後述の自作パーツと上下どちら側のチェーンに持たせるかとスケッチ次第という話であってカーテンは1枚ものでも2枚ものでも開閉方向は、どうにでもなります。

 ちなみに動画のPC画面上のカーテン操作パネル右横に出ている時計は、当サイトのページ

 手動開閉と自作カーテンボックスについては仕掛かり中。

エアコン設置により開閉方法等を変更した自作スマートカーテン

 => [2024/08/10] 先月30日、スマートカーテン設置のサッシ上部にエアコンを設置、サッシ換気窓にダクト用窓パネルを使った関係で1間プラスアルファの左右両サイドから中央間での開閉を左からダクト位置までほぼ1間の開閉に変更。
 これに伴い、リミットスイッチと併せて、念の為、実装している自動停止時間もそれぞれ数秒延長。
 また、他の自作スマート系でもやったように以前から微妙な状況っぽいESP32Webserver.hからWebserver.hに変更して対処。

 => [2023/06/15] タイマー設定ができない状態だったのでサーバスケッチの修正HTML/JQuery送信部分を追記

 => [2022/11/26] ん?なんか開閉する以前に止まることが増えてきたな...と調べてみたらESP32の割り込み処理がシビアになったのか、これまで潜んでいた事象がここに来て発生し始めたのか、正常機能していたのに金属プレートを静電容量式のタッチパネルとして停止できるように(attachInterrupt)していたロジックでGuru Meditation Error: Core 1 panic'ed (Cache disabled but cached memory region accessed)が発生するようになっていたことが判明、とりあえず、このロジックを外したところ良好に自動、メニューからの開閉も無事できるようになりました。

 => [2022/09/24] 過去に1回、今日1回、約1回/年くらいのペースかなと思いますが、プラ製チェーンだけに伸びるようで全体が緩み動作に影響するので1つ2つチェーンパーツを外す必要があります(それも無理になれば要チェーン交換)
 => [2023/01/11] 気のせいでした。モーターと反対側のスプロケットがモーター側にズレたのが原因でした...、スプロケットもチェーンも元に戻しました。

 => [2022/07/29] time.hをincludeし忘れていました。

 => [2022/06/18] 過去、参考にした方がいらしたら申し訳ありません、当記事内のHTMLソースにpost指定したformタグを入れ忘れていました(ブラウザ上のボタン押してもESP32ボード上のWebサーバに値を送信せず機能しない状態でした)...。
 また、リセットが効かないかに思われたのは、RESTART(リセット)時に別電源のモータを止めるsetOutput(9);を入れ忘れていたからでした。
 他含め、諸々、書き直した修正版スケッチ修正版HTMLファイルをアップしました。

 => [2022/06/09] 突如、機能しなくなり、調査、結果、リミットスイッチを接続していたESP32ボード上のGPIOピンを変更したところ、正常に機能するようになりました。そういえば、過去にも一度あったのですが、なぜ?

 => [2021/05/25] 電動開閉時、一定時間以上経過で停止機能もでき、一部運用任せな部分と外観の残作業はさておき、完成しました。

 => [2021/05/19] 動画をアップしつつ、追加機能として電動開閉時に手動開閉したくなった場合などを想定、静電容量式の停止用タッチパネル(単なる金属プレートとして手近にあったステー)も付けました。

 => [2021/05/16] 電動・手動切り替えクラッチ/ギア着脱機構第2弾を作ってみた結果、邪道ながらも成功、残作業はありますが、1年越しに、ほぼ完成しました。

 => [2020/05/08] 電動・手動切り替えクラッチ/ギア着脱機構のプロトタイプを作ってみました。

 => [2020/05/03] 自作カーテンボックスできました。

ちなみに

 前置き、使ったもの、初期の試行錯誤からここまでについては、ラダーチェーン&スプロケットセット購入レビューとその後、続いてArduino/ステッピングモータ28BYJ-48/ラダーチェーンで既存のカーテンを電動化、更に使用中のカーテンを無線電動化 ESP8266・ESP32/WebSocketを参照。

プラダン/リメイクシート製自作カーテンボックス
[2020/05/03]

 今日、プラダン/プラスチックダンボール、固定用のアングルダイソーのリメイクシートを買ってきて早速、カーテンボックスを作ってみました。

 プラダン1820x910x4mm 1枚1188円、リメイクシート 200x15cm 3枚330円、アングル5個520円分買いましたが、プラダンは、内1820x200mm、リメイクシートは内1枚弱、アングルは内2個しか使っていません。

 加えて、手動開閉機能をどうするか決まれば、それ込みで後日回路・モータ・リミットスイッチ周りを定位置に固定の上、この部分(おそらくプラダンの横500〜600mm、縦400〜600mm、リメイクシート700〜800mmx150mm程度、アングル1個)のカーテンボックスも追加製作、アングルとカーテンボックスの固定もするつもりですが、それにしても実際には、約500〜600円の追加ですかね。

 今、アングルとカーテンボックスは、先のブルタックでぺたっと貼ってあるのですが、これだけでもいいかな...。

 それにしてもまるで最初から付いていた木製カーテンボックスかっていうくらい近くで見ても違和感なしなし、これで500〜600円なら、超満足。

 あ、むしろ今どきの人類は、木目プリントに慣れ過ぎてるからかも?微妙な気もするけど、まぁ、それでもいいよね、満足なんだから。

[2020/05/04] いい感じだったので追加で電動にしていない部屋にも同じく白のプラダンをベースに付けてみたのですが、陽の射し込み具合によっては、裏面の貼り様が、表面に映り込むのでプラダンの色は、黒とか濃い系の方がよかったかも...
 固定は、ステー穴に位置を合わせてプラダンに穴をあけ、ボルト、ワッシャー、ナットで留めましたが、なかなか良いです。

[2020/05/08]

 手でも開けられるように歯車でクラッチっぽいものを作ってみました。

 使った歯車は、67種類入ったプラスチック製ギアパックから選んだもの。

 ラック&ピニオン、ボールねじ、クランク&スライダなどのリンク機構を使って...といろいろ思考を巡らせましたが、こじんまりと省スペースで手間をかけずに簡単に手持ちのもので作りたい...ということで、安易にサーボモータのスイングでギアを脱着してみました。

 NO/Normal Open/常時開のリミットスイッチが押される(CLOSEする/閉じる)とステッピングモータが止まり、サーボモータで接続している(仲介している)歯車を跳ね上げ、動力を切り離す...と思い描いた通りに動いてくれました。

 が...トルクが弱い気が...っていうか、負荷がかかると滑っちゃうというか、歯飛びしちゃうというか...厚さがもっと厚い方がよいのか...、溝が深い方がよいのか...、材質が硬い方がよいのか...、そもそもサーボのホーンも硬いものを...いや、ステッピングモータにした方が...。

 安定性とか考えるとイレギュラー過ぎるかな...移動するにしても、直線運動じゃないとダメかな...っていうか、歯車(ギア)について無知なのに作っちゃいけないよね...やっぱり、ちゃんと学ばないとダメか...。

[2020/05/09]

 あ、トルクは、回転体と同じスプロケットを平歯車として使うことで維持したまま、うまく動力伝達できました...そりゃそうですよね。

 が、3個同じ大きさや仲介するギアのみ小径のスプロケット(計5セット買ったタミヤ ラダーチェーン&スプロケットセットのもの)を使ってみており、他は固定、仲介・空転するスプロケットのみ、軸に通して手で持ってやってみていますが、向きによってギアが、うまく、かみあい回りやすかったり、相当な負荷がかかりつつ、回りながらも時にがっつりハマって回らなくなったり...カーテンで言えば、閉めるのは割とスムースも開ける時は、めちゃめちゃ手に負荷がかかる...。

 これまでの電動開閉の過程でこんなことはなかったのに、なぜ?やっぱり、チェーンと組み合わせて使うスプロケットは、そもそも平歯車としては使えない?

 っていうか、豊富な中から材質、径や厚さ、歯数なんかを選べて...みたいな歯車ってどこかに手頃な値段で売ってるんだろうか...?もしかして、そんな時は、レゴのギアとか使うのかな!?

 ちなみにベースは、タミヤのユニバーサルプレートやユニバーサルアームのパーツ、ステッピングモータ共々、モータ側のスプロケット台もこれに固定しています。

[2020/05/11]

 うーむ、歯車のバラエティセットみたいなものやタミヤの四駆用ギアは薄くて小さそう、レゴの歯車は良さげも材質はともかくシャフトが十字の特殊なものっぽく汎用性はなさ気。

 各種歯車を1個から製作してくれるというサイトはさすがに敷居が高そう、ならばとAmazonに小原歯車(KHK)や協育歯車工業(KG Gear)製のギアがあるのは、知っていたものの、玄人っぽすぎると思って詳細を確認していませんでしたが、こうなったら確認してみようと思うに至りました。

 前者は、簡潔ながらもAmazon内の商品説明の情報量が多く、ざっとネット検索しても小難しい、似たような情報が多い中、歯車技術資料を公開してくれており、歯だけに?ものすごく、わかりやすく噛み砕いた説明資料が各種、PDFやHTMLとして提供されていて、まだ、流し読みしかしていませんが、相当勉強になっています。

 そんなこんなもあって小原歯車(KHK)製の樹脂製ギアを買ってみようかと思っているのですが、仲介ギアはともかく、入出力ギアの歯数を同一にするとしても、このタミヤのラダーチェーン&スプロケットにモーター直と同じ程度の速度・トルクを維持して動力を伝達するにあたり、選定方法がまだ理解できていません。

 穴径は、スプロケットやステッピングモータの軸径、手持ちのカップリングの径からして3mmや5mmが都合がよい、素材は樹脂、種類は平歯車にしようと考えています。

 偏摩耗を避けるため、できれば歯数が素数になる対にした方がよいということで仲介ギアは相応の小径にする、歯形(一般的なインボリュート歯形の他、サイクロイド歯形...etc.)、アンダーカット防止の為などの噛み合い圧力角はともかく、基準圧力角(一般的な20度の他4.5/15/17.5度...etc.)を合わせる、モジュール(歯の大きさ)が同じギアの組み合わせる、プラスチック製ギア同士だと熱を持ち、膨張しやすい為、できるなら他方を金属製にする等々...が良さそうなことはわかりました。

 が、歯の厚さ(歯幅)と基準円直径もしくは、歯先円直径さえ、スプロケットと同じくらいなら、うまく伝達できるんだろうか?スプロケットと同じかそれくらいのモジュールや歯数が良いのだろうか?全ての条件が一致するものなんてあるのだろうか?なかった場合、選定にあたり、何を基準にしたらよいのだろうか?

 試しにスプロケットと歯先円直径と歯幅が近いものをモジュール、穴径で絞ってAmazonで買ってみようと思ったら、直径を変えても、在庫なしや入荷予定はあるものの、今時点1点のみとか...で見送り。

[2020/05/15]

 DS1-28はもう1個欲しいところですが、とりあえず、DS1-15、DS1-28 各1個をAmazonで買ってみました。

[2020/05/27]

 ネット上をいろいろ探し回って勘案した結果、自身初マルツでDS1-28を発注してみることにしました(納期4日間予定)。

[2020/05/29]

 思った以上に早くマルツに注文してあったDS1-28が届いたので、早速、空転する径のシャフトに中継となるギアDS1-15、両サイドにシャフト径ピッタリのスプロケットを挿し、手で持って着脱してみました。

 想定通り、概ね良好に開閉できるようになったものの、スプロケットを平歯車として試した時とは逆に開ける時ではなく、カーテンを閉め切るちょっと前くらいから負荷が大きくなっているようで仲介ギアが、モーター側とチェーンスプロケット側のギアに持っていかれ、ギアが噛んだ状態になります。

 まずは、この負荷が大きくなる原因の究明と併行して多少の負荷に耐え得る中継ギアの固定方法の検討、ひいては、他のギアもというか、装置の土台としているプレート(タミヤ ユニバーサルプレート)の材質変更の可能性も含め、固定方法を再検討する必要がありそうです。

 これに伴い、負荷が大きくなることが不可避なら、どうするにしてもサーボを使う方法だとホーンが耐えられない・耐えても不安定になりそうです。

 よって着脱機構にも他のモータを使うこと、我ながら邪道と思えるスイング式ではなく、やはり、部品在庫となっているSOTEC e-oneとか、TOSHIBA dynabook Satellite T30 160C/5W、はたまた、HP Pavilion Slimline s3140jpなどのFDやCD/DVDドライブのステッピングモータ&ボールねじ機構、または、回転角度を正確に指定できる、静音であるという点で購入済みでカーテン開閉の動力と同じステッピングモータ28BYJ-48と、やはり、購入済みのタミヤ スライドアダプターによるスライダ・クランク機構やタミヤ ラック&ピニオンによる直線運動方式などとの併用を検討、試してみることになりそうです。

 ちなみに現在、カーテンは、チェーンを付けたまま、手で開閉しており、コスパを考えるとアレですが、前述の通り、中央で束ねる恰好で何れか一方の開閉で他方も開閉できるのは、これだけでも十分かなと思ってしまうほど超絶便利です。

 電動なら無音に近いと言えるほど静音なカーテン開閉も手動開閉だと28BYJ-48のような優雅な動作にはならない(えてして豪快に開閉してしまう)のでそれなりに牽引工作物一体のラダーチェーン動作音がしますが、これは、意識してゆっくり開閉するか、プーリー式に変更でもしない限り、致し方ないですね。

[2021/05/01]

 ギヤ着脱方法が宙に浮いたまま、思考停止して1年ほど放置していましたが、重い腰を上げて、とは言え、自身には、小型化含め、他の方法の敷居が高く感じるので性懲りもなく、サーボでの着脱を諦めきれず、SG90/MG90Sより仕様上トルクの高いメタルギアMG995金属サーボホーンT25をAliexpressで買って試してみることにしました。

 これらは既に届いたので、もちろん別電源とすれば当然ながら、当初の回路でも単にSG90をMG995と交換しただけで動作自体はしましたが、固定場所等が定まらないこともあり、スイング着脱の検証までには至っていません。

 仮設でやってみようと思ったらちょうどよいカップリングが手元になくAliexpressで3x3mmのカップリングを調達中。

 とりあえず、手持ちの2mmのシャフト、2x3mmのカップリング2個でしのげるかと思いきや、カーテン駆動モータであるステッピングモータ及び構造物と微妙に干渉。

 DS1-15よりも仲介ギアの径を大きくすれば、いけるかな?ということでAliexpressより1日でも早く届けば御の字とばかりにマルツで小原歯車工業の成形平歯車DS1-25とDS1-35を購入、ゴールデンウィーク中なので手配自体GWあけの6日以降となり、仮設+これら歯車でできるかも含め、確認は、これらが届いてからということになります。

 というか、片持ちな上、カップリング2個と2mmシャフト(カップリングを除くむき出し部分が約15mm)分、長くなることでブレが出そうですし、サーボのトルクも十二分に活かせないほか、ベースプレートからちょうどサーボがはみ出す位置になるため、どうやって固定しよう...という感じでとりあえずの確認すらもできるのか未知数ですが。

 まぁ、3mmx3mmのカップリングが届いたところで固定位置は悩ましく、届いてから考えよう...と思っています...。

[2021/05/07]

 DS1-25とDS1-35が届き、前者でも微妙だったので後者で検証。

 やってみると、さすがに金属ギアサーボMG-995、当然ながらプラギヤSG-90より確実に安定感はありますが、実現できるかって言うとできるようなできないような...。

 というのもサーボモータ固定との兼ね合いもあり、不安定な状態で手で抑えつつ、動作確認...という状況では、断言できない...。

 スペース的には、3mmx3mmのカップリングで直につなぐより、むしろ、3x2mmカップリング-2mmシャフト-3x2mmカップリングの方が、明らかに設置位置を確保しやすい感じです。

 が、やはり、片持で歯車までの距離があり、しかもシャフト径が細いこともあり、2つの歯車を仲介するにも、華奢過ぎて、はじかれてしまうケースもあります。

 また、歯車を脱する際に、やや噛んだ状態から無理やり離れる感があることもあるのですが、これが、そもそもの方法論の問題なのか、水平もとれていない不安定な状態でのテストだからなのかも判然としません。

 とは言え、サーボホーンは、弧を描いてスイングする、停止後のステッピングモーター側の歯車は回らない、カーテン側の歯車はフリーでもカーテンの重みなど相応の負荷はある状態なので多少なりとも噛むのは当然でしょう。

 が、サーボを安定して固定・設置できた場合、脱時の挙動が改善するのであれば、程度によっては、より短期間で歯車が削れ、精度が落ち、寿命が縮まるのも覚悟で、強引ながらもアリかなと。

 これが無理ならサーボによる方法は諦めるしかないでしょう。

 ということで、サーボの固定方法にあぐねています...。

 安定させるため、両持ちにする、その場合、サーボと反対側の構造物には、軸が安定する程度の精度でスイング軌道の溝?を設けつつ、軸が溝から抜けないような安定性をもたせる必要があり、それをベースプレートに固定しなければならない...。

 そんなことをする前に片持のまま、スイング軌道を考えた位置にサーボを安定して固定して確認したい...結構シビアで位置決めも難しそう...。

 他の方法は、もっと難易度高そう...。

 技量不足な自身には、果てしない感がなくもない...。

[2021/05/09]

 げっ!バックラッシ(バックラッシュ)ってあるの一方向だけなんだ...。

 ってことは、歯車には向きがあるってことじゃん!

 向き合わせてみたら、一方向は、スムースって言うか、他方がぎこちない気がするじゃん!

 ってか、電動だけならチェーン両端のスプロケット以外の歯車は不要で歯車同士のかみ合わせはないから良いけど、電動・手動切り替え想定して開閉させるのに同じ歯車群を正転・逆転させようとしてる時点でダメってことじゃん!?

 ぽかーーーーーーーーーーーーーーーーーん...。

[2021/05/15]

 ポカンとしてても仕方ないので、とりあえず、バックラッシは置いといてギア着脱方法の第2案を考えてみることにしました。

 180度回転サーボなので半周行って戻ってという感じでしか使わないものの、サーボ+スライダ&クランク機構(スロット&クランクか?)を使い、間にギアを介さず、モータ側をわずかに移動させ、カーテン駆動ギアに直接着脱させてみたらどうなのかと。

 サーボMG995+ホーンT25に加え、スライダ&クランク機構には、タミヤ ユニバーサルプレート用スライドアダプタータミヤ ユニバーサルアームセットバインダー 15 No. 310 PVCに台座としてユニバーサル基板をボルト止めするなどして現在ベースとしているタミヤ ユニバーサルプレート上に配置してみようかと。

 まだ脳内構想段階なのでうまくいくのかは未知数ですが、サーボがダメならステッピングモータとカム状のもので試してみようかなとか。

自作スマートカーテン電動・手動切り替え用ギア着脱方法第2弾
[2021/05/16]

 実際、歯車着脱バージョン2のアイデアを形にしてみたら、駆動ギアの着脱でクラッチ機能相当という強引さとバックラッシ、構造物の寸法的な正確さはさておき、第1案より遥かに安定、位置決めも楽に。

 共に歯車部分をより安定して固定できるに越したことはありませんし、サーボの固定方法は未決ですが、これなら、実現できそうというか、ほぼほぼできています。

 ギア着脱時のみとは言え、サーボ特有のギーッ音はするので気になるのであれば、スケッチ共々、ここでカーテン駆動用に使っている28BYJ-48などのステッピングモータに替えるのもありでしょう。

 が、これまた片持になっていることや荷重のバランスの関係やら何やらでベースプレート(ユニバーサルプレート)に反りが生じているらしく、後方を持ち上げる感覚で手でこれを補正すると良い感じに。

 さて、プレートをより頑強なものにすればよいのか、できれば加工作業は回避したいが、そんな都合の良いものがあるのか、このままでも当て板でもすれば、補正できるものなのか...。

 あ、手でプレートの反りを調整しなくてもスムースに開閉できるようになりました。

 ところどころ微妙に位置を変更したのが功を奏したようです。

 あと、あまり意味はなさ気ですが、カーテンレールとベースプレート(後方、出っ張り方向)の間に当て板として、ちょうどベースプレート端までの長さほどの100均のステンレス製ステーを挟んでみました。

 が、ベースプレート自体、つい数日前に良さげなのを発見し、1個外して1個交換したものの、ほぼ意味をなしていなかった1本含め、100均のクランプ3本で仮固定してあったのですが、この様子だと、固定方法を変えただけでも電動については、現物合わせというか、微調整が必要になりそうな気がしなくもありません。

自作スマートカーテン外観

 仮固定とは言え、この1年間、最低でも1日に1度ずつ、手動で雑かつ豪快にカーテンを開・閉していましたが、微動だにしていないようです。

 数日前に1つ追加したクランプ以外プレート固定部分はいじってませんが、今回の電動テストにおいても動作・機能に全く影響ありません。

 よって正式に固定するにも代替案を思いつかないので、このままで完成にしておこうかな...とも。

 駆動部設置場所は、部屋のコーナーにあたる位置。

 壁2面ともコンクリート、塗装が微妙で例えば、ブルタックやテープなどを貼っても塗装がパズルのピースのように貼った部分が任意の形状に剥がれてしまう感じ。

 天井から吊るのも、下方の行き当たるところはデスク上のデッドスペースながら脚をつけるのも微妙。

 梁もあるにはありますが極薄なので。

 尚、サーボの固定もいろいろやってみたものの、今尚、仮固定状態ですが、結果行き着いたダイソーの200円商品クイックリリースクランプ 45mmが最も安定しているので、このままにしておこうかと。

 というわけで仮固定を正式固定とするということになれば、着手当時から頭の中ではできているリミットスイッチの取付方法を決め、マイコン周辺をまとめ、この部分にカーテンボックスを付ければ、完成です。

自作スマートカーテン駆動部

 内側はこんな感じで前掲写真の右奥側のグリーン部分のサーボホーンとユニバーサルバーをクランクに歯車+ステッピングモータ28BYJ-48ごとスライド・移動させ、カーテン駆動側歯車に着脱する構造になっています。

 ステッピングモータの台座には、厚さがちょうどよかったユニバーサル基板を、これを固定するためにバインダー(紙用バインダーの背表紙部分とほぼ同じもの)で挟みボルト止めしています。

 この構造の耐久性はテストもしくは常用して確認となるものの、計算苦手なので行きあたりばったりでイメージした通りに組み上げてみたら、タミヤの各種パーツのおかげもあり、奇跡的に躓くこともなく、あっさりできました。

 ちなみに前掲写真にあった紐は今となっては意味がないので取りました。

 第1案から第2案に変更したことでスケッチも一部、要変更。

 ふー、やっとできた、ただただ、ポカーンとしてなくてよかった。

[2021/05/19]

 動画もアップしてみました。

 駆動中モーター側が微妙にうねっているのは、きっと台座にしたユニバーサルボードに結構な反りがあるからかと。

自作スマートカーテン用マイクロスイッチ/リミットスイッチ

 リミットスイッチ(マイクロスイッチ)は開閉共用でこんな感じで取付決定。

 ラッキーなことにタミヤのユニバーサルプレート用スライダのパーツの1つが、横方向における取り付け位置調整の柔軟性もあって、このサイズのリミットスイッチを固定するのにちょうどよい感じでした。

 アングルパーツも脚として何かと重宝。

 あとは、チェーンに引っかからず、絶妙な長さでスイッチ先端にクロスになるようにゼムクリップか何か軽くて薄くて曲がらない程度に頑丈なものをホットボンド、なんなら念の為、極々少量のブル・タックで補強するなどして固定、チェーン上下それぞれ良き位置に付けた突起物がスイッチを押すようにします。

 もちろん、配線はあとでちゃんとハンダ付けします。

 というわけで、とりあえず、自動で電動・手動切り替えできるようになったのでここまでで、一通り機能するようになりました。

 残るはスケッチに開・閉ともに一定時間経過しても停止しない場合、途中でひっかかったなどの状況にあると判断し、停止するロジックを入れたいなー(入れました)という話とミニブレッドボードのままにするか、ハンダ付けし直してマイコンや配線部分をケーシング、追加分のカーテンボックスを作れば完成。

 っていうか、ESP32のタイマー、予想に反して超難解っぽいじゃん...、って、もしかして、そんなこと、できないの?

 ついでに金属など触れるだけでON/OFFできる静電容量タイプのスイッチ(Arduinoだとこんな感じ)を付けてカーテンの開・閉の途中で停止できるようにすることにしました。

 例えば、電動で開・閉している間に手動で開・閉したくなることもあるかなと(検証中につけておけば、高い位置にあるリミットスイッチを押しに行かなくても済むのもメリットかも)。

 これには、ESP32内蔵タッチセンサと割り込み(サンプルスケッチTouchInterrupt)を使うことにします。

 停止だけじゃなくて開閉スイッチもつけようかな、スマホやPCなくても操作できるし(原点回帰?)、あ、でも、開閉となるとタイマー自動開閉、パネル操作と物理的な手元操作パネル、何れかで動作中かどうかチェックもいるか、やめとこっかな。

クロスバー付き自作スマートカーテン用マイクロスイッチ/リミットスイッチ
[2020/05/23]

 雑ながら、リミットスイッチ先端にホットボンドでゼムクリップを付け、配線もハンダ付け、物理的な金属プレートの停止スイッチも仮付けながら付けました。

 扉を閉めた時はリミットスイッチが常に押された状態で...というのと違ってカーテンの場合、開閉後、それぞれリミットスイッチを押したら、その瞬間解放もしてくれないといけないので、さて。

 と思ったら、ラッキーなことにチェーンだからか開閉時共にサーボモーターが停止した際に反動でリミットスイッチも解放してくれたので偶然の産物に助けられました。

 が、何度も試してほぼ成功してはいるものの、常にそうなることを期待するというのは心もとないので一定時間経過で自動停止もなんとか組み込みたいところ...(組み込みました)。

 ところでカーテンを閉め切る時に負荷が、やや大きくなるようでリミッター位置を多少移動してみたところで必ずと言ってよいほど歯飛びし、数回、カタッカタッと音がしていました。

 当初は、幅200cmx高さ180cmは良いとして、このカーテン、意外なことに気づけば柄物カーテンと遮光遮熱カーテン2枚重ねで1枚ものになっており、他のに比べて結構重いからだよね、電圧もより下げられるし、やっぱり、軽快な動作実績のある他の一重の遮熱遮光カーテンに替えようかなと思っていました。

 まぁ、それはあるにしても、結果、モーター側ギアのシャフト含む構造においてモーターの反対側にあたるシャフト部が手前のモーター側に比べ、水平方向に押され、シャフトごと若干斜めになることが原因でした(その部分を軽く指で押さえてみたら歯飛びがなくなりました)。

 スライド溝はあるので上下方向はともかく、スライド溝方向の後方からの移動体の補強ってどうしたら...。

 スライド溝後方に追加で移動体を付けてもスムースに動いてくれないといけないので補強にはならない...。

 オモリをつけるとスライドに支障をきたす...。

 シャフトがしなっているわけではないのでシャフトを太くしても解決策にはならなさそう...。

 ベースプレート上に1点、固定しつつ、固定部を回転可能にした可変のバー...とすると弧を描いてしまう...、長穴にしてこれを回避すると補強にならなそう...。

 固定バーをモーター部共々スライドさせる構造に...、それが補強になるなら、そもそもスライドさせる追加構造物を作らず、補強したいところ...。

 モーター側の移動体と反対側のシャフトやカップリングをコの字型のバー状やプレート上のもので...と言っても、ちょうど良さげなものは思い浮かばない、作ればよいと言っても何でどう作ればよいものやら...。

 反対側も同じくスライダ&クランク機構に...、スライド溝の高さも違う上、行き当たりばったりで作ったモーター側と同一の軌道となる構造にするのが容易ではない?(もしかして計算能力があれば簡単?)

 この中なら、コの字型でいけるかどうか、妥当なのは、反対側もモーター側と連動するスライダ&クランク機構も...ぽやーーん。

 知らない内に勝手に改善されててくんないかなー、ゴロゴロー、ゴロゴロー(ずん飯尾さん風)。

 カーテンを替えるか、なんらかの対策をどうにかこうにか考えて講じられるのか、さて、どうしたものか...。

 カーテン替えるか。

 カーテンを替えてみました。

 が、だいぶ軽量ですが、何らかの障害があると歯飛びします。

 歯飛びはしていますが、28BYJ-48-5Vは、脱調はしていないので、トルク的には、まだ余力はあるのでこの程度のカーテンの重さの差は関係なかったようです。

 よって先のモーターシャフト延長線上の反対側が押し負けない程度に補強できれば、元の重いカーテンでもいけそうです。

 その補強がないの場合、開くことはできても、ほぼ閉まるものの、閉め切るところまでは厳しいかも。

 ちなみに、これにも起因するかと思いますが、手動と電動で開閉しきる位置が若干異なり、手動の方が、より多く開閉でき、開閉しきってしまうとリミッターがリミットスイッチを越えてしまい、リミットスイッチ先端の破損につながる為、手で開閉する場合、開閉しきらないように気をつける必要があります。

 赤外線LEDとフォトトランジスタレーザーとフォトレジスタなどをリミット用のセンサとする方法もあり、この場合、正常範囲から検知位置を越えても装置が壊れる心配はありません。

 が、リミットスイッチ同様、停止時に反動で非検知位置へうまく移動するか、調整可能かは未知数、また、検知位置を越えた場合、その位置から電動で戻した場合、リミットスイッチとは異なり、正常範囲に戻る際の通過でうまいこと検知して停止してしまうことを意識しておく必要があったりします。

 と思いましたが、電動と手動での開閉におけるリミットスイッチの件については、発想を変えて、停止時間を優先、手で開閉しきる位置付近にリミッターを配置することで、これを回避することにしました。

 とは言え、補強方法の方は、にわかには思いつきそうもないので、これの検討を頭の片隅に置いておきつつ、とりあえず、こうした条件をクリアした環境で運用するものとしておきます。

[2021/06/22]
 あ、違いましたね、やはり、ベースプレートの反り・歪みが大元の原因で、とてつもなく、うっかりしていましたが、よく見たら、片持のベースプレートにおいて支持がない側が明らかに下がってました。
 ステッピングモーター、サーボモータなど支持がない側に重みがよりかかっているので当然ですね...。
 そこで当て板を調整したところ、歯飛びもなくなりました。
 よって以降数行の思案は不要、心配は杞憂に終わりました。

歯飛び防止にシャフトの遊びを抑えるアングルを設置

[2021/07/18]
 と思ったら、再現したので、これに加え、なぜ、すぐに思いつかなかったのか、モータの奥側ではなく、多少なりとも摩擦抵抗はあるものの、回転を邪魔しない程度にチェーンスプロケット側の歯車が付いたシャフトをモータ側に押し込んで固定すべく、タミヤのアングルパーツをあてがう恰好でベースプレートに固定。
 これにより、歯飛びしていたタイミングなのか、一部、回転が鈍くなる部分がある気がしないこともありませんが、歯飛び自体はしなくなり、安定稼働するようになりました。

 ちなみに余計な負荷がかかっているから?そもそもそういうもの?なのか、あれ?歯車回らない...と思ったら、モータのカップリングが、まさかのユルユルに...、こんな風にカップリングのイモネジの緩みが出ることもあるようなので定期的にチェックするか、故障かな?と思ったら、その可能性があることも頭の隅っこに留めおいておくと良さ気。

[2024/01/26]
 いつしか、また再現、しばらくそのままにしてあったのですが、今日ふと、大雑把ながら輪ゴムで...と思ったら、ドンピシャ。
 前後(着脱)する際、レールのみで支持がなく、片持で心もとなかったモータ+ギア移動機構部のモータと反対側のレールからはみ出したシャフト外側とカーテン開閉用スプロケットのシャフト部に普通サイズの輪ゴム2本(1本ではやや弱く、3本だとややきつい)を二重にしてかけたところ、移動量が僅かなので停止時も開く際も影響なく、閉じる際も1度も歯飛びすることなく、バッチリになりました。
 拍子抜けするほど効果的で簡単かつ原始的な技...、もっと早い段階で気づきそうなものですが...。
 さておき、万一、輪ゴムが伸びたり、切れたり、経年劣化するようなら都度交換ということで。

 というわけで残るは、開閉中、一定時間以上経過で停止するプログラムの検討(完成しました)、マイコン類の格納、駆動部周辺のカーテンボックス。

前提

 前提については、冒頭リンクの先代と同じです。

回路

 回路については、冒頭リンクの先代と同じです。

電源

 電源については、冒頭リンクの先代と同じです。

OTAにおけるスケッチのアップロード

 OTAについても冒頭リンクの先代と同じです。

スケッチ

 今回、自動開閉タイマー機能を搭載するにあたり、前回のWebsocket版とは、新規と言えるほど、大幅にスケッチを書き換えました。

// ESP32版電動カーテン
// 強いて言えばBase Codeはこれ
// ESP8266/ESP32東芝エアコン大清快をWiFi操作
 
#include <WiFi.h>
#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <ESP32WebServer.h>
#include <ESPmDNS.h>
#include <SPIFFS.h>
#include <Arduino.h>
#include <FS.h>
#include <Stepper.h>
 
const char* path_root   = "/index.html";
 
const char *ssid     = "SSID";
const char *password = "PATHPHRASE";
 
#define BUFFER_SIZE 16384
uint8_t buf[BUFFER_SIZE];
 
ESP32WebServer server ( 80 );
 
File fsUploadFile;                                    // a File variable to temporarily store the received file
 
const char common_name[40] = "esp32curtain";
const char *OTAName = common_name;          // A name and a password for the OTA service
const char *mdnsName = common_name;         // Domain name for the mDNS responder
 
const int motorPin1 = 13;   // Blue  - 28BYJ48 pin 1
const int motorPin2 = 12;   // Pink  - 28BYJ48 pin 2
const int motorPin3 = 14;   // Yellow - 28BYJ48 pin 3
const int motorPin4 = 27;   // Orange - 28BYJ48 pin 4
// Red  - 28BYJ48 pin 5 (VCC)
 
const int LimitSW = 26;
 
const int stepsPerRevolution = 200;
int motorSpeed = 1200; //速度。数値が小さいほど速くなる。800以下は脱調して動かない
//int lookup[8] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001};
int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
Stepper myStepper(stepsPerRevolution, motorPin1, motorPin2, motorPin3, motorPin4);
 
// 時刻編集・設定用
String opentime = "";
String closetime = "";
String open_hour = "";
String open_minute = "";
String close_hour = "";
String close_minute = "";
String set_open_hour = "";
String set_open_minute = "";
String set_close_hour = "";
String set_close_minute = "";
// 自動タイマー既定時刻用
String default_open_hour = "07";
String default_open_minute = "30";
String default_close_hour = "18";
String default_close_minute = "00";
 
boolean readHTML() {
  File htmlFile = SPIFFS.open(path_root, "r");
  if (!htmlFile) {
    Serial.println("Failed to open index.html");
    return false;
  }
  size_t size = htmlFile.size();
  if (size >= BUFFER_SIZE) {
    Serial.print("File Size Error:");
    Serial.println((int)size);
  } else {
    Serial.print("File Size OK:");
    Serial.println((int)size);
  }
  htmlFile.read(buf, size);
  htmlFile.close();
  return true;
}
 
void handleRoot() {
  Serial.println("Access");
 
  String valueString;
  String delimiter = "/";
  int pos1, pos2, pos3;
  int slashpos;
 
  char message[20];
  String(server.arg(0)).toCharArray(message,20);
  server.send(200, "text/html", (char *)buf);
 
  if(server.arg(0).indexOf("OPEN") != -1){
    Serial.println("OPEN");
    curtain_open();
    setOutput(9);
  }
  else if(server.arg(0).indexOf("CLOSE") != -1){
    Serial.println("CLOSE");
    curtain_close();
    setOutput(9);
  }
  else if(server.arg(0).indexOf("SET") != -1){
    Serial.println("SET");
    opentime = "";
    closetime = "";
    int slash1 = server.arg(0).indexOf(delimiter);
    int slash2 = server.arg(0).indexOf(delimiter, slash1 + 1);
    if (slash1 >= 0 && slash2 < 0) {
        opentime = server.arg(0).substring(slash1);
    } else if (slash1 >= 0 && slash2 >= 0) {
        opentime = server.arg(0).substring(slash1 + 1, slash2);
        closetime = server.arg(0).substring(slash2 + 1);
    }
    if (set_timer(opentime, closetime)) {
      Serial.print("タイマー設定済み");
    } else {
      Serial.print("タイマー設定エラー");
    }
  }
 
}
 
boolean set_timer(String time1, String time2) {
   Serial.println("set");
 
  String colon = ":";
  int colonpos1, colonpos2;
  colonpos1 = time1.indexOf(colon);
  colonpos2 = time2.indexOf(colon);
  if (time1 != "") {
    open_hour = time1.substring(0,colonpos1);
    open_minute = time1.substring(colonpos1 + 1);
  }
  if (time2 != "") {
    close_hour = time2.substring(0,colonpos2);
    close_minute = time2.substring(colonpos2 + 1);
  }
 
  set_open_hour = set_open_minute = "";
  if (open_hour != "") {
    set_open_hour = open_hour;
    set_open_minute = open_minute;
  }
 
  set_close_hour = set_close_minute = "";
  if (close_hour != "") {
    set_close_hour = close_hour;
    set_close_minute = close_minute;
  }
 
  return true;
//  server.send(200, "text/html", "set");
}
 
void handleNotFound() {
 
  String message = "File Not Found\n\n";
  message += "URI: ";
  message += server.uri();
  message += "\nMethod: ";
  message += ( server.method() == HTTP_GET ) ? "GET" : "POST";
  message += "\nArguments: ";
  message += server.args();
  message += "\n";
 
  for ( uint8_t i = 0; i < server.args(); i++ ) {
    message += " " + server.argName ( i ) + ": " + server.arg ( i ) + "\n";
  }
  server.send ( 404, "text/plain", message );
}
 
void startWiFi() { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
  //  WiFi.softAP(ssid, password);             // Start the access point
  WiFi.mode(WIFI_STA);             // Start the access point
  WiFi.begin(ssid, password);             // Start the access point
  while ( WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.print("SSID \"");
  Serial.print(ssid);
  Serial.println("\" started\r\n");
 
  Serial.print("Connected to ");
  Serial.println(ssid);
 
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
 
  Serial.print("hostname : ");
  //  Serial.println(WiFi.hostname());
  Serial.println("");
}
 
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);
  }
  Serial.printf("\n");
  if (!readHTML()) {
    Serial.println("Read HTML error!!");
  }
  /*
    Serial.println("SPIFFS started. Contents:");
    {
      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 startMDNS() { // Start the mDNS responder
  MDNS.begin(mdnsName);                        // start the multicast domain name server
  Serial.print("mDNS responder started: http://");
  Serial.print(mdnsName);
  Serial.println(".local");
}
 
void startServer() { // Start a HTTP server with a file read handler and an upload handler
 
  server.on("/", handleRoot);
/*
  server.on("/edit.html",  HTTP_POST, []() {  // If a POST request is sent to the /edit.html address,
    server.send(200, "text/plain", "");
  }, handleFileUpload);                       // go to 'handleFileUpload'
*/
  server.on("/edit.html",  HTTP_POST, []() {  // If a POST request is sent to the /edit.html address,
    server.send(200, "text/plain", "");
  });
  server.onNotFound(handleNotFound);          // if someone requests any other file or page, go to function 'handleNotFound'
  // and check if the file exists
 
  server.begin();                             // start the HTTP server
  Serial.println("HTTP server started.");
}
 
void clockwise()  //時計回り
{
  for (int i = 7; i >= 0; i--)
  {
    setOutput(i);
    delayMicroseconds(motorSpeed);
  }
}
 
void counterclockwise()  //反時計回り
{
  for (int i = 0; i < 8; i++)
  {
    setOutput(i);
    delayMicroseconds(motorSpeed);
  }
}
 
void setOutput(int out)
{
  digitalWrite(motorPin1, bitRead(lookup[out], 0));
  digitalWrite(motorPin2, bitRead(lookup[out], 1));
  digitalWrite(motorPin3, bitRead(lookup[out], 2));
  digitalWrite(motorPin4, bitRead(lookup[out], 3));
}
 
void curtain_open() {
  Serial.println("open");
  while (1) {
    if (!digitalRead(LimitSW)) break;
    clockwise();
    delayMicroseconds(motorSpeed);
    delay(1);
  }
//  server.send(200, "text/html", "open");
}
 
void curtain_close() {
  Serial.println("close");
  while (1) {
    if (!digitalRead(LimitSW)) break;
    counterclockwise();
    delayMicroseconds(motorSpeed);
    delay(1);
  }
//  server.send(200, "text/html", "close");
}
 
void autotimer() {
  // ESP32内蔵RTCを使用・setup()でNTPと併用
  time_t t;
  struct tm *tm;
  static const char *wd[7] = {"Sun", "Mon", "Tue", "Wed", "Thr", "Fri", "Sat"};
  char rdate[30], rday[30], rtime[30], rsec[30];
  char rhour[30], rminute[30];
  int irhour, irminute, irsec;
 
  t = time(NULL);
  tm = localtime(&t);
 
  Serial.printf(" %04d/%02d/%02d(%s) %02d:%02d:%02d\n",
                tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
                wd[tm->tm_wday],
                tm->tm_hour, tm->tm_min, tm->tm_sec);
 
  sprintf(rdate, "%04d/%02d/%02d",
          tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday);
  sprintf(rday, " (%s)",
          wd[tm->tm_wday]);
 
  sprintf(rtime, " %02d:%02d",
          tm->tm_hour, tm->tm_min);
  delay(1000 - millis() % 1000);
 
  sprintf(rhour, "%02d", tm->tm_hour);
  delay(1000 - millis() % 1000);
 
  sprintf(rminute, "%02d", tm->tm_min);
  delay(1000 - millis() % 1000);
 
  sprintf(rsec, "%02d", tm->tm_sec);
  delay(1000 - millis() % 1000);
 
  irsec = atoi(rsec);
 
  if (set_open_hour == "") {
    set_open_hour = default_open_hour;
    set_open_minute = default_open_minute;
  }
  if (set_close_hour == "") {
    set_close_hour = default_close_hour;
    set_close_minute = default_close_minute;
  }
 
    // 既定または、指定時刻に開く
    if (String(rhour).equals(set_open_hour)
      && String(rminute).equals(set_open_minute)
      && irsec <= 3) {
      curtain_open();
      setOutput(9);
    }
    // 既定または、指定時刻に閉める
    if (String(rhour).equals(set_close_hour)
      && String(rminute).equals(set_close_minute)
      &&  irsec <= 3) {
      curtain_close();
      setOutput(9);
    }
}
 
void setup() {
  delay(1000);
  Serial.begin(115200);
  delay(10);
  Serial.println("\r\n");
 
  //declare the motor pins as outputs
  pinMode(motorPin1, OUTPUT);
  pinMode(motorPin2, OUTPUT);
  pinMode(motorPin3, OUTPUT);
  pinMode(motorPin4, OUTPUT);
  pinMode(LimitSW, INPUT_PULLUP);
 
  startWiFi();
  startOTA();
  startSPIFFS();
  startMDNS();
  startServer();
 
  // NTP start
  configTime( 9 * 3600L, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");
}
 
void loop() {
  server.handleClient();
  ArduinoOTA.handle();                        // listen for OTA events
 
  autotimer();
}

 オートタイマーには、ESP32内蔵時計とNTPを併用、デフォルト時間の他、ブラウザ上で時間を設定することもできるようにしました。

 値(設定時間)のためには、SPIFFS領域を使っていないのでESP32のリセットでデフォルトに戻ります。

 何回なのか具体的には忘れましたが、書き換え回数に制限があると思うと、それが仮に1万回だったとしても、なんとなく避けたくなって...それに電源を落としたり、リセットしたりしなれば保持できますし、仮にリセットしたとしても大勢に影響はないかなと。

 後述のHTML+CSS+JavaScriptでクリック・タップしたボタンや、これに応じて取得したデータによって制御(まぁ当然か)。

 [開ける]/[閉める]ボタンについては、それぞれ開・閉するだけ、[クリア]ボタンによるデフォルト値含め、[タイマー設定]で受け取った時間を使って自動開閉タイマーを設定(これも当然か...)。

 綱渡りっぽい工夫を要したのは、開閉時間の条件となる"秒"ですかね。

 delayなどの影響なのか、1秒毎に"秒"をとれなかったための苦肉の策、これをやらないとリミットスイッチを押せば止まるものの、押していないと1分間は、回転し続けてしまうので。

[2021/05/16]

 歯車着脱方法変更に伴い、スケッチの一部の修正を要します。

 サーボの向きにもよると思いますが、setup内のgearServo.write(forward);を削除、また、仲介させていた歯車が1つ減って回転が逆になるのでcurtain_open()内のcounterclockwise()/curtain_close()内のclockwise()を相互入れ替えすればいけます。

 また、先日、備考にも書きましたが、万一、途中でひっかかって、いつまでも開閉が完了しない場合、モーター過熱要因になり得るので開・閉ともに一定時間以上経過した場合、停止するロジックが欲しいですね。(組み込みました)

[2021/05/19]

 ESP32内蔵タッチセンサを使って静電容量式タッチ検出によるタッチパネルを仕様追加することにしたのですが、内蔵なので当該GPIOに配線し、ソフトウェア的にスケッチに追記するだけです。

ESP32開発ボード
ボードによって異なる!?
TouchGPIO
04
10
22
315
413
512
614
727
833
932

 ESP32開発ボードにおいて内蔵のタッチセンサとして割り当てられているGPIOピンは、Touch0〜Touch9と表記され、10個あるのでESP32開発ボードのピンレイアウトを参考に何れかを使います。

 全体像は、他も取り込んでからにする予定なので、今時点では箇条書きに留めます。

 サンプルスケッチTouchInterruptとESP32開発ボードのピンレイアウトを参考にしつつ、

  1. 宣言部にint threshold = 10;を追記
  2. 宣言部にbool touch_stop = false;を追記
  3. 関数void gotTouchStop(){ touch_stop = true }を追記
  4. curtain_open()/curtain_close()関数共にif (!digitalRead(LimitSW)if (!digitalRead(LimitSW) || touch_stop)に変更
  5. curtain_open()/curtain_close()関数共にgearServo.write(base);のあとにtouch_stop = false;を追記
  6. 念の為、setup内にgearServo.write(base);を追記
  7. setup内にtouchAttachInterrupt(T8, gotTouchStop, threshold);を追記
    第1引数T8は、ピンレイアウト上のTouch8、第2引数は検出時に呼び出す関数、第3引数は閾値

 尚、原因不明ながら、5の追記位置によっては、挙動がおかしくなったので注意、その位置なら不要なはずも6もその影響で念の為。

 とりあえず、ESP32開発ボードにピンヘッダがハンダ付け済みなら、当該GPIO(ここでは、T8=TOUCH8=GPIO33)にオスーメスのジャンパワイヤを挿してオスピンに指でつまむなど触れれば、タッチを検出してくれます。

 完成形としては、見栄えの良い金属プレートを見つけ次第、それに配線し、スイッチにする予定です。

 とりあえず、身近な金属プレートで試す中、threshold = 10;としましたが、この値は、環境によって変わるようです。

 自身の場合、GPIO33にジャンパワイヤ1本付ける分には、サンプルスケッチと同じ40でいけましたが、デスク上まで配線を延して金属プレートを付けた場合、40だとダメでタッチが効いた状態になり、if文にタッチの条件を入れているのでカーテンの開閉ができなくなりました。

 調べると、どうやら、閾値の値は、数値が大きいほど感度が敏感になる(スケッチ内のコメント部)とのことのようなので延長した分、鈍感にしてみようと最初に試してみたのが10で他は試していませんが、これでうまく機能した次第です。

 ただし、スケッチをアップロードする必要がある場合には、タッチピン用のジャンパワイヤをESP32ボードから外すか、付けるにしても1本に留めた方が良さげで、延長したままだと謎のエラーでスケッチのアップロードに失敗、なぜか、ジャンパワイヤ1本にしてみたら、いけました、後に延長したままでもいけたので接触不良があったようです。

 ちなみにESP32 Using the touch padsによるとTickerを使えば、タッチしていない状態や時間計測と併せてタッチする時間の違いで複数条件評価できるともあります。

[2021/05/25]

 電動開閉時、一定時間以上経過しても停止しない場合、ひっかかっている等、非常事態と判断、歯車及びモータへの負荷を極力避けるため、緊急停止するロジックできました。

 また、ひいては熱による火災などを避けるためには、正常に機能した際の時間と同じ、もしくはほんの僅かなプラスアルファくらいに留めるのが妥当でしょう。

 多少の負荷の違いもあるでしょうし、念の為、開く時、閉める時の値を別にすべく、それぞれ変数を用意しました。

  1. 宣言部にint unsigned int open_stop_time = 45;/unsigned int close_stop_time = 48;を追記
  2. curtain_open()/curtain_close()関数共、冒頭にclock_t startc, endc;/unsigned int idifftime = 0;/boolean emergencyflg = false;/startc = clock();を追記
  3. curtain_open()/curtain_close()関数共にwhile文冒頭のif文をif (!digitalRead(LimitSW) || touch_stop || emergencyflg)に変更、そのif文内にemergencyflg = false;を追記
  4. if〜else文の後にendc = clock();/idifftime = (endc - startc) / CLOCKS_PER_SEC;endc = clock();if (idifftime >= open_stop_time) { emergencyflg = true ; }を追記

 clock_tについては、http://linuxc.info/time/time2/を参考にさせて頂きました。

 Arduino IDEがC/C++ベースだけにC/C++のソースをそのまま使える(ものもある?)のは便利ですね。

/*
2021/05/25
ESP32版電動スマートカーテン
Wi-Fi操作/既定・可変タイマー付き/電動・手動自動切り替え
歯車x2タイプ:カーテン側歯車にモーター側歯車を着脱/スライダ&クランク
歯車が1つ減った為、curtain_open()/curtain_close()を相互入れ替え
ESP32内蔵静電容量式タッチセンサを使い、電動開閉途中で停止するロジック追加済み
開閉後一定時間経過で緊急停止ロジック追加済み
*/
#include <WiFi.h>
#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <ESP32WebServer.h>
#include <ESPmDNS.h>
#include <SPIFFS.h>
#include <Arduino.h>
#include <FS.h>
#include <Stepper.h>
#include <Servo.h>
 
const char* path_root = "/index.html";
 
const char *ssid = "SSID";
const char *password = "PASSPHRASE";
 
#define BUFFER_SIZE 16384
uint8_t buf[BUFFER_SIZE];
 
ESP32WebServer server ( 80 );
 
File fsUploadFile; // a File variable to temporarily store the received file
 
const char common_name[40] = "esp32_curtain";
const char *OTAName = common_name; // A name and a password for the OTA service
const char *mdnsName = common_name; // Domain name for the mDNS responder
 
const int motorPin1 = 13; // Blue - 28BYJ48 pin 1
const int motorPin2 = 12; // Pink - 28BYJ48 pin 2
const int motorPin3 = 14; // Yellow - 28BYJ48 pin 3
const int motorPin4 = 27; // Orange - 28BYJ48 pin 4
// Red - 28BYJ48 pin 5 (VCC)
 
const int LimitSW = 26;
 
const int stepsPerRevolution = 200;
int motorSpeed = 1200; //速度。数値が小さいほど速くなる。800以下は脱調して動かない
//int lookup[8] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001};
int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
Stepper myStepper(stepsPerRevolution, motorPin1, motorPin2, motorPin3, motorPin4);
 
String opentime = "";
String closetime = "";
String open_hour = "";
String open_minute = "";
String close_hour = "";
String close_minute = "";
String set_open_hour = "";
String set_open_minute = "";
String set_close_hour = "";
String set_close_minute = "";
// 自動タイマー既定時刻(1桁は先頭ゼロ埋め)
String default_open_hour = "7";
String default_open_minute = "30";
String default_close_hour = "18";
String default_close_minute = "0";
 
unsigned int open_stop_time = 45;
unsigned int close_stop_time = 48;
 
Servo gearServo;
int pos = 0;
const int forward = 180; // 正転角度
const int base = 120; // 基準角度
const int backward = 0; // 逆転角度
const int step_speed = 40; // 回転速度
 
int threshold = 10;
bool touch_stop = false;
 
#define JST 3600* 9
 
void gotTouchStop() {
touch_stop = true;
}
 
void clockwise() //時計回り
{
for (int i = 7; i >= 0; i--)
{
setOutput(i);
delayMicroseconds(motorSpeed);
}
}
 
void counterclockwise() //反時計回り
{
for (int i = 0; i < 8; i++)
{
setOutput(i);
delayMicroseconds(motorSpeed);
}
}
 
void setOutput(int out)
{
digitalWrite(motorPin1, bitRead(lookup[out], 0));
digitalWrite(motorPin2, bitRead(lookup[out], 1));
digitalWrite(motorPin3, bitRead(lookup[out], 2));
digitalWrite(motorPin4, bitRead(lookup[out], 3));
}
 
void curtain_open() {
Serial.println("open");
 
clock_t startc, endc;
unsigned int idifftime = 0;
boolean emergencyflg = false;
 
startc = clock();
 
while (1) {
if (!digitalRead(LimitSW) || touch_stop || emergencyflg) {
setOutput(9);
emergencyflg = false;
delay(1000);
gearServo.write(base);
touch_stop = false;
break;
} else {
gearServo.write(forward);
}
endc = clock();
counterclockwise();
delayMicroseconds(motorSpeed);
delay(1);
idifftime = (endc - startc) / CLOCKS_PER_SEC;
if (idifftime >= open_stop_time) {
emergencyflg = true ;
}
}
}
 
void curtain_close() {
Serial.println("close");
 
clock_t startc, endc;
unsigned int idifftime = 0;
boolean emergencyflg = false;
 
startc = clock();
 
while (1) {
if (!digitalRead(LimitSW) || touch_stop || emergencyflg) {
setOutput(9);
emergencyflg = false;
delay(1000);
gearServo.write(base);
touch_stop = false;
break;
} else {
gearServo.write(forward);
}
endc = clock();
clockwise();
delayMicroseconds(motorSpeed);
delay(1);
idifftime = (endc - startc) / CLOCKS_PER_SEC;
if (idifftime >= close_stop_time) {
emergencyflg = true ;
}
}
}
 
void autotimer() {
// ESP32内蔵RTCを使用・setup()でNTPと併用
time_t t;
struct tm *tm;
static const char *wd[7] = {"Sun", "Mon", "Tue", "Wed", "Thr", "Fri", "Sat"};
char rdate[30], rday[30], rtime[30], rsec[30];
char rhour[30], rminute[30];
int irhour, irminute, irsec;
 
t = time(NULL);
tm = localtime(&t);
 
Serial.printf(" %04d/%02d/%02d(%s) %02d:%02d:%02d\n",
tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
wd[tm->tm_wday],
tm->tm_hour, tm->tm_min, tm->tm_sec);
 
sprintf(rdate, "%04d/%02d/%02d",
tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday);
sprintf(rday, " (%s)",
wd[tm->tm_wday]);
 
sprintf(rtime, " %02d:%02d",
tm->tm_hour, tm->tm_min);
delay(1000 - millis() % 1000);
 
sprintf(rhour, "%02d", tm->tm_hour);
delay(1000 - millis() % 1000);
 
// irhour = atoi(rhour);
 
sprintf(rminute, "%02d", tm->tm_min);
delay(1000 - millis() % 1000);
 
// irminute = atoi(rminute);
 
sprintf(rsec, "%02d", tm->tm_sec);
delay(1000 - millis() % 1000);
 
irsec = atoi(rsec);
 
if (set_open_hour == "") {
set_open_hour = default_open_hour;
set_open_minute = default_open_minute;
}
if (set_close_hour == "") {
set_close_hour = default_close_hour;
set_close_minute = default_close_minute;
}
 
// 既定または、指定時刻に開く
if (String(rhour).equals(set_open_hour)
&& String(rminute).equals(set_open_minute)
&& irsec <= 3) {
Serial.println("Auto Open");
curtain_open();
}
// 既定または、指定時刻に閉める
if (String(rhour).equals(set_close_hour)
&& String(rminute).equals(set_close_minute)
&& irsec <= 6) {
Serial.println("Auto Close");
curtain_close();
}
}
 
boolean readHTML() {
File htmlFile = SPIFFS.open(path_root, "r");
if (!htmlFile) {
Serial.println("Failed to open index.html");
return false;
}
size_t size = htmlFile.size();
if (size >= BUFFER_SIZE) {
Serial.print("File Size Error:");
Serial.println((int)size);
} else {
Serial.print("File Size OK:");
Serial.println((int)size);
}
htmlFile.read(buf, size);
htmlFile.close();
return true;
}
 
void handleRoot() {
Serial.println("Access");
 
String valueString;
String delimiter = "/";
int pos1, pos2, pos3;
int slashpos;
 
char message[20];
String(server.arg(0)).toCharArray(message, 20);
server.send(200, "text/html", (char *)buf);
 
if (server.arg(0).indexOf("OPEN") != -1) {
Serial.println("OPEN");
curtain_open();
setOutput(9);
gearServo.write(base);
}
else if (server.arg(0).indexOf("CLOSE") != -1) {
Serial.println("CLOSE");
curtain_close();
setOutput(9);
gearServo.write(base);
}
else if (server.arg(0).indexOf("SET") != -1) {
Serial.println("server.arg(0) *** ");
Serial.println(server.arg(0));
Serial.println();
Serial.println("SET");
opentime = "";
closetime = "";
int slash1 = server.arg(0).indexOf(delimiter);
int slash2 = server.arg(0).indexOf(delimiter, slash1 + 1);
if (slash1 >= 0 && slash2 < 0) {
opentime = server.arg(0).substring(slash1);
} else if (slash1 >= 0 && slash2 >= 0) {
opentime = server.arg(0).substring(slash1 + 1, slash2);
closetime = server.arg(0).substring(slash2 + 1);
}
Serial.print("opentime opentime || closetime ");
Serial.print(opentime);
Serial.print(" || ");
Serial.println(closetime);
Serial.println();
if (set_timer(opentime, closetime)) {
Serial.print("タイマー設定済み");
} else {
Serial.print("タイマー設定エラー");
}
}
}
 
boolean set_timer(String time1, String time2) {
Serial.println("set");
 
String colon = ":";
int colonpos1, colonpos2;
colonpos1 = time1.indexOf(colon);
colonpos2 = time2.indexOf(colon);
if (time1 != "") {
open_hour = time1.substring(0, colonpos1);
open_minute = time1.substring(colonpos1 + 1);
}
if (time2 != "") {
close_hour = time2.substring(0, colonpos2);
close_minute = time2.substring(colonpos2 + 1);
}
 
set_open_hour = set_open_minute = "";
if (open_hour != "") {
set_open_hour = open_hour;
set_open_minute = open_minute;
}
Serial.print("set_open_hour:set_open_minute ");
Serial.print(set_open_hour);
Serial.print(" : ");
Serial.println(set_open_minute);
Serial.println();
 
set_close_hour = set_close_minute = "";
if (close_hour != "") {
set_close_hour = close_hour;
set_close_minute = close_minute;
}
Serial.print("set_close_hour:set_close_minute ");
Serial.print(set_close_hour);
Serial.print(" : ");
Serial.println(set_close_minute);
Serial.println();
 
return true;
}
 
void handleNotFound() {
 
String message = "File Not Found\n\n";
message += "URI: ";
message += server.uri();
message += "\nMethod: ";
message += ( server.method() == HTTP_GET ) ? "GET" : "POST";
message += "\nArguments: ";
message += server.args();
message += "\n";
 
for ( uint8_t i = 0; i < server.args(); i++ ) {
message += " " + server.argName ( i ) + ": " + server.arg ( i ) + "\n";
}
server.send ( 404, "text/plain", message );
}
 
void startWiFi() { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
// WiFi.softAP(ssid, password); // Start the access point
WiFi.mode(WIFI_STA); // Start the access point
WiFi.begin(ssid, password); // Start the access point
while ( WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.print("SSID \"");
Serial.print(ssid);
Serial.println("\" started\r\n");
 
Serial.print("Connected to ");
Serial.println(ssid);
 
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
 
Serial.print("hostname : ");
// Serial.println(WiFi.hostname());
Serial.println("");
}
 
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) {
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);
}
Serial.printf("\n");
if (!readHTML()) {
Serial.println("Read HTML error!!");
}
}
 
void startMDNS() { // Start the mDNS responder
MDNS.begin(mdnsName); // start the multicast domain name server
Serial.print("mDNS responder started: http://");
Serial.print(mdnsName);
Serial.println(".local");
}
 
void startServer() { // Start a HTTP server with a file read handler and an upload handler
 
server.on("/", handleRoot);
server.on("/edit.html", HTTP_POST, []() { // If a POST request is sent to the /edit.html address,
server.send(200, "text/plain", "");
});
server.onNotFound(handleNotFound); // if someone requests any other file or page, go to function 'handleNotFound'
// and check if the file exists
 
server.begin(); // start the HTTP server
Serial.println("HTTP server started.");
}
 
void setup() {
delay(1000);
Serial.begin(115200);
delay(10);
Serial.println("\r\n");
 
//declare the motor pins as outputs
pinMode(motorPin1, OUTPUT);
pinMode(motorPin2, OUTPUT);
pinMode(motorPin3, OUTPUT);
pinMode(motorPin4, OUTPUT);
pinMode(LimitSW, INPUT_PULLUP);
gearServo.attach(25);
// gearServo.write(forward);
gearServo.write(base);
touchAttachInterrupt(T8, gotTouchStop, threshold);
 
startWiFi();
startOTA();
startSPIFFS();
startMDNS();
startServer();
 
// NTP start
configTime( 9 * 3600L, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");
}
 
void loop() {
server.handleClient();
ArduinoOTA.handle(); // listen for OTA events
// MDNS.update();
autotimer();
}

 というわけで電動開閉中に手動操作したくなった場合などに使える金属プレート停止用操作パネルと共にソース全体はこんな感じになりました。

 先頭のタブとなるスペースは端折りました。

 autotimer()のカーテンを閉じる時の時間比較における追加評価の秒を3から6に変更しました(デフォルトのタイマー設定で、なぜか閉じる時だけ、空振ることがあったため)。

 ちなみにオートタイマーと緊急停止ロジックで共有できればと思い、autotimer()から分離してポインタ渡しで時間取得関数を作ってみました。

 が、前者では機能したものの、後者では時間がかかり過ぎるようでステッピングモータが回らかったので、今回の方法に落ち着き、兼用できなければ意味もないので元に戻しました。

...
const int restartLED = 17;
...
setup() {
...
  pinMode(restartLED, OUTPUT);
...
}
...
void handleroot() {
...
  else if(server.arg(0).indexOf("RESTART") != -1) {
    Serial.println("RESTART");
   setOutput(9);
    delay(1000);
    digitalWrite(restartLED, HIGH);
    delay(1000);
    digitalWrite(restartLED, LOW);
    delay(2000);
    ESP.restart();
  }
...
}
[2021/08/13]

 なんらかの入力エラーのようで操作後、たまにステッピングモータドライバULN2003の入力信号LEDがランダムに消灯、ESP32ボードをハードウェアリセットしないと機能しない現象に遭遇していたので、重い腰を上げて超簡単なESP32のソフトウェアリセットESP.restart()を入れることにしました。

 Arduino IDEにおいては、ESP32には、ESP.restart()が、ESP8266には、ESP.restartとESP.reset()が、esp-idfでは、esp_restart()があるようです。

 本番では要りませんが、一応、このロジック通ったよ確認用LEDも残しておくとこんな感じ。

 操作パネルからリセットできるようにHTMLファイルにRESTARTリセットボタンを追加。

[2021/09/28]

 ぬぬ...ソフトウェアリセットじゃダメでした...。

 あ、digitalWriteすればよいのか...。

 エラーが起きてからだとESP32にアクセスできないから、パネル開いている時、限定になっちゃうけど、いっか...。

 ありゃ、それ以前にエラー時にピンにアクセスできないっぽく?これもダメか...。

[2022/06/18]

 あ...モータは別電源...RESTART(リセット)時にsetOutput(9);を入れ忘れてたからLEDだけON/OFFしてモータドライバのLED消えなかったんだ...入れたら、OKでした。

 よく考えると何れのボタン押下もPOSTによるページアクセスなので仮にモータドライバのLEDが消えていなかったとしても開閉ボタンは機能する...ので...リセット(RESTART)機能が必要か否かは微妙...

 とは言え、以後、しばらくカーテン操作が不要な状況ならLEDをOFFにすることで無駄な電力消費を抑止できますね。

 というか、formタグ入れ忘れるというポカも...これじゃPOST効かずデータ送信できないし...。

// ESP32版電動カーテン
 
// 2-2相励磁
// このサンプルコードは28BYJ-48の時計回り・逆回りを行うデモです。
// ULN2003 インターフェースボードを使用。つまりただのトランジスタアレイで駆動するだけ
// 28BYJ-48モーターは4相、8ビートモーター。
// 減速ギア非68倍。 1バイポーラ巻線。ステップ角は5.625/64。
// 動作周波数100PPS。電流92mA。
// URL変わった模様。
// http://hp.hana-neko.com/archives/693
// => https://hp.hana-neko.com/archives/693
// => コンテンツなくなっている...というか、なんか、ドメインが乗っ取られている模様。
/*
  モータ停止状態でもコイルには電流が流れ続けます。
  なので結構な発熱があります。
  停止中に電流切るには
 
  int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
  などとやって、停止中は9番目の要素を意図的に呼び出すと吉。
*/
////////////////////////////////////////////////
//Base Code
// 4tronix Arduino http://www.4tronix.co.uk/arduino/Stepper-Motors.php
// webzoit pendant_light_switch_servo_by_websocket.ino
// webzoit roll_screen_by_websocket.ino
 
#include <WiFi.h>
//#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <ESP32WebServer.h>
#include <ESPmDNS.h>
#include <SPIFFS.h>
#include <Arduino.h>
#include <FS.h>
#include <Stepper.h>
#include <ESP32Servo.h>
#include "time.h"
 
ESP32WebServer server ( 80 );
 
File fsUploadFile;                                    // a File variable to temporarily store the received file
 
const char *ssid     = "SSID";
const char *password = "PASSPHRASE";
 
const char* path_root   = "/index.html";
 
//#define BUFFER_SIZE 16384
#define BUFFER_SIZE 32768
uint8_t buf[BUFFER_SIZE];
 
const char common_name[40] = "esp_curtain";
const char *OTAName = common_name;          // A name and a password for the OTA service
const char *mdnsName = common_name;         // Domain name for the mDNS responder
 
#define INTERVAL_VS_BASE 1000
 
const int motorPin1 = 13;   // Blue  - 28BYJ48 pin 1
const int motorPin2 = 12;   // Pink  - 28BYJ48 pin 2
const int motorPin3 = 14;   // Yellow - 28BYJ48 pin 3
const int motorPin4 = 27;   // Orange - 28BYJ48 pin 4
// Red  - 28BYJ48 pin 5 (VCC)
 
//const int LimitSW = 26;
//const int LimitSW = 23;
const int LimitSW = 32;
 
const int stepsPerRevolution = 200;
int motorSpeed = 1200; //速度。数値が小さいほど速くなる。800以下は脱調して動かない
//int lookup[8] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001};
int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
Stepper myStepper(stepsPerRevolution, motorPin1, motorPin2, motorPin3, motorPin4);
 
 
String opentime = "";
String closetime = "";
String open_hour = "";
String open_minute = "";
String close_hour = "";
String close_minute = "";
String set_open_hour = "";
String set_open_minute = "";
String set_close_hour = "";
String set_close_minute = "";
// 自動タイマー既定時刻(1桁は先頭ゼロ埋め)
String default_open_hour = "07";
String default_open_minute = "30";
String default_close_hour = "18";
String default_close_minute = "00";
// 自動タイマー作動中フラグ
// delayの影響か、秒が飛び評価できず、
// 分で評価するとリミットスイッチを押しても
// 1分間は復帰してしまうため苦肉の策。
//boolean onflg = false;
 
unsigned int open_stop_time = 48;
unsigned int close_stop_time = 48;
 
Servo gearServo;
int pos = 0;
const int forward = 180;              // 正転角度
const int base = 120;          // 基準角度
const int backward = 0;      // 逆転角度
const int step_speed = 40;     // 回転速度
 
int threshold = 10;
bool touch_stop = false;
 
#define JST     3600*9
 
void gotTouchStop() {
  touch_stop = true;
}
 
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";
}
 
boolean readHTML() {
  File htmlFile = SPIFFS.open(path_root, "r");
  if (!htmlFile) {
    Serial.println("Failed to open index.html");
    return false;
  }
  size_t size = htmlFile.size();
  if (size >= BUFFER_SIZE) {
    Serial.print("File Size Error:");
    Serial.println((int)size);
  } else {
    Serial.print("File Size OK:");
    Serial.println((int)size);
  }
  htmlFile.read(buf, size);
  htmlFile.close();
  return true;
}
 
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
    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;
}
 
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");
  }
}
 
void handleRoot() {
  Serial.println("Access");
 
  server.send(200, "text/html", (char *)buf);
 
  char message[20];
  String(server.arg(0)).toCharArray(message, 20);
 
  if (server.arg(0).indexOf("OPEN") != -1) {
    Serial.println("OPEN");
    OPEN();
  }
  else if (server.arg(0).indexOf("CLOSE") != -1) {
    Serial.println("CLOSE");
    CLOSE();
  }
  else if (server.arg(0).indexOf("SET") != -1) {
    Serial.println("SET");
    SET();
  }
  else if (server.arg(0).indexOf("RESTART") != -1) {
    Serial.println("RESTART");
    RESTART();
  }
}
 
/*__________________________________________________________Motor_FUNCTIONS__________________________________________________________*/
 
void setOutput(int out)
{
  digitalWrite(motorPin1, bitRead(lookup[out], 0));
  digitalWrite(motorPin2, bitRead(lookup[out], 1));
  digitalWrite(motorPin3, bitRead(lookup[out], 2));
  digitalWrite(motorPin4, bitRead(lookup[out], 3));
}
 
void counterclockwise()  //反時計回り
{
  for (int i = 0; i < 8; i++)
  {
    setOutput(i);
    delayMicroseconds(motorSpeed);
  }
}
 
void clockwise()  //時計回り
{
  for (int i = 7; i >= 0; i--)
  {
    setOutput(i);
    delayMicroseconds(motorSpeed);
  }
}
 
void curtain_open() {
  Serial.println("open");
 
  clock_t startc, endc;
  unsigned int idifftime = 0;
  boolean emergencyflg = false;
 
  startc = clock();
 
  while (1) {
    if (!digitalRead(LimitSW) || touch_stop || emergencyflg) {
      setOutput(9);
      delay(500);
      gearServo.write(base);
      //      delay(1000);
      emergencyflg = false;
      touch_stop = false;
      break;
    } else {
      gearServo.write(forward);
    }
    endc = clock();
    counterclockwise();
    delayMicroseconds(motorSpeed);
    //    delay(1);
    delay(2);
    idifftime = (endc - startc) / CLOCKS_PER_SEC;
    if (idifftime >= open_stop_time) {
      emergencyflg = true ;
    }
  }
}
 
void curtain_close() {
  Serial.println("close");
 
  clock_t startc, endc;
  unsigned int idifftime = 0;
  //unsigned int a[7];
  boolean emergencyflg = false;
 
  startc = clock();
 
  while (1) {
    //    if (!digitalRead(LimitSW)) {
    //    if (!digitalRead(LimitSW) || touch_stop) {
    if (!digitalRead(LimitSW) || touch_stop || emergencyflg) {
      setOutput(9);
      emergencyflg = false;
      delay(1000);
      gearServo.write(base);
      touch_stop = false;
      break;
    } else {
      gearServo.write(forward);
    }
    endc = clock();
    clockwise();
    delayMicroseconds(motorSpeed);
    delay(1);
    idifftime = (endc - startc) / CLOCKS_PER_SEC;
    if (idifftime >= close_stop_time) {
      emergencyflg = true ;
    }
  }
}
 
boolean set_timer(String time1, String time2) {
  Serial.println("set");
 
  String colon = ":";
  int colonpos1, colonpos2;
  colonpos1 = time1.indexOf(colon);
  colonpos2 = time2.indexOf(colon);
  if (time1 != "") {
    open_hour = time1.substring(0, colonpos1);
    open_minute = time1.substring(colonpos1 + 1);
  }
  if (time2 != "") {
    close_hour = time2.substring(0, colonpos2);
    close_minute = time2.substring(colonpos2 + 1);
  }
 
  set_open_hour = set_open_minute = "";
  if (open_hour != "") {
    set_open_hour = open_hour;
    set_open_minute = open_minute;
  }
  Serial.print("set_open_hour:set_open_minute ");
  Serial.print(set_open_hour);
  Serial.print(" : ");
  Serial.println(set_open_minute);
  Serial.println();
 
  set_close_hour = set_close_minute = "";
  if (close_hour != "") {
    set_close_hour = close_hour;
    set_close_minute = close_minute;
  }
  Serial.print("set_close_hour:set_close_minute ");
  Serial.print(set_close_hour);
  Serial.print(" : ");
  Serial.println(set_close_minute);
  Serial.println();
 
  return true;
  //  server.send(200, "text/html", "set");
}
 
void autotimer() {
  // ESP32内蔵RTCを使用・setup()でNTPと併用
  time_t t;
  struct tm *tm;
  static const char *wd[7] = {"Sun", "Mon", "Tue", "Wed", "Thr", "Fri", "Sat"};
  char rdate[30], rday[30], rtime[30], rsec[30];
  char rhour[30], rminute[30];
  //int irhour, irminute, irsec;
  int irsec;
 
  t = time(NULL);
  tm = localtime(&t);
 
  Serial.printf(" %04d/%02d/%02d(%s) %02d:%02d:%02d\n",
                tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
                wd[tm->tm_wday],
                tm->tm_hour, tm->tm_min, tm->tm_sec);
 
  sprintf(rdate, "%04d/%02d/%02d",
          tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday);
  sprintf(rday, " (%s)",
          wd[tm->tm_wday]);
 
  sprintf(rtime, " %02d:%02d",
          tm->tm_hour, tm->tm_min);
  delay(1000 - millis() % 1000);
 
  sprintf(rhour, "%02d", tm->tm_hour);
  delay(1000 - millis() % 1000);
 
  //  irhour = atoi(rhour);
 
  sprintf(rminute, "%02d", tm->tm_min);
  delay(1000 - millis() % 1000);
 
  //  irminute = atoi(rminute);
 
  sprintf(rsec, "%02d", tm->tm_sec);
  delay(1000 - millis() % 1000);
 
  irsec = atoi(rsec);
 
  if (set_open_hour == "") {
    set_open_hour = default_open_hour;
    set_open_minute = default_open_minute;
  }
  if (set_close_hour == "") {
    set_close_hour = default_close_hour;
    set_close_minute = default_close_minute;
  }
 
  Serial.println("set_open_hour:set_open_minute / set_close_hour:set_close_minute");
  Serial.print(set_open_hour);
  Serial.print(":");
  Serial.print(set_open_minute);
  Serial.print(" / ");
  Serial.print(set_close_hour);
  Serial.print(":");
  Serial.println(set_close_minute);
  Serial.println();
 
  Serial.println("String(rhour).equals(set_open_hour) / String(rminute).equals(set_open_minute)");
  Serial.print(String(rhour).equals(set_open_hour));
  Serial.print(" / ");
  Serial.println(String(rminute).equals(set_open_minute));
  Serial.println();
 
  Serial.println("String(rhour).equals(set_close_hour) / String(rminute).equals(set_close_minute)");
  Serial.print(String(rhour).equals(set_close_hour));
  Serial.print(" / ");
  Serial.println(String(rminute).equals(set_close_minute));
  Serial.println();
 
  Serial.println("String(rhour):String(rminute) / set_open_hour:set_open_minute");
  Serial.print(String(rhour));
  Serial.print(":");
  Serial.print(String(rminute));
  Serial.print(" / ");
  Serial.print(set_open_hour);
  Serial.print(":");
  Serial.println(set_open_minute);
  Serial.println();
 
  Serial.println("String(rhour):String(rminute) / set_close_hour:set_close_minute");
  Serial.print(String(rhour));
  Serial.print(":");
  Serial.print(String(rminute));
  Serial.print(" / ");
  Serial.print(set_close_hour);
  Serial.print(":");
  Serial.println(set_close_minute);
  Serial.println();
 
  //  if (!onflg) {
  //Serial.println("onflg OFF");
  // 既定または、指定時刻に開く
  if (String(rhour).equals(set_open_hour)
      && String(rminute).equals(set_open_minute)
      && irsec <= 3) {
    //Serial.println("Auto Open");
    //      onflg  = true;
    curtain_open();
  }
  // 既定または、指定時刻に閉める
  if (String(rhour).equals(set_close_hour)
      && String(rminute).equals(set_close_minute)
      &&  irsec <= 6) {
    Serial.println("Auto Close");
    //      onflg  = true;
    curtain_close();
  }
  // }
  /*
    if (String(rminute).equals(set_open_minute) == false ||
        String(rminute).equals(set_close_minute) == false) {
      onflg = false;
    }
    if (String(rminute).equals(set_close_hour) == false ||
        String(rminute).equals(set_close_hour) == false) {
      onflg = false;
    }
    Serial.println("Auto Timer END");
  */
}
 
void OPEN() {
  Serial.println("OPEN");
  curtain_open();
  setOutput(9);
  gearServo.write(base);
  delay(2000);
  server.send(200, "text/html", "OPEN");
}
void CLOSE() {
  Serial.println("CLOSE");
  curtain_close();
  setOutput(9);
  gearServo.write(base);
  delay(2000);
  server.send(200, "text/html", "CLOSE");
}
 
void SET() {
  Serial.println("SET");
 
  String valueString;
  String delimiter = "/";
  int pos1, pos2, pos3;
  int slashpos;
  char message[20];
  String(server.arg(0)).toCharArray(message, 20);
  server.send(200, "text/html", (char *)buf);
 
  if (server.arg(0).indexOf("SET") != -1) {
    Serial.println("server.arg(0) *** ");
    Serial.println(server.arg(0));
    Serial.println();
    Serial.println("SET");
    opentime = "";
    closetime = "";
    int slash1 = server.arg(0).indexOf(delimiter);
    int slash2 = server.arg(0).indexOf(delimiter, slash1 + 1);
    if (slash1 >= 0 && slash2 < 0) {
      opentime = server.arg(0).substring(slash1);
    } else if (slash1 >= 0 && slash2 >= 0) {
      opentime = server.arg(0).substring(slash1 + 1, slash2);
      closetime = server.arg(0).substring(slash2 + 1);
    }
    Serial.print("opentime opentime || closetime ");
    Serial.print(opentime);
    Serial.print(" || ");
    Serial.println(closetime);
    Serial.println();
    if (set_timer(opentime, closetime)) {
      Serial.print("タイマー設定済み");
    } else {
      Serial.print("タイマー設定エラー");
    }
  }
}
 
void RESTART() {
  Serial.println("RESTART");
 
  Serial.println("");
  delay(1000);
  setOutput(9);
      digitalWrite(restartLED, HIGH);
  delay(1000);
      digitalWrite(restartLED, LOW);
  delay(2000);
  ESP.restart();
}
 
/*__________________________________________________________SETUP_FUNCTIONS__________________________________________________________*/
 
void startWiFi() { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
  //  WiFi.softAP(ssid, password);             // Start the access point
  WiFi.mode(WIFI_STA);             // Start the access point
  WiFi.begin(ssid, password);             // Start the access point
  while ( WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.print("SSID \"");
  Serial.print(ssid);
  Serial.println("\" started\r\n");
 
  Serial.print("Connected to ");
  Serial.println(ssid);
 
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
 
  Serial.print("hostname : ");
  //  Serial.println(WiFi.hostname());
  Serial.println("");
}
 
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");
}
 
void startSPIFFS() { // Start the SPIFFS and list all contents
  SPIFFS.begin();                             // Start the SPI Flash File System (SPIFFS)
 
  //server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
  /*
    if(!SPIFFS.begin()) {
      Serial.println("Mount SPIFFS failed, exiting...");
      return;
    }
    Serial.println("Mount SPIFFS successfully.");
  */
 
  /*
    Serial.println("SPIFFS started. Contents:");
    {
      listDir(SPIFFS, "/", 0);
    }
    Serial.printf("\n");
  */
 
  if (!readHTML()) {
    Serial.println("Read HTML error!!");
  }
  /*
    Serial.println("SPIFFS started. Contents:");
    {
      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 startMDNS() { // Start the mDNS responder
  MDNS.begin(mdnsName);                        // start the multicast domain name server
  Serial.print("mDNS responder started: http://");
  Serial.print(mdnsName);
  Serial.println(".local");
 
  // Add service to MDNS-SD
  MDNS.addService("http", "tcp", 80);
}
 
void startServer() { // Start a HTTP server with a file read handler and an upload handler
 
  server.on("/", handleRoot);
  server.on("/Open", OPEN);
  server.on("/Close", CLOSE);
  server.on("/Setting", SET);
  server.on("/Restart", RESTART);
  /*
    server.on("/edit.html",  HTTP_POST, []() {  // If a POST request is sent to the /edit.html address,
      server.send(200, "text/plain", "");
    }, handleFileUpload);                       // go to 'handleFileUpload'
  */
  server.on("/edit.html",  HTTP_POST, []() {  // If a POST request is sent to the /edit.html address,
    server.send(200, "text/plain", "");
  });
  server.onNotFound(handleNotFound);          // if someone requests any other file or page, go to function 'handleNotFound'
  // and check if the file exists
 
  server.begin();                             // start the HTTP server
  Serial.println("HTTP server started.");
}
 
void setup() {
  delay(1000);
  Serial.begin(115200);
  delay(10);
  Serial.println("\r\n");
 
  //declare the motor pins as outputs
  pinMode(motorPin1, OUTPUT);
  pinMode(motorPin2, OUTPUT);
  pinMode(motorPin3, OUTPUT);
  pinMode(motorPin4, OUTPUT);
  pinMode(LimitSW, INPUT_PULLUP);
 
  gearServo.attach(25);
  gearServo.write(base);
  touchAttachInterrupt(T8, gotTouchStop, threshold);
 
  startWiFi();
  startOTA();
  startSPIFFS();
  startServer();
  startMDNS();
 
  // NTP start
  configTime( JST, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");
}
 
void loop() {
  server.handleClient();                      // run the server
  ArduinoOTA.handle();                        // listen for OTA events
 
  autotimer();
}

 あと、ここでは、handleRoot()内に全て書いてしまっていましたが、各ボタン押下時の動作については、こんな風に関数として切り出した方がスマートですね。

 また、一部参照リンク先が怪しいことになってるとか、WiFiClient.h使ってなかったとか、ボタン押下時の関数を個々に作り、それに伴いstartServer()関数内にserver.on("/Open", OPEN);など追記が必要...等々諸々。

 というわけで、修正済みHTMLファイルと併せてスケッチを書き直してみると、こんな感じですかね。

[2022/07/29]

 タイミング的には、重い腰を上げ、素直にmDNSを使えないAndroid対策として全てのESP8266/ESP32を使った自作スマート家電のIPを固定した直後のこと、カーテンの自動開閉時間が1時間以上ズレ、早く開閉する現象に見舞われました。

 ざっとスケッチを見直したところ、configTime()関数でNTP時間取得しようとしながら、#include "time.h"入れ忘れていたことに気づきました。

 これに関しては、自身がアップしたスケッチでも忘れていたのですが、なぜ、エラーにならなかったのでしょう...?

 一昨日くらいから、急に朝晩共にデフォルトの設定時間よりも1時間30分〜1時間45分程度、早く自動開閉したことで気づいた次第。

 それにより、今日含め、あれこれ何度かアップロードし直した際もこれに起因したエラーはなく、configTime()無視された?他のヘッダにも同名の関数が含まれる?

 というか、なぜ、一昨日から?なぜ、それまで朝晩、正確な時間に自動開閉できていたんでしょう...?

 となると他のヘッダにもconfigTime()関数があって、もしくは、他のヘッダ経由でtime.hにアクセスしてて、ここにきて、それらでは効かなくなった...!?

 それにしてもESP32温湿度計付き時計正確に動いてるけどtime.h...と思ったら、ちゃんと入ってました。

 time.hの欠如に気づく前に、何も編集せずに元のスケッチをアップロードし直してみようと思ったら、使ってない変数があるよエラーが数箇所...、警告ではなくエラーなのでコメントアウトなどする必要がありました。

 その内、handleFileRead(String path){}内のsize_t sent = server.streamFile(file, contentType);のsentについては、コメントアウトするとCSSが効かないけどと思ったら、[size_t sent =]をとるだけにすれば、CSSも効きました、って当たり前...、クライアントに返すとこ省いちゃダメですよね。

 ってなんで?

 今までスルーされてたけど、Arduino IDE、エラーチェックの精度も日に日に向上しているってことですね。

 これらは、前回の2022/06/22のスケッチに反映させました。

[2022/07/30]

 あれ?今度は、自動開閉が、1時間〜1時間40分ほど遅れました...夏時間じゃないですよね?ゼロにしてあるし...なぜ!?

 いや、開ける時間に閉まり、閉める時間に開いた...なんだこれ...。

 スケッチのどっかで誤差生んでる!?いや、でも今までいけてたしな...大目に見て予期に計らうのやめた仕様変更?

[2022/07/31]

 素直にデバグしてみたら、localtimeが取れず、1970年1月1日となっていました。

 原因は、IPを固定したことで、なんらかの事情によりタイミング的にNTP時間を取得できないか、上書きしてしまっているようです。

 今まで通り、DHCP任せにすれば、localtimeで正常にNTP時間を取れること、サンプルスケッチSimpleTimeの作法でも同様になることを確認済み。

 あ、ESP32でDHCPで固定アドレスを割り当てるのと、ntpサーバーからの時刻の取得ではまってます。を眺めてて、固定IPだから不要だと思ってたDNSサーバですが、if (!WiFi.config(local_IP, gateway, subnet, primaryDNS)) {}のようにprimaryDNSを入れたら固定IPでもNTP時間取得できました。

 WiFi.configやconfigTimeの位置は関係ないというか、configTimeについては、setup()の最後というか、WiFi開始後の方がタイムラグなく即、現在日時を取得できるので良いでしょう。

 というわけで原因は、IPを固定するにあたり、WiFi.config()でDNSサーバを指定していなかったことでした(それにより、ntpサーバにアクセスできず、NTP日時を取得できなかった)。

 数日前、ESP8266/ESP32自作スマート家電に軒並みIP固定ロジックを定型文かのように入れ込み、水平展開させたことによって、後で反映した唯一、日時を使っていたスマートカーテンにだけ影響が出たというオチでした。

 あれ?となるとtime.hなくてもNTP時間とれてたってこと?ん?違うか?ESP32の内蔵時計でそれなりに動いてたってこと?

// ESP32版電動カーテン
 
// 2-2相励磁
// このサンプルコードは28BYJ-48の時計回り・逆回りを行うデモです。
// ULN2003 インターフェースボードを使用。つまりただのトランジスタアレイで駆動するだけ
// 28BYJ-48モーターは4相、8ビートモーター。
// 減速ギア非68倍。 1バイポーラ巻線。ステップ角は5.625/64。
// 動作周波数100PPS。電流92mA。
// URL変わった模様。
// http://hp.hana-neko.com/archives/693
// => https://hp.hana-neko.com/archives/693
// => コンテンツなくなっている...というか、なんか、ドメインが乗っ取られている模様。
/*
  モータ停止状態でもコイルには電流が流れ続けます。
  なので結構な発熱があります。
  停止中に電流切るには
 
  int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
  などとやって、停止中は9番目の要素を意図的に呼び出すと吉。
*/
////////////////////////////////////////////////
//Base Code
// 4tronix Arduino http://www.4tronix.co.uk/arduino/Stepper-Motors.php
// webzoit pendant_light_switch_servo_by_websocket.ino
// webzoit roll_screen_by_websocket.ino
 
#include <WiFi.h>
//#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <ESP32WebServer.h>
#include <ESPmDNS.h>
#include <SPIFFS.h>
#include <Arduino.h>
#include <FS.h>
#include <Stepper.h>
#include <ESP32Servo.h>
#include "time.h"
 
ESP32WebServer server ( 80 );
 
File fsUploadFile;                                    // a File variable to temporarily store the received file
 
const char *ssid     = "SSID";
const char *password = "PASSPHRASE";
 
const char* path_root   = "/index.html";
 
//#define BUFFER_SIZE 16384
#define BUFFER_SIZE 32768
uint8_t buf[BUFFER_SIZE];
 
const char common_name[40] = "esp_curtain";
const char *OTAName = common_name;          // A name and a password for the OTA service
const char *mdnsName = common_name;         // Domain name for the mDNS responder
 
#define INTERVAL_VS_BASE 1000
 
const int motorPin1 = 13;   // Blue  - 28BYJ48 pin 1
const int motorPin2 = 12;   // Pink  - 28BYJ48 pin 2
const int motorPin3 = 14;   // Yellow - 28BYJ48 pin 3
const int motorPin4 = 27;   // Orange - 28BYJ48 pin 4
// Red  - 28BYJ48 pin 5 (VCC)
 
//const int LimitSW = 26;
//const int LimitSW = 23;
const int LimitSW = 32;
 
const int stepsPerRevolution = 200;
int motorSpeed = 1200; //速度。数値が小さいほど速くなる。800以下は脱調して動かない
//int lookup[8] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001};
int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
Stepper myStepper(stepsPerRevolution, motorPin1, motorPin2, motorPin3, motorPin4);
 
 
String opentime = "";
String closetime = "";
String open_hour = "";
String open_minute = "";
String close_hour = "";
String close_minute = "";
String set_open_hour = "";
String set_open_minute = "";
String set_close_hour = "";
String set_close_minute = "";
// 自動タイマー既定時刻(1桁は先頭ゼロ埋め)
String default_open_hour = "07";
String default_open_minute = "30";
String default_close_hour = "18";
String default_close_minute = "00";
// 自動タイマー作動中フラグ
// delayの影響か、秒が飛び評価できず、
// 分で評価するとリミットスイッチを押しても
// 1分間は復帰してしまうため苦肉の策。
//boolean onflg = false;
 
unsigned int open_stop_time = 48;
unsigned int close_stop_time = 48;
 
Servo gearServo;
int pos = 0;
const int forward = 180;              // 正転角度
const int base = 120;          // 基準角度
const int backward = 0;      // 逆転角度
const int step_speed = 40;     // 回転速度
 
int threshold = 10;
bool touch_stop = false;
 
#define JST     3600*9
 
void gotTouchStop() {
  touch_stop = true;
}
 
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";
}
 
boolean readHTML() {
  File htmlFile = SPIFFS.open(path_root, "r");
  if (!htmlFile) {
    Serial.println("Failed to open index.html");
    return false;
  }
  size_t size = htmlFile.size();
  if (size >= BUFFER_SIZE) {
    Serial.print("File Size Error:");
    Serial.println((int)size);
  } else {
    Serial.print("File Size OK:");
    Serial.println((int)size);
  }
  htmlFile.read(buf, size);
  htmlFile.close();
  return true;
}
 
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
    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;
}
 
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");
  }
}
 
void handleRoot() {
  Serial.println("Access");
 
  server.send(200, "text/html", (char *)buf);
 
  char message[20];
  String(server.arg(0)).toCharArray(message, 20);
 
  if (server.arg(0).indexOf("OPEN") != -1) {
    Serial.println("OPEN");
    OPEN();
  }
  else if (server.arg(0).indexOf("CLOSE") != -1) {
    Serial.println("CLOSE");
    CLOSE();
  }
  else if (server.arg(0).indexOf("SET") != -1) {
    Serial.println("SET");
    SET();
  }
  else if (server.arg(0).indexOf("RESTART") != -1) {
    Serial.println("RESTART");
    RESTART();
  }
}
 
/*__________________________________________________________Motor_FUNCTIONS__________________________________________________________*/
 
void setOutput(int out)
{
  digitalWrite(motorPin1, bitRead(lookup[out], 0));
  digitalWrite(motorPin2, bitRead(lookup[out], 1));
  digitalWrite(motorPin3, bitRead(lookup[out], 2));
  digitalWrite(motorPin4, bitRead(lookup[out], 3));
}
 
void counterclockwise()  //反時計回り
{
  for (int i = 0; i < 8; i++)
  {
    setOutput(i);
    delayMicroseconds(motorSpeed);
  }
}
 
void clockwise()  //時計回り
{
  for (int i = 7; i >= 0; i--)
  {
    setOutput(i);
    delayMicroseconds(motorSpeed);
  }
}
 
void curtain_open() {
  Serial.println("open");
 
  clock_t startc, endc;
  unsigned int idifftime = 0;
  boolean emergencyflg = false;
 
  startc = clock();
 
  while (1) {
    if (!digitalRead(LimitSW) || touch_stop || emergencyflg) {
      setOutput(9);
      delay(500);
      gearServo.write(base);
      //      delay(1000);
      emergencyflg = false;
      touch_stop = false;
      break;
    } else {
      gearServo.write(forward);
    }
    endc = clock();
    counterclockwise();
    delayMicroseconds(motorSpeed);
    //    delay(1);
    delay(2);
    idifftime = (endc - startc) / CLOCKS_PER_SEC;
    if (idifftime >= open_stop_time) {
      emergencyflg = true ;
    }
  }
}
 
void curtain_close() {
  Serial.println("close");
 
  clock_t startc, endc;
  unsigned int idifftime = 0;
  //unsigned int a[7];
  boolean emergencyflg = false;
 
  startc = clock();
 
  while (1) {
    //    if (!digitalRead(LimitSW)) {
    //    if (!digitalRead(LimitSW) || touch_stop) {
    if (!digitalRead(LimitSW) || touch_stop || emergencyflg) {
      setOutput(9);
      emergencyflg = false;
      delay(1000);
      gearServo.write(base);
      touch_stop = false;
      break;
    } else {
      gearServo.write(forward);
    }
    endc = clock();
    clockwise();
    delayMicroseconds(motorSpeed);
    delay(1);
    idifftime = (endc - startc) / CLOCKS_PER_SEC;
    if (idifftime >= close_stop_time) {
      emergencyflg = true ;
    }
  }
}
 
boolean set_timer(String time1, String time2) {
  Serial.println("set");
 
  String colon = ":";
  int colonpos1, colonpos2;
  colonpos1 = time1.indexOf(colon);
  colonpos2 = time2.indexOf(colon);
  if (time1 != "") {
    open_hour = time1.substring(0, colonpos1);
    open_minute = time1.substring(colonpos1 + 1);
  }
  if (time2 != "") {
    close_hour = time2.substring(0, colonpos2);
    close_minute = time2.substring(colonpos2 + 1);
  }
 
  set_open_hour = set_open_minute = "";
  if (open_hour != "") {
    set_open_hour = open_hour;
    set_open_minute = open_minute;
  }
  Serial.print("set_open_hour:set_open_minute ");
  Serial.print(set_open_hour);
  Serial.print(" : ");
  Serial.println(set_open_minute);
  Serial.println();
 
  set_close_hour = set_close_minute = "";
  if (close_hour != "") {
    set_close_hour = close_hour;
    set_close_minute = close_minute;
  }
  Serial.print("set_close_hour:set_close_minute ");
  Serial.print(set_close_hour);
  Serial.print(" : ");
  Serial.println(set_close_minute);
  Serial.println();
 
  return true;
  //  server.send(200, "text/html", "set");
}
 
void autotimer() {
  // ESP32内蔵RTCを使用・setup()でNTPと併用
  time_t t;
  struct tm *tm;
  static const char *wd[7] = {"Sun", "Mon", "Tue", "Wed", "Thr", "Fri", "Sat"};
  char rdate[30], rday[30], rtime[30], rsec[30];
  char rhour[30], rminute[30];
  //int irhour, irminute, irsec;
  int irsec;
 
  t = time(NULL);
  tm = localtime(&t);
 
  Serial.printf(" %04d/%02d/%02d(%s) %02d:%02d:%02d\n",
                tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
                wd[tm->tm_wday],
                tm->tm_hour, tm->tm_min, tm->tm_sec);
 
  sprintf(rdate, "%04d/%02d/%02d",
          tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday);
  sprintf(rday, " (%s)",
          wd[tm->tm_wday]);
 
  sprintf(rtime, " %02d:%02d",
          tm->tm_hour, tm->tm_min);
  delay(1000 - millis() % 1000);
 
  sprintf(rhour, "%02d", tm->tm_hour);
  delay(1000 - millis() % 1000);
 
  //  irhour = atoi(rhour);
 
  sprintf(rminute, "%02d", tm->tm_min);
  delay(1000 - millis() % 1000);
 
  //  irminute = atoi(rminute);
 
  sprintf(rsec, "%02d", tm->tm_sec);
  delay(1000 - millis() % 1000);
 
  irsec = atoi(rsec);
 
  if (set_open_hour == "") {
    set_open_hour = default_open_hour;
    set_open_minute = default_open_minute;
  }
  if (set_close_hour == "") {
    set_close_hour = default_close_hour;
    set_close_minute = default_close_minute;
  }
 
  Serial.println("set_open_hour:set_open_minute / set_close_hour:set_close_minute");
  Serial.print(set_open_hour);
  Serial.print(":");
  Serial.print(set_open_minute);
  Serial.print(" / ");
  Serial.print(set_close_hour);
  Serial.print(":");
  Serial.println(set_close_minute);
  Serial.println();
 
  Serial.println("String(rhour).equals(set_open_hour) / String(rminute).equals(set_open_minute)");
  Serial.print(String(rhour).equals(set_open_hour));
  Serial.print(" / ");
  Serial.println(String(rminute).equals(set_open_minute));
  Serial.println();
 
  Serial.println("String(rhour).equals(set_close_hour) / String(rminute).equals(set_close_minute)");
  Serial.print(String(rhour).equals(set_close_hour));
  Serial.print(" / ");
  Serial.println(String(rminute).equals(set_close_minute));
  Serial.println();
 
  Serial.println("String(rhour):String(rminute) / set_open_hour:set_open_minute");
  Serial.print(String(rhour));
  Serial.print(":");
  Serial.print(String(rminute));
  Serial.print(" / ");
  Serial.print(set_open_hour);
  Serial.print(":");
  Serial.println(set_open_minute);
  Serial.println();
 
  Serial.println("String(rhour):String(rminute) / set_close_hour:set_close_minute");
  Serial.print(String(rhour));
  Serial.print(":");
  Serial.print(String(rminute));
  Serial.print(" / ");
  Serial.print(set_close_hour);
  Serial.print(":");
  Serial.println(set_close_minute);
  Serial.println();
 
  //  if (!onflg) {
  //Serial.println("onflg OFF");
  // 既定または、指定時刻に開く
  if (String(rhour).equals(set_open_hour)
      && String(rminute).equals(set_open_minute)
      && irsec <= 3) {
    //Serial.println("Auto Open");
    //      onflg  = true;
    curtain_open();
  }
  // 既定または、指定時刻に閉める
  if (String(rhour).equals(set_close_hour)
      && String(rminute).equals(set_close_minute)
      &&  irsec <= 6) {
    Serial.println("Auto Close");
    //      onflg  = true;
    curtain_close();
  }
  // }
  /*
    if (String(rminute).equals(set_open_minute) == false ||
        String(rminute).equals(set_close_minute) == false) {
      onflg = false;
    }
    if (String(rminute).equals(set_close_hour) == false ||
        String(rminute).equals(set_close_hour) == false) {
      onflg = false;
    }
    Serial.println("Auto Timer END");
  */
}
 
void OPEN() {
  Serial.println("OPEN");
  curtain_open();
  setOutput(9);
  gearServo.write(base);
  delay(2000);
  server.send(200, "text/html", "OPEN");
}
void CLOSE() {
  Serial.println("CLOSE");
  curtain_close();
  setOutput(9);
  gearServo.write(base);
  delay(2000);
  server.send(200, "text/html", "CLOSE");
}
 
void SET() {
  Serial.println("SET");
 
  String valueString;
  String delimiter = "/";
  int pos1, pos2, pos3;
  int slashpos;
  char message[20];
  String(server.arg(0)).toCharArray(message, 20);
  server.send(200, "text/html", (char *)buf);
 
  if (server.arg(1).indexOf("SET") != -1) {
    Serial.println("server.arg(1) *** ");
    Serial.println(server.arg(1));
    Serial.println();
    Serial.println("SET");
    opentime = "";
    closetime = "";
    int slash1 = server.arg(1).indexOf(delimiter);
    int slash2 = server.arg(1).indexOf(delimiter, slash1 + 1);
    if (slash1 >= 0 && slash2 < 0) {
      opentime = server.arg(1).substring(slash1);
    } else if (slash1 >= 0 && slash2 >= 0) {
      opentime = server.arg(1).substring(slash1 + 1, slash2);
      closetime = server.arg(1).substring(slash2 + 1);
    }
    Serial.print("opentime opentime || closetime ");
    Serial.print(opentime);
    Serial.print(" || ");
    Serial.println(closetime);
    Serial.println();
    if (set_timer(opentime, closetime)) {
      Serial.print("タイマー設定済み");
    } else {
      Serial.print("タイマー設定エラー");
    }
  }
}
 
void RESTART() {
  Serial.println("RESTART");
 
  Serial.println("");
  delay(1000);
  setOutput(9);
      digitalWrite(restartLED, HIGH);
  delay(1000);
      digitalWrite(restartLED, LOW);
  delay(2000);
  ESP.restart();
}
 
/*__________________________________________________________SETUP_FUNCTIONS__________________________________________________________*/
 
void startWiFi() { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
  //  WiFi.softAP(ssid, password);             // Start the access point
  WiFi.mode(WIFI_STA);             // Start the access point
  WiFi.begin(ssid, password);             // Start the access point
  while ( WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.print("SSID \"");
  Serial.print(ssid);
  Serial.println("\" started\r\n");
 
  Serial.print("Connected to ");
  Serial.println(ssid);
 
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
 
  Serial.print("hostname : ");
  //  Serial.println(WiFi.hostname());
  Serial.println("");
}
 
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");
}
 
void startSPIFFS() { // Start the SPIFFS and list all contents
  SPIFFS.begin();                             // Start the SPI Flash File System (SPIFFS)
 
  //server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
  /*
    if(!SPIFFS.begin()) {
      Serial.println("Mount SPIFFS failed, exiting...");
      return;
    }
    Serial.println("Mount SPIFFS successfully.");
  */
 
  /*
    Serial.println("SPIFFS started. Contents:");
    {
      listDir(SPIFFS, "/", 0);
    }
    Serial.printf("\n");
  */
 
  if (!readHTML()) {
    Serial.println("Read HTML error!!");
  }
  /*
    Serial.println("SPIFFS started. Contents:");
    {
      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 startMDNS() { // Start the mDNS responder
  MDNS.begin(mdnsName);                        // start the multicast domain name server
  Serial.print("mDNS responder started: http://");
  Serial.print(mdnsName);
  Serial.println(".local");
 
  // Add service to MDNS-SD
  MDNS.addService("http", "tcp", 80);
}
 
void startServer() { // Start a HTTP server with a file read handler and an upload handler
 
  server.on("/", handleRoot);
  server.on("/Open", OPEN);
  server.on("/Close", CLOSE);
  server.on("/Setting", SET);
  server.on("/Restart", RESTART);
  /*
    server.on("/edit.html",  HTTP_POST, []() {  // If a POST request is sent to the /edit.html address,
      server.send(200, "text/plain", "");
    }, handleFileUpload);                       // go to 'handleFileUpload'
  */
  server.on("/edit.html",  HTTP_POST, []() {  // If a POST request is sent to the /edit.html address,
    server.send(200, "text/plain", "");
  });
  server.onNotFound(handleNotFound);          // if someone requests any other file or page, go to function 'handleNotFound'
  // and check if the file exists
 
  server.begin();                             // start the HTTP server
  Serial.println("HTTP server started.");
}
 
void setup() {
  delay(1000);
  Serial.begin(115200);
  delay(10);
  Serial.println("\r\n");
 
  //declare the motor pins as outputs
  pinMode(motorPin1, OUTPUT);
  pinMode(motorPin2, OUTPUT);
  pinMode(motorPin3, OUTPUT);
  pinMode(motorPin4, OUTPUT);
  pinMode(LimitSW, INPUT_PULLUP);
 
  gearServo.attach(25);
  gearServo.write(base);
  touchAttachInterrupt(T8, gotTouchStop, threshold);
 
  startWiFi();
  startOTA();
  startSPIFFS();
  startServer();
  startMDNS();
 
  // NTP start
  configTime( JST, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");
}
 
void loop() {
  server.handleClient();                      // run the server
  ArduinoOTA.handle();                        // listen for OTA events
 
  autotimer();
}
[2023/06/15]

 タイマー設定できるように修正...。

 以前できていたから、ここに書いたのに...、タイマー設定も割と使っていたはずなのに...、できていたと思っていたのが、幻だったのか...。

 タイマー設定だけできなくなっており、HTML上でライブラリの参照だけしてJQueryで送信していないことが判明、書き忘れたか...と思いきや、スケッチ側に目をやると引数もあるものと期待していたはずのESP32WebServer.arg(0)にはinputボタンのvalue値だけが入っており、実際の引数はarg(1)か、HTML上のコピー先のid値であるarg("data")でないと受信データを取得できないことが発覚...なんで?

 結果、このESP32サーバスケッチ側void SET()の太字部分を、併せてHTML側scriptにJQuery送信部分追記修正。

HTML/JavaScript

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1" />
<title>自室電動カーテン</title>
<link href='main.css' rel='stylesheet' type='text/css' />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="144x144" href="/favicon-144x144.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#00878f" />
<meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' name='viewport' />
<script>
function clearSetTimer() {
  document.getElementById("open_hour").value = "";
  document.getElementById("open_minute").value = "";
  document.getElementById("close_hour").value = "";
  document.getElementById("close_minute").value = "";
  sendCtrl("SET");
}
function sendCtrl(btn) {
var data = "";
  data += btn;
  if (btn == "SET") {
    if (document.getElementById("open_hour").value != "") {
      var openhour = ( '00' + document.getElementById("open_hour").value ).slice( -2 );
      var openminute = ( '00' + document.getElementById("open_minute").value ).slice( -2 );
      data += "/" + openhour + ":" + openminute;
    } else {
      data += "/" + "07:30";
    }
    if (document.getElementById("close_hour").value != "") {
      var closehour = ( '00' + document.getElementById("close_hour").value ).slice( -2 );
      var closeminute = ( '00' + document.getElementById("close_minute").value ).slice( -2 );
      data += "/" + closehour + ":" + closeminute;
    } else {
      data += "/" + "18:00";
    }
  }
  document.getElementById("data").value = data;
  $.ajaxSetup({timeout:1000});
  $.get("/?value=" + data + "&");
  {Connection: close};
}
function http_req(url) {
  var req = new XMLHttpRequest();
  req.open("GET", url, true);
  req.send();
}
</script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
</head>
<body>
<center>
<header>
<h1>自室電動カーテン</h1>
</header>
<form action="/" method="post">
<div style="padding:6% ;width:36% ;float:none ;"><input id="open" type="submit" onclick="sendCtrl('OPEN');" value="開ける" style="width:80px ;"></div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="close" type="submit" onclick="sendCtrl('CLOSE');" value="閉める" style="width:80px ;"></div>
<div style="clear:both ;"></div>
<div style="font-size:12px ;">既定オートタイマー/Open 7:30 Close 18:00<br>自動開閉時間を以下で指定できます *1</div>
<div>開ける時間 : <input id="open_hour" type="number" min="0" max="23" style="width:70px ;height:20px ;"> : <input id="open_minute" type="number" min="0" max="59" style="width:70px ;height:20px ;"></div>
<div>閉める時間 : <input id="close_hour" type="number" min="0" max="23" style="width:70px ;height:20px ;"> : <input id="close_minute" type="number" min="0" max="59" style="width:70px ;height:20px ;"></div>
<div style="font-size:12px ;">[0-23]時:[0-59]分指定 / 時を省略すると無効、分を省略すると"0"自動補完。</div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="set" type="submit" onclick="sendCtrl('SET');" value="タイマー設定" style="width:120px ;height:40px ;"></div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="clear" type="submit" onclick="clearSetTimer();" value="クリア" style="width:120px ;height:40px ;"></div>
<div style="font-size:12px ;text-align:left ;">有効な時:分指定後、[設定ボタン]タップで設定可能。<br>未設定の方は、デフォルト(開 07:30/閉 18:00)を自動補完。<br>[クリア]ボタンで時間指定欄の消去及びデフォルト(開 07:30/閉 18:00)自動設定。</div>
<div style="font-size:12px ;text-align:left ;">*1<br>マイコンを再起動するとリセットされ、既定の時間に自動開閉します。</div>
<div><input id="data" type="text" style="width:360px ;height:20px ;"></div>
<input type="button" name="mainmenu" value="メインメニュー" style="width:120px ;height:30px ;font-size:12px ;" onClick="http_req(location.href='http://esphamainsrv.local')">
</form>
</center>
</body></html>

 JavaScriptライブラリjQueryのajax機能による、今回は、GETで押下ボタンや時間情報などのデータを送信。

 時間入力ボックスは、form inputのtype属性をnumber、それぞれmin/maxを指定しているため、少なくとも自身愛用のFirefox(68.7.0esr)では、半角数値/全角数字以外、数値範囲外の入力については、当該ボックスを赤枠で囲って注意を促してはくれるものの、別途JavaScriptによる数値チェックや範囲チェックなどで再入力を促すようなことはしていません。

 何気に御用達さないChromium(Chromeオープンソース版)で試してみたら、賢い...、type属性numberだと半角英字や記号のみならず、全角数字さえもそもそも入力を受け付けない...確かにNumberならば、こういう挙動を期待したいところ...ぬぅ...がんばれ!Firefox。

 ちなみに、どちらも[0-99]までを有効数字として扱い、それ以外は赤枠はつくものの、[100]以上とか、[0001]とか、[0205]とか、3桁以上も受け付けつつ、末尾2桁を入力値として扱う、これは仕様通りなのか、何れのブラウザも挙動は同じ模様。

 外部CSSは、いい加減なので、さらしません...CSSはよしなに。

 尚、なぜか、パネルを開いてから30秒ほど時間をおかないとボタンを押しても機能しない、即押して、あれ?動かないと思って、また押すとダブって複数回実行されてしまうことがあるのでパネルが開いてから概ね30秒後に操作するのが無難そうです。

 もちろん、電動で開け切る時、閉め切る時は、リミットスイッチが効くようにしておけば、仮に操作が重複したとしても問題ありませんし、静電容量式の停止用タッチパネルで停止させることもできますが。

<body>
...
<center><div style="padding:6% ;width:36% ;"><input id="clear" type="submit" onclick="sendCtrl('RESTART');" value="リセット" style="width:120px ;height:40px ;"></div></center>
...
</body></html>
[2021/08/13]

 ESP.restart()によるソフトウェアリセット機能を追加するに伴い、onclickで文字列RESTARTを送信するリセットボタンを追加。

[2022/06/18]

 あ!!!、試してみてできないじゃん!ってなった方がいたら、申し訳ありません...、これじゃ送信されない...、閉じタグ含め、post指定したformタグ入れ忘れてました...........。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1" />
<title>自室電動カーテン</title>
<link href='main.css' rel='stylesheet' type='text/css' />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="144x144" href="/favicon-144x144.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#00878f" />
<meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' name='viewport' />
<script>
function clearSetTimer() {
  document.getElementById("open_hour").value = "";
  document.getElementById("open_minute").value = "";
  document.getElementById("close_hour").value = "";
  document.getElementById("close_minute").value = "";
  sendCtrl("SET");
}
function sendCtrl(btn) {
var data = "";
  data += btn;
  document.getElementById("data").value = data;
  console.log('Btn Name: ' + btn);
  if (btn == "SET") {
    if (document.getElementById("open_hour").value != "") {
      var openhour = ( '00' + document.getElementById("open_hour").value ).slice( -2 );
      var openminute = ( '00' + document.getElementById("open_minute").value ).slice( -2 );
      data += "/" + openhour + ":" + openminute;
    } else {
      data += "/" + "07:30";
    }
    if (document.getElementById("close_hour").value != "") {
      var closehour = ( '00' + document.getElementById("close_hour").value ).slice( -2 );
      var closeminute = ( '00' + document.getElementById("close_minute").value ).slice( -2 );
      data += "/" + closehour + ":" + closeminute;
    } else {
      data += "/" + "18:00";
    }
  }
}
function http_req(url) {
  var req = new XMLHttpRequest();
  req.open("GET", url, true);
  req.send();
}
</script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
</head>
<body>
<center>
<header>
<h1>自室電動カーテン</h1>
</header>
<form action="/" method="post">
<div style="padding:6% ;width:36% ;float:none ;"><input id="Open" type="submit" onclick="sendCtrl('OPEN');" value="開ける" style="width:80px ;"></div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="Close" type="submit" onclick="sendCtrl('CLOSE');" value="閉める" style="width:80px ;"></div>
<div style="clear:both ;"></div>
<div style="font-size:12px ;">既定オートタイマー/Open 7:30 Close 18:00<br>自動開閉時間を以下で指定できます *1</div>
<div>開ける時間 : <input id="open_hour" type="number" min="0" max="23" style="width:70px ;height:20px ;"> : <input id="open_minute" type="number" min="0" max="59" style="width:70px ;height:20px ;"></div>
<div>閉める時間 : <input id="close_hour" type="number" min="0" max="23" style="width:70px ;height:20px ;"> : <input id="close_minute" type="number" min="0" max="59" style="width:70px ;height:20px ;"></div>
<div style="font-size:12px ;">[0-23]時:[0-59]分指定 / 時を省略すると無効、分を省略すると"0"自動補完。</div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="Setting" type="submit" onclick="sendCtrl('SET');" value="タイマー設定" style="width:120px ;height:40px ;"></div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="clear" type="submit" onclick="clearSetTimer();" value="クリア" style="width:120px ;height:40px ;"></div>
<div style="font-size:12px ;text-align:left ;">有効な時:分指定後、[設定ボタン]タップで設定可能。<br>未設定の方は、デフォルト(開 07:30/閉 18:00)を自動補完。<br>[クリア]ボタンで時間指定欄の消去及びデフォルト(開 07:30/閉 18:00)自動設定。</div>
<div style="font-size:12px ;text-align:left ;">*1<br>マイコンを再起動するとリセットされ、既定の時間に自動開閉します。</div>
<center><div style="padding:6% ;width:36% ;"><input type="submit" name="Restart" value="リセット" style="width:120px ;height:40px ;"></div></center>
<div><input id="data" type="text" style="width:360px ;height:20px ;"></div>
<input type="button" name="mainmenu" value="メインメニュー" style="width:120px ;height:30px ;font-size:12px ;" onClick="http_req(location.href='http://esphamainsrv.local')">
</form>
</center>
</body></html>

 書き直したスケッチと併せてsetをsettingに、念のため、ボードに送信する一連のボタンのid値全部、先頭を大文字にしておくとかしておいた方が良いかなとか含め、書き直すとこんな感じでしょうか。

[2023/06/15]

 ん?タイマー設定だけできない...。

 と思ったら、JQuery参照してるのに送信してない...、いや、http_reqしようとしてたのか?すら記憶にない。

 が、いざ、JQueryで送信してみたら、ESP32Webserver.arg(0)で参照していた受信データ、今日やってみると.arg(1)(かコピーした先のid値.arg("data"))に入ってて.arg(0)にはない...、なぜ!?

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1" />
<title>自室電動カーテン</title>
<link href='main.css' rel='stylesheet' type='text/css' />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="144x144" href="/favicon-144x144.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#00878f" />
<meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' name='viewport' />
<script>
function clearSetTimer() {
  document.getElementById("open_hour").value = "";
  document.getElementById("open_minute").value = "";
  document.getElementById("close_hour").value = "";
  document.getElementById("close_minute").value = "";
  sendCtrl("SET");
}
function sendCtrl(btn) {
var data = "";
  data += btn;
  document.getElementById("data").value = data;
  console.log('Btn Name: ' + btn);
  if (btn == "SET") {
    if (document.getElementById("open_hour").value != "") {
      var openhour = ( '00' + document.getElementById("open_hour").value ).slice( -2 );
      var openminute = ( '00' + document.getElementById("open_minute").value ).slice( -2 );
      data += "/" + openhour + ":" + openminute;
    } else {
      data += "/" + "07:30";
    }
    if (document.getElementById("close_hour").value != "") {
      var closehour = ( '00' + document.getElementById("close_hour").value ).slice( -2 );
      var closeminute = ( '00' + document.getElementById("close_minute").value ).slice( -2 );
      data += "/" + closehour + ":" + closeminute;
    } else {
      data += "/" + "18:00";
    }
  }
  document.getElementById("senddata").value = data;
  $.ajaxSetup({timeout:1000});
  var path = location.origin + "/" + '?' + data;
  console.log("path: " + path);
  $.get(path);
  {Connection: close};
}
function http_req(url) {
  var req = new XMLHttpRequest();
  req.open("GET", url, true);
  req.send();
}
</script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
</head>
<body>
<center>
<header>
<h1>自室電動カーテン</h1>
</header>
<form action="/" method="post">
<div style="padding:6% ;width:36% ;float:none ;"><input id="Open" type="submit" onclick="sendCtrl('OPEN');" value="開ける" style="width:80px ;"></div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="Close" type="submit" onclick="sendCtrl('CLOSE');" value="閉める" style="width:80px ;"></div>
<div style="clear:both ;"></div>
<div style="font-size:12px ;">既定オートタイマー/Open 7:30 Close 18:00<br>自動開閉時間を以下で指定できます *1</div>
<div>開ける時間 : <input id="open_hour" type="number" min="0" max="23" style="width:70px ;height:20px ;"> : <input id="open_minute" type="number" min="0" max="59" style="width:70px ;height:20px ;"></div>
<div>閉める時間 : <input id="close_hour" type="number" min="0" max="23" style="width:70px ;height:20px ;"> : <input id="close_minute" type="number" min="0" max="59" style="width:70px ;height:20px ;"></div>
<div style="font-size:12px ;">[0-23]時:[0-59]分指定 / 時を省略すると無効、分を省略すると"0"自動補完。</div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="Setting" type="submit" onclick="sendCtrl('SET');" value="タイマー設定" style="width:120px ;height:40px ;"></div>
<div style="padding:6% ;width:36% ;float:none ;"><input id="clear" type="submit" onclick="clearSetTimer();" value="クリア" style="width:120px ;height:40px ;"></div>
<div style="font-size:12px ;text-align:left ;">有効な時:分指定後、[設定ボタン]タップで設定可能。<br>未設定の方は、デフォルト(開 07:30/閉 18:00)を自動補完。<br>[クリア]ボタンで時間指定欄の消去及びデフォルト(開 07:30/閉 18:00)自動設定。</div>
<div style="font-size:12px ;text-align:left ;">*1<br>マイコンを再起動するとリセットされ、既定の時間に自動開閉します。</div>
<center><div style="padding:6% ;width:36% ;"><input type="submit" name="Restart" value="リセット" style="width:120px ;height:40px ;"></div></center>
<div><input id="data" type="text" style="width:360px ;height:20px ;"></div>
<input type="button" name="mainmenu" value="メインメニュー" style="width:120px ;height:30px ;font-size:12px ;" onClick="http_req(location.href='http://esphamainsrv.local')">
</form>
</center>
</body></html>

 function sendCtrl(btn) {}末尾の太字部分を追記。

 併せてサーバ側スケッチも修正

操作方法

ESP32自動タイマー付き無線電動カーテン用ブラウザ版操作パネル

 ブラウザにmDNS名でmDNS.localか、IPアドレスで***.***.***.***にアクセスすれば、操作パネルが表示されます。

 [開ける]/[閉める]ボタンは、読んだそのままカーテンを開ける/閉める。

 [タイマー設定]ボタンは、時間を省略した場合、無視、分を省略した場合、0補完、開・閉用それぞれの時・分指定ボックス入力値で自動タイマーを設定。

 この時、[開ける時間]/[閉じる時間]において、何れかを省略した場合には、既定の[07:30](前者)、[18:00](後者)を自動補完。

 [クリア]ボタンは、[開ける時間]/[閉じる時間]ボックス内を消去すると同時に既定の[07:30](前者)、[18:00](後者)を設定。

 これなら、別途、ESP8266/ESP32で作って運用中の集中操作パネルとしているブラウザ版スマートホーム操作パネルに簡単に統合できます。

 テキストボックスは送信データ確認用なので運用上は、なくても構いません。

 ちなみにこのパネルについては、表示されるまでと表示されてから比較的タイムリーに応答するまでタイムラグが...後者については、画面遷移後、即、開・閉ボタンを押すと一見反応せず、もう1度押すと実は2度機能してしまう...アクセス時の処理が重かった?

 尤も、後者については、まだリミットスイッチを定位置に付けず、手押しして止めているからわかったことで、たとえ、このままにしておいたとしてもリミットスイッチを定位置に設置した後は、仮に2度押ししてもリミットスイッチが機能する為、誤作動にはなりませんが。

スクリプトから操作

$ cat curtain.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-
 
import requests
import sys
 
url = 'http://websockscurtain.local'
arg = sys.argv
urlarg = { 'value':arg[1] }
 
requests.get(url, params=urlarg)
$ chmod +u curtain.py
$ ./curtain.py OPEN
$ ./curtain.py CLOSE
$

 例えば、Python(Python3)だとrequestsを使うと(ESP32のWebサーバに対して)GETやPOSTでURLアクセスできます。

 今回、ESPmDNSを使っているのでIPアドレスではなく、mDNS.localとドメイン名でアクセスできます。

 SET(設定)は別途作るとして、sysで引数を取ることができるようにすれば、1つの簡易スクリプトでOPEN / CLOSEできます。

 操作にスクリプトが使えるということは、タイマーなしの先代同様、ブラウザからのWiFi越しの無線操作に加え、自作ラズパイスマートスピーカーから電動部自作カーテンを音声操作することも簡単にできます。

2020/04/27

 Raspberry Pi/ESPボード/WebSocketクライアントの組み合わせについての注意・特記事項

2020/12/18

 Python/pip/websocket周り仕様変更!?にハマる参照。

自作スマートスピーカー用Qtデスクトップ操作パネル

 また、このカーテン用含め、ブラウザ版操作パネルを冒頭触れたデスクトップ版操作パネルから呼ぶこともできます。

備考

 タイマー以外の部分で、ここで見当たらなければ、冒頭や後段のリンク先である先代を参照ください。

2021/04/25

 そう言えば、ひっかかって途中で止まってしまった場合を考慮して例えば、一定時間内に開、または、閉しなかったら止めるっていう機能も必要ですね(組み込みました)。

 あとスリープも欲しいところですが、この仕組みにおいて妥当な復帰方法がなさ気なので、なしで。

ホーム前へ次へ