commit 59c9f942f0846b6cac9853427a664afe2dd5bbea Author: Your Name Date: Sat Aug 8 16:11:57 2026 +0300 Old SandTable project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32c4707 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Pictures/1674052924095.jpg b/Pictures/1674052924095.jpg new file mode 100644 index 0000000..3b34f3e Binary files /dev/null and b/Pictures/1674052924095.jpg differ diff --git a/Pictures/1674053047425.jpg b/Pictures/1674053047425.jpg new file mode 100644 index 0000000..36b5bb6 Binary files /dev/null and b/Pictures/1674053047425.jpg differ diff --git a/Pictures/1674053143892.jpg b/Pictures/1674053143892.jpg new file mode 100644 index 0000000..0723289 Binary files /dev/null and b/Pictures/1674053143892.jpg differ diff --git a/Pictures/1674062162257.jpg b/Pictures/1674062162257.jpg new file mode 100644 index 0000000..5518afb Binary files /dev/null and b/Pictures/1674062162257.jpg differ diff --git a/Pictures/1674063564681.jpg b/Pictures/1674063564681.jpg new file mode 100644 index 0000000..22f7f4a Binary files /dev/null and b/Pictures/1674063564681.jpg differ diff --git a/Pictures/IMG_20220213_153728.jpg b/Pictures/IMG_20220213_153728.jpg new file mode 100644 index 0000000..607bfe1 Binary files /dev/null and b/Pictures/IMG_20220213_153728.jpg differ diff --git a/Pictures/image_2023-01-18_212445061.png b/Pictures/image_2023-01-18_212445061.png new file mode 100644 index 0000000..3e10264 Binary files /dev/null and b/Pictures/image_2023-01-18_212445061.png differ diff --git a/PlayQueue.py b/PlayQueue.py new file mode 100644 index 0000000..7ace532 --- /dev/null +++ b/PlayQueue.py @@ -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() \ No newline at end of file diff --git a/Prints/Final/Arm1Top.stl b/Prints/Final/Arm1Top.stl new file mode 100755 index 0000000..abb7a1c Binary files /dev/null and b/Prints/Final/Arm1Top.stl differ diff --git a/Prints/Final/Arm2.stl b/Prints/Final/Arm2.stl new file mode 100755 index 0000000..a0a9d41 Binary files /dev/null and b/Prints/Final/Arm2.stl differ diff --git a/Prints/Final/Arm2Gear.stl b/Prints/Final/Arm2Gear.stl new file mode 100755 index 0000000..f41718f Binary files /dev/null and b/Prints/Final/Arm2Gear.stl differ diff --git a/Prints/Final/BodyBottom.stl b/Prints/Final/BodyBottom.stl new file mode 100755 index 0000000..460a6dd Binary files /dev/null and b/Prints/Final/BodyBottom.stl differ diff --git a/Prints/Final/BodyTop.stl b/Prints/Final/BodyTop.stl new file mode 100755 index 0000000..089e54a Binary files /dev/null and b/Prints/Final/BodyTop.stl differ diff --git a/Prints/Final/BottomGear.stl b/Prints/Final/BottomGear.stl new file mode 100755 index 0000000..ed7a238 Binary files /dev/null and b/Prints/Final/BottomGear.stl differ diff --git a/Prints/Final/GearArm.stl b/Prints/Final/GearArm.stl new file mode 100755 index 0000000..cdd524e Binary files /dev/null and b/Prints/Final/GearArm.stl differ diff --git a/Prints/Final/Optional/Arm2.stl b/Prints/Final/Optional/Arm2.stl new file mode 100755 index 0000000..63a89e0 Binary files /dev/null and b/Prints/Final/Optional/Arm2.stl differ diff --git a/Prints/Final/Optional/Arm2Gear2v2.stl b/Prints/Final/Optional/Arm2Gear2v2.stl new file mode 100755 index 0000000..8410a0b Binary files /dev/null and b/Prints/Final/Optional/Arm2Gear2v2.stl differ diff --git a/Prints/Final/Optional/BodyTopLogo.stl b/Prints/Final/Optional/BodyTopLogo.stl new file mode 100755 index 0000000..9ae24d2 Binary files /dev/null and b/Prints/Final/Optional/BodyTopLogo.stl differ diff --git a/Prints/Final/Optional/Leg.stl b/Prints/Final/Optional/Leg.stl new file mode 100755 index 0000000..db1eeda Binary files /dev/null and b/Prints/Final/Optional/Leg.stl differ diff --git a/Prints/Final/Optional/MagnetHolder.stl b/Prints/Final/Optional/MagnetHolder.stl new file mode 100755 index 0000000..46dd573 Binary files /dev/null and b/Prints/Final/Optional/MagnetHolder.stl differ diff --git a/Prints/Final/Optional/MagnetHolderShort.stl b/Prints/Final/Optional/MagnetHolderShort.stl new file mode 100755 index 0000000..b75e7df Binary files /dev/null and b/Prints/Final/Optional/MagnetHolderShort.stl differ diff --git a/Prints/Final/Sensor1Body.stl b/Prints/Final/Sensor1Body.stl new file mode 100755 index 0000000..88e96c4 Binary files /dev/null and b/Prints/Final/Sensor1Body.stl differ diff --git a/Prints/Final/Sensor2Body.stl b/Prints/Final/Sensor2Body.stl new file mode 100755 index 0000000..ff66754 Binary files /dev/null and b/Prints/Final/Sensor2Body.stl differ diff --git a/Prints/RGB/Top.stl b/Prints/RGB/Top.stl new file mode 100755 index 0000000..eed2f62 Binary files /dev/null and b/Prints/RGB/Top.stl differ diff --git a/Prints/RGB/bottom.stl b/Prints/RGB/bottom.stl new file mode 100755 index 0000000..bf85c2b Binary files /dev/null and b/Prints/RGB/bottom.stl differ diff --git a/divideCoords.py b/divideCoords.py new file mode 100644 index 0000000..b1bb148 --- /dev/null +++ b/divideCoords.py @@ -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) \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..2c49f3a --- /dev/null +++ b/manage.py @@ -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() diff --git a/playControl.py b/playControl.py new file mode 100644 index 0000000..396fc0b --- /dev/null +++ b/playControl.py @@ -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 \ No newline at end of file diff --git a/playTrack.py b/playTrack.py new file mode 100644 index 0000000..c7c7704 --- /dev/null +++ b/playTrack.py @@ -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") \ No newline at end of file diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/asgi.py b/project/asgi.py new file mode 100644 index 0000000..fb8b0c8 --- /dev/null +++ b/project/asgi.py @@ -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() diff --git a/project/settings.py b/project/settings.py new file mode 100644 index 0000000..3b6f14b --- /dev/null +++ b/project/settings.py @@ -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' diff --git a/project/urls.py b/project/urls.py new file mode 100644 index 0000000..4940ce6 --- /dev/null +++ b/project/urls.py @@ -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) \ No newline at end of file diff --git a/project/wsgi.py b/project/wsgi.py new file mode 100644 index 0000000..82b3287 --- /dev/null +++ b/project/wsgi.py @@ -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() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4e8daae --- /dev/null +++ b/readme.md @@ -0,0 +1,14 @@ +# Kinetic sand art table (Old web code) + + +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 Dobson’s](https://robdobson.com/2018/08/a-new-sandbot/) version. + + + +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. + + + +The user interface is very simple and basic due to my lack of UI designing skills. + + diff --git a/sandtable/__init__.py b/sandtable/__init__.py new file mode 100644 index 0000000..65d84a4 --- /dev/null +++ b/sandtable/__init__.py @@ -0,0 +1,2 @@ + +default_app_config = 'sandtable.apps.SandtableConfig' \ No newline at end of file diff --git a/sandtable/admin.py b/sandtable/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/sandtable/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/sandtable/apps.py b/sandtable/apps.py new file mode 100644 index 0000000..0f0ac45 --- /dev/null +++ b/sandtable/apps.py @@ -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 \ No newline at end of file diff --git a/sandtable/forms.py b/sandtable/forms.py new file mode 100644 index 0000000..c2e3e0a --- /dev/null +++ b/sandtable/forms.py @@ -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 = () \ No newline at end of file diff --git a/sandtable/migrations/0001_initial.py b/sandtable/migrations/0001_initial.py new file mode 100644 index 0000000..ed3b4a1 --- /dev/null +++ b/sandtable/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/sandtable/migrations/0002_rgbw.py b/sandtable/migrations/0002_rgbw.py new file mode 100644 index 0000000..80bf0db --- /dev/null +++ b/sandtable/migrations/0002_rgbw.py @@ -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)), + ], + ), + ] diff --git a/sandtable/migrations/0003_settings_rgbw_led_speed.py b/sandtable/migrations/0003_settings_rgbw_led_speed.py new file mode 100644 index 0000000..6f7f765 --- /dev/null +++ b/sandtable/migrations/0003_settings_rgbw_led_speed.py @@ -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), + ), + ] diff --git a/sandtable/migrations/0004_rgbw_led_intensity_rgbw_led_saturation_and_more.py b/sandtable/migrations/0004_rgbw_led_intensity_rgbw_led_saturation_and_more.py new file mode 100644 index 0000000..2044584 --- /dev/null +++ b/sandtable/migrations/0004_rgbw_led_intensity_rgbw_led_saturation_and_more.py @@ -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), + ), + ] diff --git a/sandtable/migrations/0005_settings_r_settings_theta.py b/sandtable/migrations/0005_settings_r_settings_theta.py new file mode 100644 index 0000000..d20c53d --- /dev/null +++ b/sandtable/migrations/0005_settings_r_settings_theta.py @@ -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), + ), + ] diff --git a/sandtable/migrations/0006_settings_calibrate.py b/sandtable/migrations/0006_settings_calibrate.py new file mode 100644 index 0000000..bd36518 --- /dev/null +++ b/sandtable/migrations/0006_settings_calibrate.py @@ -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), + ), + ] diff --git a/sandtable/migrations/0007_alter_rgbw_led_track.py b/sandtable/migrations/0007_alter_rgbw_led_track.py new file mode 100644 index 0000000..c502e14 --- /dev/null +++ b/sandtable/migrations/0007_alter_rgbw_led_track.py @@ -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), + ), + ] diff --git a/sandtable/migrations/__init__.py b/sandtable/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sandtable/models.py b/sandtable/models.py new file mode 100644 index 0000000..4604bd9 --- /dev/null +++ b/sandtable/models.py @@ -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) diff --git a/sandtable/static/css/style.css b/sandtable/static/css/style.css new file mode 100644 index 0000000..3ece198 --- /dev/null +++ b/sandtable/static/css/style.css @@ -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; +} \ No newline at end of file diff --git a/sandtable/static/images/sand.jpg b/sandtable/static/images/sand.jpg new file mode 100644 index 0000000..7362774 Binary files /dev/null and b/sandtable/static/images/sand.jpg differ diff --git a/sandtable/static/js/chart.js b/sandtable/static/js/chart.js new file mode 100644 index 0000000..b0b5a4a --- /dev/null +++ b/sandtable/static/js/chart.js @@ -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(); \ No newline at end of file diff --git a/sandtable/static/js/color.js b/sandtable/static/js/color.js new file mode 100644 index 0000000..67f803b --- /dev/null +++ b/sandtable/static/js/color.js @@ -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); + }); +}); \ No newline at end of file diff --git a/sandtable/static/js/iro.min.js b/sandtable/static/js/iro.min.js new file mode 100644 index 0000000..2d20321 --- /dev/null +++ b/sandtable/static/js/iro.min.js @@ -0,0 +1,7 @@ +/*! + * iro.js v5.5.2 + * 2016-2021 James Daniel + * Licensed under MPL 2.0 + * github.com/jaames/iro.js + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t=t||self).iro=n()}(this,function(){"use strict";var m,s,n,i,o,x={},j=[],r=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|^--/i;function M(t,n){for(var i in n)t[i]=n[i];return t}function y(t){var n=t.parentNode;n&&n.removeChild(t)}function h(t,n,i){var r,e,u,o,l=arguments;if(n=M({},n),3=r/i?u=n:e=n}return n},function(t,n,i){n&&g(t.prototype,n),i&&g(t,i)}(l,[{key:"hsv",get:function(){var t=this.$;return{h:t.h,s:t.s,v:t.v}},set:function(t){var n=this.$;if(t=b({},n,t),this.onChange){var i={h:!1,v:!1,s:!1,a:!1};for(var r in n)i[r]=t[r]!=n[r];this.$=t,(i.h||i.s||i.v||i.a)&&this.onChange(this,i)}else this.$=t}},{key:"hsva",get:function(){return b({},this.$)},set:function(t){this.hsv=t}},{key:"hue",get:function(){return this.$.h},set:function(t){this.hsv={h:t}}},{key:"saturation",get:function(){return this.$.s},set:function(t){this.hsv={s:t}}},{key:"value",get:function(){return this.$.v},set:function(t){this.hsv={v:t}}},{key:"alpha",get:function(){return this.$.a},set:function(t){this.hsv=b({},this.hsv,{a:t})}},{key:"kelvin",get:function(){return l.rgbToKelvin(this.rgb)},set:function(t){this.rgb=l.kelvinToRgb(t)}},{key:"red",get:function(){return this.rgb.r},set:function(t){this.rgb=b({},this.rgb,{r:t})}},{key:"green",get:function(){return this.rgb.g},set:function(t){this.rgb=b({},this.rgb,{g:t})}},{key:"blue",get:function(){return this.rgb.b},set:function(t){this.rgb=b({},this.rgb,{b:t})}},{key:"rgb",get:function(){var t=l.hsvToRgb(this.$),n=t.r,i=t.g,r=t.b;return{r:G(n),g:G(i),b:G(r)}},set:function(t){this.hsv=b({},l.rgbToHsv(t),{a:void 0===t.a?1:t.a})}},{key:"rgba",get:function(){return b({},this.rgb,{a:this.alpha})},set:function(t){this.rgb=t}},{key:"hsl",get:function(){var t=l.hsvToHsl(this.$),n=t.h,i=t.s,r=t.l;return{h:G(n),s:G(i),l:G(r)}},set:function(t){this.hsv=b({},l.hslToHsv(t),{a:void 0===t.a?1:t.a})}},{key:"hsla",get:function(){return b({},this.hsl,{a:this.alpha})},set:function(t){this.hsl=t}},{key:"rgbString",get:function(){var t=this.rgb;return"rgb("+t.r+", "+t.g+", "+t.b+")"},set:function(t){var n,i,r,e,u=1;if((n=_.exec(t))?(i=K(n[1],255),r=K(n[2],255),e=K(n[3],255)):(n=H.exec(t))&&(i=K(n[1],255),r=K(n[2],255),e=K(n[3],255),u=K(n[4],1)),!n)throw new Error("Invalid rgb string");this.rgb={r:i,g:r,b:e,a:u}}},{key:"rgbaString",get:function(){var t=this.rgba;return"rgba("+t.r+", "+t.g+", "+t.b+", "+t.a+")"},set:function(t){this.rgbString=t}},{key:"hexString",get:function(){var t=this.rgb;return"#"+U(t.r)+U(t.g)+U(t.b)},set:function(t){var n,i,r,e,u=255;if((n=D.exec(t))?(i=17*Q(n[1]),r=17*Q(n[2]),e=17*Q(n[3])):(n=F.exec(t))?(i=17*Q(n[1]),r=17*Q(n[2]),e=17*Q(n[3]),u=17*Q(n[4])):(n=L.exec(t))?(i=Q(n[1]),r=Q(n[2]),e=Q(n[3])):(n=B.exec(t))&&(i=Q(n[1]),r=Q(n[2]),e=Q(n[3]),u=Q(n[4])),!n)throw new Error("Invalid hex string");this.rgb={r:i,g:r,b:e,a:u/255}}},{key:"hex8String",get:function(){var t=this.rgba;return"#"+U(t.r)+U(t.g)+U(t.b)+U(Z(255*t.a))},set:function(t){this.hexString=t}},{key:"hslString",get:function(){var t=this.hsl;return"hsl("+t.h+", "+t.s+"%, "+t.l+"%)"},set:function(t){var n,i,r,e,u=1;if((n=P.exec(t))?(i=K(n[1],360),r=K(n[2],100),e=K(n[3],100)):(n=$.exec(t))&&(i=K(n[1],360),r=K(n[2],100),e=K(n[3],100),u=K(n[4],1)),!n)throw new Error("Invalid hsl string");this.hsl={h:i,s:r,l:e,a:u}}},{key:"hslaString",get:function(){var t=this.hsla;return"hsla("+t.h+", "+t.s+"%, "+t.l+"%, "+t.a+")"},set:function(t){this.hslString=t}}]),l}();function X(t){var n,i=t.width,r=t.sliderSize,e=t.borderWidth,u=t.handleRadius,o=t.padding,l=t.sliderShape,s="horizontal"===t.layoutDirection;return r=null!=(n=r)?n:2*o+2*u,"circle"===l?{handleStart:t.padding+t.handleRadius,handleRange:i-2*o-2*u,width:i,height:i,cx:i/2,cy:i/2,radius:i/2-e/2}:{handleStart:r/2,handleRange:i-r,radius:r/2,x:0,y:0,width:s?r:i,height:s?i:r}}function Y(t,n){var i=X(t),r=i.width,e=i.height,u=i.handleRange,o=i.handleStart,l="horizontal"===t.layoutDirection,s=l?r/2:e/2,c=o+function(t,n){var i=n.hsva,r=n.rgb;switch(t.sliderType){case"red":return r.r/2.55;case"green":return r.g/2.55;case"blue":return r.b/2.55;case"alpha":return 100*i.a;case"kelvin":var e=t.minTemperature,u=t.maxTemperature-e,o=(n.kelvin-e)/u*100;return Math.max(0,Math.min(o,100));case"hue":return i.h/=3.6;case"saturation":return i.s;case"value":default:return i.v}}(t,n)/100*u;return l&&(c=-1*c+u+2*o),{x:l?s:c,y:l?c:s}}var tt,nt=2*Math.PI,it=function(t,n){return(t%n+n)%n},rt=function(t,n){return Math.sqrt(t*t+n*n)};function et(t){return t.width/2-t.padding-t.handleRadius-t.borderWidth}function ut(t){var n=t.width/2;return{width:t.width,radius:n-t.borderWidth,cx:n,cy:n}}function ot(t,n,i){var r=t.wheelAngle,e=t.wheelDirection;return i&&"clockwise"===e?n=r+n:"clockwise"===e?n=360-r+n:i&&"anticlockwise"===e?n=r+180-n:"anticlockwise"===e&&(n=r-n),it(n,360)}function lt(t,n,i){var r=ut(t),e=r.cx,u=r.cy,o=et(t);n=e-n,i=u-i;var l=ot(t,Math.atan2(-i,-n)*(360/nt)),s=Math.min(rt(n,i),o);return{h:Math.round(l),s:Math.round(100/o*s)}}function st(t){var n=t.width,i=t.boxHeight;return{width:n,height:null!=i?i:n,radius:t.padding+t.handleRadius}}function ct(t,n,i){var r=st(t),e=r.width,u=r.height,o=r.radius,l=(n-o)/(e-2*o)*100,s=(i-o)/(u-2*o)*100;return{s:Math.max(0,Math.min(l,100)),v:Math.max(0,Math.min(100-s,100))}}function at(t,n,i,r){for(var e=0;e + + + + + + + + + + {% block content %}{% endblock content %} + + + + + + + + + + + + \ No newline at end of file diff --git a/sandtable/templates/sandtable/calibrate.html b/sandtable/templates/sandtable/calibrate.html new file mode 100644 index 0000000..84b3891 --- /dev/null +++ b/sandtable/templates/sandtable/calibrate.html @@ -0,0 +1,31 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+
+
    +
  • +
    + +
    +
  • +
  • +
    + +
    +
  • +
  • +
    + +
    +
  • +
  • +
    + +
    +
  • +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/sandtable/templates/sandtable/colorPicker.html b/sandtable/templates/sandtable/colorPicker.html new file mode 100644 index 0000000..2b23fff --- /dev/null +++ b/sandtable/templates/sandtable/colorPicker.html @@ -0,0 +1,47 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+ + +
+ + +{% endblock content %} \ No newline at end of file diff --git a/sandtable/templates/sandtable/index.html b/sandtable/templates/sandtable/index.html new file mode 100644 index 0000000..bada906 --- /dev/null +++ b/sandtable/templates/sandtable/index.html @@ -0,0 +1,58 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+
+ + +
+ +
+

{{ track_name }}

+
+ + + + +
+{% endblock content %} diff --git a/sandtable/templates/sandtable/queue.html b/sandtable/templates/sandtable/queue.html new file mode 100644 index 0000000..54e6e72 --- /dev/null +++ b/sandtable/templates/sandtable/queue.html @@ -0,0 +1,33 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+
+ +
+
+ +
+
+
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/sandtable/templates/sandtable/settings.html b/sandtable/templates/sandtable/settings.html new file mode 100644 index 0000000..48a7cc7 --- /dev/null +++ b/sandtable/templates/sandtable/settings.html @@ -0,0 +1,128 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+
+ Settings +
+
+ +
+ + +
+{% endblock content %} \ No newline at end of file diff --git a/sandtable/templates/sandtable/track.html b/sandtable/templates/sandtable/track.html new file mode 100644 index 0000000..0a72f7f --- /dev/null +++ b/sandtable/templates/sandtable/track.html @@ -0,0 +1,24 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+ +
+
+ +
+ +
+
+
+ {% csrf_token %} + {{ form.field.as_hidden }} + +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/sandtable/templates/sandtable/tracks.html b/sandtable/templates/sandtable/tracks.html new file mode 100644 index 0000000..1cc21b6 --- /dev/null +++ b/sandtable/templates/sandtable/tracks.html @@ -0,0 +1,34 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+
+ +
+
+ +
+
+ Upload +
+
+{% endblock content %} \ No newline at end of file diff --git a/sandtable/templates/sandtable/uploadTracks.html b/sandtable/templates/sandtable/uploadTracks.html new file mode 100644 index 0000000..5789cb3 --- /dev/null +++ b/sandtable/templates/sandtable/uploadTracks.html @@ -0,0 +1,14 @@ +{% extends "sandtable/base.html" %} +{% load static %} + +{% block content %} +
+
+ {% csrf_token %} + {{ form.as_p }} + +
+ +

Return to home

+
+{% endblock content %} \ No newline at end of file diff --git a/sandtable/tests.py b/sandtable/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/sandtable/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/sandtable/urls.py b/sandtable/urls.py new file mode 100644 index 0000000..77b19ed --- /dev/null +++ b/sandtable/urls.py @@ -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/', track, name='track') +] \ No newline at end of file diff --git a/sandtable/views.py b/sandtable/views.py new file mode 100644 index 0000000..0f16eb4 --- /dev/null +++ b/sandtable/views.py @@ -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 \ No newline at end of file diff --git a/thr2png.py b/thr2png.py new file mode 100644 index 0000000..5453af9 --- /dev/null +++ b/thr2png.py @@ -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") + + diff --git a/writeSerial.py b/writeSerial.py new file mode 100644 index 0000000..8bda6a7 --- /dev/null +++ b/writeSerial.py @@ -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(" 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 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(" 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() \ No newline at end of file