How to Build the Robotic Eyes Kit

Step-by-step photo guide to assembling the Greene Robotics animatronic Robotic Eyes Kit — an Arduino-powered DIY robotics project for beginners. No experience needed.

Beginner30–90 minutes5 sections
Fully assembled Robotic Eyes animatronic kit with blue 3D-printed parts, dual joystick controller, and Arduino electronics

Introduction

Welcome to the official Greene Robotics tutorial for the Robotic Eyes Kit! This step-by-step guide walks you through assembling and bringing to life your own animatronic eyes — just like the ones in our product photos and videos.

This tutorial pairs with the Robotic Eyes Kit, available on our Etsy shop. Every part you need is included — servos, control board, 3D-printed pieces, and wiring — so you can focus on learning, building, and having fun.

Tip: All images can be clicked on to be enlarged, so that you can zoom in to get a closer look.

Note — two kit versions

This kit comes in two versions — Joystick-Controlled and Wirelessly-Controlled. Assembly is exactly the same for both, all the way through to “How to Operate,” where you’ll pick your kit and get the right instructions from there. Color doesn’t make a difference either — every color assembles identically.

Whether you’re a beginner or an experienced maker, this project is approachable and easy to follow. By the end, you’ll have a fully functional pair of animatronic eyes ready to control. Let’s dive in!

Credit & inspiration

Before we begin, we want to give credit where it’s due. This project is inspired by an original design by James Bruton, a talented YouTuber with 1.38 million subscribers at the time of writing. Check out his channel at https://www.youtube.com/@jamesbruton. The project is based on his video, ‘How To Make Robots Move Smoothly | Arduino Tutorial.’ James released the CAD files and code under the MIT license, which permits use, modification, and distribution for commercial purposes, provided the original creator and license are acknowledged. While our project draws inspiration from his, we’ve significantly modified it to be more accessible to makers. Unlike James’s version, which uses various screw types and complex electronics, our kit uses a single M3 screw type and simplified circuitry, making it beginner-friendly and available as a complete kit. We extend a huge thank you to James Bruton for his inspiring open-source project and all the amazing content on his channel!

Our policy

Our number one goal is for you to finish this tutorial with a fully working robot. If any parts arrived damaged or broken, message us on Etsy and we’ll send replacements. However, once your build is fully functional, we’re no longer able to provide support for anything that happens after that point — including modifications, code changes, or anything else beyond the original kit.

Robotic Eyes build — Introduction: Welcome to the official Greene Robotics tutorial for building your Robotic Eyes Kit!

Assemble the Eyes

Start with the eyes. Grab an eye and the black part with the numbers 3 and 4 on the back, shown below.

Robotic Eyes build — Assemble the Eyes: We will start by putting the eyes together.

The two eyes are different — use the one that lines up with the two holes, as shown. Attach it to the mechanism with screws: the middle hole (circled green) takes a 16mm screw, the other hole (circled red) takes a 6mm screw.

Robotic Eyes build — Assemble the Eyes: Note that the two eyes are different.

It should look like the image below. Keep the screws snug but not overtight — too tight limits movement and makes the eyes harder to operate.

Robotic Eyes build — Assemble the Eyes: Once in place, it will look like the image below.

16mm screw + 6mm screw

Start two 12mm screws into the holes on the outside of the assembly, as shown. Don’t tighten them yet — just get them started.

Robotic Eyes build — Assemble the Eyes: Now, using two 12mm screws, screw them into the holes on the outside of the assembly, as shown below.

12mm screws

Get a pair of eyelids and lay them flat, like this.

Robotic Eyes build — Assemble the Eyes: Now, get a pair of eyelids and lay them flat on the table like this.

Lift them into the position shown. The center holes line up with the screws you just started, so you can tighten those fully to hold the eyelids in place.

Robotic Eyes build — Assemble the Eyes: Lift it to the position shown below.

Once tightened, the screws hold the eyelids in place while still letting them move freely.

Robotic Eyes build — Assemble the Eyes: Once you tighten the screws, they will hold the eyelids in place, and the eyelids should be able to move freely.

Connect the push rods to the eyelids with two 6mm screws, as shown. Keep them snug but not too tight.

Robotic Eyes build — Assemble the Eyes: Now, using two 6mm screws, connect the push rods to the eyelids, as shown below.

6mm screws

Nice work — that’s one finished eye! Now build the second one (unless you’re going for a cyclops). The steps are identical, just mirrored, so reference the first eye as you go.

Robotic Eyes build — Assemble the Eyes: Nice work!

Assemble the Neck

With both eyes done, it’s time to attach them to the neck — the part that moves up, down, left, and right. Grab the piece shown below.

Robotic Eyes build — Assemble the Neck: Now that you have two finished eyes, it’s time to attach them to the part that moves up and down and left and right—the neck.

Flip the eyes upside down and arrange them exactly as shown, with the eyelid push rods facing outward.

Robotic Eyes build — Assemble the Neck: Flip the eyes upside down and arrange them exactly as the image shows below.

Once in place, use four 12mm screws to tightly secure the eye mechanism to the neck base.

Robotic Eyes build — Assemble the Neck: Once they are in place, use four 12mm screws to tightly secure the eye mechanism to the neck base.

12mm screws

Place the assembly as shown. The motors should read 1 to 4, left to right — if the leftmost motor is 3, swap the eyes’ positions. Match the image exactly, with the eyelid push rods on the outside.

Robotic Eyes build — Assemble the Neck: Now, place the assembly as shown in the image below.

Next, connect the eye assembly to the part that moves it in all directions. Gather the parts shown below.

Robotic Eyes build — Assemble the Neck: Now we will connect the finished eye assembly to the part that will move it in all directions.

Set the black joint into the base as shown. Then take the smaller of the two black pins and push it all the way through the hole to secure the joint.

Robotic Eyes build — Assemble the Neck: Put the black joint into the base as shown below.

Secure it with a 6mm screw on the other side.

Robotic Eyes build — Assemble the Neck: Use a 6mm screw on the other side to secure it in place.

6mm screw

Assembled, it should look like the image below, and the black joint should rotate easily.

Robotic Eyes build — Assemble the Neck: Once it is all assembled, it should look like the image below, and the black joint should be able to rotate easily.

Get the two black rods — these push the eyes up and down for neck movement. Attach them to the servo motors by aligning the hole on each rod with the hole on the motor.

Robotic Eyes build — Assemble the Neck: Now, get the two black rods.

Secure them with two 10mm screws — snug, but not too tight.

Robotic Eyes build — Assemble the Neck: Then, use two 10mm screws to secure them in place.

10mm screws

Now attach the assembled eyes: align the hole on the bottom of the eye base with the black neck joint you installed earlier.

Robotic Eyes build — Assemble the Neck: Once all of that is in place, you can now attach the assembled eyes.

Connect the joint with the long pin, sliding it all the way through.

Robotic Eyes build — Assemble the Neck: Now, use the long pin to connect the joint.

Secure the pin with a 6mm screw so it can’t fall out, as shown.

Robotic Eyes build — Assemble the Neck: Secure this pin with a 6mm screw so that it can’t fall out, as shown below.

6mm screw

Now connect the up-and-down rods to the eyes themselves.

Robotic Eyes build — Assemble the Neck: Now we need to connect the rods that move it up and down to the actual eyes.

Use two 6mm screws, tightened to a snug fit, as shown.

Robotic Eyes build — Assemble the Neck: Just use two 6mm screws, tightened to a snug fit, as shown below.

6mm screws

Great work — the eyes and neck are fully assembled. Next, connect it all to the base.

Robotic Eyes build — Assemble the Neck: Great work!

Assemble the Base

Get the big base and the four adhesive rubber pads.

Robotic Eyes build — Assemble the Base: Now, get the big base and the four adhesive rubber pads.

Note — don’t remove the case

Do not take off the black protective case over the circuit board. It’s there to protect the board, and if you take it off, we won’t be able to offer support. Thank you!

Flip the base upside down. The four indented circles are where the rubber pads go — they keep the robot from sliding around on a surface.

Robotic Eyes build — Assemble the Base: Flip the base upside down.

Stick the pads into the circles, as shown.

Robotic Eyes build — Assemble the Base: Put the sticky pads in the circles, as shown.

Flip the base back over and find the square pin. Align it as shown, with its screw hole facing the hole in the base so you can screw it down.

Robotic Eyes build — Assemble the Base: Now, flip the base back over and find the square pin.

Once it’s in place, secure it with a 6mm screw.

Robotic Eyes build — Assemble the Base: Once it’s in place, secure it with a 6mm screw.

6mm screw

Tighten it all the way. This pin is what the rest of the eye assembly connects to.

Robotic Eyes build — Assemble the Base: Tighten the screw all the way.

Line up the square hole on the eye assembly with the pin you just secured.

Robotic Eyes build — Assemble the Base: Now, line up the square hole on the eye assembly with the pin you just screwed into the base.

Slide it onto the pin and lock them together with another 6mm screw. Everything is now securely connected.

Robotic Eyes build — Assemble the Base: Put it on the pin, then use another 6mm screw to tighten them together.

6mm screw

Good work — all the mechanical parts are now assembled and connected. The last step is wiring everything up so the components can talk to each other and bring your robot to life.

Robotic Eyes build — Assemble the Base: Good work!

Wire it up

Time to wire it up. The wiring is designed to be simple, but pay close attention here — misplaced wires can damage your robot. Don’t let that scare you; follow the directions exactly and it’ll work perfectly. Start by facing the back of your robot.

Note — two kit versions

The wiring steps in this section are the same whether you have the Joystick-Controlled Kit or the Wirelessly-Controlled Kit, right up through connecting the servos to the board. If you have the wireless board, it will look slightly different from the one pictured, with an extra set of pins — just ignore those, they aren’t used here. Your wireless kit also doesn’t include physical joysticks, so if that’s your kit, you can skip the joystick-wiring steps further down; your robot is controlled from your phone instead, covered in the “How to Operate” section.

Robotic Eyes build — Wire it up: It’s time for the wiring section.

This is the circuit board — where all the electrical signals connect and “talk” to each other. Under the black case is an Arduino Nano, a tiny programmable computer that makes the components work together.

Start by connecting the servos to the pins in the middle of the board. Each set of three pins is labeled 1–6, matching the number sticker on each motor — so plug motor 1 into pins 1, motor 2 into pins 2, and so on.

IMPORTANT — READ THIS: Wire color orientation matters most. Every servo has the same three-pin connector with three colored wires. The yellow wire must face toward the robot (toward the “Yellow wires this side” label). Every wire must follow this orientation, or it won’t work and could damage the motors.

Robotic Eyes build — Wire it up: This is the circuit board.

Below is what the motor wires look like — three color-coded wires, as mentioned. Remember, the yellow wire faces TOWARD the “Yellow wires this side” label.

Robotic Eyes build — Wire it up: Below is an image of what the motor wires look like.

Start plugging each motor’s wire into its spot, like the example below: yellow facing toward the robot, brown facing away. This is the most important step — every wire must follow this color orientation, or you risk damaging the robot.

Robotic Eyes build — Wire it up: Now, start connecting the wires from each motor to their respective spots.

Now wire every motor to the board. The motors and pins are both labeled, so plug motor 1 into pins 1, and repeat for the rest. Each must go into its matching pin, or it won’t work as intended.

Robotic Eyes build — Wire it up: Now, wire all of the motors to the circuit board.

Once they’re all plugged in, it should look like this — every yellow wire facing the robot (the “Yellow wires this side” label) and every brown wire facing away. Double-check that color orientation, then confirm each wire is in its matching pin (1 to 1, 2 to 2, etc.). If everything checks out, nice work — the servos are wired.

Robotic Eyes build — Wire it up: Once they are all plugged in, they will look like this.

Note — wireless kit

If you have the Wirelessly-Controlled Kit, you can skip ahead — your kit doesn’t have physical joysticks to wire up. Jump straight to the “How to Operate” section.

If you’re confident everything is wired into the correct spots, now’s a good time to zip-tie the wires for a cleaner build. Snug is enough — don’t overtighten — and go slow when trimming the excess so you don’t accidentally nick a wire.

Now for the joysticks, which let you control the robot.

Robotic Eyes build — Wire it up: Now we just need the joysticks so that we can control the robot.

Each side of the joystick connector’s pins is labeled 7 or 8 — that’s how you’ll connect the wires.

Robotic Eyes build — Wire it up: Notice how each side of the pins on the joystick connectors is labeled either 7 or 8.

Grab the two long wires that connect the joysticks to the board. Orient the sticker side as shown. Each wire is labeled 7 and 8 to match the joystick board — just line up 7 to 7 and 8 to 8.

Robotic Eyes build — Wire it up: Grab the two long wires.

Connect the wires to the joystick by matching the numbers, as shown.

Robotic Eyes build — Wire it up: Connect the wires to the joystick by matching the numbers, as shown below.

Now plug the other end into the circuit board. It has its own labeled sticker — match 7 to 7 and 8 to 8, with the sticker facing you, exactly like the image below.

Robotic Eyes build — Wire it up: Now we need to plug the other end of the wire into the circuit board.

Repeat with the other wire. The order doesn’t matter — as long as 7 matches 7 and 8 matches 8, it works. Once the last wire is in, it should look like the image below.

Robotic Eyes build — Wire it up: Do the same exact thing with the other wire.

Double-check everything, and if it’s all correct — congratulations, the wiring is done!

Robotic Eyes build — Wire it up: If you double-checked everything and believe it is all wired correctly, congratulations!

Unpack the two 9V batteries and put them in their slots, as shown.

Robotic Eyes build — Wire it up: Unpack the two 9V batteries and put them in their slots, as shown below.

Use the five provided zip ties to tidy the wires and finish the build — I’d use two for the servos and 9V connector, and three for the joysticks. Place them however you like, but be careful trimming the excess so you don’t cut a wire.

Robotic Eyes build — Wire it up: You can now use the five provided zip ties to tidy up some of the wires and finalize the build.

Guess what? You just built a really cool robot! Finishing a project and seeing it work is a genuinely great feeling — and it’s what robotics is all about. Stick around for the last section to learn how to control it.

Robotic Eyes build — Wire it up: Guess what?

How to Operate

Robotic Eyes kit — How to Operate step 52

Now that your robot is fully built, it’s time to bring it to life! Below you’ll find instructions on how to power it on, operate it, and keep it running smoothly, along with some helpful tips.

Quick question

Which kit do you have?

Your Robotic Eyes kit came with either two physical joysticks or a wireless WiFi control board. Pick yours below and we’ll show you the right instructions.

Joystick-Controlled Kit

Powering On the Robot: Start by plugging BOTH 9-volt battery connectors into the batteries. Using both batteries is important—if only one is connected, the robot will be underpowered, which can cause poor performance or unexpected shutdowns. Once both batteries are connected, the robot will be ready to use.

Operating the Robot: You will use the two joysticks to control the robot. Joystick 1 controls the neck movement, allowing it to move up, down, left, and right. Joystick 2 controls the eyes, including blinking and eye movement. Take your time and experiment with the controls to get a feel for how the robot responds.

Operation Notes: The robot is designed to be operated in a calm, controlled manner. Rapid or aggressive inputs can cause it to draw too much power too quickly, which may result in a temporary shutdown. If this happens, simply wait a few seconds and the robot will power back on. This behavior can also occur when the batteries are starting to run low.

Screws & Maintenance: With anything that moves, screws can occasionally loosen over time. If you notice any parts feeling loose, just tighten the screws back down snugly. The most common screws to loosen are the two 16 mm screws that hold the eyes in place, due to frequent movement.

Battery Information: The two included 9-volt batteries will last a while, but they will eventually need to be replaced. You’ll know the batteries are running low if the robot starts moving more slowly or randomly shuts off. When this happens, replace them with two standard 9-volt batteries. We recommend the Amazon Basics 9-volt batteries that came with the kit.

You Did It! Guess what? You just finished building a really cool robot! Whether you’re a beginner or an expert, completing a project and seeing it actually work is an incredibly rewarding experience. This feeling—turning an idea into something real—is what robotics is all about, and it’s a feeling many people end up chasing once they discover a passion for it. Enjoy your robot, and have fun exploring what it can do!

Wirelessly-Controlled Kit

Powering On the Robot: Start by plugging BOTH 9-volt battery connectors into the batteries. Using both batteries is important—if only one is connected, the robot will be underpowered, which can cause poor performance or unexpected shutdowns. Once both batteries are connected, the robot’s WiFi will switch on and it will be ready to use.

Connecting to Your Robot: Once everything is wired up, your board should look like the picture below.

Robotic Eyes kit — Wirelessly-Controlled Kit: Once everything is wired up, your board should look like this.

To control your robot, you’ll need a device with WiFi capabilities — most likely whatever you’re reading this tutorial on right now, like your phone, tablet, or computer.

Open your device’s Settings app. This tutorial uses an iPhone for the screenshots, but Android and Samsung devices work exactly the same way — just look for the Wi-Fi setting instead. From Settings, navigate to Wi-Fi.

Robotic Eyes kit — Wirelessly-Controlled Kit: Open your device’s Settings app and navigate to Wi-Fi.

You should see a network called “RobotEyes” in the list. If this is your first time connecting, it will likely be listed under “Other Networks.” Tap on it and wait a moment for a control screen to pop up.

Robotic Eyes kit — Wirelessly-Controlled Kit: Look for the “RobotEyes” network and tap on it to connect.

If you’ve connected to “RobotEyes” before, that pop-up screen won’t appear automatically. In that case, just open a web browser and type 192.168.4.1 into the address bar.

Once you see the control page below, you did it — you’re connected! The controls are pretty intuitive, but if you ever need a refresher, tap the “Tutorial” button at the top of the page for a full explanation of every control.

Robotic Eyes kit — Wirelessly-Controlled Kit: The RobotEyes control page — you’re all connected and ready to go.

Operating the Robot: The control page has two tabs. The Pose Page gives you a slider for each motor, so you can pose the robot exactly how you want and save named positions to recall later with one tap (a built-in “Center” pose resets everything to neutral). The Live Page gives you a slider to move the eyes side to side, an on-screen joystick to aim the neck, and a Blink button, all in real time. Tap “Tutorial” at the top of the page any time for a refresher on the controls.

Operation Notes: The robot is designed to be operated in a calm, controlled manner. Rapid or aggressive inputs can cause it to draw too much power too quickly, which may result in a temporary shutdown. If this happens, simply wait a few seconds and the robot will power back on. This behavior can also occur when the batteries are starting to run low. When you’re finished, disconnect from the “RobotEyes” network to get your phone’s normal internet access back, and unplug the batteries to save power.

Screws & Maintenance: With anything that moves, screws can occasionally loosen over time. If you notice any parts feeling loose, just tighten the screws back down snugly. The most common screws to loosen are the two 16 mm screws that hold the eyes in place, due to frequent movement.

Battery Information: The two included 9-volt batteries will last a while, but they will eventually need to be replaced. You’ll know the batteries are running low if the robot starts moving more slowly, disconnects from WiFi, or randomly shuts off. When this happens, replace them with two standard 9-volt batteries. We recommend the Amazon Basics 9-volt batteries that came with the kit.

You Did It! Guess what? You just finished building a really cool robot! Whether you’re a beginner or an expert, completing a project and seeing it actually work is an incredibly rewarding experience. This feeling—turning an idea into something real—is what robotics is all about, and it’s a feeling many people end up chasing once they discover a passion for it. Enjoy your robot, and have fun exploring what it can do!

Frequently Asked Questions

Can I use it from another device?

Yes, but it must be connected to the “RobotEyes” WiFi network before you can control it. Only one device can be connected at a time.

Can I use it in another browser?

Yes — just make sure you’re connected to the “RobotEyes” WiFi network, then open your preferred browser and type 192.168.4.1 into the address bar.

Can I access the internet while connected?

Simple answer: no. However, if you’re on a mobile device with cellular data, you can keep using cellular data as normal — the robot doesn’t provide internet access, and it doesn’t even connect to your home network.

Can I reprogram it or modify the kit?

Our policy is that we want you to get the original kit fully working first. After that, we can’t tell you what to do — you’re curious and want to play around, and that’s exactly why you’re here! But once a kit has been modified in any way, we’re no longer able to provide support, help fix it, or send replacement parts. Thank you!

Troubleshooting

Running into an issue? We’ve put together a full troubleshooting guide covering wiring problems, power issues, loose screws, and what to do if a part arrived damaged.

View the troubleshooting guide

Bonus Content

The official tutorial is officially over… so why are you still here? It’s because you’re one of the curious ones. You didn’t just want to build the robot—you want to understand how it works. And honestly, that curiosity is one of the most important skills in engineering and robotics.

Below is the exact code that comes pre-programmed onto your kit’s board. If you’re a beginner and want to learn more about coding and engineering, one great way to do that is to copy this code and paste it into an AI, then have it explain what each part does. It might feel like cheating at first, but in reality, this is how many real programmers learn and improve.

So go ahead—dig in, explore the code, and see what makes your robot come to life!

Joystick-Controlled Kit

#include <Servo.h>

// Servo objects
Servo s1, s2, s3, s4, s5, s6;

// Joystick Pins
const int joy1X = A0;
const int joy1Y = A1;
const int joy2X = A3;
const int joy2Y = A4;

// --- TUNING PARAMETERS ---
float smoothFactor = 0.1; 
float NeckSmoothFactor = 0.07;
int deadzone = 15;        

// --- OFFSETS (TRIM) ---
// Adjust these to shift the center point of specific servos
int s3Offset = -10; // User set this to -10 (Center is 80)
int s2Offset = 0;   // Currently 0 (Center is 90). Change this to trim Servo 2.

// Store current positions for smoothing
float cur3, cur2, cur5, cur6; 

void setup() {
  Serial.begin(9600);
  
  // Attach all servos
  s1.attach(2); s2.attach(3); s3.attach(4);
  s4.attach(5); s5.attach(6); s6.attach(7);

  // Initialize smoothed servos
  // S5 & S6 start at standard center (90)
  cur5 = cur6 = 90;
  
  // Initialize S3 and S2 with their specific offsets
  cur3 = 90 + s3Offset; 
  cur2 = 90 + s2Offset;
}

void loop() {
  // ==========================================
  // --- JOYSTICK 1 Y-AXIS (Servos 3 & 2) ---
  // ==========================================
  int rawY1 = analogRead(joy1Y);

  if (abs(rawY1 - 512) < deadzone) rawY1 = 512;
  
  // Base target logic (Standard 0-180 map, centered at 90)
  int targetY1 = map(rawY1, 0, 1023, 0, 180);
  targetY1 = constrain(targetY1, 45, 140); 

  // --- SMOOTHING WITH OFFSETS ---
  
  // SERVO 3 (Offset: -10)
  float target3_Final = targetY1 + s3Offset; 
  cur3 += (target3_Final - cur3) * smoothFactor;
  
  // SERVO 2 (Offset: 0 for now)
  float target2_Final = targetY1 + s2Offset;
  cur2 += (target2_Final - cur2) * smoothFactor;
  
  s3.write((int)cur3);
  s2.write((int)cur2);


  // ==========================================
  // --- JOYSTICK 1 X-AXIS (Servos 4 & 1) ---
  // ==========================================
  int rawX1 = analogRead(joy1X);
  
  int activeAngleX = constrain(map(rawX1, 0, 1023, 0, 180), 90, 180);

  s4.write(map(activeAngleX, 90, 180, 90, 150));
  
  s1.write(map(activeAngleX, 90, 180, 90, 30));


  // ==========================================
  // --- JOYSTICK 2 (Servos 5 & 6 Mix) ---
  // ==========================================
  int rawX2 = analogRead(joy2X);
  int rawY2 = analogRead(joy2Y);

  if (abs(rawX2 - 512) < deadzone) rawX2 = 512;
  if (abs(rawY2 - 512) < deadzone) rawY2 = 512;

  int moveY = map(rawY2, 0, 1023, -90, 90);
  int moveX = map(rawX2, 0, 1023, -90, 90);

  int target5Base = 90 + moveY - moveX;
  int target6Base = 90 + moveY + moveX;

  int target5 = constrain(target5Base, 30, 165); 
  int target6 = constrain(target6Base, 30, 165); 

  cur5 += (target5 - cur5) * NeckSmoothFactor;
  cur6 += (target6 - cur6) * NeckSmoothFactor;
  
  s5.write((int)cur5);
  s6.write((int)cur6);

  delay(10); 
}

Wirelessly-Controlled Kit

/*
 * RobotEyesWiFi — wireless upgrade for the Greene Robotics animatronic eyes.
 *
 * Replaces the two physical joysticks with an ESP8266 running in Access Point
 * mode. Connect to the WiFi network "RobotEyes" (no password), then open
 * http://192.168.4.1 in a browser (most phones pop the page up automatically).
 *
 * Web UI:
 *   POSE — one slider per servo (using the same ranges as the original code),
 *          plus named save/recall poses stored on the ESP8266 itself (LittleFS),
 *          so saved poses survive reboots and work from any phone. A built-in
 *          "Center" pose (all motors at 90) can't be deleted.
 *   LIVE — Eyes slider (side to side), an on-screen joystick for the neck, and a
 *          Blink button. Switching pages re-centers everything.
 *   A Tutorial button in the header (on every page) explains each control.
 *
 * PIN MAPPING — servos 1-6 wired to D3, D4, D5, D6, D7, D8 (NodeMCU / Wemos
 *   D1 mini silkscreen labels). If your board has no D-labels, edit SERVO_PIN
 *   below using the bare GPIO fallback as a guide.
 *   (Heads-up: D3=GPIO0, D4=GPIO2, and D8=GPIO15 are boot-strap pins. GPIO15
 *   in particular MUST read LOW at power-on or the ESP8266 can fail to boot
 *   at all — don't let a servo or external pull-up hold that line HIGH while
 *   the board starts up. The eyelid/eye servos may also twitch briefly at
 *   power-on; that part is harmless.)
 *
 * Board package: "esp8266 by ESP8266 Community" (Boards Manager).
 * Libraries: everything used here ships with the ESP8266 core — no extra installs.
 */

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <DNSServer.h>
#include <Servo.h>
#include <LittleFS.h>

// --- WIFI ---
const char* AP_SSID = "RobotEyes";
const char* AP_PASS = "";               // "" = open network; set 8+ chars to lock it
IPAddress apIP(192, 168, 4, 1);

// --- SERVO PINS (see header comment) ---
#if defined(D3) && defined(D8)
const uint8_t SERVO_PIN[6] = { D3, D4, D5, D6, D7, D8 };       // NodeMCU / D1 mini labels
#else
const uint8_t SERVO_PIN[6] = { 0, 2, 14, 12, 13, 15 };         // bare GPIO equivalents of D3..D8
#endif

// --- RANGES (index 0..5 = servo 1..6, taken from the original sketch) ---
// S1 eyelid: 90 open -> 30 closed      S4 eyelid: 90 open -> 150 closed
// S2 eyes:   45..140 (trim 0)          S3 eyes:   45..140 with -10 trim = 35..130
// S5/S6 neck: 30..165 differential mix
const int s2Offset = 0;
const int s3Offset = -10;
const int  SMIN[6] = { 30, 45 + s2Offset, 45 + s3Offset, 90, 30, 30 };
const int  SMAX[6] = { 90, 140 + s2Offset, 140 + s3Offset, 150, 165, 165 };
const int SHOME[6] = { 90, 90 + s2Offset, 90 + s3Offset, 90, 90, 90 };

// --- SERVO PULSE RANGE ---
// The ESP8266 Servo library defaults to 1000-2000us, which only sweeps about
// HALF of a hobby servo's travel. These match the AVR Servo library the
// original sketch used (544-2400us) so every angle lands where it did on the
// Uno and the servos use their full range again.
const uint16_t SERVO_MIN_US = 544;
const uint16_t SERVO_MAX_US = 2400;

// --- TUNING (higher = snappier / more like the direct joystick feel) ---
const float EYE_SMOOTH  = 0.22;   // servos 2 & 3 (was 0.10)
const float NECK_SMOOTH = 0.18;   // servos 5 & 6 (was 0.07)
const float LID_SMOOTH  = 1.00;   // servos 1 & 4 — instant, like the original blink
const unsigned long BLINK_HOLD_MS = 140;

Servo servos[6];
float cur[6], target[6];
int lastWritten[6];   // only re-write a servo when its integer angle changes

bool blinking = false;
unsigned long blinkStart = 0;
float lidRestore1, lidRestore4;   // eyelid targets to return to after a blink

ESP8266WebServer server(80);
DNSServer dns;

// ------------------------------------------------------------------ WEB PAGE
const char INDEX_HTML[] PROGMEM = R"HTML(<!DOCTYPE html><html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Robot Eye Control</title>
<style>
:root{--bg:#eef3fb;--card:#ffffff;--line:#e3e9f3;--txt:#16233a;--dim:#68768d;
--acc:#2f6bff;--accd:#1f4fd8;--soft:#eaf1ff;--red:#e5484d}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--txt);
background:var(--bg);min-height:100vh}
.big{width:100%;padding:16px;border-radius:14px;border:none;background:var(--acc);color:#fff;
font-size:15px;font-weight:700;letter-spacing:.02em;cursor:pointer;margin-bottom:12px;
box-shadow:0 8px 20px rgba(47,107,255,.28);transition:.15s}
.big:active{transform:translateY(1px);box-shadow:0 4px 12px rgba(47,107,255,.28)}
/* --- app chrome --- */
header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 4px;max-width:520px;margin:0 auto}
.logo{font-weight:800;font-size:18px;letter-spacing:-.01em}
.logo b{color:var(--acc)}
.rt{display:flex;align-items:center;gap:12px}
.tutbtn{padding:8px 14px;border-radius:999px;border:1.5px solid var(--line);background:#fff;color:var(--acc);
font-weight:700;font-size:13px;cursor:pointer;white-space:nowrap}
.tutbtn:active{background:var(--soft)}
nav{display:flex;gap:8px;padding:10px 18px 16px;max-width:520px;margin:0 auto}
.tab{flex:1;padding:12px;border-radius:12px;border:1px solid var(--line);background:#fff;color:var(--dim);
font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:.2s}
.tab.on{background:var(--acc);color:#fff;border-color:var(--acc);box-shadow:0 6px 16px rgba(47,107,255,.26)}
main{max-width:520px;margin:0 auto;padding:8px 18px 40px}
.cap{text-align:center;font-size:13px;color:var(--dim);line-height:1.4;margin:2px 6px 18px}
#pgLive>.card:first-child{margin-top:6px}
.page{display:none}.page.on{display:block;animation:fade .25s}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1}}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px;margin-bottom:14px;
box-shadow:0 4px 16px rgba(20,40,80,.05)}
h3{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--dim);margin-bottom:12px}
.row{margin-bottom:18px}.row:last-child{margin-bottom:4px}
.lab{display:flex;justify-content:space-between;font-size:13px;color:var(--txt);margin-bottom:6px}
.lab b{color:var(--acc);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;height:34px;appearance:none;-webkit-appearance:none;background:transparent}
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:999px;background:#dbe4f2}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:30px;height:30px;margin-top:-12px;border-radius:7px;
background:var(--acc);border:3px solid #fff;box-shadow:0 2px 7px rgba(47,107,255,.55);cursor:pointer}
input[type=range]::-moz-range-track{height:6px;border-radius:999px;background:#dbe4f2}
input[type=range]::-moz-range-thumb{width:28px;height:28px;border-radius:7px;background:var(--acc);border:3px solid #fff}
.chips{display:flex;flex-wrap:wrap;gap:10px}
.chip{display:flex;align-items:center;gap:16px;padding:14px 16px;border-radius:14px;border:1px solid var(--line);
background:#fff;color:var(--txt);font-size:15px;cursor:pointer;transition:.15s}
.chip:active{background:var(--soft)}
.chip.fixed{background:var(--soft);border-color:var(--acc);color:var(--accd)}
.chip b{font-weight:600}
.chip .x{color:var(--dim);font-weight:700;cursor:pointer;padding:4px 8px;margin:-4px -6px -4px 0;font-size:16px;border-radius:8px}
.chip .x:active{color:var(--red);background:var(--soft)}
#pad{position:relative;width:min(64vw,250px);height:min(64vw,250px);margin:6px auto;border-radius:50%;
background:radial-gradient(circle at 50% 38%,#f6f9ff,#e6eefb);border:1px solid var(--line);
box-shadow:inset 0 2px 12px rgba(20,40,80,.08);touch-action:none}
#pad::before{content:"";position:absolute;inset:18%;border-radius:50%;border:1px dashed #c7d6ee}
#knob{position:absolute;left:50%;top:50%;width:34%;height:34%;margin:-17% 0 0 -17%;border-radius:50%;pointer-events:none;
background:radial-gradient(circle at 35% 30%,#5f8bff,var(--accd) 75%);box-shadow:0 6px 16px rgba(31,79,216,.4)}
#knob.spring{transition:transform .18s ease-out}
/* --- dialogs / overlays --- */
.ovl{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(20,32,54,.45);z-index:9;padding:24px}
.ovl.on{display:flex}
.box{width:100%;max-width:340px;background:#fff;border-radius:16px;padding:22px;box-shadow:0 20px 50px rgba(20,40,80,.3)}
.box h3{margin-bottom:12px}
.box input{width:100%;padding:12px;border-radius:10px;border:1.5px solid var(--line);background:#fff;color:var(--txt);font-size:15px;outline:none;margin-bottom:14px}
.box input:focus{border-color:var(--acc)}
.btns{display:flex;gap:8px}
.btns button{flex:1;padding:12px;border-radius:10px;border:1.5px solid var(--line);background:#fff;color:var(--dim);font-weight:700;cursor:pointer}
.btns .ok{border-color:var(--acc);color:#fff;background:var(--acc)}
.btns .danger{border-color:var(--red);color:#fff;background:var(--red)}
.cmsg{color:var(--dim);font-size:14px;margin-bottom:16px;line-height:1.4}
/* --- tutorial --- */
#tutorial{position:fixed;inset:0;z-index:20;background:var(--bg);overflow-y:auto;display:none}
#tutorial.on{display:block}
.twrap{max-width:430px;margin:0 auto;padding:26px 22px 40px}
.twrap h2{text-align:center;font-size:22px;font-weight:800;margin-bottom:18px}
.tsec{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px;margin-bottom:12px;
box-shadow:0 4px 16px rgba(20,40,80,.05)}
.tsec h4{color:var(--acc);font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:10px}
.tsec dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:14px;line-height:1.45}
.tsec dt{font-weight:700}
.tsec dd{color:var(--dim)}
</style></head><body>

<!-- ============ APP ============ -->
<header>
  <div class="logo">Robot<b>Eyes</b> Control Panel</div>
  <div class="rt"><button class="tutbtn" id="tutBtn">Tutorial</button></div>
</header>
<nav><button class="tab on" id="tPose">Pose Page</button><button class="tab" id="tLive">Live Page</button></nav>
<main>
<div id="cap" class="cap">Manually control each motor to uniquely pose your robot</div>
<section class="page on" id="pgPose">
  <div class="card" id="sliders"></div>
  <button class="big" id="saveBtn">Save Position</button>
  <div class="card"><h3>Saved Poses</h3><div class="chips" id="poseList"></div></div>
</section>
<section class="page" id="pgLive">
  <div class="card"><h3>Eyes &middot; Side to Side</h3><input type="range" id="eye" min="45" max="140" value="90"></div>
  <div class="card"><h3>Neck</h3><div id="pad"><div id="knob"></div></div></div>
  <button class="big" id="blinkBtn">Blink</button>
</section>
</main>

<!-- save dialog -->
<div id="ovl" class="ovl"><div class="box"><h3>Name this pose</h3><input id="pName" maxlength="24" placeholder="e.g. Curious">
<div class="btns"><button id="pCancel">Cancel</button><button class="ok" id="pOk">Save</button></div></div></div>

<!-- delete confirm dialog -->
<div id="cfm" class="ovl"><div class="box"><h3>Delete pose?</h3><p class="cmsg" id="cfmMsg"></p>
<div class="btns"><button id="cfmNo">Cancel</button><button class="danger" id="cfmYes">Delete</button></div></div></div>

<!-- tutorial -->
<div id="tutorial"><div class="twrap">
  <h2>How it works</h2>
  <div class="tsec">
    <h4>Pose Page</h4>
    <dl>
      <dt>Sliders</dt><dd>Each slider drives one motor. Pose the eyes exactly how you want and the robot holds still there.</dd>
      <dt>Save Position</dt><dd>Names and stores the current slider positions so you can reuse them.</dd>
      <dt>Saved poses</dt><dd>Tap a pose to snap back to it instantly. Tap the &times; to delete one.</dd>
      <dt>Center</dt><dd>A built-in pose that returns every motor to neutral (90&deg;). It can't be deleted.</dd>
    </dl>
  </div>
  <div class="tsec">
    <h4>Live Page</h4>
    <dl>
      <dt>Blink</dt><dd>Closes and re-opens the eyelids once — works even while you're moving the eyes or neck.</dd>
      <dt>Eyes slider</dt><dd>Moves the eyes side to side in real time.</dd>
      <dt>Neck joystick</dt><dd>Drag to aim the neck as you go; let go and it recenters. Switching pages centers everything.</dd>
    </dl>
  </div>
  <div class="tsec">
    <h4>Good to Know</h4>
    <dl>
      <dt>When done</dt><dd>Unplug the robot when you're finished to preserve its battery.</dd>
      <dt>Internet</dt><dd>Disconnect from the &ldquo;RobotEyes&rdquo; Wi-Fi network to get your normal internet access back.</dd>
      <dt>Saved poses</dt><dd>Your saved poses are kept even when the robot is powered off.</dd>
      <dt>Another browser</dt><dd>You can reopen the controls in any browser by going to <b>192.168.4.1</b> &mdash; as long as that device is connected to the &ldquo;RobotEyes&rdquo; network.</dd>
    </dl>
  </div>
  <button class="big" id="tutClose">Got it</button>
</div></div>

<script>
var $=function(i){return document.getElementById(i)};

// ---- tutorial overlay ----
$("tutBtn").onclick  =function(){$("tutorial").classList.add("on")};
$("tutClose").onclick=function(){$("tutorial").classList.remove("on")};

// ---- control queue (batched, throttled) ----
var pend={},busy=false;
function q(o){for(var k in o)pend[k]=o[k]}
function ok(v){}
setInterval(function(){
 if(busy)return;var k=Object.keys(pend);if(!k.length)return;
 var s=k.map(function(x){return x+"="+pend[x]}).join("&");pend={};busy=true;
 fetch("/ctl?"+s).then(function(){ok(1)}).catch(function(){ok(0)}).finally(function(){busy=false});
},50);

// ---- tabs ----
function show(t){
 $("pgPose").classList.toggle("on",t=="pose");$("pgLive").classList.toggle("on",t=="live");
 $("tPose").classList.toggle("on",t=="pose");$("tLive").classList.toggle("on",t=="live");
 $("cap").textContent = t=="pose" ? "Manually control each motor to uniquely pose your robot"
                                  : "Control your robot in real time.";
 centerAll();   // always re-center when switching pages so nothing carries over
}
$("tPose").onclick=function(){show("pose")};
$("tLive").onclick=function(){show("live")};

// ---- pose sliders ----
var CFG=[["Left Eyelid",30,90,90],["Left Eye",45,140,90],["Right Eye",35,130,80],
         ["Right Eyelid",90,150,90],["Neck Left",30,165,90],["Neck Right",30,165,90]];
CFG.forEach(function(c,i){
 var d=document.createElement("div");d.className="row";
 d.innerHTML='<div class="lab"><span>'+c[0]+'</span><b id="v'+i+'">'+c[3]+'</b></div>'+
             '<input type="range" id="p'+i+'" min="'+c[1]+'" max="'+c[2]+'" value="'+c[3]+'">';
 $("sliders").appendChild(d);
 d.querySelector("input").addEventListener("input",function(e){
  $("v"+i).textContent=e.target.value;var o={};o["s"+(i+1)]=e.target.value;q(o);
 });
});

// set every slider + servo to a list of six angles
function recall(vals){
 var o={};
 for(var i=0;i<6;i++){o["s"+(i+1)]=vals[i];$("p"+i).value=vals[i];$("v"+i).textContent=vals[i]}
 q(o);
}
function centerAll(){recall([90,90,90,90,90,90]);$("eye").value=90}

function sync(){
 fetch("/state").then(function(r){return r.json()}).then(function(j){
  j.t.forEach(function(v,i){$("p"+i).value=v;$("v"+i).textContent=v});
  ok(1);
 }).catch(function(){ok(0)});
}
setInterval(function(){
 if(!Object.keys(pend).length&&!busy)fetch("/state").then(function(){ok(1)}).catch(function(){ok(0)});
},4000);

// ---- saved poses ----
function centerChip(){
 var c=document.createElement("div");c.className="chip fixed";
 c.innerHTML='<b>Center</b>';
 c.onclick=function(){recall([90,90,90,90,90,90])};
 return c;
}
function loadPoses(){
 fetch("/poses").then(function(r){return r.text()}).then(function(t){
  var box=$("poseList");box.innerHTML="";box.appendChild(centerChip());
  t=t.trim();if(!t)return;
  t.split("\n").forEach(function(l){
   var p=l.split(";");if(p.length<7)return;
   var c=document.createElement("div");c.className="chip";
   var n=document.createElement("b");n.textContent=p[0];
   var x=document.createElement("span");x.className="x";x.textContent="✕";
   c.appendChild(n);c.appendChild(x);
   c.onclick=function(){recall([+p[1],+p[2],+p[3],+p[4],+p[5],+p[6]])};
   x.onclick=function(e){e.stopPropagation();askDelete(p[0])};
   box.appendChild(c);
  });
 }).catch(function(){var box=$("poseList");box.innerHTML="";box.appendChild(centerChip())});
}

// confirm before deleting a saved pose
var delName=null;
function askDelete(name){delName=name;$("cfmMsg").textContent='Delete "'+name+'"? This can’t be undone.';$("cfm").classList.add("on")}
$("cfmNo").onclick=function(){$("cfm").classList.remove("on");delName=null};
$("cfmYes").onclick=function(){
 if(!delName)return;
 fetch("/pose/del?name="+encodeURIComponent(delName)).catch(function(){})
  .finally(function(){$("cfm").classList.remove("on");delName=null;loadPoses()});
};
$("saveBtn").onclick=function(){$("pName").value="";$("ovl").classList.add("on");$("pName").focus()};
$("pCancel").onclick=function(){$("ovl").classList.remove("on")};
$("pName").addEventListener("keydown",function(e){if(e.key=="Enter")$("pOk").click()});
$("pOk").onclick=function(){
 var n=$("pName").value.trim();if(!n)return;
 var u="/pose/save?name="+encodeURIComponent(n);
 for(var i=0;i<6;i++)u+="&s"+(i+1)+"="+$("p"+i).value;
 fetch(u).catch(function(){}).finally(function(){$("ovl").classList.remove("on");loadPoses()});
};

// ---- live: blink / eyes / neck joystick ----
// Fire on pointerdown (not click) so a second finger can blink WHILE the other
// hand is dragging the slider or joystick — mobile suppresses the synthesized
// click of a second touch during an active drag, but pointerdown still fires.
function doBlink(e){if(e&&e.cancelable)e.preventDefault();fetch("/blink").catch(function(){})}
$("blinkBtn").addEventListener("pointerdown",doBlink);
// Reversed so dragging right looks left and vice versa (185 = 45+140, the range's mirror point).
$("eye").addEventListener("input",function(e){q({eye:185-(+e.target.value)})});

var pad=$("pad"),knob=$("knob"),liveDrag=false;
function setJoy(e){
 var r=pad.getBoundingClientRect(),R=r.width/2;
 var dx=e.clientX-(r.left+R),dy=e.clientY-(r.top+R);
 var m=Math.hypot(dx,dy),max=R*0.62;
 if(m>max){dx*=max/m;dy*=max/m}
 knob.style.transform="translate("+dx+"px,"+dy+"px)";
 q({nx:Math.round(dx/max*90),ny:Math.round(-dy/max*90)});
}
pad.addEventListener("pointerdown",function(e){liveDrag=true;knob.classList.remove("spring");pad.setPointerCapture(e.pointerId);setJoy(e);e.preventDefault()});
pad.addEventListener("pointermove",function(e){if(liveDrag)setJoy(e)});
function joyEnd(){if(!liveDrag)return;liveDrag=false;knob.classList.add("spring");knob.style.transform="translate(0,0)";q({nx:0,ny:0})}
pad.addEventListener("pointerup",joyEnd);
pad.addEventListener("pointercancel",joyEnd);

sync();loadPoses();
</script></body></html>)HTML";

// ------------------------------------------------------------------ HELPERS
int clampS(int i, int v) { return constrain(v, SMIN[i], SMAX[i]); }

String sanitizeName(String n) {
  n.replace(";", ""); n.replace("\n", ""); n.replace("\r", ""); n.trim();
  if (n.length() > 24) n = n.substring(0, 24);
  return n;
}

// ------------------------------------------------------------------ HANDLERS
void handleRoot() {
  server.send_P(200, "text/html", INDEX_HTML);
}

// One endpoint for all realtime control. Accepts any subset of:
//   s1..s6        direct servo targets (Pose sliders / recalled poses)
//   eye           45..140, drives S2 + S3 with their trims (Live slider)
//   nx, ny        -90..90 joystick, mixed onto S5/S6
void handleCtl() {
  for (int i = 0; i < 6; i++) {
    String k = "s" + String(i + 1);
    if (!server.hasArg(k)) continue;
    float v = clampS(i, server.arg(k).toInt());
    // If a blink is mid-flight, park eyelid values as the post-blink restore
    if (blinking && i == 0)      lidRestore1 = v;
    else if (blinking && i == 3) lidRestore4 = v;
    else target[i] = v;
  }
  if (server.hasArg("eye")) {
    int v = constrain(server.arg("eye").toInt(), 45, 140);
    target[1] = clampS(1, v + s2Offset);
    target[2] = clampS(2, v + s3Offset);
  }
  if (server.hasArg("nx") || server.hasArg("ny")) {
    int nx = constrain(server.arg("nx").toInt(), -90, 90);   // horizontal drag
    int ny = constrain(server.arg("ny").toInt(), -90, 90);   // vertical drag
    // vertical (ny) tilts up/down (differential), horizontal (nx) pans (common).
    // Both terms negated so drag direction matches the neck (up=up, left=left).
    target[4] = clampS(4, 90 - nx + ny);
    target[5] = clampS(5, 90 - nx - ny);
  }
  server.send(200, "text/plain", "ok");
}

void handleBlink() {
  if (!blinking) {
    blinking = true;
    blinkStart = millis();
    lidRestore1 = target[0];
    lidRestore4 = target[3];
    target[0] = SMIN[0];   // S1 closed = 30
    target[3] = SMAX[3];   // S4 closed = 150
  }
  server.send(200, "text/plain", "ok");
}

void handleState() {
  String j = "{\"t\":[";
  for (int i = 0; i < 6; i++) { j += String((int)(target[i] + 0.5f)); if (i < 5) j += ","; }
  j += "]}";
  server.send(200, "application/json", j);
}

void handlePoses() {
  File f = LittleFS.open("/poses.txt", "r");
  if (!f) { server.send(200, "text/plain", ""); return; }
  server.streamFile(f, "text/plain");
  f.close();
}

void handlePoseSave() {
  String name = sanitizeName(server.arg("name"));
  if (!name.length()) { server.send(400, "text/plain", "bad name"); return; }

  // "Center" is the reserved built-in pose — don't let a saved one shadow it
  String low = name; low.toLowerCase();
  if (low == "center") { server.send(400, "text/plain", "reserved name"); return; }

  String line = name;
  for (int i = 0; i < 6; i++)
    line += ";" + String(clampS(i, server.arg("s" + String(i + 1)).toInt()));

  // Rewrite the file, replacing any pose with the same name
  String out = ""; int count = 0;
  File f = LittleFS.open("/poses.txt", "r");
  if (f) {
    while (f.available()) {
      String l = f.readStringUntil('\n'); l.trim();
      if (!l.length()) continue;
      if (l.substring(0, l.indexOf(';')) != name) { out += l + "\n"; count++; }
    }
    f.close();
  }
  if (count >= 30) { server.send(400, "text/plain", "pose limit reached"); return; }
  out += line + "\n";

  File w = LittleFS.open("/poses.txt", "w");
  if (!w) { server.send(500, "text/plain", "fs error"); return; }
  w.print(out); w.close();
  server.send(200, "text/plain", "ok");
}

void handlePoseDel() {
  String name = sanitizeName(server.arg("name"));
  String out = "";
  File f = LittleFS.open("/poses.txt", "r");
  if (f) {
    while (f.available()) {
      String l = f.readStringUntil('\n'); l.trim();
      if (!l.length()) continue;
      if (l.substring(0, l.indexOf(';')) != name) out += l + "\n";
    }
    f.close();
  }
  File w = LittleFS.open("/poses.txt", "w");
  if (w) { w.print(out); w.close(); }
  server.send(200, "text/plain", "ok");
}

// Captive-portal style redirect: any unknown URL goes to the control page,
// which makes most phones pop the UI up right after connecting.
void handleNotFound() {
  server.sendHeader("Location", String("http://") + apIP.toString() + "/", true);
  server.send(302, "text/plain", "");
}

// ------------------------------------------------------------------ SETUP/LOOP
void setup() {
  Serial.begin(115200);
  LittleFS.begin();

  // Servos: attach (with full-travel pulse range) and park at home position
  for (int i = 0; i < 6; i++) {
    cur[i] = target[i] = SHOME[i];
    servos[i].attach(SERVO_PIN[i], SERVO_MIN_US, SERVO_MAX_US);
    servos[i].write(SHOME[i]);
    lastWritten[i] = SHOME[i];
  }

  // Access point
  WiFi.mode(WIFI_AP);
  WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
  if (strlen(AP_PASS) >= 8) WiFi.softAP(AP_SSID, AP_PASS);
  else                      WiFi.softAP(AP_SSID);
  dns.start(53, "*", apIP);   // answer every DNS query with our IP

  server.on("/",          handleRoot);
  server.on("/ctl",       handleCtl);
  server.on("/blink",     handleBlink);
  server.on("/state",     handleState);
  server.on("/poses",     handlePoses);
  server.on("/pose/save", handlePoseSave);
  server.on("/pose/del",  handlePoseDel);
  server.onNotFound(handleNotFound);
  server.begin();

  Serial.println();
  Serial.print("AP \"");  Serial.print(AP_SSID);
  Serial.print("\" up — control page at http://"); Serial.println(apIP);
}

unsigned long lastUpdate = 0;

void loop() {
  dns.processNextRequest();
  server.handleClient();

  // End-of-blink: reopen the eyelids to wherever they were before
  if (blinking && millis() - blinkStart >= BLINK_HOLD_MS) {
    target[0] = lidRestore1;
    target[3] = lidRestore4;
    blinking = false;
  }

  // 100 Hz smoothing tick — same easing behaviour as the original loop()
  if (millis() - lastUpdate >= 10) {
    lastUpdate = millis();
    cur[0] += (target[0] - cur[0]) * LID_SMOOTH;
    cur[1] += (target[1] - cur[1]) * EYE_SMOOTH;
    cur[2] += (target[2] - cur[2]) * EYE_SMOOTH;
    cur[3] += (target[3] - cur[3]) * LID_SMOOTH;
    cur[4] += (target[4] - cur[4]) * NECK_SMOOTH;
    cur[5] += (target[5] - cur[5]) * NECK_SMOOTH;
    // Only push a new pulse width when the whole-degree angle actually changes.
    // While a servo is holding still we stop writing, so the library's steady
    // timer pulses hold position instead of us nudging it 100x/sec (less jitter).
    for (int i = 0; i < 6; i++) {
      int a = (int)(cur[i] + 0.5f);
      if (a != lastWritten[i]) { servos[i].write(a); lastWritten[i] = a; }
    }
  }
}
0%