forgetest: cloud.service-protocol, the service answered by the emulator in this machine's identity

The service-protocol half of the cloud catalog on its own test: the cloud
client restarted as gfutilities' emulator under the /run/gfcloud-emulate
marker signs in, passes the firmware check, opens the WebSocket, answers
the connect-time hunt and the image requests with the dev image's canned
frames, and runs a print from the app through the real download path to
':completed' - nothing moves, nothing arms, and only the app has to be
driven, by a person or an agent through the prompt API. The real client
is restarted afterward and its hunt waited out. session_live now knows
the emulator's session is not the machine's, so enter_cloud restarts it
rather than reusing it; restart_client is the one restart the offline
and emulator entries share.

The dev image adds python3-gfutilities-emulator (the fixtures, packaged
on their own in meta-openglow); forgefirm-app moves to 12ad3b1 (gfcloud
--emulate). Catalog: 44 tests, 27 auto / 9 operator / 8 live; the new
test covers the gfutilities service layer and examples/, which step 4
will take off the other cloud tests. Replays over the prompt script;
contract and BRINGUP updated. A layer change (the dev image recipe):
everything re-requires on the next image.
This commit is contained in:
ScottW514
2026-08-22 20:19:46 -04:00
parent 0cb9044e1d
commit 1c8197faa3
6 changed files with 281 additions and 40 deletions
+10 -3
View File
@@ -150,9 +150,16 @@ bench, or one whose `/data` has been wiped, starts from a full campaign.
hunt run with the lid open, and the web-service homing (`$H` with
`homing_mode = gfcloud`) after the switch back.
The cloud tests split by what they prove. The service protocol (sign-in,
the WebSocket, the hunt, the print from the app, progress, the job's
limits reaching the engine) is `cloud.mode-switch` and one real print,
`cloud.pause-resume`. The machine's print behavior (the lid and
the firmware check, the WebSocket, the hunt, the image uploads, a
print's download and lifecycle as the app sees them) is
`cloud.service-protocol`: the cloud client restarted as gfutilities'
emulator in this machine's identity under the `/run/gfcloud-emulate`
marker, answering the real service with the dev image's canned frames
and running the print from the app without hardware, so only the app
has to be driven (by a person or an agent, anywhere). The service and
the machine together are `cloud.mode-switch` and one real print,
`cloud.pause-resume` (progress, the button wait, the job's limits
reaching the engine). The machine's print behavior (the lid and
interlock aborts, the button-wait cancel, a paused print ended by the
lid, a print longer than the ring with the app's cancel) runs under the
**offline service** (`enter_offline`: the cloud client restarted with
+10 -9
View File
@@ -48,7 +48,7 @@ hardware-validated.**
modes (cancel-and-return on a lid or interlock open, button pause/resume),
bench-validated 2026-08-17.
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
a 43-test catalog, domain-scoped inheritance, an always-required safety core,
a 44-test catalog, domain-scoped inheritance, an always-required safety core,
and a release gate that reads the exported artifact. The latest full
campaign, on dev image `20260822204234`, satisfied 43 of 43 from nothing and
authorizes a release; **no release is cut yet.**
@@ -578,7 +578,7 @@ under the domain model from the day's earlier dev images) and the export reads "
YES" for that image's manifest. That authorizes a release; it is not one
until `releases/v<version>/acceptance.json` is committed.
- **Catalog: 43 tests** in `forgetest/forgetest/suite/`, every one a port of a
- **Catalog: 44 tests** in `forgetest/forgetest/suite/`, every one a port of a
proven bench drill or a bench-verified check: the always-required core
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
@@ -587,13 +587,14 @@ until `releases/v<version>/acceptance.json` is committed.
quiet after motion, a gate setting tripping and off by value, a fan under
its floor), `camera.*`,
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
pause/resume/lid-cancel) and `cloud.*` (the mode round trip with the
lid-open hunt and the web-service homing on it, one real print, and the
job-behavior tests under the offline service: the cloud client driven
from a local socket with a synthesized laser-free job, no account, no
network, nothing on the bed). Tests that share a setup are merged; the
`auto` tests stay separate for failure isolation. 27 are `auto`, 8
`operator`, 8 `live`.
pause/resume/lid-cancel) and `cloud.*` (the service protocol answered by
the emulator in this machine's identity, with only the app to drive; the
mode round trip with the lid-open hunt and the web-service homing on it;
one real print; and the job-behavior tests under the offline service:
the cloud client driven from a local socket with a synthesized
laser-free job, no account, no network, nothing on the bed). Tests that
share a setup are merged; the `auto` tests stay separate for failure
isolation. 27 are `auto`, 9 `operator`, 8 `live`.
- **The operator's part is asked for by name, not by popup**
(`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a
timed step, a standing notice the test takes down when the machine shows
+173 -25
View File
@@ -318,6 +318,93 @@ def mode_switch(ctx):
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
@test("cloud.service-protocol", title="The service protocol, answered by the emulator in this "
"machine's identity",
subsystem="cloud", kind="operator", est_min=5,
covers=[("python3-gfutilities", "gfutilities/service/**"),
("python3-gfutilities", "gfutilities/device/basemachine.py"),
("python3-gfutilities", "gfutilities/device/emulator.py"),
("python3-gfutilities", "gfutilities/device/settings.py"),
("python3-gfutilities", "gfutilities/configuration.py"),
("python3-gfutilities", "gfutilities/_common.py"),
("python3-gfutilities", "gfutilities/__init__.py"),
("python3-gfutilities", "examples/**"),
("forgefirm-app", "gfcloud.py"), ("forgefirm-app", "ffmachine.py"),
("forgectrl", "src/super.c")],
requires=["forgectrl.auth"],
steps=["Cloud credentials configured; the machine on the network; the app open in a browser "
"(anyone can drive it, at the machine or not: nothing here moves, arms, or fires).",
"When told, set up any small job in the app and press Print; the emulator runs it at "
"once. Then confirm the app shows the print complete."],
description="The Glowforge service, end to end, with the machine emulated: the cloud client "
"restarted as gfutilities' Emulator in this machine's identity signs in, passes "
"the firmware check, opens the WebSocket, answers the connect-time hunt and the "
"service's image requests with canned frames, and runs a print from the app "
"through the same download path a job takes (the pulse header parsed, the "
"serial and format checked) and the same lifecycle events, to ':completed'. "
"What the service accepts is what the app shows. The real client is restarted "
"afterward and its connect-time hunt waited out. The machine's own side of a "
"print is the other cloud tests' to prove.")
def service_protocol(ctx):
ev = ctx.evidence
offset = enter_emulator(ctx)
try:
service_protocol_body(ctx, ev, offset)
finally:
leave_emulator(ctx)
import shutil
shutil.rmtree(EMULATOR_WORK, ignore_errors=True)
def service_protocol_body(ctx, ev, offset):
# the service's connect-time hunt, answered at once
hunt = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S)
ev["hunt"] = message(hunt)
ctx.check(hunt, "the service sent no connect-time hunt (or it never finished) within %d s", HUNT_TIMEOUT_S)
ctx.check(COMPLETED in hunt, "the emulator's hunt did not complete: %s", ev["hunt"])
# and the image it asks for after, uploaded from the canned frames
got = wait_log(ctx, offset, ["img_upload COMPLETE"], 120)
ctx.check(got["img_upload COMPLETE"], "no image upload completed after the hunt")
ctx.notice("In the app the machine shows Ready (its bed image is the emulator's canned frame). "
"Set up any small job and press Print. Nothing moves here; the test watches the "
"service's print reach the emulator and complete.")
try:
got = wait_log(ctx, offset, ["service action request: print"], 600)
ctx.check(got["service action request: print"], "no print reached the machine within 600 s")
fin = wait_action_finished(ctx, offset, "print", 300)
finally:
ctx.clear_notice()
ev["print"] = message(fin)
ctx.check(fin, "the print did not finish within 300 s of reaching the machine")
ctx.check(COMPLETED in fin, "the print did not complete: %s", ev["print"])
lines = log_lines_since(GFCLOUD_LOG, offset)
ev["images_uploaded"] = sum(1 for ln in lines if "img_upload COMPLETE" in ln)
ev["pulse"] = next((message(ln) for ln in lines if "pulse data is" in ln), None)
ctx.log("print: %s; %d images uploaded; %s", ev["print"], ev["images_uploaded"], ev["pulse"])
ctx.check(ev["pulse"], "the print's pulse data was not downloaded and parsed")
infos = []
try:
infos = sorted(f for f in os.listdir(EMULATOR_WORK) if f.endswith(".info"))
except OSError:
pass
ev["downloads"] = infos
header = None
if infos:
try:
with open(os.path.join(EMULATOR_WORK, infos[-1])) as f:
header = (json.load(f) or {}).get("header_data") or {}
except (OSError, ValueError):
header = None
ev["header_tags"] = len(header) if header else 0
ev["header_stfr"] = (header or {}).get("STfr")
ctx.check(header and header.get("STfr"), "the downloaded job's header was not parsed (%s)", infos)
ctx.log("the job's header: %d tags, STfr %s", ev["header_tags"], ev["header_stfr"])
ctx.confirm("Does the app show the print complete?")
ctx.log("PASS: session, hunt, %d image upload(s), a print downloaded (%d-tag header) and "
"completed, all against the real service with no hardware behind it",
ev["images_uploaded"], ev["header_tags"])
# ---- lid / button behavior of a cloud job (the factory's) ------------------
#
# These tests run IN cloud mode and stay there: enter_cloud() reuses a live
@@ -337,6 +424,11 @@ OFFLINE_MARK = "OFFLINE service"
OFFLINE_MARKER = "/run/gfcloud-offline"
OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
JOB_DIR = "/tmp/forgetest" # the jobs the offline tests write (tmpfs)
# The emulator (gfcloud --emulate, or the marker at start): the real
# service driven in this machine's identity with no hardware behind it.
EMULATE_MARK = "EMULATE: the emulator"
EMULATE_MARKER = "/run/gfcloud-emulate"
EMULATOR_WORK = "/tmp/gfcloud-emulate"
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
LOG_TAIL_BYTES = 4 << 20
@@ -485,7 +577,10 @@ def session_live(pid):
in (the client may log under a wrapper's pid)."""
tag = "gfcloud[%s]" % pid if pid else None
last_pid = last_any = None
emulator = False
for ln in log_tail(GFCLOUD_LOG):
if EMULATE_MARK in ln and (not tag or tag in ln):
emulator = True
for m in WS_MARKS:
if m in ln:
last_any = m
@@ -495,6 +590,9 @@ def session_live(pid):
where = "pid %s" % pid if last_pid is not None else "newest lines"
if last == OFFLINE_MARK:
return False, "%s: offline service, no web session" % where
if emulator:
# the emulator's session is live, and it is not the machine's
return False, "%s: the emulator, not the machine" % where
return last == "RX-EVENT: ready", "%s: last websocket state %s" % (where, last)
@@ -605,12 +703,80 @@ def enter_cloud(ctx):
def offline_marker_off(ctx):
"""No client started from here on comes up offline."""
"""No client started from here on comes up offline, or as the emulator."""
for path, what in ((OFFLINE_MARKER, "offline"), (EMULATE_MARKER, "emulate")):
try:
os.remove(path)
ctx.log("%s marker removed", what)
except OSError:
pass
def restart_client(ctx, marker=None, who=""):
"""The cloud client started fresh - restarted in cloud mode, switched to
from GRBL mode (the change declared) - under `marker` if one is given,
which stays in place until the client has read it (the caller waits
for the client's own line and takes the marker down; the supervisor
reports the client running seconds before Python has finished
importing). Returns the log offset from before the start."""
fc = ctx.forgectrl
st, m = fc.get("/mode")
ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st)
offset = log_size(GFCLOUD_LOG)
if marker:
with open(marker, "w") as f:
f.write("forgetest %s\n" % ctx.test.id)
if m.get("mode") == "cloud":
ctx.log("cloud mode: restarting the client%s (was pid %s)", who, m.get("pid"))
st, body = fc.post("/controller/stop")
ctx.check(st == 200, "controller stop refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", want_controller="standby", timeout=30)
ctx.check(m and m.get("controller") == "standby", "the cloud client did not stop: %s", m)
st, body = fc.post("/controller/start")
ctx.check(st == 200, "controller start refused: %s %s", st, body)
else:
ctx.log("%s mode: switching to cloud%s", m.get("mode"), who)
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
ctx.mode_changed("cloud")
return offset
def enter_emulator(ctx):
"""The real service with the emulator in this machine's identity: the
client restarted under the emulate marker (one start), its session
established. Returns the log offset from before the start, so the
caller sees the connect. The caller restarts the real client when
done (leave_emulator)."""
offset = restart_client(ctx, marker=EMULATE_MARKER, who=" as the emulator")
try:
os.remove(OFFLINE_MARKER)
ctx.log("offline marker removed")
except OSError:
pass
got = wait_log(ctx, offset, [EMULATE_MARK], 60)
ctx.check(got[EMULATE_MARK], "the client did not come up as the emulator within 60 s")
finally:
offline_marker_off(ctx)
session = wait_session(ctx, offset)
ctx.check(session_established(session),
"the emulator never established its service session (no credentials, no network, "
"or the service refused)")
ctx.log("emulator session established: %s", "; ".join(ln.split(" ", 1)[-1][:80] for ln in session))
return offset
def leave_emulator(ctx):
"""The real client back, its session waited for and its connect-time
hunt waited out, so the machine is what the next test expects."""
offline_marker_off(ctx)
offset = restart_client(ctx, who=" with the machine")
session = wait_session(ctx, offset)
ctx.check(session_established(session), "the real client did not establish its session after the emulator")
hunt = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S)
ctx.check(hunt, "the real client's connect-time hunt did not finish within %d s", HUNT_TIMEOUT_S)
ctx.check(wait_quiet(ctx, offset), "the service was still moving the head %d s after the real "
"client came back", QUIET_TIMEOUT_S)
ctx.log("the machine is back under the real client")
def enter_offline(ctx):
@@ -627,29 +793,11 @@ def enter_offline(ctx):
if m.get("mode") == "cloud" and m.get("controller") == "running" and client_offline(m.get("pid")):
ctx.log("the offline service is already up (pid %s) - reusing it", m.get("pid"))
return log_size(GFCLOUD_LOG)
offset = log_size(GFCLOUD_LOG)
with open(OFFLINE_MARKER, "w") as f:
f.write("forgetest %s\n" % ctx.test.id)
offset = restart_client(ctx, marker=OFFLINE_MARKER, who=" offline")
try:
if m.get("mode") == "cloud":
ctx.log("cloud mode: restarting the client offline (was pid %s)", m.get("pid"))
st, body = fc.post("/controller/stop")
ctx.check(st == 200, "controller stop refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", want_controller="standby", timeout=30)
ctx.check(m and m.get("controller") == "standby", "the cloud client did not stop: %s", m)
st, body = fc.post("/controller/start")
ctx.check(st == 200, "controller start refused: %s %s", st, body)
else:
ctx.log("%s mode: switching to cloud, offline", m.get("mode"))
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
ctx.mode_changed("cloud")
got = wait_log(ctx, offset, [OFFLINE_MARK], 60)
ctx.check(got[OFFLINE_MARK], "the client did not come up as the offline service within 60 s")
ctx.log("offline service up (pid %s): %s", m.get("pid"), message(got[OFFLINE_MARK]))
ctx.log("offline service up: %s", message(got[OFFLINE_MARK]))
finally:
offline_marker_off(ctx)
ctx.check(wait_quiet(ctx, offset), "the machine was not quiet %d s after the offline start", QUIET_TIMEOUT_S)
+83
View File
@@ -13,6 +13,7 @@ reuse an existing cloud session and never switch back, restart the client
for a fresh hunt, judge the print's own finish line (not another action's),
wait the service's deferred moves out, and fail for the right reasons."""
import contextlib
import json
import os
import shutil
import struct
@@ -170,6 +171,9 @@ class CloudSuiteTests(unittest.TestCase):
cloud.Offline = FakeOffline
cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker")
cloud.JOB_DIR = os.path.join(self.tmp, "jobs")
self.saved_emu = (cloud.EMULATE_MARKER, cloud.EMULATOR_WORK)
cloud.EMULATE_MARKER = os.path.join(self.tmp, "emulate-marker")
cloud.EMULATOR_WORK = os.path.join(self.tmp, "emu-work")
FakeOffline.sent = []
FakeOffline.on_send = None
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
@@ -188,6 +192,7 @@ class CloudSuiteTests(unittest.TestCase):
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER,
cloud.JOB_DIR) = self.saved
(cloud.EMULATE_MARKER, cloud.EMULATOR_WORK) = self.saved_emu
os.environ.pop("GF_SYSFS_ROOT", None)
os.environ.pop("GF_LEDS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
@@ -262,6 +267,84 @@ class CloudSuiteTests(unittest.TestCase):
self.assertIn("offline", detail)
self.assertTrue(cloud.client_offline(3100))
# -- the emulator ------------------------------------------------------------------
EMU_PID = 4100
EMU_START = ["2026-08-22T23:50:00.100000+00:00 gfcloud[4100] INFO ffmachine:build_emulator EMULATE: the "
"emulator in this machine's identity, frames from /usr/share/gfutilities/emulator, no hardware",
"2026-08-22T23:50:02.000000+00:00 gfcloud[4100] INFO authentication:authenticate_machine SUCCESS",
"2026-08-22T23:50:03.000000+00:00 gfcloud[4100] INFO websocket:ws_connect ESTABLISHED",
"2026-08-22T23:50:05.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action hunt [1578300001]: "
"finished with event \":completed\"",
"2026-08-22T23:50:06.000000+00:00 gfcloud[4100] INFO websocket:img_upload COMPLETE",
"2026-08-22T23:50:06.500000+00:00 gfcloud[4100] INFO basemachine:_finish_action lid_image [1578300002]: "
"finished with event \":completed\""]
EMU_PRINT = ["2026-08-22T23:51:00.000000+00:00 gfcloud[4100] INFO gfuiservice:run service action request: print (ready)",
"2026-08-22T23:51:01.000000+00:00 gfcloud[4100] INFO websocket:fetch_motion pulse data is uncompressed, "
"47848 byte body",
"2026-08-22T23:51:02.000000+00:00 gfcloud[4100] INFO basemachine:_finish_action print [1578300003]: "
"finished with event \":completed\""]
REAL_BACK = ["2026-08-22T23:52:02.000000+00:00 gfcloud[4200] INFO authentication:authenticate_machine SUCCESS",
"2026-08-22T23:52:03.000000+00:00 gfcloud[4200] INFO websocket:ws_connect ESTABLISHED",
"2026-08-22T23:52:03.500000+00:00 gfcloud[4200] INFO websocket:_on_open RX-EVENT: ready",
"2026-08-22T23:52:15.000000+00:00 gfcloud[4200] INFO basemachine:_finish_action hunt [1578300010]: "
"finished with event \":completed\""]
def emulator_setup(self, print_lines=None, info=True):
self.in_cloud(pid=2278)
self.append(["2026-08-22T23:49:00.500000+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready"])
starts = []
def on_post(path, form):
if path == "/controller/stop":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
elif path == "/controller/start":
starts.append(os.path.exists(cloud.EMULATE_MARKER))
if len(starts) == 1:
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=self.EMU_PID)
self.append(self.EMU_START, delay=0.2)
else:
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=4200)
self.append(self.REAL_BACK, delay=0.2)
return None
self.fc.on_post = on_post
def at_print():
os.makedirs(cloud.EMULATOR_WORK, exist_ok=True)
if info:
with open(os.path.join(cloud.EMULATOR_WORK, "2026-08-22_235101_print.info"), "w") as f:
json.dump({"header_data": {"STfr": 10000, "MCsn": 0, "PDfm": 0}}, f)
self.append(print_lines if print_lines is not None else self.EMU_PRINT, delay=0.1)
return starts, {"Set up any small job": at_print}
def test_service_protocol_against_the_emulator(self):
starts, hooks = self.emulator_setup()
run = self.run_test(cloud.service_protocol, hooks=hooks, test_id="cloud.service-protocol")
ev = run.evidence
self.assertEqual(starts, [True, False]) # emulator under the marker, the real client without
self.assertFalse(os.path.exists(cloud.EMULATE_MARKER))
self.assertEqual([p for p, _ in self.fc.posts],
["/controller/stop", "/controller/start", "/controller/stop", "/controller/start"])
self.assertIn(":completed", ev["hunt"])
self.assertIn(":completed", ev["print"])
self.assertEqual(ev["images_uploaded"], 1)
self.assertEqual(ev["header_stfr"], 10000)
self.assertEqual(self.script.asked, ["Does the app show the print complete?"])
self.assertTrue(any("PASS: session, hunt" in l for l in run.lines), run.lines[-5:])
self.assertFalse(os.path.exists(cloud.EMULATOR_WORK))
def test_service_protocol_fails_when_the_print_does_not_complete_and_still_restores_the_client(self):
failed = [l.replace(':completed"', ':failed"') for l in self.EMU_PRINT]
starts, hooks = self.emulator_setup(print_lines=failed)
self.assertFails(cloud.service_protocol, "did not complete", hooks=hooks)
self.assertEqual(starts, [True, False]) # the real client came back regardless
def test_the_emulators_session_is_not_the_machines(self):
self.append(self.EMU_START + ["2026-08-22T23:50:07.000000+00:00 gfcloud[4100] INFO websocket:_on_open "
"RX-EVENT: ready"])
live, detail = cloud.session_live(4100)
self.assertFalse(live)
self.assertIn("emulator", detail)
# -- the offline entry ----------------------------------------------------------
def test_enter_offline_reuses_a_running_offline_client(self):
self.in_offline()
@@ -4,9 +4,9 @@
# because the component entry already identifies the pinned source
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
# meta-glowforge-bsp pins the same repository; move both together.
SRCREV = "a3ca36f68b01fab968f6062183d2d94ab53e3583"
SRCREV = "12ad3b19166082c93b497a722cbb7a2fb91f810d"
# Bump PV with every SRCREV move: the hash-derived package version is not
# monotonic on its own and buildhistory QA fails the build when it sorts
# backwards.
PV = "0.1.17+git"
PV = "0.1.18+git"
@@ -5,10 +5,12 @@ DESCRIPTION = "OpenGlow/ForgeFIRM development image for Glowforge"
# Strict superset of forgefirm-image: everything the main image ships, plus
# debug tooling. forgetest is the release acceptance tool (HTTP :8090) and
# the bench diagnostics page; it belongs to the bench, never to a release
# image (docs/ACCEPTANCE.md).
# image (docs/ACCEPTANCE.md). The gfutilities emulator fixtures are the
# canned frames its service-protocol test answers the service with.
IMAGE_INSTALL += " \
forgectrl \
forgetest \
python3-gfutilities-emulator \
htop \
"