Files
sandTable/software/sandtable/config.py
T
ckochandClaude Opus 5.5 7afe562124 Switch the motors off when the designs are done or stopped
After the last design (or a Stop), the player sends $SLP (GRBL and FluidNC
sleep), so the motors stop holding and the table is quiet. The page shows
"Motors off"; the next design wakes the controller (reset + unlock) and homes
first, since the arm can be moved by hand while the motors are off. The jog
panel wakes it too. Setting: motion.motors_off_when_done (default true).

Checked on the real Uno: Idle -> $SLP -> Sleep -> reset -> Alarm -> $X -> Idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:19:12 -05:00

119 lines
4.8 KiB
Python

"""Machine settings. Defaults below; sandtable.toml (next to the software folder's README) overrides them."""
import copy
import os
import tomllib
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PATH = os.path.join(os.path.dirname(HERE), 'sandtable.toml')
DEFAULTS = {
'serial': {
'port': 'auto', # 'auto' picks the only Arduino / USB-serial port, or e.g. '/dev/ttyACM0'
'baud': 115200,
},
'controller': {
'type': 'grbl', # 'grbl' (Arduino Uno + CNC shield) or 'fluidnc' (the custom ESP32 board)
},
'machine': {
'rho_max_mm': 133.0, # ball reach from the centre (design: rho 0-133 mm)
'rho_min_mm': 3.0, # patterns stay this far out: 1 mm past where the home switch trips
# (homing.switch_rho_mm), so they never press it. A press while a
# pattern runs then means the carriage lost its place: it stops at once.
'theta_steps_per_deg': 71.111, # 200 steps x 32 microsteps x 80T/20T / 360
'rho_steps_per_mm': 160.0, # 200 x 32 / (20T GT2 = 40 mm)
'invert_theta': False, # flip if patterns come out mirrored (set by `test`)
'theta_offset_deg': 0.0, # rotate every pattern by this much
},
'motion': {
'ball_speed_mm_s': 25.0, # drawing speed of the ball across the sand
'motors_off_when_done': True, # switch the motors off when the designs are done or stopped
# (quiet, cool); the next design homes first
'max_theta_deg_s': 60.0, # arm spin limit (10 rpm)
'max_rho_mm_s': 25.0, # carriage limit
'theta_accel_deg_s2': 120.0,
'rho_accel_mm_s2': 100.0,
'max_segment_mm': 1.5, # long moves are split so the speed stays even
'max_segment_deg': 3.0,
'min_segment_mm': 0.3, # closer points are merged (very dense .thr files)
},
'homing': {
'seek_mm_s': 6.0,
'feed_mm_s': 1.0,
'pulloff_mm': 2.0,
'switch_rho_mm': 2.0, # rho where the switch trips; Y0 is the centre
'max_travel_mm': 145.0,
},
'erase': {
'enabled': True,
'pitch_mm': 7.0, # spiral spacing; a bit less than the 9.5 mm ball
'speed_mm_s': 35.0,
},
}
# GRBL settings `setup` writes to the Uno (X = theta in degrees, Y = rho in mm)
def grbl_settings(cfg):
m, mo = cfg['machine'], cfg['motion']
return {
'$0': '10', # step pulse, us (DRV8825 needs 1.9)
'$1': '255', # keep the motors powered between moves, so the arm can't drift
'$2': '0',
'$4': '0',
'$5': '0', # limit pin: switch closes to GND, internal pull-up
'$10': '1', # status reports show machine position
'$11': '0.010',
'$13': '0',
'$20': '0', # no soft limits (theta is unlimited)
'$21': '0', # no GRBL hard limits: homing presses the switch on purpose. This program
# watches the switch while patterns run and stops the machine instead.
'$22': '0', # GRBL's own homing off; this program homes rho itself
'$100': f"{m['theta_steps_per_deg']:.3f}",
'$101': f"{m['rho_steps_per_mm']:.3f}",
'$110': f"{mo['max_theta_deg_s'] * 60:.0f}",
'$111': f"{mo['max_rho_mm_s'] * 60:.0f}",
'$120': f"{mo['theta_accel_deg_s2']:.0f}",
'$121': f"{mo['rho_accel_mm_s2']:.0f}",
}
def _merge(base, over):
for k, v in over.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_merge(base[k], v)
else:
base[k] = v
return base
def load(path=None):
cfg = copy.deepcopy(DEFAULTS)
path = path or DEFAULT_PATH
if os.path.exists(path):
with open(path, 'rb') as f:
_merge(cfg, tomllib.load(f))
cfg['_path'] = path
return cfg
def set_value(path, section, key, value):
"""Set one value in the TOML file, keeping the rest of the file as written."""
lines = open(path).read().splitlines() if os.path.exists(path) else []
val = ('true' if value else 'false') if isinstance(value, bool) else (
f'"{value}"' if isinstance(value, str) else repr(value))
out, in_sec, done = [], False, False
for ln in lines:
s = ln.strip()
if s.startswith('['):
if in_sec and not done:
out.append(f'{key} = {val}')
done = True
in_sec = s == f'[{section}]'
elif in_sec and s.split('=')[0].strip() == key:
ln, done = f'{key} = {val}', True
out.append(ln)
if not done:
if not in_sec:
out += ['', f'[{section}]']
out.append(f'{key} = {val}')
with open(path, 'w') as f:
f.write('\n'.join(out) + '\n')