Hyperdrive Propulsion Matrix

Hyperdrive Propulsion Matrix

This tutorial builds a spacecraft fuel mixture control system using four rotary encoders connected to a Raspberry Pi. Players must adjust the precise mixture of Oxidizer, Fuel, and PBAN (Polybutadiene Acrylonitrile) along with selecting the correct mixture type to achieve the perfect propulsion formula. This puzzle features real-time WebSocket communication, persistent encoder values across reconnections, and status feedback for an immersive engineering challenge.

Gameplay Mechanics

The Challenge

Players must correctly mix the shuttle's fuel to solve this puzzle. The correct mixture (55% Oxidizer, 25% Fuel, 10% PBAN, Type 3) represent a specific propellant formulation found in the Captain's log.

With 101 values for each percentage encoder and 5 mixture types, there are over 5 million possible combinations (101³ × 5), making brute force impossible.

The persistent values mean players can work together, with different team members adjusting different components without losing progress.

How Players Interact

  • Observe current values: Display shows current mixture percentages and type
  • Flip security override switch: The safe cap is lifted and switch is flipped to ON to enable power to a rotary encoder
  • Adjust oxidizer: Rotate first encoder to set oxidizer percentage (0-100%)
  • Adjust fuel: Rotate second encoder to set fuel percentage (0-100%)
  • Adjust PBAN: Rotate third encoder to set binder percentage (0-100%)
  • Select mixture type: Rotate fourth encoder to choose formula type (0-4)
  • Activate check: Press button to verify the mixture
  • Wait for analysis: 3.5-second checking period builds tension
  • Read result: Display shows CORRECT or INCORRECT status

Required Components

Hardware

  • 1x Raspberry Pi (3B+ or newer)
  • 4x Rotary Encoders (with detents)
  • 4x SPDT Switches with Safety Caps
  • 1x Push button (activation button)
  • Jumper wires
  • Breadboard
  • 220Ω resistors
  • 4x Green LEDs
  • 4x Red LEDs
  • 3.3V/5V Power Supply

Software Requirements

  • Raspberry Pi OS
  • Python 3.7+
  • RPi.GPIO library
  • asyncio library
  • websockets library
  • Node-RED server

Wiring Diagram

Hyperdrive Propulsion Matrix wiring diagram

Oxidizer Encoder

  • CLK → GPIO 17
  • DT → GPIO 18
  • SW → Not used
  • + → Common Terminal of SPDT Switch
  • GND → GND

Fuel Encoder

  • CLK → GPIO 22
  • DT → GPIO 23
  • SW → Not used
  • + → Common Terminal of SPDT Switch
  • GND → GND

PBAN Encoder

  • CLK → GPIO 24
  • DT → GPIO 25
  • SW → Not used
  • + → Common Terminal of SPDT Switch
  • GND → GND

Mixture Type Selector

  • CLK → GPIO 5
  • DT → GPIO 6
  • SW → Not used
  • + → Common Terminal of SPDT Switch
  • GND → GND

Activation Button

  • Terminal 1 → GPIO 27
  • Terminal 2 → GND
  • Internal pull-up enabled

WebSocket Connection

  • Server: localhost:1880
  • Path: /encoder
  • Reconnects automatically

The Code

1Import Required Libraries

Import all necessary Python libraries for GPIO control, asynchronous operations, and WebSocket communication:

# GPIO control library
from RPi import GPIO

# Asynchronous programming support
import asyncio
import websockets

# Data serialization and utilities
import json
from datetime import datetime

What each library does:

RPi.GPIO: Provides low-level access to Raspberry Pi GPIO pins. Handles pin configuration, reading digital inputs, and managing pull-up/pull-down resistors.

asyncio: Python's asynchronous I/O framework. Enables concurrent operations without threading, perfect for handling multiple encoder inputs and WebSocket communication simultaneously.

websockets: Implements WebSocket client functionality for real-time bidirectional communication with the Node-RED server.

json: Handles conversion between Python dictionaries and JSON strings for message formatting.


2Global Configuration and Storage

Define persistent storage for encoder values and the solution:

# Store encoder values globally to persist across reconnections
ENCODER_VALUES = {
    1: 70,  # Oxidizer (0-100%)
    2: 50,  # Fuel (0-100%)
    3: 40,  # PBAN (0-100%)
    4: 0    # Mixture Type selector (0-4)
}

# Define the solution values
SOLUTION = {
    1: 55,  # Oxidizer target
    2: 25,  # Fuel target
    3: 10,  # PBAN target
    4: 3    # Mixture type target
}

BUTTON_PIN = 27  # GPIO pin for the activation button
CHECKING_TIME = 3.5  # Seconds to simulate checking process

Configuration explained:

Persistent values: ENCODER_VALUES maintains state across WebSocket reconnections, ensuring players don't lose progress if the connection drops.

Solution formula: The correct mixture is 55% Oxidizer, 25% Fuel, 10% PBAN with Mixture Type 3. This could represent a specific rocket fuel formulation.

Checking delay: The 3.5-second delay simulates a realistic analysis process, building tension during gameplay.


3Encoder Configuration

Set up the encoder pin mappings and status messages:

ENCODERS = [
    {'clk': 17, 'dt': 18, 'id': 1},  # Oxidizer
    {'clk': 22, 'dt': 23, 'id': 2},  # Fuel
    {'clk': 24, 'dt': 25, 'id': 3},  # PBAN
    {'clk': 5, 'dt': 6, 'id': 4}     # Mixture Type selector
]

WS_URI = "ws://localhost:1880/encoder"

# Status message constants
STATUS_MESSAGES = {
    'checking': {
        'status': 'CHECKING MIXTURE',
        'suffix': '. PLEASE WAIT'
    },
    'correct': {
        'status': 'CORRECT MIXTURE',
        'suffix': '. DO NOT TOUCH'
    },
    'incorrect': {
        'status': 'INCORRECTLY MIXED',
        'suffix': '. PLEASE REMIX AND REACTIVATE'
    }
}

Encoder mapping:

CLK and DT pins: Each rotary encoder uses two pins for quadrature encoding, allowing detection of both rotation direction and speed.

ID assignment: Each encoder has a unique ID (1-4) that corresponds to its function in the mixture control system.

Status messages: Pre-defined messages provide clear feedback to players through the escape room's display system.


4EncoderReader Class

Implement the main class that handles encoder reading and WebSocket communication:

class EncoderReader:
    def __init__(self, encoders):
        GPIO.setmode(GPIO.BCM)
        self.encoders = []
        self.checking = False
        
        # Setup button with pull-up resistor
        GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
        
        # Initialize each encoder
        for enc in encoders:
            GPIO.setup(enc['clk'], GPIO.IN, pull_up_down=GPIO.PUD_UP)
            GPIO.setup(enc['dt'], GPIO.IN, pull_up_down=GPIO.PUD_UP)
            self.encoders.append({
                'clk': enc['clk'],
                'dt': enc['dt'],
                'id': enc['id'],
                'value': ENCODER_VALUES[enc['id']],
                'clk_last_state': GPIO.input(enc['clk'])
            })

    async def send_keepalive(self, websocket):
        try:
            await websocket.send(json.dumps({"type": "keepalive"}))
            print(f"{datetime.now()}: Keepalive sent")
        except Exception as e:
            print(f"Keepalive error: {e}")
            raise

Class initialization:

GPIO.BCM mode: Uses Broadcom pin numbering (GPIO numbers) rather than physical pin numbers for consistency.

Pull-up resistors: Internal pull-ups ensure stable HIGH state when encoders are not being rotated.

State tracking: Stores the last CLK state for each encoder to detect state transitions.

Keepalive messages: Prevents WebSocket timeout by sending periodic heartbeat messages.


5Solution Checking Logic

Implement the mixture validation and status reporting:

Check Solution Method

async def check_solution(self, websocket):
    if self.checking:
        return
        
    self.checking = True
    await self.send_status(websocket, 'checking')
    
    # Artificial delay to simulate checking process
    await asyncio.sleep(CHECKING_TIME)
    
    # Check if all encoders match solution
    correct = all(
        ENCODER_VALUES[enc_id] == SOLUTION[enc_id]
        for enc_id in SOLUTION.keys()
    )
    
    # Send result
    status_type = 'correct' if correct else 'incorrect'
    await self.send_status(websocket, status_type)
    print(f"{datetime.now()}: Current values: {ENCODER_VALUES}")
    print(f"{datetime.now()}: Target values: {SOLUTION}")
    
    self.checking = False

Solution checking process:

Prevents multiple simultaneous checks with the checking flag.

Sends "CHECKING MIXTURE" status to build anticipation.

Uses Python's all() function for elegant validation of all four values.

Provides detailed console output for debugging and monitoring.

Encoder Reading Loop

async def read_and_send(self, websocket):
    last_keepalive = 0
    last_button_state = GPIO.input(BUTTON_PIN)
    
    # Send initial incorrect status
    await self.send_status(websocket, 'incorrect')
    
    while True:
        current_time = asyncio.get_event_loop().time()
        
        # Send keepalive every 2 seconds
        if current_time - last_keepalive >= 2:
            await self.send_keepalive(websocket)
            last_keepalive = current_time

        # Check button state for activation
        button_state = GPIO.input(BUTTON_PIN)
        if button_state != last_button_state and button_state == GPIO.LOW:
            print(f"{datetime.now()}: Button pressed! Starting solution check...")
            await self.check_solution(websocket)
        last_button_state = button_state

        # Read each encoder
        for enc in self.encoders:
            clk_state = GPIO.input(enc['clk'])
            dt_state = GPIO.input(enc['dt'])
            
            if clk_state != enc['clk_last_state'] and clk_state == 0:
                # Determine rotation direction
                new_value = enc['value'] + (1 if dt_state != clk_state else -1)
                
                # Apply limits based on encoder type
                if enc['id'] != 4:
                    # Percentage encoders (0-100)
                    enc['value'] = max(0, min(100, new_value))
                else:
                    # Mixture type selector (0-4, wraps around)
                    enc['value'] = new_value % 5
                
                # Update global store and send to server
                ENCODER_VALUES[enc['id']] = enc['value']
                
                data = json.dumps({
                    'encoder': enc['id'],
                    'position': enc['value']
                })
                await websocket.send(data)
                
                # Reset to incorrect status after any change
                await self.send_status(websocket, 'incorrect')
            
            enc['clk_last_state'] = clk_state
        
        await asyncio.sleep(0.001)

Reading logic breakdown:

Detects rotation direction by comparing CLK and DT states during transitions.

Different value ranges: 0-100 for mixture components, 0-4 for mixture type (with wraparound).

1ms sleep prevents CPU overload while maintaining responsive control.

Automatically marks mixture as incorrect after any adjustment, requiring revalidation.


6Main Connection Loop

Implement auto-reconnection and error handling:

async def main():
    while True:
        try:
            async with websockets.connect(
                WS_URI,
                ping_interval=1,
                ping_timeout=5,
                close_timeout=1,
                max_size=2**20,
                max_queue=2**10
            ) as websocket:
                print(f"{datetime.now()}: Connected to Node-RED")
                
                # Send current values immediately after connection
                for enc_id, value in ENCODER_VALUES.items():
                    data = json.dumps({
                        'encoder': enc_id,
                        'position': value
                    })
                    await websocket.send(data)
                    print(f"{datetime.now()}: Sent initial encoder data: {data}")
                
                reader = EncoderReader(ENCODERS)
                await reader.read_and_send(websocket)

        except websockets.exceptions.ConnectionClosed as e:
            print(f"{datetime.now()}: Connection closed: {e}")
        except Exception as e:
            print(f"{datetime.now()}: Error: {e}")
        finally:
            print(f"{datetime.now()}: Attempting to reconnect...")
            await asyncio.sleep(0.1)

if __name__ == '__main__':
    try:
        asyncio.get_event_loop().run_until_complete(main())
    except KeyboardInterrupt:
        print("\nProgram stopped")
        GPIO.cleanup()

Connection management:

Auto-reconnection: Infinite loop ensures the puzzle stays operational even after network interruptions.

Initial state sync: Sends all encoder values upon connection to synchronize with the server.

WebSocket parameters: Tuned for low latency with 1-second pings and reasonable buffer sizes.

Clean shutdown: GPIO.cleanup() ensures pins are reset when the program exits.

Testing Your Hyperdrive Propulsion Matrix

1Installation and Setup

Install the required Python libraries on your Raspberry Pi:

# Update package list
sudo apt-get update

# Install Python pip if not already installed
sudo apt-get install python3-pip

# Install required Python libraries
pip3 install websockets
pip3 install RPi.GPIO

# Verify installations
python3 -c "import RPi.GPIO; print('GPIO library installed')"
python3 -c "import websockets; print('WebSockets library installed')"

2Hardware Verification Script

Test your encoder connections with this simple verification script:

import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BCM)

# Test each encoder individually
encoders = [
    {'name': 'Oxidizer', 'clk': 17, 'dt': 18},
    {'name': 'Fuel', 'clk': 22, 'dt': 23},
    {'name': 'PBAN', 'clk': 24, 'dt': 25},
    {'name': 'Mixture Type', 'clk': 5, 'dt': 6}
]

for enc in encoders:
    GPIO.setup(enc['clk'], GPIO.IN, pull_up_down=GPIO.PUD_UP)
    GPIO.setup(enc['dt'], GPIO.IN, pull_up_down=GPIO.PUD_UP)
    print(f"{enc['name']}: CLK={GPIO.input(enc['clk'])}, DT={GPIO.input(enc['dt'])}")

# Test button
GPIO.setup(27, GPIO.IN, pull_up_down=GPIO.PUD_UP)
print(f"Button state: {GPIO.input(27)}")

print("\nRotate each encoder and press the button to see changes...")
try:
    while True:
        time.sleep(0.1)
except KeyboardInterrupt:
    GPIO.cleanup()

3Troubleshooting Common Issues

Encoder Not Responding

  • Check GPIO pin connections are secure
  • Verify encoder is getting 3.3V power
  • Test continuity with multimeter
  • Try different GPIO pins
  • Ensure common pin is grounded

WebSocket Connection Failed

  • Verify Node-RED is running
  • Check firewall settings
  • Confirm port 1880 is open
  • Test with ws://localhost:1880/encoder
  • Check Node-RED websocket node path

Values Jump Erratically

  • Add hardware debouncing capacitors
  • Increase software debounce delay
  • Check for electrical interference
  • Use shielded cables for encoders
  • Verify pull-up resistors are enabled

Button Not Triggering

  • Verify button is normally open type
  • Check GPIO 27 connection
  • Test with simple GPIO read script
  • Ensure pull-up is enabled
  • Try external pull-up resistor

4Console Output During Operation

Monitor the console output to verify proper operation:

2024-01-15 14:23:45: Connected to Node-RED
2024-01-15 14:23:45: Sent initial encoder data: {"encoder": 1, "position": 70}
2024-01-15 14:23:45: Sent initial encoder data: {"encoder": 2, "position": 50}
2024-01-15 14:23:45: Sent initial encoder data: {"encoder": 3, "position": 40}
2024-01-15 14:23:45: Sent initial encoder data: {"encoder": 4, "position": 0}
2024-01-15 14:23:47: Keepalive sent
2024-01-15 14:23:49: Keepalive sent
2024-01-15 14:23:50: Sent encoder data: {"encoder": 1, "position": 55}
2024-01-15 14:23:51: Sent encoder data: {"encoder": 2, "position": 25}
2024-01-15 14:23:52: Sent encoder data: {"encoder": 3, "position": 10}
2024-01-15 14:23:53: Sent encoder data: {"encoder": 4, "position": 3}
2024-01-15 14:23:54: Button pressed! Starting solution check...
2024-01-15 14:23:54: Status sent - CHECKING MIXTURE. PLEASE WAIT
2024-01-15 14:23:57: Current values: {1: 55, 2: 25, 3: 10, 4: 3}
2024-01-15 14:23:57: Target values: {1: 55, 2: 25, 3: 10, 4: 3}
2024-01-15 14:23:57: Status sent - CORRECT MIXTURE. DO NOT TOUCH

Possible Enhancements

Multiple Solutions

Store an array of valid mixtures that change based on game state, allowing for progressive difficulty or alternate paths.

Tolerance Ranges

Allow values within ±2% of the target for a more forgiving experience, simulating real-world measurement uncertainty.

Safety Warnings

Add dangerous mixture detection that triggers warning messages for specific combinations, adding realism to the fuel mixing theme.

Get in touch!

We are currently looking for educators and students to collaborate with on future projects.

Get in Touch!

hello@escapehumber.ca