"""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 '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')