- src/main.cpp: Add serial command interface (A/B/C values, s=status, 0=off) - src/main.cpp: Serial control disables MIDI timeout for persistent values - serial_control.py: Streamlit GUI with sliders and quick action buttons - serial_control.py: Filter out ttyS* ports, prioritize USB serial - test_midi.py: Update for new CC control mapping (CC 20/21/22) - README.md: Complete project documentation
280 lines
8.6 KiB
Python
280 lines
8.6 KiB
Python
#!/usr/bin/env -S uv run
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "streamlit",
|
|
# "pyserial",
|
|
# ]
|
|
# ///
|
|
"""
|
|
Streamlit GUI for Arduino PWM Serial Control.
|
|
|
|
Run with: streamlit run serial_control.py
|
|
"""
|
|
|
|
import streamlit as st
|
|
import serial
|
|
import serial.tools.list_ports
|
|
import time
|
|
|
|
# Page config
|
|
st.set_page_config(
|
|
page_title="PWM Serial Control",
|
|
page_icon="🎛️",
|
|
layout="wide"
|
|
)
|
|
|
|
st.title("🎛️ Arduino PWM Serial Control")
|
|
|
|
# Session state initialization
|
|
if 'serial_port' not in st.session_state:
|
|
st.session_state.serial_port = None
|
|
if 'ch1_value' not in st.session_state:
|
|
st.session_state.ch1_value = 0
|
|
if 'ch2_value' not in st.session_state:
|
|
st.session_state.ch2_value = 0
|
|
if 'ch3_value' not in st.session_state:
|
|
st.session_state.ch3_value = 0
|
|
if 'suppress_send' not in st.session_state:
|
|
st.session_state.suppress_send = False
|
|
|
|
|
|
def get_serial_ports():
|
|
"""Get list of available USB serial ports (filters out ttyS* hardware ports)."""
|
|
ports = serial.tools.list_ports.comports()
|
|
# Filter out ttyS* (hardware serial) and sort USB ports first
|
|
usb_ports = []
|
|
other_ports = []
|
|
for p in ports:
|
|
# Skip ttyS* hardware serial ports
|
|
if '/ttyS' in p.device or p.device.startswith('ttyS'):
|
|
continue
|
|
# Prioritize known USB serial ports
|
|
if '/ttyUSB' in p.device or '/ttyACM' in p.device or 'USB' in p.device:
|
|
usb_ports.append(p.device)
|
|
else:
|
|
other_ports.append(p.device)
|
|
return usb_ports + other_ports
|
|
|
|
|
|
def send_command(cmd: str) -> str:
|
|
"""Send command to Arduino and return response."""
|
|
if st.session_state.serial_port is None:
|
|
return "Not connected"
|
|
|
|
try:
|
|
ser = st.session_state.serial_port
|
|
ser.write(f"{cmd}\n".encode())
|
|
time.sleep(0.05) # Small delay for Arduino to respond
|
|
|
|
response = ""
|
|
while ser.in_waiting:
|
|
response += ser.read(ser.in_waiting).decode('utf-8', errors='ignore')
|
|
return response.strip() if response else "OK"
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
|
|
|
|
def connect_serial(port: str, baud: int = 115200):
|
|
"""Connect to serial port."""
|
|
try:
|
|
if st.session_state.serial_port is not None:
|
|
st.session_state.serial_port.close()
|
|
|
|
ser = serial.Serial(port, baud, timeout=1)
|
|
time.sleep(2) # Wait for Arduino reset
|
|
|
|
# Flush any startup messages
|
|
while ser.in_waiting:
|
|
ser.read(ser.in_waiting)
|
|
|
|
st.session_state.serial_port = ser
|
|
return True
|
|
except Exception as e:
|
|
st.error(f"Connection failed: {e}")
|
|
return False
|
|
|
|
|
|
def disconnect_serial():
|
|
"""Disconnect from serial port."""
|
|
if st.session_state.serial_port is not None:
|
|
try:
|
|
st.session_state.serial_port.close()
|
|
except:
|
|
pass
|
|
st.session_state.serial_port = None
|
|
|
|
|
|
# Sidebar - Connection
|
|
with st.sidebar:
|
|
st.header("🔌 Connection")
|
|
|
|
ports = get_serial_ports()
|
|
|
|
if not ports:
|
|
st.warning("No serial ports found")
|
|
selected_port = None
|
|
else:
|
|
selected_port = st.selectbox("Serial Port", ports)
|
|
|
|
baud_rate = st.selectbox("Baud Rate", [115200, 9600, 57600, 38400], index=0)
|
|
|
|
col1, col2 = st.columns(2)
|
|
|
|
with col1:
|
|
if st.button("Connect", use_container_width=True, disabled=selected_port is None):
|
|
if connect_serial(selected_port, baud_rate):
|
|
st.success("Connected!")
|
|
st.rerun()
|
|
|
|
with col2:
|
|
if st.button("Disconnect", use_container_width=True):
|
|
disconnect_serial()
|
|
st.rerun()
|
|
|
|
# Connection status
|
|
if st.session_state.serial_port is not None:
|
|
st.success(f"✅ Connected to {st.session_state.serial_port.port}")
|
|
else:
|
|
st.info("⚪ Not connected")
|
|
|
|
st.divider()
|
|
|
|
# Refresh ports button
|
|
if st.button("🔄 Refresh Ports", use_container_width=True):
|
|
st.rerun()
|
|
|
|
# Main content
|
|
connected = st.session_state.serial_port is not None
|
|
|
|
if not connected:
|
|
st.info("👆 Select a serial port and click Connect to begin")
|
|
else:
|
|
# PWM Controls
|
|
st.header("PWM Channels")
|
|
|
|
# Check if we should suppress sending (after quick action buttons)
|
|
suppress = st.session_state.suppress_send
|
|
if suppress:
|
|
st.session_state.suppress_send = False
|
|
|
|
col1, col2, col3 = st.columns(3)
|
|
|
|
with col1:
|
|
st.subheader("Channel 1 (Pin 9)")
|
|
ch1 = st.slider("PWM Value", 0, 255, st.session_state.ch1_value, key="slider_ch1")
|
|
if ch1 != st.session_state.ch1_value and not suppress:
|
|
st.session_state.ch1_value = ch1
|
|
response = send_command(f"A{ch1}")
|
|
st.caption(f"→ {response}")
|
|
elif ch1 != st.session_state.ch1_value:
|
|
st.session_state.ch1_value = ch1
|
|
|
|
with col2:
|
|
st.subheader("Channel 2 (Pin 10)")
|
|
ch2 = st.slider("PWM Value", 0, 255, st.session_state.ch2_value, key="slider_ch2")
|
|
if ch2 != st.session_state.ch2_value and not suppress:
|
|
st.session_state.ch2_value = ch2
|
|
response = send_command(f"B{ch2}")
|
|
st.caption(f"→ {response}")
|
|
elif ch2 != st.session_state.ch2_value:
|
|
st.session_state.ch2_value = ch2
|
|
|
|
with col3:
|
|
st.subheader("Channel 3 (Pin 11)")
|
|
ch3 = st.slider("PWM Value", 0, 255, st.session_state.ch3_value, key="slider_ch3")
|
|
if ch3 != st.session_state.ch3_value and not suppress:
|
|
st.session_state.ch3_value = ch3
|
|
response = send_command(f"C{ch3}")
|
|
st.caption(f"→ {response}")
|
|
elif ch3 != st.session_state.ch3_value:
|
|
st.session_state.ch3_value = ch3
|
|
|
|
st.divider()
|
|
|
|
# Quick actions
|
|
st.header("Quick Actions")
|
|
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
|
|
with col1:
|
|
if st.button("🔴 All OFF", use_container_width=True):
|
|
response = send_command("0")
|
|
st.session_state.ch1_value = 0
|
|
st.session_state.ch2_value = 0
|
|
st.session_state.ch3_value = 0
|
|
st.session_state.suppress_send = True
|
|
# Clear slider widget state to force reset
|
|
for key in ['slider_ch1', 'slider_ch2', 'slider_ch3']:
|
|
if key in st.session_state:
|
|
del st.session_state[key]
|
|
st.success(response)
|
|
st.rerun()
|
|
|
|
with col2:
|
|
if st.button("🟢 All FULL", use_container_width=True):
|
|
send_command("A255")
|
|
send_command("B255")
|
|
send_command("C255")
|
|
st.session_state.ch1_value = 255
|
|
st.session_state.ch2_value = 255
|
|
st.session_state.ch3_value = 255
|
|
st.session_state.suppress_send = True
|
|
for key in ['slider_ch1', 'slider_ch2', 'slider_ch3']:
|
|
if key in st.session_state:
|
|
del st.session_state[key]
|
|
st.rerun()
|
|
|
|
with col3:
|
|
if st.button("🟡 All 50%", use_container_width=True):
|
|
send_command("A128")
|
|
send_command("B128")
|
|
send_command("C128")
|
|
st.session_state.ch1_value = 128
|
|
st.session_state.ch2_value = 128
|
|
st.session_state.ch3_value = 128
|
|
st.session_state.suppress_send = True
|
|
for key in ['slider_ch1', 'slider_ch2', 'slider_ch3']:
|
|
if key in st.session_state:
|
|
del st.session_state[key]
|
|
st.rerun()
|
|
|
|
with col4:
|
|
if st.button("📊 Status", use_container_width=True):
|
|
response = send_command("s")
|
|
st.text(response)
|
|
|
|
st.divider()
|
|
|
|
# Manual command input
|
|
st.header("Manual Command")
|
|
|
|
col1, col2 = st.columns([3, 1])
|
|
|
|
with col1:
|
|
manual_cmd = st.text_input("Command", placeholder="e.g., A128, s, 0")
|
|
|
|
with col2:
|
|
st.write("") # Spacing
|
|
st.write("")
|
|
if st.button("Send", use_container_width=True):
|
|
if manual_cmd:
|
|
response = send_command(manual_cmd)
|
|
st.code(response)
|
|
|
|
# Command reference
|
|
with st.expander("📖 Command Reference"):
|
|
st.markdown("""
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `A<0-255>` | Set channel 1 (pin 9) PWM |
|
|
| `B<0-255>` | Set channel 2 (pin 10) PWM |
|
|
| `C<0-255>` | Set channel 3 (pin 11) PWM |
|
|
| `s` | Show status |
|
|
| `0` | All channels off |
|
|
""")
|
|
|
|
# Footer
|
|
st.divider()
|
|
st.caption("PWM Serial Control for Arduino | Commands: A/B/C + value, s=status, 0=off")
|