Files
onefinity-firmware/src/py/bbctrl/Web.py
Henrik Muehe d797f1d4fc AuxAxis: ESP32-driven external stepper (auxcnc)
bbctrl.AuxAxis manages a stepper driven by an auxcnc-style ESP32
over /dev/ttyUSB0 (or whichever serial port). Persistent config in
aux.json; UI talks to it via /api/aux/* endpoints.

- AuxAxis: serial framing, position tracking, soft-limit enforcement,
  homing state machine, ATC pneumatic control (M100..M103 wrappers).
- Ctrl: instantiate self.aux alongside the other subsystems and
  close it during shutdown.
- Web: handlers for /api/aux/{config,status,home,abort,jog,move,set-zero}.
2026-05-03 15:14:25 +02:00

1140 lines
38 KiB
Python

import os
import json
import tornado
import time
import sockjs.tornado
import datetime
import subprocess
import socket
from tornado.web import HTTPError
from tornado import gen
from tornado.escape import url_unescape
import re
import bbctrl
from urllib.request import urlopen
import iw_parse
import io
import zipfile
import shutil
def call_get_output(cmd):
p = subprocess.Popen(cmd, stdout = subprocess.PIPE)
s = p.communicate()[0].decode('utf-8')
if p.returncode: raise HTTPError(400, 'Command failed')
return s
def get_username():
return call_get_output(['getent', 'passwd', '1001']).split(':')[0]
def set_username(username):
if subprocess.call(['usermod', '-l', username, get_username()]):
raise HTTPError(400, 'Failed to set username to "%s"' % username)
def check_password(password):
# Get current password
s = call_get_output(['getent', 'shadow', get_username()])
current = s.split(':')[1].split('$')
# Check password type
if len(current) < 2 or current[1] != '1':
raise HTTPError(401, "Password invalid")
# Check current password
cmd = ['openssl', 'passwd', '-salt', current[2], '-1', password]
s = call_get_output(cmd).strip()
if s.split('$') != current: raise HTTPError(401, 'Wrong password')
class RebootHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().lcd.goodbye('Rebooting...')
subprocess.Popen(['reboot'])
class ShutdownHandler(bbctrl.APIHandler):
def put_ok(self):
subprocess.Popen(['shutdown','-h','now'])
class LogHandler(bbctrl.RequestHandler):
def get(self):
with open(self.get_ctrl().log.get_path(), 'r') as f:
self.write(f.read())
def set_default_headers(self):
fmt = socket.gethostname() + '-%Y%m%d.log'
filename = datetime.date.today().strftime(fmt)
self.set_header('Content-Disposition', 'filename="%s"' % filename)
self.set_header('Content-Type', 'text/plain')
class MessageAckHandler(bbctrl.APIHandler):
def put_ok(self, id):
self.get_ctrl().state.ack_message(int(id))
class BugReportHandler(bbctrl.RequestHandler):
def get(self):
import tarfile, io
buf = io.BytesIO()
tar = tarfile.open(mode = 'w:bz2', fileobj = buf)
def check_add(path, arcname = None):
if os.path.isfile(path):
if arcname is None: arcname = path
tar.add(path, self.basename + '/' + arcname)
def check_add_basename(path):
check_add(path, os.path.basename(path))
ctrl = self.get_ctrl()
path = ctrl.log.get_path()
check_add_basename(path)
for i in range(1, 8):
check_add_basename('%s.%d' % (path, i))
check_add_basename('/var/log/syslog')
check_add('config.json')
check_add(ctrl.get_upload(ctrl.state.get('selected', '')))
tar.close()
self.write(buf.getvalue())
def set_default_headers(self):
fmt = socket.gethostname() + '-%Y%m%d-%H%M%S'
self.basename = datetime.datetime.now().strftime(fmt)
filename = self.basename + '.tar.bz2'
self.set_header('Content-Disposition', 'filename="%s"' % filename)
self.set_header('Content-Type', 'application/x-bzip2')
class HostnameHandler(bbctrl.APIHandler):
def get(self): self.write_json(socket.gethostname())
def put(self):
if self.get_ctrl().args.demo:
raise HTTPError(400, 'Cannot set hostname in demo mode')
if 'hostname' in self.json:
if subprocess.call(['/usr/local/bin/sethostname',
self.json['hostname'].strip()]) == 0:
self.write_json('ok')
return
raise HTTPError(400, 'Failed to set hostname')
class NetworkData(bbctrl.APIHandler):
def get(self):
try:
ipAddresses = subprocess.check_output(
"ip -4 addr | grep -oE '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}'", shell=True).decode().split()
ipAddresses.remove("127.0.0.1")
regex = re.compile(r'/255$/')
filtered = [i for i in ipAddresses if not regex.match(i)]
ipAddresses = filtered[0]
except:
ipAddresses = "Not Connected"
try:
wifi = subprocess.check_output(
"sudo iw dev wlan0 info | grep ssid", shell=True).decode().split()
wifi.pop(0)
wifiName = " ".join(wifi)
except:
wifiName = "not connected"
self.write_json({
'ipAddresses': ipAddresses,
'wifi': wifiName
})
class NetworkHandler(bbctrl.APIHandler):
def get(self):
try:
ipAddresses = subprocess.check_output(
"ip -4 addr | grep -oE '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}'", shell=True).decode().split()
ipAddresses.remove("127.0.0.1")
regex = re.compile(r'/255$/')
filtered = [i for i in ipAddresses if not regex.match(i)]
ipAddresses = filtered[0]
except:
ipAddresses = "Not Connected"
hostname = socket.gethostname()
try:
wifi = json.loads(call_get_output(['config-wifi', '-j']))
except:
wifi = {'enabled': False}
try:
lines = iw_parse.call_iwlist().decode("utf-8").split("\n")
wifi['networks'] = iw_parse.get_parsed_cells(lines)
except:
wifi['networks'] = []
self.write_json({
'ipAddresses': ipAddresses,
'hostname': hostname,
'wifi': wifi
})
def put(self):
if self.get_ctrl().args.demo:
raise HTTPError(400, 'Cannot configure WiFi in demo mode')
if not 'wifi' in self.json:
raise HTTPError(400, 'Payload is missing wifi config information')
wifi = self.json['wifi']
cmd = ['config-wifi', '-r']
if not wifi['enabled']:
cmd += ['-d']
else:
if 'ssid' in wifi:
cmd += ['-s', wifi['ssid']]
if 'password' in wifi:
cmd += ['-p', wifi['password']]
if subprocess.call(cmd) == 0:
self.write_json('ok')
return
raise HTTPError(400, 'Failed to configure wifi')
class UsernameHandler(bbctrl.APIHandler):
def get(self): self.write_json(get_username())
def put_ok(self):
if self.get_ctrl().args.demo:
raise HTTPError(400, 'Cannot set username in demo mode')
if 'username' in self.json: set_username(self.json['username'])
else: raise HTTPError(400, 'Missing "username"')
class PasswordHandler(bbctrl.APIHandler):
def put(self):
if self.get_ctrl().args.demo:
raise HTTPError(400, 'Cannot set password in demo mode')
if 'current' in self.json and 'password' in self.json:
check_password(self.json['current'])
# Set password
s = '%s:%s' % (get_username(), self.json['password'])
s = s.encode('utf-8')
p = subprocess.Popen(['chpasswd', '-c', 'MD5'],
stdin = subprocess.PIPE)
p.communicate(input = s)
if p.returncode == 0:
self.write_json('ok')
return
raise HTTPError(401, 'Failed to set password')
class ConfigLoadHandler(bbctrl.APIHandler):
def get(self):
config = self.get_ctrl().config.load()
# Check if we're within the first 90 seconds of server boot
web_app = self.application
time_since_boot = time.time() - web_app.server_boot_time
config['_server_first_load'] = time_since_boot <= 90.0 # True if within first 90 seconds
self.write_json(config)
class ConfigDownloadHandler(bbctrl.APIHandler):
def set_default_headers(self):
fmt = socket.gethostname() + '-%Y%m%d.zip'
filename = datetime.date.today().strftime(fmt)
self.set_header('Content-Type', 'application/octet-stream')
self.set_header('Content-Disposition',
'attachment; filename="%s"' % filename)
def get(self,filename):
buffer = io.BytesIO()
zip_file = zipfile.ZipFile(buffer, mode="w")
config_path = self.get_path('config.json')
try:
if os.path.exists(config_path):
zip_file.write(config_path,'config.json')
else:
json_bytes = json.dumps({'version': self.version}).encode("utf-8")
zip_file.writestr("config.json",json_bytes)
except Exception: self.log.exception('Internal error: Failed to download config')
if not filename or filename == '/':
zip_file.close()
buffer.seek(0)
self.write(buffer.getvalue())
self.finish()
filename = filename[1:]
files = filename.split(',')
for filename in files:
filename = os.path.basename(url_unescape(filename))
filepath = self.get_upload(filename)
zip_file.write(filepath, filename)
zip_file.close()
buffer.seek(0)
self.write(buffer.getvalue())
self.finish()
class ConfigRestoreHandler(bbctrl.APIHandler):
def put(self):
if 'zipfile' not in self.request.files:
raise HTTPError(400, 'No file uploaded')
zip_file = self.request.files['zipfile'][0]
temp_dir = './config-temp'
if not os.path.exists(temp_dir):
os.mkdir(temp_dir)
files_path = os.path.join(temp_dir, zip_file['filename'])
with open(files_path, 'wb') as f:
f.write(zip_file['body'])
if not os.path.exists(self.get_upload()):
os.mkdir(self.get_upload())
with zipfile.ZipFile(files_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir+'/extracted')
extension = (".nc", ".ngc", ".gcode", ".gc")
for root, dirs, files in os.walk(temp_dir+'/extracted'):
for file in files:
file_path = os.path.join(root, file)
#Updating the config.json
if file =="config.json":
with open(file_path, 'r') as json_file:
json_data = json.load(json_file)
if "macros" in json_data and isinstance(json_data['macros'], list):
json_data["macros_list"] = [
{"file_name": item["file_name"]}
for item in json_data["macros"]
if isinstance(item, dict) and "file_name" in item and item["file_name"] != "default"
]
else:
json_data["macros_list"] = []
keys_to_remove = ['non_macros_list','gcode_list']
for key in keys_to_remove:
if key in json_data:
del json_data[key]
self.get_ctrl().config.save(json_data)
#moving the gcodes from temp to uploads
elif file.endswith(extension):
filename = self.get_upload(file).encode('utf8')
try:
os.link(file_path,filename)
except FileExistsError as e:
print('File already exists')
self.get_ctrl().preplanner.invalidate(file)
self.get_ctrl().state.add_file(file)
shutil.rmtree(temp_dir)
class ConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().config.save(self.json)
class ConfigResetHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().config.reset()
class FirmwareUpdateHandler(bbctrl.APIHandler):
def prepare(self): pass
def put_ok(self):
if not 'firmware' in self.request.files:
raise HTTPError(401, 'Missing "firmware"')
firmware = self.request.files['firmware'][0]
if not os.path.exists('firmware'): os.mkdir('firmware')
with open('firmware/update.tar.bz2', 'wb') as f:
f.write(firmware['body'])
self.get_ctrl().lcd.goodbye('Upgrading firmware')
subprocess.Popen(['/usr/local/bin/update-bbctrl'])
class UpgradeHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().lcd.goodbye('Upgrading firmware')
subprocess.Popen(['/usr/local/bin/upgrade-bbctrl'])
class PathHandler(bbctrl.APIHandler):
@gen.coroutine
def get(self, filename, dataType, *args):
if not os.path.exists(self.get_upload(filename)):
raise HTTPError(404, 'File not found')
preplanner = self.get_ctrl().preplanner
future = preplanner.get_plan(filename)
try:
delta = datetime.timedelta(seconds = 1)
data = yield gen.with_timeout(delta, future)
except gen.TimeoutError:
progress = preplanner.get_plan_progress(filename)
self.write_json(dict(progress = progress))
return
try:
if data is None: return
meta, positions, speeds = data
if dataType == '/positions': data = positions
elif dataType == '/speeds': data = speeds
else:
self.get_ctrl().state.set_bounds(meta['bounds'])
self.write_json(meta)
return
filename = filename + '-' + dataType[1:]
self.set_header('Content-Disposition', 'filename="%s"' % filename)
self.set_header('Content-Type', 'application/octet-stream')
self.set_header('Content-Encoding', 'gzip')
self.set_header('Content-Length', str(len(data)))
# Respond with chunks to avoid long delays
SIZE = 102400
chunks = [data[i:i + SIZE] for i in range(0, len(data), SIZE)]
for chunk in chunks:
self.write(chunk)
yield self.flush()
except tornado.iostream.StreamClosedError as e: pass
class HomeHandler(bbctrl.APIHandler):
def put_ok(self, axis, action, *args):
if axis is not None: axis = ord(axis[1:2].lower())
if action == '/set':
if not 'position' in self.json:
raise HTTPError(400, 'Missing "position"')
self.get_ctrl().mach.home(axis, self.json['position'])
elif action == '/clear': self.get_ctrl().mach.unhome(axis)
else: self.get_ctrl().mach.home(axis)
class StartHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.start()
class EStopHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.estop()
class ClearHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.clear()
class StopHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.stop()
class PauseHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.pause()
class UnpauseHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.unpause()
class OptionalPauseHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.optional_pause()
class StepHandler(bbctrl.APIHandler):
def put_ok(self): self.get_ctrl().mach.step()
class PositionHandler(bbctrl.APIHandler):
def put_ok(self, axis):
self.get_ctrl().mach.set_position(axis, float(self.json['position']), True)
class OverrideFeedHandler(bbctrl.APIHandler):
def put_ok(self, value): self.get_ctrl().mach.override_feed(float(value))
class OverrideSpeedHandler(bbctrl.APIHandler):
def put_ok(self, value): self.get_ctrl().mach.override_speed(float(value))
class ModbusReadHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().mach.modbus_read(int(self.json['address']))
class ModbusWriteHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().mach.modbus_write(int(self.json['address']),
int(self.json['value']))
class JogHandler(bbctrl.APIHandler):
def put_ok(self):
# Handle possible out of order jog command processing
if 'ts' in self.json:
ts = self.json['ts']
id = self.get_cookie('client-id')
if not hasattr(self.app, 'last_jog'):
self.app.last_jog = {}
last = self.app.last_jog.get(id, 0)
self.app.last_jog[id] = ts
if ts < last: return # Out of order
self.get_ctrl().mach.jog(self.json)
displayRotatePattern = re.compile(r'display_rotate\s*=\s*(\d)')
transformationMatrixPattern = re.compile(
r'(\n)(\s+)(MatchIsTouchscreen.*?\n)(\s+Option\s+\"TransformationMatrix\".*?\n)(.*?EndSection)',
re.DOTALL)
matchIsTouchscreenPattern = re.compile(
r'(\n)(\s+)(MatchIsTouchscreen.*?\n)(.*?EndSection)', re.DOTALL)
class ScreenRotationHandler(bbctrl.APIHandler):
@gen.coroutine
def get(self):
with open("/boot/config.txt", 'rt') as config:
lines = config.readlines()
for line in lines:
if line.startswith('display_rotate'):
self.write_json({
'rotated':
int(displayRotatePattern.search(line).group(1)) != 0
})
return
self.write_json({'rotated': False})
return
@gen.coroutine
def put_ok(self):
rotated = self.json['rotated']
subprocess.Popen([
'/usr/local/bin/edit-boot-config',
'display_rotate={}'.format(2 if rotated else 0)
])
with open("/usr/share/X11/xorg.conf.d/40-libinput.conf",
'rt') as config:
text = config.read()
text = transformationMatrixPattern.sub(r'\1\2\3\5', text)
if rotated:
text = matchIsTouchscreenPattern.sub(
r'\1\2\3\2Option "TransformationMatrix" "-1 0 1 0 -1 1 0 0 1"\1\4',
text)
with open("/usr/share/X11/xorg.conf.d/40-libinput.conf",
'wt') as config:
config.write(text)
subprocess.run('reboot')
class TimeHandler(bbctrl.APIHandler):
def get(self):
timeinfo = call_get_output(['timedatectl'])
timezones = call_get_output(['timedatectl', 'list-timezones', '--no-pager'])
self.write_json({'timeinfo': timeinfo, 'timezones': timezones})
def put_ok(self):
datetime = self.json.get('datetime', None)
timezone = self.json.get('timezone', None)
try:
if datetime is not None:
subprocess.Popen(['sudo','timedatectl', 'set-ntp', 'false'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
result1 = subprocess.Popen(['sudo','date', '-s', datetime], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
subprocess.Popen(['sudo','timedatectl', 'set-ntp', 'true'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
stdout, stderr = result1.communicate()
if(result1.returncode == 0):
self.get_log('TimeHandler').info('Result1 {} : {}'.format(result1.returncode, stdout))
else:
raise Exception(stderr)
if timezone is not None:
result2 = subprocess.Popen(['sudo','timedatectl', 'set-timezone', timezone], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
stdout, stderr = result2.communicate()
if(result2.returncode == 0):
self.get_log('TimeHandler').info('Result2 {}'.format(result2.returncode))
else:
raise Exception(stderr)
except Exception as e:
self.get_log('TimeHandler').info('Error: {}'.format(e))
class RotaryHandler(bbctrl.APIHandler):
def _populate_motors_backup(self, config_data):
"""
Populate and return motors-backup data from onefinity defaults and config template.
"""
log = self.get_log('RotaryHandler')
try:
onefinity_defaults_path = bbctrl.get_resource('http/onefinity_defaults.json')
template_path = bbctrl.get_resource('http/config-template.json')
with open(onefinity_defaults_path, 'r') as f:
defaults = json.load(f)
with open(template_path, 'r') as f:
template = json.load(f)
motors = config_data.get('motors', [])
if not motors:
raise ValueError("Motors data not found")
motors_backup = []
backup_template = template.get('motors-backup', {}).get('template', {})
backup_defaults = defaults.get('motors-backup', [])
for i, motor in enumerate(motors):
backup = {}
for key, field in backup_template.items():
backup[key] = field.get('default')
if i < len(backup_defaults):
for key, value in backup_defaults[i].items():
backup[key] = value
for key in backup_template.keys():
if key not in backup and key in motor:
backup[key] = motor[key]
motors_backup.append(backup)
return motors_backup
except Exception as e:
log.error("Failed to populate motors-backup: {}".format(str(e)))
raise ValueError("Failed to populate motors-backup: {}".format(str(e)))
def _validate_motors_backup(self, motors_backup, rotary_config):
"""
Validate that motors_backup has all required keys from rotary_config.
Returns True if valid, False otherwise.
"""
if not motors_backup or len(motors_backup) < 3:
return False
motor_2_backup = motors_backup[2]
if not motor_2_backup:
return False
# Check if motor_2_backup has all keys from rotary_config
required_keys = set(rotary_config.keys())
backup_keys = set(motor_2_backup.keys())
missing_keys = required_keys - backup_keys
if missing_keys:
self.get_log('RotaryHandler').warning("motors_backup missing required keys: {}".format(missing_keys))
return False
return True
def put_ok(self):
try:
status = self.json.get('status', None)
ctrl = self.get_ctrl()
config = ctrl.config
log = self.get_log('RotaryHandler')
path = ctrl.get_path('config.json')
rotary_config = {
'min-soft-limit': -3600,
'max-soft-limit': 3600,
'max-velocity': 6.696,
'max-accel': 500,
'max-jerk': 1000,
'step-angle': 0.25714,
'travel-per-rev': 360,
"microsteps": 16,
}
try:
if os.path.exists(path):
with open(path, 'r') as f: config_data = json.load(f)
else: config_data = {'version': self.version}
except Exception: log.error('Internal error: Failed to load config template')
motors = config_data.get("motors")
motors_backup = config_data.get("motors-backup")
if not motors:
raise ValueError("Motors data not found in configuration")
# Check whether the motors_backup has all keys as in rotary_config
if not self._validate_motors_backup(motors_backup, rotary_config):
motors_backup = self._populate_motors_backup(config_data)
config_data['motors-backup'] = motors_backup
motor_1 = motors[1]
motor_2 = motors[2]
motor_2_backup = motors_backup[2]
is_rotary_active = motor_2.get("axis") == "A"
if is_rotary_active == status:
return
motor_2["axis"] = "Y" if is_rotary_active else "A"
motor_1["max-velocity"] *= 2 if is_rotary_active else 0.5
if is_rotary_active:
for key in rotary_config.keys():
motor_2[key] = motor_2_backup[key]
else:
for key in rotary_config.keys():
motor_2_backup[key] = motor_2[key]
for key, value in rotary_config.items():
motor_2[key] = value
# Save the configuration using the standard Config.save method
# This ensures proper encoding and state updates
config.save(config_data)
# Force a complete reload to ensure all values are properly encoded and sent to frontend
config.reload()
# This addresses potential issues with microsteps being treated as machine variable
motor_2_final = motors[2]
ctrl.state.set('2mi', motor_2_final.get('microsteps'))
# Explicitly trigger state notification to ensure frontend gets updates
# This forces immediate WebSocket notification of all state changes
ctrl.state._notify()
except FileNotFoundError:
log.error('Configuration file not found at {}'.format(path))
except KeyError as e:
log.error('Missing key in configuration data: {}'.format(e))
except ValueError as e:
log.error('Validation error: {}'.format(e))
except Exception as e:
log.error('Unexpected error: {}'.format(e))
class HooksGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_config())
class HooksSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().hooks.save_config(self.json)
class HooksStatusHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().hooks.get_status())
class HooksFireHandler(bbctrl.APIHandler):
def put_ok(self, event):
data = self.json if hasattr(self, 'json') and self.json else {}
self.get_ctrl().hooks._fire(event, data)
# ----- W axis (auxcnc) endpoints --------------------------------------------
class AuxConfigGetHandler(bbctrl.APIHandler):
def get(self):
self.write_json(self.get_ctrl().aux.get_config())
class AuxConfigSaveHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.save_config(self.json or {})
class AuxStatusHandler(bbctrl.APIHandler):
def get(self):
aux = self.get_ctrl().aux
self.write_json({
'enabled': aux.enabled,
'present': aux.present,
'homed': aux.homed,
'pos_mm': aux.position_mm,
})
class AuxHomeHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.home()
class AuxAbortHandler(bbctrl.APIHandler):
def put_ok(self):
self.get_ctrl().aux.abort()
class AuxJogHandler(bbctrl.APIHandler):
"""Body: {"mm": 1.5} for relative-mm move,
{"steps": 200} for raw step move (bypasses soft limits)."""
def put_ok(self):
body = self.json or {}
aux = self.get_ctrl().aux
if 'mm' in body:
aux.move_rel_mm(float(body['mm']))
elif 'steps' in body:
aux.jog_steps(int(body['steps']))
else:
raise HTTPError(400, 'mm or steps required')
class AuxMoveHandler(bbctrl.APIHandler):
"""Body: {"mm": 12.5} absolute move in mm."""
def put_ok(self):
body = self.json or {}
if 'mm' not in body:
raise HTTPError(400, 'mm required')
self.get_ctrl().aux.move_abs_mm(float(body['mm']))
class AuxSetZeroHandler(bbctrl.APIHandler):
"""Body: {"mm": 0} set current position to <mm>."""
def put_ok(self):
body = self.json or {}
mm = float(body.get('mm', 0.0))
self.get_ctrl().aux.set_position_mm(mm)
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
def get(self):
code = self.get_query_argument("code", "")
command = self.get_query_argument("command", "")
log = self.get_log('RemoteDiagnostics')
if command == "disconnect":
subprocess.Popen(['killall', 'ngrok'])
self.write_json({'message': "Succesfully disconnected"})
if command == "connect":
try:
url = 'https://tinyurl.com/1f-remote?code={}'.format(code)
with urlopen(url) as response:
body = response.read()
os.makedirs("/tmp/ngrok", exist_ok=True)
with open("/tmp/ngrok/1f-ngrok.sh", 'wb') as f:
f.write(body)
subprocess.Popen(['/bin/bash', "/tmp/ngrok/1f-ngrok.sh"])
self.write_json({'success': True})
except Exception as e:
log.info("Failed: {}".format(str(e)))
self.write_json({
'success': False,
'code': e.code or None,
'message': e.reason or "Unknown"
})
class TimingHandler(bbctrl.APIHandler):
"""Return the bbctrl process startup timeline as JSON.
Includes monotonic-anchored events from bbctrl.Trace, the wall
clock anchor (so the timeline can be aligned with journalctl /
systemd-analyze output), and the most recent UI-side timing
payload posted by the browser.
"""
def get(self):
import bbctrl.Trace as _T
self.write_json(_T.timeline())
class UITimingHandler(bbctrl.APIHandler):
"""Browser posts its performance.now() marks here once per load."""
def put_ok(self):
import bbctrl.Trace as _T
# self.json is parsed in APIHandler.prepare()
try:
_T.set_ui_timing(self.json)
_T.mark('ui.posted_timing',
marks=len(self.json.get('marks', []) or []))
except Exception: pass
# Base class for Web Socket connections
class ClientConnection(object):
def __init__(self, app):
self.app = app
self.count = 0
def heartbeat(self):
self.timer = self.app.ioloop.call_later(3, self.heartbeat)
self.send({'heartbeat': self.count})
self.count += 1
def send(self, msg): raise HTTPError(400, 'Not implemented')
def on_open(self, id = None):
self.ctrl = self.app.get_ctrl(id)
self.ctrl.state.add_listener(self.send)
self.ctrl.log.add_listener(self.send)
self.is_open = True
self.heartbeat()
self.app.opened(self.ctrl)
def on_close(self):
self.app.ioloop.remove_timeout(self.timer)
self.ctrl.state.remove_listener(self.send)
self.ctrl.log.remove_listener(self.send)
self.is_open = False
self.app.closed(self.ctrl)
def on_message(self, data):
self.ctrl.mach.mdi(data)
# Used by CAMotics
class WSConnection(ClientConnection, tornado.websocket.WebSocketHandler):
def __init__(self, app, request, **kwargs):
ClientConnection.__init__(self, app)
tornado.websocket.WebSocketHandler.__init__(
self, app, request, **kwargs)
def send(self, msg):
self.write_message(msg)
def open(self):
self.on_open()
# Used by Web frontend
class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection):
def __init__(self, session):
ClientConnection.__init__(self, session.server.app)
sockjs.tornado.SockJSConnection.__init__(self, session)
def send(self, msg):
try:
sockjs.tornado.SockJSConnection.send(self, msg)
except:
self.close()
def on_open(self, info):
cookie = info.get_cookie('client-id')
if cookie is None: self.send(dict(sid = '')) # Trigger client reset
else:
id = cookie.value
ip = info.ip
if 'X-Real-IP' in info.headers: ip = info.headers['X-Real-IP']
self.app.get_ctrl(id).log.get('Web').info('Connection from %s' % ip)
try:
if not getattr(self.app, '_first_ws', False):
self.app._first_ws = True
import bbctrl.Trace as _T
_T.mark('ws.first_open', ip=ip)
except Exception: pass
super().on_open(id)
class StaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
self.set_header('Cache-Control',
'no-store, no-cache, must-revalidate, max-age=0')
def prepare(self):
# Mark the first request for the index page so we can see when
# chromium actually started fetching the UI on cold boot.
try:
app = self.application
if not getattr(app, '_first_root_get', False):
# Treat any GET '/' or '/index.html' as the root fetch.
p = self.request.path
if p in ('/', '/index.html', ''):
app._first_root_get = True
import bbctrl.Trace as _T
_T.mark('web.first_root_get',
ip=self.request.remote_ip,
ua=(self.request.headers.get('User-Agent') or '')[:60])
except Exception: pass
return super().prepare()
class Web(tornado.web.Application):
def __init__(self, args, ioloop):
self.args = args
self.ioloop = ioloop
self.ctrls = {}
self.server_boot_time = time.time() # Track when server started
# Init camera
if not args.disable_camera:
if self.args.demo: log = bbctrl.log.Log(args, ioloop, 'camera.log')
else: log = self.get_ctrl().log
self.camera = bbctrl.Camera(ioloop, args, log)
else: self.camera = None
# Init controller
if not self.args.demo:
self.get_ctrl()
self.monitor = bbctrl.MonitorTemp(self)
handlers = [
(r'/websocket', WSConnection),
(r'/api/diag/timing', TimingHandler),
(r'/api/diag/timing/ui', UITimingHandler),
(r'/api/log', LogHandler),
(r'/api/message/(\d+)/ack', MessageAckHandler),
(r'/api/bugreport', BugReportHandler),
(r'/api/reboot', RebootHandler),
(r'/api/shutdown', ShutdownHandler),
(r'/api/hostname', HostnameHandler),
(r'/api/wifi', NetworkData),
(r'/api/network', NetworkHandler),
(r'/api/remote/username', UsernameHandler),
(r'/api/remote/password', PasswordHandler),
(r'/api/config/load', ConfigLoadHandler),
(r'/api/config/download(/.*)?', ConfigDownloadHandler),
(r'/api/config/save', ConfigSaveHandler),
(r'/api/config/reset', ConfigResetHandler),
(r'/api/config/restore',ConfigRestoreHandler),
(r'/api/firmware/update', FirmwareUpdateHandler),
(r'/api/upgrade', UpgradeHandler),
(r'/api/file(/[^/]+)?', bbctrl.FileHandler),
(r'/api/path/([^/]+)((/positions)|(/speeds))?', PathHandler),
(r'/api/home(/[xyzabcXYZABC]((/set)|(/clear))?)?', HomeHandler),
(r'/api/start', StartHandler),
(r'/api/estop', EStopHandler),
(r'/api/clear', ClearHandler),
(r'/api/stop', StopHandler),
(r'/api/pause', PauseHandler),
(r'/api/unpause', UnpauseHandler),
(r'/api/pause/optional', OptionalPauseHandler),
(r'/api/step', StepHandler),
(r'/api/position/([xyzabcXYZABC])', PositionHandler),
(r'/api/override/feed/([\d.]+)', OverrideFeedHandler),
(r'/api/override/speed/([\d.]+)', OverrideSpeedHandler),
(r'/api/modbus/read', ModbusReadHandler),
(r'/api/modbus/write', ModbusWriteHandler),
(r'/api/jog', JogHandler),
(r'/api/video', bbctrl.VideoHandler),
(r'/api/screen-rotation', ScreenRotationHandler),
(r'/api/time', TimeHandler),
(r'/api/rotary', RotaryHandler),
(r'/api/remote-diagnostics', RemoteDiagnosticsHandler),
(r'/api/hooks', HooksGetHandler),
(r'/api/hooks/save', HooksSaveHandler),
(r'/api/hooks/status', HooksStatusHandler),
(r'/api/hooks/fire/([\w-]+)', HooksFireHandler),
(r'/api/aux/config', AuxConfigGetHandler),
(r'/api/aux/config/save', AuxConfigSaveHandler),
(r'/api/aux/status', AuxStatusHandler),
(r'/api/aux/home', AuxHomeHandler),
(r'/api/aux/abort', AuxAbortHandler),
(r'/api/aux/jog', AuxJogHandler),
(r'/api/aux/move', AuxMoveHandler),
(r'/api/aux/set-zero', AuxSetZeroHandler),
(r'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}),
]
router = sockjs.tornado.SockJSRouter(SockJSConnection, '/sockjs')
router.app = self
tornado.web.Application.__init__(self, router.urls + handlers)
try:
self.listen(args.port, address = args.addr)
except Exception as e:
raise Exception('Failed to bind %s:%d: %s' % (
args.addr, args.port, e))
print('Listening on http://%s:%d/' % (args.addr, args.port))
def opened(self, ctrl): ctrl.clear_timeout()
def closed(self, ctrl):
# Time out clients in demo mode
if self.args.demo: ctrl.set_timeout(self._reap_ctrl, ctrl)
def _reap_ctrl(self, ctrl):
ctrl.close()
del self.ctrls[ctrl.id]
def get_ctrl(self, id = None):
if not id or not self.args.demo: id = ''
if not id in self.ctrls:
ctrl = bbctrl.Ctrl(self.args, self.ioloop, id)
self.ctrls[id] = ctrl
else: ctrl = self.ctrls[id]
return ctrl
# Override default logger
def log_request(self, handler):
log = self.get_ctrl(handler.get_cookie('client-id')).log.get('Web')
log.info("%d %s", handler.get_status(), handler._request_summary())