gfhome: one-shot Glowforge web-service homing runner

New recipe installing /usr/sbin/gfhome.py and /etc/gfhome.conf.sample
(copied to /data/etc/gfhome.conf on first run). The runner signs the
machine in with its fused identity, opens the WSS control channel with
a held client reference (a clean disconnect is impossible through
ws_connect), and drives the GFUIService dispatch table itself - minus
print - so the service's camera homing sequence (settings -> hunt ->
lid image -> corner move -> lid image) runs against the real hardware
Machine. The service ends the sequence silently, so completion is a
hunt plus at least one motion followed by a configurable quiet window;
the lens is then re-referenced against the hall sensor for a
deterministic Z. Lid/e-stop are checked before the session, print
actions are refused, and exit codes distinguish configuration and
connection failures from an incomplete homing.

Installed in both images; invoked by the grblHAL controller for $H
when homing_mode = gfcloud in /data/forgefirm.conf.
This commit is contained in:
ScottW514
2026-08-07 18:32:53 -04:00
parent fcfa4e6d65
commit f2a5d0f6b6
4 changed files with 273 additions and 1 deletions
@@ -0,0 +1,28 @@
# gfhome - Glowforge web-service homing configuration.
# Copied to /data/etc/gfhome.conf on first run; edit the copy.
# Machine identity (serial/password) is read from the OCOTP fuses.
[SERVICE]
server_url: https://app.glowforge.com
status_service_url: wss://status.glowforge.com
[FACTORY_FIRMWARE]
# Homing sessions never fetch factory firmware.
check: False
[LOGGING]
file: /data/log/gfhome/gfhome.log
level: INFO
save_sent_images: False
[THERMAL]
# The heater stays off during homing; thermal policy belongs to the
# motion controller.
water_heater_percent: 0
max_start_temp: 30
[MOTION]
# Extra lens half-steps applied after the hunt (0 = hall reference).
z_home_offset: 0
warm_up_delay: 0
cool_down_delay: 0
@@ -0,0 +1,221 @@
#!/usr/bin/python3
"""
gfhome - one-shot Glowforge web-service homing for ForgeFIRM
Connects the machine to the Glowforge web service just long enough for
the service to run its camera-based homing sequence (settings report ->
hunt (Z/lens) -> lid image -> move to the home corner -> lid image),
then parks the lens at the hall-sensor reference, disconnects, and
exits. All three axes end at the factory home position: X/Y at the
back-left home corner, Z at the top-of-travel hall trigger.
The grblHAL-glowforge controller invokes this for $H when
homing_mode = gfcloud is set in /data/forgefirm.conf, releasing
/dev/glowforge for the duration of the run. It can also be run by hand
(with the controller stopped or its homing session active).
The service ends the sequence silently - there is no completion
message - so the run is considered homed once a hunt and at least one
motion have completed and the service has been quiet for --quiet
seconds.
Exit codes: 0 = homed, 1 = configuration/connection failure,
2 = homing did not complete.
(C) Copyright 2026
Scott Wiederhold, s.e.wiederhold@gmail.com
SPDX-License-Identifier: MIT
"""
import argparse
import json
import logging
import queue
import shutil
import signal
import sys
import time
from pathlib import Path
from queue import Queue
from gfutilities.configuration import parse, get_cfg, log_level
from gfutilities.service.authentication import authenticate_machine
from gfutilities.service.websocket import get_session, WsClient
CONF = '/data/etc/gfhome.conf'
CONF_SAMPLE = '/etc/gfhome.conf.sample'
logging.basicConfig(format='(%(levelname)s) %(module)s:%(funcName)s %(message)s')
logger = logging.getLogger('openglow')
def load_config(path: str) -> bool:
if path == CONF and not Path(CONF).is_file() and Path(CONF_SAMPLE).is_file():
Path(CONF).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(CONF_SAMPLE, CONF)
if not Path(path).is_file():
logger.error('config file %s not found', path)
return False
parse(path)
if not get_cfg('SERVICE.SERVER_URL'):
logger.error('config %s has no SERVICE section', path)
return False
if get_cfg('LOGGING.FILE'):
Path(get_cfg('LOGGING.FILE')).parent.mkdir(parents=True, exist_ok=True)
fh = logging.FileHandler(get_cfg('LOGGING.FILE'))
fh.setLevel(log_level(get_cfg('LOGGING.LEVEL')))
fh.setFormatter(logging.Formatter(
'%(asctime)s (%(levelname)s) %(module)s:%(funcName)s %(message)s'))
logger.addHandler(fh)
logger.setLevel(logging.DEBUG)
return True
def dispatch(machine, msg: dict) -> str:
"""Route one service action to the machine (the GFUIService dispatch
table, minus print - a print must never run inside a homing session).
Returns the action type if a puls action was accepted for run."""
action = msg.get('action_type', '')
if action == 'settings' and msg.get('status') == 'ready':
machine.run_settings_report(msg)
elif 'image' in action:
machine.run_capture(msg)
elif action in ('hunt', 'motion'):
machine.run_puls(msg)
if machine.running_action_id == msg.get('id'):
return action
elif action == 'print':
logger.warning('ignoring print action during homing')
else:
logger.info('ignoring action %s', action)
return ''
def home(machine, args) -> int:
q_rx: Queue = Queue()
q_tx: Queue = Queue()
session = get_session()
if not authenticate_machine(session):
logger.error('sign-in to %s failed', get_cfg('SERVICE.SERVER_URL'))
return 1
# ws_connect() discards the client object, which makes a clean
# disconnect impossible - build the client directly instead.
ws = WsClient(q_rx, q_tx)
ws.start()
deadline = time.monotonic() + 16
while not ws.ready and time.monotonic() < deadline:
time.sleep(0.5)
if not ws.ready:
logger.error('web socket connection failed')
ws.stop = True
return 1
result = 2
try:
machine.start(session, q_tx)
from gfhardware._common import InputSwitch
switches = machine._sw_thread.all_switches()
if not switches[InputSwitch.SW_DOORS]:
logger.error('lid is open - close it and re-home')
return 2
if not switches[InputSwitch.SW_ESTOP]:
logger.error('e-stop is tripped')
return 2
t0 = time.monotonic()
last_activity = t0
in_flight = ''
done = set()
while True:
now = time.monotonic()
if now - t0 > args.timeout:
logger.error('homing timed out after %ds (completed: %s)',
args.timeout, sorted(done) or 'nothing')
return 2
if 'hunt' not in done and now - t0 > args.start_timeout:
logger.error('service did not start homing within %ds',
args.start_timeout)
return 2
busy = bool(machine.running_action_id)
if busy:
last_activity = now
elif in_flight:
logger.info('%s completed', in_flight)
done.add(in_flight)
in_flight = ''
if ('hunt' in done and 'motion' in done and not busy
and now - last_activity >= args.quiet):
logger.info('homing complete (service quiet %.0fs)', args.quiet)
result = 0
break
try:
msg = json.loads(q_rx.get(timeout=0.5))
except queue.Empty:
continue
except ValueError:
logger.warning('unparseable service message')
continue
last_activity = time.monotonic()
logger.info('service action: %s (%s)',
msg.get('action_type'), msg.get('status'))
started = dispatch(machine, msg)
if started:
in_flight = started
finally:
if result == 0:
# Deterministic Z: the hunt file leaves the lens wherever its
# pattern ends; re-reference against the hall sensor so the
# controller can trust top-of-travel.
try:
from gfhardware.z_axis import ZAxis
ZAxis.home()
except Exception:
logger.exception('final Z reference failed')
result = 2
ws.stop = True
try:
machine.stop()
except Exception:
logger.exception('machine shutdown failed')
return result
def main() -> int:
ap = argparse.ArgumentParser(description='ForgeFIRM one-shot Glowforge cloud homing')
ap.add_argument('-c', '--config', default=CONF, help='config file (default %s)' % CONF)
ap.add_argument('--timeout', type=int, default=240,
help='overall time budget in seconds (default 240)')
ap.add_argument('--start-timeout', type=int, default=120,
help='max seconds to wait for the service to begin homing (default 120)')
ap.add_argument('--quiet', type=int, default=10,
help='silence after the last action that means done (default 10)')
args = ap.parse_args()
signal.signal(signal.SIGTERM, lambda *_: sys.exit(2))
if not load_config(args.config):
return 1
# Machine() reads the OCOTP identity and head info; it fails cleanly
# when the controller still owns /dev/glowforge.
try:
from gfhardware import Machine
machine = Machine()
except Exception:
logger.exception('machine init failed (is the motion controller '
'still holding /dev/glowforge?)')
return 1
rc = home(machine, args)
logger.info('exit %d (%s)', rc, 'homed' if rc == 0 else 'not homed')
return rc
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,21 @@
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
HOMEPAGE = "https://github.com/ScottW514/forgefirm"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
PV = "0.1.0"
SRC_URI = " \
file://gfhome.py \
file://gfhome.conf.sample \
"
S = "${WORKDIR}"
do_install() {
install -Dm 0755 ${WORKDIR}/gfhome.py ${D}${sbindir}/gfhome.py
install -Dm 0600 ${WORKDIR}/gfhome.conf.sample ${D}${sysconfdir}/gfhome.conf.sample
}
RDEPENDS:${PN} += "python3-core python3-gfhardware python3-gfutilities"
@@ -12,6 +12,8 @@ IMAGE_INSTALL:remove = "gfui-client"
# grblhal-glowforge: the grblHAL motion controller (Grbl over TCP:23).
# forgectrl: the ForgeFIRM control daemon (camera MJPEG service on :8080).
# gfhome: one-shot Glowforge web-service homing, invoked by the controller
# for $H when homing_mode = gfcloud (/data/forgefirm.conf).
# v4l-utils provides media-ctl / v4l2-ctl for the imx-media pipeline (also a
# forgectrl runtime dependency, kept explicit here for bring-up use).
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl v4l-utils"
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome v4l-utils"