Files
midi2motor/serial_control.py
devdesk 89857a1769 Add serial PWM control and Streamlit GUI
- 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
2026-01-28 00:50:59 +02:00

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")