Start Gyre from an icon: launch / quit / install-desktop
- python3 -m sandtable install-desktop puts a Gyre icon in the app grid and on the desktop (freedesktop .desktop entry with a "Quit Gyre" action). - The icon runs `launch`: starts the web page in the background (no terminal, own session, log in .cache/server.log) if it isn't running, then opens it. A dialog shows the reason if it can't start. - Quit link on the page (local computer only) and `quit`: stop any design, motors off, close the page. - Gyre icon (sandtable/static/gyre.svg), also the page's tab icon. - Simulator takes several lines per tick, so streaming tests aren't slowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a377af116
commit
db78b1f2e6
9 files changed
+195
-13
No files matched your search
@@ -247,17 +247,15 @@ The full details are in [`software/README.md`](software/README.md). In short, fr
|
|||||||
5. **Glass:** lay it on the sand ring. The two finger notches in the rim help lift it out.
|
5. **Glass:** lay it on the sand ring. The two finger notches in the rim help lift it out.
|
||||||
|
|
||||||
## 14. Everyday use
|
## 14. Everyday use
|
||||||
From the `software` folder, run:
|
Once, from the `software` folder, run `python3 -m sandtable install-desktop`. It adds a **Gyre** icon to your app grid and desktop (Linux). On GNOME, right-click the desktop icon once and choose **Allow Launching**.
|
||||||
```
|
|
||||||
python3 -m sandtable web
|
From then on, click the Gyre icon. It opens a page in your browser with the design library:
|
||||||
```
|
|
||||||
This opens a page in your browser with the design library:
|
|
||||||
- click a design to draw it; it homes and erases the sand first;
|
- click a design to draw it; it homes and erases the sand first;
|
||||||
- **Play all** runs the whole list, shuffled and repeating;
|
- **Play all** runs the whole list, shuffled and repeating;
|
||||||
- when the designs finish (or you press Stop), the motors switch off and the page shows "Motors off". The next design homes first;
|
- when the designs finish (or you press Stop), the motors switch off and the page shows "Motors off". The next design homes first;
|
||||||
- **Upload** takes `.thr` files (from [sandify.org](https://sandify.org)) and `.svg` line drawings.
|
- **Upload** takes `.thr` files (from [sandify.org](https://sandify.org)) and `.svg` line drawings.
|
||||||
|
|
||||||
Leave the terminal open while the table runs. See [`software/README.md`](software/README.md) for everything else.
|
**Quit**, at the bottom of the page, closes Gyre. See [`software/README.md`](software/README.md) for everything else, and [`pi/README.md`](pi/README.md) to run the table from a Raspberry Pi Zero 2 W with no computer at all.
|
||||||
|
|
||||||
## 15. Troubleshooting
|
## 15. Troubleshooting
|
||||||
| Symptom | Likely cause | Fix |
|
| Symptom | Likely cause | Fix |
|
||||||
|
|||||||
+6
-4
@@ -10,10 +10,12 @@ A small Python program that drives the table from your computer over USB. It has
|
|||||||
**Status:** running on the table since 2026-11-01. On a new build, do the first-run steps below in order.
|
**Status:** running on the table since 2026-11-01. On a new build, do the first-run steps below in order.
|
||||||
|
|
||||||
## Web page (everyday use)
|
## Web page (everyday use)
|
||||||
```
|
**Start it from the Gyre icon** in your app grid or on the desktop. It starts Gyre in the background, with no terminal, and opens http://127.0.0.1:8765 in your browser. Clicking it again while Gyre runs just opens the page.
|
||||||
python3 -m sandtable web
|
- **Close it:** use **Quit** at the bottom of the page, or right-click the icon and choose **Quit Gyre**. Either way any design stops and the motors switch off first.
|
||||||
```
|
- **One-time setup (Linux):** `python3 -m sandtable install-desktop` adds the icon to the app grid and the desktop. On GNOME, right-click the desktop icon once and choose **Allow Launching**. To add it to the dock, right-click it in the app grid and choose **Pin to Dash**.
|
||||||
This opens http://127.0.0.1:8765 in your browser. The table stays on the USB cable and the page is the remote control, so leave the program running in its terminal (Ctrl-C there quits).
|
- **If it can't start,** a pop-up shows the reason. The log is `.cache/server.log`.
|
||||||
|
- **From a terminal:** `python3 -m sandtable web` does the same in the foreground (Ctrl-C quits).
|
||||||
|
- **On a Raspberry Pi** inside the table, there's no computer to start at all: see `../pi/README.md`.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -452,6 +452,9 @@ def main(argv=None):
|
|||||||
sub = ap.add_subparsers(dest='cmd', required=True)
|
sub = ap.add_subparsers(dest='cmd', required=True)
|
||||||
sub.add_parser('ports', help='list serial ports')
|
sub.add_parser('ports', help='list serial ports')
|
||||||
sub.add_parser('info', help='connect and show the controller and its settings')
|
sub.add_parser('info', help='connect and show the controller and its settings')
|
||||||
|
sub.add_parser('launch', help='start the web page in the background if needed and open it (the Gyre icon)')
|
||||||
|
sub.add_parser('quit', help='close the web page started by launch (stops any design, motors off)')
|
||||||
|
sub.add_parser('install-desktop', help='put a Gyre icon in the app grid and on the desktop (Linux)')
|
||||||
for name, text in (('web', 'the web page: a library of designs to play, plus the jog panel'),
|
for name, text in (('web', 'the web page: a library of designs to play, plus the jog panel'),
|
||||||
('ui', 'the same web page, opened at the jog panel: home, jog, calibrate')):
|
('ui', 'the same web page, opened at the jog panel: home, jog, calibrate')):
|
||||||
u = sub.add_parser(name, help=text)
|
u = sub.add_parser(name, help=text)
|
||||||
@@ -483,5 +486,9 @@ def main(argv=None):
|
|||||||
m.add_argument('--no-home', action='store_true')
|
m.add_argument('--no-home', action='store_true')
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
cfg = config.load(args.config)
|
cfg = config.load(args.config)
|
||||||
|
if args.cmd in ('launch', 'quit', 'install-desktop'): # no USB connection needed here
|
||||||
|
from . import launcher
|
||||||
|
sys.exit({'launch': launcher.launch, 'quit': launcher.quit_gyre,
|
||||||
|
'install-desktop': lambda: launcher.install_desktop() and 0}[args.cmd]())
|
||||||
{'ports': cmd_ports, 'info': cmd_info, 'flash': cmd_flash, 'ui': cmd_web, 'web': cmd_web, 'setup': cmd_setup, 'test': cmd_test, 'home': cmd_home,
|
{'ports': cmd_ports, 'info': cmd_info, 'flash': cmd_flash, 'ui': cmd_web, 'web': cmd_web, 'setup': cmd_setup, 'test': cmd_test, 'home': cmd_home,
|
||||||
'play': cmd_play, 'gen': cmd_gen, 'erase': cmd_erase, 'move': cmd_move}[args.cmd](cfg, args)
|
'play': cmd_play, 'gen': cmd_gen, 'erase': cmd_erase, 'move': cmd_move}[args.cmd](cfg, args)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Start Gyre from a desktop icon: run the web page in the background (no terminal window) and
|
||||||
|
open it in the browser. Used by the Gyre icon (`install-desktop` puts it in the app grid and on
|
||||||
|
the desktop):
|
||||||
|
python3 -m sandtable launch start if needed, then open the page
|
||||||
|
python3 -m sandtable quit stop any design, motors off, close Gyre
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
SOFTWARE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ICON = os.path.join(SOFTWARE, 'sandtable', 'static', 'gyre.svg')
|
||||||
|
LOG = os.path.join(SOFTWARE, '.cache', 'server.log')
|
||||||
|
|
||||||
|
|
||||||
|
def status(port):
|
||||||
|
"""The running Gyre page's status, or None if nothing (or something else) answers."""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f'http://127.0.0.1:{port}/api/status', timeout=1.5) as r:
|
||||||
|
d = json.load(r)
|
||||||
|
return d if 'machine' in d else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def say(title, msg, error=False):
|
||||||
|
"""Tell the person at the desktop: a dialog if possible, else a notification."""
|
||||||
|
print(f'{title}: {msg}', flush=True)
|
||||||
|
if shutil.which('zenity'):
|
||||||
|
subprocess.run(['zenity', '--error' if error else '--info', '--title', title, '--width', '420',
|
||||||
|
'--text', msg], stderr=subprocess.DEVNULL)
|
||||||
|
elif shutil.which('notify-send'):
|
||||||
|
subprocess.run(['notify-send', '-a', 'Gyre', title, msg])
|
||||||
|
|
||||||
|
|
||||||
|
def launch(port=8765):
|
||||||
|
if status(port) is None:
|
||||||
|
os.makedirs(os.path.dirname(LOG), exist_ok=True)
|
||||||
|
log = open(LOG, 'a')
|
||||||
|
log.write(time.strftime('\n=== %Y-%m-%d %H:%M:%S starting from the Gyre icon\n'))
|
||||||
|
log.flush()
|
||||||
|
subprocess.Popen([sys.executable, '-m', 'sandtable', 'web', '--no-browser', '--http-port', str(port)],
|
||||||
|
cwd=SOFTWARE, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||||
|
start_new_session=True) # keeps running after this launcher exits
|
||||||
|
t_end = time.time() + 25
|
||||||
|
while time.time() < t_end and status(port) is None:
|
||||||
|
time.sleep(0.4)
|
||||||
|
if status(port) is None:
|
||||||
|
with open(LOG) as f:
|
||||||
|
tail = ''.join(f.readlines()[-12:])
|
||||||
|
say('Gyre could not start', f'The page did not come up. The last lines of its log:\n\n{tail}', error=True)
|
||||||
|
return 1
|
||||||
|
webbrowser.open(f'http://127.0.0.1:{port}/')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def quit_gyre(port=8765):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(f'http://127.0.0.1:{port}/api/quit', data=b'{}', method='POST',
|
||||||
|
headers={'Content-Type': 'application/json'})
|
||||||
|
with urllib.request.urlopen(req, timeout=40) as r:
|
||||||
|
ok = json.load(r).get('ok')
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
say('Gyre', 'Gyre is not running.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def install_desktop():
|
||||||
|
"""Put a Gyre icon in the app grid and on the desktop (Linux desktops that follow the
|
||||||
|
freedesktop.org standard: GNOME, KDE, Cinnamon, Xfce...)."""
|
||||||
|
entry = f"""[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Gyre
|
||||||
|
GenericName=Sand art table
|
||||||
|
Comment=Draw designs on the Gyre sand table
|
||||||
|
Exec={sys.executable} -m sandtable launch
|
||||||
|
Path={SOFTWARE}
|
||||||
|
Icon={ICON}
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;
|
||||||
|
StartupNotify=false
|
||||||
|
Actions=quit;
|
||||||
|
|
||||||
|
[Desktop Action quit]
|
||||||
|
Name=Quit Gyre
|
||||||
|
Exec={sys.executable} -m sandtable quit
|
||||||
|
"""
|
||||||
|
apps = os.path.expanduser('~/.local/share/applications')
|
||||||
|
os.makedirs(apps, exist_ok=True)
|
||||||
|
places = [os.path.join(apps, 'gyre.desktop')]
|
||||||
|
desktop = subprocess.run(['xdg-user-dir', 'DESKTOP'], capture_output=True, text=True).stdout.strip() \
|
||||||
|
if shutil.which('xdg-user-dir') else os.path.expanduser('~/Desktop')
|
||||||
|
if desktop and os.path.isdir(desktop) and desktop != os.path.expanduser('~'):
|
||||||
|
places.append(os.path.join(desktop, 'Gyre.desktop'))
|
||||||
|
for p in places:
|
||||||
|
with open(p, 'w') as f:
|
||||||
|
f.write(entry)
|
||||||
|
os.chmod(p, 0o755)
|
||||||
|
if shutil.which('gio'): # GNOME: allow launching from the desktop
|
||||||
|
subprocess.run(['gio', 'set', p, 'metadata::trusted', 'true'], stderr=subprocess.DEVNULL)
|
||||||
|
if shutil.which('update-desktop-database'):
|
||||||
|
subprocess.run(['update-desktop-database', apps], stderr=subprocess.DEVNULL)
|
||||||
|
for p in places:
|
||||||
|
print(' wrote', p)
|
||||||
|
return places
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="sand" cx="45%" cy="40%" r="65%">
|
||||||
|
<stop offset="0" stop-color="#f3e8d2"/><stop offset="1" stop-color="#d9c6a3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="ball" cx="35%" cy="30%" r="70%">
|
||||||
|
<stop offset="0" stop-color="#ffffff"/><stop offset="0.45" stop-color="#b9bec4"/><stop offset="1" stop-color="#5d6268"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="128" cy="128" r="122" fill="#1d1d1f"/>
|
||||||
|
<circle cx="128" cy="128" r="104" fill="url(#sand)"/>
|
||||||
|
<path d="M128.0,122.0 L128.3,121.8 L128.6,121.7 L129.0,121.5 L129.4,121.4 L129.7,121.3 L130.1,121.2 L130.5,121.1 L130.9,121.1 L131.4,121.1 L131.8,121.1 L132.2,121.1 L132.7,121.2 L133.1,121.3 L133.6,121.4 L134.1,121.5 L134.5,121.7 L135.0,121.9 L135.4,122.2 L135.8,122.4 L136.3,122.7 L136.7,123.1 L137.1,123.4 L137.5,123.8 L137.9,124.2 L138.2,124.7 L138.6,125.1 L138.9,125.6 L139.2,126.2 L139.4,126.7 L139.7,127.3 L139.9,127.9 L140.1,128.5 L140.2,129.1 L140.3,129.7 L140.4,130.4 L140.5,131.0 L140.5,131.7 L140.5,132.4 L140.4,133.1 L140.3,133.8 L140.2,134.5 L140.0,135.2 L139.8,135.9 L139.5,136.6 L139.2,137.3 L138.9,138.0 L138.5,138.6 L138.1,139.3 L137.6,139.9 L137.1,140.5 L136.6,141.1 L136.0,141.7 L135.4,142.3 L134.7,142.8 L134.1,143.3 L133.3,143.8 L132.6,144.2 L131.8,144.6 L131.0,144.9 L130.2,145.3 L129.3,145.5 L128.4,145.8 L127.5,146.0 L126.6,146.1 L125.7,146.2 L124.8,146.3 L123.8,146.3 L122.8,146.2 L121.9,146.1 L120.9,145.9 L119.9,145.7 L119.0,145.5 L118.0,145.2 L117.0,144.8 L116.1,144.4 L115.2,143.9 L114.3,143.4 L113.4,142.8 L112.5,142.2 L111.7,141.5 L110.9,140.8 L110.1,140.0 L109.3,139.2 L108.6,138.3 L108.0,137.4 L107.3,136.5 L106.8,135.5 L106.2,134.5 L105.7,133.4 L105.3,132.3 L104.9,131.2 L104.6,130.1 L104.3,128.9 L104.1,127.7 L104.0,126.5 L103.9,125.3 L103.9,124.0 L103.9,122.8 L104.0,121.5 L104.2,120.3 L104.5,119.0 L104.8,117.8 L105.1,116.5 L105.6,115.3 L106.1,114.1 L106.7,112.9 L107.3,111.7 L108.0,110.6 L108.8,109.5 L109.6,108.4 L110.5,107.3 L111.4,106.3 L112.4,105.4 L113.5,104.5 L114.6,103.6 L115.7,102.8 L117.0,102.0 L118.2,101.3 L119.5,100.7 L120.8,100.1 L122.2,99.6 L123.6,99.2 L125.1,98.8 L126.5,98.5 L128.0,98.2 L129.5,98.1 L131.0,98.0 L132.6,98.0 L134.1,98.1 L135.6,98.3 L137.2,98.5 L138.7,98.8 L140.2,99.2 L141.8,99.7 L143.2,100.3 L144.7,100.9 L146.2,101.6 L147.6,102.4 L149.0,103.3 L150.3,104.2 L151.6,105.3 L152.9,106.3 L154.1,107.5 L155.2,108.7 L156.3,110.0 L157.4,111.4 L158.3,112.8 L159.2,114.3 L160.1,115.8 L160.8,117.3 L161.5,119.0 L162.1,120.6 L162.6,122.3 L163.0,124.0 L163.4,125.8 L163.6,127.6 L163.8,129.4 L163.9,131.2 L163.9,133.0 L163.8,134.8 L163.6,136.7 L163.3,138.5 L162.9,140.3 L162.4,142.1 L161.8,143.9 L161.1,145.7 L160.4,147.4 L159.5,149.1 L158.5,150.8 L157.5,152.4 L156.4,154.0 L155.2,155.5 L153.9,157.0 L152.5,158.4 L151.1,159.8 L149.5,161.0 L148.0,162.2 L146.3,163.4 L144.6,164.4 L142.8,165.4 L141.0,166.3 L139.1,167.0 L137.1,167.7 L135.2,168.3 L133.2,168.8 L131.1,169.2 L129.0,169.5 L127.0,169.7 L124.8,169.8 L122.7,169.8 L120.6,169.6 L118.5,169.4 L116.4,169.0 L114.2,168.6 L112.2,168.0 L110.1,167.4 L108.0,166.6 L106.0,165.7 L104.1,164.7 L102.1,163.6 L100.3,162.4 L98.4,161.1 L96.7,159.7 L95.0,158.2 L93.4,156.7 L91.8,155.0 L90.3,153.2 L89.0,151.4 L87.7,149.5 L86.5,147.5 L85.4,145.5 L84.4,143.4 L83.5,141.2 L82.7,139.0 L82.0,136.8 L81.4,134.5 L81.0,132.1 L80.6,129.8 L80.4,127.4 L80.3,125.0 L80.3,122.6 L80.5,120.2 L80.7,117.7 L81.1,115.3 L81.6,112.9 L82.3,110.6 L83.0,108.2 L83.9,105.9 L84.9,103.6 L86.0,101.4 L87.3,99.2 L88.6,97.1 L90.1,95.0 L91.7,93.0 L93.3,91.1 L95.1,89.2 L97.0,87.5 L98.9,85.8 L101.0,84.3 L103.1,82.8 L105.4,81.4 L107.7,80.2 L110.0,79.0 L112.4,78.0 L114.9,77.1 L117.5,76.3 L120.0,75.7 L122.7,75.1 L125.3,74.8 L128.0,74.5 L130.7,74.4 L133.4,74.4 L136.1,74.5 L138.8,74.8 L141.5,75.3 L144.2,75.8 L146.9,76.5 L149.5,77.4 L152.1,78.3 L154.7,79.5 L157.2,80.7 L159.6,82.1 L162.0,83.6 L164.3,85.2 L166.6,86.9 L168.7,88.8 L170.8,90.8 L172.8,92.8 L174.6,95.0 L176.4,97.3 L178.0,99.7 L179.6,102.1 L181.0,104.7 L182.2,107.3 L183.4,110.0 L184.4,112.8 L185.3,115.6 L186.0,118.4 L186.6,121.3 L187.1,124.3 L187.4,127.3 L187.5,130.2 L187.5,133.3 L187.4,136.3 L187.1,139.3 L186.6,142.3 L186.0,145.3 L185.3,148.2 L184.3,151.1 L183.3,154.0 L182.1,156.8 L180.7,159.6 L179.2,162.3 L177.6,165.0 L175.8,167.6 L173.9,170.0 L171.9,172.4 L169.7,174.7 L167.4,176.9 L165.0,179.0 L162.5,180.9 L159.9,182.8 L157.2,184.5 L154.4,186.0 L151.5,187.5 L148.6,188.7 L145.6,189.9 L142.5,190.9 L139.3,191.7 L136.1,192.4 L132.9,192.9 L129.6,193.3 L126.4,193.4 L123.1,193.5 L119.7,193.3 L116.4,193.0 L113.1,192.5 L109.9,191.9 L106.6,191.1 L103.4,190.1 L100.2,189.0 L97.1,187.7 L94.1,186.2 L91.1,184.6 L88.2,182.8 L85.4,180.9 L82.6,178.8 L80.0,176.6 L77.5,174.3 L75.1,171.8 L72.8,169.2 L70.6,166.5 L68.6,163.6 L66.7,160.7 L65.0,157.7 L63.4,154.5 L62.0,151.3 L60.7,148.0 L59.6,144.7 L58.7,141.2 L57.9,137.8 L57.3,134.2 L56.9,130.7 L56.6,127.1 L56.6,123.5 L56.7,119.9 L57.0,116.3 L57.5,112.7 L58.2,109.1 L59.0,105.6 L60.1,102.1 L61.3,98.6 L62.7,95.2 L64.3,91.9 L66.0,88.6 L67.9,85.5 L69.9,82.4 L72.2,79.4 L74.5,76.5 L77.1,73.8 L79.7,71.1 L82.5,68.6 L85.5,66.3 L88.5,64.0 L91.7,62.0 L95.0,60.1 L98.4,58.3 L101.8,56.7 L105.4,55.3 L109.0,54.1 L112.7,53.1 L116.5,52.2 L120.3,51.5 L124.1,51.0 L128.0,50.8 L131.9,50.7 L135.8,50.8 L139.7,51.1 L143.6,51.6 L147.4,52.3 L151.3,53.1 L155.1,54.2 L158.8,55.5 L162.5,57.0 L166.1,58.6 L169.7,60.5 L173.1,62.5 L176.5,64.7 L179.7,67.1 L182.8,69.6 L185.8,72.3 L188.7,75.2 L191.4,78.2 L194.0,81.3 L196.4,84.6 L198.7,88.0 L200.8,91.Line truncated
|
||||||
|
<circle cx="206.0" cy="102.7" r="11" fill="url(#ball)" stroke="#3a3d41" stroke-width="1.5"/>
|
||||||
|
<circle cx="128" cy="128" r="104" fill="none" stroke="#000" stroke-opacity="0.25" stroke-width="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -14,6 +14,7 @@ from .grbl import RT_HOLD, RT_JOG_CANCEL, GrblError
|
|||||||
PAGE = r"""<!doctype html>
|
PAGE = r"""<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Gyre - Jog</title>
|
<title>Gyre - Jog</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#f6f4ef;--card:#fff;--ink:#222;--mute:#666;--line:#ddd;--acc:#2f6f5e;--stop:#c0392b}
|
:root{--bg:#f6f4ef;--card:#fff;--ink:#222;--mute:#666;--line:#ddd;--acc:#2f6f5e;--stop:#c0392b}
|
||||||
@media (prefers-color-scheme:dark){:root{--bg:#1b1c1e;--card:#26282b;--ink:#eee;--mute:#aaa;--line:#3a3d41;--acc:#5fb49c}}
|
@media (prefers-color-scheme:dark){:root{--bg:#1b1c1e;--card:#26282b;--ink:#eee;--mute:#aaa;--line:#3a3d41;--acc:#5fb49c}}
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ class Player:
|
|||||||
PAGE = r"""<!doctype html>
|
PAGE = r"""<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Gyre - Sand Art Table</title>
|
<title>Gyre - Sand Art Table</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#f4f1ea;--card:#fffdf8;--ink:#2a2622;--mute:#6f675d;--line:#e2dbcf;--acc:#2f6f5e;--acc-ink:#fff;--stop:#c0392b;--bar:#e9e2d4}
|
:root{--bg:#f4f1ea;--card:#fffdf8;--ink:#2a2622;--mute:#6f675d;--line:#e2dbcf;--acc:#2f6f5e;--acc-ink:#fff;--stop:#c0392b;--bar:#e9e2d4}
|
||||||
@media (prefers-color-scheme:dark){:root{--bg:#18191b;--card:#232528;--ink:#ece8e1;--mute:#a59e94;--line:#36393d;--acc:#5fb49c;--acc-ink:#0d1f1a;--bar:#2e3135}}
|
@media (prefers-color-scheme:dark){:root{--bg:#18191b;--card:#232528;--ink:#ece8e1;--mute:#a59e94;--line:#36393d;--acc:#5fb49c;--acc-ink:#0d1f1a;--bar:#2e3135}}
|
||||||
@@ -431,6 +432,7 @@ a{color:var(--acc)}
|
|||||||
<footer>Click a design to draw it. Each one starts with an erase spiral (turn off "erase first" to draw over the sand as it is).
|
<footer>Click a design to draw it. Each one starts with an erase spiral (turn off "erase first" to draw over the sand as it is).
|
||||||
Make your own at <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball.
|
Make your own at <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball.
|
||||||
· <a href="/jog">Jog & calibrate</a>
|
· <a href="/jog">Jog & calibrate</a>
|
||||||
|
<span id="quit" style="display:none"> · <a href="#" onclick="return quitApp()">Quit</a></span>
|
||||||
<span id="power" style="display:none"> · <a href="#" onclick="return powerAct('reboot')">Restart</a> · <a href="#" onclick="return powerAct('poweroff')">Power off</a></span></footer>
|
<span id="power" style="display:none"> · <a href="#" onclick="return powerAct('reboot')">Restart</a> · <a href="#" onclick="return powerAct('poweroff')">Power off</a></span></footer>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
@@ -506,6 +508,7 @@ async function tick(){
|
|||||||
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
|
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
|
||||||
lastConnErr=m.connected?'':m.error;
|
lastConnErr=m.connected?'':m.error;
|
||||||
$('power').style.display = ST.power_buttons ? 'inline' : 'none';
|
$('power').style.display = ST.power_buttons ? 'inline' : 'none';
|
||||||
|
$('quit').style.display = ST.can_quit ? 'inline' : 'none';
|
||||||
if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;}
|
if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;}
|
||||||
if(p.message) showErr(p.message);
|
if(p.message) showErr(p.message);
|
||||||
const now=p.now;
|
const now=p.now;
|
||||||
@@ -521,12 +524,17 @@ async function tick(){
|
|||||||
markPlaying();
|
markPlaying();
|
||||||
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
|
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
|
||||||
}
|
}
|
||||||
|
function quitApp(){
|
||||||
|
if(!confirm('Close Gyre? Any design stops and the motors switch off.')) return false;
|
||||||
|
post('quit').then(j=>{ if(j.ok){ clearInterval(TICK); showErr(j.msg); $('mach').textContent='closed'; } });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function powerAct(what){
|
function powerAct(what){
|
||||||
const q = what==='poweroff' ? 'Power off the table\'s computer? Unplug it only after the green light on the Pi stops blinking.' : 'Restart the table\'s computer? The page comes back in about a minute.';
|
const q = what==='poweroff' ? 'Power off the table\'s computer? Unplug it only after the green light on the Pi stops blinking.' : 'Restart the table\'s computer? The page comes back in about a minute.';
|
||||||
if(confirm(q)) post(what).then(j=>{ if(j.ok) showErr(j.msg); });
|
if(confirm(q)) post(what).then(j=>{ if(j.ok) showErr(j.msg); });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
loadLib(); tick(); setInterval(tick,1000);
|
loadLib(); tick(); const TICK=setInterval(tick,1000);
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -582,6 +590,9 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
|
|||||||
q = parse_qs(u.query)
|
q = parse_qs(u.query)
|
||||||
if u.path in ('/', '/index.html'):
|
if u.path in ('/', '/index.html'):
|
||||||
self._send(200, PAGE, 'text/html; charset=utf-8')
|
self._send(200, PAGE, 'text/html; charset=utf-8')
|
||||||
|
elif u.path == '/favicon.svg':
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), 'static', 'gyre.svg'), 'rb') as f:
|
||||||
|
self._send(200, f.read(), 'image/svg+xml')
|
||||||
elif u.path == '/jog':
|
elif u.path == '/jog':
|
||||||
self._send(200, jog_page, 'text/html; charset=utf-8')
|
self._send(200, jog_page, 'text/html; charset=utf-8')
|
||||||
elif u.path == '/api/status':
|
elif u.path == '/api/status':
|
||||||
@@ -590,7 +601,8 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
|
|||||||
self._send(200, json.dumps({'machine': st, 'player': player.status(),
|
self._send(200, json.dumps({'machine': st, 'player': player.status(),
|
||||||
'state': st['state'], 'theta': st['theta'], 'rho': st['rho'],
|
'state': st['state'], 'theta': st['theta'], 'rho': st['rho'],
|
||||||
'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'],
|
'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'],
|
||||||
'error': st['error'], 'power_buttons': POWER_BUTTONS}))
|
'error': st['error'], 'power_buttons': POWER_BUTTONS,
|
||||||
|
'can_quit': not POWER_BUTTONS}))
|
||||||
elif u.path == '/api/library':
|
elif u.path == '/api/library':
|
||||||
self._send(200, json.dumps(lib.items()))
|
self._send(200, json.dumps(lib.items()))
|
||||||
elif u.path == '/thumb':
|
elif u.path == '/thumb':
|
||||||
@@ -642,6 +654,20 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
|
|||||||
lib.remove(body.get('id', '')); ok, msg = True, 'removed'
|
lib.remove(body.get('id', '')); ok, msg = True, 'removed'
|
||||||
elif act in ('poweroff', 'reboot'):
|
elif act in ('poweroff', 'reboot'):
|
||||||
ok, msg = power(act)
|
ok, msg = power(act)
|
||||||
|
elif act == 'quit':
|
||||||
|
if POWER_BUTTONS or self.client_address[0] not in ('127.0.0.1', '::1'):
|
||||||
|
ok, msg = False, 'Quit only works on the computer Gyre runs on'
|
||||||
|
else:
|
||||||
|
log('quit from the page or the Gyre icon')
|
||||||
|
player.stop()
|
||||||
|
try:
|
||||||
|
if panel.connected:
|
||||||
|
with panel.lock:
|
||||||
|
ctrl.motors_off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
threading.Thread(target=srv.shutdown, daemon=True).start()
|
||||||
|
ok, msg = True, 'Gyre is closed. Start it again from the Gyre icon.'
|
||||||
elif act in ('jog', 'home', 'unlock', 'calibrate'):
|
elif act in ('jog', 'home', 'unlock', 'calibrate'):
|
||||||
ok, msg = busy() or {
|
ok, msg = busy() or {
|
||||||
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
|
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
|
||||||
|
|||||||
@@ -195,7 +195,8 @@ class FakeGrbl:
|
|||||||
real_dt = now - self.t_last
|
real_dt = now - self.t_last
|
||||||
dt = real_dt * self.speedup
|
dt = real_dt * self.speedup
|
||||||
self.t_last = now
|
self.t_last = now
|
||||||
self._take_line()
|
for _ in range(16): # take several queued lines per tick, as fast as GRBL
|
||||||
|
self._take_line()
|
||||||
if self.sleeping:
|
if self.sleeping:
|
||||||
self.state = 'Sleep'
|
self.state = 'Sleep'
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -225,6 +225,26 @@ class TestReconnect(unittest.TestCase):
|
|||||||
fake.close()
|
fake.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuit(unittest.TestCase):
|
||||||
|
def test_quit_closes_the_page_and_rests_the_motors(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
lib = make_library(tmp)
|
||||||
|
fake, c = make_controller(100)
|
||||||
|
s = socket.socket(); s.bind(('127.0.0.1', 0)); port = s.getsockname()[1]; s.close()
|
||||||
|
th = threading.Thread(target=serve, args=(c, CFG), kwargs={'port': port, 'open_browser': False, 'library': lib},
|
||||||
|
daemon=True)
|
||||||
|
th.start()
|
||||||
|
from sandtable import launcher
|
||||||
|
self.assertTrue(wait_for(lambda: launcher.status(port) is not None, 10))
|
||||||
|
self.assertTrue(launcher.status(port)['can_quit'])
|
||||||
|
launcher.quit_gyre(port)
|
||||||
|
th.join(10)
|
||||||
|
self.assertFalse(th.is_alive()) # the page has closed
|
||||||
|
self.assertIsNone(launcher.status(port))
|
||||||
|
self.assertTrue(fake.sleeping) # motors off
|
||||||
|
fake.close()
|
||||||
|
|
||||||
|
|
||||||
class TestHttp(unittest.TestCase):
|
class TestHttp(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
|
|||||||
Reference in new issue
Block a user