Share the web-service machine glue between gfhome and gfcloud

Extract the shared-config identity overrides and the forgectrl-routed
ForgectrlMachine from gfhome into an installed module (python3-ffmachine);
gfhome imports it instead of carrying its own copy, so the coming gfcloud
daemon builds the same machine the same way.
This commit is contained in:
ScottW514
2026-08-08 19:08:13 -04:00
parent cd2107fe3c
commit 5d7ca46c07
4 changed files with 160 additions and 115 deletions
@@ -0,0 +1,133 @@
"""
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()
@@ -0,0 +1,21 @@
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"
@@ -42,14 +42,15 @@ import time
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from gfutilities.configuration import parse, get_cfg, log_level, set_cfg from gfutilities.configuration import parse, get_cfg, log_level
from gfutilities.service.authentication import authenticate_machine from gfutilities.service.authentication import authenticate_machine
from gfutilities.service.dispatch import dispatch_action, PULS_ACTIONS from gfutilities.service.dispatch import dispatch_action, PULS_ACTIONS
from gfutilities.service.websocket import get_session, WsClient from gfutilities.service.websocket import get_session, WsClient
import ffmachine
CONF = '/data/etc/gfhome.conf' CONF = '/data/etc/gfhome.conf'
CONF_SAMPLE = '/etc/gfhome.conf.sample' CONF_SAMPLE = '/etc/gfhome.conf.sample'
MACHINE_CONF = os.environ.get('GFHOME_CONF', '/data/forgefirm.conf')
logging.basicConfig(format='(%(levelname)s) %(module)s:%(funcName)s %(message)s') logging.basicConfig(format='(%(levelname)s) %(module)s:%(funcName)s %(message)s')
logger = logging.getLogger('openglow') logger = logging.getLogger('openglow')
@@ -77,116 +78,6 @@ def load_config(path: str) -> bool:
return True return True
def _hostname_for(serial):
"""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 = 'BCDFGHJKMQRTVWXY2346789'[serial % 23] + enc
serial //= 23
return '{}-{}'.format(enc[:3], enc[3:])
def apply_identity_overrides():
"""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 make_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()
def home(machine, args) -> int: def home(machine, args) -> int:
q_rx: Queue = Queue() q_rx: Queue = Queue()
q_tx: Queue = Queue() q_tx: Queue = Queue()
@@ -308,12 +199,12 @@ def main() -> int:
if not load_config(args.config): if not load_config(args.config):
return 1 return 1
apply_identity_overrides() ffmachine.apply_identity_overrides()
# Machine() reads the OCOTP identity and head info; it fails cleanly # Machine() reads the OCOTP identity and head info; it fails cleanly
# when the controller still owns /dev/glowforge. # when the controller still owns /dev/glowforge.
try: try:
machine = make_machine() machine = ffmachine.build_machine()
except Exception: except Exception:
logger.exception('machine init failed (is the motion controller ' logger.exception('machine init failed (is the motion controller '
'still holding /dev/glowforge?)') 'still holding /dev/glowforge?)')
@@ -18,4 +18,4 @@ do_install() {
install -Dm 0600 ${WORKDIR}/gfhome.conf.sample ${D}${sysconfdir}/gfhome.conf.sample install -Dm 0600 ${WORKDIR}/gfhome.conf.sample ${D}${sysconfdir}/gfhome.conf.sample
} }
RDEPENDS:${PN} += "python3-core python3-gfhardware python3-gfutilities" RDEPENDS:${PN} += "python3-core python3-ffmachine python3-gfhardware python3-gfutilities"