Move the web-service apps to python3-gfhardware/forgefirm-app

gfhome.py, ffmachine.py, and gfcloud.py (with the gfcloud init script
and gfhome conf sample) live in the python3-gfhardware repository's
forgefirm-app/ directory. The three recipes fetch that repo through a
shared include (forgefirm-app.inc) carrying a single pinned SRCREV,
replacing the local file:// copies. Package names, dependencies, and
installed paths are unchanged.
This commit is contained in:
ScottW514
2026-08-09 15:02:09 -04:00
parent be7927c60a
commit 3d9e9ebd60
11 changed files with 41 additions and 606 deletions
@@ -1,133 +0,0 @@
"""
ffmachine - shared ForgeFIRM hardware-machine glue for the Glowforge
web-service clients: the gfhome one-shot homing runner and the gfcloud
full-cloud daemon both drive the same hardware Machine with captures
routed through forgectrl, and honour the same shared-config identity
overrides. Config-file parsing and logging stay in each client.
(C) Copyright 2026
Scott Wiederhold, s.e.wiederhold@gmail.com
SPDX-License-Identifier: MIT
"""
import logging
import os
from gfutilities.configuration import get_cfg, set_cfg
logger = logging.getLogger('openglow')
# The shared machine config (identity overrides, homing/controller mode),
# managed from the forgectrl UI. Override the path with GFHOME_CONF.
MACHINE_CONF = os.environ.get('GFHOME_CONF', '/data/forgefirm.conf')
_HOSTNAME_ALPHABET = 'BCDFGHJKMQRTVWXY2346789'
def hostname_for(serial) -> str:
"""The factory serial -> hostname encoding (base 23 over the consonant
alphabet, up to six characters, split XXX-YYY) - the same derivation
gfhardware applies to the fuse serial."""
enc = ''
serial = int(serial)
while serial > 0 and len(enc) < 6:
enc = _HOSTNAME_ALPHABET[serial % 23] + enc
serial //= 23
return '{}-{}'.format(enc[:3], enc[3:])
def apply_identity_overrides(machine_conf: str = MACHINE_CONF) -> None:
"""Identity overrides from the shared machine config (set in the
forgectrl UI): non-empty gf_serial / gf_password beat the OCOTP fuse
identity - Machine.__init__ sets its fuse values with keep_value, so
whatever is in the config store first wins. The hostname is never
overridden independently: it derives from the serial, so a serial
override re-derives it."""
keys = {}
try:
with open(machine_conf) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
keys[k.strip()] = v.strip()
except OSError:
return
for key, cfg in (('gf_serial', 'MACHINE.SERIAL'),
('gf_password', 'MACHINE.PASSWORD')):
if keys.get(key):
set_cfg(cfg, keys[key])
logger.info('identity override: %s from %s', cfg, machine_conf)
if keys.get('gf_serial'):
try:
set_cfg('MACHINE.HOSTNAME', hostname_for(keys['gf_serial']))
logger.info('identity override: MACHINE.HOSTNAME derived '
'from gf_serial')
except ValueError:
logger.warning('gf_serial is not numeric; hostname left at '
'the fuse derivation')
def build_machine():
"""Build the hardware Machine with captures routed through forgectrl.
forgectrl owns the imx-media pipeline whenever it serves a stream
(LightBurn typically keeps one open), so direct V4L2 grabs fail busy.
Its snapshot endpoint delivers the same factory-configured
full-resolution JPEG, works during an active stream (mux borrow), and
takes a per-shot lamp override - head captures request lamp=0 because
added white light washes out the measure-laser dot the cloud's focus
analysis needs. Direct capture remains the fallback when the daemon is
unreachable.
"""
import requests
from gfhardware import Machine
from gfhardware.leds import head_all_led_off, set_head_led_from_pulse
from gfutilities.service.websocket import img_upload
class ForgectrlMachine(Machine):
@staticmethod
def _snapshot(cam: str, lamp: int = None) -> bytes:
url = '%s/cam/snapshot?cam=%s&res=full' % (
get_cfg('FORGECTRL.URL') or 'http://127.0.0.1:8080', cam)
if lamp is not None:
url += '&lamp=%d' % lamp
rsp = requests.get(url, timeout=45)
rsp.raise_for_status()
if not rsp.content.startswith(b'\xff\xd8'):
raise ValueError('forgectrl returned a non-JPEG body')
return rsp.content
def _save_sent(self, img: bytes, msg: dict) -> None:
if get_cfg('LOGGING.SAVE_SENT_IMAGES'):
with open('%s/%s.jpeg' % (get_cfg('LOGGING.DIR'), msg['id']),
'wb') as f:
f.write(img)
def _lid_image(self, msg: dict) -> None:
logger.info('capturing Lid Image via forgectrl')
try:
img = self._snapshot('lid')
except Exception:
logger.exception('forgectrl snapshot failed; direct capture')
return super()._lid_image(msg)
logger.info('uploading Lid Image')
img_upload(self._session, img, msg)
self._save_sent(img, msg)
def _head_image(self, msg: dict, settings: dict = None) -> None:
logger.info('capturing Head Image via forgectrl')
if settings and settings.get('HCil') is not None:
set_head_led_from_pulse(settings['HCil'])
try:
img = self._snapshot('head', lamp=0)
except Exception:
logger.exception('forgectrl snapshot failed; direct capture')
return super()._head_image(msg, settings)
head_all_led_off()
logger.info('uploading Head Image')
img_upload(self._session, img, msg)
self._save_sent(img, msg)
return ForgectrlMachine()
@@ -1,21 +0,0 @@
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
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://ffmachine.py"
S = "${WORKDIR}"
inherit python3-dir
do_install() {
install -Dm 0644 ${WORKDIR}/ffmachine.py ${D}${PYTHON_SITEPACKAGES_DIR}/ffmachine.py
}
FILES:${PN} += "${PYTHON_SITEPACKAGES_DIR}/ffmachine.py"
RDEPENDS:${PN} += "python3-core python3-gfhardware python3-gfutilities python3-requests"
@@ -0,0 +1,15 @@
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
# gfcloud). Their sources live in the forgefirm-app/ directory of the
# python3-gfhardware repository.
HOMEPAGE = "https://github.com/ScottW514/python3-gfhardware"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=62f8bb455fcc4bf177ecab380f71cd5d"
SRC_URI = "git://github.com/ScottW514/python3-gfhardware.git;protocol=https;branch=cloud-action-surface"
# Pinned; bump deliberately (AUTOREV is not reproducible).
SRCREV = "7ea88246fabe1142a1bb728dffce8799a71fa846"
PV = "0.1.0+git"
S = "${WORKDIR}/git"
@@ -1,17 +1,6 @@
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
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://gfcloud.py \
file://gfcloud.init \
"
S = "${WORKDIR}"
require forgefirm-app.inc
inherit update-rc.d
@@ -19,9 +8,9 @@ INITSCRIPT_NAME = "gfcloud"
INITSCRIPT_PARAMS = "defaults 92"
do_install() {
install -Dm 0755 ${WORKDIR}/gfcloud.py ${D}${sbindir}/gfcloud.py
install -Dm 0755 ${S}/forgefirm-app/gfcloud.py ${D}${sbindir}/gfcloud.py
install -d ${D}${sysconfdir}/init.d
install -m 0755 ${WORKDIR}/gfcloud.init ${D}${sysconfdir}/init.d/gfcloud
install -m 0755 ${S}/forgefirm-app/gfcloud.init ${D}${sysconfdir}/init.d/gfcloud
}
# Shares the Glowforge web-service config with gfhome (SERVICE section,
@@ -0,0 +1,10 @@
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
require forgefirm-app.inc
do_install() {
install -Dm 0755 ${S}/forgefirm-app/gfhome.py ${D}${sbindir}/gfhome.py
install -Dm 0600 ${S}/forgefirm-app/gfhome.conf.sample ${D}${sysconfdir}/gfhome.conf.sample
}
RDEPENDS:${PN} += "python3-core python3-ffmachine python3-gfhardware python3-gfutilities"
@@ -0,0 +1,13 @@
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
require forgefirm-app.inc
inherit python3-dir
do_install() {
install -Dm 0644 ${S}/forgefirm-app/ffmachine.py ${D}${PYTHON_SITEPACKAGES_DIR}/ffmachine.py
}
FILES:${PN} += "${PYTHON_SITEPACKAGES_DIR}/ffmachine.py"
RDEPENDS:${PN} += "python3-core python3-gfhardware python3-gfutilities python3-requests"
@@ -1,49 +0,0 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: gfcloud
# Required-Start: $local_fs $network
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Glowforge web-service controller (full cloud mode)
### END INIT INFO
DAEMON=/usr/sbin/gfcloud.py
PIDFILE=/var/run/gfcloud.pid
CONF=/data/forgefirm.conf
# Boot-time controller-mode dispatch, mirror of grblhal.init: this daemon
# runs the machine under the Glowforge web service. It starts only when
# controller_mode = cloud (which keeps grblHAL down); anything else - grbl,
# unset, or no config file - leaves it stopped.
controller_mode() {
[ -r $CONF ] || { echo grbl; return; }
m=$(sed -n 's/^[ \t]*controller_mode[ \t]*=[ \t]*\([a-z]*\).*/\1/p' $CONF | tail -n 1)
echo "${m:-grbl}"
}
case "$1" in
start)
if [ "$(controller_mode)" != "cloud" ]; then
echo "gfcloud not started (controller_mode != cloud)"
exit 0
fi
echo "Starting gfcloud"
start-stop-daemon -S -q -p $PIDFILE -m -b -x $DAEMON
;;
stop)
echo "Stopping gfcloud"
start-stop-daemon -K -q -p $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop
sleep 2
$0 start
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
exit 0
@@ -1,109 +0,0 @@
#!/usr/bin/python3
"""
gfcloud - full Glowforge web-service controller for ForgeFIRM.
Runs the machine under the Glowforge web service (the factory cloud
experience): the phone/web app drives homing, framing, and printing.
Started by the gfcloud init service when controller_mode = cloud in
/data/forgefirm.conf, which keeps grblHAL down so this daemon owns
/dev/glowforge exclusively.
Reconnects (fresh single-use ws_token) and 401 re-auth are handled in
gfutilities. On SIGTERM the service loop stops and the machine is shut
down safe (laser latched, steppers disabled, deadman released).
(C) Copyright 2026
Scott Wiederhold, s.e.wiederhold@gmail.com
SPDX-License-Identifier: MIT
"""
import argparse
import logging
import shutil
import signal
import sys
import time
from pathlib import Path
from gfutilities.configuration import parse, get_cfg, log_level
from gfutilities import GFUIService
import ffmachine
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 main() -> int:
ap = argparse.ArgumentParser(description='ForgeFIRM full Glowforge cloud controller')
ap.add_argument('-c', '--config', default=CONF, help='config file (default %s)' % CONF)
args = ap.parse_args()
if not load_config(args.config):
return 1
ffmachine.apply_identity_overrides()
# Machine() reads the OCOTP identity and head info; it fails cleanly if
# grblHAL still holds /dev/glowforge (controller_mode must be cloud).
try:
machine = ffmachine.build_machine()
except Exception:
logger.exception('machine init failed (is grblHAL still running? '
'controller_mode must be cloud)')
return 1
service = GFUIService(machine)
def _shutdown(*_):
logger.info('shutdown requested')
service.request_stop()
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
# Run for the life of the daemon. connect() can fail if the network or
# service is briefly unavailable (e.g. at boot); retry until stopped.
# Once connected, run() stays up across WS drops (gfutilities reconnects
# with a fresh token) and returns only when a stop is requested, having
# shut the machine down safe.
while not service.stop:
if service.connect():
service.run()
break
logger.error('connect failed; retrying in 10s')
for _ in range(100):
if service.stop:
break
time.sleep(0.1)
logger.info('gfcloud exit')
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -1,45 +0,0 @@
# 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;
# gf_serial / gf_password in /data/forgefirm.conf (the forgectrl UI,
# GF Cloud tab) override it. The service hostname always derives from
# whichever serial is in effect.
[SERVICE]
server_url: https://app.glowforge.com
status_service_url: wss://status.glowforge.com
[FACTORY_FIRMWARE]
# check: probe the firmware version the service advertises so forgectrl can
# warn when Glowforge has moved past the version this release was tested
# against. Read-only - ForgeFIRM never downloads or installs factory firmware.
check: True
# Where the probe records {latest_gf_version, tested_against_gf}; forgectrl
# reads this for the cloud-mode compatibility banner (GF_LATEST_FILE).
status_file: /data/forgefirm/gf-latest.json
[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
# The factory board's estop sense line reads low during any motion, so
# it must not gate motion there; enable only on hardware with a real
# e-stop circuit.
estop_halts_motion: False
[FORGECTRL]
# Camera captures fetch from the forgectrl daemon (it owns the camera
# pipeline); direct V4L2 capture is the fallback.
url: http://127.0.0.1:8080
@@ -1,214 +0,0 @@
#!/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 same
shared config supplies optional identity overrides (gf_serial /
gf_password; the fuse identity is the fallback), managed from the
forgectrl UI. The service hostname is always derived from whichever
serial is in effect - it is never set independently.
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 os
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.dispatch import dispatch_action, PULS_ACTIONS
from gfutilities.service.websocket import get_session, ws_connect
import ffmachine
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 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
# No session passed: homing is a single short connect that never needs
# a reconnect token refresh.
ws = ws_connect(q_rx, q_tx)
if not ws:
logger.error('web socket connection failed')
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'))
# Homing borrows the service only for camera homing; a print must
# never run inside a homing session (allow_print=False).
result = dispatch_action(machine, msg, allow_print=False)
if result in PULS_ACTIONS:
in_flight = result
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.shutdown()
try:
machine.stop()
except Exception:
logger.exception('machine shutdown failed')
return result
def main() -> int:
try:
# The controller exports its own $H budget minus a margin, so
# the runner always gives up before the controller kills it.
timeout_default = max(30, int(os.environ.get('GFHOME_TIMEOUT_S', 240)))
except ValueError:
timeout_default = 240
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=timeout_default,
help='overall time budget in seconds (default %d)' % timeout_default)
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
ffmachine.apply_identity_overrides()
# Machine() reads the OCOTP identity and head info; it fails cleanly
# when the controller still owns /dev/glowforge.
try:
machine = ffmachine.build_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())
@@ -1,21 +0,0 @@
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-ffmachine python3-gfhardware python3-gfutilities"