Build an MHS-Inspired Light Lab
Build a bounded Arduino light lab with Claude Code and repeatable measurements.
Introduction
30 Second Summary
Giving software control of a physical device feels different from letting it edit a document. One unchecked choice can leave real hardware running at full power.
In this project, you will build a small light lab where Claude Code operates an Arduino rig built from an LED and a photoresistor through a deterministic driver. This MHS-inspired workflow turns bounded hardware actions into 50 inspectable measurements.
What You'll Build
From Terminal, you can run a bounded two-trial light experiment that finishes with 50 measurements plus an error-bar plot.
By the end of this project, you'll have:
- A three-command light rig that lets you set an allowed LED brightness from Terminal. You can read the photoresistor or stop the LED on demand.
- A deterministic safety boundary that rejects brightness requests above 180. The LED also shuts itself off after 10 seconds.
- Repeatable evidence from two trials, captured as 50 measurements in results.csv. You can inspect uncertainty in light_response.png. You can share a measured conclusion from REPORT.md.
- An optional Secret Mission: Diagnose a hidden physical fault using only bounded observations from the existing three-command interface.
Are there any prerequisites?
The project assumes a Mac, basic Python experience, Arduino familiarity, an Arduino Uno or classic Nano, plus the listed electronic parts. Eligible Claude Code access is required.
Before We Start
Before any hands-on work begins, commit to a physical boundary that stays dependable when an AI proposes an unsafe action. Claude Code will reason and plan for a test of how LED brightness affects a photoresistor. Deterministic driver code will enforce the brightness ceiling and actuator timeout.
Set Up the Mac and Wire the Light Rig
The bounded experiment depends on a known-good Mac toolchain. It also depends on a correctly wired light rig.
This step removes both unknowns before Claude Code touches the hardware. The later safety boundary can then sit on a stable foundation.
In this step, get ready to:
- Prepare the three Mac applications required by the light lab.
- Create an isolated Python workspace for the driver.
- Wire the LED actuator circuit plus the photoresistor sensor circuit.
Install and verify the Mac applications
Why do you need three applications?
Python runs the deterministic driver plus the measurement scripts.
Arduino IDE uploads firmware to the physical board.
Claude Code helps create the project while Manual permission mode keeps each action visible to you.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check your current Python version by running this command:
python3 --version
What does this check do?
The command asks the system Python executable to print its installed version. This reveals whether your Mac already has the pinned release.
✔️ I see Python 3.15.2
Your Python installation already matches the project. You can continue with this release.
ⓧ I see an older version
The installed release is below the project pin. Install Python 3.15.2 before continuing.
- Open the Python 3.15.2 release page in your browser.
- Download the macOS installer from the release page.
- Launch the downloaded installer from your Downloads folder.
- Complete the signed installer prompts.
ⓧ Command not found
Python is not available through your current Terminal. Install the pinned macOS release before continuing.
- Open the Python 3.15.2 release page in your browser.
- Download the macOS installer from the release page.
- Launch the downloaded installer from your Downloads folder.
- Complete the signed installer prompts.
- Close the current Terminal window if you installed Python.
- Open a new Terminal window through Spotlight.
- Confirm the pinned Python release by running this command:
python3 --version
What should you see?
Terminal should now identify Python 3.15.2. This confirms the interpreter is ready for the isolated workspace.
Still seeing the wrong Python version?
Close every Terminal window after the installer finishes. Open a fresh window so macOS reloads the available command paths.
Need help checking which Python installation Terminal is using?
- Press Cmd+Space to open Spotlight.
- Type Arduino IDE into Spotlight.
- Press Enter if Spotlight finds the application.
- Check the version shown in the application's information window if Arduino IDE opens.
✔️ I see Arduino IDE 2.3.10
Your Arduino IDE release matches the project. Leave the application open for the board package setup below.
ⓧ I see an older version
The existing application is older than the pinned release. Replace it with Arduino IDE 2.3.10 for a consistent interface.
- Open the Arduino software page in your browser.
- Download Arduino IDE 2.3.10 for macOS.
- Open the downloaded application image.
- Copy Arduino IDE.app into /Applications.
- Open Arduino IDE once through Spotlight.
ⓧ Arduino IDE is not installed
Install Arduino IDE 2.3.10 before connecting the board.
- Open the Arduino software page in your browser.
- Download Arduino IDE 2.3.10 for macOS.
- Open the downloaded application image.
- Copy Arduino IDE.app into /Applications.
- Open Arduino IDE once through Spotlight.
Arduino AVR Boards supplies the board definitions required by an Uno or classic Nano. The version pin keeps compilation behavior consistent later.
- Click Tools in the Arduino IDE menu bar.
- Hover over Board.
- Select Boards Manager.
- Enter Arduino AVR Boards in the search field.
- Select version 1.8.8 from the package version menu.
- Click INSTALL on the package card.
The package can take a few minutes to download. The card shows INSTALLED with version 1.8.8 when it is ready.
That is the Arduino software layer in place. Your IDE can now recognize the board family used by the light rig.
Arduino AVR Boards did not install?
Confirm your Mac has an internet connection. Reopen Boards Manager if the package list remains empty.
Help me troubleshoot the Arduino AVR Boards installation.
- Switch back to Terminal.
- Check whether Claude Code is already available by running this command:
claude --version
What does this check do?
The command prints the installed Claude Code release. It also reveals whether Terminal can find the command.
✔️ I see Claude Code 2.1.318
Claude Code already matches the pinned release. Continue to the fresh Terminal check below.
ⓧ I see an older version
Install the pinned release over the older one. This keeps the permission flow consistent with the project.
- Install Claude Code 2.1.318 by running this command:
curl -fsSL https://claude.ai/install.sh | bash -s 2.1.318
What does this installer do?
The command downloads Anthropic's native installer. The final argument pins the Claude Code release to 2.1.318.
ⓧ Command not found
Claude Code is not installed in the current environment. Install the pinned native release before authenticating.
- Install Claude Code 2.1.318 by running this command:
curl -fsSL https://claude.ai/install.sh | bash -s 2.1.318
What does this installer do?
The command downloads Anthropic's native installer. The final argument pins the Claude Code release to 2.1.318.
- Close the current Terminal window.
- Open a new Terminal window through Spotlight.
- Verify the pinned Claude Code release by running this command:
claude --version
What should you see?
Terminal should print release 2.1.318 followed by the Claude Code product name. The fresh window proves the command is available on your path.
Authentication opens a browser sign-in flow for your eligible Anthropic account. Your browser handles the account details while Terminal waits for confirmation.
- Start the authentication flow by running this command:
claude
What happens when Claude starts?
Claude Code starts an interactive session. It may send you to your browser so you can authenticate.
Manual permission mode keeps shell commands plus file edits behind approval prompts. You remain the human checkpoint for every hardware-facing action.
- Complete the browser authentication with your eligible account.
- Return to the Claude Code session in Terminal.
- Select Manual permission mode when prompted.
Claude Code did not authenticate?
Confirm that your account includes Claude Code access. Restart the command from the new Terminal window if the browser flow was interrupted.
Help me troubleshoot Claude Code authentication on macOS.
Create the isolated Python workspace
A virtual environment keeps this project's Python libraries inside one folder. The driver can use pinned packages without changing unrelated Python projects.
- Open another Terminal window through Spotlight.
- Create ~/mhs-light-lab by running this command:
mkdir -p ~/mhs-light-lab && cd ~/mhs-light-lab
What does this command do?
The first part creates the mhs-light-lab folder in your home directory. The second part moves the Terminal session into that folder.
- Create the .venv environment by running this command:
python3 -m venv .venv
What does this command do?
Python creates an isolated interpreter plus package directory inside ~/mhs-light-lab/.venv. The project dependencies stay contained there.
- Activate the environment by running this command:
source .venv/bin/activate
What does activation change?
Activation makes python plus its package installer resolve to the copies inside .venv. Your Terminal prompt should now begin with (.venv).
- Install the pinned Python libraries by running this command:
python -m pip install pyserial==3.5 matplotlib==3.11.2
What does this install do?
- pySerial 3.5 gives the later driver a persistent serial connection to the Arduino.
- Matplotlib 3.11.2 turns repeated sensor measurements into the error-bar plot.
- Confirm both libraries inside the activated environment by running this command:
python -m pip show pyserial matplotlib
What does this check show?
The output should include package details for pySerial version 3.5 plus Matplotlib version 3.11.2. Their installation locations should sit inside ~/mhs-light-lab/.venv.
Packages missing from the environment?
Check that your prompt begins with (.venv). Reactivate the environment before repeating the installation if that prefix is missing.
Help me verify the packages inside my virtual environment.
Wire and position the light rig
The LED is the actuator controlled with PWM on pin 9. The photoresistor forms a voltage divider whose output reaches analog input A0.
Breadboard wiring is genuinely fiddly at this scale. Removing USB power first keeps an accidental misplaced jumper away from a powered circuit.
- Disconnect the Arduino USB cable from your Mac.
- Connect pin 9 to one end of the 220 ohm resistor.
- Connect the resistor's other end to the LED anode.
- Connect the LED cathode to GND.
- Connect 5V to one photoresistor leg.
- Connect the other photoresistor leg to A0.
- Connect the A0 junction to one side of the 10 kΩ resistor.
- Connect the resistor's other side to GND.
Check the two circuit paths
The actuator path runs from pin 9 through the 220 ohm resistor plus the LED to ground.
The sensor path runs from 5V through the photoresistor to the A0 junction. The 10 kΩ resistor connects that junction to ground.
- Aim the LED directly toward the photoresistor.
- Set a fixed short distance between the LED plus the photoresistor.
- Reconnect the Arduino with the USB data cable.
The Arduino power LED should now be on. This confirms that the board is receiving USB power.
- Switch back to Arduino IDE.
- Choose the tab below that matches your physical board.
Arduino Uno
- Click Tools in the Arduino IDE menu bar.
- Hover over Board.
- Hover over Arduino AVR Boards.
- Select Arduino Uno.
Classic Arduino Nano
- Click Tools in the Arduino IDE menu bar.
- Hover over Board.
- Hover over Arduino AVR Boards.
- Select Arduino Nano.
The board selection tells Arduino IDE which hardware definition to use. The port selection connects that definition to the board attached to your Mac.
- Click Tools in the Arduino IDE menu bar.
- Select Port.
- Select the port associated with your connected Arduino.
Arduino IDE should now show the selected board plus its connected port in the board selector. That proves the software can see the powered rig.
Board or port missing?
Confirm that your cable carries data. Some USB cables provide power without exposing a port to the Mac.
Check that Arduino AVR Boards 1.8.8 still shows as installed. Reconnect the cable after checking both ends.
Help me find my connected Arduino in Arduino IDE.
Before you run the final checks, do you expect every installed version plus the connected board to match the pinned setup?
- Switch back to the activated Terminal from earlier.
- Verify Python one final time by running this command:
python3 --version
What should you see?
Terminal should print Python 3.15.2. The system interpreter now matches the project pin.
- Verify Claude Code one final time by running this command:
claude --version
What should you see?
Terminal should print Claude Code release 2.1.318. The command should work from the fresh Terminal environment.
- Verify the workspace libraries one final time by running this command:
python -m pip show pyserial matplotlib
What should you see?
The output should identify pySerial 3.5 plus Matplotlib 3.11.2. Both package locations should point inside the active .venv.
Setup checkpoint
Your Arduino Uno or classic Nano is connected over USB. Its PWM LED circuit uses pin 9. Its photoresistor voltage-divider sensor circuit uses A0.
The LED faces the photoresistor at a fixed short distance. The ~/mhs-light-lab/.venv environment contains pySerial 3.5 plus Matplotlib 3.11.2.
Your Mac plus the physical rig are ready. Next up, you will give the Arduino exactly three physical behaviors.
Program the Arduino Protocol
Your Mac toolchain and light rig are ready. The Arduino can only become useful once it understands a small set of predictable commands.
A serial protocol gives the board three physical behaviors: set brightness, sense light, or stop output. Testing those behaviors by hand creates the first visible win before agent automation reaches the rig.
In this step, get ready to:
- Have Claude Code create the three-command Arduino firmware.
- Upload the firmware to the connected Uno or classic Nano.
- Verify the LED and photoresistor through Serial Monitor.
Ask Claude Code for the firmware
The firmware defines the boundary between serial messages and physical hardware. Pin 9 controls the LED through PWM. Pin A0 samples the photoresistor.
What must the firmware support?
- The firmware defines LED_PIN as 9.
- The firmware defines LIGHT_PIN as A0.
- Each serial command ends with a newline and uses 115200 baud.
- The SET 0..255 behavior writes an allowed PWM brightness and returns OK SET with the accepted value.
- The READ behavior samples the sensor and returns LIGHT with the reading.
- The STOP behavior turns the LED off and returns OK STOP.
- Any other input returns ERROR COMMAND.
Manual permission mode pauses Claude Code before it changes the project. That pause gives you a review point before the firmware reaches the board.
- Switch back to Claude Code in the Terminal from earlier.
- Ask Claude Code to create arduino_driver/arduino_driver.ino inside ~/mhs-light-lab with the exact protocol contract above.
- Keep the proposed file edit pending while you compare it with the reference implementation.
✔️ Awesome, I've got everything!
- Approve Claude Code's proposed file edit.
- Confirm that Claude Code creates arduino_driver/arduino_driver.ino inside ~/mhs-light-lab.
Your project now contains the firmware that translates three serial commands into bounded physical behaviors.
ⓧ I'd like to double check the full code
- Compare Claude Code's proposed arduino_driver/arduino_driver.ino file with this complete reference:
const int LED_PIN = 9;
const int LIGHT_PIN = A0;
bool parseSetValue(const char line[], size_t length, int &value) {
if (length <= 4 || line[0] != 'S' || line[1] != 'E' ||
line[2] != 'T' || line[3] != ' ') {
return false;
}
value = 0;
for (size_t index = 4; index < length; index++) {
if (line[index] < '0' || line[index] > '9') {
return false;
}
value = value * 10 + (line[index] - '0');
if (value > 255) {
return false;
}
}
return true;
}
void setup() {
pinMode(LED_PIN, OUTPUT);
analogWrite(LED_PIN, 0);
Serial.begin(115200);
Serial.println("READY");
}
void loop() {
if (Serial.available() <= 0) {
return;
}
char line[20];
size_t length = Serial.readBytesUntil('\n', line, sizeof(line) - 1);
if (length == 0) {
return;
}
if (line[length - 1] == '\r') {
length--;
}
line[length] = '\0';
if (length == 4 && line[0] == 'R' && line[1] == 'E' &&
line[2] == 'A' && line[3] == 'D') {
Serial.print("LIGHT ");
Serial.println(analogRead(LIGHT_PIN));
return;
}
if (length == 4 && line[0] == 'S' && line[1] == 'T' &&
line[2] == 'O' && line[3] == 'P') {
analogWrite(LED_PIN, 0);
Serial.println("OK STOP");
return;
}
int brightness = 0;
if (parseSetValue(line, length, brightness)) {
analogWrite(LED_PIN, brightness);
Serial.print("OK SET ");
Serial.println(brightness);
return;
}
Serial.println("ERROR COMMAND");
}
What does this code do?
- The LED_PIN and LIGHT_PIN constants connect the protocol to the existing circuit.
- The parseSetValue() function accepts numeric SET values from 0 through 255.
- The setup() function configures the LED output and starts it in the off state.
- The loop() function reads one newline-terminated serial message at a time.
- A READ message returns the current analog sensor value.
- A STOP message writes zero to the LED output.
- A valid SET message writes the parsed brightness to the LED output.
- The final fallback rejects every message outside the three-command protocol.
- Ask Claude Code to replace any differences with the reference implementation.
- Approve the proposed edit once the file matches exactly.
- Confirm that the final file path is ~/mhs-light-lab/arduino_driver/arduino_driver.ino.
Does the proposed firmware look different?
- Check that the folder is named arduino_driver and the sketch is named arduino_driver.ino.
- Ask Claude Code to replace the whole file when its implementation uses different command names or pin values.
- Keep the edit unapproved until every line matches the reference.
Need help comparing the generated firmware?
Upload the firmware to the selected board
The Arduino IDE compiles the sketch for a specific board before uploading it. The board selection must match the connected Uno or classic Nano.
- Return to Arduino IDE from earlier.
- Open ~/mhs-light-lab/arduino_driver/arduino_driver.ino from Arduino IDE's top menu.
Arduino Uno
- Click Tools in the top menu.
- Select Board.
- Select Arduino AVR Boards.
- Select Arduino Uno.
Classic Arduino Nano
- Click Tools in the top menu.
- Select Board.
- Select Arduino AVR Boards.
- Select the board entry that matches your classic Nano.
- Click Tools in the top menu.
- Select Port.
- Select the connected port you identified earlier.
- Click the Upload button in the Arduino IDE toolbar.
- Wait for Arduino IDE to finish compiling and uploading the sketch.
You'll see successful completion in the console and a notification in the bottom-right corner. The board is now running your three-command firmware.
Upload not completing?
- Recheck that the selected board entry matches the connected Uno or classic Nano.
- Recheck that Arduino IDE still shows the connected port from earlier.
- Confirm that the USB data cable remains seated and the Arduino power LED remains on.
Need help getting the sketch onto the board?
Test the three behaviors by hand
The Serial Monitor lets you send one protocol message at a time. This direct test separates firmware and wiring problems from the automation you add later.
- Open Serial Monitor from the Arduino IDE toolbar.
- Set the baud rate control to 115200.
- Set the line-ending control so every submitted command ends with a newline.
Before you send the sequence, can you predict the exact firmware response to each command from the code you reviewed?
- Send SET 90 through Serial Monitor.
You'll see OK SET 90 in Serial Monitor. The LED lights at partial brightness.
- Send READ through Serial Monitor.
You'll see LIGHT followed by the photoresistor's numeric reading.
- Send STOP through Serial Monitor.
You'll see OK STOP in Serial Monitor. The LED turns off and remains off.
You have the first hardware feedback loop working. Your board can now set light output, measure the resulting sensor response, and return the actuator to its off state.
Not seeing the expected response?
- Confirm that Serial Monitor is set to 115200 baud.
- Confirm that each command is submitted with a newline ending.
- Check that the LED circuit still uses pin 9 and the sensor junction still reaches A0.
Need help tracing the failed behavior?
Your Arduino now exposes a small physical protocol with visible proof that every behavior works. Next, you'll place a persistent Python bridge in front of it and discover what that first driver still fails to protect.
Build and Test the Bounded Driver
Your Arduino already sets brightness, reads the photoresistor, and stops the LED through three serial commands. Now you will give Claude Code a local client for those behaviors.
A narrow command list still allows maximum brightness to stay on indefinitely. You will see that gap before a deterministic driver enforces the brightness limit and automatic stop.
In this step, get ready to:
- Build a persistent local bridge and three-command client.
- Experience the missing safety boundary at full brightness.
- Enforce a brightness ceiling and automatic stop in the bridge.
Build the first local bridge and client
Classic Arduino boards can reset when a serial connection opens. A persistent bridge keeps one port open for the whole session.
Why keep one serial connection?
Opening the port for every command can repeatedly reset the board. The bridge owns one long-running connection while short-lived clients send local JSON requests.
This keeps hardware communication stable while preserving a small command surface.
- Ask Claude Code to create the first version of bridge.py inside ~/mhs-light-lab.
- Specify that the bridge opens the Arduino serial port once.
- Specify that the bridge listens only on 127.0.0.1:8765 for newline-delimited JSON requests.
- Specify that this first version accepts only set_brightness, read_light, and stop actions.
- Specify that this first version allows brightness values from 0 through 255 without an automatic stop timer.
- Ask Claude Code to create device_client.py with only set VALUE, read, and stop commands.
- Review each proposed file before accepting it.
- Leave requests to invoke device_client.py unapproved.
Compare the generated client with the project version before operating the rig.
✔️ Awesome, I've got everything!
Your client exposes three commands and sends requests only to the local bridge.
ⓧ I'd like to double check the full code
import json
import socket
import sys
HOST = "127.0.0.1"
PORT = 8765
def send_request(request):
with socket.create_connection((HOST, PORT), timeout=3) as connection:
connection.sendall((json.dumps(request) + "\n").encode("utf-8"))
data = bytearray()
while b"\n" not in data:
chunk = connection.recv(1024)
if not chunk:
break
data.extend(chunk)
response = json.loads(bytes(data).split(b"\n", 1)[0].decode("utf-8"))
if not response.get("ok"):
raise RuntimeError(response.get("error", "Unknown driver error"))
return response
def usage():
return "Usage: python device_client.py set VALUE | read | stop"
def main():
if len(sys.argv) < 2:
raise SystemExit(usage())
command = sys.argv[1]
if command == "set" and len(sys.argv) == 3:
request = {"action": "set_brightness", "value": int(sys.argv[2])}
elif command == "read" and len(sys.argv) == 2:
request = {"action": "read_light"}
elif command == "stop" and len(sys.argv) == 2:
request = {"action": "stop"}
else:
raise SystemExit(usage())
try:
print(json.dumps(send_request(request), indent=2))
except (OSError, RuntimeError, ValueError) as error:
raise SystemExit(f"Driver error: {error}") from error
if __name__ == "__main__":
main()
How is the client constrained?
- The client connects only to 127.0.0.1 on port 8765.
- The set command becomes a set_brightness action.
- The read command becomes a read_light action.
- The stop command becomes a stop action.
Expose the missing safety boundary
The first bridge mirrors the firmware's full brightness range. Running it reveals whether a three-command interface provides enough protection on its own.
- List the connected serial devices from the activated project Terminal by running:
python -m serial.tools.list_ports
What does this command do?
The command lists serial ports visible to Python. Your Arduino port is the entry associated with the connected board.
- Store the Arduino port from the output here: your Arduino port.
- Close Serial Monitor in Arduino IDE.
- Switch back to the separate activated project Terminal from setup.
- Start the first bridge with the recorded port by running:
python bridge.py [[ARDUINO_PORT="your Arduino port"]]
What should you see?
The bridge opens the Arduino port once and starts listening on 127.0.0.1:8765. This Terminal stays occupied while the bridge runs.
Bridge cannot open the port?
- Confirm Serial Monitor is closed because it can keep control of the Arduino port.
- Confirm the value stored as your Arduino port matches the current port-listing output.
- Confirm the project virtual environment is active in the bridge Terminal.
Get help with a bridge that cannot connect to the Arduino port.
- Switch to the other activated project Terminal for client requests.
Before you run this, do you expect the three-command client alone to prevent full brightness?
- Request the firmware's maximum brightness yourself by running:
python device_client.py set 255
The missing boundary is visible
You will see the LED at full brightness. It remains on because the first bridge has no automatic timer.
The client exposes only three commands, but the bridge still accepts the firmware's unrestricted maximum.
- Stop the LED immediately by running:
python device_client.py stop
What did the stop command prove?
The client can return the LED to its safe state when you request it. The first bridge depends on someone remembering to make that request.
You caught the gap safely. The LED is off after the manual stop.
Replace the bridge with deterministic limits
The final bridge owns the safety policy below the client. Its brightness ceiling and timer apply to every request regardless of who sends it.
- Ask Claude Code to replace bridge.py with the Complete Code version.
- Require integer brightness values from 0 through 180.
- Require LightDevice.lock to serialize access to the serial port.
- Require MAX_ON_SECONDS to schedule STOP after 10.0 seconds.
- Require the action list to remain limited to set_brightness, read_light, and stop.
- Review every proposed change before accepting it.
Compare the revised bridge with the complete deterministic version.
✔️ Awesome, I've got everything!
Your revised bridge now owns the brightness ceiling and automatic stop.
ⓧ I'd like to double check the full code
import json
import socket
import sys
import threading
import time
import serial
HOST = "127.0.0.1"
PORT = 8765
BAUD_RATE = 115200
MAX_BRIGHTNESS = 180
MAX_ON_SECONDS = 10.0
MAX_REQUEST_BYTES = 4096
class LightDevice:
def __init__(self, port):
self.serial_port = serial.Serial(
port=port,
baudrate=BAUD_RATE,
timeout=2,
write_timeout=2,
)
self.lock = threading.Lock()
self.stop_timer = None
time.sleep(2.0)
self.serial_port.reset_input_buffer()
def _exchange(self, command):
with self.lock:
self.serial_port.write(f"{command}\n".encode("utf-8"))
self.serial_port.flush()
reply = self.serial_port.readline().decode("utf-8").strip()
if not reply:
raise RuntimeError("Arduino returned no response")
return reply
def _cancel_timer(self):
if self.stop_timer is not None:
self.stop_timer.cancel()
self.stop_timer = None
def _timed_stop(self):
self.stop_timer = None
try:
self._exchange("STOP")
print("Safety timer stopped the LED")
except Exception as error:
print(f"Safety stop failed: {error}")
def _schedule_stop(self):
self._cancel_timer()
self.stop_timer = threading.Timer(MAX_ON_SECONDS, self._timed_stop)
self.stop_timer.daemon = True
self.stop_timer.start()
def execute(self, request):
action = request.get("action")
if action == "set_brightness":
value = request.get("value")
if type(value) is not int:
raise ValueError("Brightness must be an integer")
if not 0 <= value <= MAX_BRIGHTNESS:
raise ValueError(
f"Brightness must be between 0 and {MAX_BRIGHTNESS}"
)
reply = self._exchange(f"SET {value}")
self._schedule_stop()
return {
"ok": True,
"reply": reply,
"auto_stop_seconds": MAX_ON_SECONDS,
}
if action == "read_light":
reply = self._exchange("READ")
if not reply.startswith("LIGHT "):
raise RuntimeError(f"Unexpected Arduino response: {reply}")
return {"ok": True, "light": int(reply.split(" ", 1)[1])}
if action == "stop":
self._cancel_timer()
return {"ok": True, "reply": self._exchange("STOP")}
raise ValueError("Allowed actions: set_brightness, read_light, stop")
def close(self):
self._cancel_timer()
try:
self._exchange("STOP")
finally:
self.serial_port.close()
def receive_request(connection):
data = bytearray()
while b"\n" not in data:
chunk = connection.recv(1024)
if not chunk:
break
data.extend(chunk)
if len(data) > MAX_REQUEST_BYTES:
raise ValueError("Request is too large")
line = bytes(data).split(b"\n", 1)[0]
if not line:
raise ValueError("Request is empty")
return json.loads(line.decode("utf-8"))
def serve(device):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"Bridge listening on {HOST}:{PORT}")
while True:
connection, _ = server.accept()
with connection:
try:
response = device.execute(receive_request(connection))
except Exception as error:
response = {"ok": False, "error": str(error)}
connection.sendall(
(json.dumps(response) + "\n").encode("utf-8")
)
def main():
if len(sys.argv) != 2:
raise SystemExit("Usage: python bridge.py YOUR_ARDUINO_PORT")
device = LightDevice(sys.argv[1])
try:
serve(device)
except KeyboardInterrupt:
print("\nStopping bridge")
finally:
device.close()
if __name__ == "__main__":
main()
Where does the safety come from?
- The MAX_BRIGHTNESS check rejects values above 180 before any serial command reaches the Arduino.
- The LightDevice.lock allows one serial exchange at a time.
- The stop_timer schedules a deterministic STOP after every accepted brightness request.
- The close() method stops the LED when the bridge shuts down.
- Stop the first bridge by pressing Control-C in its Terminal.
- Restart the revised bridge with the same Arduino port by running:
python bridge.py [[ARDUINO_PORT="your Arduino port"]]
What changed at runtime?
The new process loads the revised limits from bridge.py. It keeps listening on the same local address while enforcing the policy before serial writes.
Before you run this, do you think the request reaches the Arduino or stops inside the bridge?
- Test a value above the new ceiling yourself by running:
python device_client.py set 255
The bridge rejects the request
You will see a driver error describing the accepted brightness range. The LED stays off because the bridge rejects 255 before sending a serial command.
This expected failure proves that client arguments cannot raise the deterministic maximum.
Before you run the accepted value, do you expect the LED to require another manual stop?
- Test the highest allowed brightness yourself by running:
python device_client.py set 180
The bounded request succeeds
You will see a successful JSON response containing OK SET 180 and an automatic stop interval of 10.0 seconds. The LED lights at the highest permitted brightness.
- Watch the LED for 10 seconds.
You will see the LED turn off without another client command. The bridge Terminal prints Safety timer stopped the LED.
That is the safety boundary working on your side. The bridge now keeps the rig within its brightness and on-time limits.
Limit or timer not working?
- Confirm you stopped the first bridge before starting the revised process.
- Confirm MAX_BRIGHTNESS is 180 in bridge.py.
- Confirm _schedule_stop() runs after an accepted brightness command.
Get help with a brightness request that bypasses the limit or an LED that does not stop automatically.
Your bounded bridge is running with the LED safely off. Next, you will turn these protected commands into a planned and repeatable experiment.
Plan the Repeatable Experiment
That safety boundary is working. The bridge rejects brightness above 180 before the request reaches the Arduino.
It also turns the LED off after 10 seconds.
A reviewed experimental plan gives Claude Code a fixed procedure before it receives operational permission.
In this step, get ready to:
- Define the hypothesis and experimental variables.
- Review the controls and repeated measurement schedule.
- Limit Claude to the approved experiment script.
Plan the experiment before hardware access
A hypothesis states the pattern you expect across the five brightness levels. Variables and controls make that pattern testable across two trials.
- Switch back to Claude Code from earlier.
- Remind Claude that it cannot invoke device_client.py or operate the hardware yet.
- Ask Claude to create EXPERIMENT.md inside ~/mhs-light-lab with a testable hypothesis about the sensor response.
- Require EXPERIMENT.md to identify brightness as the independent variable.
- Require the plan to identify the photoresistor ADC reading as the dependent variable.
- Require the plan to keep the LED-to-photoresistor distance fixed.
- Require the plan to keep the ambient conditions fixed.
- Require a schedule using brightness levels 0, 45, 90, 135, and 180 with five readings per level across two complete trials.
- Require STOP after each trial.
Claude should create EXPERIMENT.md without operating the light rig.
Review the plan against the rig
A written plan can look complete while leaving a control vague. Your review turns every requirement into an explicit experimental rule.
- Review the hypothesis in EXPERIMENT.md for a predicted relationship between brightness and the ADC reading.
- Confirm that brightness is named as the independent variable.
- Confirm that the photoresistor ADC reading is named as the dependent variable.
- Confirm that the LED-to-photoresistor distance stays fixed.
- Confirm that the ambient conditions stay fixed.
- Confirm that the measurement schedule uses all five required brightness levels.
- Confirm that each level receives five readings in each of two complete trials.
- Confirm that each trial ends with STOP.
Good work. The experiment now has a repeatable recipe that you can inspect before the LED moves.
Grant only bounded operational permission
This permission change is deliberately narrow. The deterministic bridge still owns brightness and timeout enforcement.
Claude Code remains in Manual mode. These agent permissions keep every broader shell action behind your approval.
- Tell Claude that EXPERIMENT.md has passed your review.
- Authorize Claude to use python with the project-local run_experiment.py script only after it requests your approval.
- Keep every broader shell permission in Manual mode.
- Keep every broader file permission in Manual mode.
- Confirm that bypassPermissions is not enabled.
- Keep direct invocation of device_client.py outside Claude's approved scope.
Before the final check, pause to predict the single command scope Claude should name.
- Ask Claude to summarize its operational permission without running the experiment or operating the hardware.
You should see Claude name only Python running the project-local run_experiment.py script after your approval.
The bridge remains running in its separate activated Terminal. The LED remains off because no hardware command ran during this step.
Claude asks for broader access?
- Deny any request for unrestricted shell access.
- Restate that Claude may only request approval to run run_experiment.py through Python.
- Keep direct use of device_client.py outside the approved scope.
Help me narrow Claude Code's requested permissions.
Your experiment now has a reviewed plan and a narrow permission boundary. Next, Claude can turn that recipe into repeated measurements without gaining unrestricted hardware access.
Run, Plot, and Report the Experiment
Your bounded driver is ready. Claude Code has a reviewed experimental plan plus narrowly scoped permission.
A single sensor reading only proves that the rig responds once. Repeated trials reveal whether the response is consistent enough to support a measured conclusion.
In this step, get ready to:
- Create the deterministic experiment runner.
- Inspect repeated measurements through error bars.
- Write a one-paragraph report grounded in the data.
Create and run the deterministic experiment
The Python program turns the reviewed plan into the same sequence of bounded requests every time. It must also return the LED to its safe state when the experiment finishes or encounters a problem.
What should Claude build?
- The program imports send_request from device_client so every hardware action passes through the deterministic bridge.
- The constants define five brightness levels across two complete trials.
- Each brightness level produces five sensor readings.
- The cleanup path sends stop even when data collection exits early.
- The program saves 50 measurement rows to results.csv.
- The program calculates a mean plus a sample standard deviation for each brightness level.
- The program uses Matplotlib to save two error-bar series in light_response.png.
- The program prints a summary for each trial.
- Switch back to the Claude Code session in ~/mhs-light-lab.
- Ask Claude to create run_experiment.py from the reviewed EXPERIMENT.md using the acceptance criteria above.
- Review Claude's proposed file change.
- Compare the proposed file with the reference below.
- Ask Claude to correct any mismatch before approval.
- Approve the file edit once every line matches.
✔️ Awesome, I've got everything!
Your run_experiment.py file matches the bounded experiment design.
ⓧ I'd like to double check the full code
import csv
import statistics
import time
import matplotlib.pyplot as plt
from device_client import send_request
BRIGHTNESS_LEVELS = [0, 45, 90, 135, 180]
TRIALS = [1, 2]
SAMPLES_PER_LEVEL = 5
SETTLE_SECONDS = 0.4
SAMPLE_INTERVAL_SECONDS = 0.1
RESULTS_FILE = "results.csv"
PLOT_FILE = "light_response.png"
def collect_measurements():
rows = []
try:
for trial in TRIALS:
for brightness in BRIGHTNESS_LEVELS:
send_request(
{"action": "set_brightness", "value": brightness}
)
time.sleep(SETTLE_SECONDS)
for sample in range(1, SAMPLES_PER_LEVEL + 1):
response = send_request({"action": "read_light"})
rows.append(
{
"trial": trial,
"brightness": brightness,
"sample": sample,
"light": response["light"],
}
)
time.sleep(SAMPLE_INTERVAL_SECONDS)
send_request({"action": "stop"})
finally:
try:
send_request({"action": "stop"})
except OSError:
pass
return rows
def write_csv(rows):
with open(RESULTS_FILE, "w", newline="", encoding="utf-8") as csv_file:
writer = csv.DictWriter(
csv_file,
fieldnames=["trial", "brightness", "sample", "light"],
)
writer.writeheader()
writer.writerows(rows)
def summarize_trial(rows, trial):
means = []
spreads = []
for brightness in BRIGHTNESS_LEVELS:
values = [
row["light"]
for row in rows
if row["trial"] == trial and row["brightness"] == brightness
]
means.append(statistics.mean(values))
spreads.append(statistics.stdev(values))
return means, spreads
def write_plot(rows):
formats = {1: "o-", 2: "s-"}
for trial in TRIALS:
means, spreads = summarize_trial(rows, trial)
plt.errorbar(
BRIGHTNESS_LEVELS,
means,
yerr=spreads,
fmt=formats[trial],
capsize=4,
)
plt.savefig(PLOT_FILE, dpi=150, bbox_inches="tight")
def print_summary(rows):
for trial in TRIALS:
means, spreads = summarize_trial(rows, trial)
pairs = [
f"{brightness}:{mean_value:.1f}±{spread:.1f}"
for brightness, mean_value, spread in zip(
BRIGHTNESS_LEVELS, means, spreads
)
]
print(f"Trial {trial} mean±stdev by brightness: " + ", ".join(pairs))
def main():
rows = collect_measurements()
write_csv(rows)
write_plot(rows)
print_summary(rows)
print(f"Saved {len(rows)} measurements to {RESULTS_FILE}")
print(f"Saved error-bar plot to {PLOT_FILE}")
if __name__ == "__main__":
main()
How does the experiment stay bounded?
The program never writes to the Arduino serial port directly. Every request passes through send_request() and reaches the existing bridge.
The finally block protects the safe ending. It requests a stop even when collection exits before all 50 readings are complete.
Before you approve the run, do you expect both trials to follow a similar brightness trend?
- Have Claude run the approved project-local experiment with:
python run_experiment.py
What does this command do?
- The script traverses BRIGHTNESS_LEVELS once for each value in TRIALS.
- Each request remains subject to the bridge's brightness ceiling plus its automatic stop timer.
- The script writes the latest physical measurements to results.csv.
- The script saves the calculated error-bar plot to light_response.png.
You should see summaries beginning with Trial 1 mean±stdev by brightness: plus Trial 2 mean±stdev by brightness:. The final lines confirm that 50 measurements plus the error-bar plot were saved.
You have turned the physical rig into a repeatable data-collection system. The cleanup request leaves the LED off after the second trial.
Did the experiment stop early?
- Confirm the bridge is still running in its separate Terminal.
- Confirm the Arduino remains connected to the same USB port.
- Confirm Claude is working inside ~/mhs-light-lab with the existing virtual environment.
- Run the experiment again only after the bridge reports that it is listening.
Help me troubleshoot an experiment run that stopped before saving all 50 readings.
Inspect repeatability and error bars
A CSV file keeps every individual reading available for inspection. The plot compresses those readings into per-trial means plus sample standard deviations.
Before you run the complete check, do you expect the dataset to contain 50 measurements plus one header row?
- Switch back to the activated project Terminal from earlier.
- Run the end-to-end verification with:
python run_experiment.py && wc -l results.csv && test -f light_response.png
What does this check prove?
- The first command replaces the dataset with a fresh bounded experiment run.
- The second command counts the CSV header plus all measurement rows.
- The third command verifies that light_response.png exists.
You should see both trial summaries followed by a line count of 51 for results.csv. Completion of the full command confirms that the PNG file exists.
Did the final check fail?
- Check the experiment output for the last brightness level that completed.
- Confirm the bridge remained running throughout both trials.
- Keep the LED-to-photoresistor distance fixed before repeating the command.
Help me diagnose why the final experiment check did not produce 51 CSV lines plus the plot file.
- Press Cmd+Space to open Spotlight.
- Type Finder into the search field.
- Press Enter to open Finder.
- Select your home folder in the Finder sidebar.
- Open the mhs-light-lab folder.
- Open results.csv.
What should the dataset contain?
- The header contains trial, brightness, sample, and light.
- The data contains trial values 1 plus 2.
- Each trial contains brightness values 0, 45, 90, 135, and 180.
- Each trial and brightness pair contains sample values from 1 through 5.
- The file contains no timestamps, which keeps matching trials easy to compare.
- Return to the mhs-light-lab folder in Finder.
- Open light_response.png.
- Compare the direction of the two trial series.
- Compare the sizes of their error bars.
Similar series support repeatability under the fixed setup. Larger error bars reveal more variation among the five readings at that brightness level.
Keep the measurements honest
Strong disagreement between trials is evidence about the setup. Keep the physical conditions fixed before rerunning the experiment instead of editing the measured values.
Write a report grounded in the measurements
Measurements support a claim only when the wording stays inside what the rig observed. This experiment measures photoresistor ADC response under controlled conditions.
What belongs in the report?
- Describe the observed relationship between PWM brightness plus ADC response.
- Compare the overall direction of both trials.
- Describe variability using the error bars.
- Name one limitation of the physical setup.
- Keep every claim grounded in EXPERIMENT.md plus results.csv plus the printed summary.
- Keep the entire report to exactly one paragraph.
- Switch back to the Claude Code session from earlier.
- Ask Claude to read EXPERIMENT.md.
- Ask Claude to analyze results.csv plus the latest printed summary.
- Ask Claude to draft REPORT.md as exactly one paragraph using the criteria above.
- Review the proposed report before approving the file write.
- Reject any claim that goes beyond the measured ADC response.
- Approve the file write once the paragraph stays within the evidence.
The report now connects the written plan to the measurements. Its limitation keeps the conclusion proportionate to this uncalibrated physical rig.
- Switch back to the mhs-light-lab folder in Finder.
- Open REPORT.md.
- Confirm the report contains exactly one paragraph.
- Confirm the paragraph covers the trend.
- Confirm the paragraph covers repeatability.
- Confirm the paragraph covers variability.
- Confirm the paragraph names one setup limitation.
- Look at the rig to confirm the LED is off.
That completes the evidence chain from a reviewed plan to repeatable measurements. You now have 50 inspectable readings, an error-bar plot, and a measured report.
Secret mission
Diagnose a Hidden Physical Fault
A hidden sensor-power fault can make healthy software look broken. Use only the existing bounded commands plus prior measurements to diagnose it. Restore the rig to prove the readings recover.
Clean Up Your Resources
Clean Up Your Resources
Everything you built stays on your Mac or physical rig. Idle local resources create no ongoing infrastructure cost.
Account charges for Claude Code can apply during active use. Decide whether to keep your resources available, pause them for later, or delete them entirely.
Resources you used:
- A connected Arduino Uno or classic Arduino Nano light rig with the LED circuit on pin 9 plus the sensor circuit on A0.
- The persistent bridge process from bridge.py, if it is still running in Terminal.
- The ~/mhs-light-lab folder containing firmware source, driver code, the reviewed plan, measured data, the plot, the report, plus the .venv Python environment.
Keep everything running
No action is needed. Choose this if you want to rerun the bounded experiment or revisit the fault diagnosis.
- Keep the LED off between experiments.
- Leave the restored photoresistor jumper connected.
- Keep the USB cable connected while actively testing the rig.
- Preserve ~/mhs-light-lab so your measurements plus report remain available.
- Use device_client.py only while bridge.py is running.
Pause - I'll come back to this later
Shut down the local bridge to release the serial port. Keep the circuit plus project files ready for your next session.
- Press Control-C in the Terminal running bridge.py if the process is active.
- Disconnect the Arduino USB data cable from your Mac.
- Leave the restored photoresistor jumper connected to the sensor circuit.
- Keep the LED positioned at the same short distance from the photoresistor.
- Keep ~/mhs-light-lab intact for your next session.
The disconnected rig now uses no power. Your plan plus experimental evidence remain ready for another run.
Delete - I don't want to use this again
Deleting the project folder is irreversible. Your Arduino board plus electronic parts remain reusable after you disassemble the rig.
- Press Control-C in the Terminal running bridge.py if the process is active.
- Disconnect the Arduino USB data cable from your Mac.
- Remove the jumper wires from the Arduino plus breadboard.
- Return the Arduino, breadboard, LED, photoresistor, resistors, jumper wires, plus USB cable to storage.
- Delete the ~/mhs-light-lab folder by running this command:
rm -rf ~/mhs-light-lab
What does this remove?
This command permanently removes ~/mhs-light-lab. The deleted contents include the firmware source, bridge, client, experiment script, plan, dataset, plot, report, plus virtual environment.
Python, Arduino IDE, plus Claude Code remain installed for future projects.
Your reusable hardware is back in storage. The local experiment files are now gone from your Mac.
Folder Still Present?
If the folder remains, confirm that the command used the exact ~/mhs-light-lab path. Avoid changing the path because this deletion is permanent.
Help me safely check why the project folder was not removed.
Nice Work!
Nice Work!
You did it! Your bounded Arduino light lab now produces repeatable evidence under Claude Code's direction.
You've learned how to:
- Build a three-behavior serial protocol that lets Claude Code set brightness, read the sensor, or stop the LED through a persistent Python bridge.
- Enforce a deterministic safety boundary with a brightness ceiling of 180 plus a 10-second automatic shutoff.
- Produce repeatable evidence from 50 sensor readings across two trials in results.csv, light_response.png, plus REPORT.md.
- Secret Mission: Diagnose a hidden physical fault from bounded observations. Confirm the restored rig against the earlier trial.
Ready to quiz yourself?