From abf85fccfeab4503a8b26779064fb498e51197fc Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 8 Aug 2026 19:08:13 -0400 Subject: [PATCH] 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. --- .../gfcloud/files/gfcloud.init | 49 ++++++++ .../gfcloud/files/gfcloud.py | 109 ++++++++++++++++++ .../recipes-forgefirm/gfcloud/gfcloud.bb | 29 +++++ .../images/forgefirm-image.bb | 5 +- 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init create mode 100644 meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py create mode 100644 meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb diff --git a/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init b/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init new file mode 100644 index 0000000..32411a3 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py b/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py new file mode 100644 index 0000000..b80e185 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py @@ -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()) diff --git a/meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb b/meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb new file mode 100644 index 0000000..4dce42a --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index 93b9444..4752123 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -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 —