Add the gfcloud full-cloud controller daemon

gfcloud runs the machine under the Glowforge web service (the factory
cloud experience: the app drives homing, framing, printing). Its init
service starts it only when controller_mode = cloud - mutually exclusive
with grblHAL, which already stands down for that mode - so it owns
/dev/glowforge exclusively. Persistent GFUIService loop with a clean
SIGTERM shutdown that safes the hardware; the init script mirrors
grblhal.init. The image installs gfcloud and python3-ffmachine.
This commit is contained in:
ScottW514
2026-08-08 19:08:13 -04:00
parent 5d7ca46c07
commit abf85fccfe
4 changed files with 191 additions and 1 deletions
@@ -0,0 +1,49 @@
#!/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
@@ -0,0 +1,109 @@
#!/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())
@@ -0,0 +1,29 @@
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}"
inherit update-rc.d
INITSCRIPT_NAME = "gfcloud"
INITSCRIPT_PARAMS = "defaults 92"
do_install() {
install -Dm 0755 ${WORKDIR}/gfcloud.py ${D}${sbindir}/gfcloud.py
install -d ${D}${sysconfdir}/init.d
install -m 0755 ${WORKDIR}/gfcloud.init ${D}${sysconfdir}/init.d/gfcloud
}
# Shares the Glowforge web-service config with gfhome (SERVICE section,
# camera captures via forgectrl); pulls the shared machine glue.
RDEPENDS:${PN} += "python3-core python3-ffmachine python3-gfhardware python3-gfutilities"
@@ -14,13 +14,16 @@ IMAGE_INSTALL:remove = "gfui-client"
# 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).
# gfcloud: full Glowforge web-service controller daemon (the factory cloud
# experience), started when controller_mode = cloud - mutually exclusive with
# grblHAL. Pulls python3-ffmachine (shared web-service machine glue).
# v4l-utils provides media-ctl / v4l2-ctl for the imx-media pipeline (also a
# forgectrl runtime dependency, kept explicit here for bring-up use).
# fwup: applies signed .fw archives (ForgeFIRM upgrades + factory restore)
# to the inactive rootfs slot.
# ffboot: boot-slot inventory and switching (also ships fw_env.config).
# slotmigrate: boot-time reclaim of the legacy p4 layout (grows /data).
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome v4l-utils fwup ffboot slotmigrate"
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate"
# The release rootfs must fit a 200 MiB factory eMMC slot (409600 blocks).
# Sizing: content + 40 MiB working space, hard-capped at the slot size —