Compare commits

1 Commits

Author SHA1 Message Date
fe362e10ab Hooks: ATC IPC layer between gcode preprocessor and runtime
Adds bbctrl.Hooks: a small dispatch layer for HOOK:<event>:<data>
messages embedded in g-code as (MSG,HOOK:droptool:) etc. Hooks can
block the unpause until the registered callback completes and
auto-resume after.

- bbctrl.Hooks: registry, fire, dispatch_hook_message, persistent
  config in hooks.json, REST surface (/api/hooks, /api/hooks/save,
  /api/hooks/status, /api/hooks/fire/<event>).
- Ctrl: instantiate self.hooks alongside the other subsystems.
- Planner._add_message: when a (MSG,...) line is HOOK:<event>:<data>,
  route it through ctrl.hooks instead of state.messages so it never
  surfaces as a UI popup and dispatch is immediate (state.messages
  has a 250ms debounce).
- Web: handlers for the /api/hooks routes.
2026-05-03 14:16:21 +02:00
6 changed files with 597 additions and 81 deletions

View File

@@ -1,77 +0,0 @@
# Onefinity firmware — agent guidelines
## Branch model
This fork lives on **two long-lived branches**:
- **`master`** — public-facing fork. General-use upgrades on top of
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
changes that benefit any Onefinity owner.
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
and adds everything specific to the auxcnc-ESP-driven A axis and
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
Z-A coupling interlock, the A-axis UI surface, and the
`/api/aux/*` endpoints.
Upstream:
- `upstream``https://github.com/OneFinityCNC/onefinity-firmware.git`
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
`origin/pre-split-backup` is a tag preserving the pre-split master
tip. Keep it indefinitely until further notice.
## Where does a change go?
| Change | Branch |
|---|---|
| UI polish, theme, layout that any user benefits from | `master` |
| Build / install / boot performance | `master` |
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
| W → A renames or aux.json migrations | `private-mods` |
When in doubt: ask "would this be useful on a stock Onefinity with no
ESP attached?" If yes → `master`. If no → `private-mods`.
## Workflow
```bash
# Day-to-day shop / hardware work (default)
git checkout private-mods
# … do work, commit …
git push origin private-mods
# Generic improvement to master
git checkout master
# … do work, commit …
git push origin master
# After landing on master, replay private-mods on top
git checkout private-mods
git rebase master
git push --force-with-lease origin private-mods
```
If a change accidentally lands on `master` but is bespoke (touches
the file table above), move it: `git reset --hard <prev>` on master,
cherry-pick onto `private-mods`, force-push master.
## Deploy
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
`onefin-local`). No controller backend; A-axis row stays hidden.
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
- `./deploy.sh prod` — bundle a release tarball.
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
## Commit before ending a turn; push after significant changes.

View File

@@ -71,6 +71,8 @@ class Ctrl(object):
self.jog = bbctrl.Jog(self)
with Trace.span('ctrl.pwr'):
self.pwr = bbctrl.Pwr(self)
with Trace.span('ctrl.hooks'):
self.hooks = bbctrl.Hooks(self)
with Trace.span('ctrl.mach.connect'):
self.mach.connect()

454
src/py/bbctrl/Hooks.py Normal file
View File

@@ -0,0 +1,454 @@
################################################################################
#
# Hooks - External event triggers during G-code execution
#
# Integrates with the controller's pause/unpause cycle to run external
# actions (webhooks, scripts) at specific points during G-code execution.
#
# ## How tool-change hooks work (the important one):
#
# G-code: T5 M6
#
# 1. Planner replaces M6 with tool-change override G-code (configurable).
# Default: "M0 M6 (MSG, Change tool)"
#
# 2. Planner emits: set(tool,5), pause(program), message("Change tool")
# These are sent to the AVR as serial commands.
#
# 3. AVR finishes current move, enters HOLDING state.
# Reports back: xx=HOLDING, pr="Program pause"
#
# 4. Pi: Mach._update() sees HOLDING, flushes CommandQueue.
# CommandQueue executes callbacks: state.set('tool', 5) fires.
#
# 5. Hooks._on_state_change() sees tool changed.
# Sets self._hook_busy = True, runs the hook in a thread.
# While _hook_busy, Mach.unpause() is blocked via can_unpause().
#
# 6. Machine sits in HOLDING. UI shows "Change tool" message.
# User cannot resume yet (unpause is gated).
#
# 7. Hook thread finishes (toolchanger done). Sets _hook_busy = False.
# If auto_resume is set, calls unpause automatically.
# Otherwise user clicks Continue in UI.
#
# 8. Mach.unpause() → planner.restart() → AVR UNPAUSE → motion resumes.
#
# ## Configuration (hooks.json):
#
# {
# "tool-change": {
# "type": "webhook",
# "url": "http://toolchanger.local/api/change",
# "method": "POST",
# "timeout": 120,
# "block_unpause": true,
# "auto_resume": true
# },
# "program-start": {
# "type": "script",
# "command": "/usr/local/bin/dust-collector on",
# "block_unpause": false
# }
# }
#
# block_unpause: if true, unpause is blocked until hook completes
# auto_resume: if true AND block_unpause, auto-unpause after hook done
#
################################################################################
import os
import json
import subprocess
import threading
import traceback
from urllib.request import Request, urlopen
from urllib.error import URLError
# Events that can be hooked
HOOK_EVENTS = [
'tool-change', # M6 - tool change requested
'program-start', # Program begins running
'program-end', # M2/M30 - program ends
'pause', # M0/M1 - program pause
'estop', # Emergency stop triggered
'homing-start', # Homing cycle begins
'homing-end', # Homing cycle completes
'custom', # Triggered by (MSG,HOOK:name:data) comments
]
class Hooks:
def __init__(self, ctrl):
self.ctrl = ctrl
self.log = ctrl.log.get('Hooks')
self.hooks = {}
# Hook execution state
self._hook_busy = False # True while a blocking hook runs
self._hook_busy_event = None # Which event is blocking
self._hook_error = None # Error from last hook, if any
self._hook_thread = None
# In-process hook handlers registered by Python modules. Keyed by
# event name (matches what the G-code emits as HOOK:<event>).
# Take precedence over hooks.json entries with the same name.
self._internal = {}
# Track state for edge detection — must be set before add_listener
# because add_listener fires immediately with current state
self._last_cycle = ctrl.state.get('cycle', 'idle')
self._last_state = ctrl.state.get('xx', '')
self._last_tool = ctrl.state.get('tool', 0)
self._last_pause_reason = ctrl.state.get('pr', '')
# Highest message id we've already inspected for HOOK: lines.
self._last_msg_id = -1
self._initialized = False
self._load_config()
# Listen for state changes
ctrl.state.add_listener(self._on_state_change)
self._initialized = True
# -- Config management --
def _get_config_path(self):
return self.ctrl.get_path(filename='hooks.json')
def _load_config(self):
path = self._get_config_path()
if os.path.exists(path):
try:
with open(path) as f:
self.hooks = json.load(f)
self.log.info('Loaded %d hook(s) from %s' %
(len(self.hooks), path))
except Exception:
self.log.error('Failed to load hooks.json: %s' %
traceback.format_exc())
else:
self.log.info('No hooks.json found, hooks disabled')
def save_config(self, config):
"""Save hook configuration (called from API)."""
path = self._get_config_path()
with open(path, 'w') as f:
json.dump(config, f, indent=2)
self.hooks = config
self.log.info('Saved %d hook(s)' % len(config))
def get_config(self):
return self.hooks
# -- Unpause gating (called from Mach) --
def can_unpause(self):
"""Returns True if no blocking hook is running.
Called by Mach.unpause() to gate resume."""
if self._hook_busy:
self.log.info('Unpause blocked: hook "%s" still running' %
self._hook_busy_event)
return False
return True
def get_status(self):
"""Return current hook execution status for the UI."""
return {
'busy': self._hook_busy,
'event': self._hook_busy_event,
'error': self._hook_error,
}
# -- State change listener --
def _on_state_change(self, update):
"""Called on every state update from the controller."""
if not self._initialized:
return
state = self.ctrl.state
# Detect tool change (tool number changed while HOLDING)
if 'tool' in update:
new_tool = update['tool']
if new_tool != self._last_tool:
self._fire('tool-change', {
'old_tool': self._last_tool,
'new_tool': new_tool,
})
self._last_tool = new_tool
# Detect cycle changes
if 'cycle' in update:
new_cycle = update['cycle']
if new_cycle != self._last_cycle:
if new_cycle == 'running' and self._last_cycle == 'idle':
self._fire('program-start', {})
elif new_cycle == 'idle' and self._last_cycle == 'running':
self._fire('program-end', {})
elif new_cycle == 'homing':
self._fire('homing-start', {})
elif self._last_cycle == 'homing' and new_cycle == 'idle':
self._fire('homing-end', {})
self._last_cycle = new_cycle
# Detect AVR state changes
if 'xc' in update or 'xx' in update:
new_state = state.get('xx', '')
if new_state != self._last_state:
if new_state == 'ESTOPPED':
# Cancel any running hook on estop. The hook thread
# cannot be killed from Python, but we can ask the
# AuxAxis to send ABORT to the ESP so its in-flight
# motion stops. Also drain the external-axis
# worker queue so resume after clear doesn't replay
# stale moves.
try:
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
ext.abort()
except Exception:
pass
if self._hook_busy:
self.log.warning('E-stop: cancelling hook "%s"' %
self._hook_busy_event)
try:
aux = getattr(self.ctrl, 'aux', None)
if aux is not None:
aux.abort()
except Exception:
pass
self._hook_busy = False
self._hook_busy_event = None
self._fire('estop', {})
self._last_state = new_state
# Detect pause
if 'pr' in update:
pr = update['pr']
if pr and pr != self._last_pause_reason:
self._fire('pause', {'reason': pr})
self._last_pause_reason = pr
# Detect custom hook messages emitted via (MSG,HOOK:event_name:data)
# gcode comments. State stores them as a list under 'messages'
# ([{'id': N, 'text': '...'}, ...]); fire only on new ids.
if 'messages' in update:
msgs = update['messages']
if isinstance(msgs, list):
for m in msgs:
try:
mid = m.get('id', -1)
text = m.get('text', '')
except AttributeError:
continue
if mid <= self._last_msg_id:
continue
self._last_msg_id = mid
if isinstance(text, str) and text.startswith('HOOK:'):
parts = text[5:].split(':', 1)
event = parts[0]
data = parts[1] if len(parts) > 1 else ''
self._fire('custom', {
'event': event,
'data': data,
}, custom_name=event)
# -- Hook execution --
def dispatch_hook_message(self, text):
"""Direct entry point for HOOK:<event>:<data> messages emitted
by the planner via (MSG,HOOK:...) comments. Bypasses the
state.messages list (which the UI also reads), so callers can
suppress popup display without losing the hook dispatch.
Returns True if the text matched a HOOK: line and was
dispatched, False otherwise."""
if not isinstance(text, str) or not text.startswith('HOOK:'):
return False
parts = text[5:].split(':', 1)
event = parts[0]
data = parts[1] if len(parts) > 1 else ''
self._fire('custom', {'event': event, 'data': data},
custom_name=event)
return True
def register_internal(self, name, fn, block_unpause=True,
auto_resume=True, timeout=120):
"""Register an in-process handler for HOOK:<name> events.
fn(context) -> None. May raise. Runs synchronously in the hook
thread; while it runs and block_unpause=True, Mach.unpause is
gated."""
self._internal[name] = {
'type': 'internal',
'fn': fn,
'block_unpause': block_unpause,
'auto_resume': auto_resume,
'timeout': timeout,
}
self.log.info('Registered internal hook: %s' % name)
def _fire(self, event, context, custom_name=None):
"""Fire a hook event."""
# Internal handlers win over hooks.json entries.
hook = None
if custom_name:
hook = self._internal.get(custom_name)
if not hook:
hook = self._internal.get(event)
if not hook:
hook = self.hooks.get(event)
if custom_name and not hook:
hook = self.hooks.get(custom_name)
if not hook:
return
self.log.info('Hook firing: %s %s' % (event, json.dumps(context)))
# Add standard context
state = self.ctrl.state
context.update({
'event': event,
'position': (state.get_position()
if hasattr(state, 'get_position') else {}),
'state': state.get('xx', ''),
'cycle': state.get('cycle', 'idle'),
})
block_unpause = hook.get('block_unpause', event == 'tool-change')
auto_resume = hook.get('auto_resume', False)
if block_unpause:
# Run in thread, block unpause until done
self._hook_busy = True
self._hook_busy_event = event
self._hook_error = None
# Update UI state so frontend knows we're busy
self.ctrl.state.set('hook_busy', True)
self.ctrl.state.set('hook_event', event)
self._hook_thread = threading.Thread(
target=self._run_hook_blocking,
args=(hook, event, context, auto_resume),
daemon=True
)
self._hook_thread.start()
else:
# Fire and forget (non-blocking)
self._execute_hook(hook, context)
def _run_hook_blocking(self, hook, event, context, auto_resume):
"""Runs in a background thread. Blocks unpause until complete."""
try:
self._execute_hook(hook, context)
self.log.info('Hook "%s" completed successfully' % event)
except Exception as e:
self._hook_error = str(e)
self.log.error('Hook "%s" failed: %s' % (event, e))
finally:
self._hook_busy = False
self._hook_busy_event = None
# Schedule UI update on the ioloop thread
self.ctrl.ioloop.call_later(0, self._hook_finished, auto_resume)
def _hook_finished(self, auto_resume):
"""Called on the ioloop after a blocking hook completes."""
self.ctrl.state.set('hook_busy', False)
self.ctrl.state.set('hook_event', '')
if self._hook_error:
self.ctrl.state.set('hook_error', self._hook_error)
self.log.error('Hook error: %s' % self._hook_error)
else:
self.ctrl.state.set('hook_error', '')
if auto_resume and not self._hook_error:
self.log.info('Hook done, auto-resuming')
try:
self.ctrl.mach.unpause()
except Exception as e:
self.log.error('Auto-resume failed: %s' % e)
def _execute_hook(self, hook, context):
"""Execute a single hook (webhook, script, or internal). May block."""
hook_type = hook.get('type', 'webhook')
if hook_type == 'webhook':
self._fire_webhook(hook, context)
elif hook_type == 'script':
self._fire_script(hook, context)
elif hook_type == 'internal':
fn = hook.get('fn')
if fn is None:
raise Exception('Internal hook missing fn')
fn(context)
else:
raise Exception('Unknown hook type: %s' % hook_type)
def _fire_webhook(self, hook, context):
"""Fire a webhook HTTP request."""
url = hook.get('url')
if not url:
raise Exception('Webhook missing url')
method = hook.get('method', 'POST').upper()
timeout = hook.get('timeout', 30)
headers = dict(hook.get('headers', {}))
body = dict(hook.get('body', {}))
# Merge context into body
body['_context'] = context
data = json.dumps(body).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = Request(url, data=data, headers=headers, method=method)
self.log.info('Webhook %s %s' % (method, url))
resp = urlopen(req, timeout=timeout)
self.log.info('Webhook response: %d' % resp.status)
if resp.status >= 400:
raise Exception('Webhook returned %d' % resp.status)
def _fire_script(self, hook, context):
"""Fire a local script/command. Blocks until complete."""
command = hook.get('command')
if not command:
raise Exception('Script hook missing command')
timeout = hook.get('timeout', 120)
# Pass context as environment variables
env = os.environ.copy()
env['HOOK_EVENT'] = context.get('event', '')
env['HOOK_STATE'] = context.get('state', '')
env['HOOK_CYCLE'] = context.get('cycle', '')
env['HOOK_DATA'] = json.dumps(context)
if 'old_tool' in context:
env['HOOK_OLD_TOOL'] = str(context['old_tool'])
if 'new_tool' in context:
env['HOOK_NEW_TOOL'] = str(context['new_tool'])
self.log.info('Script: %s' % command)
result = subprocess.run(
command, shell=True, env=env,
timeout=timeout,
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout = result.stdout.decode('utf-8', errors='replace').strip()
stderr = result.stderr.decode('utf-8', errors='replace').strip()
if stdout:
self.log.info('Script stdout: %s' % stdout)
if result.returncode != 0:
raise Exception('Script failed (%d): %s' %
(result.returncode, stderr or 'non-zero exit'))

View File

@@ -196,12 +196,23 @@ class Planner():
def _add_message(self, text):
self.ctrl.state.add_message(text)
line = self.ctrl.state.get('line', 0)
if 0 <= line: where = '%s:%d' % (self.where, line)
else: where = self.where
# HOOK:<event>:<data> messages are an internal IPC channel
# between the gcode preprocessor and Hooks; bypass the user
# message list so they don't surface as popups, and dispatch
# the hook directly. Routing through state.messages would
# only deliver it after the 0.25s state-change debounce, by
# which point we'd have to keep it visible to ensure Hooks
# could see it.
hooks = getattr(self.ctrl, 'hooks', None)
if hooks is not None and hooks.dispatch_hook_message(text):
self.log.info('HOOK msg: %s' % text, where = where)
return
self.ctrl.state.add_message(text)
self.log.message(text, where = where)
@@ -259,6 +270,13 @@ class Planner():
if type != 'set': self.log.info('Cmd:' + log_json(block))
if type == 'line':
ext = self._external_axis_for_line(block)
if ext is not None:
# Side effect: enqueue the ESP move on the external-
# axis worker. The AVR still receives the full target
# (including A) so ex.position[A] tracks gplan; no
# motor steps for A because no motor maps to it.
self._dispatch_external_line(block, ext)
self._enqueue_line_time(block)
return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'],
@@ -289,8 +307,17 @@ class Planner():
if name[2:] == '_homed':
motor = self.ctrl.state.find_motor(name[1])
if motor is not None:
# Synthetic external motor (index 4) doesn't exist
# on the AVR; mirror the homed flag in State only.
from bbctrl.ExternalAxis import EXTERNAL_MOTOR_INDEX
if motor is not None and motor < EXTERNAL_MOTOR_INDEX:
return Cmd.set_sync('%dh' % motor, value)
if motor == EXTERNAL_MOTOR_INDEX:
# Update synthetic motor flag and the<axis>_homed
# projection consumed by the DRO.
self.cmdq.enqueue(
id, self.ctrl.state.set,
'%dh' % EXTERNAL_MOTOR_INDEX, value)
return
@@ -339,6 +366,68 @@ class Planner():
self.planner.set_logger(None)
# ----------------------------------------------- external-axis routing
#
# When an axis is exposed to gplan via a synthetic motor (no AVR
# channel), we need to fork its motion off to the ESP at line
# encode time and let the rest of the line proceed to the AVR.
# The split is done here rather than in gplan because gplan
# treats all six axes uniformly and just emits target dicts; we
# don't want to teach it about the ESP.
def _external_axis_for_line(self, block):
"""Return the ExternalAxis instance for whichever axis in
block['target'] is external, or None."""
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is None or not ext.enabled:
return None
target = block.get('target') or {}
if ext.axis_letter in target or ext.axis_letter.upper() in target:
return ext
return None
def _dispatch_external_line(self, block, ext):
"""Side-effect: enqueue the ESP move on the external-axis
worker thread (non-blocking). Returns the block (possibly
unchanged) for the AVR.
We do NOT strip the external axis target from the AVR line.
The AVR's exec_move_to_target updates ex.position[axis] for
every axis in the target dict regardless of motor mapping,
and reports it back via the `p` indexed var. Leaving A in
the target keeps state.ap in sync with gplan's idea of A
(otherwise the AVR's stale ex.position[A] would clobber
ExternalAxis's state.ap=N update on the next status report).
The AVR doesn't step any motor for the external axis (no
motor maps to it) - so leaving A in the target is
physically a no-op for the steppers, while keeping the
host-side state coherent.
We pass the full S-curve parameters to the ESP so its move
duration matches the AVR's exactly. The ESP runs the same
7-segment jerk-limited trajectory the AVR would have run
if A had been a real motor."""
target = block.get('target') or {}
# Read the external target (case-insensitive) without modifying
# the dict so the AVR still sees A.
ext_mm = target.get(ext.axis_letter)
if ext_mm is None:
ext_mm = target.get(ext.axis_letter.upper())
try:
ext.enqueue_line(
ext_mm,
block.get('max-accel', 0.0),
block.get('max-jerk', 0.0),
block.get('entry-vel', 0.0),
block.get('exit-vel', 0.0),
block.get('times', [0]*7),
)
except Exception as e:
self.log.error('External axis enqueue failed: %s' % e)
raise
return block
def reset(self, *args, **kwargs):
stop = kwargs.get('stop', True)
if stop:
@@ -352,6 +441,16 @@ class Planner():
self.cmdq.clear()
self.reset_times()
# Drain the external-axis worker queue and force the next
# move to re-sync position from the ESP (since State.reset
# below will zero <axis>p which makes ext._pos_mm stale).
ext = getattr(self.ctrl, 'ext_axis', None)
if ext is not None:
try: ext.abort()
except Exception: pass
try: ext._pos_mm = None
except Exception: pass
resetState = kwargs.get('resetState', True)
if resetState:
self.ctrl.state.reset()
@@ -369,6 +468,19 @@ class Planner():
self.where = path
path = self.ctrl.get_path('upload', path)
self.log.info('GCode:' + path)
# Rewrite ATC M-codes (M100..M103) before gplan sees them.
# preprocess_file is a no-op when no rewriting is needed and
# idempotent when run twice on the same file, so this is
# safe on every load. W tokens are no longer rewritten - the
# auxcnc stepper is now exposed as a virtual A axis and gcode
# should use A directly.
try:
from bbctrl.AuxPreprocessor import preprocess_file
if preprocess_file(path, log = self.log):
self.log.info('Rewrote ATC M-codes in %s' % path)
except Exception:
self.log.exception('Aux preprocess at load failed; '
'attempting to load file unchanged')
self._sync_position()
self.planner.load(path, self.get_config(False, True))
self.reset_times()

View File

@@ -766,6 +766,27 @@ class RotaryHandler(bbctrl.APIHandler):
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)
class RemoteDiagnosticsHandler(bbctrl.APIHandler):
def get(self):
@@ -798,7 +819,6 @@ class RemoteDiagnosticsHandler(bbctrl.APIHandler):
'message': e.reason or "Unknown"
})
class TimingHandler(bbctrl.APIHandler):
"""Return the bbctrl process startup timeline as JSON.
@@ -992,6 +1012,10 @@ class Web(tornado.web.Application):
(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'/(.*)', StaticFileHandler,
{'path': bbctrl.get_resource('http/'),
'default_filename': 'index.html'}),

View File

@@ -66,6 +66,7 @@ from bbctrl.AVR import AVR
from bbctrl.AVREmu import AVREmu
from bbctrl.IOLoop import IOLoop
from bbctrl.MonitorTemp import MonitorTemp
from bbctrl.Hooks import Hooks
import bbctrl.Cmd as Cmd
import bbctrl.v4l2 as v4l2
import bbctrl.Log as log