Use pigpio for remote projector, eyes and antennas on Pi Zero

This commit is contained in:
shaboomi 2025-08-09 13:56:54 +12:00
parent 32037347dc
commit 85d7efde7c
3 changed files with 186 additions and 57 deletions

View file

@ -1,49 +1,96 @@
import board
import pwmio
"""
Servo control for the duck's antennas using a remote Raspberry Pi Zero.
In the previous implementation this module used CircuitPython's ``pwmio``
module to produce PWM signals on the local GPIO pins (``board.D13`` and
``board.D12``). In the new setup the antennas are physically connected to
a Pi Zero that is exposed via USB gadget mode. We therefore use the
pigpio library to send servo pulse widths to the remote pins over the
network. This allows the controlling code to run on the Pi 4 while
driving servos attached to the Pi Zero.
The servo angle is specified in the range [-1, 1], where -1 corresponds
to the minimum position (1ms pulse), 0 corresponds to the neutral
position (1.5ms pulse) and 1 corresponds to the maximum position
(2ms pulse). These values are converted into microsecond pulse widths
for ``pi.set_servo_pulsewidth``.
"""
import pigpio
import math
import time
LEFT_ANTENNA_PIN = board.D13
RIGHT_ANTENNA_PIN = board.D12
# BCM pin numbers for the left and right antenna servos on the Pi Zero.
# ``board.D13`` → BCM 13, ``board.D12`` → BCM 12.
LEFT_ANTENNA_PIN = 13
RIGHT_ANTENNA_PIN = 12
LEFT_SIGN = 1
RIGHT_SIGN = -1
MIN_UPDATE_INTERVAL = 1 / 50 # 20ms
# Minimum update interval when sweeping the antennas (20 ms).
MIN_UPDATE_INTERVAL = 1 / 50
def value_to_duty_cycle(v):
pulse_width_ms = 1.5 + (v * 0.5) # 1ms to 2ms
duty_cycle = int((pulse_width_ms / 20) * 65535)
return min(max(duty_cycle, 3277), 6553)
def value_to_pulse_width(value: float) -> int:
"""Map a value in [-1, 1] to a servo pulse width in microseconds.
A value of 0 returns the neutral pulse width of 1500 µs. Values of -1
and 1 return 1000 µs and 2000 µs respectively. The result is
clamped to [500, 2500]µs which is within the pigpio servo limits.
"""
# Clamp input range
v = max(-1.0, min(1.0, value))
return int(1500 + (v * 500))
class Antennas:
def __init__(self):
neutral_duty = value_to_duty_cycle(0)
self.pwm_left = pwmio.PWMOut(LEFT_ANTENNA_PIN, frequency=50, duty_cycle=neutral_duty)
self.pwm_right = pwmio.PWMOut(RIGHT_ANTENNA_PIN, frequency=50, duty_cycle=neutral_duty)
"""Control left and right servo antennas via a remote pigpio daemon.
def set_position_left(self, position):
self.set_position(self.pwm_left, position, LEFT_SIGN)
Parameters
----------
host : str, optional
IP address or hostname of the pigpio daemon running on the Pi Zero.
Defaults to ``"192.168.7.2"``.
"""
def set_position_right(self, position):
self.set_position(self.pwm_right, position, RIGHT_SIGN)
def __init__(self, host: str = "192.168.7.2") -> None:
# Connect to remote pigpio
self.pi = pigpio.pi(host)
if not self.pi.connected:
raise RuntimeError(f"Failed to connect to pigpio daemon on {host}")
def set_position(self, pwm, value, sign=1):
# if value == 0:
# return
if -1 <= value <= 1:
duty_cycle = value_to_duty_cycle(value * sign) # Convert value to duty cycle (1ms-2ms)
pwm.duty_cycle = duty_cycle
# Store pins and set to servo mode (pigpio automatically sets mode)
self.left_pin = LEFT_ANTENNA_PIN
self.right_pin = RIGHT_ANTENNA_PIN
# Initialise servos to neutral position
neutral = value_to_pulse_width(0)
self.pi.set_servo_pulsewidth(self.left_pin, neutral)
self.pi.set_servo_pulsewidth(self.right_pin, neutral)
def set_position_left(self, position: float) -> None:
self.set_position(self.left_pin, position, LEFT_SIGN)
def set_position_right(self, position: float) -> None:
self.set_position(self.right_pin, position, RIGHT_SIGN)
def set_position(self, pin: int, value: float, sign: int = 1) -> None:
"""Set the servo on ``pin`` to ``value`` (clamped to [-1, 1])."""
if -1.0 <= value <= 1.0:
pulse_width = value_to_pulse_width(value * sign)
self.pi.set_servo_pulsewidth(pin, pulse_width)
else:
print("Invalid input! Enter a value between -1 and 1.")
def stop(self):
def stop(self) -> None:
"""Centre the servos and close the pigpio connection."""
time.sleep(MIN_UPDATE_INTERVAL)
# Centre both servos
self.set_position_left(0)
self.set_position_right(0)
time.sleep(MIN_UPDATE_INTERVAL)
self.pwm_left.deinit()
self.pwm_right.deinit()
# Disable servo pulses and close connection
self.pi.set_servo_pulsewidth(self.left_pin, 0)
self.pi.set_servo_pulsewidth(self.right_pin, 0)
self.pi.stop()
if __name__ == "__main__":

View file

@ -1,32 +1,80 @@
import board
import digitalio
"""
Update: This module has been modified to use the pigpio library for driving
the eye LEDs on a remote Raspberry Pi Zero. The Pi Zero is connected to
the main Raspberry Pi 4 via USB (gadget mode) and exposes its GPIO pins over
the network using the pigpio daemon. The IP address of the Pi Zero must
be supplied when creating the `Eyes` instance (defaults to ``192.168.7.2``).
Instead of using CircuitPython's ``digitalio`` and ``board`` modules, we
instantiate a ``pigpio.pi`` object and configure the relevant pins as
outputs. The pins are referenced using their Broadcom (BCM) numbers, which
match the ``board.Dxx`` naming convention used previously (e.g. ``board.D24``
corresponds to BCM 24).
To blink the LEDs, this class writes a high (1) or low (0) value to both
pins using ``pi.write``. When stopping, the pins are explicitly set low and
the pigpio connection is terminated.
"""
import pigpio
import random
import time
from threading import Thread, Event
LEFT_EYE_PIN = board.D24
RIGHT_EYE_PIN = board.D23
# BCM pin numbers corresponding to the eye LEDs on the Pi Zero. These match
# the ``board.Dxx`` names previously used (D24 → BCM 24, D23 → BCM 23).
LEFT_EYE_PIN = 24
RIGHT_EYE_PIN = 23
class Eyes:
def __init__(self, blink_duration=0.1, min_interval=1.0, max_interval=4.0):
self.left_eye = digitalio.DigitalInOut(LEFT_EYE_PIN)
self.left_eye.direction = digitalio.Direction.OUTPUT
"""Controls blinking of two eye LEDs via a remote pigpio daemon.
self.right_eye = digitalio.DigitalInOut(RIGHT_EYE_PIN)
self.right_eye.direction = digitalio.Direction.OUTPUT
Parameters
----------
host : str, optional
IP address or hostname of the pigpio daemon running on the Pi Zero.
Defaults to ``"192.168.7.2"``. Change this if your Pi Zero uses a
different IP address.
blink_duration : float, optional
Duration (in seconds) that the eyes remain closed for each blink.
min_interval : float, optional
Minimum time (in seconds) between blinks.
max_interval : float, optional
Maximum time (in seconds) between blinks.
"""
self.blink_duration = blink_duration
self.min_interval = min_interval
self.max_interval = max_interval
def __init__(self, host: str = "192.168.7.2", blink_duration: float = 0.1,
min_interval: float = 1.0, max_interval: float = 4.0) -> None:
# Establish a remote connection to the pigpio daemon running on the
# Raspberry Pi Zero. If the connection fails, ``pigpio.pi`` will
# return an object with ``connected`` set to 0.
self.pi = pigpio.pi(host)
if not self.pi.connected:
raise RuntimeError(f"Failed to connect to pigpio daemon on {host}")
self._stop_event = Event()
self._thread = Thread(target=self.run, daemon=True)
# Configure the eye pins as outputs and start with the LEDs off.
self.left_pin = LEFT_EYE_PIN
self.right_pin = RIGHT_EYE_PIN
self.pi.set_mode(self.left_pin, pigpio.OUTPUT)
self.pi.set_mode(self.right_pin, pigpio.OUTPUT)
self.pi.write(self.left_pin, 0)
self.pi.write(self.right_pin, 0)
self.blink_duration: float = blink_duration
self.min_interval: float = min_interval
self.max_interval: float = max_interval
# Threading setup for blinking logic.
self._stop_event: Event = Event()
self._thread: Thread = Thread(target=self.run, daemon=True)
self._thread.start()
def _set_eyes(self, state):
self.left_eye.value = state
self.right_eye.value = state
def _set_eyes(self, state: bool) -> None:
"""Set both eye LEDs on (True) or off (False)."""
value = 1 if state else 0
self.pi.write(self.left_pin, value)
self.pi.write(self.right_pin, value)
def run(self):
try:
@ -41,11 +89,13 @@ class Eyes:
self._stop_event.set()
def stop(self):
"""Stop the blinking thread and release resources."""
self._stop_event.set()
self._thread.join()
# Ensure the LEDs are turned off before closing the connection.
self._set_eyes(False)
self.left_eye.deinit()
self.right_eye.deinit()
# Terminate connection to the pigpio daemon.
self.pi.stop()
if __name__ == "__main__":

View file

@ -1,24 +1,56 @@
import board
import digitalio
"""
This module controls a projector relay connected to a Raspberry Pi Zero.
The Pi Zero is exposed to the main Raspberry Pi 4 via USB in gadget mode,
and GPIO control is achieved through the pigpio library. The default
IP address of the Pi Zero is ``192.168.7.2``; update this if your setup
differs.
Previously this module relied on CircuitPython's ``digitalio`` and ``board``
modules. Those do not work when the code is executed on the Pi 4 and the
device is physically attached to a remote Pi Zero. The updated version
instead uses pigpio to toggle a BCM pin on the remote device. The pin
number corresponds to ``board.D25`` (BCM 25).
"""
import pigpio
import time
PROJECTOR_GPIO = board.D25
# BCM pin used to toggle the projector. ``board.D25`` translates to BCM 25.
PROJECTOR_GPIO = 25
class Projector:
def __init__(self):
self.project = digitalio.DigitalInOut(PROJECTOR_GPIO)
self.project.direction = digitalio.Direction.OUTPUT
"""Toggle a projector connected to a remote Raspberry Pi Zero via pigpio.
Parameters
----------
host : str, optional
IP address or hostname of the pigpio daemon on the Pi Zero.
Defaults to ``"192.168.7.2"``.
"""
def __init__(self, host: str = "192.168.7.2") -> None:
# Connect to the remote pigpio daemon. If the connection fails,
# ``pigpio.pi`` returns an object with ``connected`` set to 0.
self.pi = pigpio.pi(host)
if not self.pi.connected:
raise RuntimeError(f"Failed to connect to pigpio daemon on {host}")
# Configure the projector GPIO as an output and start with it off.
self.pin = PROJECTOR_GPIO
self.pi.set_mode(self.pin, pigpio.OUTPUT)
self.on = False
self.pi.write(self.pin, 0)
def switch(self):
def switch(self) -> None:
"""Toggle the projector on/off."""
self.on = not self.on
self.pi.write(self.pin, 1 if self.on else 0)
self.project.value = self.on
def stop(self):
self.project.value = False
self.project.deinit()
def stop(self) -> None:
"""Ensure the projector is off and clean up the pigpio connection."""
self.pi.write(self.pin, 0)
self.pi.stop()
if __name__ == "__main__":