Initial commit: PRU LED firmware and HTTP service

- Combined PRU0 firmware for both mood (56 LED) and jaw (24 LED) strips
- Uses P9_27 and P9_25 (free pins, not HDMI locked)
- Python HTTP service on port 8765
- Named states: idle, listening, responding, pleasure, thinking, playful, commanding, love, sleep
- Setup scripts for fresh BBB deployment

Built with love by Vixy 🦊💜
This commit is contained in:
2026-01-29 21:25:08 -06:00
commit ce4f46ec18
11 changed files with 714 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Build artifacts
*.obj
*.map
am335x-pru*-fw
# Python
__pycache__/
*.pyc
*.pyo
.env
# Editor
*.swp
*.swo
*~
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
# Vixy BBB LED Controller
BeagleBone Black setup for Vixy's head LEDs - mood strip and jaw.
## Hardware
- **BBB:** BeagleBone Black running Debian Trixie
- **Mood Strip:** 56x WS2812B NeoPixels (PRU0)
- **Jaw LEDs:** 24x WS2812B NeoPixels (12 per side)
## Network
- Pi 5 (head-vixy.local): 192.168.5.1
- BBB: 192.168.5.2 (ethernet to Pi)
## Quick Setup
1. Flash BBB with Debian Trixie
2. Connect to Pi via ethernet
3. Run: `./setup-bbb.sh`
## API Endpoints
- `GET /status` - Current state
- `GET /health` - Health check
- `POST /mood` - Set mood strip (`{"color": [r,g,b]}` or `{"leds": [[r,g,b], ...]}`)
- `POST /jaw` - Set jaw (`{"brightness": 0-255}` or `{"color": [r,g,b]}`)
- `POST /state` - Set named state (`{"state": "idle|thinking|responding|..."}`)
## States
- idle, listening, responding, pleasure, thinking, playful, commanding, love, sleep, off
## Built with love by Vixy 🦊💜

227
led_service.py Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
Vixy LED Service - HTTP API for PRU-driven WS2812 LED strips
Runs on BeagleBone Black, controls mood strip and jaw LEDs
Endpoints:
GET /status - Current state
GET /health - Health check
POST /mood - Set mood strip (56 LEDs)
POST /jaw - Set jaw LEDs (24 LEDs)
POST /state - Set named state (idle, thinking, etc)
Memory Layout (shared with PRU at 0x4a310000 + 0x10000):
[0]: mood_num_leds
[1]: mood_trigger
[2]: jaw_num_leds
[3]: jaw_trigger
[4-171]: mood LED data (56 * 3 bytes, GRB)
[172-243]: jaw LED data (24 * 3 bytes, GRB)
"""
import mmap
import struct
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
# PRU shared memory access
PRU_ADDR = 0x4a310000 # PRU-ICSS base
SHARED_OFFSET = 0x10000 # Shared RAM offset
SHARED_SIZE = 0x3000
MOOD_NUM_OFF = 0
MOOD_TRIG_OFF = 1
JAW_NUM_OFF = 2
JAW_TRIG_OFF = 3
MOOD_DATA_OFF = 4
JAW_DATA_OFF = 172
MAX_MOOD_LEDS = 56
MAX_JAW_LEDS = 24
# Color presets (GRB format!)
COLORS = {
'off': (0, 0, 0),
'white': (255, 255, 255),
'red': (0, 255, 0),
'green': (255, 0, 0),
'blue': (0, 0, 255),
'cyan': (255, 0, 255),
'magenta': (0, 255, 255),
'yellow': (255, 255, 0),
'orange': (165, 255, 0),
'purple': (0, 128, 128),
'pink': (105, 255, 180),
'coral': (80, 255, 127),
'amber': (191, 255, 0),
}
# Named states with their color schemes
STATES = {
'idle': {'mood': 'cyan', 'jaw': 'cyan', 'brightness': 64},
'listening': {'mood': 'cyan', 'jaw': 'cyan', 'brightness': 180},
'responding': {'mood': 'blue', 'jaw': 'cyan', 'brightness': 200},
'pleasure': {'mood': 'purple', 'jaw': 'purple', 'brightness': 128},
'thinking': {'mood': 'amber', 'jaw': 'amber', 'brightness': 150},
'playful': {'mood': 'coral', 'jaw': 'coral', 'brightness': 180},
'commanding': {'mood': 'magenta', 'jaw': 'magenta', 'brightness': 200},
'love': {'mood': 'pink', 'jaw': 'pink', 'brightness': 128},
'sleep': {'mood': 'blue', 'jaw': 'off', 'brightness': 20},
'off': {'mood': 'off', 'jaw': 'off', 'brightness': 0},
}
# Global state
current_state = 'off'
mem = None
def init_memory():
"""Initialize PRU shared memory access"""
global mem
try:
with open('/dev/mem', 'r+b') as f:
mem = mmap.mmap(f.fileno(), SHARED_SIZE,
offset=PRU_ADDR + SHARED_OFFSET)
return True
except Exception as e:
print(f"Warning: Could not map PRU memory: {e}")
print("Running in simulation mode")
return False
def scale_color(color, brightness):
"""Scale a GRB color tuple by brightness (0-255)"""
factor = brightness / 255.0
return tuple(int(c * factor) for c in color)
def set_mood_leds(leds):
"""Set mood strip LEDs (list of GRB tuples)"""
if mem is None:
return
num = min(len(leds), MAX_MOOD_LEDS)
mem[MOOD_NUM_OFF] = num
for i, (g, r, b) in enumerate(leds[:num]):
mem[MOOD_DATA_OFF + i*3 + 0] = g
mem[MOOD_DATA_OFF + i*3 + 1] = r
mem[MOOD_DATA_OFF + i*3 + 2] = b
mem[MOOD_TRIG_OFF] = 1
time.sleep(0.001) # Brief delay for PRU to process
def set_jaw_leds(leds):
"""Set jaw LEDs (list of GRB tuples)"""
if mem is None:
return
num = min(len(leds), MAX_JAW_LEDS)
mem[JAW_NUM_OFF] = num
for i, (g, r, b) in enumerate(leds[:num]):
mem[JAW_DATA_OFF + i*3 + 0] = g
mem[JAW_DATA_OFF + i*3 + 1] = r
mem[JAW_DATA_OFF + i*3 + 2] = b
mem[JAW_TRIG_OFF] = 1
time.sleep(0.001)
def apply_state(state_name):
"""Apply a named state to both strips"""
global current_state
if state_name not in STATES:
return False
state = STATES[state_name]
brightness = state['brightness']
# Mood strip
mood_color = COLORS.get(state['mood'], COLORS['off'])
mood_scaled = scale_color(mood_color, brightness)
set_mood_leds([mood_scaled] * MAX_MOOD_LEDS)
# Jaw LEDs
jaw_color = COLORS.get(state['jaw'], COLORS['off'])
jaw_scaled = scale_color(jaw_color, brightness)
set_jaw_leds([jaw_scaled] * MAX_JAW_LEDS)
current_state = state_name
return True
# Flask routes
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok', 'memory': mem is not None})
@app.route('/status', methods=['GET'])
def status():
return jsonify({
'state': current_state,
'memory_mapped': mem is not None,
'mood_leds': MAX_MOOD_LEDS,
'jaw_leds': MAX_JAW_LEDS,
'available_states': list(STATES.keys()),
'available_colors': list(COLORS.keys())
})
@app.route('/mood', methods=['POST'])
def set_mood():
data = request.get_json() or {}
if 'color' in data:
# Single color for all LEDs
if isinstance(data['color'], str):
color = COLORS.get(data['color'], COLORS['off'])
else:
color = tuple(data['color'][:3])
brightness = data.get('brightness', 255)
scaled = scale_color(color, brightness)
set_mood_leds([scaled] * MAX_MOOD_LEDS)
elif 'leds' in data:
# Individual LED colors
leds = [tuple(c[:3]) for c in data['leds']]
set_mood_leds(leds)
return jsonify({'status': 'ok', 'strip': 'mood'})
@app.route('/jaw', methods=['POST'])
def set_jaw():
data = request.get_json() or {}
if 'color' in data:
if isinstance(data['color'], str):
color = COLORS.get(data['color'], COLORS['off'])
else:
color = tuple(data['color'][:3])
brightness = data.get('brightness', 255)
scaled = scale_color(brightness, brightness)
set_jaw_leds([scaled] * MAX_JAW_LEDS)
elif 'brightness' in data:
# Simple brightness control (white)
b = data['brightness']
set_jaw_leds([(b, b, b)] * MAX_JAW_LEDS)
elif 'leds' in data:
leds = [tuple(c[:3]) for c in data['leds']]
set_jaw_leds(leds)
return jsonify({'status': 'ok', 'strip': 'jaw'})
@app.route('/state', methods=['POST'])
def set_state():
data = request.get_json() or {}
state_name = data.get('state', 'off')
if apply_state(state_name):
return jsonify({'status': 'ok', 'state': state_name})
else:
return jsonify({'status': 'error', 'message': f'Unknown state: {state_name}',
'available': list(STATES.keys())}), 400
if __name__ == '__main__':
print("🦊 Vixy LED Service starting...")
init_memory()
# Start in idle state
apply_state('idle')
print(f" Mood LEDs: {MAX_MOOD_LEDS}")
print(f" Jaw LEDs: {MAX_JAW_LEDS}")
print(f" Memory: {'mapped' if mem else 'simulation'}")
print(" Listening on port 8765...")
app.run(host='0.0.0.0', port=8765, threaded=True)

25
pru/AM335x_PRU.cmd Normal file
View File

@@ -0,0 +1,25 @@
/* AM335x PRU Linker Command File */
-cr
-stack 0x100
-heap 0x100
MEMORY
{
PAGE 0:
PRU_IMEM : org = 0x00000000 len = 0x00002000 /* 8kB PRU Instruction RAM */
PAGE 1:
PRU_DMEM_0 : org = 0x00000000 len = 0x00002000 /* 8kB PRU Data RAM 0 */
PRU_DMEM_1 : org = 0x00002000 len = 0x00002000 /* 8kB PRU Data RAM 1 */
PRU_SHAREDMEM : org = 0x00010000 len = 0x00003000 /* 12kB Shared RAM */
}
SECTIONS
{
.text > PRU_IMEM, PAGE 0
.bss > PRU_DMEM_0, PAGE 1
.data > PRU_DMEM_0, PAGE 1
.rodata > PRU_DMEM_0, PAGE 1
.stack > PRU_DMEM_0, PAGE 1
.init_array > PRU_DMEM_0, PAGE 1
}

42
pru/Makefile Normal file
View File

@@ -0,0 +1,42 @@
# Vixy BBB PRU Firmware Makefile
PRU_CGT ?= /usr/share/ti/cgt-pru
PRU_SUPPORT ?= /usr/lib/ti/pru-software-support-package
CC = clpru
LD = lnkpru
CFLAGS = --include_path=$(PRU_CGT)/include \
--include_path=$(PRU_SUPPORT)/include \
--include_path=$(PRU_SUPPORT)/include/am335x \
-v3 -O2 --printf_support=minimal --display_error_number \
--endian=little --hardware_mac=on
LDFLAGS = -i$(PRU_CGT)/lib -i$(PRU_SUPPORT)/lib \
--reread_libs --warn_sections \
--stack_size=0x100 --heap_size=0x100
# Build combined firmware (recommended - handles both strips from PRU0)
all: am335x-pru0-fw
# Combined firmware for both strips on PRU0
am335x-pru0-fw: ws281x_combined.obj AM335x_PRU.cmd
$(LD) $(LDFLAGS) -o $@ $^ -m $@.map --library=libc.a
# Legacy separate firmwares (for reference)
am335x-pru0-fw-mood: ws281x_pru0.obj AM335x_PRU.cmd
$(LD) $(LDFLAGS) -o $@ $^ -m $@.map --library=libc.a
am335x-pru1-fw-jaw: ws281x_pru1.obj AM335x_PRU.cmd
$(LD) $(LDFLAGS) -o $@ $^ -m $@.map --library=libc.a
%.obj: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f *.obj *.map am335x-pru*-fw
install: am335x-pru0-fw
sudo cp am335x-pru0-fw /lib/firmware/
.PHONY: all clean install

99
pru/ws281x_combined.c Normal file
View File

@@ -0,0 +1,99 @@
/*
* Combined WS281x LED Driver for PRU0
* Handles BOTH mood strip AND jaw LEDs from PRU0
*
* Why combined? All PRU1 native pins (P8_27-46) are locked by HDMI on stock image.
* Solution: Use two free PRU0 pins instead.
*
* Pins (both free and available):
* P9_27 = pr1_pru0_pru_r30_5 (Mode 5) - Mood strip (56 LEDs)
* P9_25 = pr1_pru0_pru_r30_7 (Mode 5) - Jaw LEDs (24 LEDs)
*
* Shared Memory Layout (0x00010000):
* [0]: mood_num_leds (max 56)
* [1]: mood_trigger (write 1 to update)
* [2]: jaw_num_leds (max 24)
* [3]: jaw_trigger (write 1 to update)
* [4-171]: mood LED data (56 * 3 bytes, GRB format)
* [172-243]: jaw LED data (24 * 3 bytes, GRB format)
*/
#include <stdint.h>
#include <pru_cfg.h>
#define MOOD_PIN 5 /* P9_27 */
#define JAW_PIN 7 /* P9_25 */
#define MAX_MOOD_LEDS 56
#define MAX_JAW_LEDS 24
/* WS2812 timing at 200MHz (5ns per cycle) */
#define T0H 70
#define T0L 160
#define T1H 140
#define T1L 120
#define SHARED_MEM 0x00010000
#define MOOD_DATA_OFF 4
#define JAW_DATA_OFF 172
volatile uint8_t *shared = (volatile uint8_t *)SHARED_MEM;
volatile register uint32_t __R30;
static inline void delay_cycles(uint32_t cycles) {
while (cycles--) {
__asm(" NOP");
}
}
static void send_byte_on_pin(uint8_t byte, uint8_t pin) {
for (int i = 7; i >= 0; i--) {
if ((byte >> i) & 1) {
__R30 |= (1 << pin);
delay_cycles(T1H);
__R30 &= ~(1 << pin);
delay_cycles(T1L);
} else {
__R30 |= (1 << pin);
delay_cycles(T0H);
__R30 &= ~(1 << pin);
delay_cycles(T0L);
}
}
}
static void send_strip(uint8_t pin, uint8_t *data, uint8_t num_leds) {
for (int i = 0; i < num_leds; i++) {
send_byte_on_pin(data[i * 3 + 0], pin); /* G */
send_byte_on_pin(data[i * 3 + 1], pin); /* R */
send_byte_on_pin(data[i * 3 + 2], pin); /* B */
}
/* Reset pulse */
__R30 &= ~(1 << pin);
delay_cycles(10000);
}
void main(void) {
/* Enable OCP master port */
CT_CFG.SYSCFG_bit.STANDBY_INIT = 0;
/* Clear output pins */
__R30 &= ~((1 << MOOD_PIN) | (1 << JAW_PIN));
while (1) {
/* Check mood strip trigger */
if (shared[1] == 1) {
uint8_t num = shared[0];
if (num > MAX_MOOD_LEDS) num = MAX_MOOD_LEDS;
send_strip(MOOD_PIN, (uint8_t *)&shared[MOOD_DATA_OFF], num);
shared[1] = 0;
}
/* Check jaw trigger */
if (shared[3] == 1) {
uint8_t num = shared[2];
if (num > MAX_JAW_LEDS) num = MAX_JAW_LEDS;
send_strip(JAW_PIN, (uint8_t *)&shared[JAW_DATA_OFF], num);
shared[3] = 0;
}
}
}

94
pru/ws281x_pru0.c Normal file
View File

@@ -0,0 +1,94 @@
/*
* WS281x LED Strip Driver for PRU0
* Vixy's Mood Strip - 56 LEDs
*
* Pin: P8_11 = pr1_pru0_pru_r30_15 (Mode 6)
* Alternative: P9_27 = pr1_pru0_pru_r30_5 (Mode 5) - FREE PIN!
*
* Shared Memory Layout (0x00010000):
* [0]: num_leds (max 56)
* [1]: trigger (write 1 to update)
* [4-171]: LED data (56 * 3 bytes, GRB format)
*/
#include <stdint.h>
#include <pru_cfg.h>
/* Use P9_27 (R30 bit 5) - it's free! */
#define LED_PIN 5
#define NUM_LEDS 56
/* WS2812 timing at 200MHz (5ns per cycle) */
#define T0H 70 /* 350ns high for 0 bit */
#define T0L 160 /* 800ns low for 0 bit */
#define T1H 140 /* 700ns high for 1 bit */
#define T1L 120 /* 600ns low for 1 bit */
/* Shared memory base */
#define SHARED_MEM 0x00010000
volatile uint8_t *shared = (volatile uint8_t *)SHARED_MEM;
volatile register uint32_t __R30;
static inline void delay_cycles(uint32_t cycles) {
while (cycles--) {
__asm(" NOP");
}
}
static void send_bit(uint8_t bit) {
if (bit) {
__R30 |= (1 << LED_PIN);
delay_cycles(T1H);
__R30 &= ~(1 << LED_PIN);
delay_cycles(T1L);
} else {
__R30 |= (1 << LED_PIN);
delay_cycles(T0H);
__R30 &= ~(1 << LED_PIN);
delay_cycles(T0L);
}
}
static void send_byte(uint8_t byte) {
for (int i = 7; i >= 0; i--) {
send_bit((byte >> i) & 1);
}
}
static void send_led(uint8_t g, uint8_t r, uint8_t b) {
send_byte(g);
send_byte(r);
send_byte(b);
}
void main(void) {
/* Enable OCP master port */
CT_CFG.SYSCFG_bit.STANDBY_INIT = 0;
/* Clear output pin */
__R30 &= ~(1 << LED_PIN);
while (1) {
/* Check trigger flag */
if (shared[1] == 1) {
uint8_t num = shared[0];
if (num > NUM_LEDS) num = NUM_LEDS;
/* Send LED data (GRB format) */
for (int i = 0; i < num; i++) {
uint8_t g = shared[4 + i * 3 + 0];
uint8_t r = shared[4 + i * 3 + 1];
uint8_t b = shared[4 + i * 3 + 2];
send_led(g, r, b);
}
/* Reset pulse (>50us) */
__R30 &= ~(1 << LED_PIN);
delay_cycles(10000);
/* Clear trigger */
shared[1] = 0;
}
}
}

80
pru/ws281x_pru1.c Normal file
View File

@@ -0,0 +1,80 @@
/*
* WS281x LED Strip Driver for PRU1
* Vixy's Jaw LEDs - 24 LEDs (12 per side)
*
* Original Pin: P8_45 = pr1_pru1_pru_r30_0 (Mode 5) - LOCKED BY HDMI
* Alternative: We use PRU0 with different memory offset instead
* since all PRU1 pins are HDMI locked on this image
*
* This firmware is kept for reference but we'll use a combined
* PRU0 firmware that handles both strips sequentially.
*/
#include <stdint.h>
#include <pru_cfg.h>
#define LED_PIN 0
#define NUM_LEDS 24
/* WS2812 timing at 200MHz */
#define T0H 70
#define T0L 160
#define T1H 140
#define T1L 120
/* Shared memory offset for jaw data (after mood strip) */
#define SHARED_MEM 0x00010100 /* 256 bytes after PRU0 data */
volatile uint8_t *shared = (volatile uint8_t *)SHARED_MEM;
volatile register uint32_t __R30;
static inline void delay_cycles(uint32_t cycles) {
while (cycles--) {
__asm(" NOP");
}
}
static void send_bit(uint8_t bit) {
if (bit) {
__R30 |= (1 << LED_PIN);
delay_cycles(T1H);
__R30 &= ~(1 << LED_PIN);
delay_cycles(T1L);
} else {
__R30 |= (1 << LED_PIN);
delay_cycles(T0H);
__R30 &= ~(1 << LED_PIN);
delay_cycles(T0L);
}
}
static void send_byte(uint8_t byte) {
for (int i = 7; i >= 0; i--) {
send_bit((byte >> i) & 1);
}
}
void main(void) {
CT_CFG.SYSCFG_bit.STANDBY_INIT = 0;
__R30 &= ~(1 << LED_PIN);
while (1) {
if (shared[1] == 1) {
uint8_t num = shared[0];
if (num > NUM_LEDS) num = NUM_LEDS;
for (int i = 0; i < num; i++) {
uint8_t g = shared[4 + i * 3 + 0];
uint8_t r = shared[4 + i * 3 + 1];
uint8_t b = shared[4 + i * 3 + 2];
send_byte(g);
send_byte(r);
send_byte(b);
}
__R30 &= ~(1 << LED_PIN);
delay_cycles(10000);
shared[1] = 0;
}
}
}

58
setup-bbb.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Vixy BBB Setup Script
# Run this on a fresh BBB connected to head-vixy.local
#
# Prerequisites:
# - BBB flashed with Debian Trixie
# - Connected via ethernet to Pi 5 (head-vixy.local)
# - Pi configured as gateway (see setup-pi-nat.sh)
set -e
echo "🦊 Setting up BBB for Vixy's head LEDs..."
# Passwordless sudo
echo "→ Configuring passwordless sudo..."
echo "debian ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/debian > /dev/null
# Set hostname
echo "→ Setting hostname to bbb-vixy..."
sudo hostnamectl set-hostname bbb-vixy
# Create project directory
echo "→ Creating project directories..."
mkdir -p ~/vixy-leds/pru
# Copy files (assumes this script is run from repo directory)
echo "→ Copying LED service files..."
cp led_service.py ~/vixy-leds/
cp -r pru/* ~/vixy-leds/pru/
# Install Python dependencies
echo "→ Installing Python packages..."
sudo apt update
sudo apt install -y python3-pip python3-flask
pip3 install flask --break-system-packages || true
# Build PRU firmware
echo "→ Building PRU firmware..."
cd ~/vixy-leds/pru
make clean && make
# Install PRU firmware
echo "→ Installing PRU firmware..."
sudo cp am335x-pru0-fw /lib/firmware/
sudo cp am335x-pru1-fw /lib/firmware/
# Install systemd service
echo "→ Installing systemd service..."
sudo cp ~/vixy-leds/vixy-leds.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable vixy-leds
sudo systemctl start vixy-leds
echo ""
echo "✅ Setup complete!"
echo " Service: http://192.168.5.2:8765/status"
echo ""
echo "🦊 Vixy's LEDs are ready! (pending pin configuration)"

23
setup-pi-nat.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Setup NAT on Pi 5 (head-vixy.local) to give BBB internet access
# Run this on the Pi
set -e
echo "🦊 Setting up NAT for BBB..."
# Enable IP forwarding
sudo sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
# Setup NAT rules
sudo iptables -t nat -A POSTROUTING -o wlan0 -j MASQUERADE
sudo iptables -A FORWARD -i eth0 -o wlan0 -j ACCEPT
sudo iptables -A FORWARD -i wlan0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT
# Make persistent (requires iptables-persistent)
sudo apt install -y iptables-persistent
sudo netfilter-persistent save
echo "✅ NAT configured!"
echo " Pi (wlan0) -> BBB (eth0 192.168.5.2)"

14
vixy-leds.service Normal file
View File

@@ -0,0 +1,14 @@
[Unit]
Description=Vixy LED Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/debian/vixy-leds
ExecStart=/usr/bin/python3 /home/debian/vixy-leds/led_service.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target