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
+51
View File
@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
env/
ENV/
# Environment / secrets
.env
.env.*
!.env.example
# Django
*.log
db.sqlite3
db.sqlite3-journal
media/
staticfiles/
# Python tooling
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache/
# IDEs / editors
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Build / packaging
build/
dist/
*.egg-info/
.eggs/
# Jupyter
.ipynb_checkpoints/
# Local development
*.local
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

+50
View File
@@ -0,0 +1,50 @@
import sqlite3
import os
import playTrack, playControl
DB_PATH = '/home/pi/SandTable/db.sqlite3'
def PlayQ():
connection = sqlite3.connect(DB_PATH)
cursor = connection.cursor()
playControl.init_control_table()
playControl.set_command(playControl.COMMAND_PLAY)
playControl.set_status(playControl.STATUS_PLAYING, pid=os.getpid())
# Reading the queue table
rows = cursor.execute("SELECT id, file, track_length, pic FROM sandtable_queue").fetchall()
# Let the first playTrack() call ask the device for its position; after
# that we just keep tracking it in memory as moves complete.
coords = None
# While there are entries in the table
while rows:
file = rows[0][1]
# Playing the track in the first index
coords = playTrack.playTrack(file, coords=coords)
# A stop request means playTrack() bailed out mid-file - leave this
# track in the queue (so it picks back up / can be replayed) rather
# than deleting it like a normally-finished or skipped ("next")
# track.
if playControl.get_command() == playControl.COMMAND_STOP:
break
# Deleting the first track that was just played
cursor.execute("DELETE FROM sandtable_queue WHERE id = ?", (rows[0][0],))
connection.commit()
# Reading the new table
rows = cursor.execute("SELECT id, file, track_length, pic FROM sandtable_queue").fetchall()
playControl.set_status(playControl.STATUS_IDLE, pid=0)
connection.close()
if __name__ == "__main__":
PlayQ()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+108
View File
@@ -0,0 +1,108 @@
import math
# Distance * length_of_the_arms to get units
# 0.03 seems a good distance
def checkDistance(coord1, coord2, distance):
# Check if the line point are in the outer edge
if coord1[1] > 0.97 and coord2[1] > 0.97:
return False
# Changing polar coords to X and Y
x1, y1 = polarToXY(coord1[0], coord1[1])
x2, y2 = polarToXY(coord2[0], coord2[1])
# Getting the change of coords
deltaX = x2 - x1
deltaY = y2 - y1
# Getting the distance between the points
d = math.sqrt(deltaX**2 + deltaY**2)
# If distance is over twice the wanted distance
if d > distance * 2:
return True
else:
return False
# Change from polar to cartesian
def polarToXY(theta, r):
x = r * math.cos(theta)
y = r * math.sin(theta)
return x, y
# Changes from cartesian to polar
def xyToPolar(x, y):
r = round(math.sqrt(x**2 + y**2), 5)
th = round(math.atan2(y, x), 5)
return th, r
def divideBy(coord1, coord2, distance):
# Changing polar coords to X and Y
x1, y1 = polarToXY(coord1[0], coord1[1])
x2, y2 = polarToXY(coord2[0], coord2[1])
# Getting the change of coords
deltaX = x2 - x1
deltaY = y2 - y1
# Getting the distance between the points
d = math.sqrt(deltaX**2 + deltaY**2)
# Calculating how many points it divides to
num_points = int(d / distance)
# If number of point is 0 then exit
if num_points == 0:
return False
# Calculating the distance between points
r = d / num_points
# Getting the angle from point A to point B
theta = math.atan2(deltaY, deltaX)
# Starting the coord list with point A
new_coords = [[x1, y1]]
# Calculating all the points between
for i in range(0, num_points):
x_new = new_coords[-1][0] + r * math.cos(theta)
y_new = new_coords[-1][1] + r * math.sin(theta)
new_coords.append([x_new, y_new])
# And adding the point B
new_coords.append([x2, y2])
coords_polar = []
# Changing the points to polar
for i in new_coords:
th, r_new = xyToPolar(i[0], i[1])
coords_polar.append([th, r_new])
if th == coord2[0]:
coords_polar.append(coord2)
break
return coords_polar
# Writing list to a file to test
def writeToFile(coords_polar):
with open('test.thr', 'a') as f:
for i in coords_polar:
f.write(str(i[0]) + ' ' + str(i[1]) + '\n')
if __name__ == '__main__':
coords = divideBy([math.pi /2, 1], [0, 1], 0.01)
writeToFile(coords)
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Shared play/pause/stop/skip control for the sand table player.
Replaces the old OS-signal-based approach (SIGUSR1/SIGUSR2/SIGTERM caught
by a handler with module-global flags, process discovery via psutil
scanning cmdlines for "PlayQueue.py"). That worked but had some real
downsides:
- views.py's "playing" flag was a plain Python global, so with more than
one web worker process it could disagree with reality.
- "is a player running" was answered by grepping every process's cmdline
for a string, which is a little fragile.
- Signals give no visibility into *what* state the player is actually in
(playing/paused/idle) or which track - only views.py's own guess.
This module stores that state in a single row of a small SQLite table
instead - the same DB the rest of the project already uses. The running
player (playTrack.py) polls `get_command()` between points; the web app
(views.py) writes to it with `set_command()` and reads real status with
`get_status()`. No signals, no process-name matching for control - psutil
is only still used as a safety net to force-kill a stuck process by its
recorded PID.
"""
import sqlite3
import psutil
DB_PATH = "/home/pi/SandTable/db.sqlite3"
TABLE = "sandtable_control"
# status: what the player is actually doing right now
STATUS_IDLE = "idle"
STATUS_PLAYING = "playing"
STATUS_PAUSED = "paused"
# command: what the player has been asked to do - it's expected to notice
# and react next time it checks
COMMAND_PLAY = "play"
COMMAND_PAUSE = "pause"
COMMAND_STOP = "stop"
COMMAND_NEXT = "next"
def _connect():
conn = sqlite3.connect(DB_PATH)
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id INTEGER PRIMARY KEY CHECK (id = 0),
command TEXT NOT NULL DEFAULT '{COMMAND_PLAY}',
status TEXT NOT NULL DEFAULT '{STATUS_IDLE}',
current_track TEXT,
pid INTEGER
)
"""
)
conn.execute(
f"INSERT OR IGNORE INTO {TABLE} (id, command, status) VALUES (0, ?, ?)",
(COMMAND_PLAY, STATUS_IDLE),
)
return conn
def init_control_table():
conn = _connect()
conn.commit()
conn.close()
def set_command(command):
conn = _connect()
conn.execute(f"UPDATE {TABLE} SET command = ? WHERE id = 0", (command,))
conn.commit()
conn.close()
def get_command():
conn = _connect()
row = conn.execute(f"SELECT command FROM {TABLE} WHERE id = 0").fetchone()
conn.close()
return row[0] if row else COMMAND_PLAY
def set_status(status, current_track=None, pid=None):
"""current_track/pid are only updated when explicitly passed - pass
current_track='' or pid=0 if you actually want to clear them."""
conn = _connect()
if current_track is not None:
conn.execute(f"UPDATE {TABLE} SET current_track = ? WHERE id = 0", (current_track,))
if pid is not None:
conn.execute(f"UPDATE {TABLE} SET pid = ? WHERE id = 0", (pid,))
conn.execute(f"UPDATE {TABLE} SET status = ? WHERE id = 0", (status,))
conn.commit()
conn.close()
def get_status():
conn = _connect()
row = conn.execute(f"SELECT status, current_track, pid FROM {TABLE} WHERE id = 0").fetchone()
conn.close()
if row is None:
return {"status": STATUS_IDLE, "current_track": None, "pid": None}
return {"status": row[0], "current_track": row[1], "pid": row[2]}
def stop_player(hard=True):
"""Ask the running player to stop, and (by default) also force-kill its
process by the PID it registered - useful right before a manual
home/stop/shutdown, where you don't want to wait for the cooperative
stop to be noticed. Returns True if a live player was found."""
status = get_status()
pid = status.get("pid")
found = False
if pid and psutil.pid_exists(pid):
found = True
if hard:
try:
psutil.Process(pid).terminate()
except psutil.NoSuchProcess:
pass
set_command(COMMAND_STOP)
set_status(STATUS_IDLE, pid=0)
return found
+92
View File
@@ -0,0 +1,92 @@
import sys, time
import divideCoords, writeSerial, playControl
def playTrack(track_name, coords=None):
"""Play a single .thr file, point by point.
`coords` is the starting (theta, r). If not given, we ask the device
for its current position via writeSerial.getPolar() - the firmware
tracks this itself now, so there's no need to keep a copy in the DB
anymore (and nothing here writes position to the DB at all).
Cooperatively checks playControl's command between points, so this can
be paused/stopped/skipped from the web UI while it's running. Returns
the ending (theta, r).
"""
path = "/home/pi/SandTable/media/" + track_name
if coords is None:
coords = writeSerial.getPolar()
if coords is None:
print("Couldn't read starting position from the device, assuming (0, 0)")
coords = [0.0, 0.0]
else:
coords = list(coords)
playControl.set_status(playControl.STATUS_PLAYING, current_track=track_name)
with open(path) as f:
for line in f:
# Cooperative pause: block here (not mid-move) until resumed
while playControl.get_command() == playControl.COMMAND_PAUSE:
playControl.set_status(playControl.STATUS_PAUSED, current_track=track_name)
time.sleep(0.1)
command = playControl.get_command()
if command == playControl.COMMAND_STOP:
playControl.set_status(playControl.STATUS_IDLE)
return coords
if command == playControl.COMMAND_NEXT:
playControl.set_command(playControl.COMMAND_PLAY) # consume the one-shot skip
return coords
playControl.set_status(playControl.STATUS_PLAYING, current_track=track_name)
# If line is comment or line does not contain anything then continue to next line
if '#' in line or len(line) < 4 or "/" in line:
continue
# Getting theta and rho from the line and changing them to float values
th_str, r_str = line.split(' ')
th = float(th_str)
r = float(r_str)
if r > 1.0:
print("R Too big")
# If distance between points is too far (0.03 units), interpolate
if divideCoords.checkDistance(coords, [th, r], 0.01):
split_coords = divideCoords.divideBy(coords, [th, r], 0.03)
if split_coords:
for i in split_coords:
if not writeSerial.sendPolarWithRetry(i[0], i[1]):
print(f"Failed to move to intermediate point {i}, aborting track")
return coords
coords = [i[0], i[1]]
# Writing point to the controller and waiting for the move to
# finish (blocks until COMMAND_POLAR's completion ACK arrives)
if not writeSerial.sendPolarWithRetry(th, r):
print(f"Failed to move to ({th}, {r}), aborting track")
return coords
coords = [th, r]
# Delay just in case
time.sleep(0.1)
return coords
if __name__ == "__main__":
if len(sys.argv) > 1:
name = sys.argv[1]
playControl.init_control_table()
playTrack(name)
playControl.set_status(playControl.STATUS_IDLE)
else:
print("Invalid arguments")
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for project project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
application = get_asgi_application()
+133
View File
@@ -0,0 +1,133 @@
"""
Django settings for project project.
Generated by 'django-admin startproject' using Django 4.0.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-yo3-am^btpfn7c*ia%8p5nezcqox#_0if9tj4$c6v7c%gujy42'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['127.0.0.1', '192.168.1.242', '192.168.1.242:8000']
STATICFILES_DIRS = [
str(BASE_DIR) + '/sandtable/static',
]
# Application definition
INSTALLED_APPS = [
#my apps
'sandtable',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'project.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'project.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+29
View File
@@ -0,0 +1,29 @@
"""project URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('sandtable.urls'))
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for project project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
application = get_wsgi_application()
+14
View File
@@ -0,0 +1,14 @@
# Kinetic sand art table (Old web code)
<img src="/Pictures/1674052924095.jpg" width="400"><img src="/Pictures/1674053047425.jpg" width="400">
This is my kinetic sand art coffee table project I've been working on. The idea is derived from Sisyphus industries product and motivation is from [grammesm](https://alwaystinkering.com/2020/01/14/diy-kinetic-sand-art-table/) which was inspired by [Rob Dobsons](https://robdobson.com/2018/08/a-new-sandbot/) version.
<img src="/Pictures/image_2023-01-18_212445061.png">
The table is running a Django server on a Raspberry pi 4 B 4GB which communicates to arduino nano (RP2040 version) through serial. The arduino is sitting on a [Keyestudio CNC Shield V4 For NANO](https://www.keyestudio.com/products/keyestudio-a4988-3d-printer-stepper-motor-driver-cnc-shield-v4-for-nano) which houses two TMC2208 stepper motor drivers.
<img src="/Pictures/1674062162257.jpg" width="480">
The user interface is very simple and basic due to my lack of UI designing skills.
<img src="/Pictures/1674063564681.jpg">
+2
View File
@@ -0,0 +1,2 @@
default_app_config = 'sandtable.apps.SandtableConfig'
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class SandtableConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'sandtable'
def ready(self):
# Startup code here
pass
+14
View File
@@ -0,0 +1,14 @@
from django import forms
from .models import *
class UploadFileForm(forms.ModelForm):
class Meta:
model = Tracks
fields = ('file', )
class AddToQueueForm(forms.ModelForm):
class Meta:
model = Queue
fields = ()
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 4.0 on 2022-01-23 13:37
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Queue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.CharField(max_length=200)),
('track_length', models.IntegerField()),
('pic', models.CharField(max_length=200)),
],
),
migrations.CreateModel(
name='Tracks',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='tracks/')),
('name', models.CharField(max_length=200)),
('pic', models.ImageField(upload_to='')),
('track_length', models.IntegerField(default=0)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
],
),
]
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 4.0 on 2022-01-30 09:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sandtable', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='RGBW',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('r', models.IntegerField(default=0)),
('g', models.IntegerField(default=0)),
('b', models.IntegerField(default=0)),
('w', models.IntegerField(default=0)),
('led_track', models.CharField(default='None', max_length=100)),
('changed', models.BooleanField(default=False)),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.0 on 2022-02-13 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sandtable', '0002_rgbw'),
]
operations = [
migrations.CreateModel(
name='Settings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('motor_speed', models.IntegerField(default=0)),
],
),
migrations.AddField(
model_name='rgbw',
name='led_speed',
field=models.IntegerField(default=100),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 4.0 on 2022-10-14 20:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sandtable', '0003_settings_rgbw_led_speed'),
]
operations = [
migrations.AddField(
model_name='rgbw',
name='led_intensity',
field=models.IntegerField(default=100),
),
migrations.AddField(
model_name='rgbw',
name='led_saturation',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='rgbw',
name='led_speed',
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name='settings',
name='motor_speed',
field=models.IntegerField(default=1),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.0 on 2022-10-16 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sandtable', '0004_rgbw_led_intensity_rgbw_led_saturation_and_more'),
]
operations = [
migrations.AddField(
model_name='settings',
name='r',
field=models.FloatField(default=0.0),
),
migrations.AddField(
model_name='settings',
name='theta',
field=models.FloatField(default=0.0),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.3 on 2022-12-01 21:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sandtable', '0005_settings_r_settings_theta'),
]
operations = [
migrations.AddField(
model_name='settings',
name='calibrate',
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-09-06 19:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sandtable', '0006_settings_calibrate'),
]
operations = [
migrations.AlterField(
model_name='rgbw',
name='led_track',
field=models.IntegerField(default=0),
),
]
View File
+38
View File
@@ -0,0 +1,38 @@
from django.db import models
# Creating track entries
class Tracks(models.Model):
file = models.FileField(upload_to='tracks/')
name = models.CharField(max_length=200)
pic = models.ImageField()
track_length = models.IntegerField(default=0)
uploaded_at = models.DateTimeField(auto_now_add=True)
# Creating entries for the queue
class Queue(models.Model):
file = models.CharField(max_length=200)
track_length = models.IntegerField()
pic = models.CharField(max_length=200)
# Putting LED strip values to database
class RGBW(models.Model):
r = models.IntegerField(default=0)
g = models.IntegerField(default=0)
b = models.IntegerField(default=0)
w = models.IntegerField(default=0)
led_track = models.IntegerField(default=0)
changed = models.BooleanField(default=False)
led_speed = models.IntegerField(default=1)
led_intensity = models.IntegerField(default=100)
led_saturation = models.IntegerField(default=0)
# Settings
class Settings(models.Model):
motor_speed = models.IntegerField(default=1)
r = models.FloatField(default=0.0)
theta = models.FloatField(default=0.0)
calibrate = models.IntegerField(default=0)
+323
View File
@@ -0,0 +1,323 @@
:root {
font-size: 16px;
font-family: 'Open Sans';
--text-primary: #b6b6b6;
--text-secondary: #ececec;
--bg-primary: #121212;
--bg-secondary: #222222;
}
body {
background-color: var(--bg-primary);
margin: 0;
padding: 0;
height: 100vh;
}
.header{
position: fixed;
width: 100vw;
top: 0;
height: 8rem;
background-color: var(--bg-secondary);
}
.center {
text-align: center;
color: var(--text-secondary);
display: flex;
flex-direction: column;
justify-content: center;
}
.center a {
font-size: 50px;
}
/* Navigation bar */
.nav {
width: 100%;
height: 8rem;
position: fixed;
bottom: 0;
background-color: var(--bg-secondary);
}
ul.nav-list {
text-align: center;
display: flex;
justify-content: center;
margin-left: -50px;
margin-top: 0px;
height: 95%;
list-style-type: none;
align-items: center;
}
ul.nav-list li {
width: 25%;
padding-top: 1rem;
}
ul.nav-list .icon {
font-size: 70px;
color: var(--text-primary);
}
/* Index page */
main.index {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 85%;
}
#canvas-div {
visibility: visible;
height: 0;
padding-bottom: 100%;
width: 90%;
max-width: 1000px;
margin: auto;
margin-bottom: 0;
margin-top: 8rem;
}
.track-playing {
font-size: 40px;
text-align: center;
color: var(--text-primary);
}
.track-playing p {
margin: 0;
}
.media {
display: flex;
justify-content: space-evenly;
text-align: center;
align-items: center;
}
.media a {
width: 33%;
color: var(--text-primary);
}
.media .icon {
font-size: 70px;
}
.media #play .icon {
font-size: 200px;
}
.pause {
display: none;
}
/* Tracks page */
main.tracks {
height: 100%;
}
.header-list {
height: 100%;
display: flex;
justify-content: space-evenly;
align-items: center;
list-style-type: none;
margin: 0;
padding: 0;
}
.header-list li {
text-align: center;
}
.header-list a {
color: var(--text-secondary);
text-decoration: none;
font-size: 50px;
}
.track-list-div {
padding-top: 8rem;
padding-bottom: 12rem;
}
ul.track-list {
text-align: center;
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
margin-top: 0px;
list-style-type: none;
color: var(--text-primary);
}
ul.track-list li {
display: flex;
flex-direction: column;
justify-content: flex-start;
width: 50%;
}
ul.track-list a {
margin-left: 15%;
margin-right: 15%;
}
ul.track-list .list-pic {
align-self: center;
width: 100%;
}
ul.track-list .track-name {
color: var(--text-primary);
text-decoration: none;
font-size: 30px;
}
.upload-div {
position: fixed;
bottom: 10rem;
width: 100vw;
text-align: center;
}
.upload-div a {
color: var(--text-secondary);
background-color: var(--bg-primary);
text-decoration: none;
font-size: 30px;
}
.empty {
text-align: center;
font-size: 20px;
}
/* Upload page */
main.upload {
color: var(--text-primary);
text-decoration: none;
}
/* Queue page */
main.queue {
height: 85%;
}
.queue-list-div {
height: 75vh;
}
.queue-list {
display: flex;
flex-direction: column;
justify-content: flex-start;
list-style-type: decimal;
color: var(--text-primary);
font-size: 20px;
}
ul.queue-list li {
display: flex;
flex-direction: column;
justify-content: flex-start;
width: 50%;
}
.queue-list a {
margin-left: 30px;
color: var(--text-primary);
text-decoration: none;
font-size: 30px;
}
/* Track page */
.track-info {
margin-top: 8rem;
width: 98vw;
}
.track-info img {
width: 100%;
}
/* Color-picker div */
#picker {
width: 100%;
max-width: 1000px;
margin: auto;
margin-bottom: 0;
margin-top: 0;
position: absolute;
}
#picker .IroColorPicker {
margin: 20px;
margin-bottom: 20px;
}
div#rgbw {
height: 100vw;
}
div#rgbw ul {
padding-inline-start: 0;
height: 95%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
list-style-type: none;
align-content: space-between;
}
div#rgbw li {
text-align: start;
margin: 0;
width: 40%;
}
div#rgbw li.right {
text-align: end;
padding-inline-end: 5px;
}
.dot {
height: 5rem;
width: 5rem;
margin-left: 0;
margin-right: 0;
border-radius: 50%;
display: inline-block;
font-size: 20px;
color: var(--text-primary);
text-align: center;
}
.dot p {
font-size: 35px;
margin-top: 0px;
}
.dot.red {
background-color: red;
}
.dot.green {
background-color: green;
}
.dot.blue {
background-color: blue;
}
.dot.white {
background-color: white;
}
/* Settings page */
.settings-list {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
list-style-type: none;
}
.settings-list li {
margin: 40px 0;
text-align: center;
}
.btn {
background-color: var(--bg-primary);
color: var(--text-secondary);
border-radius: 50%;
font-size: 40px;
padding: 20px 50px;
}
.settings-list a {
color: var(--text-secondary);
font-size: 30px;
}
#speedSlider {
width: 80vw;
}
#ledSlider {
width: 80vw;
}
#ledIntensitySlider {
width: 80vw;
}
#ledSaturationSlider {
width: 80vw;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+35
View File
@@ -0,0 +1,35 @@
var body = document.body;
body.style.margin = 0;
var div = document.getElementById('canvas-div');
var canvas = document.getElementById('chart');
canvas.height = div.offsetHeight;
canvas.width = div.offsetWidth;
var x_center = div.offsetWidth/2
var y_center = div.offsetWidth/2
var ctx = canvas.getContext("2d");
// sand background
var sand = new Image();
sand.src = "static/images/sand.jpg"
var pat = ctx.createPattern(sand, "repeat")
ctx.save();
ctx.beginPath();
ctx.fillStyle = pat;
ctx.arc(x_center, y_center, div.offsetWidth/2-5, 0, 2 * Math.PI);
ctx.fill();
ctx.strokeStyle = 'white';
ctx.lineWidth = 5;
ctx.stroke();
// sand leds
var leds = ctx.createRadialGradient(x_center, y_center, 0, x_center, y_center, div.offsetWidth/2+50)
leds.addColorStop(0, "rgba(255,0,255,0.1)")
leds.addColorStop(1, "rgba(255,0,255,0.3)")
ctx.fillStyle = leds;
ctx.fill();
+94
View File
@@ -0,0 +1,94 @@
var div = document.getElementById('picker')
var red = document.getElementById('red')
var green = document.getElementById('green')
var blue = document.getElementById('blue')
var white = document.getElementById('white')
var red_value = red.innerHTML
var green_value = green.innerHTML
var blue_value = blue.innerHTML
var white_value = white.innerHTML
var colorPicker = new iro.ColorPicker('#picker', {
// Set the size of the color picker
width: div.offsetWidth-40,
// Set the initial color to pure red
color: {r: red_value, g: green_value, b: blue_value},
sliderSize: 100,
handleRadius: 35,
margin: 20,
wheelLightness: false
});
var whitePicker = new iro.ColorPicker('#picker', {
width: div.offsetWidth-40,
color: {r: white_value, g: white_value, b: white_value},
sliderSize: 100,
handleRadius: 35,
value: 0,
layout: [
{
component: iro.ui.Slider,
options: {
sliderType: 'value'
}
},
]
});
colorPicker.on('input:end', function(color) {
// Get RGB values
var R = color.rgb['r'];
var G = color.rgb['g'];
var B = color.rgb['b'];
// Write value to div
red.innerHTML = R;
green.innerHTML = G;
blue.innerHTML = B;
// POST values without refreshing
const formData = new FormData();
formData.append('r', R);
formData.append('g', G);
formData.append('b', B);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
console.log(formData);
fetch('/color/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
})
.catch(error => {
console.error('Error:', error);
});
});
whitePicker.on('input:end', function(color) {
// Get white value
var W = parseInt(color.value * 2.555);
// Write value to div
white.innerHTML = W;
// POST values without refreshing
const formData = new FormData();
formData.append('w', W);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
console.log(formData);
fetch('/color/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
})
.catch(error => {
console.error('Error:', error);
});
});
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
{% load static %}
<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" href ="{% static 'css/style.css' %}">
</head>
<body>
{% block content %}{% endblock content %}
<nav class="nav">
<ul class="nav-list">
<li>
<a href="{% url 'sandtable:index' %}">
<span class="icon"><ion-icon name="infinite-outline"></ion-icon></span>
</a>
</li>
<li>
<a href="{% url 'sandtable:tracks' %}">
<span class="icon"><ion-icon name="list-outline"></ion-icon></span>
</a>
</li>
<li>
<a href="{% url 'sandtable:color' %}">
<span class="icon"><ion-icon name="color-filter-outline"></ion-icon></span>
</a>
</li>
<li>
<a href="{% url 'sandtable:settings' %}">
<span class="icon"><ion-icon name="settings-outline"></ion-icon></span>
</a>
</li>
</ul>
</nav>
<script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
</body>
</html>
@@ -0,0 +1,31 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main>
<div class="track-list-div">
<ul class="settings-list">
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="+1" name="+1">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="-1" name="-1">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="+10" name="+10">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="-10" name="-10">
</form>
</li>
</ul>
</div>
</main>
{% endblock content %}
@@ -0,0 +1,47 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main class='colorPicker'>
<div class="header center">
<a>Color picker</a>
</div>
<div class="track-list-div color-div">
<div id="picker"></div>
<div id="rgbw">
<ul>
<li>
<a>
<span class="dot red">R
<p id="red">{{ colors.r }}</p>
</span>
</a>
</li>
<li class="right">
<a>
<span class="dot green">G
<p id="green">{{ colors.g }}</p>
</span>
</a>
</li>
<li>
<a>
<span class="dot blue">B
<p id="blue">{{ colors.b }}</p>
</span>
</a>
</li>
<li class="right">
<a>
<span class="dot white">W
<p id="white">{{ colors.w }}</p>
</span>
</a>
</li>
</ul>
</div>
</div>
</main>
<script src="{% static 'js/iro.min.js' %}"></script>
<script src="{% static 'js/color.js' %}"></script>
{% endblock content %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main class='index'>
<div id="canvas-div">
<canvas id="chart"></canvas>
<script src="{% static 'js/chart.js' %}"></script>
</div>
<div class='track-playing'>
<p>{{ track_name }}</p>
</div>
<div class='media'>
<form action="#" method="get" id="eraseForm">
<a id='erase'>
<input type="hidden" name="eraseButton" value="true">
<span class="icon"><ion-icon name="trash-outline"></ion-icon></span>
</a>
</form>
<form action="#" method="get" id="playForm">
<input type="hidden" name="playButton" value="true">
<a id='play'>
{% if playing %}
<span class="icon"><ion-icon name="pause-outline"></ion-icon></span>
{% else %}
<span class="icon"><ion-icon name="play-outline"></ion-icon></span>
{% endif %}
</a>
</form>
<form action="#" method="get" id="nextForm">
<input type="hidden" name="nextButton" value="true">
<a id='next' href="#">
<span class="icon"><ion-icon name="play-forward-outline"></ion-icon></span>
</a>
</form>
</div>
<script>
var play_button = document.getElementById("play");
var erase_button = document.getElementById("erase");
var next_button = document.getElementById("next");
play_button.onpointerup = function() {
document.getElementById("playForm").submit();
}
erase_button.onpointerup = function() {
document.getElementById("eraseForm").submit();
}
next_button.onpointerup = function() {
document.getElementById("nextForm").submit();
}
</script>
</main>
{% endblock content %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main class='queue'>
<div class="header">
<ul class="header-list">
<li>
<a href="{% url 'sandtable:tracks' %}">Tracks</a>
</li>
<li>
<a href="{% url 'sandtable:queue' %}">Queue</a>
</li>
</ul>
</div>
<div class="track-list-div">
<ul class="queue-list">
{% for track in queue %}
<li>
<a>{{ track.file }}</a>
</li>
{% empty %}
<div class='empty'><a>Queue is empty.</a></div>
{% endfor %}
</ul>
</div>
<div class="upload-div">
<form action="#" method="get">
<input type="submit" class="btn" value="Clear queue" name="clearQueue">
</form>
</div>
</main>
{% endblock content %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main class='settings'>
<div class="header center">
<a>Settings</a>
</div>
<div class="track-list-div">
<ul class="settings-list">
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Led fade" name="fadeButton">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Home" name="homeButton">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Calibrate" name="calibrateButton">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Update coords" name="updateButton">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Stop motors" name="stopButton">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Power off" name="powerButton">
</form>
</li>
<li>
<form action="#" method="get">
<input type="submit" class="btn" value="Reset core" name="resetButton">
</form>
</li>
<li>
<a>Motor speed:</a>
<form action="#" method="get" id="motorSliderForm">
<input type="range" id="speedSlider" min="1" max="10" value={{ motorSpeed }} name="motorSpeedSlider">
<a id="speedSliderValue"></a>
</form>
</li>
<li>
<a>Led speed:</a>
<form action="#" method="get" id="ledSliderForm">
<input type="range" id="ledSlider" min="1" max="200" value={{ ledSpeed }} name="ledSpeedSlider">
<a id="ledSliderValue"></a>
</form>
</li>
<li>
<a>Led intensity:</a>
<form action="#" method="get" id="ledIntensitySliderForm">
<input type="range" id="ledIntensitySlider" min="0" max="100" value={{ ledIntensity }} name="ledIntensitySlider">
<a id="ledIntensitySliderValue"></a>
</form>
</li>
<li>
<a>Led saturation:</a>
<form action="#" method="get" id="ledSaturationSliderForm">
<input type="range" id="ledSaturationSlider" min="0" max="100" value={{ ledSaturation }} name="ledSaturationSlider">
<a id="ledSaturationSliderValue"></a>
</form>
</li>
</ul>
</div>
<script>
var speedSlider = document.getElementById("speedSlider");
var speedValue = document.getElementById("speedSliderValue");
speedValue.innerHTML = speedSlider.value;
speedSlider.oninput = function() {
speedValue.innerHTML = this.value;
}
speedSlider.onpointerup = function() {
document.getElementById("motorSliderForm").submit();
}
var ledSlider = document.getElementById("ledSlider");
var ledValue = document.getElementById("ledSliderValue");
ledValue.innerHTML = ledSlider.value;
ledSlider.oninput = function() {
ledValue.innerHTML = this.value;
}
ledSlider.onpointerup = function() {
document.getElementById("ledSliderForm").submit();
}
var ledIntensitySlider = document.getElementById("ledIntensitySlider");
var ledIntensityValue = document.getElementById("ledIntensitySliderValue");
ledIntensityValue.innerHTML = ledIntensitySlider.value;
ledIntensitySlider.oninput = function() {
ledIntensityValue.innerHTML = this.value;
}
ledIntensitySlider.onpointerup = function() {
document.getElementById("ledIntensitySliderForm").submit();
}
var ledSaturationSlider = document.getElementById("ledSaturationSlider");
var ledSaturationValue = document.getElementById("ledSaturationSliderValue");
ledSaturationValue.innerHTML = ledSaturationSlider.value;
ledSaturationSlider.oninput = function() {
ledSaturationValue.innerHTML = this.value;
}
ledSaturationSlider.onpointerup = function() {
document.getElementById("ledSaturationSliderForm").submit();
}
</script>
</main>
{% endblock content %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main>
<div class="header center">
<a>{{ track.name }}</a>
<a style="font-size: 20px;">Length: {{ track.track_length }}</a>
</div>
<div class="track-info">
<form action="#" method="get">
<input type="submit" class="btn" value="Delete track" name="deleteTrack">
</form>
<img src="{{ track.pic.url }}">
</div>
<div class="center">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.field.as_hidden }}
<button class="btn" type="submit">Add to queue</button>
</form>
</div>
</main>
{% endblock content %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main class='tracks'>
<div class="header">
<ul class="header-list">
<li>
<a href="{% url 'sandtable:tracks' %}">Tracks</a>
</li>
<li>
<a href="{% url 'sandtable:queue' %}">Queue</a>
</li>
</ul>
</div>
<div class="track-list-div">
<ul class="track-list">
{% for track in tracks %}
<li>
<a href="{% url 'sandtable:track' track.id %}">
<img class="list-pic" src="{{ track.pic.url }}">
<a class="track-name" href="{% url 'sandtable:track' track.id %}">{{ track.name }}</a>
</a>
</li>
{% empty %}
<div class='empty'><a>No tracks have been added yet.</a></div>
{% endfor %}
</ul>
</div>
<div class="upload-div">
<a href="{% url 'sandtable:upload' %}">Upload</a>
</div>
</main>
{% endblock content %}
@@ -0,0 +1,14 @@
{% extends "sandtable/base.html" %}
{% load static %}
{% block content %}
<main class='upload'>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Upload</button>
</form>
<p><a href="{% url 'sandtable:tracks' %}">Return to home</a></p>
</main>
{% endblock content %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+16
View File
@@ -0,0 +1,16 @@
from unicodedata import name
from django.urls import path, include
from .views import *
from .forms import *
app_name = 'sandtable'
urlpatterns = [
path('', index, name='index'),
path('tracks/', tracks, name='tracks'),
path('queue/', queue, name='queue'),
path('settings/', settings, name='settings'),
path('calibrate/', calibrate, name='calibrate'),
path(r'color/', color, name='color'),
path('upload/', uploadTracks, name='upload'),
path('tracks/<int:track_id>', track, name='track')
]
+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
+54
View File
@@ -0,0 +1,54 @@
from matplotlib import pyplot as plt
import sys
# Creates a .png file from .thr file
def drawFile(file):
# Craetes a base with almost no borders
plt.figure(figsize=(20, 20), frameon=False)
ax = plt.axes([0, 0, 1, 1], projection='polar')
# Turns off grids and axis markings
plt.axis('off')
plt.grid(visible=None)
# Inverting the y axis and turining the graph 90 degrees, because .thr does this
ax.set_theta_direction(-1)
ax.set_theta_zero_location("N")
with open(file) as f:
theta = []
rho = []
counter = 0
for line in f:
# discard if line commented
if "#" in line or len(line) < 5 or "/" in line:
continue
else:
comp = line.split()
theta.append(float(comp[0]))
rho.append(float(comp[1]))
counter += 1
plt.polar(theta, rho, marker='o', color='blue', markersize=1, linewidth=3)
# Draws a green dot as starting point
plt.polar(float(theta[0]), rho[0], marker='o', color='green', markersize=30)
# Draws a red dot as end point
plt.polar(float(theta[-1]), rho[-1], marker='o', color='red', markersize=30)
name = file.split('.')
plt.savefig(name[0]+'.png')
return counter
if __name__ == "__main__":
if len(sys.argv) > 1:
name = sys.argv[1]
drawFile(name)
else:
drawFile("media/tracks/sunburst.thr")
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
Low level serial protocol for the sand table controller.
Speaks the framed protocol used by the firmware:
struct command_message_t {
uint8_t prefix; // COMMAND_PREFIX (0x69)
uint8_t length; // number of valid bytes in `data`
uint8_t id; // COMMAND_ID (0x00)
uint8_t command; // commands_e
uint8_t crc; // 0x100 - (sum of every other byte in the message)
uint8_t data[160];
}
Every command the firmware accepts correctly is acknowledged with a
COMMAND_ACK message whose single data byte echoes the command that was
processed. For COMMAND_HOME and COMMAND_POLAR that ACK is only sent once the
steppers have physically finished moving, so waiting for it doubles as
"block until the move is done".
Position is now tracked entirely by the firmware (COMMAND_GET_POLAR), so
this module no longer keeps or persists any position of its own - callers
that need the current position call getPolar().
"""
import glob
import struct
import time
import serial
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
BAUDRATE = 115200
COMMAND_PREFIX = 0x69
COMMAND_ID = 0x00
COMMAND_DATA_SIZE = 160
# commands_e - order must match the firmware enum exactly
COMMAND_ACK = 0
COMMAND_NACK = 1
COMMAND_LED = 2
COMMAND_HOME = 3
COMMAND_DISABLE_MOTORS = 4
COMMAND_MOTOR_STEP = 5
COMMAND_MOTOR_SPEED = 6
COMMAND_POLAR = 7
COMMAND_GET_POLAR = 8
COMMAND_SET_OFFSET = 9
COMMAND_RESET_OFFSET = 10
COMMAND_NAMES = {
COMMAND_ACK: "ACK",
COMMAND_NACK: "NACK",
COMMAND_LED: "LED",
COMMAND_HOME: "HOME",
COMMAND_DISABLE_MOTORS: "DISABLE_MOTORS",
COMMAND_MOTOR_STEP: "MOTOR_STEP",
COMMAND_MOTOR_SPEED: "MOTOR_SPEED",
COMMAND_POLAR: "POLAR",
COMMAND_GET_POLAR: "GET_POLAR",
COMMAND_SET_OFFSET: "SET_OFFSET",
COMMAND_RESET_OFFSET: "RESET_OFFSET",
}
MIN_TRACK_SPEED = 1
MAX_TRACK_SPEED = 1000
MAX_BRIGHTNESS = 255
POLAR_STRUCT = struct.Struct("<ff") # struct polar_t { float theta; float r; }
ser = None
PORT = None # set once startSerial() successfully opens a port
# -----------------------------------------------------------------------------
# Connection
# -----------------------------------------------------------------------------
def _find_port():
"""Look for a /dev/ttyACMx device. Returns the first match (sorted), or
None if nothing is plugged in."""
candidates = sorted(glob.glob("/dev/ttyACM*"))
if not candidates:
return None
if len(candidates) > 1:
print(f"Multiple ttyACM ports found {candidates}, using {candidates[0]}")
return candidates[0]
def startSerial(port=None):
"""Open the serial connection. If `port` isn't given, auto-detects a
/dev/ttyACM* device."""
global ser, PORT
port = port or _find_port()
if port is None:
ser = None
print("Failed to start serial connection: no /dev/ttyACM* device found")
return
try:
ser = serial.Serial(port, BAUDRATE, timeout=1)
PORT = port
# Give the board a moment in case it resets on port open (common on
# AVR / USB-CDC boards) before we start talking to it.
time.sleep(2)
ser.reset_input_buffer()
except Exception as e:
ser = None
print(f"Failed to start serial connection on {port}: {e}")
def resetBuffer():
if ser is not None:
ser.reset_input_buffer()
def _ensure_serial():
if ser is None:
startSerial()
return ser is not None
# -----------------------------------------------------------------------------
# Packet building / CRC (matches firmware's command_calculate_crc())
# -----------------------------------------------------------------------------
def _build_packet(command, data=b""):
if len(data) > COMMAND_DATA_SIZE:
raise ValueError(
f"data length {len(data)} exceeds COMMAND_DATA_SIZE ({COMMAND_DATA_SIZE})"
)
length = len(data)
packet = bytearray([COMMAND_PREFIX, length, COMMAND_ID, command, 0x00])
packet += data
crc_sum = 0
for i, byte in enumerate(packet):
if i == 4: # skip the crc field itself
continue
crc_sum += byte
packet[4] = (0x100 - (crc_sum & 0xFF)) & 0xFF
return packet
# -----------------------------------------------------------------------------
# Reading responses
# -----------------------------------------------------------------------------
def _read_message(timeout=2.0):
"""Read one command_message_t from the serial port, resyncing on the
0x69 prefix byte. Returns a dict {prefix, length, id, command, crc, data}
or None on timeout / no connection."""
if not _ensure_serial():
return None
deadline = time.monotonic() + timeout
old_timeout = ser.timeout
try:
while time.monotonic() < deadline:
ser.timeout = max(0.01, deadline - time.monotonic())
b = ser.read(1)
if not b:
continue
if b[0] != COMMAND_PREFIX:
continue # resync: keep looking for the prefix byte
header = ser.read(4) # length, id, command, crc
if len(header) < 4:
continue
length, msg_id, command, crc = header
data = b""
if length:
data = ser.read(length)
if len(data) < length:
continue # malformed/short read, keep resyncing
return {
"prefix": b[0],
"length": length,
"id": msg_id,
"command": command,
"crc": crc,
"data": data,
}
finally:
ser.timeout = old_timeout
return None
def _wait_for_ack(expected_command, timeout=10.0):
"""Wait for a COMMAND_ACK whose data[0] echoes expected_command. Returns
the ACK payload (bytes) on success, or None on timeout/NACK."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
msg = _read_message(timeout=remaining)
if msg is None:
return None
if msg["command"] == COMMAND_NACK:
name = COMMAND_NAMES.get(expected_command, expected_command)
print(f" -> device NACKed command {name}")
return None
if msg["command"] == COMMAND_ACK:
acked = msg["data"][0] if msg["data"] else None
if acked == expected_command:
return msg["data"]
continue # ACK for something else - keep waiting
return None
# -----------------------------------------------------------------------------
# High level command helpers
# -----------------------------------------------------------------------------
def sendCommand(command, data=b"", wait_ack=True, ack_timeout=10.0):
"""Build, send, and (optionally) block for the completion ACK of a
command. Returns the ACK payload (bytes, possibly empty) on success,
True if wait_ack=False and the write succeeded, or None/False on
failure."""
if not _ensure_serial():
return None if wait_ack else False
packet = _build_packet(command, data)
try:
ser.write(packet)
ser.flush()
except Exception as e:
print(f"Failed to write to serial: {e}")
return None if wait_ack else False
if not wait_ack:
return True
ack_data = _wait_for_ack(command, timeout=ack_timeout)
if ack_data is None:
name = COMMAND_NAMES.get(command, command)
print(f"Timed out waiting for ACK on {name}")
return ack_data
def sendPolar(theta, r, wait_ack=True, ack_timeout=15.0):
"""Move to (theta, r). Blocks until the steppers report the move is
complete (unless wait_ack=False). Returns True on success, False/None
on failure/timeout."""
data = POLAR_STRUCT.pack(theta, r)
result = sendCommand(COMMAND_POLAR, data, wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
def sendPolarWithRetry(theta, r, retries=3, ack_timeout=15.0):
"""Same as sendPolar() but retries a few times on timeout/NACK before
giving up (returns False)."""
for attempt in range(retries):
if sendPolar(theta, r, wait_ack=True, ack_timeout=ack_timeout):
return True
print(f" retrying polar move to ({theta}, {r}) [{attempt + 1}/{retries}]")
resetBuffer()
time.sleep(0.05)
return False
def getPolar(timeout=5.0):
"""Ask the device for its current (theta, r).
ASSUMPTION: the reply is a command_message_t with command ==
COMMAND_GET_POLAR and an 8-byte polar_t payload (same <ff> layout as
what's sent to COMMAND_POLAR). I don't have the firmware's
COMMAND_GET_POLAR case handler, so if the real reply looks different
(e.g. comes back wrapped as an ACK instead) let me know and I'll adjust
this.
Returns (theta, r) or None on failure/timeout.
"""
if not _ensure_serial():
return None
packet = _build_packet(COMMAND_GET_POLAR)
try:
ser.write(packet)
ser.flush()
except Exception as e:
print(f"Failed to write to serial: {e}")
return None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
msg = _read_message(timeout=deadline - time.monotonic())
if msg is None:
break
if msg["command"] == COMMAND_NACK:
print(" -> device NACKed GET_POLAR")
return None
if msg["command"] == COMMAND_GET_POLAR and len(msg["data"]) >= 8:
return POLAR_STRUCT.unpack(msg["data"][:8])
# anything else (e.g. a stray ACK for a previous command) - ignore and keep waiting
print("Timed out waiting for GET_POLAR reply")
return None
def home(wait_ack=True, ack_timeout=120.0):
result = sendCommand(COMMAND_HOME, b"", wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
def disableMotors(wait_ack=True, ack_timeout=5.0):
result = sendCommand(COMMAND_DISABLE_MOTORS, b"", wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
def setMotorSpeed(speed, wait_ack=True, ack_timeout=5.0):
"""ASSUMPTION: motor speed is encoded as a uint16 little-endian value,
matching the LED track speed convention. I don't have the firmware's
COMMAND_MOTOR_SPEED handler, so confirm this - if it actually expects a
float or a different width, this needs to change."""
speed = max(0, min(int(speed), 0xFFFF))
data = struct.pack("<H", speed)
result = sendCommand(COMMAND_MOTOR_SPEED, data, wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
def setOffset(m1_delta, m2_delta, wait_ack=True, ack_timeout=5.0):
"""Add to the firmware's stored per-motor calibration offset (steps).
This is ADDITIVE (stepper_add_offset), not absolute - each call nudges
the offset further, matching the old "+1/-1/+10/-10" calibrate button
behavior. Both deltas are signed 8-bit (-128..127)."""
m1 = max(-128, min(int(m1_delta), 127))
m2 = max(-128, min(int(m2_delta), 127))
data = struct.pack("<bb", m1, m2)
result = sendCommand(COMMAND_SET_OFFSET, data, wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
def resetOffset(wait_ack=True, ack_timeout=5.0):
"""Zero out the firmware's stored calibration offset entirely."""
result = sendCommand(COMMAND_RESET_OFFSET, b"", wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
def setLedTrack(colors, speed, wait_ack=True, ack_timeout=5.0):
"""colors: list of (r, g, b, w) tuples, 0-255 each. speed: track speed
(uint16). A single solid color is just a one-element list. The device
holds no LED track state of its own, so the *full* track has to be
re-sent on every change - matches the reference LED script's
"speed + count + colors" payload layout."""
if not colors:
raise ValueError("colors must contain at least one (r, g, b, w) tuple")
# 3 header bytes (speed lo/hi + count) + 4 bytes/color must fit in 160
if 3 + len(colors) * 4 > COMMAND_DATA_SIZE:
raise ValueError("too many colors for a single COMMAND_LED payload")
payload = bytearray()
speed = max(0, min(int(speed), 0xFFFF))
payload.append(speed & 0xFF)
payload.append((speed >> 8) & 0xFF)
payload.append(len(colors))
for color in colors:
payload += struct.pack("BBBB", *(max(0, min(int(c), 255)) for c in color))
result = sendCommand(COMMAND_LED, bytes(payload), wait_ack=wait_ack, ack_timeout=ack_timeout)
if not wait_ack:
return result
return result is not None
startSerial()