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,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())