From 3d9e9ebd606e4d471de8ce1d6a0c6c1b9a2bc519 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 9 Aug 2026 15:02:09 -0400 Subject: [PATCH] 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. --- .../ffmachine/files/ffmachine.py | 133 ----------- .../ffmachine/python3-ffmachine.bb | 21 -- .../forgefirm-app/forgefirm-app.inc | 15 ++ .../{gfcloud => forgefirm-app}/gfcloud.bb | 17 +- .../recipes-forgefirm/forgefirm-app/gfhome.bb | 10 + .../forgefirm-app/python3-ffmachine.bb | 13 ++ .../gfcloud/files/gfcloud.init | 49 ---- .../gfcloud/files/gfcloud.py | 109 --------- .../gfhome/files/gfhome.conf.sample | 45 ---- .../recipes-forgefirm/gfhome/files/gfhome.py | 214 ------------------ .../recipes-forgefirm/gfhome/gfhome.bb | 21 -- 11 files changed, 41 insertions(+), 606 deletions(-) delete mode 100644 meta-forgefirm/recipes-forgefirm/ffmachine/files/ffmachine.py delete mode 100644 meta-forgefirm/recipes-forgefirm/ffmachine/python3-ffmachine.bb create mode 100644 meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc rename meta-forgefirm/recipes-forgefirm/{gfcloud => forgefirm-app}/gfcloud.bb (53%) create mode 100644 meta-forgefirm/recipes-forgefirm/forgefirm-app/gfhome.bb create mode 100644 meta-forgefirm/recipes-forgefirm/forgefirm-app/python3-ffmachine.bb delete mode 100644 meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init delete mode 100644 meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py delete mode 100644 meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.conf.sample delete mode 100644 meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.py delete mode 100644 meta-forgefirm/recipes-forgefirm/gfhome/gfhome.bb diff --git a/meta-forgefirm/recipes-forgefirm/ffmachine/files/ffmachine.py b/meta-forgefirm/recipes-forgefirm/ffmachine/files/ffmachine.py deleted file mode 100644 index fbde446..0000000 --- a/meta-forgefirm/recipes-forgefirm/ffmachine/files/ffmachine.py +++ /dev/null @@ -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() diff --git a/meta-forgefirm/recipes-forgefirm/ffmachine/python3-ffmachine.bb b/meta-forgefirm/recipes-forgefirm/ffmachine/python3-ffmachine.bb deleted file mode 100644 index 84fd28d..0000000 --- a/meta-forgefirm/recipes-forgefirm/ffmachine/python3-ffmachine.bb +++ /dev/null @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc new file mode 100644 index 0000000..ebc2484 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb similarity index 53% rename from meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb rename to meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb index 4dce42a..06ccba9 100644 --- a/meta-forgefirm/recipes-forgefirm/gfcloud/gfcloud.bb +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb @@ -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, diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfhome.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfhome.bb new file mode 100644 index 0000000..5529aa2 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfhome.bb @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/python3-ffmachine.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-app/python3-ffmachine.bb new file mode 100644 index 0000000..5d74fdf --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/python3-ffmachine.bb @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init b/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init deleted file mode 100644 index 32411a3..0000000 --- a/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.init +++ /dev/null @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py b/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py deleted file mode 100644 index b80e185..0000000 --- a/meta-forgefirm/recipes-forgefirm/gfcloud/files/gfcloud.py +++ /dev/null @@ -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()) diff --git a/meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.conf.sample b/meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.conf.sample deleted file mode 100644 index d183852..0000000 --- a/meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.conf.sample +++ /dev/null @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.py b/meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.py deleted file mode 100644 index 19341a8..0000000 --- a/meta-forgefirm/recipes-forgefirm/gfhome/files/gfhome.py +++ /dev/null @@ -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()) diff --git a/meta-forgefirm/recipes-forgefirm/gfhome/gfhome.bb b/meta-forgefirm/recipes-forgefirm/gfhome/gfhome.bb deleted file mode 100644 index 2aa8658..0000000 --- a/meta-forgefirm/recipes-forgefirm/gfhome/gfhome.bb +++ /dev/null @@ -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"