Old SandTable project

This commit is contained in:
Your Name
2026-08-08 16:11:57 +03:00
commit 59c9f942f0
68 changed files with 2686 additions and 0 deletions
+436
View File
@@ -0,0 +1,436 @@
import colorsys
from xml.dom import ValidationErr
from django.shortcuts import render, redirect
from .models import *
from .forms import *
import thr2png, os, psutil, writeSerial, playControl
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
# -----------------------------------------------------------------------------
# LED handling
#
# The device no longer stores an LED track itself - every speed/intensity/
# saturation/color change has to rebuild the full track and resend it via
# writeSerial.setLedTrack(). What mode is "currently active" is tracked in
# the RGBW row (id=1) itself via led_track:
#
# LED_TRACK_SOLID (0) - use the stored r/g/b/w directly, as a one-color track
# LED_TRACK_FADE (5) - cycle through FADE_COLORS (matches what fadeButton
# used to trigger on the old firmware)
#
# I only wired up these two because they're the only two entry points that
# exist in this file (color() and the fadeButton branch below). If there
# were other led_track presets (1-4) driving different sequences on the old
# firmware, tell me what they were and I'll add them.
# -----------------------------------------------------------------------------
LED_TRACK_SOLID = 0
LED_TRACK_FADE = 5
FADE_COLORS = [
(255, 0, 0, 0), # Red
(255, 255, 0, 0), # Yellow
(0, 255, 0, 0), # Green
(0, 255, 255, 0), # Cyan
(0, 0, 255, 0), # Blue
]
def _scale_color(color, intensity_pct, saturation_pct=None):
"""Apply intensity (0-100, brightness) and, for hued preset colors,
saturation (0-100) to an (r, g, b, w) tuple. The white channel only
gets intensity applied - there's no meaningful "saturation" of white."""
# Defensive: Django doesn't coerce a model field's type on a plain
# attribute assignment (only on a DB round trip), so values coming
# straight from request.POST/GET can still be strings here - cast
# explicitly rather than letting the arithmetic below blow up. Also
# guard against None, since the color-picker only ever sets r/g/b OR w
# at once, so the other channel(s) can be unset on a fresh row.
r, g, b, w = (int(c) if c is not None else 0 for c in color)
if saturation_pct is not None:
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
s *= max(0.0, min(saturation_pct, 100.0)) / 100.0
r, g, b = colorsys.hsv_to_rgb(h, s, v)
r, g, b = r * 255, g * 255, b * 255
scale = max(0.0, min(intensity_pct, 100.0)) / 100.0
return (
round(r * scale),
round(g * scale),
round(b * scale),
round(w * scale),
)
def _sendCurrentLed(led_data):
"""Rebuild whatever LED mode is currently active from its stored
settings and resend the full track."""
speed = int(led_data.led_speed or writeSerial.MIN_TRACK_SPEED)
intensity = float(led_data.led_intensity) if led_data.led_intensity is not None else 100.0
saturation = float(led_data.led_saturation) if led_data.led_saturation is not None else 100.0
if led_data.led_track == LED_TRACK_FADE:
colors = [_scale_color(c, intensity, saturation) for c in FADE_COLORS]
else:
# Solid color: saturation doesn't really mean anything for an
# explicitly hand-picked RGB value, so only intensity (brightness)
# is applied here. Speed is also irrelevant for a single color.
colors = [_scale_color((led_data.r, led_data.g, led_data.b, led_data.w), intensity)]
writeSerial.setLedTrack(colors, speed, wait_ack=False)
def index(request):
status = playControl.get_status()
playing = status['status'] in (playControl.STATUS_PLAYING, playControl.STATUS_PAUSED)
queue = Queue.objects.all()
track_name = ""
if len(queue) > 0:
track = queue[0].file
track_name = track.split('/')[-1].replace(".thr", '')
# Erase button
if(request.GET.get('eraseButton')):
playControl.stop_player()
return redirect('sandtable:index')
# Button to start playing
if(request.GET.get('playButton')):
status = playControl.get_status()
if status['status'] == playControl.STATUS_IDLE:
os.system("/bin/python /home/pi/SandTable/PlayQueue.py &")
elif status['status'] == playControl.STATUS_PLAYING:
playControl.set_command(playControl.COMMAND_PAUSE)
elif status['status'] == playControl.STATUS_PAUSED:
playControl.set_command(playControl.COMMAND_PLAY)
return redirect('sandtable:index')
# Next button
if(request.GET.get('nextButton')):
if playControl.get_status()['status'] == playControl.STATUS_IDLE:
print("Not running")
else:
playControl.set_command(playControl.COMMAND_NEXT)
print("next")
return redirect('sandtable:index')
context = { 'playing': playing, 'track_name': track_name }
return render(request, 'sandtable/index.html', context)
def tracks(request):
tracks = Tracks.objects.order_by('uploaded_at')
context = { 'tracks': tracks }
return render(request, 'sandtable/tracks.html', context)
def track(request, track_id):
track = Tracks.objects.get(id=track_id)
# Button to delete track
if(request.GET.get('deleteTrack')):
track.delete()
return redirect('sandtable:tracks')
# Add track to queue
elif request.method == 'POST':
form = AddToQueueForm(request.POST)
if form.is_valid():
new_queue_track = form.save(commit=False)
new_queue_track.file = track.file
new_queue_track.track_length = track.track_length
new_queue_track.pic = track.pic
new_queue_track.save()
# Start playing
if playControl.get_status()['status'] == playControl.STATUS_IDLE:
os.system("/bin/python /home/pi/SandTable/PlayQueue.py &")
return redirect('sandtable:tracks')
else:
form = UploadFileForm()
context = { 'track': track, 'form': form }
return render(request, 'sandtable/track.html', context)
def queue(request):
queue = Queue.objects.all()
context = { 'queue': queue }
# If clearQueue button is pressed then delete all entries in queue table
if(request.GET.get('clearQueue')):
Queue.objects.all().delete()
return redirect('sandtable:queue')
return render(request, 'sandtable/queue.html', context)
def uploadTracks(request):
if request.method == 'POST':
form = UploadFileForm(request.POST, request.FILES)
if form.is_valid():
new_track = form.save(commit=False)
# Get file name
file_name = request.FILES['file'].name
# If file exists then raise error
if os.path.isfile('/home/pi/SandTable/media/tracks/' + file_name):
raise ValidationErr('File already exists')
# If file is not .thr then raise error
if '.thr' not in file_name:
raise ValidationErr('Not a thr file')
name = file_name.split('.')
new_track.name = name[0]
new_track.save()
# Generate image from .thr and return track length
new_track.track_length = thr2png.drawFile('/home/pi/SandTable/media/tracks/' + file_name)
new_track.pic = 'tracks/' + name[0] + '.png'
new_track.save()
return redirect('sandtable:tracks')
else:
form = UploadFileForm()
context = { 'form': form }
return render(request, 'sandtable/uploadTracks.html', context)
def settings(request):
settings_data = Settings.objects.get(id=0)
motor_speed = settings_data.motor_speed
led_data = RGBW.objects.get(id=1)
led_speed = led_data.led_speed
led_intensity = led_data.led_intensity
led_saturation = led_data.led_saturation
# Button to home motors
if(request.GET.get('homeButton')):
playControl.stop_player()
writeSerial.home(wait_ack=False)
# No more r/theta reset here - the firmware tracks position itself
# now, and calibration offsets are stored firmware-side too (see
# COMMAND_SET_OFFSET), so there's nothing left to reapply after a
# home like the old code used to do with COMMAND_M2_STEP.
return redirect('sandtable:settings')
# Button to calibrate
if(request.GET.get('calibrateButton')):
return redirect('sandtable:calibrate')
# Button to stop motors
elif(request.GET.get('stopButton')):
playControl.stop_player()
# ASSUMPTION: mapped the old immediate-stop button to
# COMMAND_DISABLE_MOTORS since there's no direct "stop" in the new
# enum. This likely de-energizes the steppers rather than just
# pausing them, so you'll probably need to re-home before playing
# again - confirm this is the behavior you want.
writeSerial.disableMotors(wait_ack=False)
return redirect('sandtable:settings')
# Button to power down the pi
elif(request.GET.get('powerButton')):
playControl.stop_player()
os.system("sudo shutdown -h")
return redirect('sandtable:settings')
# Button to reset core 1
elif(request.GET.get('resetButton')):
# TODO: no equivalent to the old COMMAND_RESET in the new
# commands_e enum. If resetting core 1 is still needed, this needs
# a new firmware command.
print("resetButton: not implemented under the new protocol yet")
return redirect('sandtable:settings')
# Button to do led fade
elif(request.GET.get('fadeButton')):
led_data.led_track = LED_TRACK_FADE
led_data.save()
_sendCurrentLed(led_data)
return redirect('sandtable:settings')
# Motor speed slider
elif(request.GET.get('motorSpeedSlider')):
value = request.GET.get('motorSpeedSlider')
settings_data.motor_speed = int(value)
settings_data.save()
writeSerial.setMotorSpeed(int(value), wait_ack=False)
return redirect('sandtable:settings')
# Led speed slider
elif(request.GET.get('ledSpeedSlider')):
value = request.GET.get('ledSpeedSlider')
led_data.led_speed = int(value)
led_data.save()
_sendCurrentLed(led_data)
return redirect('sandtable:settings')
# Led intensity slider
elif(request.GET.get('ledIntensitySlider')):
value = request.GET.get('ledIntensitySlider')
led_data.led_intensity = int(value)
led_data.save()
_sendCurrentLed(led_data)
return redirect('sandtable:settings')
# Led saturation slider
elif(request.GET.get('ledSaturationSlider')):
value = request.GET.get('ledSaturationSlider')
led_data.led_saturation = int(value)
led_data.save()
_sendCurrentLed(led_data)
return redirect('sandtable:settings')
context = { 'motorSpeed': motor_speed, 'ledSpeed': led_speed, 'ledIntensity': led_intensity, 'ledSaturation': led_saturation }
return render(request, 'sandtable/settings.html', context)
def calibrate(request):
settings_data = Settings.objects.get(id=0)
# Calibration is now handled firmware-side (COMMAND_SET_OFFSET adds to
# a stored per-motor step offset, COMMAND_RESET_OFFSET zeros it out).
# These buttons only ever adjusted motor 2 in the old code (M2_STEP),
# so that's preserved here - m1's delta is always 0. `settings_data.
# calibrate` is kept purely as a local display counter mirroring what's
# been sent; the firmware is the actual source of truth for the offset
# itself.
if(request.GET.get('+1')):
writeSerial.setOffset(0, 1, wait_ack=False)
settings_data.calibrate += 1
settings_data.save()
return redirect('sandtable:calibrate')
if(request.GET.get('-1')):
writeSerial.setOffset(0, -1, wait_ack=False)
settings_data.calibrate -= 1
settings_data.save()
return redirect('sandtable:calibrate')
if(request.GET.get('+10')):
writeSerial.setOffset(0, 10, wait_ack=False)
settings_data.calibrate += 10
settings_data.save()
return redirect('sandtable:calibrate')
if(request.GET.get('-10')):
writeSerial.setOffset(0, -10, wait_ack=False)
settings_data.calibrate -= 10
settings_data.save()
return redirect('sandtable:calibrate')
# New: full reset of the firmware's calibration offset. There wasn't a
# button for this before (COMMAND_RESET_OFFSET didn't exist yet) - I've
# added the handler so it's ready, but you'll need to add the actual
# button/link in calibrate.html to trigger it (?resetButton=1).
if(request.GET.get('resetButton')):
writeSerial.resetOffset(wait_ack=False)
settings_data.calibrate = 0
settings_data.save()
return redirect('sandtable:calibrate')
return render(request, 'sandtable/calibrate.html')
@csrf_exempt
def color(request):
# If entry in the table does not exist, then create new with id=1
if not RGBW.objects.filter(id=1).exists():
if request.method == 'POST':
newcolor = RGBW()
newcolor.id = 1
# If POST contains RGB or WHITE
if 'r' in request.POST:
newcolor.r = int(request.POST.get('r'))
newcolor.g = int(request.POST.get('g'))
newcolor.b = int(request.POST.get('b'))
elif 'w' in request.POST:
newcolor.w = int(request.POST.get('w'))
# Don't know why I thought that I needed this
newcolor.changed = True
newcolor.led_track = LED_TRACK_SOLID
newcolor.save()
_sendCurrentLed(newcolor)
return HttpResponse('')
else:
context = { 'colors': {'r':0, 'g':0, 'b':0, 'w':0} }
return render(request, 'sandtable/colorPicker.html', context)
else:
colors = RGBW.objects.get(id=1)
if request.method == 'POST':
# If POST contains RGB or WHITE
if 'r' in request.POST:
colors.r = int(request.POST.get('r'))
colors.g = int(request.POST.get('g'))
colors.b = int(request.POST.get('b'))
elif 'w' in request.POST:
colors.w = int(request.POST.get('w'))
# Don't know why I thought that I needed this
colors.changed = True
colors.led_track = LED_TRACK_SOLID
colors.save()
_sendCurrentLed(colors)
return HttpResponse('')
else:
context = { 'colors': colors }
return render(request, 'sandtable/colorPicker.html', context)
# Find if process is running and killit if needed
# NOTE: no longer used by the play/pause/stop/skip buttons above (they use
# playControl instead) - left in place in case anything else in the project
# still calls it.
def checkProcesses(name, kill=False):
for proc in psutil.process_iter(attrs=["pid", "name", "cmdline"]):
try:
# Check if process name contains the given name string.
if "python" in proc.name():
for cmd in proc.cmdline():
if name.lower() in cmd.lower():
# Kill the process if kill=True
if kill:
proc.kill()
return proc.pid
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
return False