用 websocket 遙控 Wifi 模型車

我受到一個 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。

nippleJS web UI for wifi car

因為手机有 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 進行溝通;我用ArduinoJsonJSON 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。

1:10 HSP 94123 chassis modification & wifi control

我用 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: