Community project

Realtime AI Voice Assistant

ESP32
Photo of Realtime AI Voice Assistant
Generated with AI

Sichatbot

Published October 26, 2026

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

Wiring diagram for Realtime AI Voice Assistant

Gather all the parts

QtyComponent
1

0.96" OLED Display 128x64 I2C (Blue)

Monochrome 0.96 inch OLED display with 128 x 64 blue pixels, driven by an SSD1306 controller over I2C (address 0x3C). It runs from 3.3 to 5 V and connects through a four-pin VCC, GND, SCL, SDA header.

1

HiLetgo INMP441 I²S Microphone Module

Omnidirectional 24-bit I²S MEMS microphone module based on the TDK InvenSense INMP441 IC. Outputs digital audio over a 3-wire I²S bus (SCK/BCLK, WS/LRCLK, SD/DOUT). Supply 1.8–3.3 V; native 3.3 V operation with no level shifting required. L/R channel select pin: tie to GND for left-channel mono output or VDD for right-channel mono output. Use the ESP32 Arduino core or ESP-IDF I2S peripheral API; no separate PlatformIO library is required.

1

TB6612FNG dual H-bridge motor-driver module (front wheels)

Modul penggerak dua motor yang menerima sinyal kecil dari ESP32 lalu memberi daya aman ke roda depan.

1

TB6612FNG dual H-bridge motor-driver module (rear wheels)

Modul penggerak dua motor yang menerima sinyal kecil dari ESP32 lalu memberi daya aman ke roda belakang.

1

TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio

TT-format bi-metal DC gearbox motor with a 1:90 reduction ratio - roughly double the torque and half the speed of the more common 1:48 TT motor. Runs on 3-6VDC (60-120 RPM no-load depending on voltage), has steel output gears for durability, and is one of several TT-motor gearbox ratios (1:48, 1:90, 1:120) used across 2WD/4WD robot car and crawler chassis kits, this one favoring torque/climbing over speed. Ships with no wires attached and cannot be driven directly from a microcontroller pin; requires a separate high-current motor driver such as a DRV8833 or TB6612FNG.

1

TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio

TT-format bi-metal DC gearbox motor with a 1:90 reduction ratio - roughly double the torque and half the speed of the more common 1:48 TT motor. Runs on 3-6VDC (60-120 RPM no-load depending on voltage), has steel output gears for durability, and is one of several TT-motor gearbox ratios (1:48, 1:90, 1:120) used across 2WD/4WD robot car and crawler chassis kits, this one favoring torque/climbing over speed. Ships with no wires attached and cannot be driven directly from a microcontroller pin; requires a separate high-current motor driver such as a DRV8833 or TB6612FNG.

1

TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio

TT-format bi-metal DC gearbox motor with a 1:90 reduction ratio - roughly double the torque and half the speed of the more common 1:48 TT motor. Runs on 3-6VDC (60-120 RPM no-load depending on voltage), has steel output gears for durability, and is one of several TT-motor gearbox ratios (1:48, 1:90, 1:120) used across 2WD/4WD robot car and crawler chassis kits, this one favoring torque/climbing over speed. Ships with no wires attached and cannot be driven directly from a microcontroller pin; requires a separate high-current motor driver such as a DRV8833 or TB6612FNG.

1

TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio

TT-format bi-metal DC gearbox motor with a 1:90 reduction ratio - roughly double the torque and half the speed of the more common 1:48 TT motor. Runs on 3-6VDC (60-120 RPM no-load depending on voltage), has steel output gears for durability, and is one of several TT-motor gearbox ratios (1:48, 1:90, 1:120) used across 2WD/4WD robot car and crawler chassis kits, this one favoring torque/climbing over speed. Ships with no wires attached and cannot be driven directly from a microcontroller pin; requires a separate high-current motor driver such as a DRV8833 or TB6612FNG.

1

Lithium Ion Battery - 7.4V 1000mAh (2S LiPo)

7.4V (2S) 1000mAh lithium polymer battery pack with a 25C continuous discharge rate, JST-XH charge plug, and Deans discharge connector; sized for R/C vehicles, robotics, and other portable projects needing a compact 2-cell high-current supply.

1

24v Buck Converter

Set output to 5.0 V before connecting the ESP32

LM2596-based adjustable step-down buck converter module. Commonly used to regulate a higher battery rail, such as a 2S 18650 pack, down to 5V for Arduino logic. It is a regulator, not a charger or battery protection board.

1

Flying Fish IR Receiver Module

Modul penerima inframerah yang membaca sinyal dari remote milik Anda untuk dipelajari robot.

1

940 nm Infrared LED

LED inframerah yang mengirim ulang perintah remote ke perangkat milik Anda.

1

2N3904 NPN Transistor

General-purpose NPN bipolar junction transistor (BJT) in a TO-92 package. Rated for 60V collector-emitter voltage and 200mA continuous collector current with an hFE (gain) of 100–300. Commonly used as a low-side switch or signal amplifier driven by a microcontroller digital output pin via a current-limiting base resistor.

1

1 kOhm Resistor

1 kOhm

Resistor pembatas arus dari GPIO ke kaki basis transistor pemancar IR.

1

100 Ohm Resistor

100 Ohm

Resistor pembatas arus untuk LED inframerah agar LED tidak rusak.

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"

Functionoled_1ESP32
powerVCC → OLED power3V3
groundGND → OLED groundGND
i2cSDA → OLED display dataGPIO 8
i2cSCL → OLED display clockGPIO 9

2. Connections between "mic_1" and "ESP32"

Functionmic_1ESP32
powerVDD → Microphone power3V3
groundGND → Microphone groundGND
digitalSCK → Microphone I2S bit clockGPIO 4
digitalWS → Microphone I2S word clockGPIO 5
dataSD → Microphone I2S data to ESP32GPIO 6
groundL/R → Select the left microphone channelGND

3. Connections between "robot_battery" and "ESP32"

Functionrobot_batteryESP32
powerBAT+ → 24v Buck Converter VIN+EXT
groundBAT- → 24v Buck Converter VIN-EXT

4. Connections between "power_buck" and "ESP32"

Functionpower_buckESP32
powerVOUT+ → Regulated 5 V for board and motors5V
groundVOUT- → Shared ground returnGND

5. Connections between "motor_driver_front" and "ESP32"

Functionmotor_driver_frontESP32
powerVM → 5 V motor power5V
powerVCC → 3.3 V logic power3V3
groundGND → Shared groundGND
digitalAIN1 → Left wheel forward directionGPIO 10
digitalAIN2 → Left wheel reverse directionGPIO 11
pwmPWMA → Left wheel speed PWMGPIO 12
digitalBIN1 → Right wheel forward directionGPIO 13
digitalBIN2 → Right wheel reverse directionGPIO 14
pwmPWMB → Right wheel speed PWMGPIO 15
digitalSTBY → Enable motor driverGPIO 16
powerA01 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M1EXT
powerA02 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M2EXT
powerB01 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M1EXT
powerB02 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M2EXT

6. Connections between "motor_driver_rear" and "ESP32"

Functionmotor_driver_rearESP32
powerVM → 5 V motor power5V
powerVCC → 3.3 V logic power3V3
groundGND → Shared groundGND
powerA01 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M1EXT
powerA02 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M2EXT
powerB01 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M1EXT
powerB02 → TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M2EXT
digitalAIN1 → Left rear wheel forward directionGPIO 17
digitalAIN2 → Left rear wheel reverse directionGPIO 18
pwmPWMA → Left rear wheel speed PWMGPIO 21
digitalBIN1 → Right rear wheel forward directionGPIO 38
digitalBIN2 → Right rear wheel reverse directionGPIO 39
pwmPWMB → Right rear wheel speed PWMGPIO 40
digitalSTBY → Enable rear motor driverGPIO 41

7. Connections between "ir_receiver_1" and "ESP32"

Functionir_receiver_1ESP32
powerVCC → 3.3 V power for receiver3V3
groundGND → Shared groundGND
digitalOUT → Learned remote signal inputGPIO 2

8. Connections between "ir_base_resistor" and "ESP32"

Functionir_base_resistorESP32
digitalEND 1 → 38 kHz IR carrier signal from ESP32GPIO 3
digitalEND 2 → 2N3904 NPN Transistor Base (B)EXT

9. Connections between "ir_tx_transistor" and "ESP32"

Functionir_tx_transistorESP32
groundEmitter (E) → Shared groundGND
dataCollector (C) → 940 nm Infrared LED CATHODEEXT

10. Connections between "ir_led_resistor" and "ESP32"

Functionir_led_resistorESP32
powerEND 1 → 5 V supply for IR LED5V
digitalEND 2 → 940 nm Infrared LED ANODEEXT

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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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.

Open in Schematik