Implement automatic page scaling feature with viewport adjustments

This commit is contained in:
nearxos
2026-02-18 09:33:44 +02:00
parent a9b3726ace
commit d6b09cdd6f
35 changed files with 5722 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
# reTerminal DM4 Audio Configuration Report
## Date: 2026-01-14
## Official Documentation Summary
Based on Seeed Studio reTerminal DM documentation:
### Hardware Components
- **Audio Codec**: TI TLV320AIC3104 Low-Power Stereo Audio Codec
- **Microphones**: Dual MEMS microphones (left and right below screen)
- **Audio Output**: 3.5mm audio jack (output only, no microphone input)
- **Buzzer**: Built-in buzzer for system notifications/alerts
- **Internal Speakers**: ❌ **NONE** - The device does NOT have built-in speakers in the monitor
- **Interface**: IIS interface for microphones → codec, PCM interface for codec → CM4
### Audio Features
- Dual microphone array configuration
- Waterproof acoustic membrane (IP65 rating)
- **Stereo audio output via 3.5mm jack** (requires external speakers/headphones)
- **HDMI audio output** (when connected to external display)
- **Built-in buzzer only** - for basic alerts/notifications, not for audio playback
- **No internal speakers** - all audio playback requires external connection
---
## Current Device Configuration
### Audio Hardware Detection
**Playback Devices:**
```
card 0: vc4hdmi0 [vc4-hdmi-0] - HDMI audio output
card 1: seeed2micvoicec [seeed2micvoicec] - reTerminal built-in audio (PRIMARY)
card 2: vc4hdmi1 [vc4-hdmi-1] - HDMI audio output
```
**Recording Devices:**
```
card 1: seeed2micvoicec [seeed2micvoicec] - Dual MEMS microphones
```
### Audio Codec Status
**Detected Codec**: `tlv320aic3x` (TLV320AIC3x series - compatible with TLV320AIC3104)
**Kernel Messages:**
```
tlv320aic3x 1-0018: supply IOVDD not found, using dummy regulator
tlv320aic3x 1-0018: supply DVDD not found, using dummy regulator
tlv320aic3x 1-0018: supply AVDD not found, using dummy regulator
tlv320aic3x 1-0018: supply DRVDD not found, using dummy regulator
tlv320aic3x 1-0018: Invalid supply voltage(s) AVDD: -22, DVDD: -22
```
**Note**: The supply voltage warnings are common and don't prevent functionality. The codec uses dummy regulators and operates normally.
### ALSA Mixer Configuration
**Primary Controls:**
- **PCM**: 127/127 (100%) - Main playback volume ✓
- **Line**: 0/9 (0%) - Line output level
- **Line DAC**: 71/118 (60%) - Line DAC volume
- **HP (Headphone)**: 0/9 (0%) - Headphone output
- **HP DAC**: 71/118 (60%) - Headphone DAC volume
- **PGA**: 32/119 (27%) - Microphone preamp gain ✓
**Audio Routing:**
- **Playback**: Line Mixer DACL1/R1 enabled → 3.5mm audio jack
- **Capture**: PGA enabled → Dual MEMS microphones
- **AGC**: Disabled (Automatic Gain Control off)
### Audio System Status
| Component | Status | Details |
|-----------|--------|---------|
| Hardware Detection | ✓ Working | Card 1: seeed2micvoicec detected |
| Playback | ✓ Working | PCM at 100%, tested with speaker-test |
| Recording | ✓ Working | Microphones functional, tested recording |
| Codec Driver | ✓ Loaded | tlv320aic3x kernel module active |
| User Permissions | ✓ Configured | User in `audio` group |
| ALSA System | ✓ Operational | Direct ALSA (no PulseAudio) |
---
## Configuration Comparison
### ✅ Matches Documentation
1. **Audio Codec**: TLV320AIC3x detected (compatible with TLV320AIC3104)
2. **Dual Microphones**: Recording device shows stereo input capability
3. **3.5mm Audio Jack**: Line output controls present and configured
4. **ALSA Configuration**: Using ALSA directly as documented
5. **Audio Groups**: User properly configured in audio group
### ⚠️ Notes & Observations
1. **Supply Voltage Warnings**: Kernel shows supply voltage warnings, but device functions normally
2. **Card Numbering**: Documentation may reference different card numbers; device uses card 1
3. **PulseAudio**: Not installed (using ALSA directly) - this is fine and matches some configurations
4. **Volume Levels**:
- PCM (main output): 100% ✓
- Line/HP outputs: Lower levels (may need adjustment for 3.5mm jack)
- Microphone gain: 27% (may need adjustment based on use case)
---
## Testing Results
### Playback Test
```bash
speaker-test -D hw:1,0 -t sine -f 1000 -c 2 -l 1
```
**Result**: ✓ Successfully generated test tone
### Recording Test
```bash
arecord -D hw:1,0 -f cd -d 1 test.wav
```
**Result**: ✓ Successfully recorded 173KB stereo audio file
---
## Recommended Configuration
### For 3.5mm Audio Jack Output
If using the 3.5mm audio jack, you may need to adjust the Line output:
```bash
# Increase Line output volume (0-9 scale)
amixer -c 1 sset 'Line' 9
# Or use Line DAC for finer control (0-118 scale)
amixer -c 1 sset 'Line DAC' 118
```
### For Microphone Input
Current microphone gain is at 27%. Adjust if needed:
```bash
# Increase microphone gain (0-119 scale)
amixer -c 1 sset 'PGA' 50
# Enable AGC for automatic gain control
amixer -c 1 sset 'AGC' on
```
### Set Default Audio Card
To make card 1 the default for applications:
```bash
# Create ALSA configuration
cat > ~/.asoundrc << EOF
pcm.!default {
type hw
card 1
device 0
}
ctl.!default {
type hw
card 1
}
EOF
```
---
## Troubleshooting
### No Sound from 3.5mm Jack
1. Check Line output volume:
```bash
amixer -c 1 sget 'Line'
amixer -c 1 sset 'Line' 9
```
2. Verify Line DAC routing:
```bash
amixer -c 1 sget 'Line DAC'
amixer -c 1 sset 'Line DAC' 118
```
3. Test with direct hardware access:
```bash
aplay -D hw:1,0 /path/to/test.wav
```
### Microphone Not Working
1. Check microphone gain:
```bash
amixer -c 1 sget 'PGA'
amixer -c 1 sset 'PGA' 50
```
2. Verify recording device:
```bash
arecord -l
arecord -D hw:1,0 -f cd test.wav
```
3. Check microphone routing:
```bash
amixer -c 1 sget 'PGA Mixer'
```
### Supply Voltage Warnings
The kernel warnings about supply voltages are informational. The codec uses dummy regulators and functions correctly. These warnings can be ignored unless experiencing actual audio issues.
---
## Important Note: No Internal Speakers
**⚠️ The reTerminal DM4 does NOT have built-in speakers in the monitor.**
The device only includes:
- **Built-in Buzzer** (`/sys/class/leds/usr-buzzer`) - For system alerts/notifications only
- **3.5mm Audio Jack** - For connecting external speakers or headphones
- **HDMI Audio** - For audio output when connected to external displays
For audio playback, you **must** connect:
- External speakers via 3.5mm jack, OR
- Headphones via 3.5mm jack, OR
- HDMI display with audio support
The buzzer can only produce simple beep tones for alerts - it cannot play music or general audio content.
## Summary
**Audio Status**: ✅ **FULLY FUNCTIONAL** (with external speakers/headphones)
- Hardware detected correctly
- Playback working (tested via 3.5mm jack)
- Recording working (tested)
- Configuration matches documentation
- **No internal speakers** - external audio output required
- Minor adjustments may be needed for optimal 3.5mm jack output
The reTerminal DM4 audio system is properly configured and operational according to the official documentation specifications. All audio playback requires external speakers or headphones connected via the 3.5mm audio jack.

View File

@@ -0,0 +1,311 @@
# Simple Buzzer Control Guide - reTerminal DM4
## Overview
The reTerminal DM4 has a built-in buzzer that can be controlled for alerts and notifications. The buzzer is located at the bottom right corner of the screen.
## Quick Start
### Turn Buzzer ON
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
### Turn Buzzer OFF
```bash
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
### Check Buzzer Status
```bash
cat /sys/class/leds/usr-buzzer/brightness
```
- `0` = OFF
- `1` or `255` = ON
---
## Basic Control Methods
### Method 1: Command Line (Simple)
**Single Beep (0.2 seconds):**
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.2
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
**Double Beep:**
```bash
for i in 1 2; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
done
```
**Long Beep (1 second):**
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 1
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
### Method 2: Python Script
**Simple Python Control:**
```python
import subprocess
import time
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
def buzzer_on():
"""Turn buzzer ON"""
subprocess.run(['sudo', 'tee', BUZZER_PATH], input='1', text=True)
def buzzer_off():
"""Turn buzzer OFF"""
subprocess.run(['sudo', 'tee', BUZZER_PATH], input='0', text=True)
def beep(duration=0.2):
"""Play a beep for specified duration (in seconds)"""
buzzer_on()
time.sleep(duration)
buzzer_off()
# Usage
beep(0.2) # Short beep
beep(0.5) # Medium beep
beep(1.0) # Long beep
```
**Blinking Pattern:**
```python
def beep_pattern(count=3, on_time=0.1, off_time=0.1):
"""Blink buzzer multiple times"""
for _ in range(count):
buzzer_on()
time.sleep(on_time)
buzzer_off()
time.sleep(off_time)
# Usage
beep_pattern(3, 0.1, 0.1) # 3 quick beeps
```
### Method 3: Bash Function
Add to your `~/.bashrc`:
```bash
buzzer() {
local action=$1
local duration=${2:-0.2}
local BUZZER='/sys/class/leds/usr-buzzer/brightness'
case $action in
on)
echo 1 | sudo tee $BUZZER > /dev/null
;;
off)
echo 0 | sudo tee $BUZZER > /dev/null
;;
beep)
echo 1 | sudo tee $BUZZER > /dev/null
sleep $duration
echo 0 | sudo tee $BUZZER > /dev/null
;;
*)
echo "Usage: buzzer {on|off|beep [duration]}"
;;
esac
}
```
Then use:
```bash
buzzer on # Turn on
buzzer off # Turn off
buzzer beep 0.2 # Beep for 0.2 seconds
buzzer beep 0.5 # Beep for 0.5 seconds
```
---
## Common Patterns
### Success Alert (2 short beeps)
```bash
for i in 1 2; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
done
```
### Error Alert (3 fast beeps)
```bash
for i in 1 2 3; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.05
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.05
done
```
### Warning Alert (1 long beep)
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.5
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
### Notification (1 short beep)
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.2
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
---
## Using the Test Script
A test script is available at `/tmp/test_buzzer.sh` on the device:
```bash
# Run the test script
ssh guard "/tmp/test_buzzer.sh"
```
Or copy and run locally:
```bash
scp guard:/tmp/test_buzzer.sh ./
chmod +x test_buzzer.sh
./test_buzzer.sh
```
---
## Important Notes
1. **Requires sudo**: All buzzer control commands require root privileges
2. **Simple on/off**: The buzzer can only be turned ON or OFF - no volume or frequency control
3. **Location**: Bottom right corner of the screen
4. **Use case**: Alerts, notifications, system events
---
## Troubleshooting
### Buzzer Not Working
1. **Check device exists:**
```bash
ls -la /sys/class/leds/usr-buzzer/
```
2. **Test manually:**
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
# Wait a moment - you should hear the buzzer
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
3. **Check permissions:**
```bash
ls -la /sys/class/leds/usr-buzzer/brightness
# Should show: -rw-r--r-- (requires sudo to write)
```
4. **Verify current state:**
```bash
cat /sys/class/leds/usr-buzzer/brightness
# 0 = off, 1 or 255 = on
```
### No Sound from Buzzer
- Check if buzzer is physically present and working
- Verify the device is powered on
- Try a longer duration (e.g., `sleep 1` instead of `sleep 0.2`)
- Check kernel messages: `dmesg | grep -i buzzer`
---
## Examples
### Example 1: Simple Alert Function
```bash
#!/bin/bash
alert() {
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.3
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
}
# Use it
alert
```
### Example 2: Python Notification System
```python
import subprocess
import time
def notify(message_type):
BUZZER = '/sys/class/leds/usr-buzzer/brightness'
patterns = {
'info': (1, 0.1), # 1 beep, 0.1s
'success': (2, 0.1), # 2 beeps, 0.1s each
'error': (3, 0.05), # 3 fast beeps
'warning': (1, 0.5), # 1 long beep
}
count, duration = patterns.get(message_type, (1, 0.2))
for _ in range(count):
subprocess.run(['sudo', 'tee', BUZZER], input='1', text=True)
time.sleep(duration)
subprocess.run(['sudo', 'tee', BUZZER], input='0', text=True)
time.sleep(0.1)
# Usage
notify('success') # 2 short beeps
notify('error') # 3 fast beeps
```
---
## Quick Reference
| Action | Command |
|--------|---------|
| Turn ON | `echo 1 \| sudo tee /sys/class/leds/usr-buzzer/brightness` |
| Turn OFF | `echo 0 \| sudo tee /sys/class/leds/usr-buzzer/brightness` |
| Check Status | `cat /sys/class/leds/usr-buzzer/brightness` |
| Single Beep | `echo 1 \| sudo tee /sys/class/leds/usr-buzzer/brightness && sleep 0.2 && echo 0 \| sudo tee /sys/class/leds/usr-buzzer/brightness` |
---
## Related Documentation
- `BUZZER-TEST-GUIDE.md` - Detailed testing guide
- `FLASK-BUZZER-CONTROL.md` - Flask web API for buzzer control
- `starwars_buzzer.sh` - Star Wars theme example script
---
## Summary
The buzzer is simple to control:
1. **Path**: `/sys/class/leds/usr-buzzer/brightness`
2. **Values**: `0` = OFF, `1` = ON
3. **Requires**: `sudo` privileges
4. **Control**: Write `1` to turn on, `0` to turn off
That's it! Simple and straightforward.

View File

@@ -0,0 +1,152 @@
# Buzzer Control - reTerminal DM4
## Quick Reference
**Device Path:** `/sys/class/leds/usr-buzzer/brightness`
### Basic Commands
```bash
# Turn ON
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
# Turn OFF
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
# Check Status
cat /sys/class/leds/usr-buzzer/brightness # 0=OFF, 1/255=ON
```
### Single Beep
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.2
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
---
## Test Scripts
### Bash Script
**Location on device:** `/home/pi/buzzer/test_buzzer.sh`
**Run:**
```bash
ssh guard "/home/pi/buzzer/test_buzzer.sh"
# or
/home/pi/buzzer/test_buzzer.sh
```
**Tests included:**
- Single beep
- Double beep
- Triple beep
- Long beep
- Rapid beeps
- Slow beeps
- Success pattern
- Error pattern
### Python Script
**Location on device:** `/home/pi/buzzer/test_buzzer.py`
**Run:**
```bash
ssh guard "python3 /home/pi/buzzer/test_buzzer.py"
# or
python3 /home/pi/buzzer/test_buzzer.py
```
**Same tests as bash script, with Python implementation**
---
## Python Control
```python
import subprocess
import time
BUZZER = '/sys/class/leds/usr-buzzer/brightness'
def buzzer_on():
subprocess.run(['sudo', 'tee', BUZZER], input='1', text=True)
def buzzer_off():
subprocess.run(['sudo', 'tee', BUZZER], input='0', text=True)
def beep(duration=0.2):
buzzer_on()
time.sleep(duration)
buzzer_off()
# Usage
beep(0.2) # Short beep
beep(0.5) # Long beep
```
---
## Common Patterns
### Success Alert (2 short beeps)
```bash
for i in 1 2; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
done
```
### Error Alert (3 fast beeps)
```bash
for i in 1 2 3; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.05
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.05
done
```
### Notification (1 short beep)
```bash
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.2
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
---
## Troubleshooting
**Buzzer not working?**
1. Check device: `ls -la /sys/class/leds/usr-buzzer/`
2. Test manually: `echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness`
3. Verify permissions: Requires `sudo`
**No sound?**
- Check if buzzer is physically present
- Try longer duration: `sleep 1` instead of `sleep 0.2`
- Check kernel: `dmesg | grep -i buzzer`
---
## Notes
- **Requires:** `sudo` privileges
- **Type:** Simple on/off (no volume/frequency control)
- **Location:** Bottom right corner of screen
- **Use:** Alerts, notifications, system events
---
## Related Files
- Test scripts: `/home/pi/buzzer/test_buzzer.sh` (bash), `/home/pi/buzzer/test_buzzer.py` (Python)
- Detailed guide: `BUZZER-CONTROL-SIMPLE.md`
- Flask API: `FLASK-BUZZER-CONTROL.md`

View File

@@ -0,0 +1,264 @@
# reTerminal DM4 Buzzer Test Guide
## Overview
The reTerminal DM4 has a built-in buzzer located at the bottom right corner of the screen. The buzzer is controlled via the Linux LED subsystem and can be used for system alerts and notifications.
## Buzzer Device Location
- **Sysfs Path**: `/sys/class/leds/usr-buzzer`
- **Control Method**: LED brightness control (0 = off, 1 = on)
- **Type**: Active buzzer (on/off control)
## Quick Test Methods
### Method 1: Simple On/Off Test
```bash
# Turn buzzer ON
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
# Turn buzzer OFF
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
### Method 2: Single Beep
```bash
# Beep for 0.2 seconds
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.2
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
```
### Method 3: Double Beep
```bash
# Double beep pattern
for i in 1 2; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.1
done
```
### Method 4: Rapid Beeps
```bash
# 5 rapid beeps
for i in {1..5}; do
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.05
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
sleep 0.05
done
```
## Complete Test Script
A comprehensive test script is available on the device at `/tmp/test_buzzer.sh`. To use it:
```bash
# Run the test script
/tmp/test_buzzer.sh
```
Or create your own script:
```bash
#!/bin/bash
# Buzzer test script for reTerminal DM4
BUZZER_PATH='/sys/class/leds/usr-buzzer'
echo 'Testing reTerminal DM4 Buzzer'
echo '=============================='
echo ''
# Test 1: Single beep
echo 'Test 1: Single beep (0.2 seconds)'
echo 1 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.2
echo 0 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.5
# Test 2: Double beep
echo 'Test 2: Double beep'
for i in 1 2; do
echo 1 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.1
echo 0 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.1
done
sleep 0.5
# Test 3: Long beep
echo 'Test 3: Long beep (0.5 seconds)'
echo 1 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.5
echo 0 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.5
# Test 4: Rapid beeps
echo 'Test 4: Rapid beeps (5 beeps)'
for i in {1..5}; do
echo 1 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.05
echo 0 | sudo tee $BUZZER_PATH/brightness > /dev/null
sleep 0.05
done
echo ''
echo 'Buzzer test complete!'
```
## Using Timer Trigger (Advanced)
The buzzer can also use the timer trigger for automatic beeping patterns:
```bash
# Set timer trigger
echo timer | sudo tee /sys/class/leds/usr-buzzer/trigger
# Set delay_on and delay_off (in milliseconds)
echo 100 | sudo tee /sys/class/leds/usr-buzzer/delay_on
echo 100 | sudo tee /sys/class/leds/usr-buzzer/delay_off
# Disable timer trigger (return to manual control)
echo none | sudo tee /sys/class/leds/usr-buzzer/trigger
```
## Checking Buzzer Status
```bash
# Check current brightness (0 = off, 1 = on)
cat /sys/class/leds/usr-buzzer/brightness
# Check max brightness
cat /sys/class/leds/usr-buzzer/max_brightness
# Check current trigger
cat /sys/class/leds/usr-buzzer/trigger
```
## Integration Examples
### Python Script
```python
#!/usr/bin/env python3
import time
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
def buzzer_on():
with open(BUZZER_PATH, 'w') as f:
f.write('1')
def buzzer_off():
with open(BUZZER_PATH, 'w') as f:
f.write('0')
def beep(duration=0.2):
buzzer_on()
time.sleep(duration)
buzzer_off()
# Test
beep(0.2) # Single beep
time.sleep(0.5)
beep(0.1) # Short beep
time.sleep(0.1)
beep(0.1) # Short beep (double beep)
```
### Bash Function
Add to your `~/.bashrc`:
```bash
# Buzzer control function
buzzer() {
local action=${1:-status}
local BUZZER='/sys/class/leds/usr-buzzer/brightness'
case $action in
on)
echo 1 | sudo tee $BUZZER > /dev/null
echo "Buzzer ON"
;;
off)
echo 0 | sudo tee $BUZZER > /dev/null
echo "Buzzer OFF"
;;
beep)
local duration=${2:-0.2}
echo 1 | sudo tee $BUZZER > /dev/null
sleep $duration
echo 0 | sudo tee $BUZZER > /dev/null
;;
status)
local state=$(cat $BUZZER)
echo "Buzzer is: $([ $state -eq 1 ] && echo 'ON' || echo 'OFF')"
;;
*)
echo "Usage: buzzer {on|off|beep [duration]|status}"
;;
esac
}
```
Then use:
```bash
buzzer beep 0.2 # Single beep
buzzer on # Turn on
buzzer off # Turn off
buzzer status # Check status
```
## Troubleshooting
### Buzzer Not Working
1. **Check device exists:**
```bash
ls -la /sys/class/leds/usr-buzzer/
```
2. **Check permissions:**
```bash
# You need sudo to control the buzzer
sudo echo 1 > /sys/class/leds/usr-buzzer/brightness
```
3. **Check kernel messages:**
```bash
dmesg | grep -i buzzer
```
4. **Verify GPIO/device tree:**
```bash
# Check if buzzer is in device tree
ls /proc/device-tree/ | grep -i buzz
```
### No Sound from Buzzer
- The buzzer is a simple active buzzer - it should make a continuous tone when ON
- If no sound, check physical connections (buzzer may be damaged)
- Verify the device is powered on
- Check if buzzer is disabled in device tree or kernel config
## Notes
- **Buzzer is simple**: It can only be ON or OFF - no frequency control
- **Requires sudo**: Writing to sysfs requires root privileges
- **Location**: Bottom right corner of the screen
- **Use case**: System alerts, notifications, error indicators
- **Not for audio**: The buzzer cannot play music or complex sounds - only simple beeps
## Related Documentation
- See `AUDIO-CONFIGURATION-REPORT.md` for complete audio system information
- reTerminal DM4 User Manual for hardware specifications

View File

@@ -0,0 +1,660 @@
# Flask Buzzer Control Documentation
## Overview
This guide explains how to control the reTerminal DM4 buzzer from a Python Flask web application. The buzzer is accessed via the Linux sysfs interface at `/sys/class/leds/usr-buzzer/brightness`.
## Prerequisites
- Python 3.x installed
- Flask installed (`pip install flask`)
- sudo permissions (or user in appropriate groups)
- reTerminal DM4 with buzzer accessible
## Basic Buzzer Control Functions
### Simple Python Buzzer Control
```python
import os
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
def buzzer_on():
"""Turn buzzer ON"""
with open(BUZZER_PATH, 'w') as f:
f.write('1')
def buzzer_off():
"""Turn buzzer OFF"""
with open(BUZZER_PATH, 'w') as f:
f.write('0')
def buzzer_beep(duration=0.2):
"""Play a beep for specified duration (in seconds)"""
import time
buzzer_on()
time.sleep(duration)
buzzer_off()
```
**Note**: These functions require root privileges. See "Permission Setup" section below.
## Flask Application Examples
### Example 1: Basic Flask App with Buzzer Control
```python
#!/usr/bin/env python3
from flask import Flask, jsonify, request
import os
import time
import subprocess
app = Flask(__name__)
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
def buzzer_on():
"""Turn buzzer ON"""
try:
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input='1', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def buzzer_off():
"""Turn buzzer OFF"""
try:
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input='0', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def buzzer_beep(duration=0.2):
"""Play a beep for specified duration"""
buzzer_on()
time.sleep(duration)
buzzer_off()
@app.route('/')
def index():
return jsonify({
'status': 'Buzzer Control API',
'endpoints': {
'/buzzer/on': 'Turn buzzer ON',
'/buzzer/off': 'Turn buzzer OFF',
'/buzzer/beep': 'Play a beep (duration parameter)',
'/buzzer/status': 'Get buzzer status'
}
})
@app.route('/buzzer/on', methods=['POST', 'GET'])
def turn_on():
if buzzer_on():
return jsonify({'status': 'success', 'message': 'Buzzer turned ON'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer ON'}), 500
@app.route('/buzzer/off', methods=['POST', 'GET'])
def turn_off():
if buzzer_off():
return jsonify({'status': 'success', 'message': 'Buzzer turned OFF'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer OFF'}), 500
@app.route('/buzzer/beep', methods=['POST', 'GET'])
def beep():
duration = float(request.args.get('duration', 0.2))
if duration < 0 or duration > 5:
return jsonify({'status': 'error', 'message': 'Duration must be between 0 and 5 seconds'}), 400
buzzer_beep(duration)
return jsonify({'status': 'success', 'message': f'Beeped for {duration} seconds'})
@app.route('/buzzer/status', methods=['GET'])
def status():
try:
with open(BUZZER_PATH, 'r') as f:
state = f.read().strip()
return jsonify({
'status': 'success',
'buzzer': 'ON' if state == '1' else 'OFF',
'state': state
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
```
### Example 2: Advanced Flask App with Pattern Support
```python
#!/usr/bin/env python3
from flask import Flask, jsonify, request
import subprocess
import time
import threading
app = Flask(__name__)
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
class BuzzerController:
def __init__(self):
self.is_playing = False
self.play_thread = None
def _write_buzzer(self, value):
"""Internal method to write to buzzer"""
try:
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input=str(value), text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def on(self):
"""Turn buzzer ON"""
return self._write_buzzer(1)
def off(self):
"""Turn buzzer OFF"""
return self._write_buzzer(0)
def beep(self, duration=0.2):
"""Play a single beep"""
self.on()
time.sleep(duration)
self.off()
def play_pattern(self, pattern):
"""
Play a pattern
pattern: list of tuples [(duration_on, duration_off), ...]
Example: [(0.1, 0.1), (0.1, 0.1), (0.1, 0.3)] = two short beeps, pause, one beep
"""
if self.is_playing:
return False
def _play():
self.is_playing = True
for on_time, off_time in pattern:
self.on()
time.sleep(on_time)
self.off()
time.sleep(off_time)
self.is_playing = False
self.play_thread = threading.Thread(target=_play, daemon=True)
self.play_thread.start()
return True
def starwars_theme(self, duration=5):
"""Play Star Wars theme pattern"""
if self.is_playing:
return False
def _play():
self.is_playing = True
start_time = time.time()
# Opening sequence
self.beep(0.05)
time.sleep(0.05)
self.beep(0.05)
time.sleep(0.05)
self.beep(0.05)
time.sleep(0.1)
self.beep(0.1)
time.sleep(0.1)
self.beep(0.15)
time.sleep(0.15)
# Continue pattern until duration reached
while time.time() - start_time < duration:
self.beep(0.08)
time.sleep(0.05)
self.beep(0.08)
time.sleep(0.05)
self.beep(0.08)
time.sleep(0.1)
self.beep(0.12)
time.sleep(0.1)
self.off()
self.is_playing = False
self.play_thread = threading.Thread(target=_play, daemon=True)
self.play_thread.start()
return True
# Create global buzzer controller
buzzer = BuzzerController()
@app.route('/')
def index():
return jsonify({
'status': 'Advanced Buzzer Control API',
'endpoints': {
'/buzzer/on': 'Turn buzzer ON (POST/GET)',
'/buzzer/off': 'Turn buzzer OFF (POST/GET)',
'/buzzer/beep': 'Play a beep (GET: ?duration=0.2)',
'/buzzer/pattern': 'Play custom pattern (POST: JSON)',
'/buzzer/starwars': 'Play Star Wars theme (GET: ?duration=5)',
'/buzzer/status': 'Get buzzer status (GET)'
}
})
@app.route('/buzzer/on', methods=['POST', 'GET'])
def turn_on():
if buzzer.on():
return jsonify({'status': 'success', 'message': 'Buzzer turned ON'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer ON'}), 500
@app.route('/buzzer/off', methods=['POST', 'GET'])
def turn_off():
if buzzer.off():
return jsonify({'status': 'success', 'message': 'Buzzer turned OFF'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer OFF'}), 500
@app.route('/buzzer/beep', methods=['POST', 'GET'])
def beep():
duration = float(request.args.get('duration', 0.2))
if duration < 0 or duration > 5:
return jsonify({'status': 'error', 'message': 'Duration must be between 0 and 5 seconds'}), 400
buzzer.beep(duration)
return jsonify({'status': 'success', 'message': f'Beeped for {duration} seconds'})
@app.route('/buzzer/pattern', methods=['POST'])
def play_pattern():
data = request.get_json()
if not data or 'pattern' not in data:
return jsonify({'status': 'error', 'message': 'Pattern required in JSON body'}), 400
pattern = data['pattern']
if not isinstance(pattern, list):
return jsonify({'status': 'error', 'message': 'Pattern must be a list'}), 400
# Validate pattern format
try:
validated_pattern = []
for item in pattern:
if isinstance(item, list) and len(item) == 2:
validated_pattern.append((float(item[0]), float(item[1])))
else:
return jsonify({'status': 'error', 'message': 'Each pattern item must be [on_time, off_time]'}), 400
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid pattern format'}), 400
if buzzer.play_pattern(validated_pattern):
return jsonify({'status': 'success', 'message': 'Pattern playing'})
return jsonify({'status': 'error', 'message': 'Buzzer is already playing'}), 409
@app.route('/buzzer/starwars', methods=['GET'])
def starwars():
duration = float(request.args.get('duration', 5))
if duration < 0 or duration > 10:
return jsonify({'status': 'error', 'message': 'Duration must be between 0 and 10 seconds'}), 400
if buzzer.starwars_theme(duration):
return jsonify({'status': 'success', 'message': f'Playing Star Wars theme for {duration} seconds'})
return jsonify({'status': 'error', 'message': 'Buzzer is already playing'}), 409
@app.route('/buzzer/status', methods=['GET'])
def status():
try:
result = subprocess.run(['cat', BUZZER_PATH],
capture_output=True, text=True, check=True)
state = result.stdout.strip()
return jsonify({
'status': 'success',
'buzzer': 'ON' if state == '1' else 'OFF',
'state': state,
'is_playing': buzzer.is_playing
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
```
## Permission Setup
### Option 1: Run Flask with sudo (Not Recommended for Production)
```bash
sudo python3 app.py
```
### Option 2: Configure sudoers for passwordless access (Recommended)
Create a file `/etc/sudoers.d/buzzer-control`:
```
# Allow buzzer control without password
pi ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/class/leds/usr-buzzer/brightness
```
Then your Flask app can use sudo without password prompts.
### Option 3: Use udev rules (Most Secure)
Create `/etc/udev/rules.d/99-buzzer.rules`:
```
SUBSYSTEM=="leds", KERNEL=="usr-buzzer", MODE="0666", GROUP="audio"
```
Add your user to the audio group:
```bash
sudo usermod -a -G audio pi
```
Then reload udev:
```bash
sudo udevadm control --reload-rules
sudo udevadm trigger
```
After this, you can write directly without sudo:
```python
def buzzer_on():
with open(BUZZER_PATH, 'w') as f:
f.write('1')
```
## API Usage Examples
### Using curl
```bash
# Turn buzzer ON
curl http://localhost:5000/buzzer/on
# Turn buzzer OFF
curl http://localhost:5000/buzzer/off
# Play a beep (0.5 seconds)
curl "http://localhost:5000/buzzer/beep?duration=0.5"
# Play Star Wars theme (5 seconds)
curl "http://localhost:5000/buzzer/starwars?duration=5"
# Play custom pattern
curl -X POST http://localhost:5000/buzzer/pattern \
-H "Content-Type: application/json" \
-d '{"pattern": [[0.1, 0.1], [0.1, 0.1], [0.1, 0.3], [0.2, 0.2]]}'
# Check status
curl http://localhost:5000/buzzer/status
```
### Using Python requests
```python
import requests
base_url = "http://localhost:5000"
# Turn on
response = requests.post(f"{base_url}/buzzer/on")
print(response.json())
# Play beep
response = requests.get(f"{base_url}/buzzer/beep", params={"duration": 0.3})
print(response.json())
# Play pattern
pattern = [[0.1, 0.1], [0.1, 0.1], [0.1, 0.3]]
response = requests.post(f"{base_url}/buzzer/pattern", json={"pattern": pattern})
print(response.json())
# Check status
response = requests.get(f"{base_url}/buzzer/status")
print(response.json())
```
### Using JavaScript/Fetch
```javascript
// Turn buzzer ON
fetch('http://localhost:5000/buzzer/on', {method: 'POST'})
.then(res => res.json())
.then(data => console.log(data));
// Play beep
fetch('http://localhost:5000/buzzer/beep?duration=0.5')
.then(res => res.json())
.then(data => console.log(data));
// Play Star Wars theme
fetch('http://localhost:5000/buzzer/starwars?duration=5')
.then(res => res.json())
.then(data => console.log(data));
// Play custom pattern
fetch('http://localhost:5000/buzzer/pattern', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
pattern: [[0.1, 0.1], [0.1, 0.1], [0.1, 0.3], [0.2, 0.2]]
})
})
.then(res => res.json())
.then(data => console.log(data));
```
## Web Interface Example
### HTML/JavaScript Frontend
```html
<!DOCTYPE html>
<html>
<head>
<title>Buzzer Control</title>
<style>
body { font-family: Arial; padding: 20px; }
button { padding: 10px 20px; margin: 5px; font-size: 16px; }
.status { margin-top: 20px; padding: 10px; background: #f0f0f0; }
</style>
</head>
<body>
<h1>reTerminal DM4 Buzzer Control</h1>
<div>
<button onclick="buzzerOn()">Buzzer ON</button>
<button onclick="buzzerOff()">Buzzer OFF</button>
<button onclick="beep(0.2)">Short Beep</button>
<button onclick="beep(0.5)">Long Beep</button>
<button onclick="starwars()">Star Wars Theme</button>
</div>
<div>
<h3>Custom Pattern</h3>
<input type="text" id="pattern" placeholder="0.1,0.1,0.1,0.1,0.1,0.3" style="width: 300px;">
<button onclick="playPattern()">Play Pattern</button>
</div>
<div class="status" id="status">Ready</div>
<script>
const API_URL = 'http://localhost:5000';
function updateStatus(message) {
document.getElementById('status').textContent = message;
}
async function buzzerOn() {
const response = await fetch(`${API_URL}/buzzer/on`, {method: 'POST'});
const data = await response.json();
updateStatus(data.message);
}
async function buzzerOff() {
const response = await fetch(`${API_URL}/buzzer/off`, {method: 'POST'});
const data = await response.json();
updateStatus(data.message);
}
async function beep(duration) {
const response = await fetch(`${API_URL}/buzzer/beep?duration=${duration}`);
const data = await response.json();
updateStatus(data.message);
}
async function starwars() {
const response = await fetch(`${API_URL}/buzzer/starwars?duration=5`);
const data = await response.json();
updateStatus(data.message);
}
async function playPattern() {
const patternStr = document.getElementById('pattern').value;
const parts = patternStr.split(',');
const pattern = [];
for (let i = 0; i < parts.length; i += 2) {
if (i + 1 < parts.length) {
pattern.push([parseFloat(parts[i]), parseFloat(parts[i+1])]);
}
}
const response = await fetch(`${API_URL}/buzzer/pattern`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pattern: pattern})
});
const data = await response.json();
updateStatus(data.message);
}
</script>
</body>
</html>
```
## Security Considerations
1. **Authentication**: Add authentication for production use:
```python
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(username, password):
return username == 'admin' and password == 'secret'
@app.route('/buzzer/on')
@auth.login_required
def turn_on():
# ...
```
2. **Rate Limiting**: Prevent abuse:
```python
from flask_limiter import Limiter
limiter = Limiter(app, key_func=get_remote_address)
@app.route('/buzzer/beep')
@limiter.limit("10 per minute")
def beep():
# ...
```
3. **CORS**: If accessing from web pages:
```python
from flask_cors import CORS
CORS(app)
```
## Installation and Setup
1. **Install Flask:**
```bash
pip3 install flask
# For advanced features:
pip3 install flask-httpauth flask-limiter flask-cors
```
2. **Set up permissions** (choose one method from above)
3. **Run the application:**
```bash
python3 app.py
```
4. **Run as a service** (systemd):
Create `/etc/systemd/system/buzzer-api.service`:
```ini
[Unit]
Description=Buzzer Control API
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/buzzer-api
ExecStart=/usr/bin/python3 /home/pi/buzzer-api/app.py
Restart=always
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl enable buzzer-api
sudo systemctl start buzzer-api
```
## Troubleshooting
### Permission Denied
- Check if user has sudo access
- Verify udev rules are applied
- Check file permissions: `ls -la /sys/class/leds/usr-buzzer/brightness`
### Buzzer Not Responding
- Verify buzzer device exists: `ls /sys/class/leds/usr-buzzer/`
- Test manually: `echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness`
- Check kernel messages: `dmesg | grep buzzer`
### Flask App Not Starting
- Check if port 5000 is in use: `sudo netstat -tulpn | grep 5000`
- Verify Python and Flask are installed: `python3 --version && pip3 list | grep Flask`
## Complete Example Project Structure
```
buzzer-api/
├── app.py # Main Flask application
├── buzzer.py # Buzzer control module
├── requirements.txt # Python dependencies
├── static/ # Static files (CSS, JS)
│ └── index.html # Web interface
└── README.md # Project documentation
```
**requirements.txt:**
```
Flask==2.3.0
flask-httpauth==4.8.0
flask-limiter==3.0.0
flask-cors==4.0.0
```
## Related Documentation
- See `BUZZER-TEST-GUIDE.md` for basic buzzer testing
- See `AUDIO-CONFIGURATION-REPORT.md` for complete audio system info

View File

@@ -0,0 +1,92 @@
# reTerminal DM4 Buzzer Control - Jira Summary
## Summary
Implemented buzzer control functionality for reTerminal DM4 device with test scripts and documentation.
## Device Information
- **Model:** reTerminal DM4 (Raspberry Pi CM4)
- **Buzzer Location:** Bottom right corner of screen
- **Control Path:** `/sys/class/leds/usr-buzzer/brightness`
- **Control Method:** Linux LED subsystem (on/off only)
## Implementation
### Test Scripts Created
1. **Bash Test Script** (`test_buzzer.sh`)
- Location: `/home/pi/buzzer/test_buzzer.sh` on device
- Tests: 8 different buzzer patterns (single, double, triple, long, rapid, slow, success, error)
- Usage: `ssh guard "/home/pi/buzzer/test_buzzer.sh"` or `/home/pi/buzzer/test_buzzer.sh`
2. **Python Test Script** (`test_buzzer.py`)
- Location: `/home/pi/buzzer/test_buzzer.py` on device
- Same test patterns as bash script
- Usage: `ssh guard "python3 /home/pi/buzzer/test_buzzer.py"` or `python3 /home/pi/buzzer/test_buzzer.py`
### Basic Control Commands
```bash
# Turn ON
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness
# Turn OFF
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness
# Check Status
cat /sys/class/leds/usr-buzzer/brightness # 0=OFF, 1/255=ON
```
### Python Control Example
```python
import subprocess
import time
BUZZER = '/sys/class/leds/usr-buzzer/brightness'
def beep(duration=0.2):
subprocess.run(['sudo', 'tee', BUZZER], input='1', text=True)
time.sleep(duration)
subprocess.run(['sudo', 'tee', BUZZER], input='0', text=True)
```
## Common Use Cases
- **Success Alert:** 2 short beeps
- **Error Alert:** 3 fast beeps
- **Notification:** 1 short beep
- **Warning:** 1 long beep
## Requirements
- `sudo` privileges required
- Test scripts available on device at `/tmp/`
- Buzzer is simple on/off (no volume/frequency control)
## Documentation
- **Quick Reference:** `BUZZER-CONTROL.md` - Concise guide with commands and test script references
- **Detailed Guide:** `BUZZER-CONTROL-SIMPLE.md` - Comprehensive documentation
- **Flask API:** `FLASK-BUZZER-CONTROL.md` - Web API implementation
## Testing
Both test scripts have been uploaded to the device and verified working. Scripts test:
- Single beep patterns
- Multiple beep patterns
- Different timing intervals
- Common alert patterns (success, error)
## Status
**Complete** - Test scripts created, uploaded to device, and documentation provided.
## Files
- `test_buzzer.sh` - Bash test script
- `test_buzzer.py` - Python test script
- `BUZZER-CONTROL.md` - Quick reference documentation
- `BUZZER-CONTROL-SIMPLE.md` - Detailed documentation
- `FLASK-BUZZER-CONTROL.md` - Flask API documentation

View File

@@ -0,0 +1,577 @@
# reTerminal DM4 LED Control Guide
## Overview
The reTerminal DM4 has a user-controllable LED (`usr-led`) that can be used for status indicators, notifications, and visual feedback. The LED is controlled via the Linux LED subsystem, similar to the buzzer.
**⚠️ Important Note**: While the LED control interface exists and responds to commands, the physical LED may not be visible or installed on all reTerminal DM4 units. The control interface will accept commands and report correct values (0 for off, 255 for on), but if the LED doesn't appear to turn on physically, it may not be present on your specific model. Always verify with Seeed Studio documentation for your specific unit.
## LED Device Information
- **Device Path**: `/sys/class/leds/usr-led`
- **Control Method**: Brightness control (0 = off, 1 = on)
- **Type**: Simple on/off LED (max brightness = 1)
- **Location**: User-controllable status LED
- **Hardware**: Controlled via PCA9535 GPIO expander (I2C address 0x21)
- **Note**: When setting brightness to 1, the system may report 255 (this is normal - it represents max brightness)
## Available LEDs on reTerminal DM4
The device has multiple LEDs, but the user-controllable one is:
- **`usr-led`**: User-controllable LED (primary LED for applications)
- **`usr-buzzer`**: Buzzer (not an LED, but controlled similarly)
- **`ACT`**: Activity LED (system-controlled, shows SD card activity)
- **`PWR`**: Power LED (system-controlled)
- **`lcd-pwr`**: LCD power LED (system-controlled)
- **`audio-pwr`**: Audio power LED (system-controlled)
## Basic LED Control
### Simple On/Off
```bash
# Turn LED ON
echo 1 | sudo tee /sys/class/leds/usr-led/brightness
# Turn LED OFF
echo 0 | sudo tee /sys/class/leds/usr-led/brightness
```
### Check LED Status
```bash
# Check current brightness (0 = off, 1 = on)
cat /sys/class/leds/usr-led/brightness
# Check max brightness
cat /sys/class/leds/usr-led/max_brightness
```
## Python Control
### Simple Python Functions
```python
import subprocess
LED_PATH = '/sys/class/leds/usr-led/brightness'
def led_on():
"""Turn LED ON"""
subprocess.run(['sudo', 'tee', LED_PATH],
input='1', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def led_off():
"""Turn LED OFF"""
subprocess.run(['sudo', 'tee', LED_PATH],
input='0', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def led_toggle():
"""Toggle LED state"""
with open(LED_PATH, 'r') as f:
current = f.read().strip()
new_state = '0' if current == '1' else '1'
subprocess.run(['sudo', 'tee', LED_PATH],
input=new_state, text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def led_status():
"""Get LED status"""
with open(LED_PATH, 'r') as f:
return 'ON' if f.read().strip() == '1' else 'OFF'
```
### Blinking Pattern
```python
import time
import subprocess
LED_PATH = '/sys/class/leds/usr-led/brightness'
def led_blink(count=5, on_time=0.2, off_time=0.2):
"""Blink LED specified number of times"""
for _ in range(count):
subprocess.run(['sudo', 'tee', LED_PATH],
input='1', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
time.sleep(on_time)
subprocess.run(['sudo', 'tee', LED_PATH],
input='0', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
time.sleep(off_time)
def led_pulse(duration=2, interval=0.1):
"""Pulse LED (rapid on/off) for specified duration"""
end_time = time.time() + duration
while time.time() < end_time:
subprocess.run(['sudo', 'tee', LED_PATH],
input='1', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
time.sleep(interval)
subprocess.run(['sudo', 'tee', LED_PATH],
input='0', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
time.sleep(interval)
```
## Using LED Triggers
The LED supports various triggers for automatic control:
### Available Triggers
```bash
# List available triggers
cat /sys/class/leds/usr-led/trigger
```
Common triggers:
- `none` - Manual control (default)
- `timer` - Blink at specified intervals
- `heartbeat` - Blink in heartbeat pattern
- `default-on` - Always on
- `cpu` - Blink based on CPU activity
- `mmc0` - Blink on SD card activity
### Timer Trigger (Blinking)
```bash
# Set timer trigger
echo timer | sudo tee /sys/class/leds/usr-led/trigger
# Set delay_on (time LED is ON in milliseconds)
echo 500 | sudo tee /sys/class/leds/usr-led/delay_on
# Set delay_off (time LED is OFF in milliseconds)
echo 500 | sudo tee /sys/class/leds/usr-led/delay_off
# Disable timer (return to manual control)
echo none | sudo tee /sys/class/leds/usr-led/trigger
```
### Heartbeat Trigger
```bash
# Set heartbeat pattern
echo heartbeat | sudo tee /sys/class/leds/usr-led/trigger
# Return to manual control
echo none | sudo tee /sys/class/leds/usr-led/trigger
```
### CPU Activity Trigger
```bash
# Blink based on CPU activity
echo cpu | sudo tee /sys/class/leds/usr-led/trigger
# Return to manual control
echo none | sudo tee /sys/class/leds/usr-led/trigger
```
## Bash Script Examples
### Simple LED Control Script
```bash
#!/bin/bash
# LED control script
LED_PATH='/sys/class/leds/usr-led/brightness'
case "$1" in
on)
echo 1 | sudo tee $LED_PATH > /dev/null
echo "LED ON"
;;
off)
echo 0 | sudo tee $LED_PATH > /dev/null
echo "LED OFF"
;;
toggle)
current=$(cat $LED_PATH)
if [ "$current" = "1" ]; then
echo 0 | sudo tee $LED_PATH > /dev/null
echo "LED OFF"
else
echo 1 | sudo tee $LED_PATH > /dev/null
echo "LED ON"
fi
;;
blink)
count=${2:-5}
for i in $(seq 1 $count); do
echo 1 | sudo tee $LED_PATH > /dev/null
sleep 0.2
echo 0 | sudo tee $LED_PATH > /dev/null
sleep 0.2
done
;;
status)
current=$(cat $LED_PATH)
echo "LED is: $([ $current -eq 1 ] && echo 'ON' || echo 'OFF')"
;;
*)
echo "Usage: $0 {on|off|toggle|blink [count]|status}"
exit 1
;;
esac
```
## Flask Integration
### Add LED Control to Flask App
```python
from flask import Flask, jsonify, request
import subprocess
import threading
import time
app = Flask(__name__)
LED_PATH = '/sys/class/leds/usr-led/brightness'
class LEDController:
def __init__(self):
self.is_blinking = False
self.blink_thread = None
def _write_led(self, value):
"""Internal method to write to LED"""
try:
subprocess.run(['sudo', 'tee', LED_PATH],
input=str(value), text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def on(self):
"""Turn LED ON"""
return self._write_led(1)
def off(self):
"""Turn LED OFF"""
return self._write_led(0)
def toggle(self):
"""Toggle LED state"""
try:
result = subprocess.run(['cat', LED_PATH],
capture_output=True, text=True, check=True)
current = result.stdout.strip()
new_state = '0' if current == '1' else '1'
return self._write_led(new_state)
except:
return False
def blink(self, count=5, on_time=0.2, off_time=0.2):
"""Blink LED specified number of times"""
if self.is_blinking:
return False
def _blink():
self.is_blinking = True
for _ in range(count):
self.on()
time.sleep(on_time)
self.off()
time.sleep(off_time)
self.is_blinking = False
self.blink_thread = threading.Thread(target=_blink, daemon=True)
self.blink_thread.start()
return True
def pulse(self, duration=2, interval=0.1):
"""Pulse LED for specified duration"""
if self.is_blinking:
return False
def _pulse():
self.is_blinking = True
end_time = time.time() + duration
while time.time() < end_time:
self.on()
time.sleep(interval)
self.off()
time.sleep(interval)
self.is_blinking = False
self.blink_thread = threading.Thread(target=_pulse, daemon=True)
self.blink_thread.start()
return True
def status(self):
"""Get LED status"""
try:
result = subprocess.run(['cat', LED_PATH],
capture_output=True, text=True, check=True)
state = result.stdout.strip()
return 'ON' if state == '1' else 'OFF'
except:
return 'UNKNOWN'
led = LEDController()
@app.route('/led/on', methods=['POST', 'GET'])
def led_on():
if led.on():
return jsonify({'status': 'success', 'message': 'LED turned ON'})
return jsonify({'status': 'error', 'message': 'Failed to turn LED ON'}), 500
@app.route('/led/off', methods=['POST', 'GET'])
def led_off():
if led.off():
return jsonify({'status': 'success', 'message': 'LED turned OFF'})
return jsonify({'status': 'error', 'message': 'Failed to turn LED OFF'}), 500
@app.route('/led/toggle', methods=['POST', 'GET'])
def led_toggle():
if led.toggle():
return jsonify({'status': 'success', 'message': 'LED toggled'})
return jsonify({'status': 'error', 'message': 'Failed to toggle LED'}), 500
@app.route('/led/blink', methods=['POST', 'GET'])
def led_blink():
count = int(request.args.get('count', 5))
on_time = float(request.args.get('on_time', 0.2))
off_time = float(request.args.get('off_time', 0.2))
if led.blink(count, on_time, off_time):
return jsonify({
'status': 'success',
'message': f'LED blinking {count} times'
})
return jsonify({
'status': 'error',
'message': 'LED is already blinking'
}), 409
@app.route('/led/pulse', methods=['POST', 'GET'])
def led_pulse():
duration = float(request.args.get('duration', 2))
interval = float(request.args.get('interval', 0.1))
if led.pulse(duration, interval):
return jsonify({
'status': 'success',
'message': f'LED pulsing for {duration} seconds'
})
return jsonify({
'status': 'error',
'message': 'LED is already active'
}), 409
@app.route('/led/status', methods=['GET'])
def led_status():
state = led.status()
return jsonify({
'status': 'success',
'led': state,
'is_blinking': led.is_blinking
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
```
## Permission Setup
Same as buzzer control - see `FLASK-BUZZER-CONTROL.md` for detailed permission setup options.
### Quick Setup (sudoers method)
Create `/etc/sudoers.d/led-control`:
```
# Allow LED control without password
pi ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/class/leds/usr-led/brightness
```
### Udev Rules Method
Create `/etc/udev/rules.d/99-led.rules`:
```
SUBSYSTEM=="leds", KERNEL=="usr-led", MODE="0666", GROUP="audio"
```
Then add user to audio group and reload udev:
```bash
sudo usermod -a -G audio pi
sudo udevadm control --reload-rules
sudo udevadm trigger
```
## Use Cases
### Status Indicator
```python
def show_status(status):
"""Show status with LED"""
if status == 'success':
led.on()
time.sleep(0.5)
led.off()
elif status == 'error':
led.blink(3, 0.1, 0.1) # Fast blink 3 times
elif status == 'warning':
led.blink(2, 0.3, 0.3) # Slow blink 2 times
```
### Notification System
```python
def notify(message_type):
"""Notify user with LED patterns"""
patterns = {
'info': (1, 0.2, 0.2), # 1 blink, 0.2s on/off
'success': (2, 0.1, 0.1), # 2 fast blinks
'error': (3, 0.05, 0.05), # 3 very fast blinks
'warning': (2, 0.3, 0.3), # 2 slow blinks
}
count, on_time, off_time = patterns.get(message_type, (1, 0.2, 0.2))
led.blink(count, on_time, off_time)
```
### System Monitoring
```python
import psutil
def monitor_system():
"""Use LED to indicate system load"""
cpu_percent = psutil.cpu_percent(interval=1)
if cpu_percent > 80:
led.pulse(1, 0.05) # Fast pulse for high load
elif cpu_percent > 50:
led.pulse(1, 0.1) # Medium pulse
else:
led.off() # Off for low load
```
## Combined LED and Buzzer Control
You can combine LED and buzzer for multi-modal notifications:
```python
def alert(message_type):
"""Alert with both LED and buzzer"""
if message_type == 'critical':
# Fast LED blink + buzzer beep
threading.Thread(target=led.blink, args=(5, 0.1, 0.1), daemon=True).start()
buzzer.beep(0.3)
elif message_type == 'notification':
# Slow LED blink
threading.Thread(target=led.blink, args=(2, 0.3, 0.3), daemon=True).start()
```
## Troubleshooting
### LED Not Responding / Not Visible
**Important**: The LED control interface accepts commands, but the LED may not be physically visible or may be in a location that's not easily observable. Here are troubleshooting steps:
1. **Verify LED device exists:**
```bash
ls -la /sys/class/leds/usr-led/
cat /sys/class/leds/usr-led/brightness
```
2. **Ensure trigger is set to 'none' for manual control:**
```bash
# Check current trigger
cat /sys/class/leds/usr-led/trigger
# Set to 'none' for manual control
echo none | sudo tee /sys/class/leds/usr-led/trigger
```
3. **Test with different values:**
```bash
# Turn OFF
echo 0 | sudo tee /sys/class/leds/usr-led/brightness
sleep 1
# Turn ON (may show as 255, which is normal)
echo 1 | sudo tee /sys/class/leds/usr-led/brightness
sleep 1
# Check what value was actually set
cat /sys/class/leds/usr-led/brightness
```
4. **Check if LED responds to trigger modes:**
```bash
# Try default-on trigger
echo default-on | sudo tee /sys/class/leds/usr-led/trigger
sleep 2
# Return to manual control
echo none | sudo tee /sys/class/leds/usr-led/trigger
```
5. **Physical verification:**
- The LED may be located in a position that's not easily visible
- Check around the screen bezel, especially near the buzzer location
- The LED might be very dim or require specific viewing angle
- Some reTerminal DM units may not have a physical LED installed
6. **Check GPIO control (alternative method):**
```bash
# The LED is controlled via PCA9535 GPIO expander
# Base GPIO: 578
# You can try controlling via GPIO directly if LED subsystem doesn't work
```
### LED Always On/Off
- Check if a trigger is active: `cat /sys/class/leds/usr-led/trigger`
- Set trigger to 'none' for manual control: `echo none | sudo tee /sys/class/leds/usr-led/trigger`
- Verify brightness value: `cat /sys/class/leds/usr-led/brightness` (should be 0 for off, 255 for on)
### Brightness Shows 255 Instead of 1
**This is normal behavior!** When you set brightness to 1 (on), the system may report it as 255. This is because:
- The LED subsystem internally uses 0-255 range
- Setting 1 maps to maximum brightness (255)
- This is expected behavior and doesn't indicate a problem
### LED Not Physically Visible
According to official documentation, the reTerminal DM may not have a user-controllable LED indicator in all models. If the LED doesn't appear to turn on:
1. **Verify your model**: Check if your specific reTerminal DM4 unit includes a user LED
2. **Check documentation**: Refer to Seeed Studio's official documentation for your specific model
3. **Contact support**: If LED control is required, contact Seeed Studio support for model-specific information
### Alternative: Use System LEDs
If `usr-led` is not available or not visible, you can use system LEDs for status indication:
- **ACT LED**: Shows SD card activity (system-controlled)
- **PWR LED**: Power indicator (system-controlled)
Note: System LEDs are typically read-only and controlled by the system.
## Related Documentation
- See `FLASK-BUZZER-CONTROL.md` for Flask integration examples
- See `BUZZER-TEST-GUIDE.md` for buzzer control
- See `AUDIO-CONFIGURATION-REPORT.md` for complete hardware info

View File

@@ -0,0 +1,141 @@
# reTerminal DM4 LED Summary
## Total LEDs Available: 9
### User-Controllable LEDs: **2**
1. **`usr-led`** - User-controllable LED
- Path: `/sys/class/leds/usr-led/brightness`
- Max brightness: 1 (on/off)
- Status: ✅ Controllable (may not be physically visible on all models)
- Control: `echo 1 | sudo tee /sys/class/leds/usr-led/brightness` (ON)
- Control: `echo 0 | sudo tee /sys/class/leds/usr-led/brightness` (OFF)
2. **`usr-buzzer`** - User-controllable buzzer (controlled like LED)
- Path: `/sys/class/leds/usr-buzzer/brightness`
- Max brightness: 1 (on/off)
- Status: ✅ Controllable and confirmed working
- Control: `echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness` (ON)
- Control: `echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness` (OFF)
- Note: This is actually a buzzer, not an LED, but uses the same control interface
---
### System-Controlled LEDs: **7**
These LEDs are managed by the system and typically not user-controllable:
1. **`ACT`** - Activity LED (SD card activity)
- Path: `/sys/class/leds/ACT/brightness`
- Max brightness: 1
- Trigger: `mmc0` (blinks on SD card activity)
- Status: System-controlled (read-only for users)
2. **`audio-pwr`** - Audio power LED
- Path: `/sys/class/leds/audio-pwr/brightness`
- Max brightness: 1
- Trigger: `none`
- Status: System-controlled (indicates audio power state)
3. **`default-on`** - Virtual LED (always on)
- Path: `/sys/class/leds/default-on/brightness`
- Max brightness: 255
- Trigger: `default-on`
- Status: System-controlled virtual LED
4. **`lcd-pwr`** - LCD power LED
- Path: `/sys/class/leds/lcd-pwr/brightness`
- Max brightness: 1
- Trigger: `none`
- Status: System-controlled (indicates LCD power state)
5. **`mmc0`** - SD card activity LED (primary)
- Path: `/sys/class/leds/mmc0/brightness`
- Max brightness: 255
- Trigger: `mmc0` (blinks on SD card activity)
- Status: System-controlled
6. **`mmc0::`** - SD card activity LED (secondary)
- Path: `/sys/class/leds/mmc0::/brightness`
- Max brightness: 255
- Trigger: `mmc0` (blinks on SD card activity)
- Status: System-controlled
7. **`PWR`** - Power LED
- Path: `/sys/class/leds/PWR/brightness`
- Max brightness: 1
- Trigger: `default-on` (always on when powered)
- Status: System-controlled (indicates power state)
---
## Summary
| Category | Count | LEDs |
|----------|-------|------|
| **User-Controllable** | **2** | `usr-led`, `usr-buzzer` |
| **System-Controlled** | **7** | `ACT`, `audio-pwr`, `default-on`, `lcd-pwr`, `mmc0`, `mmc0::`, `PWR` |
| **Total** | **9** | All LEDs in the system |
---
## Control Methods
### User-Controllable LEDs
**Both `usr-led` and `usr-buzzer` can be controlled using:**
```bash
# Turn ON
echo 1 | sudo tee /sys/class/leds/<led-name>/brightness
# Turn OFF
echo 0 | sudo tee /sys/class/leds/<led-name>/brightness
# Check status
cat /sys/class/leds/<led-name>/brightness
```
### System LEDs
System LEDs are typically read-only and controlled by the kernel/system. Attempting to control them may:
- Be ignored by the system
- Be overridden by system triggers
- Require disabling system triggers first (not recommended)
---
## Notes
1. **`usr-buzzer`** is technically a buzzer, not an LED, but uses the LED control interface
2. **`usr-led`** may not be physically visible on all reTerminal DM4 models
3. All LEDs require `sudo` for control (or proper udev rules/permissions)
4. System LEDs are best left to system control for proper functionality
---
## Quick Reference
```bash
# List all LEDs
ls /sys/class/leds/
# Check LED status
cat /sys/class/leds/<led-name>/brightness
cat /sys/class/leds/<led-name>/trigger
# Control user LEDs
echo 1 | sudo tee /sys/class/leds/usr-led/brightness # LED ON
echo 0 | sudo tee /sys/class/leds/usr-led/brightness # LED OFF
echo 1 | sudo tee /sys/class/leds/usr-buzzer/brightness # Buzzer ON
echo 0 | sudo tee /sys/class/leds/usr-buzzer/brightness # Buzzer OFF
```
---
## Related Documentation
- `LED-CONTROL-GUIDE.md` - Detailed LED control guide
- `LED-TROUBLESHOOTING.md` - Troubleshooting LED issues
- `FLASK-BUZZER-CONTROL.md` - Flask API for buzzer/LED control
- `BUZZER-TEST-GUIDE.md` - Buzzer testing guide

View File

@@ -0,0 +1,111 @@
# LED Troubleshooting Guide
## Issue: LED Control Works But LED Doesn't Turn On
### Symptoms
- Commands execute successfully
- Brightness value changes correctly (0 for off, 255 for on)
- No physical LED visible or LED doesn't appear to turn on
### Possible Causes
1. **LED Not Installed on Your Model**
- Not all reTerminal DM4 units may have a physical `usr-led` installed
- The control interface exists in software but hardware may be missing
- Check your specific model's documentation from Seeed Studio
2. **LED Location Not Visible**
- The LED may be in a location that's difficult to see
- May require specific viewing angle or lighting conditions
- Check around the screen bezel, especially near buzzer location
3. **LED Requires Different Control Method**
- Some LEDs may need to be controlled via GPIO directly
- May require specific initialization sequence
### Verification Steps
1. **Check LED Device Exists:**
```bash
ls -la /sys/class/leds/usr-led/
cat /sys/class/leds/usr-led/brightness
cat /sys/class/leds/usr-led/max_brightness
```
2. **Test Control Interface:**
```bash
# Turn OFF
echo 0 | sudo tee /sys/class/leds/usr-led/brightness
cat /sys/class/leds/usr-led/brightness # Should show 0
# Turn ON
echo 1 | sudo tee /sys/class/leds/usr-led/brightness
cat /sys/class/leds/usr-led/brightness # Should show 255
```
3. **Test with Triggers:**
```bash
# Try default-on trigger
echo default-on | sudo tee /sys/class/leds/usr-led/trigger
sleep 2
cat /sys/class/leds/usr-led/brightness # Should show 255
# Return to manual
echo none | sudo tee /sys/class/leds/usr-led/trigger
```
4. **Check Hardware Info:**
```bash
cat /sys/class/leds/usr-led/uevent
dmesg | grep -i led
```
### Official Documentation Status
According to Seeed Studio's official documentation:
- The reTerminal DM has a **buzzer** that is well-documented and confirmed to work
- The **LED indicator** (`usr-led`) control is mentioned but may not be physically present on all models
- Some documentation sources indicate the LED may be system-managed rather than user-controllable
### Recommendations
1. **Contact Seeed Studio Support**
- Verify if your specific model includes a user-controllable LED
- Request model-specific documentation
- Ask about LED location and visibility
2. **Use Alternative Indicators**
- Use the buzzer for audio feedback
- Use screen display for visual feedback
- Use system LEDs (ACT, PWR) if available (though these are typically read-only)
3. **Check Model Variants**
- Different reTerminal DM variants may have different hardware configurations
- Verify your exact model number and compare with official specifications
### Alternative: GPIO Direct Control
If the LED subsystem doesn't work, you can try controlling via GPIO directly:
```bash
# The LED is on PCA9535 GPIO expander (base GPIO 578)
# You would need to export the specific GPIO pin
# This requires knowing the exact pin number on the PCA9535
# Example (may need adjustment):
GPIO_PIN=578 # Base + pin offset
echo $GPIO_PIN | sudo tee /sys/class/gpio/export
echo out | sudo tee /sys/class/gpio/gpio$GPIO_PIN/direction
echo 1 | sudo tee /sys/class/gpio/gpio$GPIO_PIN/value # ON
echo 0 | sudo tee /sys/class/gpio/gpio$GPIO_PIN/value # OFF
```
**Note**: GPIO direct control requires knowing the exact pin mapping, which may not be documented.
### Conclusion
The LED control interface is functional and accepts commands correctly. If the LED doesn't physically turn on:
- The control software is working
- The hardware may not be present on your model
- Contact Seeed Studio for model-specific information
- Consider using buzzer or screen display as alternatives

View File

@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>reTerminal DM4 Buzzer Control</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
padding: 30px;
}
h1 {
color: #333;
margin-bottom: 10px;
text-align: center;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 30px;
}
button {
flex: 1;
min-width: 120px;
padding: 15px 25px;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
color: white;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
button:active {
transform: translateY(0);
}
.btn-on {
background: #4CAF50;
}
.btn-on:hover {
background: #45a049;
}
.btn-off {
background: #f44336;
}
.btn-off:hover {
background: #da190b;
}
.btn-beep {
background: #2196F3;
}
.btn-beep:hover {
background: #0b7dda;
}
.btn-starwars {
background: #FF9800;
}
.btn-starwars:hover {
background: #e68900;
}
.pattern-section {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.pattern-section h3 {
margin-bottom: 15px;
color: #333;
}
.pattern-input {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
input[type="text"] {
flex: 1;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.status {
margin-top: 20px;
padding: 15px;
background: #e3f2fd;
border-left: 4px solid #2196F3;
border-radius: 5px;
min-height: 50px;
}
.status.success {
background: #e8f5e9;
border-left-color: #4CAF50;
}
.status.error {
background: #ffebee;
border-left-color: #f44336;
}
.status-text {
font-weight: bold;
color: #333;
}
.status-message {
color: #666;
margin-top: 5px;
}
.info {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
.info p {
color: #856404;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🎵 reTerminal DM4 Buzzer Control</h1>
<p class="subtitle">Control the built-in buzzer via web interface</p>
<div class="button-group">
<button class="btn-on" onclick="buzzerOn()">🔊 Buzzer ON</button>
<button class="btn-off" onclick="buzzerOff()">🔇 Buzzer OFF</button>
<button class="btn-beep" onclick="beep(0.2)">🔔 Short Beep</button>
<button class="btn-beep" onclick="beep(0.5)">🔔 Long Beep</button>
<button class="btn-starwars" onclick="starwars()">⭐ Star Wars Theme</button>
</div>
<div class="pattern-section">
<h3>🎼 Custom Pattern</h3>
<p style="color: #666; margin-bottom: 10px; font-size: 14px;">
Enter pattern as comma-separated values: on_time,off_time,on_time,off_time,...
<br>Example: 0.1,0.1,0.1,0.1,0.1,0.3 (two short beeps, pause, one beep)
</p>
<div class="pattern-input">
<input type="text" id="pattern" placeholder="0.1,0.1,0.1,0.1,0.1,0.3" value="0.1,0.1,0.1,0.1,0.1,0.3">
<button class="btn-beep" onclick="playPattern()" style="min-width: 150px;">Play Pattern</button>
</div>
</div>
<div class="status" id="status">
<div class="status-text">Ready</div>
<div class="status-message">Click a button to control the buzzer</div>
</div>
<div class="info">
<p><strong>API Endpoint:</strong> <span id="apiUrl">http://localhost:5000</span></p>
<p><strong>Status:</strong> <span id="connectionStatus">Not connected</span></p>
</div>
</div>
<script>
// Configuration
const API_URL = window.location.hostname === ''
? 'http://localhost:5000'
: `http://${window.location.hostname}:5000`;
document.getElementById('apiUrl').textContent = API_URL;
function updateStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.className = `status ${type}`;
const statusText = statusDiv.querySelector('.status-text');
const statusMessage = statusDiv.querySelector('.status-message');
if (type === 'success') {
statusText.textContent = '✓ Success';
} else if (type === 'error') {
statusText.textContent = '✗ Error';
} else {
statusText.textContent = 'Ready';
}
statusMessage.textContent = message;
}
function updateConnectionStatus(connected) {
const statusEl = document.getElementById('connectionStatus');
statusEl.textContent = connected ? 'Connected' : 'Not connected';
statusEl.style.color = connected ? '#4CAF50' : '#f44336';
}
async function makeRequest(endpoint, method = 'GET', body = null) {
try {
const options = {
method: method,
headers: {}
};
if (body) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_URL}${endpoint}`, options);
const data = await response.json();
updateConnectionStatus(true);
return data;
} catch (error) {
updateConnectionStatus(false);
throw error;
}
}
async function buzzerOn() {
try {
const data = await makeRequest('/buzzer/on', 'POST');
updateStatus(data.message, 'success');
} catch (error) {
updateStatus(`Error: ${error.message}`, 'error');
}
}
async function buzzerOff() {
try {
const data = await makeRequest('/buzzer/off', 'POST');
updateStatus(data.message, 'success');
} catch (error) {
updateStatus(`Error: ${error.message}`, 'error');
}
}
async function beep(duration) {
try {
const data = await makeRequest(`/buzzer/beep?duration=${duration}`);
updateStatus(data.message, 'success');
} catch (error) {
updateStatus(`Error: ${error.message}`, 'error');
}
}
async function starwars() {
try {
const data = await makeRequest('/buzzer/starwars?duration=5');
updateStatus(data.message, 'success');
} catch (error) {
updateStatus(`Error: ${error.message}`, 'error');
}
}
async function playPattern() {
const patternStr = document.getElementById('pattern').value.trim();
if (!patternStr) {
updateStatus('Please enter a pattern', 'error');
return;
}
const parts = patternStr.split(',');
if (parts.length % 2 !== 0) {
updateStatus('Pattern must have even number of values (on,off pairs)', 'error');
return;
}
const pattern = [];
for (let i = 0; i < parts.length; i += 2) {
const onTime = parseFloat(parts[i]);
const offTime = parseFloat(parts[i + 1]);
if (isNaN(onTime) || isNaN(offTime)) {
updateStatus('Invalid pattern format. Use numbers only.', 'error');
return;
}
pattern.push([onTime, offTime]);
}
try {
const data = await makeRequest('/buzzer/pattern', 'POST', { pattern });
updateStatus(data.message, 'success');
} catch (error) {
updateStatus(`Error: ${error.message}`, 'error');
}
}
// Check API connection on load
window.addEventListener('load', async () => {
try {
const data = await makeRequest('/');
updateConnectionStatus(true);
updateStatus('API connected and ready', 'success');
} catch (error) {
updateConnectionStatus(false);
updateStatus('Cannot connect to API. Make sure Flask app is running.', 'error');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
# When reTerminal (CM4) is connected in USB boot mode (eMMC disable jumper),
# Raspberry Pi Foundation USB device appears (vendor 2b8e). Trigger auto-flash.
# Install: sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
# sudo udevadm control --reload-rules
# The trigger script starts the actual flash via systemd so udev does not block.
SUBSYSTEM=="usb", ATTR{idVendor}=="2b8e", ACTION=="add", \
RUN+="/usr/local/bin/cm4-flash-trigger.sh"

View File

@@ -0,0 +1,165 @@
# Automatic eMMC provisioning for reTerminal DM4 (CM4)
This guide covers:
1. **Auto-flash**: When the reTerminal is switched to boot mode (eMMC disable jumper) and connected via USB to a provisioning host, the host automatically deploys a golden image to the CM4 eMMC.
2. **Backup**: When a device is detected (USB or network), the dashboard asks you to choose **Backup** or **Deploy**. Backup saves the device eMMC to a timestamped file in `backups/`.
3. **Network**: If the device boots over the network and runs the **provisioning client** (see `network-client/`), it registers with the dashboard and appears as "Device (Network)"; you then choose Backup or Deploy. Deploy streams the golden image to the device; Backup uploads the device eMMC to the server.
4. **Cloud-init**: The golden image includes cloud-init so each device configures itself on first boot (hostname, network, packages, kiosk setup).
---
## Part 1: Auto-flash when reTerminal is in boot mode
### How it works
- reTerminal has an **eMMC disable** jumper (see reTerminal docs; often “J2” or “nRPIBOOT”). When the jumper is fitted, the CM4 boots in **USB device mode** and waits for `rpiboot` from the host.
- You connect the reTerminals **USB slave** port to a **provisioning PC** (Linux).
- **udev** detects the Raspberry Pi Foundation USB device (vendor `2b8e`) and runs a trigger script.
- The trigger starts a **flash job** that:
1. Runs **rpiboot** (from the `usbboot` project). The CM4 then exposes its eMMC as a USB mass-storage device.
2. After `rpiboot` exits, finds the new block device (eMMC) and writes your **golden image** to it with `dd`.
- You remove the jumper and power cycle; the reTerminal boots from eMMC and runs **cloud-init** on first boot.
### Provisioning host setup (Linux)
#### 1. Build and install usbboot (rpiboot)
```bash
sudo apt-get install -y libusb-1.0-0-dev
git clone --depth=1 https://github.com/raspberrypi/usbboot
cd usbboot
make
sudo mkdir -p /opt/usbboot
sudo cp rpiboot /opt/usbboot/
```
#### 2. Create golden image and config directory
- Build your golden image (see Part 2) and place it where the script will find it, e.g.:
```bash
sudo mkdir -p /var/lib/cm4-provisioning
sudo cp /path/to/your/golden-reterminal.img /var/lib/cm4-provisioning/golden.img
```
- Or use a different path and set `GOLDEN_IMAGE` when installing the script (see below).
#### 3. Install the auto-flash script and trigger
```bash
# From this repo (chromium-setup/emmc-provisioning/)
SCRIPT_DIR="$(pwd)"
sudo mkdir -p /opt/cm4-provisioning
sudo cp flash-emmc-on-connect.sh /opt/cm4-provisioning/
sudo chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
# Optional: override paths via environment (create env file)
echo 'GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img' | sudo tee /opt/cm4-provisioning/env
echo 'RPIBOOT_DIR=/opt/usbboot' | sudo tee -a /opt/cm4-provisioning/env
echo 'EMMC_SIZE_BYTES=8589934592' | sudo tee -a /opt/cm4-provisioning/env # 8GB; use 17179869184 for 16GB
sudo cp cm4-flash-trigger.sh /usr/local/bin/
sudo chmod +x /usr/local/bin/cm4-flash-trigger.sh
```
If your golden image path or rpiboot path is different, set `GOLDEN_IMAGE`, `RPIBOOT_DIR`, and optionally `EMMC_SIZE_BYTES` in `/opt/cm4-provisioning/env` and source it from the script, or pass them into the systemd-run call in the trigger (e.g. by making the trigger source the env file and export variables before `systemd-run`).
#### 4. Install udev rule
```bash
sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
```
#### 5. Enable provisioning (safety)
Provisioning runs only if the “enabled” file exists:
```bash
sudo mkdir -p /etc/cm4-provisioning
sudo touch /etc/cm4-provisioning/enabled
```
To disable auto-flash, remove that file: `sudo rm /etc/cm4-provisioning/enabled`.
#### 6. Optional: pass environment into the flash job
If you use `/opt/cm4-provisioning/env`, update the trigger so the flash script sees those variables. For example change `/usr/local/bin/cm4-flash-trigger.sh` to:
```bash
#!/usr/bin/env bash
set -a
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
set +a
export GOLDEN_IMAGE RPIBOOT_DIR EMMC_SIZE_BYTES
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
exec systemd-run --no-block --unit=cm4-flash-once --property=Environment="GOLDEN_IMAGE=$GOLDEN_IMAGE" ...
```
Or keep it simple and edit the defaults inside `flash-emmc-on-connect.sh` (e.g. `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`).
### Usage
1. Fit the **eMMC disable** jumper on the reTerminal.
2. Connect the reTerminal **USB slave** port to the provisioning PC.
3. Power the reTerminal (or apply power after USB).
4. On the host, `rpiboot` will run automatically; when it exits, the script will `dd` the golden image to the eMMC. Watch logs: `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f`.
5. When done, remove the jumper and power cycle the reTerminal. It will boot from eMMC; cloud-init will run on first boot.
---
## Part 2: Golden image with cloud-init
Raspberry Pi OS (recent versions) supports **cloud-init** using the **NoCloud** datasource: it reads `user-data`, `meta-data`, and optionally `network-config` from the **boot** (FAT32) partition.
### Creating the golden image
1. **Flash Raspberry Pi OS** (or your base image) to a spare SD card or a loop file.
2. **Mount the boot partition** (first partition, FAT32). On the image file it might be at an offset; use `losetup -P` or mount the SDs partition.
3. **Add cloud-init NoCloud files** on the boot partition (same level as `config.txt`, not in a subfolder for default NoCloud):
- `user-data` main config (packages, runcmd, etc.)
- `meta-data` optional (instance-id, local-hostname)
- `network-config` optional (network config in netplan format)
You can use the examples in this repo:
```bash
# After mounting boot partition at e.g. /mnt/boot
# (On Raspberry Pi OS, boot is often /boot/firmware on the running system, or the first FAT partition of the image)
cp emmc-provisioning/cloud-init/user-data /mnt/boot/
cp emmc-provisioning/cloud-init/meta-data /mnt/boot/
cp emmc-provisioning/cloud-init/network-config /mnt/boot/
```
4. **Customise** `user-data` and `network-config` (hostname, WiFi, packages, Chromium kiosk, etc.).
5. **Copy your kiosk/Chromium scripts** into the image rootfs if needed (e.g. under `/home/pi/` or `/opt/`) and reference them from `user-data` `runcmd` or a systemd unit.
6. **Unmount**, then create a **golden image** from the SD or loop device (e.g. `dd` or `dd` of the whole block device). Use that as `golden.img` on the provisioning host.
### Cloud-init file locations on the Pi
- **NoCloud**: Boot partition root `user-data`, `meta-data`, `network-config`.
- Some images expect them in a subfolder `cloud-init/` or on a separate vfat partition labeled `cidata`; check your OS docs. Standard Raspberry Pi OS NoCloud uses the boot partition root.
### Per-device config (optional)
NoCloud can also use a **seed** partition or **config drive**. For per-device hostname/settings you can:
- Use **meta-data** `instance-id` and `local-hostname` and generate different `meta-data` per device when imaging (e.g. script that writes `meta-data` before flashing), or
- Use a first-boot script that calls a provisioning server (e.g. by serial number) and applies device-specific config; cloud-init can launch that script from `runcmd`.
---
## Summary
| Step | Action |
|------|--------|
| 1 | Build `usbboot`, install `rpiboot` on provisioning host. |
| 2 | Create golden image with cloud-init `user-data`, `meta-data`, `network-config` on boot partition. |
| 3 | Install `flash-emmc-on-connect.sh`, `cm4-flash-trigger.sh`, and udev rule; set `GOLDEN_IMAGE` and enable file. |
| 4 | Put reTerminal in boot mode (jumper), connect USB to host; image is written automatically. |
| 5 | Remove jumper, power cycle; device boots from eMMC and cloud-init runs on first boot. |
This gives you automatic deployment of the golden image to eMMC when the reTerminal is in boot mode, plus first-boot configuration via cloud-init.

View File

@@ -0,0 +1,292 @@
# Portal Styling Guide (Template)
Use this document when building new portals so they match the visual and UX style of the reference portal (e.g. FreePBX / TM VOIP Extensions Portal). Replace placeholders like `[Portal Name]` with your portals name where relevant.
---
## 1. Design philosophy
- **Dark theme:** Dark backgrounds with light text; no light-mode variant in this guide.
- **Accent:** Single accent (teal/cyan gradient) for primary actions, links, and highlights.
- **Clarity:** Clear hierarchy (cards, sections, labels), consistent spacing, readable typography.
- **Consistency:** Same tokens, components, and patterns across all pages (login, app, modals).
---
## 2. Design tokens (CSS variables)
Define these in `:root` (or in a shared CSS file) and use them everywhere instead of hard-coded colors.
### Colors
| Token | Value | Usage |
|-------|--------|--------|
| `--bg-primary` | `#0a0e14` | Page background |
| `--bg-secondary` | `#11151c` | Header, secondary surfaces |
| `--bg-tertiary` | `#1a1f2b` | Inputs, table header, hover states |
| `--bg-card` | `#151a24` | Cards, modals |
| `--accent-primary` | `#00d4aa` | Primary accent (teal) |
| `--accent-secondary` | `#00b894` | Accent variant, secondary accent |
| `--accent-glow` | `rgba(0, 212, 170, 0.15)` | Focus rings, subtle highlights |
| `--text-primary` | `#e6e8eb` | Main text |
| `--text-secondary` | `#8b949e` | Labels, secondary text |
| `--text-muted` | `#5c6370` | Placeholders, disabled, hints |
| `--border-color` | `#2d333b` | Borders (cards, inputs, tables) |
| `--danger` | `#ff6b6b` | Errors, delete, destructive actions |
| `--danger-glow` | `rgba(255, 107, 107, 0.15)` | Danger focus/hover background |
| `--warning` | `#ffd93d` | Warnings |
| `--success` | `#00d4aa` | Success (can match accent) |
| `--gradient-accent` | `linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%)` | Primary buttons, logo text |
### Example `:root` block
```css
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-tertiary: #1a1f2b;
--bg-card: #151a24;
--accent-primary: #00d4aa;
--accent-secondary: #00b894;
--accent-glow: rgba(0, 212, 170, 0.15);
--text-primary: #e6e8eb;
--text-secondary: #8b949e;
--text-muted: #5c6370;
--border-color: #2d333b;
--danger: #ff6b6b;
--danger-glow: rgba(255, 107, 107, 0.15);
--warning: #ffd93d;
--success: #00d4aa;
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
}
```
---
## 3. Typography
- **Body / UI font:** `'Outfit', -apple-system, BlinkMacSystemFont, sans-serif`
- **Monospace (data, code, IDs):** `'JetBrains Mono', monospace`
Load from Google Fonts:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
```
- **Body:** `color: var(--text-primary);` `line-height: 1.6;`
- **Labels:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional `text-transform: uppercase;` `letter-spacing: 0.5px;`
- **Card/section titles:** `font-size: 1.1rem;` `font-weight: 600;`
- **Table header:** `font-size: 0.75rem0.8rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;` `color: var(--text-secondary);`
- **Table body:** `font-size: 0.9rem;` monospace for IDs/codes
---
## 4. Page layout
### Global
- **Reset:** `* { margin: 0; padding: 0; box-sizing: border-box; }`
- **Body:** `background: var(--bg-primary);` `color: var(--text-primary);` `min-height: 100vh;` `font-family: 'Outfit', ...`
### Background treatment (optional)
Subtle gradient overlay for depth:
```css
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
pointer-events: none;
z-index: -1;
}
```
### Header (fixed)
- **Container:** `background: var(--bg-secondary);` `border-bottom: 1px solid var(--border-color);` `position: fixed; top: 0; left: 0; right: 0; z-index: 1000;` optional `backdrop-filter: blur(10px);`
- **Top row:** Logo left; status/user/actions right; `padding: 1rem 2rem;` `display: flex; align-items: center; justify-content: space-between;`
- **Tabs row:** Under the top row; `background: var(--bg-tertiary);` `padding: 0.5rem 2rem;` `border-top: 1px solid var(--border-color);` horizontal flex, gap, overflow-x auto for small screens
### Main content
- **Container:** `max-width: 1400px;` `margin: 0 auto;` `padding: 2rem;` `padding-top: calc(2rem + 140px);` (offset for fixed header + tabs). On mobile reduce padding and increase top offset if header stacks.
---
## 5. Logo
- **Wrapper:** flex, `align-items: center;` `gap: 0.75rem;`
- **Icon:** Square (e.g. 40×40px), `background: var(--gradient-accent);` `border-radius: 10px;` optional `box-shadow: 0 4px 20px var(--accent-glow);` emoji or icon inside.
- **Title (h1):** `font-size: 1.5rem;` `font-weight: 600;` `background: var(--gradient-accent);` `-webkit-background-clip: text;` `background-clip: text;` `-webkit-text-fill-color: transparent;`
---
## 6. Tabs (main navigation)
- **Tab button (default):** `padding: 0.75rem 1.5rem;` `background: transparent;` `border: none;` `color: var(--text-secondary);` `font-size: 0.95rem;` `font-weight: 500;` `border-radius: 8px;` flex with icon + label, `gap: 0.5rem;`
- **Hover:** `color: var(--text-primary);` `background: var(--bg-tertiary);`
- **Active:** `background: var(--gradient-accent);` `color: var(--bg-primary);`
- **Tab content:** `display: none;` by default; `.tab-content.active { display: block; }` optional fade-in animation.
---
## 7. Cards
- **Base:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 1.5rem;` `margin-bottom: 1.5rem;`
- **Card header:** flex, `justify-content: space-between;` `align-items: center;` `margin-bottom: 1.5rem;` `padding-bottom: 1rem;` `border-bottom: 1px solid var(--border-color);`
- **Card title:** `font-size: 1.1rem;` `font-weight: 600;` flex with icon + text, `gap: 0.5rem;`
---
## 8. Buttons
- **Base:** `padding: 0.75rem 1.5rem;` `border-radius: 8px;` `font-size: 0.9rem;` `font-weight: 500;` inline-flex, `align-items: center;` `gap: 0.5rem;` `transition: all 0.2s ease;`
- **Primary:** `background: var(--gradient-accent);` `color: var(--bg-primary);` `border: none;` hover: slight `translateY(-2px);` `box-shadow: 0 4px 20px var(--accent-glow);`
- **Secondary:** `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `color: var(--text-primary);` hover: `border-color: var(--accent-primary);`
- **Danger:** `background: transparent;` `border: 1px solid var(--danger);` `color: var(--danger);` hover: `background: var(--danger);` `color: white;`
- **Disabled:** `opacity: 0.5;` `cursor: not-allowed;`
- **Icon-only (small):** e.g. 28×28px, `border-radius: 6px;` same semantic colors (e.g. `.btn-remove` with `--danger`).
---
## 9. Forms
- **Grid:** `display: grid;` `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));` `gap: 1rem;`
- **Form group:** flex column, `gap: 0.5rem;`
- **Label:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional uppercase + letter-spacing
- **Input / select:** `padding: 0.75rem 1rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 8px;` `color: var(--text-primary);` `font-size: 0.9rem;` monospace for IDs/codes
- **Focus:** `outline: none;` `border-color: var(--accent-primary);` `box-shadow: 0 0 0 3px var(--accent-glow);`
- **Placeholder:** `color: var(--text-muted);`
- **Read-only:** `background: var(--bg-secondary);` `cursor: default;`
- **Checkbox:** `accent-color: var(--accent-primary);` (or custom size e.g. 18×18px)
- **Password field:** wrapper with toggle button; input `padding-right: 3rem;` so toggle doesnt overlap text.
---
## 10. Tables
- **Container:** `overflow-x: auto;` `border-radius: 12px;` `border: 1px solid var(--border-color);`
- **Table:** `width: 100%;` `border-collapse: collapse;`
- **th / td:** `padding: 0.6rem 0.75rem;` `text-align: left;` `border-bottom: 1px solid var(--border-color);` `color: var(--text-primary);`
- **th:** `background: var(--bg-tertiary);` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;`
- **tbody tr hover:** `background-color: var(--bg-tertiary);`
- **Last row:** `tr:last-child td { border-bottom: none; }`
- **Data cells:** `font-family: 'JetBrains Mono', monospace;` `font-size: 0.9rem;`
- **Actions column:** right-aligned; min-width for action buttons; `.table-actions { display: flex; gap: 1rem; flex-wrap: wrap; }`
- **Data table variant:** `.data-table` with alternating row background (e.g. `nth-child(even)` subtle `rgba(255,255,255,0.02)`) and same hover.
---
## 11. Badges and status
- **Status badge (e.g. connection):** flex, `align-items: center;` `gap: 0.5rem;` `padding: 0.5rem 1rem;` `background: var(--bg-tertiary);` `border-radius: 20px;` `font-size: 0.85rem;` `border: 1px solid var(--border-color);`
- **Status dot:** 8×8px circle; `.connected { background: var(--success); }` `.error { background: var(--danger); }` optional pulse animation
- **Pill badge (e.g. extension ID):** `padding: 0.25rem 0.75rem;` `background: var(--accent-glow);` `color: var(--accent-primary);` `border-radius: 20px;` `font-weight: 500;`
- **Tech badge (e.g. PJSIP/SIP):** small, `border-radius: 4px;` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` distinct colors per type (e.g. PJSIP blue, SIP purple)
---
## 12. Search and filters
- **Search box:** wrapper relative; input `padding: 0.6rem 1rem 0.6rem 2.5rem;` `width: 250px;` same colors as form inputs; optional `::before` search icon (e.g. 🔍) `left: 0.75rem;`
- **Filter checkbox:** inline-flex, `align-items: center;` `gap: 0.4rem;` `color: var(--text-secondary);` `accent-color: var(--accent-primary);`
---
## 13. Empty and loading states
- **Empty state:** `text-align: center;` `padding: 3rem;` `color: var(--text-muted);`
- **No results:** same idea, `padding: 2rem;`
- **Loading:** flex center, `padding: 2rem;` spinner (e.g. 32×32px border, `border-top-color: var(--accent-primary);` `animation: spin 1s linear infinite;`)
---
## 14. Modals
- **Overlay:** `position: fixed;` `inset: 0;` `background: rgba(0,0,0,0.7);` `z-index: 1000;` flex center; `display: none;` `.active { display: flex; }`
- **Dialog:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 2rem;` `max-width: 500px;` `width: 90%;` optional scale-in animation
- **Modal header:** flex, `align-items: center;` `gap: 1rem;` `margin-bottom: 1.5rem;`
- **Modal icon:** e.g. 48×48px, `background: var(--accent-glow);` `border-radius: 12px;` centered content
- **Modal title:** `font-size: 1.25rem;` `font-weight: 600;`
- **Detail sections:** `margin-bottom: 2rem;` section title `font-size: 1.1rem;` `color: var(--accent-primary);` `border-bottom: 1px solid var(--border-color);` `padding-bottom: 0.5rem;`
- **Details grid:** `grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));` `gap: 1rem;` each item: label (small, secondary) + value (primary)
---
## 15. Toasts (notifications)
- **Container:** `position: fixed;` `top: 1rem;` `right: 1rem;` `z-index: 1100;` flex column, `gap: 0.5rem;`
- **Toast:** `padding: 1rem 1.5rem;` `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 8px;` flex, `align-items: center;` `gap: 0.75rem;` `max-width: 400px;` slide-in animation
- **Success:** `border-left: 3px solid var(--success);`
- **Error:** `border-left: 3px solid var(--danger);`
---
## 16. Pagination
- **Wrapper:** flex, `justify-content: center;` `align-items: center;` `gap: 0.5rem;` `padding: 1rem;` `border-top: 1px solid var(--border-color);`
- **Button:** `padding: 0.5rem 0.75rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 6px;` `color: var(--text-primary);` `font-size: 0.85rem;` `min-width: 36px;`
- **Hover:** `background: var(--accent-glow);` `border-color: var(--accent-primary);` `color: var(--accent-primary);`
- **Active page:** `background: var(--accent-primary);` `color: var(--bg-primary);`
- **Disabled:** `opacity: 0.4;` `cursor: not-allowed;`
- **Info text:** `color: var(--text-secondary);` `font-size: 0.85rem;` between prev/next
---
## 17. Login page
- **Layout:** full viewport, flex center; `padding: 2rem;`
- **Card:** same tokens as app cards; `max-width: 400px;` `padding: 2.5rem;` `border-radius: 16px;` `box-shadow: 0 8px 32px rgba(0,0,0,0.3);`
- **Logo:** centered; icon (e.g. 64×64px) with gradient + glow; title with gradient text
- **Form:** same form-group and input styles as app; full-width primary submit button
- **Error message:** `color: var(--danger);` `font-size: 0.9rem;` above or below form
- Use the same `:root` variables and body background so login and app feel like one product.
---
## 18. Responsive
- **Breakpoint:** e.g. `@media (max-width: 768px)`
- **Header top:** `flex-direction: column;` `gap: 1rem;` `text-align: center;` reduce padding
- **Tabs:** reduce horizontal padding; allow horizontal scroll if needed
- **Container:** reduce padding; increase `padding-top` if header height grows (e.g. `calc(1rem + 180px)`)
- **Form grid:** `grid-template-columns: 1fr;` for single column on small screens
---
## 19. Checklist for a new portal
- [ ] Copy or recreate the `:root` design tokens.
- [ ] Load **Outfit** and **JetBrains Mono** (or same weights).
- [ ] Use the same header structure: logo + status/user + actions, then tabs.
- [ ] Use `.card`, `.card-header`, `.card-title` for sections.
- [ ] Use `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger` for actions.
- [ ] Use `.form-grid`, `.form-group`, and input/select styles for forms.
- [ ] Use `.table-container`, table, `.data-table` and th/td styles for lists.
- [ ] Use same modal overlay/dialog and toast styles.
- [ ] Use same empty, loading, and error states.
- [ ] Apply same login page layout and token usage.
- [ ] Test at 768px width for basic responsive behavior.
- [ ] Replace [Portal Name] and any product-specific labels in this guide for your portal.
---
## 20. Reference files (this repo)
| File | Purpose |
|------|--------|
| `static/css/main.css` | Full implementation of tokens, layout, components |
| `app/templates/base.html` | App shell: fonts, header, tabs, container, toasts |
| `app/templates/login.html` | Login layout and inline tokens (can be moved to main.css) |
| `app/templates/tabs/_dashboard.html` | Example: cards, stats, table container |
| `app/templates/tabs/_users.html` | Example: card, form-grid, form-group, buttons, table |
For a new portal, you can copy `main.css` and adapt it (e.g. change `:root` if you need a different accent), then build your base template and pages to use the same class names and structure described above.

View File

@@ -0,0 +1,146 @@
# CM4 eMMC provisioning on Proxmox (LXC + host)
The auto-flash **runs on the Proxmox host** (where the USB device appears). The **LXC** holds the same scripts and shares the **golden image** directory with the host so you can manage the image from the container.
## What is deployed
| Where | What |
|-------|-----|
| **Proxmox host** | udev rule, trigger script, flash script, rpiboot (after you run the install script), `/var/lib/cm4-provisioning/` (golden image dir), `/etc/cm4-provisioning/enabled` |
| **LXC 201 (cm4-provisioning)** | Same scripts in `/opt/cm4-provisioning/`, same env; `/var/lib/cm4-provisioning/` is a **bind mount** from the host (shared storage for the golden image) |
When you plug the reTerminal in boot mode into the **host**, udev on the host runs the flash (rpiboot + dd). The golden image is read from `/var/lib/cm4-provisioning/golden.img` on the host (same path visible in the LXC).
---
## Deployment that was done
1. **LXC 201** created on Proxmox `10.130.60.224`:
- Hostname: `cm4-provisioning`
- Debian 12, 1 GB RAM, 8 GB rootfs
- Bind mount: host `/var/lib/cm4-provisioning` → container `/var/lib/cm4-provisioning`
2. **On the host**:
- `/opt/cm4-provisioning/flash-emmc-on-connect.sh` flash script
- `/usr/local/bin/cm4-flash-trigger.sh` started by udev
- `/etc/udev/rules.d/90-cm4-boot-mode.rules` run trigger when USB vendor `2b8e` is added
- `/opt/cm4-provisioning/env` `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`
- `/etc/cm4-provisioning/enabled` safety switch (remove to disable auto-flash)
3. **Inside LXC 201**:
- Same scripts in `/opt/cm4-provisioning/` and env (for reference/backup)
- Golden image path: `/var/lib/cm4-provisioning/golden.img` (bind-mounted from host)
- **Dashboard** (optional): Flask app in `/opt/cm4-provisioning/dashboard/` to monitor deployment and show connection steps; see below.
4. **usbboot (rpiboot)** was **not** built on the host (no outbound DNS during deploy). You must install it when the host has internet.
---
## What you need to do
### 1. Build and install rpiboot on the Proxmox host (when it has internet)
On your machine (repo already synced to the host):
```bash
# From your repo
scp chromium-setup/emmc-provisioning/scripts/install-usbboot-on-host.sh root@10.130.60.224:/tmp/
ssh root@10.130.60.224 "bash /tmp/install-usbboot-on-host.sh"
```
Or on the host (if the deploy folder is still there):
```bash
ssh root@10.130.60.224
bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
```
This installs dependencies, clones usbboot, builds it, and copies `rpiboot` to `/opt/usbboot/`.
### 2. Put the golden image on the host (or in the LXC)
The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**. Because that directory is bind-mounted into the LXC, you can use either:
- **From the host:**
```bash
scp your-golden.img root@10.130.60.224:/var/lib/cm4-provisioning/golden.img
```
- **From the LXC** (e.g. after copying the image into the container elsewhere first):
```bash
pct exec 201 -- ls -la /var/lib/cm4-provisioning/
# Copy to that path inside the container; it's the same as the host path.
```
### 3. Run the provisioning dashboard (optional)
The dashboard shows **connection steps** and **live deployment status** (idle / connecting / flashing / done / error) and a recent flash log. It reads the same `status.json` and `flash.log` that the hosts flash script writes (via the bind-mounted `/var/lib/cm4-provisioning`).
**Inside LXC 201:**
```bash
# Copy dashboard into the container (from host, if you have the repo there)
# Or from your workstation:
# rsync -a chromium-setup/emmc-provisioning/dashboard/ root@10.130.60.224:/tmp/dashboard/
# ssh root@10.130.60.224 "pct push 201 /tmp/dashboard/app.py /opt/cm4-provisioning/dashboard/ && pct push 201 /tmp/dashboard/cm4-dashboard.service /opt/cm4-provisioning/dashboard/ && pct exec 201 -- mkdir -p /opt/cm4-provisioning/dashboard/templates && ..."
# Inside the LXC (pct exec 201 -- bash):
apt-get update && apt-get install -y python3-flask
mkdir -p /opt/cm4-provisioning/dashboard/templates
# Copy app.py, templates/index.html, cm4-dashboard.service into the container (see dashboard/README.md)
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now cm4-dashboard
```
Then open **http://&lt;LXC-201-IP&gt;:5000** (get the IP with `pct exec 201 -- hostname -I`). If the LXC is on a private network, set up port forwarding on the Proxmox host or use a reverse proxy so you can reach the dashboard from your browser.
### 4. Optional: disable or enable auto-flash
- **Disable:**
`ssh root@10.130.60.224 "rm /etc/cm4-provisioning/enabled"`
- **Enable again:**
`ssh root@10.130.60.224 "touch /etc/cm4-provisioning/enabled"`
---
## Usage
1. Place the reTerminal in **boot mode** (eMMC disable jumper).
2. Connect its **USB slave** port to the **Proxmox host** (not to the LXC).
3. Power the reTerminal (or connect after power).
4. On the host, udev will run the trigger and then the flash script (rpiboot, then dd). Watch logs:
```bash
ssh root@10.130.60.224 "journalctl -u cm4-flash-once -f"
# or
ssh root@10.130.60.224 "journalctl -t cm4-flash -f"
```
5. When flashing finishes, remove the jumper and power cycle the reTerminal so it boots from eMMC.
---
## Redeploy / update scripts
From your repo (e.g. after changing scripts):
```bash
./chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.130.60.224
```
That script syncs the repo to the host and reinstalls scripts on both the host and LXC 201. It does **not** overwrite `/opt/cm4-provisioning/env` or `/etc/cm4-provisioning/enabled` if youve changed them; adjust the script if you want that. It also does **not** build usbboot; run `install-usbboot-on-host.sh` on the host when needed.
---
## Summary
| Item | Location |
|------|----------|
| LXC | 201, hostname `cm4-provisioning`, Proxmox `10.130.60.224` |
| Golden image | `/var/lib/cm4-provisioning/golden.img` (host and LXC see the same file) |
| Flash runs on | Proxmox **host** (udev + rpiboot + dd) |
| Build rpiboot on host | Run `scripts/install-usbboot-on-host.sh` on the host when it has internet |
| Dashboard | Flask app in LXC at `http://<LXC-IP>:5000`; switch Flash/Backup mode, list and download backups; see **dashboard/README.md** and section 3 above |
| Backups | Saved under `/var/lib/cm4-provisioning/backups/`. When a device is detected (USB or network), choose **Backup** or **Deploy** in the dashboard. |
| Network deploy/backup | Network-booted devices run **network-client/provisioning-client.sh** and register with the dashboard; they then appear under "Device detected (Network)" and you choose Backup or Deploy. See **network-client/README.md**. |

View File

@@ -0,0 +1,17 @@
# reTerminal DM4 eMMC auto-provisioning
Automatically flash a **golden image** to the CM4 eMMC when the reTerminal is connected in **boot mode** (eMMC disable jumper). Optional **backup** mode saves the current eMMC to a timestamped image file instead. Uses **cloud-init** for first-boot configuration.
| File | Purpose |
|------|--------|
| **EMMC-PROVISIONING-GUIDE.md** | Full setup and usage guide read this first. |
| **flash-emmc-on-connect.sh** | Script that runs `rpiboot` then either flashes the golden image to eMMC or backs up eMMC to a file (mode set via dashboard or `mode` file). |
| **cm4-flash-trigger.sh** | Called by udev when CM4 in boot mode is connected; starts the flash job. |
| **90-cm4-boot-mode.rules** | udev rule: when USB device 2b8e is added, run the trigger script. |
| **cloud-init/** | Example NoCloud files (`user-data`, `meta-data`, `network-config`) for the golden image. |
| **dashboard/** | Flask web UI: auto-detect device (USB or network), prompt **Backup or Deploy**, show status and connection steps. See **dashboard/README.md**. |
| **network-client/** | Script for network-booted devices: register with the dashboard and perform Deploy (pull image, write eMMC) or Backup (upload eMMC). See **network-client/README.md**. |
Quick start: see **EMMC-PROVISIONING-GUIDE.md**.
**Proxmox:** LXC 201 + host setup is documented in **PROXMOX-LXC-DEPLOYMENT.md**. Use **scripts/deploy-to-proxmox.sh** to deploy to a Proxmox host; flash runs on the host, golden image is in a bind-mounted dir shared with the LXC.

View File

@@ -0,0 +1,5 @@
# NoCloud meta-data: enables cloud-init. Optional instance-id for multi-device.
# Copy to the boot (FAT32) partition of your image as 'meta-data'.
instance-id: reterminal-01
# local-hostname: reterminal-01 # optional override

View File

@@ -0,0 +1,21 @@
# Cloud-init network-config (NoCloud). Copy to boot partition as 'network-config'.
# Adjust for your LAN: DHCP or static.
version: 2
ethernets:
eth0:
dhcp4: true
# eth0:
# addresses:
# - 192.168.1.100/24
# gateway4: 192.168.1.1
# nameservers:
# addresses:
# - 8.8.8.8
# Optional WiFi (uncomment and set your SSID/password)
# wlan0:
# dhcp4: true
# access-points:
# "YourSSID":
# password: "YourPassword"

View File

@@ -0,0 +1,34 @@
#cloud-config
# Cloud-init user-data for reTerminal DM4 golden image (eMMC).
# Copy to the boot (FAT32) partition of your image as 'user-data'.
# Raspberry Pi OS uses NoCloud: meta-data, user-data, network-config on boot partition.
package_update: true
package_upgrade: false
packages:
- chromium-browser
- wmctrl
# - python3-pip # uncomment if you need Flask/other apps
# Optional: set hostname from serial or leave default
# hostname: reterminal-%s # %s = first column of meta-data instance-id if set
# Optional: enable I2C/SPI for reTerminal peripherals (LED, buzzer, etc.)
# Uncomment if your image does not already enable these:
# write_files:
# - path: /boot/firmware/config.txt.d/99-reterminal.txt
# content: |
# dtparam=i2c_arm=on
# dtparam=spi=on
# Run once on first boot (e.g. copy kiosk scripts, start Chromium on boot)
runcmd:
# Example: ensure Chromium kiosk autostart
# - systemctl enable chromium-kiosk
- cloud-init single --name cc_final_message
# Power state after first boot (optional)
# power_state:
# mode: reboot
# delay: 1

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Called by udev when CM4 in boot mode is connected. Starts the flash job in the background
# so udev can return immediately. Install to /usr/local/bin/cm4-flash-trigger.sh
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
exec systemd-run --no-block --unit=cm4-flash-once "$FLASH_SCRIPT"

View File

@@ -0,0 +1,38 @@
# CM4 Provisioning Dashboard
Flask web UI to monitor the eMMC deployment process and show device connection steps.
- **Connection steps**: Numbered instructions for putting the reTerminal in boot mode and connecting it.
- **Live status**: Idle / Connecting (rpiboot) / Flashing / Backup / Done / Error, with optional progress.
- **Backup / Restore**: Toggle between **Flash** (deploy golden image) and **Backup** (save eMMC to a timestamped file when device is connected in boot mode). List and download saved backups.
- **Recent log**: Tail of the flash log (from the host, via the shared bind mount).
The dashboard reads `/var/lib/cm4-provisioning/status.json` and `flash.log`, which the flash script (running on the Proxmox host) updates. When the dashboard runs inside the LXC, that directory is bind-mounted from the host, so it sees the same files.
## Run locally (development)
```bash
cd dashboard
pip install flask # or use venv
python3 app.py
# Open http://localhost:5000
```
## Run in LXC (Proxmox)
1. Copy the dashboard into the container (e.g. to `/opt/cm4-provisioning/dashboard`).
2. Install Flask if needed: `apt install -y python3-flask` or `pip install flask`.
3. Install the systemd unit and enable it:
```bash
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now cm4-dashboard
```
4. Open `http://<LXC-IP>:5000` (or port-forward from the Proxmox host).
## Environment (optional)
- `CM4_STATUS_FILE` path to status JSON (default: `/var/lib/cm4-provisioning/status.json`).
- `CM4_LOG_FILE` path to flash log (default: `/var/lib/cm4-provisioning/flash.log`).

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Flask dashboard for CM4 eMMC provisioning.
Monitors deployment status, shows device connection steps, backup/restore.
Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register, then Backup/Deploy).
"""
import json
import os
import time
from pathlib import Path
from flask import Flask, render_template, jsonify, request, send_file, Response
app = Flask(__name__)
BASE_DIR = Path(os.environ.get("CM4_PROVISIONING_DIR", "/var/lib/cm4-provisioning"))
STATUS_FILE = os.environ.get("CM4_STATUS_FILE", str(BASE_DIR / "status.json"))
LOG_FILE = os.environ.get("CM4_LOG_FILE", str(BASE_DIR / "flash.log"))
ACTION_REQUEST_FILE = os.environ.get("CM4_ACTION_REQUEST_FILE", str(BASE_DIR / "action_request"))
DEVICE_SOURCE_FILE = os.environ.get("CM4_DEVICE_SOURCE_FILE", str(BASE_DIR / "device_source"))
BACKUPS_DIR = Path(os.environ.get("CM4_BACKUPS_DIR", str(BASE_DIR / "backups")))
GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.img")))
NETWORK_DEVICES_FILE = Path(os.environ.get("CM4_NETWORK_DEVICES_FILE", str(BASE_DIR / "network_devices.json")))
DEFAULT_STATUS = {
"phase": "idle",
"message": "Waiting for reTerminal in boot mode or network.",
"progress": None,
"updated": None,
}
def read_status():
try:
with open(STATUS_FILE, "r") as f:
data = json.load(f)
out = {**DEFAULT_STATUS, **data}
if out.get("phase") == "waiting_choice":
try:
with open(DEVICE_SOURCE_FILE, "r") as sf:
out["device_source"] = (sf.read() or "").strip() or "usb"
except (FileNotFoundError, OSError):
out["device_source"] = "usb"
return out
except (FileNotFoundError, json.JSONDecodeError):
return DEFAULT_STATUS
def read_log_tail(lines=50):
try:
with open(LOG_FILE, "r") as f:
all_lines = f.readlines()
return "".join(all_lines[-lines:]).strip() if all_lines else ""
except (FileNotFoundError, PermissionError):
return ""
def _load_network_devices():
try:
if NETWORK_DEVICES_FILE.is_file():
with open(NETWORK_DEVICES_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {"devices": []}
def _save_network_devices(data):
try:
os.makedirs(NETWORK_DEVICES_FILE.parent, exist_ok=True)
with open(NETWORK_DEVICES_FILE, "w") as f:
json.dump(data, f, indent=2)
return True
except (PermissionError, OSError):
return False
def list_backups():
if not BACKUPS_DIR.is_dir():
return []
out = []
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and p.suffix in (".img", ".img.gz"):
try:
st = p.stat()
out.append({"name": p.name, "size": st.st_size, "mtime": st.st_mtime})
except OSError:
pass
return out
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/status")
def api_status():
return jsonify(read_status())
@app.route("/api/log")
def api_log():
return jsonify({"log": read_log_tail()})
@app.route("/api/pending-devices")
def api_pending_devices():
"""Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy."""
st = read_status()
usb = None
if st.get("phase") == "waiting_choice":
usb = {"source": "usb", "message": st.get("message", "Device connected (USB). Choose action.")}
data = _load_network_devices()
network = [d for d in data.get("devices", []) if d.get("action") in (None, "wait")]
return jsonify({"usb": usb, "network": network})
@app.route("/api/device-action", methods=["POST"])
def api_device_action():
"""User chose Backup or Deploy for a device. source=usb | network; for network pass mac=."""
body = request.get_json(force=True, silent=True) or {}
source = (body.get("source") or "").strip().lower()
action = (body.get("action") or "").strip().lower()
if action not in ("backup", "deploy"):
return jsonify({"ok": False, "error": "action must be 'backup' or 'deploy'"}), 400
if source == "usb":
try:
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True)
with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action)
return jsonify({"ok": True})
except (PermissionError, OSError):
return jsonify({"ok": False, "error": "Could not write action file"}), 500
if source == "network":
mac = (body.get("mac") or "").strip()
if not mac:
return jsonify({"ok": False, "error": "mac required for network device"}), 400
data = _load_network_devices()
for d in data.get("devices", []):
if (d.get("mac") or "").lower() == mac.lower():
d["action"] = action
d["action_at"] = time.time()
_save_network_devices(data)
return jsonify({"ok": True})
return jsonify({"ok": False, "error": "Device not found"}), 404
return jsonify({"ok": False, "error": "source must be 'usb' or 'network'"}), 400
@app.route("/api/register-device", methods=["POST"])
def api_register_device():
"""Called by a network-booted device to register (mac, ip)."""
body = request.get_json(force=True, silent=True) or request.form
mac = (body.get("mac") or "").strip()
ip = (body.get("ip") or request.remote_addr or "").strip()
if not mac:
return jsonify({"ok": False, "error": "mac required"}), 400
data = _load_network_devices()
devices = data.get("devices", [])
for d in devices:
if (d.get("mac") or "").lower() == mac.lower():
d["ip"] = ip
d["registered_at"] = time.time()
d["action"] = d.get("action") or "wait"
_save_network_devices(data)
return jsonify({"ok": True, "message": "registered"})
devices.append({"mac": mac, "ip": ip, "registered_at": time.time(), "action": "wait"})
data["devices"] = devices
_save_network_devices(data)
return jsonify({"ok": True, "message": "registered"})
@app.route("/api/device-action-poll")
def api_device_action_poll():
"""Network device polls this to get its assigned action (deploy/backup) and URL."""
mac = (request.args.get("mac") or "").strip()
if not mac:
return jsonify({"action": "wait"}), 200
data = _load_network_devices()
base = request.host_url.rstrip("/")
for d in data.get("devices", []):
if (d.get("mac") or "").lower() == mac.lower():
action = d.get("action") or "wait"
if action == "deploy":
return jsonify({"action": "deploy", "url": f"{base}/api/golden-image"})
if action == "backup":
return jsonify({"action": "backup", "upload_url": f"{base}/api/backup-upload?mac={mac}"})
return jsonify({"action": "wait"})
return jsonify({"action": "wait"})
@app.route("/api/golden-image")
def api_golden_image():
"""Stream the golden image for network deploy (device pulls and writes to eMMC)."""
if not GOLDEN_IMAGE.is_file():
return jsonify({"error": "Golden image not found"}), 404
return send_file(
GOLDEN_IMAGE,
mimetype="application/octet-stream",
as_attachment=True,
download_name="golden.img",
)
@app.route("/api/backup-upload", methods=["POST"])
def api_backup_upload():
"""Network device uploads its eMMC backup (raw body)."""
mac = (request.args.get("mac") or "").strip().replace(":", "-")[:20]
if not mac:
return jsonify({"error": "mac query param required"}), 400
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
name = f"backup-net-{mac}-{int(time.time())}.img"
path = BACKUPS_DIR / name
try:
with open(path, "wb") as f:
while True:
chunk = request.stream.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
return jsonify({"ok": True, "file": name})
except (OSError, IOError) as e:
if path.exists():
path.unlink(missing_ok=True)
return jsonify({"error": str(e)}), 500
@app.route("/api/backups")
def api_backups():
return jsonify({"backups": list_backups()})
@app.route("/api/backups/<path:name>")
def api_backup_download(name):
if ".." in name or "/" in name or "\\" in name:
return jsonify({"error": "invalid name"}), 400
path = BACKUPS_DIR / name
if not path.is_file():
return jsonify({"error": "not found"}), 404
return send_file(path, as_attachment=True, download_name=name)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)

View File

@@ -0,0 +1,16 @@
[Unit]
Description=CM4 eMMC Provisioning Dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cm4-provisioning/dashboard
ExecStart=/usr/bin/python3 -m flask --app app run --host=0.0.0.0 --port=5000
Restart=on-failure
RestartSec=5
Environment=FLASK_ENV=production
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CM4 eMMC Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Portal Styling Guide tokens */
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-tertiary: #1a1f2b;
--bg-card: #151a24;
--accent-primary: #00d4aa;
--accent-secondary: #00b894;
--accent-glow: rgba(0, 212, 170, 0.15);
--text-primary: #e6e8eb;
--text-secondary: #8b949e;
--text-muted: #5c6370;
--border-color: #2d333b;
--danger: #ff6b6b;
--danger-glow: rgba(255, 107, 107, 0.15);
--warning: #ffd93d;
--success: #00d4aa;
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
pointer-events: none;
z-index: -1;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
z-index: 1000;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--gradient-accent);
border-radius: 10px;
box-shadow: 0 4px 20px var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.logo-title {
font-size: 1.5rem;
font-weight: 600;
background: var(--gradient-accent);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.main {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
padding-top: calc(2rem + 72px);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .label {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.subtitle { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1.5rem; }
.steps { list-style: none; }
.steps li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-color);
}
.steps li:last-child { border-bottom: none; }
.step-num {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.8rem;
}
.step-num.done { background: var(--gradient-accent); color: var(--bg-primary); }
.status-box {
padding: 1rem 1.25rem;
border-radius: 12px;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
}
.status-box.idle { background: var(--accent-glow); border-color: var(--accent-primary); }
.status-box.rpiboot,
.status-box.flashing,
.status-box.backup,
.status-box.waiting_choice { background: rgba(255, 217, 61, 0.1); border-color: var(--warning); }
.status-box.done { background: rgba(0, 212, 170, 0.12); border-color: var(--success); }
.status-box.error { background: var(--danger-glow); border-color: var(--danger); }
.status-phase {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.35rem;
}
.status-box.idle .status-phase { color: var(--accent-primary); }
.status-box.rpiboot .status-phase,
.status-box.flashing .status-phase,
.status-box.backup .status-phase,
.status-box.waiting_choice .status-phase { color: var(--warning); }
.status-box.done .status-phase { color: var(--success); }
.status-box.error .status-phase { color: var(--danger); }
.status-message { color: var(--text-primary); font-size: 0.95rem; }
.status-error { color: var(--danger); font-size: 0.9rem; margin-top: 0.5rem; }
.status-updated { color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem; }
.progress-wrap {
margin-top: 0.75rem;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--gradient-accent);
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-bar.indeterminate {
width: 40%;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.log-box {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
max-height: 180px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-muted);
}
.log-box:empty::before { content: 'No flash log yet.'; color: var(--text-muted); }
.backups-list { list-style: none; }
.backups-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-color);
gap: 0.75rem;
}
.backups-list li:last-child { border-bottom: none; }
.backups-list a {
color: var(--accent-primary);
text-decoration: none;
font-weight: 500;
}
.backups-list a:hover { text-decoration: underline; }
.backups-meta { color: var(--text-muted); font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
.pending-device {
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.pending-device .label { font-weight: 500; color: var(--text-primary); }
.pending-device .actions { display: flex; gap: 0.5rem; }
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
font-family: 'Outfit', sans-serif;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.btn-primary {
background: var(--gradient-accent);
color: var(--bg-primary);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pending-device .btn { padding: 0.5rem 1rem; font-size: 0.85rem; }
code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
background: var(--bg-tertiary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
color: var(--accent-primary);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.9rem;
}
details summary {
cursor: pointer;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
@media (max-width: 768px) {
.header { padding: 1rem; flex-direction: column; gap: 0.5rem; text-align: center; }
.main { padding: 1rem; padding-top: calc(1rem + 100px); }
}
</style>
</head>
<body>
<header class="header">
<div class="logo">
<div class="logo-icon"></div>
<h1 class="logo-title">CM4 eMMC Provisioning</h1>
</div>
</header>
<main class="main">
<p class="subtitle">reTerminal DM4 — deploy or backup via USB boot mode or network boot</p>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Connect the device</span></h2>
</div>
<p style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.75rem;">Choose one:</p>
<p style="font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">USB boot mode</p>
<ol class="steps">
<li><span class="step-num">1</span> Set the reTerminal to <strong>boot mode</strong>: fit the <strong>eMMC disable</strong> jumper (e.g. J2 / nRPIBOOT).</li>
<li><span class="step-num">2</span> Connect the reTerminals <strong>USB slave</strong> port to the Proxmox host. Power on. The device will appear in “Device detected” below; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
<li><span class="step-num">3</span> When done, remove the jumper and power cycle so it boots from eMMC.</li>
</ol>
<p style="font-size: 0.9rem; font-weight: 600; margin: 1rem 0 0.25rem 0;">Network boot</p>
<ol class="steps">
<li><span class="step-num">1</span> Enable network boot on the CM4 (e.g. <code>BOOT_ORDER=0x21</code>) and ensure it can reach this server.</li>
<li><span class="step-num">2</span> Boot the device over the network with an environment that runs the <strong>provisioning client</strong> (register + poll for action). It will show under “Device detected (Network)”. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Device detected — choose action</span></h2>
</div>
<p id="pendingHint" style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.75rem;">When a device is detected (USB boot mode or network boot), it will appear below. Choose <strong>Backup</strong> to save its eMMC to a file, or <strong>Deploy</strong> to write the golden image to it.</p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-state" style="display: none;">No device waiting. Connect a reTerminal in USB boot mode, or ensure a network-booted device has registered.</p>
<h3 style="font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 1rem 0 0.5rem 0;">Saved backups</h3>
<ul id="backupsList" class="backups-list"></ul>
<p id="backupsEmpty" class="empty-state" style="display: none;">No backups yet.</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Deployment status</span></h2>
</div>
<div id="status" class="status-box idle">
<div class="status-phase">Idle</div>
<div class="status-message">Waiting for reTerminal in boot mode.</div>
<div id="statusError" class="status-error" style="display:none;"></div>
<div id="statusUpdated" class="status-updated"></div>
<div id="progressWrap" class="progress-wrap" style="display:none;">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<details style="margin-top: 0.75rem;">
<summary>Recent flash log</summary>
<div id="log" class="log-box" style="margin-top: 0.5rem;"></div>
</details>
</div>
</main>
<script>
const statusEl = document.getElementById('status');
const phaseEl = statusEl.querySelector('.status-phase');
const messageEl = statusEl.querySelector('.status-message');
const errorEl = document.getElementById('statusError');
const updatedEl = document.getElementById('statusUpdated');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const logEl = document.getElementById('log');
const phaseLabels = {
idle: 'Idle',
rpiboot: 'Connecting',
waiting_choice: 'Choose action',
flashing: 'Flashing',
backup: 'Backing up',
done: 'Done',
error: 'Error'
};
function renderStatus(data) {
const phase = data.phase || 'idle';
statusEl.className = 'status-box ' + phase;
phaseEl.textContent = phaseLabels[phase] || phase;
messageEl.textContent = data.message || '';
if (data.error) {
errorEl.textContent = data.error;
errorEl.style.display = 'block';
} else {
errorEl.style.display = 'none';
}
if (data.updated) {
updatedEl.textContent = 'Updated: ' + data.updated;
updatedEl.style.display = 'block';
} else {
updatedEl.style.display = 'none';
}
const progress = data.progress;
const inProgress = phase === 'rpiboot' || phase === 'flashing' || phase === 'backup';
const showProgress = progress != null && (inProgress || phase === 'done');
if (showProgress) {
progressWrap.style.display = 'block';
progressBar.classList.remove('indeterminate');
progressBar.style.width = (progress === null ? 0 : progress) + '%';
if (progress === null && inProgress) {
progressBar.classList.add('indeterminate');
progressBar.style.width = '40%';
}
} else if (inProgress) {
progressWrap.style.display = 'block';
progressBar.classList.add('indeterminate');
progressBar.style.width = '40%';
} else {
progressWrap.style.display = 'none';
}
}
function renderPendingDevices(usb, network) {
const container = document.getElementById('pendingDevices');
const noPending = document.getElementById('noPending');
container.innerHTML = '';
let hasAny = false;
if (usb) {
hasAny = true;
const div = document.createElement('div');
div.className = 'pending-device';
div.innerHTML = '<span class="label">Device connected (USB boot mode)</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></span>';
container.appendChild(div);
}
(network || []).forEach(function(d) {
hasAny = true;
const div = document.createElement('div');
div.className = 'pending-device';
div.innerHTML = '<span class="label">Device (Network): ' + escapeHtml(d.ip || '') + ' — ' + escapeHtml(d.mac || '') + '</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></span>';
container.appendChild(div);
});
noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() {
const source = btn.getAttribute('data-source');
const action = btn.getAttribute('data-action');
const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action };
if (mac) body.mac = mac;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchPending(); fetchStatus(); }
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); });
};
});
}
function fetchPending() {
fetch('/api/pending-devices').then(function(r) { return r.json(); }).then(function(data) {
renderPendingDevices(data.usb || null, data.network || []);
}).catch(function() { renderPendingDevices(null, []); });
}
function fetchStatus() {
fetch('/api/status')
.then(r => r.json())
.then(renderStatus)
.catch(() => renderStatus({ phase: 'error', message: 'Could not load status.' }));
}
function fetchLog() {
fetch('/api/log')
.then(r => r.json())
.then(data => { logEl.textContent = data.log || ''; })
.catch(() => { logEl.textContent = ''; });
}
function fmtSize(n) {
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GB';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB';
return (n / 1e3).toFixed(0) + ' KB';
}
function fmtDate(ts) { return new Date(ts * 1000).toLocaleString(); }
function fetchBackups() {
fetch('/api/backups')
.then(r => r.json())
.then(data => {
const list = document.getElementById('backupsList');
const empty = document.getElementById('backupsEmpty');
list.innerHTML = '';
const backups = data.backups || [];
if (backups.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
backups.forEach(b => {
const li = document.createElement('li');
li.innerHTML = '<a href="/api/backups/' + encodeURIComponent(b.name) + '" download>' + escapeHtml(b.name) + '</a>' +
'<span class="backups-meta">' + fmtSize(b.size) + ' · ' + fmtDate(b.mtime) + '</span>';
list.appendChild(li);
});
})
.catch(() => {});
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
fetchStatus();
fetchLog();
fetchPending();
fetchBackups();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
</script>
</body>
</html>

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Auto-flash CM4 eMMC when reTerminal is connected in boot mode (eMMC disable jumper).
# Run this from udev or a systemd service when Raspberry Pi USB boot device (2b8e) is detected.
# Requires: usbboot (rpiboot) built, golden image at GOLDEN_IMAGE path.
set -e
# Load overrides from env file if present
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
# Configuration - adjust paths and size for your setup
RPIBOOT_DIR="${RPIBOOT_DIR:-/opt/usbboot}"
GOLDEN_IMAGE="${GOLDEN_IMAGE:-/var/lib/cm4-provisioning/golden.img}"
# Expected eMMC size in bytes (reTerminal CM4 often 8GB or 16GB). Used to identify the correct block device.
EMMC_SIZE_BYTES="${EMMC_SIZE_BYTES:-$(( 8 * 1024 * 1024 * 1024 ))}"
LOG_TAG="cm4-flash"
STATUS_FILE="${STATUS_FILE:-/var/lib/cm4-provisioning/status.json}"
LOG_FILE="${LOG_FILE:-/var/lib/cm4-provisioning/flash.log}"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*"; echo "[$(date -Iseconds)] $*" >> "$LOG_FILE" 2>/dev/null || true; }
write_status() {
local phase="$1" message="$2" progress="${3:-null}" error="${4:-}"
local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
message="${message//\"/\\\"}"; error="${error//\"/\\\"}"
if [[ -n "$error" ]]; then
printf '{"phase":"%s","message":"%s","progress":%s,"error":"%s","updated":"%s"}\n' \
"$phase" "$message" "$progress" "$error" "$ts" > "$STATUS_FILE" 2>/dev/null || true
else
printf '{"phase":"%s","message":"%s","progress":%s,"updated":"%s"}\n' \
"$phase" "$message" "$progress" "$ts" > "$STATUS_FILE" 2>/dev/null || true
fi
}
# Optional: only run if this file exists (safety)
ENABLE_FILE="${ENABLE_FILE:-/etc/cm4-provisioning/enabled}"
if [[ -n "$ENABLE_FILE" && ! -f "$ENABLE_FILE" ]]; then
log "Skipping: $ENABLE_FILE not present"
write_status "idle" "Provisioning disabled (remove /etc/cm4-provisioning/enabled to enable)" "null" 2>/dev/null || true
exit 0
fi
# When a device is detected we ask the user (dashboard): Backup or Deploy?
# These files are used to wait for the user's choice (written by dashboard, read by this script).
BACKUPS_DIR="${BACKUPS_DIR:-/var/lib/cm4-provisioning/backups}"
ACTION_REQUEST_FILE="${ACTION_REQUEST_FILE:-/var/lib/cm4-provisioning/action_request}"
CURRENT_DEVICE_FILE="${CURRENT_DEVICE_FILE:-/var/lib/cm4-provisioning/current_device}"
DEVICE_SOURCE_FILE="${DEVICE_SOURCE_FILE:-/var/lib/cm4-provisioning/device_source}"
WAIT_TIMEOUT="${WAIT_TIMEOUT:-600}"
# Golden image required for deploy
if [[ ! -f "$GOLDEN_IMAGE" ]]; then
log "Golden image not found (required for deploy): $GOLDEN_IMAGE"
write_status "error" "Golden image not found" "null" "Golden image not found. Add golden.img for deploy."
exit 1
fi
RPIBOOT_BIN="$RPIBOOT_DIR/rpiboot"
if [[ ! -x "$RPIBOOT_BIN" ]]; then
log "rpiboot not found: $RPIBOOT_BIN (build usbboot and set RPIBOOT_DIR)"
write_status "error" "rpiboot not installed" "null" "rpiboot not found. Run install-usbboot-on-host.sh on the host."
exit 1
fi
# Ensure status dir exists and start with running state
mkdir -p "$(dirname "$STATUS_FILE")" "$BACKUPS_DIR" 2>/dev/null || true
write_status "rpiboot" "Connecting to CM4 in boot mode…" "0"
# Block devices before rpiboot (so we can detect new one after)
before_devs=$(lsblk -nd -o NAME 2>/dev/null | sort)
log "Starting rpiboot to expose CM4 eMMC as mass storage..."
if ! "$RPIBOOT_BIN"; then
log "rpiboot failed or no device connected"
write_status "error" "rpiboot failed" "null" "rpiboot failed or no device connected"
exit 1
fi
# rpiboot exits when mass storage appears; give udev a moment to create /dev/sdX
sleep 3
# Find new block device (prefer one matching expected eMMC size)
target_dev=""
for dev in /dev/sd[a-z] /dev/sd[a-z][a-z]; do
[[ -b "$dev" ]] || continue
# Skip partitions
[[ "$dev" =~ [0-9]$ ]] && continue
size=$(blockdev --getsize64 "$dev" 2>/dev/null || true)
if [[ -n "$size" ]]; then
# Allow 5% tolerance on size
if (( size >= EMMC_SIZE_BYTES * 95 / 100 && size <= EMMC_SIZE_BYTES * 105 / 100 )); then
target_dev=$dev
break
fi
# Otherwise take first new disk that appeared (fallback)
if [[ -z "$target_dev" && "$before_devs" != *"${dev#/dev/}"* ]]; then
target_dev=$dev
fi
fi
done
if [[ -z "$target_dev" ]]; then
log "No suitable block device found after rpiboot (expected ~${EMMC_SIZE_BYTES} bytes)"
write_status "error" "No eMMC device found" "null" "No suitable block device after rpiboot"
exit 1
fi
# Ask user (dashboard): Backup or Deploy?
write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard." "null"
echo "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true
echo "$target_dev" > "$CURRENT_DEVICE_FILE" 2>/dev/null || true
log "Waiting for user choice (Backup or Deploy) in dashboard; timeout ${WAIT_TIMEOUT}s..."
for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
sleep 2
if [[ -f "$ACTION_REQUEST_FILE" ]]; then
action=$(cat "$ACTION_REQUEST_FILE" 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
rm -f "$ACTION_REQUEST_FILE" 2>/dev/null || true
if [[ "$action" == "backup" ]]; then
backup_name="backup-$(date +%Y%m%d-%H%M%S).img"
backup_path="$BACKUPS_DIR/$backup_name"
write_status "backup" "Creating backup…" "null"
log "Backing up $target_dev to $backup_path..."
if dd if="$target_dev" of="$backup_path" bs=4M status=progress conv=fsync 2>>"$LOG_FILE"; then
log "Backup complete: $backup_path"
write_status "done" "Backup complete: $backup_name" "100"
else
write_status "error" "Backup failed" "null" "dd failed"
fi
elif [[ "$action" == "deploy" ]]; then
write_status "flashing" "Writing golden image…" "null"
log "Flashing $GOLDEN_IMAGE to $target_dev..."
if dd if="$GOLDEN_IMAGE" of="$target_dev" bs=4M status=progress conv=fsync 2>>"$LOG_FILE"; then
log "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal."
write_status "done" "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." "100"
else
write_status "error" "Flash failed" "null" "dd failed"
fi
else
write_status "error" "Unknown action" "null" "action_request must be 'backup' or 'deploy'"
fi
rm -f "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null || true
exit 0
fi
done
log "Timeout waiting for user choice"
write_status "idle" "Timeout waiting for choice. Connect the device again to retry." "null"
rm -f "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null || true
exit 0

View File

@@ -0,0 +1,27 @@
# Network provisioning client
When a reTerminal (or any Pi) has **network boot** enabled and boots over the network, it can register with the provisioning dashboard and then perform **Deploy** (pull golden image and write to eMMC) or **Backup** (read eMMC and upload to the server) when you choose the action in the dashboard.
## Flow
1. Device boots (e.g. from NFS or a minimal netboot root).
2. Run **provisioning-client.sh** with `PROVISIONING_SERVER` set to the dashboard URL (e.g. `http://<LXC-IP>:5000`).
3. The script registers (MAC + IP) and polls `GET /api/device-action-poll?mac=...`.
4. In the dashboard, the device appears under "Device detected (Network)". You click **Backup** or **Deploy**.
5. The device gets the action from the next poll: **Deploy** → it downloads `GET /api/golden-image` and runs `dd of=/dev/mmcblk0`. **Backup** → it runs `dd if=/dev/mmcblk0` and POSTs to `POST /api/backup-upload?mac=...`.
## Usage on the device
```bash
export PROVISIONING_SERVER=http://192.168.1.10:5000 # dashboard URL
./provisioning-client.sh
```
- **EMMC_DEV**: override eMMC block device (default `/dev/mmcblk0`).
The device must have network access to the dashboard and (for deploy) the dashboard must have `golden.img` in its provisioning directory.
## Integrating into a netboot environment
- Add this script to your netboot root (e.g. NFS-mounted filesystem or initramfs).
- Run it from a first-boot or default login script so that when the device boots from the network it registers and waits for an action. Optionally run it as a systemd service that starts after network is up.

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Run this script on a reTerminal that has booted over the network (or any Pi with network).
# It registers with the provisioning dashboard and waits for Backup or Deploy, then performs the action.
# Usage: PROVISIONING_SERVER=http://192.168.1.10:5000 ./provisioning-client.sh
# Requires: curl, and for deploy/backup: enough space and write access to eMMC (e.g. /dev/mmcblk0).
set -e
BASE_URL="${PROVISIONING_SERVER:-http://192.168.1.10:5000}"
BASE_URL="${BASE_URL%/}"
EMMC_DEV="${EMMC_DEV:-/dev/mmcblk0}"
MAC=$(cat /sys/class/net/eth0/address 2>/dev/null || cat /sys/class/net/enp*/address 2>/dev/null | head -1 || echo "unknown")
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
echo "Registering with $BASE_URL (MAC=$MAC IP=$IP)..."
curl -s -X POST -H "Content-Type: application/json" -d "{\"mac\":\"$MAC\",\"ip\":\"$IP\"}" "$BASE_URL/api/register-device" || true
echo "Polling for action (Backup or Deploy)..."
while true; do
resp=$(curl -s "$BASE_URL/api/device-action-poll?mac=$MAC" 2>/dev/null || echo '{"action":"wait"}')
action=$(echo "$resp" | grep -o '"action":"[^"]*"' | cut -d'"' -f4)
url=$(echo "$resp" | grep -o '"url":"[^"]*"' | cut -d'"' -f4)
upload_url=$(echo "$resp" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
if [[ "$action" == "deploy" && -n "$url" ]]; then
echo "Deploy: downloading image and writing to $EMMC_DEV..."
if [[ ! -b "$EMMC_DEV" ]]; then
echo "Error: $EMMC_DEV not found"
sleep 10
continue
fi
curl -sL "$url" | dd of="$EMMC_DEV" bs=4M status=progress conv=fsync
echo "Deploy done. Reboot to run from eMMC."
exit 0
fi
if [[ "$action" == "backup" && -n "$upload_url" ]]; then
echo "Backup: reading $EMMC_DEV and uploading..."
if [[ ! -b "$EMMC_DEV" ]]; then
echo "Error: $EMMC_DEV not found"
sleep 10
continue
fi
dd if="$EMMC_DEV" bs=4M status=progress 2>/dev/null | curl -s -X POST -T - "$upload_url"
echo "Backup done."
exit 0
fi
sleep 5
done

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Deploy CM4 eMMC provisioning to a Proxmox host (creates LXC 201, installs scripts on host and in LXC).
# Usage: ./deploy-to-proxmox.sh [proxmox_host]
# Example: ./deploy-to-proxmox.sh root@10.130.60.224
# Requires: ssh key access to root@<host>
set -e
PROXMOX="${1:-root@10.130.60.224}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
echo "Deploying to $PROXMOX ..."
rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh'
ssh "$PROXMOX" bash -s << 'REMOTE'
set -e
DEPLOY=/tmp/emmc-provisioning-deploy
# Ensure LXC 201 exists (create if not)
if ! pct status 201 &>/dev/null; then
echo "Creating LXC 201 (cm4-provisioning)..."
pct create 201 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
--hostname cm4-provisioning --memory 1024 --swap 0 --cores 1 \
--rootfs local-zfs:8 --net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 0 --features nesting=1 -tag cm4-provisioning
mkdir -p /var/lib/cm4-provisioning
pct set 201 -mp0 /var/lib/cm4-provisioning,mp=/var/lib/cm4-provisioning
fi
# Host: install scripts and udev
mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
cp "$DEPLOY/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
cp "$DEPLOY/cm4-flash-trigger.sh" /usr/local/bin/
chmod +x /usr/local/bin/cm4-flash-trigger.sh
cp "$DEPLOY/90-cm4-boot-mode.rules" /etc/udev/rules.d/
udevadm control --reload-rules
cat > /opt/cm4-provisioning/env << 'ENV'
GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img
RPIBOOT_DIR=/opt/usbboot
EMMC_SIZE_BYTES=8589934592
ENV
touch /etc/cm4-provisioning/enabled
mkdir -p /var/lib/cm4-provisioning/backups
# Start LXC if stopped
pct start 201 2>/dev/null || true
# LXC: install scripts
pct exec 201 -- mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
pct push 201 "$DEPLOY/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
pct exec 201 -- chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
pct push 201 "$DEPLOY/cm4-flash-trigger.sh" /usr/local/bin/cm4-flash-trigger.sh
pct exec 201 -- chmod +x /usr/local/bin/cm4-flash-trigger.sh
pct exec 201 -- bash -c 'echo -e "GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img\nRPIBOOT_DIR=/opt/usbboot\nEMMC_SIZE_BYTES=8589934592" > /opt/cm4-provisioning/env'
# LXC: install dashboard
pct exec 201 -- mkdir -p /opt/cm4-provisioning/dashboard/templates
pct push 201 "$DEPLOY/dashboard/app.py" /opt/cm4-provisioning/dashboard/
pct push 201 "$DEPLOY/dashboard/templates/index.html" /opt/cm4-provisioning/dashboard/templates/
pct push 201 "$DEPLOY/dashboard/cm4-dashboard.service" /opt/cm4-provisioning/dashboard/
echo "Deploy done. Install usbboot on host when online: ssh $PROXMOX 'bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh'"
echo "To enable the dashboard in LXC 201: pct exec 201 -- bash -c 'apt-get install -y python3-flask; cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/; systemctl daemon-reload; systemctl enable --now cm4-dashboard'"
REMOTE
echo "Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/)."
echo "When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh"
echo "Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see PROXMOX-LXC-DEPLOYMENT.md)."

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Run on the Proxmox HOST (root) when the host has internet.
# Builds usbboot (rpiboot) and installs to /opt/usbboot so the auto-flash can run.
set -e
apt-get update
apt-get install -y libusb-1.0-0-dev git
cd /tmp
rm -rf usbboot
git clone --depth=1 https://github.com/raspberrypi/usbboot
cd usbboot
make
mkdir -p /opt/usbboot
cp rpiboot /opt/usbboot/
echo "usbboot installed at /opt/usbboot/rpiboot"

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
Flask Buzzer Control Application for reTerminal DM4
Provides REST API for controlling the built-in buzzer
"""
from flask import Flask, jsonify, request
import subprocess
import time
import threading
app = Flask(__name__)
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
class BuzzerController:
"""Buzzer controller class with pattern support"""
def __init__(self):
self.is_playing = False
self.play_thread = None
def _write_buzzer(self, value):
"""Internal method to write to buzzer"""
try:
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input=str(value), text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def on(self):
"""Turn buzzer ON"""
return self._write_buzzer(1)
def off(self):
"""Turn buzzer OFF"""
return self._write_buzzer(0)
def beep(self, duration=0.2):
"""Play a single beep"""
self.on()
time.sleep(duration)
self.off()
def play_pattern(self, pattern):
"""
Play a pattern
pattern: list of tuples [(duration_on, duration_off), ...]
Example: [(0.1, 0.1), (0.1, 0.1), (0.1, 0.3)] = two short beeps, pause, one beep
"""
if self.is_playing:
return False
def _play():
self.is_playing = True
for on_time, off_time in pattern:
self.on()
time.sleep(on_time)
self.off()
time.sleep(off_time)
self.is_playing = False
self.play_thread = threading.Thread(target=_play, daemon=True)
self.play_thread.start()
return True
def starwars_theme(self, duration=5):
"""Play Star Wars theme pattern"""
if self.is_playing:
return False
def _play():
self.is_playing = True
start_time = time.time()
# Opening sequence
self.beep(0.05)
time.sleep(0.05)
self.beep(0.05)
time.sleep(0.05)
self.beep(0.05)
time.sleep(0.1)
self.beep(0.1)
time.sleep(0.1)
self.beep(0.15)
time.sleep(0.15)
# Continue pattern until duration reached
while time.time() - start_time < duration:
self.beep(0.08)
time.sleep(0.05)
self.beep(0.08)
time.sleep(0.05)
self.beep(0.08)
time.sleep(0.1)
self.beep(0.12)
time.sleep(0.1)
self.off()
self.is_playing = False
self.play_thread = threading.Thread(target=_play, daemon=True)
self.play_thread.start()
return True
# Create global buzzer controller
buzzer = BuzzerController()
@app.route('/')
def index():
"""API documentation endpoint"""
return jsonify({
'status': 'Buzzer Control API',
'version': '1.0',
'endpoints': {
'/buzzer/on': {
'method': 'POST, GET',
'description': 'Turn buzzer ON'
},
'/buzzer/off': {
'method': 'POST, GET',
'description': 'Turn buzzer OFF'
},
'/buzzer/beep': {
'method': 'GET',
'description': 'Play a beep',
'parameters': {'duration': 'float (0-5 seconds)'}
},
'/buzzer/pattern': {
'method': 'POST',
'description': 'Play custom pattern',
'body': {'pattern': '[[on_time, off_time], ...]'}
},
'/buzzer/starwars': {
'method': 'GET',
'description': 'Play Star Wars theme',
'parameters': {'duration': 'float (0-10 seconds)'}
},
'/buzzer/status': {
'method': 'GET',
'description': 'Get buzzer status'
}
}
})
@app.route('/buzzer/on', methods=['POST', 'GET'])
def turn_on():
"""Turn buzzer ON"""
if buzzer.on():
return jsonify({'status': 'success', 'message': 'Buzzer turned ON'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer ON'}), 500
@app.route('/buzzer/off', methods=['POST', 'GET'])
def turn_off():
"""Turn buzzer OFF"""
if buzzer.off():
return jsonify({'status': 'success', 'message': 'Buzzer turned OFF'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer OFF'}), 500
@app.route('/buzzer/beep', methods=['POST', 'GET'])
def beep():
"""Play a beep for specified duration"""
duration = float(request.args.get('duration', 0.2))
if duration < 0 or duration > 5:
return jsonify({
'status': 'error',
'message': 'Duration must be between 0 and 5 seconds'
}), 400
buzzer.beep(duration)
return jsonify({
'status': 'success',
'message': f'Beeped for {duration} seconds'
})
@app.route('/buzzer/pattern', methods=['POST'])
def play_pattern():
"""Play a custom pattern"""
data = request.get_json()
if not data or 'pattern' not in data:
return jsonify({
'status': 'error',
'message': 'Pattern required in JSON body'
}), 400
pattern = data['pattern']
if not isinstance(pattern, list):
return jsonify({
'status': 'error',
'message': 'Pattern must be a list'
}), 400
# Validate pattern format
try:
validated_pattern = []
for item in pattern:
if isinstance(item, list) and len(item) == 2:
validated_pattern.append((float(item[0]), float(item[1])))
else:
return jsonify({
'status': 'error',
'message': 'Each pattern item must be [on_time, off_time]'
}), 400
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid pattern format: {str(e)}'
}), 400
if buzzer.play_pattern(validated_pattern):
return jsonify({
'status': 'success',
'message': 'Pattern playing'
})
return jsonify({
'status': 'error',
'message': 'Buzzer is already playing'
}), 409
@app.route('/buzzer/starwars', methods=['GET'])
def starwars():
"""Play Star Wars theme"""
duration = float(request.args.get('duration', 5))
if duration < 0 or duration > 10:
return jsonify({
'status': 'error',
'message': 'Duration must be between 0 and 10 seconds'
}), 400
if buzzer.starwars_theme(duration):
return jsonify({
'status': 'success',
'message': f'Playing Star Wars theme for {duration} seconds'
})
return jsonify({
'status': 'error',
'message': 'Buzzer is already playing'
}), 409
@app.route('/buzzer/status', methods=['GET'])
def status():
"""Get buzzer status"""
try:
result = subprocess.run(['cat', BUZZER_PATH],
capture_output=True, text=True, check=True)
state = result.stdout.strip()
return jsonify({
'status': 'success',
'buzzer': 'ON' if state == '1' else 'OFF',
'state': state,
'is_playing': buzzer.is_playing
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 500
if __name__ == '__main__':
print("Starting Buzzer Control API...")
print("API available at http://0.0.0.0:5000")
print("Documentation at http://0.0.0.0:5000/")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
Flask LED and Buzzer Control Application for reTerminal DM4
Provides REST API for controlling both the LED and buzzer
"""
from flask import Flask, jsonify, request
import subprocess
import time
import threading
app = Flask(__name__)
LED_PATH = '/sys/class/leds/usr-led/brightness'
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
class LEDController:
"""LED controller class"""
def __init__(self):
self.is_active = False
self.thread = None
def _write(self, value):
"""Internal method to write to LED"""
try:
subprocess.run(['sudo', 'tee', LED_PATH],
input=str(value), text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def on(self):
"""Turn LED ON"""
return self._write(1)
def off(self):
"""Turn LED OFF"""
return self._write(0)
def toggle(self):
"""Toggle LED state"""
try:
result = subprocess.run(['cat', LED_PATH],
capture_output=True, text=True, check=True)
current = result.stdout.strip()
new_state = '0' if current == '1' else '1'
return self._write(new_state)
except:
return False
def blink(self, count=5, on_time=0.2, off_time=0.2):
"""Blink LED specified number of times"""
if self.is_active:
return False
def _blink():
self.is_active = True
for _ in range(count):
self.on()
time.sleep(on_time)
self.off()
time.sleep(off_time)
self.is_active = False
self.thread = threading.Thread(target=_blink, daemon=True)
self.thread.start()
return True
def pulse(self, duration=2, interval=0.1):
"""Pulse LED for specified duration"""
if self.is_active:
return False
def _pulse():
self.is_active = True
end_time = time.time() + duration
while time.time() < end_time:
self.on()
time.sleep(interval)
self.off()
time.sleep(interval)
self.is_active = False
self.thread = threading.Thread(target=_pulse, daemon=True)
self.thread.start()
return True
def status(self):
"""Get LED status"""
try:
result = subprocess.run(['cat', LED_PATH],
capture_output=True, text=True, check=True)
state = result.stdout.strip()
return 'ON' if state == '1' else 'OFF'
except:
return 'UNKNOWN'
class BuzzerController:
"""Buzzer controller class"""
def __init__(self):
self.is_playing = False
self.play_thread = None
def _write(self, value):
"""Internal method to write to buzzer"""
try:
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input=str(value), text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True)
return True
except subprocess.CalledProcessError:
return False
def on(self):
"""Turn buzzer ON"""
return self._write(1)
def off(self):
"""Turn buzzer OFF"""
return self._write(0)
def beep(self, duration=0.2):
"""Play a single beep"""
self.on()
time.sleep(duration)
self.off()
def play_pattern(self, pattern):
"""Play a pattern"""
if self.is_playing:
return False
def _play():
self.is_playing = True
for on_time, off_time in pattern:
self.on()
time.sleep(on_time)
self.off()
time.sleep(off_time)
self.is_playing = False
self.play_thread = threading.Thread(target=_play, daemon=True)
self.play_thread.start()
return True
def status(self):
"""Get buzzer status"""
try:
result = subprocess.run(['cat', BUZZER_PATH],
capture_output=True, text=True, check=True)
state = result.stdout.strip()
return 'ON' if state == '1' else 'OFF'
except:
return 'UNKNOWN'
# Create global controllers
led = LEDController()
buzzer = BuzzerController()
@app.route('/')
def index():
"""API documentation endpoint"""
return jsonify({
'status': 'LED and Buzzer Control API',
'version': '1.0',
'endpoints': {
'LED': {
'/led/on': 'Turn LED ON (POST, GET)',
'/led/off': 'Turn LED OFF (POST, GET)',
'/led/toggle': 'Toggle LED (POST, GET)',
'/led/blink': 'Blink LED (GET: ?count=5&on_time=0.2&off_time=0.2)',
'/led/pulse': 'Pulse LED (GET: ?duration=2&interval=0.1)',
'/led/status': 'Get LED status (GET)'
},
'Buzzer': {
'/buzzer/on': 'Turn buzzer ON (POST, GET)',
'/buzzer/off': 'Turn buzzer OFF (POST, GET)',
'/buzzer/beep': 'Play beep (GET: ?duration=0.2)',
'/buzzer/status': 'Get buzzer status (GET)'
},
'Combined': {
'/alert': 'Alert with LED and buzzer (GET: ?type=info|success|error|warning)'
}
}
})
# LED Endpoints
@app.route('/led/on', methods=['POST', 'GET'])
def led_on():
"""Turn LED ON"""
if led.on():
return jsonify({'status': 'success', 'message': 'LED turned ON'})
return jsonify({'status': 'error', 'message': 'Failed to turn LED ON'}), 500
@app.route('/led/off', methods=['POST', 'GET'])
def led_off():
"""Turn LED OFF"""
if led.off():
return jsonify({'status': 'success', 'message': 'LED turned OFF'})
return jsonify({'status': 'error', 'message': 'Failed to turn LED OFF'}), 500
@app.route('/led/toggle', methods=['POST', 'GET'])
def led_toggle():
"""Toggle LED state"""
if led.toggle():
return jsonify({'status': 'success', 'message': 'LED toggled'})
return jsonify({'status': 'error', 'message': 'Failed to toggle LED'}), 500
@app.route('/led/blink', methods=['POST', 'GET'])
def led_blink():
"""Blink LED"""
count = int(request.args.get('count', 5))
on_time = float(request.args.get('on_time', 0.2))
off_time = float(request.args.get('off_time', 0.2))
if led.blink(count, on_time, off_time):
return jsonify({
'status': 'success',
'message': f'LED blinking {count} times'
})
return jsonify({
'status': 'error',
'message': 'LED is already active'
}), 409
@app.route('/led/pulse', methods=['POST', 'GET'])
def led_pulse():
"""Pulse LED"""
duration = float(request.args.get('duration', 2))
interval = float(request.args.get('interval', 0.1))
if led.pulse(duration, interval):
return jsonify({
'status': 'success',
'message': f'LED pulsing for {duration} seconds'
})
return jsonify({
'status': 'error',
'message': 'LED is already active'
}), 409
@app.route('/led/status', methods=['GET'])
def led_status():
"""Get LED status"""
state = led.status()
return jsonify({
'status': 'success',
'led': state,
'is_active': led.is_active
})
# Buzzer Endpoints
@app.route('/buzzer/on', methods=['POST', 'GET'])
def buzzer_on():
"""Turn buzzer ON"""
if buzzer.on():
return jsonify({'status': 'success', 'message': 'Buzzer turned ON'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer ON'}), 500
@app.route('/buzzer/off', methods=['POST', 'GET'])
def buzzer_off():
"""Turn buzzer OFF"""
if buzzer.off():
return jsonify({'status': 'success', 'message': 'Buzzer turned OFF'})
return jsonify({'status': 'error', 'message': 'Failed to turn buzzer OFF'}), 500
@app.route('/buzzer/beep', methods=['POST', 'GET'])
def buzzer_beep():
"""Play a beep"""
duration = float(request.args.get('duration', 0.2))
if duration < 0 or duration > 5:
return jsonify({
'status': 'error',
'message': 'Duration must be between 0 and 5 seconds'
}), 400
buzzer.beep(duration)
return jsonify({
'status': 'success',
'message': f'Beeped for {duration} seconds'
})
@app.route('/buzzer/status', methods=['GET'])
def buzzer_status():
"""Get buzzer status"""
state = buzzer.status()
return jsonify({
'status': 'success',
'buzzer': state,
'is_playing': buzzer.is_playing
})
# Combined Alert Endpoint
@app.route('/alert', methods=['GET'])
def alert():
"""Alert with LED and buzzer"""
alert_type = request.args.get('type', 'info')
patterns = {
'info': {
'led': (1, 0.2, 0.2), # 1 blink
'buzzer': (0.1, 0.1) # Short beep
},
'success': {
'led': (2, 0.1, 0.1), # 2 fast blinks
'buzzer': (0.2, 0.1) # Medium beep
},
'warning': {
'led': (2, 0.3, 0.3), # 2 slow blinks
'buzzer': (0.3, 0.2) # Longer beep
},
'error': {
'led': (3, 0.05, 0.05), # 3 very fast blinks
'buzzer': (0.2, 0.05) # Fast beeps
}
}
if alert_type not in patterns:
return jsonify({
'status': 'error',
'message': 'Invalid alert type. Use: info, success, warning, error'
}), 400
pattern = patterns[alert_type]
# Start LED blink
count, on_time, off_time = pattern['led']
led.blink(count, on_time, off_time)
# Play buzzer pattern
beep_duration, pause = pattern['buzzer']
buzzer_pattern = [(beep_duration, pause)] * count
buzzer.play_pattern(buzzer_pattern)
return jsonify({
'status': 'success',
'message': f'Alert ({alert_type}) triggered'
})
if __name__ == '__main__':
print("Starting LED and Buzzer Control API...")
print("API available at http://0.0.0.0:5000")
print("Documentation at http://0.0.0.0:5000/")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -0,0 +1,4 @@
Flask==2.3.0
flask-httpauth==4.8.0
flask-limiter==3.0.0
flask-cors==4.0.0

View File

@@ -0,0 +1,122 @@
#!/bin/bash
# Star Wars Theme Song - Buzzer Version
# Note: Buzzer can only be on/off, so we use timing patterns to create rhythm
# Volume is simulated at 10% by using very short pulse patterns (PWM-like effect)
BUZZER_PATH='/sys/class/leds/usr-buzzer/brightness'
DURATION=5 # Total duration in seconds
# Function to play a note with 10% volume effect
# Uses rapid on/off pulses to simulate lower volume
# $1 = note duration in hundredths of a second (e.g., 5 = 0.05s)
play_note() {
local note_duration=$1
# For 10% volume: 10ms on, 90ms off pattern
# This creates a quieter, pulsing effect
local cycles=$((note_duration * 2)) # Each cycle is ~50ms
for i in $(seq 1 $cycles); do
echo 1 | sudo tee $BUZZER_PATH > /dev/null 2>&1
sleep 0.005 # 5ms on (10% duty cycle)
echo 0 | sudo tee $BUZZER_PATH > /dev/null 2>&1
sleep 0.045 # 45ms off
done
}
# Function for silence/pause
pause() {
local duration=$1
sleep $duration
}
# Star Wars Theme Pattern
# Using timing to represent the iconic opening sequence
echo "Playing Star Wars Theme (5 seconds, 10% volume)..."
echo "=================================================="
echo ""
START_TIME=$(date +%s)
# Opening sequence (famous opening notes)
# Short-short-short-long pattern
play_note 2 # Very short
pause 0.05
play_note 2
pause 0.05
play_note 2
pause 0.1
play_note 4 # Longer
pause 0.1
play_note 6 # Even longer
pause 0.15
# Main theme rhythm - first phrase
play_note 3
pause 0.05
play_note 3
pause 0.05
play_note 3
pause 0.1
play_note 5
pause 0.1
play_note 4
pause 0.1
play_note 3
pause 0.1
play_note 5
pause 0.15
# Continue theme pattern - second phrase
play_note 4
pause 0.05
play_note 4
pause 0.05
play_note 4
pause 0.1
play_note 6
pause 0.1
play_note 5
pause 0.1
play_note 4
pause 0.1
play_note 6
pause 0.15
# Final sequence - third phrase
play_note 5
pause 0.05
play_note 5
pause 0.05
play_note 5
pause 0.1
play_note 8
pause 0.1
play_note 6
pause 0.1
play_note 5
pause 0.1
play_note 10
pause 0.2
# Fill remaining time to reach 5 seconds
CURRENT_TIME=$(date +%s)
ELAPSED=$((CURRENT_TIME - START_TIME))
REMAINING=$((DURATION - ELAPSED))
if [ $REMAINING -gt 0 ]; then
# Continue theme pattern for remaining time
while [ $(date +%s) -lt $((START_TIME + DURATION)) ]; do
play_note 4
pause 0.1
play_note 5
pause 0.1
play_note 4
pause 0.15
done
fi
# Ensure buzzer is off
echo 0 | sudo tee $BUZZER_PATH > /dev/null 2>&1
echo ""
echo "Star Wars theme complete!"

116
chromium-setup/test_buzzer.py Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Buzzer Test Script for reTerminal DM4
Tests various buzzer patterns and functions
"""
import subprocess
import time
import sys
BUZZER_PATH = '/sys/class/leds/usr-buzzer/brightness'
def buzzer_on():
"""Turn buzzer ON"""
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input='1', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def buzzer_off():
"""Turn buzzer OFF"""
subprocess.run(['sudo', 'tee', BUZZER_PATH],
input='0', text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
def beep(duration=0.2):
"""Play a single beep"""
buzzer_on()
time.sleep(duration)
buzzer_off()
def blink(count=3, on_time=0.1, off_time=0.1):
"""Blink buzzer multiple times"""
for _ in range(count):
buzzer_on()
time.sleep(on_time)
buzzer_off()
time.sleep(off_time)
def get_status():
"""Get current buzzer status"""
try:
result = subprocess.run(['cat', BUZZER_PATH],
capture_output=True, text=True, check=True)
return 'ON' if result.stdout.strip() in ['1', '255'] else 'OFF'
except:
return 'UNKNOWN'
def main():
print("=" * 50)
print(" reTerminal DM4 Buzzer Test Script (Python)")
print("=" * 50)
print()
# Test 1: Single beep
print("Test 1: Single beep (0.2s)")
beep(0.2)
time.sleep(0.5)
# Test 2: Double beep
print("Test 2: Double beep")
blink(2, 0.1, 0.1)
time.sleep(0.5)
# Test 3: Triple beep
print("Test 3: Triple beep")
blink(3, 0.1, 0.1)
time.sleep(0.5)
# Test 4: Long beep
print("Test 4: Long beep (0.5s)")
beep(0.5)
time.sleep(0.5)
# Test 5: Rapid beeps
print("Test 5: Rapid beeps (5x)")
blink(5, 0.05, 0.05)
time.sleep(0.5)
# Test 6: Slow beeps
print("Test 6: Slow beeps (3x)")
blink(3, 0.3, 0.3)
time.sleep(0.5)
# Test 7: Success pattern
print("Test 7: Success pattern (2 short)")
blink(2, 0.1, 0.1)
time.sleep(0.5)
# Test 8: Error pattern
print("Test 8: Error pattern (3 fast)")
blink(3, 0.05, 0.05)
time.sleep(0.5)
# Ensure buzzer is off
buzzer_off()
print()
print("=" * 50)
print(" Buzzer test complete!")
print("=" * 50)
print()
print(f"Current buzzer status: {get_status()}")
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
buzzer_off()
sys.exit(0)
except Exception as e:
print(f"\n\nError: {e}")
buzzer_off()
sys.exit(1)

82
chromium-setup/test_buzzer.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Buzzer Test Script for reTerminal DM4
# Tests various buzzer patterns and functions
BUZZER_PATH='/sys/class/leds/usr-buzzer/brightness'
echo "=========================================="
echo " reTerminal DM4 Buzzer Test Script"
echo "=========================================="
echo ""
# Function to play a beep
beep() {
local duration=${1:-0.2}
echo 1 | sudo tee $BUZZER_PATH > /dev/null 2>&1
sleep $duration
echo 0 | sudo tee $BUZZER_PATH > /dev/null 2>&1
}
# Function to blink buzzer
blink() {
local count=${1:-3}
local on_time=${2:-0.1}
local off_time=${3:-0.1}
for i in $(seq 1 $count); do
echo 1 | sudo tee $BUZZER_PATH > /dev/null 2>&1
sleep $on_time
echo 0 | sudo tee $BUZZER_PATH > /dev/null 2>&1
sleep $off_time
done
}
# Test 1: Single beep
echo "Test 1: Single beep (0.2s)"
beep 0.2
sleep 0.5
# Test 2: Double beep
echo "Test 2: Double beep"
blink 2 0.1 0.1
sleep 0.5
# Test 3: Triple beep
echo "Test 3: Triple beep"
blink 3 0.1 0.1
sleep 0.5
# Test 4: Long beep
echo "Test 4: Long beep (0.5s)"
beep 0.5
sleep 0.5
# Test 5: Rapid beeps
echo "Test 5: Rapid beeps (5x)"
blink 5 0.05 0.05
sleep 0.5
# Test 6: Slow beeps
echo "Test 6: Slow beeps (3x)"
blink 3 0.3 0.3
sleep 0.5
# Test 7: Success pattern (2 short)
echo "Test 7: Success pattern"
blink 2 0.1 0.1
sleep 0.5
# Test 8: Error pattern (3 fast)
echo "Test 8: Error pattern"
blink 3 0.05 0.05
sleep 0.5
# Ensure buzzer is off
echo 0 | sudo tee $BUZZER_PATH > /dev/null 2>&1
echo ""
echo "=========================================="
echo " Buzzer test complete!"
echo "=========================================="
echo ""
echo "Current buzzer status: $(cat $BUZZER_PATH) (0=OFF, 1=ON)"

60
chromium-setup/test_led.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/bin/bash
# LED Test Script for reTerminal DM4
LED_PATH='/sys/class/leds/usr-led/brightness'
echo "Testing reTerminal DM4 LED"
echo "=========================="
echo ""
# Test 1: Turn ON
echo "Test 1: Turning LED ON..."
echo 1 | sudo tee $LED_PATH > /dev/null
sleep 1
echo "LED should be ON now"
echo ""
# Test 2: Turn OFF
echo "Test 2: Turning LED OFF..."
echo 0 | sudo tee $LED_PATH > /dev/null
sleep 1
echo "LED should be OFF now"
echo ""
# Test 3: Blink pattern
echo "Test 3: Blinking LED (5 times)..."
for i in {1..5}; do
echo 1 | sudo tee $LED_PATH > /dev/null
sleep 0.2
echo 0 | sudo tee $LED_PATH > /dev/null
sleep 0.2
done
echo ""
# Test 4: Fast blink
echo "Test 4: Fast blink (10 times)..."
for i in {1..10}; do
echo 1 | sudo tee $LED_PATH > /dev/null
sleep 0.1
echo 0 | sudo tee $LED_PATH > /dev/null
sleep 0.1
done
echo ""
# Test 5: Slow blink
echo "Test 5: Slow blink (3 times)..."
for i in {1..3}; do
echo 1 | sudo tee $LED_PATH > /dev/null
sleep 0.5
echo 0 | sudo tee $LED_PATH > /dev/null
sleep 0.5
done
echo ""
# Ensure LED is off
echo 0 | sudo tee $LED_PATH > /dev/null
echo "LED test complete!"
echo ""
echo "Current LED status:"
cat $LED_PATH && echo " (0 = OFF, 1 = ON)"