Community project
Realtime AI Voice Assistant
Build a mobile robot that responds to voice commands and infrared remote control. This four-wheel drive robot is powered by an ESP32 microcontroller and features a real-time AI voice assistant that can understand spoken instructions, display responses on an OLED screen, and execute motor commands. The robot combines dual motor drivers for independent front and rear wheel control, an I2S microphone for audio input, and an infrared receiver for remote operation.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for constructing the mechanical chassis, installing all electronics, and configuring the firmware. Readers will learn how to connect the motor drivers, set up the microphone and display, integrate the infrared remote system, and configure the AI backend to power the voice assistant functionality.
Wiring diagram

Gather all the parts
Assemble it in 7 steps
1. Pasang roda dan motor pada rangka
Pasang motor_left_front dan motor_left_rear pada sisi kiri rangka, lalu motor_right_front dan motor_right_rear pada sisi kanan. Pasang satu roda pada setiap poros motor. Jangan sambungkan baterai dulu.
- Buat semua roda menghadap arah yang sama sebelum memasang kabel.
- Jika satu roda nanti berputar terbalik, tukar dua kabel motor roda itu di terminal driver.
- Jangan mencoba menggerakkan motor dari pin ESP32 secara langsung — pin board dapat rusak karena motor membutuhkan arus besar.
2. Pasang OLED dan mikrofon
Pada oled_1, sambungkan VCC → 3V3 (power), GND → GND (ground), SDA → GPIO8 (data), dan SCL → GPIO9 (clock). Pada mic_1, sambungkan VDD → 3V3 (power), GND → GND (ground), SCK → GPIO4 (clock), WS → GPIO5 (signal), SD → GPIO6 (data), dan L/R → GND (memilih kanal mikrofon kiri).
- Gunakan kabel merah hanya untuk 3V3 dan kabel hitam untuk GND supaya lebih mudah diperiksa.
- Arahkan lubang mikrofon ke luar casing robot.
- Jangan memberi 5 V ke OLED atau mikrofon — keduanya memakai 3,3 V dan dapat rusak bila VCC dan GND tertukar.
3. Hubungkan driver untuk roda depan
Pada motor_driver_front, sambungkan VM → 5V (power), VCC → 3V3 (power), GND → GND (ground), AIN1 → GPIO10 (arah kiri), AIN2 → GPIO11 (arah kiri), PWMA → GPIO12 (kecepatan kiri), BIN1 → GPIO13 (arah kanan), BIN2 → GPIO14 (arah kanan), PWMB → GPIO15 (kecepatan kanan), dan STBY → GPIO16 (menyalakan driver). Sambungkan A01 dan A02 ke dua terminal motor_left_front, lalu B01 dan B02 ke dua terminal motor_right_front.
- Gunakan kabel yang lebih tebal untuk jalur VM, GND, dan kabel motor.
- Tuliskan label kiri/kanan pada kabel sebelum menutup rangka.
- Pastikan VM dan VCC tidak tertukar — VM adalah 5 V untuk motor, sedangkan VCC adalah 3,3 V untuk logika driver.
4. Hubungkan driver untuk roda belakang
Pada motor_driver_rear, sambungkan VM → 5V (power), VCC → 3V3 (power), GND → GND (ground), AIN1 → GPIO17 (arah kiri), AIN2 → GPIO18 (arah kiri), PWMA → GPIO21 (kecepatan kiri), BIN1 → GPIO38 (arah kanan), BIN2 → GPIO39 (arah kanan), PWMB → GPIO40 (kecepatan kanan), dan STBY → GPIO41 (menyalakan driver). Sambungkan A01 dan A02 ke dua terminal motor_left_rear, lalu B01 dan B02 ke dua terminal motor_right_rear.
- Sisi kiri depan dan kiri belakang harus sama-sama bergerak maju saat perintah maju diberikan.
- Jangan gunakan GPIO35, GPIO36, atau GPIO37 karena dipakai memori board.
- Semua GND harus tersambung menjadi satu: kedua driver, ESP32, pengatur tegangan, dan baterai. Tanpa ground bersama motor bisa bergerak tidak menentu.
5. Rakit pemancar dan penerima remote inframerah
Pada ir_receiver_1, sambungkan VCC → 3V3 (power), GND → GND (ground), dan OUT → GPIO2 (signal). Pada ir_base_resistor, sambungkan satu ujung → GPIO3 (signal) dan ujung lainnya → Base (B) ir_tx_transistor. Sambungkan Emitter (E) ir_tx_transistor → GND (ground) serta Collector (C) → CATHODE ir_led_1. Terakhir, sambungkan 5V → satu ujung ir_led_resistor (power), ujung lainnya → ANODE ir_led_1 (power).
- Pada LED IR, kaki panjang biasanya anoda/positif; bila tidak jelas, cocokkan dengan lembar data atau penanda sisi datar pada LED.
- Letakkan sensor Flying Fish di bagian depan robot dan arahkan LED IR ke perangkat yang akan dikendalikan.
- Jangan menyambungkan LED IR langsung ke GPIO3 — LED dapat menarik arus terlalu besar dan merusak pin board.
- Susunan kaki 2N3904 berbeda antar bentuk kemasan; periksa label E, B, dan C pada penjual atau lembar data sebelum menyolder.
6. Rakit daya baterai robot
Dengan baterai robot_battery dilepas, sambungkan BAT+ → VIN+ pada power_buck (power) dan BAT- → VIN- pada power_buck (ground). Putar pengatur kecil pada power_buck sambil mengukur VOUT+ dan VOUT- dengan multimeter hingga tepat 5,0 V. Setelah itu, sambungkan VOUT+ → pin 5V/VIN ESP32 dan ke VM kedua driver (power), serta VOUT- → GND ESP32 dan GND kedua driver (ground).
- Atur tegangan pengatur terlebih dahulu sebelum board ESP32 dihubungkan.
- Paket 1000 mAh diperkirakan bertahan sekitar 24 menit saat robot sering bergerak; waktu nyata tergantung berat dan permukaan lantai.
- Jangan hubungkan baterai 7,4 V langsung ke ESP32 atau VM motor TT — tegangan itu dapat merusak board dan motor.
- Jangan mencolok USB bersamaan dengan daya 5 V dari pengatur ke pin 5V/VIN; dua sumber daya dapat saling mendorong arus.
- Isi ulang baterai 2S dengan pengisi daya balance khusus LiPo di luar rangka, di tempat yang tidak mudah terbakar.
7. Pelajari dan gunakan tombol remote Anda
Hidupkan board, sambungkan ponsel ke Wi-Fi SASINOKIT, lalu buka 192.168.4.1. Di bagian Remote IR Universal, pilih TV, AC, atau Lainnya dan isi nama tombol seperti Power. Tekan Pelajari Tombol, lalu arahkan remote asli ke ir_receiver_1 dari jarak 3–10 cm dan tekan tombol yang sama sekali. Setelah pesan berhasil muncul, arahkan ir_led_1 ke perangkat Anda, masukkan nama tombol yang sama, lalu tekan Kirim Tombol.
- Gunakan satu nama yang konsisten, misalnya Power, Volume Naik, atau Mode AC, agar mudah ditemukan kembali.
- Jika perangkat tidak merespons, dekatkan LED IR ke sensor perangkat dan pastikan LED menghadap ke depan.
- Hanya pelajari dan gunakan remote untuk perangkat yang Anda miliki atau Anda berwenang mengendalikannya.
- Uji robot dengan roda terangkat saat menggunakan RemoteBot agar robot tidak bergerak mendadak.
Review all connections
1. Connections between "oled_1" and "ESP32"
2. Connections between "mic_1" and "ESP32"
3. Connections between "robot_battery" and "ESP32"
4. Connections between "power_buck" and "ESP32"
5. Connections between "motor_driver_front" and "ESP32"
6. Connections between "motor_driver_rear" and "ESP32"
7. Connections between "ir_receiver_1" and "ESP32"
8. Connections between "ir_base_resistor" and "ESP32"
9. Connections between "ir_tx_transistor" and "ESP32"
10. Connections between "ir_led_resistor" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
#include <IRremote.hpp>
#define IR_RX_PIN 2
#define IR_TX_PIN 3
#define OLED_SDA 8
#define OLED_SCL 9
#define FRONT_LEFT_IN1 10
#define FRONT_LEFT_IN2 11
#define FRONT_LEFT_PWM 12
#define FRONT_RIGHT_IN1 13
#define FRONT_RIGHT_IN2 14
#define FRONT_RIGHT_PWM 15
#define FRONT_STBY 16
#define REAR_LEFT_IN1 17
#define REAR_LEFT_IN2 18
#define REAR_LEFT_PWM 21
#define REAR_RIGHT_IN1 38
#define REAR_RIGHT_IN2 39
#define REAR_RIGHT_PWM 40
#define REAR_STBY 41
WebServer server(80); DNSServer dnsServer; Preferences pref; Adafruit_SSD1306 oled(128,64,&Wire,-1);
unsigned long restartAt=0; bool mdnsStarted=false;
String irLearnSlot="",irNotice="Belum ada tombol yang sedang dipelajari."; unsigned long irLearnUntil=0;
String persona="Kamu adalah SASINOKIT-AI, robot ramah. Jawab singkat dalam bahasa Indonesia.",apiKey,provider,model,robotName="SASINOKIT",username="adminer",userPassword="21232123",voice="wanita",lastReply="Halo, saya siap membantu.",testedKey,testedProvider,testedModel;
bool dance=false; uint8_t danceStep=0,faceFrame=0; unsigned long nextMove=0,stopAt=0,nextFaceAt=0;
const char LOGIN[] PROGMEM=R"HTML(<!doctype html><html lang=id><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>SASINOKIT-AI</title><style>:root{--a:#6e66ff;--b:#17c5b5;--bg:#090d19;--ink:#eef3ff}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px Arial;color:var(--ink);background:radial-gradient(circle at 18% 15%,#322b77,transparent 30%),radial-gradient(circle at 80% 90%,#075f62,transparent 32%),var(--bg)}main{width:min(90%,390px);padding:32px;border:1px solid #ffffff20;border-radius:26px;background:#121a30d9;box-shadow:0 20px 55px #0008}h1{margin:0;font-size:28px;letter-spacing:2px}.line{width:54px;height:4px;margin:14px 0 25px;border-radius:5px;background:linear-gradient(90deg,var(--a),var(--b))}p{color:#9ba9c8;line-height:1.5}input,button{width:100%;border:0;border-radius:13px;padding:14px;margin:6px 0;font:inherit}input{background:#090f20;color:white;border:1px solid #ffffff18}button{background:linear-gradient(115deg,var(--a),var(--b));color:#fff;font-weight:bold}#err{min-height:20px;color:#ff8c9d;font-size:14px}</style><main><h1>SASINOKIT-AI</h1><div class=line></div><p>Masuk untuk mengatur robot dan berbicara dengan AI.</p><input id=u placeholder=Username><input id=p type=password placeholder=Password><div id=err></div><button onclick=go()>Masuk</button></main><script>async function go(){let r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u.value,password:p.value})}),j=await r.json();if(j.ok)location='/app';else err.textContent=j.message||'Login gagal.'}addEventListener('keydown',e=>{if(e.key==='Enter')go()})</script>)HTML";
const char APP[] PROGMEM=R"HTML(<!doctype html><html lang=id><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>SASINOKIT-AI</title><style>:root{--a:#6e66ff;--b:#18c7b8;--bg:#090d19;--card:#141c31;--field:#0b1120;--ink:#eef3ff;--mut:#9ba9c8;--line:#ffffff18;--good:#39ff94;--bad:#ff5876}*{box-sizing:border-box}body{font:16px Arial;margin:0;color:var(--ink);background:radial-gradient(circle at 12% 3%,#26235b,transparent 27%),radial-gradient(circle at 100% 40%,#075958,transparent 27%),var(--bg)}header{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;padding:17px max(18px,calc((100% - 930px)/2));background:#090d19dc;backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}h1{font-size:23px;letter-spacing:1.8px;margin:0;background:linear-gradient(90deg,#fff,var(--b));color:transparent;background-clip:text}.user{position:relative}.user button{width:auto;padding:10px 14px}.menu{display:none;position:absolute;right:0;top:48px;width:160px;padding:7px;background:#1a2440;border:1px solid var(--line);border-radius:13px;box-shadow:0 16px 35px #0008}.menu.on{display:block}.menu button{width:100%;padding:10px;text-align:left;background:transparent;box-shadow:none;color:#fff}main{max-width:930px;margin:auto;padding:18px}section{margin:15px 0;padding:20px;border:1px solid var(--line);border-radius:22px;background:linear-gradient(135deg,#19243ce8,#101729e8);box-shadow:0 13px 35px #0004}h2{margin:0 0 16px;font-size:19px}.online,.offline{margin-left:8px;font-size:12px;letter-spacing:1px}.online{color:var(--good);text-shadow:0 0 9px #39ff94}.offline{color:var(--bad);text-shadow:0 0 9px #ff5876}input,textarea,select,button{font:inherit;border-radius:13px;padding:12px;margin:5px 0;border:0}input,textarea,select{width:100%;background:var(--field);color:var(--ink);border:1px solid var(--line);outline:none}input:focus,textarea:focus,select:focus{border-color:var(--a);box-shadow:0 0 0 3px #6e66ff33}button{color:#fff;background:linear-gradient(120deg,var(--a),var(--b));font-weight:bold;cursor:pointer;box-shadow:0 7px 18px #0006;transition:transform .14s,filter .14s}button:active{transform:scale(.96);box-shadow:none}.soft{background:#263452}.row{display:flex;gap:9px;align-items:center}.row>*{flex:1}.hint,.status{display:block;color:var(--mut);font-size:13px;line-height:1.45;margin:7px 0}.net{display:flex;gap:8px;align-items:center;margin:6px 0;padding:10px;border:1px solid var(--line);border-radius:12px;background:#0d1424}.net button{padding:8px 10px;margin:0;box-shadow:none}.net b{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dbm{color:var(--b);font-size:13px}.chat{height:190px;overflow:auto;padding:12px;border-radius:14px;background:#080e1b;border:1px solid var(--line);white-space:pre-wrap;line-height:1.5}.mic{flex:0 0 54px;font-size:16px;padding:10px}.remote{max-width:370px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.remote button{height:59px}.blank{visibility:hidden}.actions{display:flex;gap:10px;margin-top:12px}.actions button{flex:1}.themes{display:flex;gap:8px}.themes button{width:32px;height:32px;padding:0;border-radius:50%;border:2px solid #fff8}.v{background:#6e66ff}.o{background:#04a5a5}.s{background:#e95b79}#load{display:none;place-items:center;position:fixed;inset:0;z-index:10;background:#060914c9;backdrop-filter:blur(5px)}#load.on{display:grid}.loader{padding:24px;border-radius:17px;background:#1b2743;box-shadow:0 18px 45px #0009}.spin{width:26px;height:26px;margin:auto auto 12px;border:3px solid #ffffff33;border-top-color:var(--b);border-radius:50%;animation:r .8s linear infinite}@keyframes r{to{transform:rotate(360deg)}}#toast{position:fixed;z-index:11;left:50%;bottom:22px;transform:translate(-50%,100px);padding:13px 17px;border-radius:13px;background:#16885d;box-shadow:0 10px 28px #0008;transition:.25s;white-space:nowrap}#toast.on{transform:translate(-50%,0)}@media(max-width:530px){main{padding:12px}section{padding:16px}.row{display:block}.row>*{margin:5px 0}}</style><header><h1>SASINOKIT-AI</h1><div class=user><button class=soft onclick="menu.classList.toggle('on')">User</button><div id=menu class=menu><button onclick="profile.scrollIntoView()">Profil</button><button onclick="fetch('/api/logout').then(()=>location='/')">Keluar</button></div></div></header><main><section><div class=row><h2>WIFI <span id=wifiState class=offline>PUTUS</span></h2><div class=themes><button class=v onclick="theme('v')"></button><button class=o onclick="theme('o')"></button><button class=s onclick="theme('s')"></button></div></div><button onclick=scan()>Cari Wi-Fi</button><div id=nets class=hint>Tekan Cari Wi-Fi untuk melihat jaringan di sekitar.</div><input id=ssid placeholder="Nama Wi-Fi"><input id=pass type=password placeholder="Password Wi-Fi"><button onclick=saveWifi()>Simpan dan Sambungkan</button><span id=wifiInfo class=status></span></section><section><h2>API KEY AI</h2><input id=key type=password placeholder="Tempel API key Google Gemini atau OpenAI GPT"><button onclick=testKey()>Tes sambungan AI</button><span id=aiInfo class=status>Masukkan key, lalu tes sambungan.</span><button onclick=saveKey()>Simpan Key</button></section><section><h2>Karakter Robot</h2><textarea id=persona rows=4></textarea><button onclick=savePersona()>Simpan Karakter</button><h3>Pilihan Suara</h3><select id=voice><option value=wanita>Wanita dewasa</option><option value=pria>Pria dewasa</option><option value=remaja-w>Remaja wanita</option><option value=remaja-p>Remaja pria</option><option value=anak-w>Anak perempuan</option><option value=anak-p>Anak laki-laki</option></select><span class=hint>Jawaban suara keluar dari speaker ponsel atau browser yang membuka halaman ini.</span></section><section><h2>ChatBot</h2><div id=chat class=chat>Memuat...</div><div class=row><textarea id=message rows=3 placeholder="Ketik pesan untuk robot"></textarea><button class=mic onclick=listen()>Mic</button></div><button onclick=chatBot()>Kirim Pesan</button></section><section><h2>RemoteBot</h2><div class=remote><i class=blank></i><button onpointerdown="move('maju')" onpointerup="move('stop')" onpointerleave="move('stop')">MAJU</button><i class=blank></i><button onpointerdown="move('kiri')" onpointerup="move('stop')" onpointerleave="move('stop')">KIRI</button><button onclick="move('stop')">STOP</button><button onpointerdown="move('kanan')" onpointerup="move('stop')" onpointerleave="move('stop')">KANAN</button><i class=blank></i><button onpointerdown="move('mundur')" onpointerup="move('stop')" onpointerleave="move('stop')">MUNDUR</button><i class=blank></i></div><div class=actions><button onclick="move('joget')">JOGET</button><button class=soft onclick="move('stop')">Stop</button></div><span id=motorInfo class=status></span></section><section><h2>Remote IR Universal</h2><span class=hint>Hanya pelajari dan gunakan remote perangkat milik Anda sendiri. Arahkan remote asli ke sensor Flying Fish dari jarak 3–10 cm saat belajar.</span><div class=row><select id=irDevice><option value=tv>TV</option><option value=ac>AC</option><option value=lainnya>Lainnya</option></select><input id=irName placeholder="Nama tombol, misalnya Power atau Vol Up"></div><div class=actions><button onclick=learnIr()>Pelajari Tombol</button><button class=soft onclick=sendIr()>Kirim Tombol</button></div><span id=irInfo class=status>Masukkan nama tombol, lalu tekan Pelajari Tombol.</span></section><section id=profile><h2>Profil User</h2><input id=name placeholder="Nama tampilan"><input id=user placeholder=Username><input id=newpass type=password placeholder="Password baru (kosongkan bila tidak diubah)"><button onclick=saveProfile()>Simpan Profil</button></section></main><div id=load><div class=loader><div class=spin></div><span id=loadText>Memproses...</span></div></div><div id=toast></div><script>const $=x=>document.getElementById(x);let tested=false;const C={v:['#6e66ff','#18c7b8'],o:['#08a7a3','#2bc7e8'],s:['#e65a7b','#f0a34c']};function theme(x){document.documentElement.style.setProperty('--a',C[x][0]);document.documentElement.style.setProperty('--b',C[x][1]);localStorage.setItem('skTheme',x)}function loading(x,t){loadText.textContent=t||'Memproses...';load.className=x?'on':''}function note(t,b){toast.textContent=t;toast.style.background=b?'#ad3f56':'#16885d';toast.className='on';setTimeout(()=>toast.className='',3200)}async function post(u,d){let r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d||{})}),j=await r.json();if(!r.ok)throw Error(j.message||'Permintaan gagal.');return j}function esc(s){return String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]))}async function init(){let j=await fetch('/api/status').then(r=>r.json());wifiInfo.textContent=j.wifi;wifiState.textContent=j.online?'TERSAMBUNG':'PUTUS';wifiState.className=j.online?'online':'offline';persona.value=j.persona;voice.value=j.voice;chat.textContent=j.reply;name.value=j.name;user.value=j.username;if(j.model)aiInfo.textContent='Key tersimpan: '+j.provider+' | Model: '+j.model}async function scan(){loading(1,'Mencari Wi-Fi...');try{let j=await post('/api/scan');nets.innerHTML=j.networks.length?j.networks.map(n=>'<div class=net><b>'+esc(n.ssid)+'</b><span class=dbm>'+n.rssi+' dBm</span><button onclick="pick(this)">Pilih</button></div>').join(''):'Tidak ada jaringan ditemukan.';note('Daftar Wi-Fi diperbarui')}catch(e){note(e.message,1)}loading(0)}function pick(b){ssid.value=b.parentElement.querySelector('b').textContent;note('Wi-Fi dipilih: '+ssid.value)}async function saveWifi(){if(!ssid.value.trim())return note('Pilih atau isi nama Wi-Fi dahulu.',1);loading(1,'Menyimpan dan menyambungkan...');try{let j=await post('/api/wifi',{ssid:ssid.value,password:pass.value});wifiInfo.textContent=j.message;note('Data Wi-Fi disimpan. Menunggu koneksi...');setTimeout(()=>{init();loading(0)},5000)}catch(e){loading(0);note(e.message,1)}}async function testKey(){if(!key.value.trim())return note('Masukkan API key dahulu.',1);loading(1,'Menguji sambungan AI...');try{let j=await post('/api/test',{key:key.value});tested=j.ok;aiInfo.textContent=j.message;note(j.ok?'AI tersambung.':'AI tidak tersambung.',!j.ok)}catch(e){note(e.message,1)}loading(0)}async function saveKey(){if(!tested)return note('Tes sambungan AI harus berhasil dahulu.',1);loading(1,'Menyimpan API key...');try{let j=await post('/api/key',{key:key.value});aiInfo.textContent=j.message;key.value='';note('API key berhasil disimpan')}catch(e){note(e.message,1)}loading(0)}async function savePersona(){loading(1,'Menyimpan karakter...');try{await post('/api/persona',{persona:persona.value,voice:voice.value});note('Karakter robot disimpan')}catch(e){note(e.message,1)}loading(0)}async function chatBot(){let q=message.value.trim();if(!q)return;loading(1,'Robot sedang berpikir...');try{let j=await post('/api/chat',{message:q});chat.textContent='Anda: '+q+'\n\nSASINOKIT: '+j.reply;say(j.reply);message.value=''}catch(e){chat.textContent=e.message;note(e.message,1)}loading(0)}function say(t){if(!('speechSynthesis'in window))return;let u=new SpeechSynthesisUtterance(t);u.lang='id-ID';u.rate=voice.value.includes('anak')?1.16:voice.value.includes('remaja')?1.08:1;speechSynthesis.cancel();speechSynthesis.speak(u)}function listen(){let R=window.SpeechRecognition||window.webkitSpeechRecognition;if(!R)return note('Browser ini tidak menyediakan pengenalan suara.',1);let r=new R();r.lang='id-ID';r.interimResults=false;loading(1,'Mendengarkan...');r.onresult=e=>{message.value=e.results[0][0].transcript;loading(0);chatBot()};r.onerror=()=>{loading(0);note('Mikrofon tidak dapat digunakan. Izinkan akses mikrofon di browser.',1)};r.start()}async function move(c){try{let j=await post('/api/move',{command:c});motorInfo.textContent=j.message}catch(e){note(e.message,1)}}async function learnIr(){let n=irName.value.trim();if(!n)return note('Isi nama tombol remote dahulu.',1);loading(1,'Menunggu sinyal remote...');try{let j=await post('/api/ir/learn',{device:irDevice.value,name:n});irInfo.textContent=j.message;note('Arahkan remote asli dan tekan '+n);setTimeout(()=>{loading(0);irStatus()},15000)}catch(e){loading(0);note(e.message,1)}}async function irStatus(){try{let j=await fetch('/api/ir/status').then(r=>r.json());irInfo.textContent=j.message;if(j.done)note(j.message,!j.ok)}catch(e){}}async function sendIr(){let n=irName.value.trim();if(!n)return note('Isi nama tombol remote dahulu.',1);loading(1,'Mengirim sinyal inframerah...');try{let j=await post('/api/ir/send',{device:irDevice.value,name:n});irInfo.textContent=j.message;note(j.message,!j.ok)}catch(e){note(e.message,1)}loading(0)}async function saveProfile(){if(!user.value.trim())return note('Username tidak boleh kosong.',1);loading(1,'Menyimpan profil...');try{let j=await post('/api/profile',{name:name.value,username:user.value,password:newpass.value});newpass.value='';note(j.message)}catch(e){note(e.message,1)}loading(0)}theme(localStorage.getItem('skTheme')||'v');init();setInterval(init,12000);</script>)HTML";
void drawFace(){
if(!oled.width())return;
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
uint8_t f=faceFrame++%8;
int shift=(f==2?-4:(f==3?4:0));
if(f==5){ oled.fillRoundRect(25,27,29,3,2,SSD1306_WHITE); oled.fillRoundRect(75,27,29,3,2,SSD1306_WHITE); }
else { oled.fillRoundRect(22+shift,18,31,18,7,SSD1306_WHITE); oled.fillRoundRect(75+shift,18,31,18,7,SSD1306_WHITE); oled.fillCircle(37+shift,27,5,SSD1306_BLACK); oled.fillCircle(90+shift,27,5,SSD1306_BLACK); }
if(f==6){oled.drawCircle(64,48,9,SSD1306_WHITE);oled.fillRect(54,43,20,6,SSD1306_BLACK);}
else if(f==7){oled.drawLine(53,48,75,48,SSD1306_WHITE);}
else {oled.drawLine(53,47,56,50,SSD1306_WHITE);oled.drawLine(56,50,72,50,SSD1306_WHITE);oled.drawLine(72,50,75,47,SSD1306_WHITE);}
oled.display();
}
void intro(){if(!oled.begin(SSD1306_SWITCHCAPVCC,0x3C))return;oled.clearDisplay();oled.setTextColor(SSD1306_WHITE);oled.setTextSize(2);oled.setCursor(7,13);oled.print("SASINOKIT");oled.setTextSize(1);oled.setCursor(33,42);oled.print("MEMULAI...");for(int i=0;i<3;i++){oled.fillRect(32+i*22,54,14,4,SSD1306_WHITE);oled.display();delay(1000);}drawFace();}
String irSlot(String device,String name){String x=device+"_"+name;uint32_t h=5381;for(size_t i=0;i<x.length();i++)h=((h<<5)+h)+(uint8_t)tolower(x[i]);return "ir"+String(h,HEX);}void serviceIr(){if(irLearnUntil&&millis()>irLearnUntil){irLearnUntil=0;irNotice="Waktu belajar habis. Coba lagi dan tekan tombol remote saat diminta.";}if(IrReceiver.decode()){if(irLearnUntil){IRData &d=IrReceiver.decodedIRData;if(d.protocol!=UNKNOWN){String data=String((int)d.protocol)+","+String((uint32_t)d.address)+","+String((uint32_t)d.command)+","+String((uint32_t)d.numberOfBits)+","+String((uint32_t)d.decodedRawData);pref.putString(irLearnSlot.c_str(),data);irNotice="Tombol remote berhasil dipelajari dan disimpan.";}else irNotice="Remote tidak dikenali. Coba arahkan lebih dekat atau gunakan remote lain.";irLearnUntil=0;}IrReceiver.resume();}}bool sendLearnedIr(String slot){String s=pref.getString(slot.c_str(),"");if(!s.length())return false;uint32_t v[5]={0,0,0,0,0};uint8_t n=0,start=0;for(uint16_t i=0;i<=s.length()&&n<5;i++)if(i==s.length()||s[i]==','){v[n++]=strtoul(s.substring(start,i).c_str(),NULL,10);start=i+1;}if(n!=5)return false;IRData d={};d.protocol=(decode_type_t)v[0];d.address=v[1];d.command=v[2];d.numberOfBits=v[3];d.decodedRawData=v[4];IrSender.write(&d,0);return true;}
void motor(int a,int b,int p,int v){digitalWrite(a,v>0);digitalWrite(b,v<0);ledcWrite(p,abs(v));}void drive(int l,int r){motor(FRONT_LEFT_IN1,FRONT_LEFT_IN2,FRONT_LEFT_PWM,l);motor(REAR_LEFT_IN1,REAR_LEFT_IN2,REAR_LEFT_PWM,l);motor(FRONT_RIGHT_IN1,FRONT_RIGHT_IN2,FRONT_RIGHT_PWM,r);motor(REAR_RIGHT_IN1,REAR_RIGHT_IN2,REAR_RIGHT_PWM,r);}void stopMotors(){drive(0,0);dance=false;stopAt=0;}String motion(String c){c.toLowerCase();if(c.indexOf("stop")>=0){stopMotors();return"Robot berhenti.";}if(c.indexOf("joget")>=0){dance=true;danceStep=0;nextMove=millis();return"Robot mulai joget.";}int s=190;if(c.indexOf("maju")>=0)drive(s,s);else if(c.indexOf("mundur")>=0)drive(-s,-s);else if(c.indexOf("kiri")>=0)drive(-s,s);else if(c.indexOf("kanan")>=0)drive(s,-s);else return"Perintah roda tidak dikenali.";stopAt=millis()+1400;return"Robot bergerak.";}void serviceMotors(){if(dance&&millis()>=nextMove){int m[4][2]={{175,175},{-175,-175},{175,-175},{-175,175}};drive(m[danceStep][0],m[danceStep][1]);nextMove=millis()+550;if(++danceStep==4){dance=false;stopAt=nextMove;}}if(!dance&&stopAt&&millis()>stopAt)stopMotors();}
String aiCall(String p,String m,String k,String q,bool &ok){ok=false;HTTPClient h;h.setTimeout(25000);String b;if(p=="Google Gemini"){DynamicJsonDocument d(3072);d["systemInstruction"]["parts"][0]["text"]=persona;d["contents"][0]["parts"][0]["text"]=q;serializeJson(d,b);h.begin("https://generativelanguage.googleapis.com/v1beta/models/"+m+":generateContent?key="+k);h.addHeader("Content-Type","application/json");if(h.POST(b)==200){DynamicJsonDocument o(16384);auto e=deserializeJson(o,h.getString());h.end();if(!e){ok=true;return o["candidates"][0]["content"]["parts"][0]["text"]|"AI tidak memberi jawaban.";}}}else{DynamicJsonDocument d(3072);d["model"]=m;d["messages"][0]["role"]="system";d["messages"][0]["content"]=persona;d["messages"][1]["role"]="user";d["messages"][1]["content"]=q;serializeJson(d,b);h.begin("https://api.openai.com/v1/chat/completions");h.addHeader("Content-Type","application/json");h.addHeader("Authorization","Bearer "+k);if(h.POST(b)==200){DynamicJsonDocument o(16384);auto e=deserializeJson(o,h.getString());h.end();if(!e){ok=true;return o["choices"][0]["message"]["content"]|"AI tidak memberi jawaban.";}}}String e=h.getString();h.end();return"AI tidak merespons: "+e.substring(0,90);}bool detectKey(String k,String&p,String&m){bool ok;aiCall("Google Gemini","gemini-2.5-flash",k,"Balas koneksi berhasil",ok);if(ok){p="Google Gemini";m="gemini-2.5-flash";return true;}aiCall("OpenAI GPT","gpt-4o-mini",k,"Balas koneksi berhasil",ok);if(ok){p="OpenAI GPT";m="gpt-4o-mini";return true;}return false;}void sendJson(JsonDocument&d,int c=200){String s;serializeJson(d,s);server.send(c,"application/json",s);}bool logged(){return server.hasHeader("Cookie")&&server.header("Cookie").indexOf("sk_auth=1")>=0;}bool gate(){if(logged())return true;server.send(401,"application/json","{\"message\":\"Silakan login dahulu.\"}");return false;}
void routes(){const char*h[]={"Cookie"};server.collectHeaders(h,1);server.on("/",[](){server.send_P(200,"text/html",LOGIN);});
server.on("/generate_204",[](){server.sendHeader("Location","http://192.168.4.1/",true);server.send(302,"text/plain","");});
server.on("/hotspot-detect.html",[](){server.sendHeader("Location","http://192.168.4.1/",true);server.send(302,"text/plain","");});
server.on("/connecttest.txt",[](){server.sendHeader("Location","http://192.168.4.1/",true);server.send(302,"text/plain","");});server.on("/app",[](){if(!logged()){server.sendHeader("Location","/");server.send(302);return;}server.send_P(200,"text/html",APP);});server.on("/api/login",HTTP_POST,[](){DynamicJsonDocument i(256),o(128);deserializeJson(i,server.arg("plain"));bool ok=i["username"]==username&&i["password"]==userPassword;o["ok"]=ok;if(ok)server.sendHeader("Set-Cookie","sk_auth=1; Path=/");else o["message"]="Username atau password salah.";sendJson(o,ok?200:401);});server.on("/api/logout",[](){server.sendHeader("Set-Cookie","sk_auth=; Max-Age=0; Path=/");server.send(200,"application/json","{\"ok\":true}");});server.on("/api/status",[](){if(!gate())return;DynamicJsonDocument d(1400);bool on=WiFi.status()==WL_CONNECTED;d["online"]=on;d["wifi"]=on?"Tersambung ke "+WiFi.SSID()+" | IP: "+WiFi.localIP().toString():"Belum tersambung. Hotspot SASINOKIT tetap tersedia.";d["persona"]=persona;d["voice"]=voice;d["reply"]=lastReply;d["provider"]=provider;d["model"]=model;d["name"]=robotName;d["username"]=username;sendJson(d);});server.on("/api/scan",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument d(4096);auto a=d["networks"].to<JsonArray>();int n=WiFi.scanNetworks();for(int x=0;x<n;x++){JsonObject q=a.createNestedObject();q["ssid"]=WiFi.SSID(x);q["rssi"]=WiFi.RSSI(x);}WiFi.scanDelete();sendJson(d);});server.on("/api/wifi",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(512),o(256);deserializeJson(i,server.arg("plain"));String s=i["ssid"]|"",p=i["password"]|"";if(!s.length()){o["message"]="Nama Wi-Fi kosong.";sendJson(o,400);return;}pref.putString("ssid",s);pref.putString("pass",p);WiFi.disconnect();WiFi.begin(s.c_str(),p.c_str());restartAt=millis()+2500;o["message"]="Wi-Fi disimpan. Board akan restart untuk menyambungkan ke "+s+". Setelah restart buka sasinokit-ai.local atau lihat IP pada layar OLED/router.";sendJson(o);});server.on("/api/test",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(512),o(512);deserializeJson(i,server.arg("plain"));String p,m;bool ok=WiFi.status()==WL_CONNECTED&&detectKey(i["key"]|"",p,m);testedKey=i["key"]|"";testedProvider=p;testedModel=m;o["ok"]=ok;o["message"]=ok?"Tersambung. Penyedia: "+p+" | Model: "+m:"Tidak tersambung. Periksa Wi-Fi dan API key.";sendJson(o);});server.on("/api/key",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(512),o(512);deserializeJson(i,server.arg("plain"));String k=i["key"]|"";if(k!=testedKey||!testedProvider.length()){o["message"]="Tes sambungan yang berhasil diperlukan sebelum key disimpan.";sendJson(o,400);return;}apiKey=k;provider=testedProvider;model=testedModel;pref.putString("key",apiKey);pref.putString("provider",provider);pref.putString("model",model);o["message"]="Key tersimpan. Model: "+model;sendJson(o);});server.on("/api/persona",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(2048);deserializeJson(i,server.arg("plain"));persona=i["persona"]|persona;voice=String(i["voice"]|"wanita");pref.putString("persona",persona);pref.putString("voice",voice);server.send(200,"application/json","{\"ok\":true}");});server.on("/api/profile",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(512),o(256);deserializeJson(i,server.arg("plain"));robotName=i["name"]|robotName;username=i["username"]|username;String p=i["password"]|"";if(p.length())userPassword=p;pref.putString("name",robotName);pref.putString("user",username);pref.putString("upass",userPassword);o["message"]="Profil berhasil disimpan.";sendJson(o);});server.on("/api/move",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(256),o(256);deserializeJson(i,server.arg("plain"));o["message"]=motion(i["command"]|"");sendJson(o);});server.on("/api/ir/learn",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(384),o(256);deserializeJson(i,server.arg("plain"));String d=i["device"]|"",n=i["name"]|"";if(!d.length()||!n.length()){o["message"]="Pilih perangkat dan isi nama tombol dahulu.";sendJson(o,400);return;}irLearnSlot=irSlot(d,n);irLearnUntil=millis()+12000;irNotice="Menunggu tombol remote selama 12 detik...";o["message"]=irNotice;sendJson(o);});server.on("/api/ir/status",[](){if(!gate())return;DynamicJsonDocument o(256);o["message"]=irNotice;o["done"]=irLearnUntil==0;o["ok"]=irNotice.indexOf("berhasil")>=0;sendJson(o);});server.on("/api/ir/send",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(384),o(256);deserializeJson(i,server.arg("plain"));String slot=irSlot(i["device"]|"",i["name"]|"");bool ok=sendLearnedIr(slot);o["ok"]=ok;o["message"]=ok?"Sinyal IR dikirim. Arahkan LED IR ke perangkat Anda.":"Tombol ini belum dipelajari.";sendJson(o,ok?200:404);});server.onNotFound([](){server.sendHeader("Location","http://192.168.4.1/",true);server.send(302,"text/plain","");});server.on("/api/chat",HTTP_POST,[](){if(!gate())return;DynamicJsonDocument i(1024),o(4096);deserializeJson(i,server.arg("plain"));String q=i["message"]|"",r=motion(q);if(r=="Perintah roda tidak dikenali."){if(!apiKey.length()){o["message"]="API key belum disimpan.";sendJson(o,400);return;}bool ok;r=aiCall(provider,model,apiKey,q,ok);}lastReply=r;o["reply"]=r;sendJson(o);});server.begin();}
void setup(){Serial.begin(115200);pref.begin("sasinokit",false);persona=pref.getString("persona",persona);apiKey=pref.getString("key","");provider=pref.getString("provider","");model=pref.getString("model","");robotName=pref.getString("name",robotName);username=pref.getString("user",username);userPassword=pref.getString("upass",userPassword);voice=pref.getString("voice","wanita");Wire.begin(OLED_SDA,OLED_SCL);intro();int p[]={10,11,12,13,14,15,16,17,18,21,38,39,40,41};for(int x:p)pinMode(x,OUTPUT);digitalWrite(FRONT_STBY,HIGH);digitalWrite(REAR_STBY,HIGH);ledcAttach(FRONT_LEFT_PWM,18000,8);ledcAttach(FRONT_RIGHT_PWM,18000,8);ledcAttach(REAR_LEFT_PWM,18000,8);ledcAttach(REAR_RIGHT_PWM,18000,8);IrReceiver.begin(IR_RX_PIN,DISABLE_LED_FEEDBACK);IrSender.begin(IR_TX_PIN,DISABLE_LED_FEEDBACK,0);WiFi.mode(WIFI_AP_STA);WiFi.setHostname("sasinokit-ai");WiFi.softAP("SASINOKIT","21232123");dnsServer.start(53,"*",WiFi.softAPIP());String s=pref.getString("ssid","");if(s.length())WiFi.begin(s.c_str(),pref.getString("pass","").c_str());routes();}void loop(){dnsServer.processNextRequest();server.handleClient();serviceMotors();serviceIr();static bool hadWiFi=false;bool now=WiFi.status()==WL_CONNECTED;if(now&&!hadWiFi){MDNS.begin("sasinokit-ai");mdnsStarted=true;}hadWiFi=now;if(millis()>=nextFaceAt){drawFace();nextFaceAt=millis()+850;}if(restartAt&&millis()>=restartAt)ESP.restart();delay(2);}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




