mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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:
@@ -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"
|
||||||
+3
-14
@@ -1,17 +1,6 @@
|
|||||||
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
||||||
HOMEPAGE = "https://github.com/ScottW514/forgefirm"
|
|
||||||
|
|
||||||
LICENSE = "MIT"
|
require forgefirm-app.inc
|
||||||
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
|
inherit update-rc.d
|
||||||
|
|
||||||
@@ -19,9 +8,9 @@ INITSCRIPT_NAME = "gfcloud"
|
|||||||
INITSCRIPT_PARAMS = "defaults 92"
|
INITSCRIPT_PARAMS = "defaults 92"
|
||||||
|
|
||||||
do_install() {
|
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 -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,
|
# 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"
|
|
||||||
Reference in New Issue
Block a user