linefollower_instruction_site/index.html

1590 lines
61 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Responsive Robot Lessons</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<link rel="stylesheet" href="styles.css" />
<style>
</style>
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Tabs -->
<div class="bg-white shadow sticky top-0 z-10">
<div class="max-w-8xl mx-auto px-4 py-4 flex space-x-6 select-none">
<button class="tab-btn active text-blue-600" data-target="lesson1">Environment Setup</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson2">Uploading Code</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson3">Motors</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson4">Color Sensors</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson5">I2C Multiplexing</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson6">OLED Display</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson7">RGB LED(Neopixel)</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson8">Sonar</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson8b">Sonar Filtering</button>
<button class="tab-btn text-gray-600 hover:text-blue-600 hidden" data-target="lesson9">Motor
Encoders</button>
<button hidden class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson10">Reciever</button>
<button class="tab-btn text-gray-600 hover:text-blue-600" data-target="lesson11">PID</button>
<!-- Add more tabs here -->
</div>
</div>
<!-- Lessons container -->
<div class="max-w-7xl mx-auto px-6 py-8 space-y-8">
<!-- Lesson 1 -->
<section id="lesson1" class="lesson-content">
<h1 class="text-3xl font-bold mt=0 mb-6">Setting up our MicroPython toolchain</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Setting up the IDE</h2>
<p>There are many IDEs that we can use to program MicroPython robot, we'll be using one called
"Thonny". So let's begin by downloading it.</p>
<h3>Download</h3>
<ul class="ml-6 list-disc">
<li><a href="https://github.com/thonny/thonny/releases/download/v4.1.7/thonny-4.1.7.exe"
class="text-blue-600 hover:underline">Windows</a>
</li>
<li><a href="https://github.com/thonny/thonny/releases/download/v4.1.7/thonny-4.1.7.pkg"
class="text-blue-600 hover:underline">Mac</a></li>
</ul>
<p>You can also get it from the Thonny website, <a
href="https://thonny.org/">https://thonny.org/</a></p>
</div>
<div>
</div>
<!-- Step 2: Image -->
<div class="prose">
<h2>Step 2: Install MicroPython firmware on board</h2>
<ol class="list-decimal ml-6">
<li>In Thonny, click on the Run->Configure Interpreter buttons at the top of the window.</li>
<li>Choose "MicroPython (Raspberry Pi Pico) from the first dropdown menu.</li>
<li>Click the "<u>Install or update MicroPython</u>" button down the bottom-right of the window.
</li>
<li>You need to put your device in FLASH mode, hold down the BOOT button, press and release the
RESET button.</li>
<li>You should now find a drive called "RPI-RP2". in the Target volume dropdown, shoose it.</li>
<li>Choose the variant "Raspberry Pi Pico - Pico / Pico H"</li>
<li>Press Install button to install the firmware.</li>
<li>Reset the device again with the RESET button.</li>
<li>Press the cancel button to close the firmware update panel, then press OK to officially
begin coding.</li>
</ol>
</div>
<div>
<img src="images/thonny_firmware.png" alt="Robot moving further"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<div>
<img src="images/rp2040zero.png" alt="Robot moving further"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
</div>
</section>
<!-- Lesson 2 (hidden initially) -->
<section id="lesson2" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">The Thonny IDE</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Open/Create the main.py file</h2>
<p>When your device is plugged into your computer, you should be able to press the File->Load
button, and choose "Raspberry Pi Pico" to load from.</p>
<p>If no such file exists we need to make it, press File->New, and then save it to the Raspberry Pi
Pico as "main.py"</p>
</div>
<div>
</div>
<!-- Step 2: Image -->
<div class="prose">
<h2>Step 2: Open the Serial Monitor</h2>
<p>To get messages back from your device, and to read errors, press the view->shellbutton and make
sure
you have the shell window open at the bottom.</p>
</br>
<h2>Step 3: Hello World</h2>
<p>Your code is only updated on your device when you press "Run". If you write the code
<code>print("Hello World")</code> and then save, yuou should see the message appear in the
Serial monitor.
</p>
<p>Note that the code won't persist on the robot after a reset unless you also SAVE it to the
device, RUN just runs it once</p>
</br>
<h2>Step 4: Code all the things!</h2>
<p>From here you can start to write code, if you need a refresher on basic python code, go to the
online lessons here.</p>
<p><a href="https://realrobots.net/robocode/">https://realrobots.net/robocode/</a></p>
</p>
</div>
<div>
<img src="images/thonny_code.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
</div>
</section>
<!-- Lesson 3 (hidden initially) -->
<section id="lesson3" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Driving a Motor</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Wiring</h2>
<p>!!DO <strong>NOT</strong> CONNECT POSITIVE BATTERY DIRECTLY TO THE MICROCONTROLLER!!</p>
</br>
<p>The connections on the RP2040 all use 3.3v (3v3), which is far too little to drive the motors. It
can drive a motor DRIVER though. The motor driver lets us control the larger voltage from the
battery to the motor.</p>
</br>
<p><code>MOTOR+ (red) ------------- DRIVER OUT1</code></p>
<p><code>MOTOR- (black) ----------- DRIVER OUT2</code></p>
<p><code>RP2040 GP9 --------------- DRIVER IN2</code></p>
<p><code>RP2040 GP8 --------------- DRIVER IN1</code></p>
<p><code>BATTERY+ (red) ----------- DRIVER VCC</code></p>
<p><code>BATTERY- (black) --------- DRIVER GND --------- RP2040 GND</code></p>
</br>
<p>The driver is powered by the battery, but the microcontroller with be powered by the USB. It's
very important that the positive battery voltage is never connected directly to the RP2040 as
that will immediately release the magic smoke.</p>
<p>The GND on the RP2040, driver, and battery must all be connected though. The GND is 0 volts, and
is used as a reference to make sure 0 is the same on every device.</p>
</div>
<div>
<img src="images/motor_instructions_chalk.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding</h2>
<p>We control the motor driver by switching which of the OUT1 and OUT2 connections is the positive
and negative. When one is "high" and the other "low", the motor spins one way. If we switch
which is high and low the motor will spin the other way.</p>
</br>
<p>To set OUT1 to max voltage, we set IN1 to 3.3v, the same for OUT2 and IN2. We can get finer
control by using PWM controls, which turn the pins high and low quickly enough that it simulates
voltages in between 0v and 3.3v</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
from machine import Pin, PWM
import time
motorIN1 = PWM(Pin(8))
motorIN2 = PWM(Pin(9))
# DRIVE FORWARD
motorIN1.duty_u16(65535) # This is the maximum value
motorIN2.duty_u16(0) # This is the minimum value
time.sleep(1)
# DRIVE BACKWARD
motorIN1.duty_u16(0)
motorIN2.duty_u16(65535)
time.sleep(1)
# STOP
motorIN1.duty_u16(0)
motorIN2.duty_u16(0) </code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: Making it easier</h2>
<p>This works, but takes a lot of typing each time, and is a little hard to read. On the right we
can see a function which lets us drive the motor with a single command, and set the speed with a
number from -100 to 100.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
from machine import Pin, PWM
# Initialize motor PWM pins
motorIN1 = PWM(Pin(8))
motorIN2 = PWM(Pin(9))
def motor(power):
# Make sure power is never greater than 100 or less than -100
if power > 100:
power = 100
elif power < -100:
power = -100
# Convert 0-100 value, to 0 to 65535
duty = abs(power) * 65535 // 100
# Apply power to the correct motor pin
if power > 0:
motorIN1.duty_u16(duty)
motorIN2.duty_u16(0)
elif power < 0:
motorIN1.duty_u16(0)
motorIN2.duty_u16(duty)
else:
motorIN1.duty_u16(0)
motorIN2.duty_u16(0)
# TESTS
motor(100) # FULL FORWARD
time.sleep(3)
motor(-100) # FULL REVERSE
time.sleep(3)
# Start at FULL REVERSE and slowly change from -100 to 100
for i in range(-100, 100):
motor(i)
print(i)
time.sleep(0.1) </code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 4: Making it professional</h2>
<p>Finally, to make our final code modular, cleaner, and easier to understand we can offload all out
motor code into a new module.</p>
</br>
<p>We make a second file called <code>motor.py</code> which we'll import and call from our main
<code>code.py</code> file.
</p>
<p>Now all we need to do from the main code is:</p>
</br>
<p>Import the module</p>
<p><code>from motor import Motor</code></p>
</br>
<p>Initialize a motor object</p>
<p><code>left_motor = Motor(8, 9)</code></p>
</br>
<p>Set the power</p>
<p><code>left_motor.move(100)</code></p>
</br>
<p>You can also add a second, third, or 20th motor at any time just by initializing another
Motor object.</p>
<p><code>right_motor = Motor(10, 11)</code></p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm">
<code class="language-python">
# code.py
import time
import motor
left_motor = motor.Motor(9,8)
# TESTS
left_motor.move(100) # FULL FORWARD
time.sleep(2)
left_motor.move(-100) # FULL REVERSE
time.sleep(2)
left_motor.move(0)
# Start at FULL REVERSE and slowly change from -100 to 100
for i in range(-100, 100):
left_motor.move(i)
print(i)
time.sleep(0.1)
</code></pre>
</br>
<pre class="bg-gray-100 p-4 rounded shadow text-sm">
<code class="language-python">
# motor.py
from machine import Pin, PWM
class Motor:
def __init__(self, in1, in2, frequency=1000):
# Set up PWM outputs on the specified pins
self.in1 = PWM(Pin(in1))
self.in2 = PWM(Pin(in2))
self.in1.freq(frequency)
self.in2.freq(frequency)
# Initialize duty to 0
self.in1.duty_u16(0)
self.in2.duty_u16(0)
def move(self, power):
# Constrain power to -100 to 100
if power > 100:
power = 100
elif power < -100:
power = -100
# Scale to duty cycle (065535 for RP2040)
duty = abs(power) * 65535 // 100
if power > 0:
self.in1.duty_u16(duty)
self.in2.duty_u16(0)
elif power < 0:
self.in1.duty_u16(0)
self.in2.duty_u16(duty)
else:
self.in1.duty_u16(0)
self.in2.duty_u16(0)
</code></pre>
</div>
</div>
</section>
<!-- Lesson 4 (hidden initially) -->
<section id="lesson4" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Reading the Colour Sensor</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Wiring</h2>
<p>The TCS3472 Colour sensor is a smart device that uses I2C communication. This is a method of
communication from one microcontroller to another, using only 2 wires (SCL and SDA).</p>
</br>
<p><code>RP2040 3v3 --------------- SENSOR 3v3</code></p>
<p><code>RP2040 GND --------------- SENSOR GND</code></p>
<p><code>RP2040 GP0 --------------- SENSOR SDA</code></p>
<p><code>RP2040 GP1 --------------- SENSOR SCL</code></p>
</br>
<p>So we need to connect power (3v3 and GND), and the two GPIO pins for communication.</p>
<p>We don't use VIN, this is for if we are using more than 3v3, the sensor has its own voltage
regulator on board.</p>
<p>The INT pin on the sensor is an INTERRUPT. It flips on and off when theres new data to be read.
</p>
<p>The LED pin on the sensor can be connected to a GPIO and switched high and low to turn it off and
on. It's on by default.</p>
</div>
<div>
<img src="images/color_sensor_instructions_chalk.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding</h2>
<p>The minimal code to communicate with the colour sensor is a bit more in depth than the motor.
We need to write and read registers, send bytes back and forth to turn it on, tell it what speed
to work at, and how sensitive to be. </p>
</br>
<p>Since it's quite a lot we'll begin by putting it in a separate file. The code here includes the
minimum needed to set up and read the colour sensor.</p>
</br>
<p><code>init_sensor()</code> sends 4 messages to the sensor. The first tells it to turn on, the
second tells it to turn on its RGB colour sensing.</p>
</br>
<p>The "Integration Time" controls how long the sensor will collect data before collating it into a
message for you. Since we want to read hundreds of times a second we set it low.</p>
</br>
<p>Finally we set the "gain" quite high, this amplifies the signal, which is necessary since our
fast integration time leaves the sensor with low sentivity.</p>
</br>
<p>If we only had to check the colour every half a second or so, we could get a much more sensitive
result by setting a high integration time, and low gain.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# color.py
import time
TCS_ADDR = 0x29
COMMAND_BIT = 0x80
def i2c_locked(i2c, func, *args, **kwargs):
"""Wraps I2C operations with lock acquisition and release."""
while not i2c.try_lock():
pass
try:
return func(*args, **kwargs)
finally:
i2c.unlock()
def write_register(i2c, addr, reg, value):
"""Writes a byte to a register."""
i2c_locked(i2c, i2c.writeto, addr, bytes([COMMAND_BIT | reg, value]))
def read_register(i2c, addr, reg, length):
"""Reads multiple bytes from a register."""
result = bytearray(length)
def transfer():
i2c.writeto(addr, bytes([COMMAND_BIT | reg]))
i2c.readfrom_into(addr, result)
return result
return i2c_locked(i2c, transfer)
def init_sensor(i2c):
time.sleep(0.01)
write_register(i2c, TCS_ADDR, 0x00, 0x01) # Enable (POWER ON)
time.sleep(0.01)
write_register(i2c, TCS_ADDR, 0x00, 0x03) # Enable (COLOR SENSING ON)
write_register(i2c, TCS_ADDR, 0x01, 0xFF) # Integration time 0xFF=2.4ms 0xF0=38.4ms 0x00=614.4ms
write_register(i2c, TCS_ADDR, 0x0F, 0x03) # Gain 0x00=1x, 0x01=4x 0.02=16x, 0x03=60x
def read_rgbc(i2c):
data = read_register(i2c, TCS_ADDR, 0x14, 8)
c = data[1] << 8 | data[0]
r = data[3] << 8 | data[2]
g = data[5] << 8 | data[4]
b = data[7] << 8 | data[6]
return r, g, b, c
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: code.py</h2>
<p>Finally, this is how we call our color.py code from our main code, initialise and read the
sensor.</p>
</br>
<p>We use the <code>busio</code> library to handle our i2c. We initialise the connection by telling
it which pins we're using for SCL and SDA, and how fast to communicate.</p>
</br>
<p>We then pass on that connection to initialize the sensor using
<code>color.init_sensor(i2c)</code>
</p>
</br>
<p>The whenever we read <code>color.read_rgbc(i2c)</code> it will give us an array with the
<code>[red, green, blue, color]</code>, with the color being the total light reflected, rather
than just a single color.
</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# code.py
import busio
import time
import board
import color
i2c = busio.I2C(scl=board.GP1, sda=board.GP0, frequency=1_000_000)
color.init_sensor(i2c)
while True:
value = color.read_rgbc(i2c)
print(value)
time.sleep(0.1) </code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 4: How to use it?</h2>
<p>To take these numbers and use them effectively, we need to break out the different colour
channels.</p>
</br>
<p>To the right is an example of how we might take the total colour from the result, and then use it
to decide whether to turn or not.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm">
<code class="language-python">
value = color.read_rgbc(i2c) # Read the sensor [r,g,b,c]
# Extract each of the color values from the array
r = value[0] # red
g = value[1] # green
b = value[2] # blue
c = value[3] # all colors
if c > 30: # If the c value is greater than 30
# Turn Right
left_motor.move(50)
left_motor.move(-50)
else:
# Go Straight
left_motor.move(50)
left_motor.move(50)
</code></pre>
</div>
</div>
</section>
<!-- Lesson 5 (hidden initially) -->
<section id="lesson5" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Reading MORE Colour Sensors</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Wiring</h2>
<p>Although you can connect many devices to the SCL SDA pins, each device must have a different
address. This is a problem because every colour sensor has the same address.</p>
</br>
<p>If we just connected all our devices to the same SCA and SDA lines, they would both try to
respond at the same time to every message addressed to them</p>
</br>
<p>So we use a MULTIPLEXER. This lets us have 8 different I2C channels, with only one active at
once. So with the connections in the picture, we'd switch to channel 5 to talk to the left
sensor, and channel 7 to talk to the right.</p>
</br>
<p><code>RP2040 3v3 -- MUXER 3v3 -- SENSOR 1 3v3 -- SENSOR 2 3v3</code></p>
<p><code>RP2040 GND -- MUXER GND -- SENSOR 1 GND -- SENSOR 2 GND</code></p>
<p><code>RP2040 GP0 -- MUXER SDA</code></p>
<p><code>RP2040 GP1 -- MUXER SCL</code></p>
<p><code>MUXER SD5 --- SENSOR 1 SDA</code></p>
<p><code>MUXER SC5 --- SENSOR 1 SCL</code></p>
<p><code>MUXER SD7 --- SENSOR 2 SDA</code></p>
<p><code>MUXER SC7 --- SENSOR 2 SCL</code></p>
</br>
<p>Using this method, we can connect up to 8 colour sensors.</p>
</div>
<div>
<img src="images/muxer_instructions_chalk.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding</h2>
<p>Create a new module and paste the code on the right in there.</p>
</br>
<p>It's similar to the colour sensor code, we pass the <code>select_channel()</code> function the
i2c, and a number for which channel (0-7) we want to talk through.</p>
</br>
<p>It sends a single message over the I2C bus, to the multiplexers address, telling it which channel
to open.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# muxer.py
TCA_ADDR = 0x70
def i2c_locked(i2c, func, *args, **kwargs):
"""Wraps I2C operations with lock acquisition and release."""
while not i2c.try_lock():
pass
try:
return func(*args, **kwargs)
finally:
i2c.unlock()
def select_channel(i2c, channel):
"""Selects the TCA9548A multiplexer channel."""
if not 0 <= channel <= 7:
raise ValueError("Channel must be 0-7")
i2c_locked(i2c, i2c.writeto, TCA_ADDR, bytes([1 << channel]))
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: code.py</h2>
<p>So in our main code we just import the muxer module, and make sure we switch to the correct
channel before sending any messages.</p>
</br>
<p>We only need to keep one colour sensor object, but we do need to make sure we send the
initialization message on each channel.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# code.py
import busio
import time
import board
import color
import muxer
i2c = busio.I2C(scl=board.GP1, sda=board.GP0, frequency=1_000_000)
# select channel 5
muxer.select_channel(i2c, 5)
# init sensor on channel 5
color.init_sensor(i2c)
# select channel 7
muxer.select_channel(i2c, 7)
#init sensor on channel 7
color.init_sensor(i2c)
while True:
# select channel 5
muxer.select_channel(i2c, 5)
# read the values on channel 5
value5 = color.read_rgbc(i2c)
# select channel 7
muxer.select_channel(i2c, 7)
# read the values on channel 7
value7 = color.read_rgbc(i2c)
# print both sets of readings
print(value5, value7)
time.sleep(0.1)
</code></pre>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson6" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Using the OLED Display</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Wiring</h2>
<p>The OLED display also communicates using I2C, so all we need to do is connect the power (3v3 and
GND), and the I2C lines (SDA and SCL).</p>
</br>
<p><code>RP2040 3v3 ------- OLED 3v3</code></p>
<p><code>RP2040 GND ------- OLED GND</code></p>
<p><code>RP2040 GP0 ------- OLED SDA</code></p>
<p><code>RP2040 GP1 ------- OLED SCL</code></p>
</br>
</div>
<div>
<img src="images/oled_instructions_chalk.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding</h2>
<p>The code for the OLED is a little more complicated that what we want to code for ourselves, so
we'll download a library and place it in out CIRCUITPY/lib folder.</p>
</br>
<p><a href="files/adafruit_ssd1306.mpy">adafruit_ssd1306.mpy</a></p>
<p><a href="files/adafruit_framebuf.py">adafruit_framebuf.py</a></p>
</br>
<p>We initialize the display with <code>display = adafruit_ssd1306.SSD1306_I2C(128, 32, i2c)</code>.
</p>
</br>
<p>We colour the entire screen either black (off) or blue/white (on) with
<code>display.fill(0)</code> for off, or 1 for on.
</p>
</br>
<p>We write text with <code>display.text(text, x, y, color)</code>.</p>
</br>
<p>Finally, before anythin will actually appear on the screen, we send it using
<code>display.show()</code>.
</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
import board
import time
import busio
import adafruit_ssd1306
i2c = busio.I2C(scl=board.GP1, sda=board.GP0, frequency=1_000_000)
display = adafruit_ssd1306.SSD1306_I2C(128, 32, i2c)
display.fill(0) # Fill the screen with BLACK
# Print Hello world at the top left in COLOR
display.text("Hello World", 0, 0, 1)
display.show() # Send the update to the screen
time.sleep(2)
display.fill(1)# Fille the screen with COLOR
display.show() # Send the update to the screen
time.sleep(2)
# Print Hello world at x:10, y:20 in BLACK
display.text("Hello World", 10, 20, 0)
display.show() # Send the update to the screen
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: How to use it?</h2>
<p>One of the main things we'll use the screen for is getting feedback on what the sensors are
seeing, or what the robot is doing.</p>
</br>
<p>So when the robot is following a line, we might do something like the code on the right so that
we can see the sensor colours in real time.</p>
</br>
<div class="flex justify-center">
<img src="images/oled_example.jpg" alt="Robot turning right" class="rounded shadow w-64" />
</div>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
import board
import time
import busio
import adafruit_ssd1306
import color
import muxer
i2c = busio.I2C(scl=board.GP1, sda=board.GP0, frequency=1_000_000)
display = adafruit_ssd1306.SSD1306_I2C(128, 32, i2c)
# initialize two colour sensors
muxer.select_channel(i2c, 0)
color.init_sensor(i2c)
muxer.select_channel(i2c, 1)
color.init_sensor(i2c)
while True:
# Read the colour channel from both colour sensors
muxer.select_channel(i2c, 0)
value0 = color.read_rgbc(i2c)[3]
muxer.select_channel(i2c, 1)
value1 = color.read_rgbc(i2c)[3]
# Print the results on the display
display.fill(0)
display.text("L: " + str(value0), 0, 0, 1)
display.text("R: " + str(value1), 0, 10, 1)
display.show()
time.sleep(0.1)
</code></pre>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson7" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">RGB LED (Neopixel)</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Wiring</h2>
<p>The RGB LED has its own little microcontroller we can talk to and set each of the three LEDs
inside. We only need power and a single data connection.</p>
</br>
<p><code>RP2040 3v3 ------- RGB 5v</code></p>
<p><code>RP2040 GND ------- RGB GND</code></p>
<p><code>RP2040 GP29 ------ RGB DIN</code></p>
</br>
<p>Even though the RGB LED says it wants 5v, we give it 3v3. It's designed for 5v, but if we give it
5v power then the data sent to it using a 3v3 signal from GP29 won't be recognized.</p>
</br>
<p>Many of these leds can be connected in a string, with each one passing the messages along to the
next. We'll just be using one in the examples here.</p>
</div>
<div>
<img src="images/neopixel_instructions_chalk.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding</h2>
<p>Once again we'll be using a library to handle all the bits and bytes under the hood, so we only
have to worry about setting colours.</p>
</br>
<p><a href="files/neopixel.py">neopixel.py</a></p>
</br>
<p>We only need to initialize the pixel with
<code>pixel = neopixel.NeoPixel(board.GP29, 1, brightness=0.2)</code>.
</p>
<p>The "1" is the number of pixels we have, and we CAN set the brightness up to 1.0 but it's quite
bright.</p>
</br>
<p>Then we just need to tell the pixel what colour to be, we talk to
<code>pixel[0] = (red,green,blue)</code> because
it's the only one we have. The numbers for each colour go from minimum 0, to maximum 255.
</p>
</br>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
import board
import time
import neopixel
pixel = neopixel.NeoPixel(board.GP29, 1, brightness=0.2)
while True:
pixel[0] = (255, 0, 0) # Red
time.sleep(1)
pixel[0] = (0, 255, 0) # Green
time.sleep(1)
pixel[0] = (0, 0, 255) # Blue
time.sleep(1)
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: Make some pretty effects</h2>
<p>This example has three loops, each one fades from one primary colour to the next.</p>
</br>
<p>We increase one LEDs brightness by assigning it <code>i</code>, which counts from 0-255</p>
</br>
<p>We decrease another LEDs brightness by making it <code>255-i</code> so it begins at max, and
counts
down to zero as i becomes higher.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
import board
import time
import neopixel
pixel = neopixel.NeoPixel(board.GP29, 1, brightness=0.2)
while True:
# Fade Red->Green
for i in range(256):
pixel[0] = (255-i, i, 0)
# Fade Green->Blue
for i in range(256):
pixel[0] = (0, 255-i, i)
# Fade Blue->Red
for i in range(256):
pixel[0] = (i, 0, 255-i)
</code></pre>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson8" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Sonar HC-SR04</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Wiring</h2>
<p>The HC-SR04 sonar sends out a little pulse when we turn the TRIG pin on and off. When the sound
bounces back from something the ECHO pin will turn on and off. We use the time between the TRIG
and the ECHO to determine how far the sound got before bouncing back from an object.</p>
</br>
<p><code>RP2040 3v3 ------- SONAR 5v</code></p>
<p><code>RP2040 GND ------- SONAR GND</code></p>
<p><code>RP2040 GP26 ------ SONAR TRIG</code></p>
<p><code>RP2040 GP28 ------ SONAR ECHO</code></p>
</br>
</div>
<div>
<img src="images/sonar_instructions_chalk.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding</h2>
<p>We'll use a library to handle sending and listening for the pulses. Put this module on your
device.</p>
</br>
<p>When the "distance()" function is called, it sends a short pulse on the trigger pin, then waits
for a response on the echo pin. The time it takes for the echo to return is used to calculate
the distance.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# sonar.py
import machine
import time
class Sonar:
def __init__(self, trigger_pin, echo_pin):
self.trigger = machine.Pin(trigger_pin, machine.Pin.OUT)
self.echo = machine.Pin(echo_pin, machine.Pin.IN)
def distance(self):
# Send a 10µs pulse to trigger
self.trigger.off()
time.sleep_us(2)
self.trigger.on()
time.sleep_us(10)
self.trigger.off()
# Wait for echo start
while self.echo.value() == 0:
pass
start = time.ticks_us()
# Wait for echo end
while self.echo.value() == 1:
pass
end = time.ticks_us()
# Calculate duration
duration = time.ticks_diff(end, start)
# Convert to distance (speed of sound ~343 m/s)
distance = (duration / 2) * 0.0343
return int(distance)
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: Call it in the main.py</h2>
<p>Now all we need to do is create the object and call the distance function in the main.py file.
</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# main.py
import time
import sonar
# Create sonar object with trigger on GP3 and echo on GP2
sonar = sonar.Sonar(trigger_pin=26, echo_pin=28)
while True:
dist = sonar.distance()
print("Distance:", dist, "cm")
time.sleep(1)
</code></pre>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson10" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Receiver</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 2 -->
<div class="prose">
<h2>Step 1: Coding</h2>
<p>Create a new file called <code>remote.py</code> and add the code to the right.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# remote.py
import board
import busio
START_BYTE = 0xAA
END_BYTE = 0xBB
PACKET_LENGTH = 8
# Calibration values
LEFT_MIN = 0
LEFT_MID = 131
LEFT_MAX = 203
RIGHT_MIN = 51
RIGHT_MID = 120
RIGHT_MAX = 255
class ThumbInput:
def __init__(self, tx=board.GP0, rx=board.GP1, baudrate=115200):
self.uart = busio.UART(tx=tx, rx=rx, baudrate=baudrate, timeout=0.1)
self.buffer = bytearray()
def read(self):
"""Returns (left_percent, right_percent) in range [-100, 100], or None if packet incomplete."""
data = self.uart.read(1)
if data:
byte = data[0]
self.buffer.append(byte)
if len(self.buffer) > PACKET_LENGTH:
self.buffer = self.buffer[-PACKET_LENGTH:]
if len(self.buffer) == PACKET_LENGTH and self.buffer[0] == START_BYTE and self.buffer[-1] == END_BYTE:
payload = self.buffer[1:7]
self.buffer = bytearray()
left_raw = payload[1]
right_raw = payload[3]
left = self._map_thumbstick(left_raw, LEFT_MIN, LEFT_MID, LEFT_MAX)
right = self._map_thumbstick(right_raw, RIGHT_MIN, RIGHT_MID, RIGHT_MAX)
return (int(left), int(right))
return None
def _map_thumbstick(self, x, min_val, mid_val, max_val):
if x < mid_val:
return (x - mid_val) / (mid_val - min_val) * 100
else:
return (x - mid_val) / (max_val - mid_val) * 100
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 2: Using the recieved signal</h2>
<p>Import just the <code>ThumbInput</code> part of the library.</p>
</br>
<p>Initialize the receiver with <code>receiver = ThumbInput()</code></p>
</br>
<p>Create a new loop as this won't work nicely with any i2c, this loop should be placed after the
motors are created, and before the i2c is initialized.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
from remote import ThumbInput
receiver = ThumbInput()
while True:
result = receiver.read()
if result:
left_motor.move(result[0])
right_motor.move(result[1])
#print("Left:", result[0], "Right:", result[1])
time.sleep(0.001)
</code></pre>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson8b" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Sonar Filtering</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 1 -->
<div class="prose">
<h2>Step 1: Understand the problem</h2>
<p>Like most inputs, the sonar data is noisy, and there are different types of noise.</p>
<p>On the right you can see a fairly fixed output, but with sudden spikes of irregular data.</p>
</br>
<p>So we need code that will filter our spikes like that, the easiest is to just throw them away. To
do this we'll use a simple moving average filter.</p>
</div>
<div>
<img src="images/sonar_noise.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<!-- Step 2 -->
<div class="prose">
<h2>Step 2: Coding a moving average filter</h2>
<p>Instead of just taking single data points and making decisions, we'll keep an array of the last 8
readings.</p>
</br>
<p>First we need to start keeping a rolling buffer of the last 8 readings.</p>
<p>We'll use a list to store the readings, and we'll use the <code>append()</code> method to add new
readings to the end of the list.</p>
<p>We'll use the <code>pop(0)</code> method to remove the oldest reading from the start of the list.
</p>
<p>We'll use the <code>len()</code> method to check the size of the list.</p>
<p>We'll use the <code>print()</code> method to print the list.</p>
</br>
<p><b>NOTE:</b> We can get smoother results by using a larger buffer size, but this can also start
to introduce lag.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
BUFFER_SIZE = 8 # The size of the buffer
buffer = [] # Create an empty array to hold
# the readings (this is the rolling buffer)
while True:
dist = sonar.distance() # Get a new reading
buffer.append(dist) # Add it to the buffer
# If the buffer is too big, remove the oldest reading
if len(buffer) > BUFFER_SIZE:
buffer.pop(0)
print(buffer) # Print the buffer
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: Take the average</h2>
<p>Now all we need to do is create the object and call the distance function in the main.py file.
</p>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
def average_filter(values):
total = 0
for i in range(len(values)):
total = total + values[i]
return total / len(values)
# Call in the main loop
filtered = average_filter(values)
print(filtered)
</code>
</pre>
</div>
<div>
<img src="images/sonar_noise_averaged.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<div class="prose">
<h2>Step 4: Take the median</h2>
<p>That's better, but those large peaks are still throwing our average off quite a bit. We can improve this by taking the MEDIAN of the buffer rather than the average.
</p>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
def median_filter(values):
sorted_vals = sorted(values) # sort the values from low->high
mid = int(len(sorted_vals) / 2) # get the middle index
return sorted_vals[mid] # return the value of the median
# Call in the main loop
filtered = median_filter(values)
print(filtered)
</code>
</pre>
</div>
<div>
<img src="images/sonar_noise_median.png" alt="Robot turning right"
class="rounded shadow w-full max-w-xs md:max-w-full" />
</div>
<div class="prose">
<h2>Step 5: Fine tuning</h2>
<p>Before moving on, play with the buffer size to see how it effects the results AND the responsiveness to changes.</p>
<p>Try and find a good balance with the time.sleep() value as well.</p>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson10" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">Receiver</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 2 -->
<div class="prose">
<h2>Step 1: Coding</h2>
<p>Create a new file called <code>remote.py</code> and add the code to the right.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# remote.py
import board
import busio
START_BYTE = 0xAA
END_BYTE = 0xBB
PACKET_LENGTH = 8
# Calibration values
LEFT_MIN = 0
LEFT_MID = 131
LEFT_MAX = 203
RIGHT_MIN = 51
RIGHT_MID = 120
RIGHT_MAX = 255
class ThumbInput:
def __init__(self, tx=board.GP0, rx=board.GP1, baudrate=115200):
self.uart = busio.UART(tx=tx, rx=rx, baudrate=baudrate, timeout=0.1)
self.buffer = bytearray()
def read(self):
"""Returns (left_percent, right_percent) in range [-100, 100], or None if packet incomplete."""
data = self.uart.read(1)
if data:
byte = data[0]
self.buffer.append(byte)
if len(self.buffer) > PACKET_LENGTH:
self.buffer = self.buffer[-PACKET_LENGTH:]
if len(self.buffer) == PACKET_LENGTH and self.buffer[0] == START_BYTE and self.buffer[-1] == END_BYTE:
payload = self.buffer[1:7]
self.buffer = bytearray()
left_raw = payload[1]
right_raw = payload[3]
left = self._map_thumbstick(left_raw, LEFT_MIN, LEFT_MID, LEFT_MAX)
right = self._map_thumbstick(right_raw, RIGHT_MIN, RIGHT_MID, RIGHT_MAX)
return (int(left), int(right))
return None
def _map_thumbstick(self, x, min_val, mid_val, max_val):
if x < mid_val:
return (x - mid_val) / (mid_val - min_val) * 100
else:
return (x - mid_val) / (max_val - mid_val) * 100
</code></pre>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 2: Using the recieved signal</h2>
<p>Import just the <code>ThumbInput</code> part of the library.</p>
</br>
<p>Initialize the receiver with <code>receiver = ThumbInput()</code></p>
</br>
<p>Create a new loop as this won't work nicely with any i2c, this loop should be placed after the
motors are created, and before the i2c is initialized.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
from remote import ThumbInput
receiver = ThumbInput()
while True:
result = receiver.read()
if result:
left_motor.move(result[0])
right_motor.move(result[1])
#print("Left:", result[0], "Right:", result[1])
time.sleep(0.001)
</code></pre>
</div>
</section>
<!-- Lesson 6 (hidden initially) -->
<section id="lesson11" class="lesson-content hidden">
<h1 class="text-3xl font-bold mb-6">PID</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Step 2 -->
<div class="prose">
<h2>Step 1: Coding</h2>
<p>Up until now we've been using an algorithm known as "bang-bang" to track the line.</p>
</br>
<p>If the left sensor sees something, we take a single action, <code>turn left</code>.</p>
<p>Else if the right sensor sees something, we take a single action, <code>gurn right</code>.</p>
<p>Else <code>go straight</code>.</p>
</br>
<p>This works well, but it can be a bit jerky.</p>
</div>
<div>
<h2>Bang-Bang Algorithm</h2>
<canvas id="canvasBangBang" width="500" height="300"></canvas>
<div style="max-width: 500px; margin: 0 auto; font-family: sans-serif;">
<h3>Bang-Bang Controller Parameters</h3>
<label>Threshold: <input type="range" id="bbThresholdSlider" min="0" max="1024" step="0.001"
value="500"> <span id="bbThresholdValue">500</span></label><br>
<label>Turn Speed: <input type="range" id="bbTurnSpeedSlider" min="0" max="100" step="1"
value="50">
<span id="bbTurnSpeedValue">50</span></label><br>
<p><strong>left_color:</strong> <span id="leftColor">0</span></p>
<strong>right_color:</strong> <span id="rightColor">0</span>
<pre class="bg-gray-100 p-4 rounded shadow text-sm">
<code id="code-block" class="language-python"></code>
</pre>
</br> <button id="resetBangBangBtn"
class="px-2 py-1 bg-red-600 text-white text-sm font-semibold rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400">
Reset Robot Position
</button>
</div>
</br>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 2: Use a PID algorithm</h2>
<p>A PID algorithm on the other hand tries to always keep the robot exactly on the line.</p>
</br>
<p>First we have to change our two colour values, <code>left_color</code> and
<code>right_color</code> into a single <code>error</code> value.
</p>
<p><code>right_color - left_color = error</code></p>
</br>
<p>Now if we are on the line our error should be 0, with ot becoming positive if we move one way,
and negative if we move the other.</p>
</br>
<p>Try adjusting the <code>Kp</code> value to control how aggressively the robot will attempt to
drive towards the line.</p>
</div>
<div>
<h2>PID Algorithm</h2>
<canvas id="canvasPID" width="500" height="300"></canvas>
<div style="max-width: 500px; margin: 0 auto; font-family: sans-serif;">
<h3>PID Controller Parameters</h3>
<label>Kp: <input type="range" id="kpSlider" min="0" max="1" step="0.001" value="0.074"> <span
id="kpVal">0.074</span></label><br>
<label>Ki: <input type="range" id="kiSlider" min="0" max="0.5" step="0.0001" value="0.0000">
<span id="kiVal">0.0</span></label><br>
<label>Kd: <input type="range" id="kdSlider" min="0" max="0.5" step="0.001" value="0.000">
<span id="kdVal">0.0</span></label><br><br>
<strong>Error (right_color-left_color):</strong> <span id="errorLabel">0</span>
</br> <button id="resetBtn"
class="px-2 py-1 bg-red-600 text-white text-sm font-semibold rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400">
Reset Robot Position
</button>
</div>
</div>
<!-- Step 3 -->
<div class="prose">
<h2>Step 3: The Code</h2>
<p>The pid.py library is simple, we initialize it with a <code>P</code>, <code>I</code>, and
<code>D</code>.
</p>
</br>
<p>These stand for <code>Proportional</code>, <code>Integral</code>, and <code>Derivative</code></p>
</br>
<p>The <code>P</code> value controls how aggressively proportional the final result will be, if we
set it too high, the output will be too high and the robot will adjust too hard. If we set it
too low, the robot will be too lazy about tracking the line and may not turn fast enough to stay
on it.</p>
</div>
<div>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# pid.py
class PIDController:
def __init__(self, kp, ki, kd):
self.kp = kp
self.ki = ki
self.kd = kd
self.integral = 0
self.last_error = 0
def compute(self, error):
self.integral += error
derivative = error - self.last_error
self.last_error = error
output = (self.kp * error) + (self.ki * self.integral) + (self.kd * derivative)
return output
</code></pre>
</br>
<pre class="bg-gray-100 p-4 rounded shadow text-sm"><code class="language-python">
# code.py
import pid
linePID = pid.PIDController(kp=1.0, ki=0.0, kd=0.0)
while True:
error = right_color - left_color # Calculate the error
output = linePID.compute(error)
# Use the output to adjust the motors
print(error, output)
</code></pre>
</div>
</section>
</div>
<script>
// Tab button logic
const tabs = document.querySelectorAll('.tab-btn');
const lessons = document.querySelectorAll('.lesson-content');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Remove active class from all tabs
tabs.forEach(t => {
t.classList.remove('active', 'text-blue-600');
t.classList.add('text-gray-600');
});
// Hide all lessons
lessons.forEach(lesson => lesson.classList.add('hidden'));
// Show the selected lesson
const targetId = tab.getAttribute('data-target');
document.getElementById(targetId).classList.remove('hidden');
// Activate the clicked tab
tab.classList.add('active', 'text-blue-600');
tab.classList.remove('text-gray-600');
});
});
</script>
<script type="module">
import { LineFollowerSim } from './LineFollowerSim.js';
// Bang-Bang controller (default)
const canvas1 = document.getElementById('canvasBangBang');
const sim1 = new LineFollowerSim(canvas1);
sim1.start();
// PID controller example
const canvas2 = document.getElementById('canvasPID');
// Get sliders and output spans
const kpSlider = document.getElementById("kpSlider");
const kiSlider = document.getElementById("kiSlider");
const kdSlider = document.getElementById("kdSlider");
const kpVal = document.getElementById("kpVal");
const kiVal = document.getElementById("kiVal");
const kdVal = document.getElementById("kdVal");
const errorLabel = document.getElementById("errorLabel");
let error = 0, integral = 0, lastError = 0;
let lastUpdateTime = 0;
const pidUpdateInterval = 50; // ms
// Setup velocity on the box (just once, after constructing the sim)
const pidControl = function () {
// Always move the box every frame based on velocity
this.box.x += this.box.vel;
// Only update velocity (PID) every `pidUpdateInterval` ms
const now = performance.now();
if (now - lastUpdateTime < pidUpdateInterval) return;
lastUpdateTime = now;
const sensorMid = this.box.x;
const target = this.lineX;
const proximityThreshold = this.box.width;
const distance = Math.abs(target - sensorMid);
if (distance < proximityThreshold) {
error = target - sensorMid;
integral += error;
const derivative = error - lastError;
lastError = error;
const Kp = parseFloat(kpSlider.value);
const Ki = parseFloat(kiSlider.value);
const Kd = parseFloat(kdSlider.value);
kpVal.textContent = Kp.toFixed(3);
kiVal.textContent = Ki.toFixed(4);
kdVal.textContent = Kd.toFixed(3);
errorLabel.textContent = error.toFixed(2);
// Apply PID to velocity
this.box.vel = Kp * error + Ki * integral + Kd * derivative;
} else {
errorLabel.textContent = "—";
lastError = 0;
integral *= 0.9;
// Optionally slow the robot when it's off track
this.box.vel *= 0.9;
}
};
const sim2 = new LineFollowerSim(canvas2, pidControl);
sim2.box.vel = 0;
sim2.start();
const resetBtn = document.getElementById("resetBtn");
resetBtn.addEventListener("click", () => {
// Put the box instantly on the line
sim2.box.x = sim2.lineX;
// Reset PID controller internal state
error = 0;
integral = 0;
lastError = 0;
// Reset velocity (if you use velocity)
sim2.box.vel = 0;
// Also update error label immediately
errorLabel.textContent = "0";
});
</script>
</body>
</html>