├── image.png ├── webapp ├── images │ ├── ir.png │ ├── line.png │ ├── light.png │ ├── motor.png │ ├── remote.png │ ├── servo.png │ └── ultrasonic.png ├── css │ ├── tabs.css │ └── page.css ├── index.html └── js │ ├── tabs.js │ ├── bt.js │ ├── motors.js │ ├── sensors.js │ ├── beebot.js │ └── bluetoothTerminal.js ├── README.md ├── arduino └── lafvin2wd │ ├── menu_beebot.h │ ├── menu_motortest.h │ ├── lafvin2wd.ino │ ├── menu_sensortest.h │ ├── const.h │ ├── serial.h │ ├── lafvin2wd.h │ └── lafvin2wd.cpp └── LICENSE /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/image.png -------------------------------------------------------------------------------- /webapp/images/ir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/ir.png -------------------------------------------------------------------------------- /webapp/images/line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/line.png -------------------------------------------------------------------------------- /webapp/images/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/light.png -------------------------------------------------------------------------------- /webapp/images/motor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/motor.png -------------------------------------------------------------------------------- /webapp/images/remote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/remote.png -------------------------------------------------------------------------------- /webapp/images/servo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/servo.png -------------------------------------------------------------------------------- /webapp/images/ultrasonic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adrianotiger/lafvin-2wd-web/main/webapp/images/ultrasonic.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lafvin-2wd-web 2 | Drive the Lafvin robot over a webpage 3 | 4 | ## Install 5 | Download the Arduino code to the Lafvin robot: 6 | 7 | 8 | ## How to play 9 | Open the [webapp](https://adrianotiger.github.io/lafvin-2wd-web/webapp/) and connect the Lafvin robot with over the browser. 10 | 11 | ### Credits 12 | Web Bluetooth (Danila Loginov) 13 | https://github.com/loginov-rocks/Web-Bluetooth-Terminal/ 14 | 15 | Lafvin - Robots 16 | https://lafvintech.com/ 17 | -------------------------------------------------------------------------------- /webapp/css/tabs.css: -------------------------------------------------------------------------------- 1 | .b_val 2 | { 3 | font-size: 4vmin; 4 | } 5 | 6 | .b_val b 7 | { 8 | display: block; 9 | } 10 | 11 | .beebot_button 12 | { 13 | width:64px; 14 | height:64px; 15 | max-width: 8vw; 16 | max-height: 8vw; 17 | vertical-align:top; 18 | margin:2px; 19 | font-size: calc(min(32px, 4vw)); 20 | line-height: calc(min(32px, 4vw)); 21 | padding: 1vw; 22 | } 23 | 24 | .beebot_button:hover 25 | { 26 | background: white; 27 | } 28 | 29 | .beebot_reset 30 | { 31 | float:left; 32 | background:linear-gradient(to bottom, #fa6, #f73); 33 | margin-left:5vw; 34 | } 35 | 36 | .beebot_start 37 | { 38 | float: right; 39 | background:linear-gradient(to bottom,#beb,#7b7); 40 | margin-right:5vw; 41 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/menu_beebot.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | /* 5 | * Message sent: 6 | * Byte Description 7 | * 1 '>' for execute 8 | * 2 [F]orward, [B]ackwards, [L]eft, [R]ight 9 | * 3,4 for F and B, the quantity in cm 10 | * for L and R, the quantity of 45° 11 | */ 12 | 13 | void loop_beebot() 14 | { 15 | struct TMsg* pmsg = ser.getNextPacket(true); 16 | if(pmsg->type == '>') 17 | { 18 | Serial.println("Sending program code"); 19 | uint16_t duration = ser.parseValue(&pmsg->value[0], 4); 20 | uint8_t speed = ser.parseValue(&pmsg->extra[0], 2); 21 | 22 | switch(pmsg->id) 23 | { 24 | case 'F': robo.move(speed, true); break; 25 | case 'B': robo.move(speed, false); break; 26 | case 'L': robo.rotate(speed, true); break; 27 | case 'R': robo.rotate(speed, false); break; 28 | } 29 | delay(duration); 30 | robo.stop(); 31 | } 32 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/menu_motortest.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | /* 5 | * Message sent: 6 | * Byte Description 7 | * 1 '@' for motor 8 | * 2 [L]eft motor, [R]ight motor, [S]ervo 9 | * 3 high byte of the value 10 | * 4 low byte of the value 11 | */ 12 | 13 | void loop_motortest() 14 | { 15 | struct TMsg* pmsg = ser.getNextPacket(true); 16 | if(pmsg->type == '@') 17 | { 18 | Serial.println("GOT A MOTOR MESSAGE!"); 19 | uint16_t speed = ser.parseValue(&pmsg->value[0], 2); 20 | 21 | bool isMotor = false; 22 | bool isForward = true; 23 | bool isLeft = false; 24 | if(pmsg->id == 'L' || pmsg->id == 'R') 25 | { 26 | isForward = true; 27 | isMotor = true; 28 | isLeft = pmsg->id == 'L'; 29 | } 30 | else if(pmsg->id == 'l' || pmsg->id == 'r') 31 | { 32 | isForward = false; 33 | isMotor = true; 34 | isLeft = pmsg->id == 'l'; 35 | } 36 | if(isMotor) 37 | { 38 | robo.moveMotor(isLeft, speed, isForward); 39 | delay(2000); 40 | robo.moveMotor(isLeft, 0, true); 41 | } 42 | else 43 | { 44 | robo.moveServo(speed); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/lafvin2wd.ino: -------------------------------------------------------------------------------- 1 | 2 | #include "lafvin2wd.h" 3 | #include "serial.h" 4 | 5 | extern Lafvin2wd robo = Lafvin2wd(); 6 | extern SerialMsg ser = SerialMsg(); 7 | int8_t menu = MENU_MAIN; 8 | 9 | #include "menu_sensortest.h" 10 | #include "menu_motortest.h" 11 | #include "menu_beebot.h" 12 | 13 | void setup() 14 | { 15 | Serial.begin(9600); 16 | 17 | robo.setup(); 18 | } 19 | 20 | void loop_main(); 21 | 22 | void loop() 23 | { 24 | ser.parseSerial(); 25 | 26 | if(ser.hasMenuPacket()) loop_main(); 27 | 28 | switch(menu) 29 | { 30 | case MENU_MAIN: 31 | break; 32 | case MENU_SENSORTEST: 33 | loop_sensortest(); 34 | break; 35 | case MENU_MOTORTEST: 36 | loop_motortest(); 37 | break; 38 | case MENU_BEEBOT: 39 | loop_beebot(); 40 | break; 41 | } 42 | } 43 | 44 | void loop_main() 45 | { 46 | struct TMsg* pmsg = ser.getNextPacket(true); 47 | switch(pmsg->id) 48 | { 49 | case 'S': menu = MENU_SENSORTEST; Serial.println("ENTER SENSOR TEST"); break; 50 | case 'M': menu = MENU_MOTORTEST; Serial.println("ENTER MOTOR TEST"); break; 51 | case 'B': menu = MENU_BEEBOT; Serial.println("ENTER BEE BOT PROGRAMMING"); break; 52 | default: break; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 17 |
Connect device... 15 | 16 |
18 | 19 |

20 | Press "Find BT device", allow the device/laptop to access the BT and search for JDY-16. 21 |

22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /webapp/js/tabs.js: -------------------------------------------------------------------------------- 1 | let globalTabs = []; 2 | let activeTab = null; 3 | 4 | class Tab 5 | { 6 | #tab = null; 7 | #div = null; 8 | #name = "invalid"; 9 | 10 | constructor(name) 11 | { 12 | this.#name = name; 13 | 14 | this.#tab = _CN("div", {class:"controlTab", style:`left:${1+globalTabs.length * 30}vw`}, [_CN("table", {}, [_CN("tr", {}, [_CN("td", {}, [name])])])], document.body); 15 | this.#div = _CN("div", {class:"controlDiv"}, [], document.body); 16 | 17 | _CN("h2", {}, [name], this.#div); 18 | _CN("hr", {}, [], this.#div); 19 | 20 | this.#tab.addEventListener("click", ()=>{ 21 | this.show(); 22 | }); 23 | 24 | globalTabs.push(this); 25 | } 26 | 27 | getDiv() 28 | { 29 | return this.#div; 30 | } 31 | 32 | async show() 33 | { 34 | globalTabs.forEach(gt=>{ 35 | if(gt !== this) 36 | gt.hide(); 37 | }); 38 | 39 | activeTab = this; 40 | this.#div.style.height = "86%"; 41 | this.#div.style.zIndex = 205; 42 | this.#div.style.opacity = 1.0; 43 | console.log("Start " + this.#name + "..."); 44 | try { 45 | // Send a message to enter right menu. 46 | await bt.send("#" + this.#name[0]); 47 | } catch (error) { 48 | console.log(error, 'error'); 49 | } 50 | } 51 | 52 | async hide() 53 | { 54 | this.#div.style.height = "1vh"; 55 | this.#div.style.opacity = 0.1; 56 | setTimeout(()=>{ 57 | this.#div.style.zIndex = 200; 58 | }); 59 | } 60 | 61 | eval(msg) 62 | { 63 | if(msg[0] === '#') 64 | { 65 | if(msg[1] === 'S') // sensors 66 | { 67 | 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/menu_sensortest.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | 4 | bool readLeftSensor = true; 5 | 6 | /* 7 | * Message sent: 8 | * Byte Description 9 | * 1 '$' for sensor 10 | * 2 [R]emote, [D]istance IR, [L]ight intensity, [B]lack line, [U]ltrasound. [d],[l],[b] for left sensor 11 | * 3 '-' if message has only 1 byte or high byte of the value 12 | * 4 low byte of the value 13 | */ 14 | 15 | void loop_sensortest() 16 | { 17 | uint8_t sensorVal = 0; 18 | readLeftSensor = !readLeftSensor; 19 | robo.readAllSensors(); 20 | 21 | if(robo.hasRemote()) 22 | { 23 | Serial.print("$R-"); 24 | uint8_t charIndex = 0; 25 | uint8_t sensorVal = robo.getRemote(); 26 | for(;charIndex= sizeof(IR_MAP)) Serial.println("_"); 35 | } 36 | if(robo.hasIRDistance(readLeftSensor)) 37 | { 38 | sensorVal = robo.getIRDistance(readLeftSensor); 39 | Serial.print(readLeftSensor ? "$d-" : "$D-"); 40 | Serial.print(sensorVal); 41 | } 42 | if(robo.hasLight(readLeftSensor)) 43 | { 44 | sensorVal = robo.getLightIntensity(readLeftSensor); 45 | Serial.print(readLeftSensor ? "$l" : "$L"); 46 | ser.printValue8(sensorVal); 47 | } 48 | if(robo.hasLine(readLeftSensor)) 49 | { 50 | sensorVal = robo.getLine(readLeftSensor); 51 | Serial.print(readLeftSensor ? "$b-" : "$B-"); 52 | Serial.print(sensorVal); 53 | } 54 | if(robo.hasUltrasonic()) 55 | { 56 | sensorVal = robo.getUltrasonic(); 57 | Serial.print("$U"); 58 | ser.printValue8(sensorVal); 59 | } 60 | 61 | Serial.println(); 62 | delay(200); 63 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/const.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #define PIN_IR 3 4 | #define PIN_IR_DIST_LEFT A1 5 | #define PIN_IR_DIST_RIGHT A2 6 | #define PIN_LIGHT_LEFT A0 7 | #define PIN_LIGHT_RIGHT A3 8 | #define PIN_RESERVE_SDA A4 9 | #define PIN_RESERVE_SCL A5 10 | #define PIN_SDA 18 11 | #define PIN_SCL 19 12 | #define PIN_LINE_LEFT 7 13 | #define PIN_LINE_RIGHT 9 14 | #define PIN_ULTRASONIC_PULSE 12 15 | #define PIN_ULTRASONIC_IN 13 16 | #define PIN_MOTOR_LEFT_DIR 2 17 | #define PIN_MOTOR_LEFT_SPEED 5 18 | #define PIN_MOTOR_RIGHT_DIR 4 19 | #define PIN_MOTOR_RIGHT_SPEED 6 20 | #define PIN_SERVO 10 21 | 22 | #define PIN_FREE_CUSTOM_1 8 23 | #define PIN_FREE_CUSTOM_2 11 24 | 25 | /* RESERVED PINS 26 | * #define PIN_TX 1 ==> used from the BT module and from the USB serial 27 | * #define PIN_RX 0 ==> used from the BT module and from the USB serial 28 | * #define PIN_A0 14 ==> 29 | * #define PIN_A1 15 ==> 30 | * #define PIN_A2 16 ==> 31 | * #define PIN_A3 17 ==> 32 | * #define PIN_A4 18 ==> 33 | * #define PIN_A5 19 ==> 34 | */ 35 | 36 | #define IR_UP 0x46 37 | #define IR_LEFT 0x44 38 | #define IR_RIGHT 0x43 39 | #define IR_DOWN 0x15 40 | #define IR_OK 0x40 41 | const uint8_t IR_KEY[] = {0x52, 0x16, 0x19, 0x0D, 0x0C, 0x18, 0x5E, 0x08, 0x1C, 0x5A}; 42 | #define IR_STAR 0x42 43 | #define IR_HASH 0x4A 44 | const char IR_CHAR[] = {'L','U','R','D','O','H','S','0','1','2','3','4','5','6','7','8','9'}; 45 | const uint8_t IR_MAP[] = {IR_LEFT,IR_UP,IR_RIGHT,IR_DOWN,IR_OK,IR_HASH,IR_STAR,IR_KEY[0],IR_KEY[1],IR_KEY[2],IR_KEY[3],IR_KEY[4],IR_KEY[5],IR_KEY[6],IR_KEY[7],IR_KEY[8],IR_KEY[9]}; 46 | 47 | #define SERVO_MIN 0 48 | #define SERVO_MAX 180 49 | 50 | #define MENU_MAIN 0 51 | #define MENU_SENSORTEST 1 52 | #define MENU_MOTORTEST 2 53 | #define MENU_BEEBOT 3 54 | 55 | -------------------------------------------------------------------------------- /arduino/lafvin2wd/serial.h: -------------------------------------------------------------------------------- 1 | struct TMsg { 2 | uint8_t len; 3 | union 4 | { 5 | struct 6 | { 7 | char type; 8 | char id; 9 | uint8_t value[4]; 10 | uint8_t extra[2]; 11 | }; 12 | char ch[8]; 13 | }; 14 | }; 15 | 16 | class SerialMsg 17 | { 18 | private: 19 | struct TMsg _msg[16]; 20 | uint8_t _msgIndex; 21 | uint8_t _msgCount; 22 | char _temp; 23 | 24 | createPacket() 25 | { 26 | _msgIndex = (_msgIndex + 1) % 16; 27 | _msg[_msgIndex].len = 0; 28 | _msgCount++; 29 | if(_msgCount > 15) _msgCount = 15; 30 | } 31 | 32 | public: 33 | SerialMsg() 34 | { 35 | _msgCount = 0; 36 | _msgIndex = 0; 37 | 38 | _msg[0].len = 0; 39 | } 40 | 41 | parseSerial() 42 | { 43 | while(Serial.available() > 0) 44 | { 45 | _temp = Serial.read(); 46 | if(_msg[_msgIndex].len == 0) 47 | { 48 | if(_temp == '@' || _temp == '#' || _temp == '$' || _temp == '>') 49 | { 50 | _msg[_msgIndex].type = _temp; 51 | _msg[_msgIndex].len++; 52 | } 53 | } 54 | else 55 | { 56 | _msg[_msgIndex].ch[_msg[_msgIndex].len] = _temp; 57 | _msg[_msgIndex].len++; 58 | if(_msg[_msgIndex].type == '#' && _msg[_msgIndex].len >= 2) createPacket(); // menu 59 | else if(_msg[_msgIndex].type == '$' && _msg[_msgIndex].len >= 2) createPacket(); // sensor request 60 | else if(_msg[_msgIndex].type == '@' && _msg[_msgIndex].len >= 4) createPacket(); // motor 61 | else if(_msg[_msgIndex].type == '>' && _msg[_msgIndex].len == 8) createPacket(); // motor 62 | } 63 | } 64 | } 65 | 66 | bool hasPackets() 67 | { 68 | return _msgCount > 0; 69 | } 70 | 71 | bool hasMenuPacket() 72 | { 73 | return hasPackets() && getNextPacket(false)->type == '#'; 74 | } 75 | 76 | struct TMsg* getNextPacket(bool remove) 77 | { 78 | if(_msgCount == 0) return NULL; 79 | 80 | uint8_t index = (_msgIndex + 16 - _msgCount) % 16; 81 | if(remove) _msgCount--; 82 | 83 | return &_msg[index]; 84 | } 85 | 86 | uint16_t parseValue(char* val, uint8_t bytes) 87 | { 88 | uint16_t ret = 0; 89 | for(uint8_t j=0;j{o.appendChild("string"==typeof e||"number"==typeof e?document.createTextNode(e):e)}),null!==n&&n.appendChild(o),o} 2 | 3 | let bt = null; 4 | let btMsg = []; 5 | 6 | function findBT(butt) 7 | { 8 | butt.style.display = "hidden"; 9 | 10 | if(!bt) 11 | { 12 | bt = new BluetoothTerminal({ 13 | // serviceUuid: 0xFFE0, 14 | // characteristicUuid: 0xFFE1, 15 | // characteristicValueSize: 20, 16 | // receiveSeparator: '\n', 17 | // sendSeparator: '\n', 18 | // logLevel: 'log', 19 | }); 20 | 21 | // Set a callback that will be called when an incoming message from the 22 | // connected device is received. 23 | bt.onReceive((message) => { 24 | console.info(`Message received: "${message}"`); 25 | while(message.length > 0) 26 | { 27 | btMsg.push(message[0]); 28 | message = message.slice(1); 29 | } 30 | while(btMsg.length >= 4) 31 | { 32 | parseBTData(); 33 | } 34 | }); 35 | 36 | // Open the browser Bluetooth device picker to select a device if none was 37 | // previously selected, establish a connection with the selected device, and 38 | // initiate communication. 39 | bt.connect().then(() => { 40 | // Retrieve the name of the currently connected device. 41 | console.info(`Device "${bt.getDeviceName()}" successfully connected`); 42 | document.getElementById("connectionDiv").getElementsByTagName("th")[0].textContent = bt.getDeviceName(); 43 | 44 | document.getElementById("connectionDiv").style.height = "25px"; 45 | 46 | [...document.getElementsByClassName("controlDiv")].forEach(ct=>{ct.style.height = "80vh"}); 47 | 48 | globalTabs[0].show(); 49 | }); 50 | 51 | bt.onDisconnect(() => { 52 | document.getElementById("connectionDiv").style.height = ""; 53 | butt.style.display = ""; 54 | document.getElementById("connectionDiv").getElementsByTagName("th")[0].textContent = "Disconnected"; 55 | [...document.getElementsByTagName("controlDiv")].forEach(ct=>{ct.style.height = "2vh"}); 56 | }); 57 | } 58 | 59 | console.log(bt); 60 | } 61 | 62 | function parseBTData() 63 | { 64 | switch(btMsg[0]) 65 | { 66 | case '$': // sensor value $XYY [X=Sensor, Y=Value(hex)] 67 | { 68 | if(btMsg.length >= 4) 69 | { 70 | activeTab.eval(btMsg.splice(0, 4).join("")); 71 | } 72 | break; 73 | } 74 | case '#': // menu #X [X=Menu], stop the current loop 75 | { 76 | if(btMsg.length >= 2) 77 | { 78 | activeTab.eval(btMsg.splice(0, 2).join("")); 79 | } 80 | break; 81 | } 82 | default: // unable to find a valid command, remove this char 83 | { 84 | btMsg.splice(0, 1); 85 | break; 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /webapp/js/motors.js: -------------------------------------------------------------------------------- 1 | const motorTest = new class extends Tab 2 | { 3 | #trs = []; 4 | #inps = []; 5 | constructor() 6 | { 7 | super("Motors Test"); 8 | 9 | super.getDiv().style.height = "1vh"; 10 | 11 | let table = _CN("table", {}, [], super.getDiv()); 12 | _CN("tr", {}, [ 13 | _CN("th", {}, ["Device"]), 14 | _CN("th", {style:"width:40vw;"}, ["Control"]), 15 | _CN("th", {}, ["Info"]), 16 | ], table); 17 | 18 | this.#trs['l'] = _CN("tr", {}, [ 19 | _CN("td", {}, [_CN("img",{src:"images/motor.png"}), _CN("br"), "Left motor"]), 20 | _CN("td", {}, [_CN("input", {type:"range",min:-100,max:100,value:0,step:1,style:"width:80%;"}),_CN("br"),_CN("b",{},["Power: 0%"])]), 21 | _CN("td", {}, ["The left gear motor can be powered over a PWM signal."]), 22 | ], table); 23 | 24 | this.#trs['r'] = _CN("tr", {}, [ 25 | _CN("td", {}, [_CN("img",{src:"images/motor.png"}), _CN("br"), "Right motor"]), 26 | _CN("td", {}, [_CN("input", {type:"range",min:-100,max:100,value:0,step:1,style:"width:80%;"}),_CN("br"),_CN("b",{},["Power: 0%"])]), 27 | _CN("td", {}, ["The right gear motor can be powered over a PWM signal."]), 28 | ], table); 29 | 30 | this.#trs['s'] = _CN("tr", {}, [ 31 | _CN("td", {}, [_CN("img",{src:"images/servo.png"}), _CN("br"), "Ultrasonic Servo"]), 32 | _CN("td", {}, [_CN("input", {type:"range",min:0,max:180,value:90,step:1,style:"width:80%;"}),_CN("br"),_CN("b",{},["Angle: 90°"])]), 33 | _CN("td", {}, ["The servo can move 90° to the left or right."]), 34 | ], table); 35 | 36 | const motors = [ 37 | {text:"Power: *%", factor:1.25, key:'@L'}, 38 | {text:"Power: *%", factor:1.25, key:'@R'}, 39 | {text:"Angle: *°", factor:1, key:'@S'} 40 | ]; 41 | Object.keys(this.#trs).forEach((trk, index)=>{ 42 | const inp = this.#trs[trk].getElementsByTagName("input")[0]; 43 | this.#inps.push(inp); 44 | 45 | inp.addEventListener("change", ()=>{ 46 | this.#trs[trk].getElementsByTagName("b")[0].textContent = motors[index].text.replace("*", inp.value); 47 | console.log("Changed"); 48 | if(inp.value < 0) 49 | { 50 | this.sendValue(motors[index].key.toLowerCase(), -inp.value * motors[index].factor); 51 | } 52 | else 53 | { 54 | this.sendValue(motors[index].key, inp.value * motors[index].factor); 55 | } 56 | if(inp.value !== 0 && index < 2) 57 | { 58 | setTimeout(()=>{inp.value = 0;},2000); 59 | } 60 | }); 61 | inp.addEventListener("input", ()=>{ 62 | this.#trs[trk].getElementsByTagName("b")[0].textContent = motors[index].text.replace("*", inp.value); 63 | }); 64 | }); 65 | } 66 | 67 | sendValue(motor, val) 68 | { 69 | let v = parseInt(val).toString(16); 70 | if(v.length < 2) v = '0' + v; 71 | else if(v.length > 2) v = v.substring(0,2); 72 | bt.send(motor + v); 73 | } 74 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/lafvin2wd.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include "const.h" 3 | 4 | #include 5 | 6 | class Sensor 7 | { 8 | private: 9 | uint16_t _value; 10 | bool _changed; 11 | bool _analog; 12 | bool _analogLow; 13 | uint8_t _pin; 14 | public: 15 | Sensor() {} 16 | void setup(uint8_t pin, bool isAnalog) { pinMode(pin, INPUT); _pin=pin; _analog=isAnalog; _value=0; } 17 | void measure() { _analogLow = false; if(_analog) setValue(analogRead(_pin)); else setValue(digitalRead(_pin)); } 18 | void measureLowRes() { _analogLow = true; if(_analog) setValue(analogRead(_pin) / 16); else setValue(digitalRead(_pin)); } 19 | uint16_t getmaxAnalogValue() { return _analogLow ? 512/16 : 512; } 20 | void setValue(uint16_t val) { if(val != _value) {_value=val; _changed=true;} } 21 | bool isDetected() { return _changed; } 22 | uint16_t getValue() { _changed = false; return _value; } 23 | void resetValue() { _value = 0; } 24 | }; 25 | 26 | class Motor 27 | { 28 | private: 29 | uint8_t _speed; 30 | int8_t _dir; 31 | bool _invert; 32 | uint8_t _pinDir; 33 | uint8_t _pinSpeed; 34 | public: 35 | Motor() { } 36 | void setup(uint8_t pinDir, uint8_t pinSpeed, bool invert=false) 37 | { 38 | _speed=0; 39 | _invert = invert; 40 | _pinDir = pinDir; 41 | _pinSpeed = pinSpeed; 42 | pinMode(pinDir, OUTPUT); 43 | pinMode(pinSpeed, OUTPUT); 44 | } 45 | 46 | void move(uint8_t speed, bool forward) 47 | { 48 | _speed = speed; 49 | if(forward) 50 | { 51 | _dir = 1; 52 | digitalWrite(_pinDir, _invert ? LOW : HIGH); 53 | } 54 | else 55 | { 56 | _dir = -1; 57 | digitalWrite(_pinDir, _invert ? HIGH : LOW); 58 | } 59 | analogWrite(_pinSpeed, speed); 60 | } 61 | }; 62 | 63 | class Lafvin2wd 64 | { 65 | private: 66 | Sensor remote; 67 | Sensor irDist[2]; 68 | Sensor light[2]; 69 | Sensor lineTrack[2]; 70 | Sensor ultrasound; 71 | Motor motor[2]; 72 | Servo servo; 73 | public: 74 | Lafvin2wd(); 75 | 76 | void setup(); 77 | void readAllSensors(); 78 | void readRemote(); 79 | void readIRDist(); 80 | void readLight(); 81 | void readLine(); 82 | void readUltrasonic(); 83 | 84 | bool hasRemote(); 85 | uint8_t getRemote(); 86 | 87 | bool hasIRDistance(bool left); 88 | uint8_t getIRDistance(bool left); 89 | 90 | bool hasLight(bool left); 91 | uint8_t getLightIntensity(bool left); 92 | 93 | bool hasLine(bool left); 94 | uint8_t getLine(bool left); 95 | 96 | bool hasUltrasonic(); 97 | uint8_t getUltrasonic(); 98 | 99 | void moveMotor(bool leftMotor, uint8_t speed, bool forward); 100 | void moveServo(int16_t angle); 101 | 102 | void move(uint8_t speed, bool forward); 103 | void stop(); 104 | void rotate(uint8_t speed, bool left); 105 | }; 106 | -------------------------------------------------------------------------------- /webapp/css/page.css: -------------------------------------------------------------------------------- 1 | html 2 | { 3 | position: fixed; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | body 9 | { 10 | 11 | width: 100%; 12 | height: 100%; 13 | margin: 0px; 14 | background: linear-gradient(to bottom, #ffe, #fec); 15 | margin: 0 auto; 16 | text-align: center; 17 | } 18 | 19 | button 20 | { 21 | font-size: 120%; 22 | border-radius: 1vmin; 23 | margin: 0 auto; 24 | padding: 15px; 25 | cursor: pointer; 26 | margin-top: 10px; 27 | border-style: none; 28 | box-shadow: inset 2px 2px 0.1vmin #ddf, inset -2px -2px 0.1vmin #669, 0px 0px 1px 1px #000, 0px 0px 1px 2px #fff; 29 | background: linear-gradient(to bottom, #dcf, #98e); 30 | } 31 | 32 | #connectionDiv 33 | { 34 | position: absolute; 35 | left: 1%; 36 | width: 98%; 37 | top: 1%; 38 | height: 30vh; 39 | border-radius: 1vmin; 40 | margin: 0 auto; 41 | 42 | box-shadow: inset 2px 2px 0.1vmin #ddf, inset -2px -2px 0.1vmin #669, 0px 0px 1px 0px #000; 43 | background: linear-gradient(to bottom, #ddd, #aaa); 44 | transition: all 0.5s ease-in-out; 45 | overflow: hidden; 46 | } 47 | 48 | #connectionDiv table 49 | { 50 | height: 15px; 51 | line-height: 15px; 52 | font-size: 20px; 53 | width: 99%; 54 | background: linear-gradient(to right, transparent 0%, #ddf 5%, #ddf 95%, transparent 100%); 55 | margin:2px auto; 56 | padding: 0px 100px; 57 | color: blue; 58 | } 59 | 60 | #connectionDiv svg 61 | { 62 | float: right; 63 | } 64 | 65 | .controlDiv 66 | { 67 | position: absolute; 68 | width: 98%; 69 | height: 2vh; 70 | left: 1%; 71 | bottom: 10vh; 72 | border-radius: 1vmin; 73 | box-shadow: inset 2px 2px 0.1vmin #ddf, inset -2px -2px 0.1vmin #669, 0px 0px 1px 0px #000; 74 | background: linear-gradient(to bottom, #ffd, #ffa); 75 | transition: all 0.5s ease-in-out; 76 | overflow-y: hidden; 77 | z-index: 200; 78 | } 79 | 80 | .controlDiv img 81 | { 82 | max-height: 5vh; 83 | } 84 | 85 | .controlDiv table 86 | { 87 | width: 98%; 88 | height: 70vh; 89 | text-align: center; 90 | } 91 | 92 | .controlDiv table tr 93 | { 94 | transition: all 0.3s linear; 95 | } 96 | .controlDiv table td 97 | { 98 | max-height: 5vh; 99 | overflow: hidden; 100 | font-size: 2vh; 101 | } 102 | 103 | .controlTab 104 | { 105 | position: absolute; 106 | width: 32vw; 107 | height: 10vh; 108 | left: 2%; 109 | bottom: 1vh; 110 | border-radius: 1vmin; 111 | box-shadow: inset 2px 2px 0.1vmin #ddf, inset -2px -2px 0.1vmin #669, 0px 0px 1px 0px #000; 112 | background: linear-gradient(to bottom, #dfd, #afa); 113 | transition: all 0.2s ease-in-out; 114 | cursor: pointer; 115 | overflow-y: auto; 116 | z-index: 100; 117 | } 118 | 119 | .controlTab:hover 120 | { 121 | background: white; 122 | text-shadow: 1px 1px 2px #000; 123 | z-index:110; 124 | } 125 | 126 | .controlTab table 127 | { 128 | margin:0 auto; 129 | margin-top:3vh; 130 | height: 6vh; 131 | width: 80%; 132 | } 133 | 134 | .controlTab table td 135 | { 136 | font-size: 2.5vh; 137 | padding: 0px 1vw; 138 | vertical-align: bottom; 139 | line-height: 3vh; 140 | font-weight: bolder; 141 | text-align:center; 142 | } -------------------------------------------------------------------------------- /arduino/lafvin2wd/lafvin2wd.cpp: -------------------------------------------------------------------------------- 1 | #include "lafvin2wd.h" 2 | #define DECODE_NEC 3 | #define USE_TIMER2 4 | #include 5 | 6 | Lafvin2wd::Lafvin2wd() 7 | { 8 | remote.setup(PIN_IR, false); 9 | irDist[0].setup(PIN_IR_DIST_LEFT, false); 10 | irDist[1].setup(PIN_IR_DIST_RIGHT, false); 11 | light[0].setup(PIN_LIGHT_LEFT, true); 12 | light[1].setup(PIN_LIGHT_RIGHT, true); 13 | lineTrack[0].setup(PIN_LINE_LEFT, false); 14 | lineTrack[1].setup(PIN_LINE_RIGHT, false); 15 | ultrasound.setup(PIN_ULTRASONIC_IN, false); 16 | 17 | pinMode(PIN_ULTRASONIC_PULSE, OUTPUT); 18 | 19 | motor[0].setup(PIN_MOTOR_LEFT_DIR, PIN_MOTOR_LEFT_SPEED, false); 20 | motor[1].setup(PIN_MOTOR_RIGHT_DIR, PIN_MOTOR_RIGHT_SPEED, true); 21 | } 22 | 23 | void Lafvin2wd::setup() 24 | { 25 | IrReceiver.begin(PIN_IR); 26 | 27 | servo.attach(PIN_SERVO); 28 | moveMotor(true, 0, true); 29 | moveMotor(false, 0, true); 30 | moveServo(90); 31 | } 32 | 33 | void Lafvin2wd::readRemote() 34 | { 35 | if (IrReceiver.decode()) 36 | { 37 | remote.setValue(IrReceiver.decodedIRData.command); 38 | //Serial.print("Received: 0x"); Serial.println(IrReceiver.decodedIRData.command, HEX); 39 | IrReceiver.resume(); 40 | } 41 | } 42 | 43 | void Lafvin2wd::readIRDist() 44 | { 45 | irDist[0].measure(); 46 | irDist[1].measure(); 47 | } 48 | 49 | void Lafvin2wd::readLight() 50 | { 51 | light[0].measureLowRes(); 52 | light[1].measureLowRes(); 53 | } 54 | 55 | void Lafvin2wd::readLine() 56 | { 57 | lineTrack[0].measure(); 58 | lineTrack[1].measure(); 59 | } 60 | 61 | void Lafvin2wd::readUltrasonic() 62 | { 63 | digitalWrite(PIN_ULTRASONIC_PULSE, LOW); 64 | delayMicroseconds(2); 65 | digitalWrite(PIN_ULTRASONIC_PULSE, HIGH); 66 | delayMicroseconds(10); 67 | digitalWrite(PIN_ULTRASONIC_PULSE, LOW); 68 | float distance = pulseIn(PIN_ULTRASONIC_IN, HIGH) / 58.00; 69 | if(distance > 250) distance = 250; 70 | ultrasound.setValue((uint8_t)distance); 71 | } 72 | 73 | void Lafvin2wd::readAllSensors() 74 | { 75 | readRemote(); 76 | readIRDist(); 77 | readLight(); 78 | readLine(); 79 | readUltrasonic(); 80 | } 81 | 82 | bool Lafvin2wd::hasRemote() 83 | { 84 | return remote.isDetected(); 85 | } 86 | 87 | uint8_t Lafvin2wd::getRemote() 88 | { 89 | uint8_t ret = remote.getValue(); 90 | remote.resetValue(); 91 | return ret; 92 | } 93 | 94 | bool Lafvin2wd::hasIRDistance(bool left) 95 | { 96 | return irDist[left?0:1].isDetected(); 97 | } 98 | 99 | uint8_t Lafvin2wd::getIRDistance(bool left) 100 | { 101 | return irDist[left?0:1].getValue()==LOW?1:0; // inverted 1=detected 102 | } 103 | 104 | bool Lafvin2wd::hasLight(bool left) 105 | { 106 | return light[left?0:1].isDetected(); 107 | } 108 | 109 | uint8_t Lafvin2wd::getLightIntensity(bool left) 110 | { 111 | uint16_t val = light[left?0:1].getValue(); 112 | uint16_t maxv = light[left?0:1].getmaxAnalogValue(); 113 | if(val > maxv) val = maxv; 114 | return (maxv - val) * 100 / maxv; 115 | } 116 | 117 | bool Lafvin2wd::hasLine(bool left) 118 | { 119 | return lineTrack[left?0:1].isDetected(); 120 | } 121 | 122 | uint8_t Lafvin2wd::getLine(bool left) 123 | { 124 | return lineTrack[left?0:1].getValue()==LOW?0:1; // inverted 1=detected 125 | } 126 | 127 | bool Lafvin2wd::hasUltrasonic() 128 | { 129 | return ultrasound.isDetected(); 130 | } 131 | 132 | uint8_t Lafvin2wd::getUltrasonic() 133 | { 134 | return ultrasound.getValue(); 135 | } 136 | 137 | void Lafvin2wd::moveMotor(bool leftMotor, uint8_t speed, bool forward) 138 | { 139 | if(leftMotor) 140 | { 141 | motor[0].move(speed, forward); 142 | } 143 | else 144 | { 145 | motor[1].move(speed, forward); 146 | } 147 | } 148 | 149 | void Lafvin2wd::move(uint8_t speed, bool forward) 150 | { 151 | motor[0].move(speed, forward); 152 | motor[1].move(speed, forward); 153 | } 154 | 155 | void Lafvin2wd::stop() 156 | { 157 | motor[0].move(0, true); 158 | motor[1].move(0, true); 159 | } 160 | 161 | void Lafvin2wd::rotate(uint8_t speed, bool left) 162 | { 163 | motor[0].move(speed, !left); 164 | motor[1].move(speed, left); 165 | } 166 | 167 | void Lafvin2wd::moveServo(int16_t angle) 168 | { 169 | servo.write(angle); 170 | } 171 | 172 | 173 | -------------------------------------------------------------------------------- /webapp/js/sensors.js: -------------------------------------------------------------------------------- 1 | const sensorTest = new class extends Tab 2 | { 3 | #trs = []; 4 | constructor() 5 | { 6 | super("Sensors Test"); 7 | 8 | super.getDiv().style.height = "1vh"; 9 | 10 | let table = _CN("table", {}, [], super.getDiv()); 11 | _CN("tr", {}, [ 12 | _CN("th", {}, ["Sensor"]), 13 | _CN("th", {style:"width:15vw;"}, ["Left"]), 14 | _CN("th", {style:"width:15vw;"}, ["Right"]), 15 | _CN("th", {}, ["Info"]), 16 | ], table); 17 | this.#trs['u'] = _CN("tr", {}, [ 18 | _CN("td", {}, [_CN("img",{src:"images/ultrasonic.png"}), _CN("br"), "Ultrasonic Distance"]), 19 | _CN("td", {colspan:2,class:"b_val"}, ["∞ cm"]), 20 | _CN("td", {}, ["The HC-SR0 sensor is able to measure the distance from 2cm up to 250cm (analog)."]), 21 | ], table); 22 | this.#trs['d'] = _CN("tr", {}, [ 23 | _CN("td", {}, [_CN("img",{src:"images/ir.png"}), _CN("br"), "Infrared Distance"]), 24 | _CN("td", {class:"b_val"}, ["🔴"]), 25 | _CN("td", {class:"b_val"}, ["🔴"]), //🟢 26 | _CN("td", {}, ["The IR-08H is an infrared sensor able to detect a reflection from 1 to 20cm set by the potentiometer (digital)."]), 27 | ], table); 28 | this.#trs['b'] = _CN("tr", {}, [ 29 | _CN("td", {}, [_CN("img",{src:"images/line.png"}), _CN("br"), "Infrared Detector"]), 30 | _CN("td", {class:"b_val"}, ["⚪"]), 31 | _CN("td", {class:"b_val"}, ["⚪"]), /* ⚫ */ 32 | _CN("td", {}, ["The KY-033 will just report if it detects a black surface (digital)."]), 33 | ], table); 34 | this.#trs['l'] = _CN("tr", {}, [ 35 | _CN("td", {}, [_CN("img",{src:"images/light.png"}), _CN("br"), "Light"]), 36 | _CN("td", {class:"b_val"}, ["🌑", _CN("b",{style:"font-size:70%;"})]), 37 | _CN("td", {class:"b_val"}, ["🌑", _CN("b",{style:"font-size:70%;"})]), /* 🌒 🌓 🌔 🌕 */ 38 | _CN("td", {}, ["Simple LDR, returning the light itensity (analog)"]), 39 | ], table); 40 | this.#trs['r'] = _CN("tr", {}, [ 41 | _CN("td", {}, [_CN("img",{src:"images/remote.png"}), _CN("br"), "Remote"]), 42 | _CN("td", {colspan:2,class:"b_val"}, ["🔟 ", _CN("b")]), 43 | _CN("td", {}, ["A value, returned from the remote."]), 44 | ], table); 45 | 46 | } 47 | 48 | #colorize(tr) 49 | { 50 | tr.style.backgroundColor = "#8f8"; 51 | setTimeout(()=>{ 52 | tr.style.backgroundColor = ""; 53 | }, 800); 54 | } 55 | 56 | eval(msg) 57 | { 58 | super.eval(msg); 59 | 60 | //Expect: $XYY 61 | if(msg[0] !=='$') return; 62 | 63 | switch(msg[1]) 64 | { 65 | case 'R': // remote - $R0X 66 | { 67 | const s = {'U':"UP", 'D':"DOWN", 'L':"LEFT", 'R':"RIGHT", 'O':"OK", 'S':"STAR", 'H':"HASH", '0':"0", '1':"1", '2':"2", '3':"3", '4':"4", '5':"5", '6':"6", '7':"7", '8':"8", '9':"9", '_':"INVALID"} 68 | this.#colorize(this.#trs['r']); 69 | this.#trs['r'].getElementsByTagName("b")[0].textContent = s[msg[3]]; 70 | break; 71 | } 72 | case 'd': // ir distance left - $d0X 73 | case 'D': // ir distance right - $D0X 74 | { 75 | this.#colorize(this.#trs['d']); 76 | const td = this.#trs['d'].getElementsByTagName("td")[msg[1]==='d'?1:2]; 77 | switch(msg[3]) 78 | { 79 | case '0': td.textContent = '🔴'; break; 80 | case '1': td.textContent = '🟢'; break; 81 | default: td.textContent = '🤔'; break; 82 | } 83 | 84 | break; 85 | } 86 | case 'l': // light left - $lXX 87 | case 'L': // light right - $LXX 🌑 🌒 🌓 🌔 🌕 88 | { 89 | this.#colorize(this.#trs['l']); 90 | const td = this.#trs['l'].getElementsByTagName("td")[msg[1]==='l'?1:2]; 91 | const b = this.#trs['l'].getElementsByTagName("b")[msg[1]==='l'?0:1]; 92 | const light = Number.parseInt(msg.substring(2, 4), 16); 93 | td.childNodes[0].textContent = ["🌑","🌒","🌓","🌔","🌕"][parseInt(light / 21)]; 94 | b.textContent = light; 95 | 96 | break; 97 | } 98 | case 'b': // black line left - $b0X 99 | case 'B': // black line right - $B0X 100 | { 101 | this.#colorize(this.#trs['b']); 102 | const td = this.#trs['b'].getElementsByTagName("td")[msg[1]==='b'?1:2]; 103 | switch(msg[3]) 104 | { 105 | case '0': td.textContent = '⚪'; break; 106 | case '1': td.textContent = '⚫'; break; 107 | default: td.textContent = '🤔'; break; 108 | } 109 | 110 | break; 111 | } 112 | case 'U': // Ultrasonic distance - $UXX 113 | { 114 | this.#colorize(this.#trs['u']); 115 | const td = this.#trs['u'].getElementsByTagName("td")[1]; 116 | const dist = Number.parseInt(msg.substring(2, 4), 16); 117 | td.textContent = (dist > 250 ? "∞ cm" : dist + " cm"); 118 | break; 119 | } 120 | } 121 | } 122 | }; -------------------------------------------------------------------------------- /webapp/js/beebot.js: -------------------------------------------------------------------------------- 1 | const beeBot = new class extends Tab 2 | { 3 | #cmd = []; 4 | #cmddiv = null; 5 | #isExecuting = false; 6 | #forwardTime = null; 7 | #rotateTime = null; 8 | #speed = null; 9 | #playDiv = null; 10 | 11 | #commands = { 12 | up:{symbol:"⬆",cmd:"F"}, /* 0x0800ms, speed:0x40 */ 13 | down:{symbol:"⬇",cmd:"B"}, /* 0x0800ms, speed:0x40 */ 14 | left:{symbol:"↺",cmd:"L"}, /* 0x0100ms, speed:0x40 */ 15 | right:{symbol:"↻",cmd:"R"} /* 0x0100ms, speed:0x40 */ 16 | }; 17 | 18 | constructor() 19 | { 20 | super("BeeBot emulation"); 21 | 22 | const div = super.getDiv(); 23 | div.style.height = "1vh"; 24 | 25 | this.#cmd = []; 26 | 27 | _CN("button", {class:"beebot_button beebot_reset"}, ["🗘"], div).addEventListener("click",()=>{ 28 | this.#cmddiv.innerHTML = ""; 29 | this.#cmd = []; 30 | }); 31 | 32 | _CN("button", {class:"beebot_button"}, ["↺"], div).addEventListener("click",()=>{this.addCommand({...this.#commands.left})}); 33 | _CN("button", {class:"beebot_button"}, ["⬆"], div).addEventListener("click",()=>{this.addCommand({...this.#commands.up})}); 34 | _CN("button", {class:"beebot_button"}, ["⬇"], div).addEventListener("click",()=>{this.addCommand({...this.#commands.down})}); 35 | _CN("button", {class:"beebot_button"}, ["↻"], div).addEventListener("click",()=>{this.addCommand({...this.#commands.right})}); 36 | this.#playDiv = _CN("button", {class:"beebot_button beebot_start"}, ["▶"], div); 37 | this.#playDiv.addEventListener("click",()=>{ 38 | if(this.#isExecuting) 39 | { 40 | this.#stopExecuting(); 41 | } 42 | else if(this.#cmd.length > 0) 43 | { 44 | this.#isExecuting = true; 45 | this.#playDiv.textContent = "⛔"; 46 | this.#executeProgram(0); 47 | } 48 | }); 49 | 50 | this.#cmddiv = _CN("div", {style:"background:#cc5;width:96%;height:30vh;margin:0 auto;"}, [], div); 51 | 52 | let config = _CN("div", {style:"border-radius:2vh;width:90%;margin:0 auto;background:#ee0;"}, [_CN("h2", {style:"text-align:left;margin:5px;"}, ["⚙️"])], div); 53 | let confF = _CN("span", {style:"width:25vw;display:inline-block;margin:2vw;"}, ["Forward time (ms): "], config); 54 | let confR = _CN("span", {style:"width:25vw;display:inline-block;margin:2vw;"}, ["Rotate time (ms): "], config); 55 | let confS = _CN("span", {style:"width:25vw;display:inline-block;margin:2vw;"}, ["Speed (%): "], config); 56 | 57 | this.#forwardTime = _CN("input", {type:"range", value:"1060", min:400, max:3000, step:20, style:"width:100%;"}, [], confF); 58 | this.#forwardTime.value = 1060; 59 | this.#forwardTime.addEventListener("input", (ev)=>{ 60 | confF.getElementsByTagName("b")[0].textContent = this.#forwardTime.value + " ms"; 61 | }); 62 | this.#rotateTime = _CN("input", {type:"range", value:"800", min:400, max:3000, step:20, style:"width:100%;"}, [], confR); 63 | this.#rotateTime.value = 800; 64 | this.#rotateTime.addEventListener("input", (ev)=>{ 65 | confR.getElementsByTagName("b")[0].textContent = this.#rotateTime.value + " ms"; 66 | }); 67 | this.#speed = _CN("input", {type:"range", value:"50", min:20, max:100, step:2, style:"width:100%;"}, [], confS); 68 | this.#speed.value = 50; 69 | this.#speed.addEventListener("input", (ev)=>{ 70 | confS.getElementsByTagName("b")[0].textContent = this.#speed.value + " %"; 71 | }); 72 | _CN("b", {}, [`${this.#forwardTime.value} ms`], confF); 73 | _CN("b", {}, [`${this.#rotateTime.value} ms`], confR); 74 | _CN("b", {}, [`${this.#speed.value} %`], confS); 75 | 76 | } 77 | 78 | #stopExecuting() 79 | { 80 | this.#playDiv.textContent = "▶"; 81 | this.#isExecuting = false; 82 | } 83 | 84 | #executeProgram(index) 85 | { 86 | if(index > 0) this.#cmd[index-1].div.style.background="#aaa"; 87 | if(index >= this.#cmd.length || !this.#isExecuting) 88 | { 89 | this.#stopExecuting(); 90 | return; 91 | } 92 | this.#cmd[index].div.style.background="#ffa"; 93 | 94 | let str = ">" + this.#cmd[index].cmd; 95 | let timeout = 1000; 96 | let speed = parseInt(this.#speed.value); 97 | if(this.#cmd[index].cmd === 'F' || this.#cmd[index].cmd === 'B') 98 | { 99 | timeout = parseInt(this.#forwardTime.value); 100 | str += timeout.toString(16).padStart(4, '0'); 101 | str += speed.toString(16).padStart(2, '0'); 102 | } 103 | else if(this.#cmd[index].cmd === 'L' || this.#cmd[index].cmd === 'R') 104 | { 105 | timeout = parseInt(this.#rotateTime.value); 106 | str += timeout.toString(16).padStart(4, '0'); 107 | str += speed.toString(16).padStart(2, '0'); 108 | } 109 | else 110 | { 111 | str += "000000"; 112 | } 113 | 114 | bt.send(str); 115 | 116 | setTimeout(()=>{ 117 | this.#executeProgram(index+1); 118 | }, timeout + 500); 119 | } 120 | 121 | addCommand(cmd) 122 | { 123 | cmd.div = _CN("button", {style:"width:32px;height:32px;line-height:32px;background:#aaa;padding:0px;margin:1px;"}, [cmd.symbol], this.#cmddiv); 124 | cmd.div.addEventListener("click", ()=>{ 125 | this.#cmd.forEach((c, i)=>{ 126 | if(c === cmd) 127 | { 128 | this.#cmddiv.removeChild(c.div); 129 | this.#cmd.splice(i, 1); 130 | } 131 | }); 132 | }); 133 | this.#cmd.push(cmd); 134 | } 135 | 136 | eval(msg) 137 | { 138 | super.eval(msg); 139 | } 140 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /webapp/js/bluetoothTerminal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * BluetoothTerminal class. 3 | */ 4 | class BluetoothTerminal { 5 | /** 6 | * Creates a BluetoothTerminal instance with the provided configuration. 7 | * Supports both options object (preferred) and individual parameters (deprecated and will be removed in v2.0.0). 8 | * @param [optionsOrServiceUuid] Optional options object or service UUID as an integer (16-bit or 32-bit) or a 9 | * string (128-bit UUID) 10 | * @param [characteristicUuid] Optional characteristic UUID as an integer (16-bit or 32-bit) or a string (128-bit 11 | * UUID) 12 | * @param [receiveSeparator] Optional receive separator with length equal to one character 13 | * @param [sendSeparator] Optional send separator with length equal to one character 14 | * @param [onConnectCallback] Optional callback for successful connection 15 | * @param [onDisconnectCallback] Optional callback for disconnection 16 | */ 17 | constructor(optionsOrServiceUuid, characteristicUuid, 18 | // @deprecated 19 | receiveSeparator, 20 | // @deprecated 21 | sendSeparator, 22 | // @deprecated 23 | onConnectCallback, 24 | // @deprecated 25 | onDisconnectCallback // @deprecated 26 | ) { 27 | // Event listeners bound to this instance to maintain the correct context. 28 | this._boundCharacteristicValueChangedListener = void 0; 29 | this._boundGattServerDisconnectedListener = void 0; 30 | // Private properties configurable via setters. 31 | this._serviceUuid = 0xFFE0; 32 | this._characteristicUuid = 0xFFE1; 33 | this._characteristicValueSize = 20; 34 | this._receiveSeparator = '\n'; 35 | this._sendSeparator = '\n'; 36 | this._onConnectCallback = null; 37 | this._onDisconnectCallback = null; 38 | this._onReceiveCallback = null; 39 | this._onLogCallback = null; 40 | this._logLevel = 'log'; 41 | // Current Bluetooth device object. 42 | this._device = null; 43 | // Current Bluetooth characteristic object. 44 | this._characteristic = null; 45 | // Buffer that accumulates incoming characteristic value until a separator character is received. 46 | this._receiveBuffer = ''; 47 | // Bind event listeners to preserve 'this' context when called by the event system. 48 | this._boundCharacteristicValueChangedListener = this._characteristicValueChangedListener.bind(this); 49 | this._boundGattServerDisconnectedListener = this._gattServerDisconnectedListener.bind(this); 50 | this._logDebug('constructor', 'BluetoothTerminal instance initialized'); 51 | if (typeof optionsOrServiceUuid === 'object') { 52 | const options = optionsOrServiceUuid; 53 | if (options.serviceUuid !== undefined) { 54 | this.setServiceUuid(options.serviceUuid); 55 | } 56 | if (options.characteristicUuid !== undefined) { 57 | this.setCharacteristicUuid(options.characteristicUuid); 58 | } 59 | if (options.characteristicValueSize !== undefined) { 60 | this.setCharacteristicValueSize(options.characteristicValueSize); 61 | } 62 | if (options.receiveSeparator !== undefined) { 63 | this.setReceiveSeparator(options.receiveSeparator); 64 | } 65 | if (options.sendSeparator !== undefined) { 66 | this.setSendSeparator(options.sendSeparator); 67 | } 68 | if (options.onConnectCallback !== undefined) { 69 | this.onConnect(options.onConnectCallback); 70 | } 71 | if (options.onDisconnectCallback !== undefined) { 72 | this.onDisconnect(options.onDisconnectCallback); 73 | } 74 | if (options.onReceiveCallback !== undefined) { 75 | this.onReceive(options.onReceiveCallback); 76 | } 77 | if (options.onLogCallback !== undefined) { 78 | this.onLog(options.onLogCallback); 79 | } 80 | if (options.logLevel !== undefined) { 81 | this.setLogLevel(options.logLevel); 82 | } 83 | } else { 84 | // @deprecated 85 | if (optionsOrServiceUuid !== undefined) { 86 | this.setServiceUuid(optionsOrServiceUuid); 87 | } 88 | if (characteristicUuid !== undefined) { 89 | this.setCharacteristicUuid(characteristicUuid); 90 | } 91 | if (receiveSeparator !== undefined) { 92 | this.setReceiveSeparator(receiveSeparator); 93 | } 94 | if (sendSeparator !== undefined) { 95 | this.setSendSeparator(sendSeparator); 96 | } 97 | if (onConnectCallback !== undefined) { 98 | this.onConnect(onConnectCallback); 99 | } 100 | if (onDisconnectCallback !== undefined) { 101 | this.onDisconnect(onDisconnectCallback); 102 | } 103 | } 104 | } 105 | 106 | /** 107 | * Sets integer or string representing service UUID used. 108 | * @param uuid Service UUID as an integer (16-bit or 32-bit) or a string (128-bit UUID) 109 | * @see https://developer.mozilla.org/en-US/docs/Web/API/BluetoothUUID 110 | */ 111 | setServiceUuid(uuid) { 112 | if (!Number.isInteger(uuid) && typeof uuid !== 'string') { 113 | throw new Error('Service UUID must be either an integer or a string'); 114 | } 115 | if (uuid === 0) { 116 | throw new Error('Service UUID cannot be zero'); 117 | } 118 | if (typeof uuid === 'string' && uuid.trim() === '') { 119 | throw new Error('Service UUID cannot be an empty string'); 120 | } 121 | this._serviceUuid = uuid; 122 | this._logDebug('setServiceUuid', `Service UUID set to "${uuid}"`); 123 | } 124 | 125 | /** 126 | * Sets integer or string representing characteristic UUID used. 127 | * @param uuid Characteristic UUID as an integer (16-bit or 32-bit) or a string (128-bit UUID) 128 | * @see https://developer.mozilla.org/en-US/docs/Web/API/BluetoothUUID 129 | */ 130 | setCharacteristicUuid(uuid) { 131 | if (!Number.isInteger(uuid) && typeof uuid !== 'string') { 132 | throw new Error('Characteristic UUID must be either an integer or a string'); 133 | } 134 | if (uuid === 0) { 135 | throw new Error('Characteristic UUID cannot be zero'); 136 | } 137 | if (typeof uuid === 'string' && uuid.trim() === '') { 138 | throw new Error('Characteristic UUID cannot be an empty string'); 139 | } 140 | this._characteristicUuid = uuid; 141 | this._logDebug('setCharacteristicUuid', `Characteristic UUID set to "${uuid}"`); 142 | } 143 | 144 | /** 145 | * Sets the maximum size (in bytes) for each characteristic write operation. Larger messages will be automatically 146 | * split into chunks of this size. 147 | * @param size Maximum characteristic value size in bytes (positive integer) 148 | */ 149 | setCharacteristicValueSize(size) { 150 | if (!Number.isInteger(size) || size <= 0) { 151 | throw new Error('Characteristic value size must be a positive integer'); 152 | } 153 | this._characteristicValueSize = size; 154 | this._logDebug('setCharacteristicValueSize', `Characteristic value size set to "${size}"`); 155 | } 156 | 157 | /** 158 | * Sets character representing separator for messages received from the connected device, end of line for example. 159 | * @param separator Receive separator with length equal to one character 160 | */ 161 | setReceiveSeparator(separator) { 162 | if (typeof separator !== 'string') { 163 | throw new Error('Receive separator must be a string'); 164 | } 165 | if (separator.length !== 1) { 166 | throw new Error('Receive separator length must be equal to one character'); 167 | } 168 | this._receiveSeparator = separator; 169 | this._logDebug('setReceiveSeparator', `Receive separator set to "${separator}"`); 170 | } 171 | 172 | /** 173 | * Sets character representing separator for messages sent to the connected device, end of line for example. 174 | * @param separator Send separator with length equal to one character 175 | */ 176 | setSendSeparator(separator) { 177 | if (typeof separator !== 'string') { 178 | throw new Error('Send separator must be a string'); 179 | } 180 | if (separator.length !== 1) { 181 | throw new Error('Send separator length must be equal to one character'); 182 | } 183 | this._sendSeparator = separator; 184 | this._logDebug('setSendSeparator', `Send separator set to "${separator}"`); 185 | } 186 | 187 | /** 188 | * Sets a callback that will be called after the device is fully connected and communication has started. 189 | * @deprecated Use `onConnect()` instead. 190 | * @param [callback] Callback for successful connection; omit or pass null/undefined to remove 191 | */ 192 | setOnConnected(callback) { 193 | this.onConnect(callback); 194 | } 195 | 196 | /** 197 | * Sets a callback that will be called after the device is fully connected and communication has started. 198 | * @param [callback] Callback for successful connection; omit or pass null/undefined to remove 199 | */ 200 | onConnect(callback) { 201 | this._onConnectCallback = callback || null; 202 | this._logDebug('onConnect', `onConnect callback ${this._onConnectCallback === null ? 'removed' : 'set'}`); 203 | } 204 | 205 | /** 206 | * Sets a callback that will be called after the device is disconnected. 207 | * @deprecated Use `onDisconnect()` instead. 208 | * @param [callback] Callback for disconnection; omit or pass null/undefined to remove 209 | */ 210 | setOnDisconnected(callback) { 211 | this.onDisconnect(callback); 212 | } 213 | 214 | /** 215 | * Sets a callback that will be called after the device is disconnected. 216 | * @param [callback] Callback for disconnection; omit or pass null/undefined to remove 217 | */ 218 | onDisconnect(callback) { 219 | this._onDisconnectCallback = callback || null; 220 | this._logDebug('onDisconnect', `onDisconnect callback ${this._onDisconnectCallback === null ? 'removed' : 'set'}`); 221 | } 222 | 223 | /** 224 | * Sets a callback that will be called when an incoming message from the connected device is received. 225 | * @param [callback] Callback for incoming message; omit or pass null/undefined to remove 226 | */ 227 | onReceive(callback) { 228 | this._onReceiveCallback = callback || null; 229 | this._logDebug('onReceive', `onReceive callback ${this._onReceiveCallback === null ? 'removed' : 'set'}`); 230 | } 231 | 232 | /** 233 | * Sets a callback that will be called every time any log message is produced by the class, regardless of the log 234 | * level set. 235 | * @param [callback] Callback for log messages; omit or pass null/undefined to remove 236 | */ 237 | onLog(callback) { 238 | this._onLogCallback = callback || null; 239 | this._logDebug('onLog', `onLog callback ${this._onLogCallback === null ? 'removed' : 'set'}`); 240 | } 241 | 242 | /** 243 | * Sets the log level that controls which messages are displayed in the console. The level hierarchy (from least to 244 | * most verbose) is: "none", "error", "warn", "info", "log", "debug". Each level includes all less verbose levels. 245 | * @param logLevel Log level as a string ("none", "error", "warn", "info", "log", or "debug") 246 | */ 247 | setLogLevel(logLevel) { 248 | if (typeof logLevel !== 'string') { 249 | throw new Error('Log level must be a string'); 250 | } 251 | if (!BluetoothTerminal._logLevels.includes(logLevel)) { 252 | throw new Error(`Log level must be one of: "${BluetoothTerminal._logLevels.join('", "')}"`); 253 | } 254 | this._logLevel = logLevel; 255 | this._logDebug('setLogLevel', `Log level set to "${logLevel}"`); 256 | } 257 | 258 | /** 259 | * Opens the browser Bluetooth device picker to select a device if none was previously selected, establishes 260 | * a connection with the selected device, and initiates communication. 261 | * If configured, the `onConnect()` callback function will be executed after the connection is established. 262 | * @async 263 | * @returns Promise that resolves when the device is fully connected and communication has started, or rejects if an 264 | * error occurs. 265 | */ 266 | async connect() { 267 | this._logInfo('connect', 'Initiating connection process...'); 268 | if (!this._device) { 269 | this._logInfo('connect', 'Opening browser Bluetooth device picker...'); 270 | try { 271 | this._device = await this._requestDevice(this._serviceUuid); 272 | } catch (error) { 273 | this._logError('connect', error, errorMessage => `Connection failed: "${errorMessage}"`); 274 | throw error; 275 | } 276 | } else { 277 | this._logInfo('connect', `Connecting to previously selected device "${this.getDeviceName()}"...`); 278 | } 279 | 280 | // Register event listener to handle disconnection and attempt automatic reconnection. 281 | this._device.addEventListener('gattserverdisconnected', this._boundGattServerDisconnectedListener); 282 | try { 283 | await this._connectDevice(); 284 | } catch (error) { 285 | this._logError('connect', error, errorMessage => `Connection failed: "${errorMessage}"`); 286 | throw error; 287 | } 288 | this._logInfo('connect', `Device "${this.getDeviceName()}" successfully connected`); 289 | } 290 | 291 | /** 292 | * Disconnects from the currently connected device and cleans up associated resources. 293 | * If configured, the `onDisconnect()` callback function will be executed after the complete disconnection. 294 | */ 295 | disconnect() { 296 | if (!this._device) { 297 | this._logWarn('disconnect', 'No device is currently connected'); 298 | return; 299 | } 300 | this._logInfo('disconnect', `Initiating disconnection from device "${this.getDeviceName()}"...`); 301 | if (this._characteristic) { 302 | // Stop receiving and processing incoming messages from the device. 303 | this._characteristic.removeEventListener('characteristicvaluechanged', this._boundCharacteristicValueChangedListener); 304 | this._characteristic = null; 305 | } 306 | 307 | // Remove reconnection handler to prevent automatic reconnection attempts. 308 | this._device.removeEventListener('gattserverdisconnected', this._boundGattServerDisconnectedListener); 309 | if (!this._device.gatt) { 310 | throw new Error('GATT server is not available'); 311 | } 312 | if (!this._device.gatt.connected) { 313 | this._logWarn('disconnect', `Device "${this.getDeviceName()}" is already disconnected`); 314 | return; 315 | } 316 | try { 317 | this._device.gatt.disconnect(); 318 | } catch (error) { 319 | this._logError('disconnect', error, errorMessage => `Disconnection failed: "${errorMessage}"`); 320 | throw error; 321 | } 322 | this._logInfo('disconnect', `Device "${this.getDeviceName()}" successfully disconnected`); 323 | this._device = null; 324 | if (this._onDisconnectCallback) { 325 | this._logDebug('disconnect', `Executing onDisconnect callback...`); 326 | this._onDisconnectCallback(); 327 | this._logDebug('disconnect', `onDisconnect callback was executed successfully`); 328 | } 329 | } 330 | 331 | /** 332 | * Handler for incoming messages received from the connected device. Override this method to process messages 333 | * received from the connected device. Each time a complete message (ending with the receive separator) is processed, 334 | * this method will be called with the message string. 335 | * @deprecated Use `onReceive()` instead. 336 | * @param message String message received from the connected device, with separators removed 337 | */ 338 | receive(message) {// eslint-disable-line @typescript-eslint/no-unused-vars 339 | // The placeholder method is intended to be overridden by users to handle incoming messages. 340 | } 341 | 342 | /** 343 | * Sends a message to the connected device, automatically adding the configured send separator and splitting the 344 | * message into appropriate chunks if it exceeds the maximum characteristic value size. 345 | * @async 346 | * @param message String message to send to the connected device 347 | * @returns Promise that resolves when message successfully sent, or rejects if the device is disconnected or an 348 | * error occurs. 349 | */ 350 | async send(message) { 351 | // Ensure message is a string, defaulting to empty string if undefined/null. 352 | message = String(message || ''); 353 | 354 | // Validate that the message is not empty after conversion. 355 | if (!message) { 356 | throw new Error('Message must be a non-empty string'); 357 | } 358 | 359 | // Verify the communication channel before attempting to send. 360 | if (!this._device || !this._characteristic) { 361 | throw new Error('Device must be connected to send a message'); 362 | } 363 | this._logDebug('send', `Sending message: "${message}"...`); 364 | 365 | // Append the configured send separator to the message. 366 | message += this._sendSeparator; 367 | 368 | // Split the message into chunks according to the characteristic value size limit. 369 | const chunks = []; 370 | for (let i = 0; i < message.length; i += this._characteristicValueSize) { 371 | chunks.push(message.slice(i, i + this._characteristicValueSize)); 372 | } 373 | this._logDebug('send', `Sending in ${chunks.length} chunk${chunks.length > 1 ? 's' : ''}: "${chunks.join('", "')}"...`); 374 | try { 375 | // Send chunks sequentially. 376 | for (let i = 0; i < chunks.length; i++) { 377 | this._logDebug('send', `Sending chunk ${i + 1}/${chunks.length}: "${chunks[i]}"...`); 378 | await this._characteristic.writeValue(new TextEncoder().encode(chunks[i])); 379 | } 380 | } catch (error) { 381 | this._logError('send', error, errorMessage => `Sending failed: "${errorMessage}"`); 382 | throw error; 383 | } 384 | this._logDebug('send', 'Message successfully sent'); 385 | } 386 | 387 | /** 388 | * Retrieves the name of the currently connected device. 389 | * @returns Device name or an empty string if no device is connected or has no name. 390 | */ 391 | getDeviceName() { 392 | return this._device && this._device.name ? this._device.name : ''; 393 | } 394 | 395 | /** 396 | * Establishes a connection to the current device, starts communication, sets up an event listener to process 397 | * incoming messages, and invokes the `onConnect()` callback if one has been configured. This method is called 398 | * internally by the `connect()` method and the reconnection listener. 399 | * @async 400 | * @returns Promise that resolves when the device is fully connected and communication has started, or rejects if an 401 | * error occurs. 402 | */ 403 | async _connectDevice() { 404 | if (!this._device) { 405 | throw new Error('Device must be selected to connect'); 406 | } 407 | this._log('_connectDevice', `Establishing connection to device "${this.getDeviceName()}"...`); 408 | try { 409 | this._characteristic = await this._startNotifications(this._device, this._serviceUuid, this._characteristicUuid); 410 | } catch (error) { 411 | this._logError('_connectDevice', error, errorMessage => `Connection failed: "${errorMessage}"`); 412 | throw error; 413 | } 414 | 415 | // Set up an event listener to receive and process incoming messages from the device. 416 | this._characteristic.addEventListener('characteristicvaluechanged', this._boundCharacteristicValueChangedListener); 417 | if (this._onConnectCallback) { 418 | this._logDebug('_connectDevice', `Executing onConnect callback...`); 419 | this._onConnectCallback(); 420 | this._logDebug('_connectDevice', `onConnect callback was executed successfully`); 421 | } 422 | this._log('_connectDevice', 'Connection established and communication started'); 423 | } 424 | 425 | /** 426 | * Opens the browser Bluetooth device picker and allows the user to select a device that supports the specified 427 | * service UUID. This method is stateless and doesn't modify any instance properties. 428 | * @async 429 | * @param serviceUuid Service UUID 430 | * @returns Promise that resolves with the selected Bluetooth device object. 431 | */ 432 | async _requestDevice(serviceUuid) { 433 | this._logDebug('_requestDevice', `Opening browser Bluetooth device picker for service UUID "${serviceUuid}"...`); 434 | let device; 435 | try { 436 | device = await navigator.bluetooth.requestDevice({ 437 | filters: [{ 438 | services: [serviceUuid] 439 | }] 440 | }); 441 | } catch (error) { 442 | this._logError('_requestDevice', error, errorMessage => `Requesting device failed: "${errorMessage}"`); 443 | throw error; 444 | } 445 | this._logDebug('_requestDevice', `Device "${device.name}" selected`); 446 | return device; 447 | } 448 | 449 | /** 450 | * Establishes a connection to the provided device GATT server, retrieves the specified service, accesses the 451 | * specified characteristic, and starts notifications on that characteristic. This method is stateless and doesn't 452 | * modify any instance properties. 453 | * @async 454 | * @param device Bluetooth device object 455 | * @param serviceUuid Service UUID 456 | * @param characteristicUuid Characteristic UUID 457 | * @returns Promise that resolves with the Bluetooth characteristic object with notifications enabled. 458 | */ 459 | async _startNotifications(device, serviceUuid, characteristicUuid) { 460 | if (!device.gatt) { 461 | throw new Error('GATT server is not available'); 462 | } 463 | this._log('_startNotifications', 'Connecting to GATT server...'); 464 | const server = await device.gatt.connect(); 465 | this._log('_startNotifications', 'GATT server connected successfully'); 466 | this._log('_startNotifications', `Looking for service with UUID "${serviceUuid}"...`); 467 | const service = await server.getPrimaryService(serviceUuid); 468 | this._log('_startNotifications', `Service with UUID "${serviceUuid}" found successfully`); 469 | this._log('_startNotifications', `Looking for characteristic with UUID "${characteristicUuid}"...`); 470 | const characteristic = await service.getCharacteristic(characteristicUuid); 471 | this._log('_startNotifications', `Characteristic with UUID "${characteristicUuid}" found successfully`); 472 | this._log('_startNotifications', 'Starting notifications on characteristic...'); 473 | await characteristic.startNotifications(); 474 | this._log('_startNotifications', 'Notifications on characteristic started successfully'); 475 | return characteristic; 476 | } 477 | 478 | /** 479 | * Handles the `characteristicvaluechanged` event from the Bluetooth characteristic. Decodes incoming value, 480 | * accumulates characters until the receive separator is encountered, then processes the complete message and invokes 481 | * appropriate callback. 482 | * @param event Event 483 | */ 484 | _characteristicValueChangedListener(event) { 485 | // `event.target` will be `BluetoothRemoteGATTCharacteristic` when event triggered with this listener. 486 | const value = new TextDecoder().decode(event.target.value); 487 | this._logDebug('_characteristicValueChangedListener', `Value received: "${value}"`); 488 | for (const c of value) { 489 | if (c === this._receiveSeparator) { 490 | const message = this._receiveBuffer.trim(); 491 | this._receiveBuffer = ''; 492 | if (message) { 493 | this._logDebug('_characteristicValueChangedListener', `Message received: "${message}"`); 494 | // @deprecated 495 | this.receive(message); 496 | if (this._onReceiveCallback) { 497 | this._logDebug('_characteristicValueChangedListener', `Executing onReceive callback with message "${message}"...`); 498 | this._onReceiveCallback(message); 499 | this._logDebug('_characteristicValueChangedListener', 'onReceive callback was executed successfully'); 500 | } 501 | } 502 | } else { 503 | this._receiveBuffer += c; 504 | } 505 | } 506 | } 507 | 508 | /** 509 | * Handles the 'gattserverdisconnected' event from the Bluetooth device. This event is triggered when the connection 510 | * to the GATT server is lost. The method invokes the `onDisconnect()` callback if one has been configured and 511 | * attempts to reconnect to the device automatically. 512 | * @param event Event 513 | */ 514 | _gattServerDisconnectedListener(event) { 515 | // `event.target` will be `BluetoothDevice` when event triggered with this listener. 516 | const device = event.target; 517 | this._log('_gattServerDisconnectedListener', `Device "${device.name}" was disconnected...`); 518 | if (this._onDisconnectCallback) { 519 | this._logDebug('_gattServerDisconnectedListener', `Executing onDisconnect callback...`); 520 | this._onDisconnectCallback(); 521 | this._logDebug('_gattServerDisconnectedListener', `onDisconnect callback was executed successfully`); 522 | } 523 | 524 | // `this._device` is not reassigned to `device` (`event.target`) here because `this._device` _should_ already be 525 | // set during the previous connection process and _should_ remain valid for reconnection. 526 | 527 | this._log('_gattServerDisconnectedListener', `Attempting to reconnect to device "${this.getDeviceName()}"...`); 528 | 529 | // Using IIFE to leverage async/await while maintaining the void return type required by the event handler 530 | // interface. Try/catch is required here to avoid propagating the error as there is no place to catch it. 531 | (async () => { 532 | try { 533 | await this._connectDevice(); 534 | this._log('_gattServerDisconnectedListener', `Device "${this.getDeviceName()}" successfully reconnected`); 535 | } catch (error) { 536 | this._logError('_gattServerDisconnectedListener', error, errorMessage => `Reconnection failed: "${errorMessage}"`); 537 | } 538 | })(); 539 | } 540 | _logGeneric(logLevel, method, message, error) { 541 | if (this._onLogCallback) { 542 | this._onLogCallback(logLevel, method, message, error); 543 | } 544 | if (BluetoothTerminal._logLevels.indexOf(this._logLevel) < BluetoothTerminal._logLevels.indexOf(logLevel)) { 545 | return; 546 | } 547 | const logMessage = `[BluetoothTerminal][${method}] ${message}`; 548 | switch (logLevel) { 549 | case 'debug': 550 | console.debug(logMessage); 551 | break; 552 | case 'log': 553 | console.log(logMessage); 554 | break; 555 | case 'info': 556 | console.info(logMessage); 557 | break; 558 | case 'warn': 559 | console.warn(logMessage); 560 | break; 561 | case 'error': 562 | console.error(logMessage); 563 | break; 564 | default: 565 | throw new Error(`Log level must be one of: "${BluetoothTerminal._logLevels.join('", "')}"`); 566 | } 567 | } 568 | _logDebug(method, message) { 569 | this._logGeneric('debug', method, message); 570 | } 571 | _log(method, message) { 572 | this._logGeneric('log', method, message); 573 | } 574 | _logInfo(method, message) { 575 | this._logGeneric('info', method, message); 576 | } 577 | _logWarn(method, message) { 578 | this._logGeneric('warn', method, message); 579 | } 580 | _logError(method, error, messageConstructor) { 581 | const errorMessage = error instanceof Error ? error.message : String(error); 582 | const message = messageConstructor(errorMessage); 583 | this._logGeneric('error', method, message, error); 584 | } 585 | } 586 | 587 | // Conditionally export the class as CommonJS module for browser vs Node.js compatibility. 588 | // From least to most verbose, index matters! 589 | BluetoothTerminal._logLevels = ['none', 'error', 'warn', 'info', 'log', 'debug']; 590 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 591 | module.exports = BluetoothTerminal; 592 | } --------------------------------------------------------------------------------