我受到一個 Arduino 教程 “WiFi Controlled Car with a Self Hosted HTML/JS Joystick” 啟發,盡量喺 web 寫 code 好過喺電子裝置入面寫 Arduino 嘅 C;我覺得寫 wifi 搖控車嘅教育價值一定高過寫迷宫智能車,主要原因係 javascript 同 web 嘅實用價值明顯較高,而且係 Interpreted Language 唔需要 compile 會方便過 Arduino 好多。只要喺電腦上面寫一個 web UI 同測試好之後,然後用同一個 source 放到 有 wifi 功能嘅 wemos D1 入面嘅 web server,再喺手机上用 wifi 連接,開 broswer 咁用就可以控制 wifi 模型車,根本唔需要寫 mobile app。
因為手机有 touch screen 嘅好處,nippleJS 係一個 javascript library 取代左右制,令轉向角度更加細緻順暢;而前後加減速我用入波定速嘅形式控制,車上有測速限制最高速度。一般 Arduino 智能車係用両個獨立 130減速摩打,用 TB6612 驅動模块造調速同轉向,用四個 GPIO port 而造出相當唔錯嘅效果。但我架車係 1:10 平砲搖控車架改裝,用 HSP 94123 車架,用 servo 轉向,配低轉速大扭力 540有刷摩打,用双向有刷迷你电调以 5V電壓調速檔 servo 用;變相一架車有両個 5V servo 造晒前後左右動作,只用両個 GPIO port,而 UI 係直接輸出 servo 用嘅角度,以 90度為正前方或者靜止。
<!DOCTYPE html> <html> <head> <title>AP-motor</title> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"> <link rel="stylesheet" href="style.css" type="text/css"> <script src="jquery-3.7.1.min.js"></script> <script src="nipplejs.min.js"></script> <script src="script.js"></script> </head> <body> <div id="container"> <div id="anr"> <label id="label_a">A</label> <label id="label_n">N</label> <label id="label_r">R</label> </div> <div id="display-tx"></div> <div id="display-rx"></div> <div id="display-rx-data"></div> <div id="nipple-lr"></div> <div id="btn"> <form id="form-update" method="POST" action="http://motor.local/update" enctype="multipart/form-data"> <label id="label_upload" for="upload">FS upload <input id="upload" type="file" accept=".bin,.bin.gz" name="filesystem"> </label> <label id="label_ap_mode">AP mode</label> <label id="label_restart">Restart</label> <label id="label_refresh">Refresh</label> </form> </div> </div> </body> </html>
html { width: 100%; height: 100%; touch-action: none; } body { display: flex; width: 100%; height: 100%; margin: 0 auto; padding: 0; background-color: #000; color: #FFF; font-size: 12px; font-family: sans-serif; justify-content: center; align-content: center; } #container { width: 800px; height: 270px; position: relative; margin: auto; } #display-tx, #display-rx, #display-rx-data { text-align: center; width: 160px; margin: 0 80px; user-select: none; } #display-tx { color: lightpink; font-size: 20px; top: 0; left: 40%; position: absolute; } #display-rx{ color: deepskyblue; font-size: 20px; top: 0; left: 20%; position: absolute; } #display-rx-data { color: deepskyblue; } ul { list-style-type: none; margin: 0; padding: 0; line-height: 1.5; } #btn { top: 0; right: 0; position: absolute; display: inline-block; white-space: nowrap; } #anr { top: 0; left: 0; position: absolute; } .back { max-height: 20px; margin-top: -10px !important; opacity: 0.25 !important; } #label_a, #label_n, #label_r { display: flex; justify-content: center; align-items: center; width: 80px; height: 80px; font-size: 7em; } input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; } label { border: 1px solid #666; display: inline-block; padding: 5px; cursor: pointer; font-size: 12px; margin: 0; background: #222; } label:active { border-color: #CCC; box-shadow: 0 0 30px 10px rgba(0, 255, 255, 0.33) !important; }
因為 servo 嘅 poll rate 係 50Hz,於是 html 就要靠 javascript 每 20ms refresh 一次,監察訊號暢順度。一般 web 係用 HTTP protocol,default 用 port 80,只適合大約一秒 refresh 一次;如果要低 latency 長期頻密咁 update,就要用令外加多一個 port 行 websocket。ESP8266 有 websocket server 嘅 library,亦有 esp8266 webserver, websocket 只係用黎輸出最小嘅文字 data,好似 wifi 咁會長期同 client 接駁,可以雙向交流,將 wifi 車 sensor 嘅 data 不断送到 broswer 個 UI,同時 UI 嘅操控會經 websocket 送到 wifi 車,javascript 係 websocket 同 web 嘅 client side 角色。
"use strict"; jQuery.noConflict(); //https://yoannmoi.net/nipplejs/ //https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications //https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js //https://cdnjs.cloudflare.com/ajax/libs/nipplejs/0.10.0/nipplejs.min.js const mdns = "motor"; const socket = new WebSocket("ws://" + mdns + ".local:443", ['arduino']); const nipple_size_lr = 480; const servo_fade_time = 1000; const angle_o = 90; const servo_half_angle = 60; const servo_d = 1; let servo_angle = 90; let servo_target_angle = 90; let json_data = ""; let json_data_old = ""; /******************************************************************** document ready ********************************************************************/ jQuery(function () { // nipple.js const lr_options = { zone: document.getElementById('nipple-lr'), color: '#FFF', size: nipple_size_lr, fadeTime: servo_fade_time, position: { left: '60%', top: '55%' }, mode: 'static', shape: 'square', lockX: true, }; const nipple_lr = nipplejs.create(lr_options); nipple_lr.on("move", function (evt, data) { servo_target_angle = data.vector.x * servo_half_angle + angle_o; }); nipple_lr.on("end", function (evt, data) { servo_target_angle = angle_o; }); //button jQuery("#label_upload").on("change", function () { jQuery("#form-update").submit(); }); jQuery("#label_ap_mode").on("click", { command: "AP_MODE" }, fn_ws_send); jQuery("#label_restart").on("click", { command: "RESTART" }, fn_ws_send); jQuery("#label_refresh").on("click", function () { location.reload(); }); jQuery("#label_a").on("click", { command: "A" }, fn_ws_send); jQuery("#label_n").on("click", { command: "N" }, fn_ws_send); jQuery("#label_r").on("click", { command: "R" }, fn_ws_send); setInterval(loop_20ms, 20); });//end document ready /******************************************************************** loop ********************************************************************/ function loop_20ms() { fn_nipple_json(); fn_ws_json(json_data, "#display-rx", "#display-tx"); if (socket.readyState != 1) { jQuery("#label_upload, #label_ap_mode, #label_restart").hide(); } else { jQuery("#label_upload, #label_ap_mode, #label_restart").show(); } } /******************************************************************** function ********************************************************************/ function fn_nipple_json() { if (servo_angle == Math.round(servo_target_angle)) { servo_angle = Math.round(servo_target_angle); } else if (servo_angle < servo_target_angle) { servo_angle += servo_d; } else if (servo_angle > servo_target_angle) { servo_angle -= servo_d; } json_data = '{"servo":' + servo_angle + '}'; } function fn_ws_json(json_tx, container_rx, container_tx) { const items = []; jQuery.each(JSON.parse(json_tx), function (key, val) { items.push("<li name='key-" + key + "' data-val='" + val + "'>" + key + ": " + val + "</li>"); }); let html_data = jQuery("<ul />", { "html": items.join("") }); jQuery(container_tx).html(html_data); if (socket.readyState == 1) { if (json_tx != json_data_old) { socket.send(json_tx); json_data_old = json_tx; } } else if (socket.readyState == 3) { jQuery(container_rx).html('CLOSED'); } else if (socket.readyState == 0) { jQuery(container_rx).html('CONNECTION OPEN'); } //event socket.onmessage = function (event) { const items = []; const jsonRX = JSON.parse(event.data); jQuery.each(jsonRX, function (key, val) { items.push("<li name='key-" + key + "' data-val='" + val + "'>" + key + ": " + val + "</li>"); }); let html_data = jQuery("<ul />", { "html": items.join("") }); jQuery(container_rx + "-data").html(html_data); jQuery(container_rx).html(jsonRX["power(mW)"] + "<br/>mW"); }; socket.onerror = function (error) { jQuery(container_rx).html("ERROR: " + error.message); socket.close(); }; } function fn_ws_send(event) { if (socket.readyState == 1) { socket.send(JSON.stringify(event.data)); } else { location.reload(); } }
如果要將電腦寫好嘅 html 或者 js 之類嘅 file 放到 Wemos D1 mini,就要用到 LittleFS 用到 file system 部份。一般 sketch 嘅 ino file 刷到 Wemos D1 mini 時刷唔到 FS,所以要喺Arduino IDE 裝 LittleFS Filesystem Uploader 。另外,8266 webserver 比一般電腦嘅 Apache server 落後得多,唔識自己揾 FS 入面有咩 file,要喺 sketch 寫明每個會用到嘅 file 茗。
#include <wemos-share-library.h> const char mdns[] = "motor"; const double rps_target = 7.16; // 0.75 mps / (0.1*Pi) * 3 gear ratio const double esc_interval = 0.1; char esc_command = 'N'; //******************************************************************* // Instantiate class objects, global var and Function Prototypes //******************************************************************* Servo servo; Servo esc; //******************************************************************* // setup //******************************************************************* void setup() { enable_ap = 1; enable_root = 1; enable_counter = 1; fn_wemos_blynk_begin(mdns, ""); digitalWrite(pin_led, 0); // led on pinMode(pin_d6, OUTPUT); servo.attach(pin_d6); servo.write(90); json["servo"] = 90; pinMode(pin_d7, OUTPUT); esc.attach(pin_d7); esc.write(90); json["esc"] = 90; server.serveStatic("/", LittleFS, "/index.html"); server.serveStatic("/style.css", LittleFS, "/style.css"); server.serveStatic("/jquery-3.6.1.min.js", LittleFS, "/jquery-3.6.1.min.js"); server.serveStatic("/nipplejs.min.js", LittleFS, "/nipplejs.min.js"); server.serveStatic("/script.js", LittleFS, "/script.js"); } //******************************************************************* // loop //******************************************************************* ICACHE_RAM_ATTR void fn_handle_int() {} ICACHE_RAM_ATTR void fn_handle_encoder(bool encoder_cw) {} void fn_loop_1ms() {} void fn_loop_20ms() { //servo write if (!is_wsconnect) { servo.write(90); json["servo"] = 90; } else { json["servo"] = jsonRX["servo"].as<uint8_t>(); if (json["servo"].as<uint8_t>() >= 30 && json["servo"].as<uint8_t>() <= 150) { servo.write(180 - json["servo"].as<uint8_t>()); } } //esc input if (!is_wsconnect) { esc_command = 'N'; json["esc"] = 90; } else if (jsonRX["command"] == "A") { jsonRX["command"] = ""; esc_command = 'A'; } else if (jsonRX["command"] == "N") { jsonRX["command"] = ""; esc_command = 'N'; json["esc"] = 90; } else if (jsonRX["command"] == "R") { jsonRX["command"] = ""; esc_command = 'R'; } else if (jsonRX["command"] == "AP_MODE") { jsonRX["command"] = ""; WiFi.disconnect(); WiFi.mode(WIFI_AP); WiFi.softAP(String(json["dns"]), password); } else if (jsonRX["command"] == "RESTART") { jsonRX["command"] = ""; fn_cors_header(); server.send(204); ESP.restart(); } // esc constant speed control if ((esc_command == 'A' && fn_rps_delta()) || (esc_command == 'R' && !fn_rps_delta())) { json["esc"] = json["esc"].as<double>() - esc_interval; } else if ((esc_command == 'A' && !fn_rps_delta()) || (esc_command == 'R' && fn_rps_delta())) { json["esc"] = json["esc"].as<double>() + esc_interval; } //esc dead zone if (json["esc"].as<uint8_t>() > 90 && json["esc"].as<uint8_t>() < 120) { json["esc"] = 120; } else if (json["esc"].as<uint8_t>() < 90 && json["esc"].as<uint8_t>() > 60) { json["esc"] = 60; } //esc write if (!is_wsconnect) { esc.write(90); json["esc"] = 90; } else if (json["esc"].as<uint8_t>() >= 1 && json["esc"].as<uint8_t>() <= 180) { esc.write(json["esc"].as<uint8_t>()); } } void fn_loop_1000ms() {} void fn_loop_8640ms() {} //******************************************************************* // function //******************************************************************* bool fn_rps_delta() { if (json["rps"].as<double>() < rps_target) { return false; } else { return true; } }
因為我有幾塊 Wemos D1,大部份 code 包括 wifi連接, OTA,INA219 電流計, MPU9250 九軸陀螺儀, 測速之類嘅 code 都抽到共同嘅 file 中,而且之前已經寫好,保持搖控車專用嘅 sketch 䀆量簡洁易明。新加 websocket 收發嘅 function 只需要由 example 直接抄再改小小,重點在於點図安排當中嘅 data 進行溝通;我用ArduinoJson 同 JSON format 嘅好處在於兼容 javascript,方便將 data 輸出到 UI。單單 server 同 client 之間嘅 data 溝通就會遇到好多問題,例如會遇到 CORS 嘅限制,需要由電腦 browser 經 wifi 嘅 station mode 駁到 Wemos D1 mini pro 進行開發同測試,到無問題後先再用手机 wifi 嘅 AP mode 測試效果;於是我寫到開机如果 station mode 駁唔倒我部電腦嘅 wifi hotspot,就自動轉落去 AP mode 比手机 connect。
#include <ArduinoJson.h> #include <ESP8266mDNS.h> #include <ESP8266WebServer.h> #include <ESP8266WiFi.h> #include <LittleFS.h> #include <WebSocketsServer.h> ...... StaticJsonDocument<512> json; StaticJsonDocument<128> jsonRX; ESP8266WebServer server(80); WebSocketsServer server_ws = WebSocketsServer(443); ...... void fn_wifi_begin() { static uint8_t wifi_reconnect = 0; if (WiFi.getMode() == 1) { // 1=station, 2=ap //Serial.println("STATION mode"); if (WiFi.status() != 3) { // 0=idle, 3=connected, 4=fail, 7=disconnect WiFi.disconnect(); wifi_reconnect++; WiFi.begin(ssid, password); } else { if (year() < 2000) { fn_ntp_sync(TIMEZONE); } json["ip"] = WiFi.localIP().toString(); json["rssi"] = WiFi.RSSI(); wifi_reconnect = 0; } } else if (enable_ap && wifi_reconnect >= 2) { Serial.println("AP fallback"); WiFi.disconnect(); WiFi.mode(WIFI_AP); WiFi.softAP(String(json["dns"]), password); wifi_reconnect = 0; } else if (WiFi.getMode() == 2) { //Serial.println("AP mode"); json["ip"] = WiFi.softAPIP().toString(); wifi_reconnect = 0; } } void fn_wsServerEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { Serial.print("Event("); Serial.print(type); Serial.print("): "); switch (type) { case WStype_DISCONNECTED: is_wsconnect = false; Serial.printf("[%u] Disconnected!n", num); break; case WStype_CONNECTED: { is_wsconnect = true; IPAddress ip = server_ws.remoteIP(num); Serial.printf("[%u] Connected from %d.%d.%d.%d url: %sn", num, ip[0], ip[1], ip[2], ip[3], payload); } break; case WStype_TEXT: is_wsconnect = true; DeserializationError error = deserializeJson(jsonRX, (const char*)payload); if (error) { Serial.print(F("deserializeJson() failed: ")); Serial.println(error.c_str()); return; } Serial.printf("[%u] get Text: %sn", num, payload); break; } }
我將所有電子零件銲到 6*4PCB板,而且盡量用插座或者排插可以分番開方便debug 或者換靈件,再用車架上嘅手机夾取代螺絲固定方便裝拆;但 OTA 功能係必需,唔可能吓吓拆番出黎用 usb 線駁住先 update 倒啲 code。但 OTA 係唔會 update FS 部份啲 file,所以需要喺 web UI 上加個 FS upload 功能,揾番 complie 出黎有個 mklittlefs.bin 嘅 file upload。
我用 3粒18650 12V充電宝造電源方便叉電,然後經 INA219 測電流電壓之後去到 MP1584 開關降压模块輸出 5V。摩打電源同 MCU電源最好分開,用令一塊降压直接由 12V 降庒到 3.3V 輸入 Wemos D1 mini pro 。因為我用嘅摩打細食,為左簡化起見,我用 AMS1117 LDO 由 5V 降压到 3.3V,純水避免用版上面嘅 5V 輸入變得更加耐用;LDO 係線性降压只適用於 1 -3V 降压穏压,我用 USB示波器測過波紋比開關降压更差,由 12V 直接降到 3.3V 嘅話,靜止耗電由 0.9W 提升到 1.8W。
Reference: