mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-16 01:29:55 -07:00
Compare commits
6
Commits
v0.0.7
..
dfd32d5fd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd32d5fd1 | ||
|
|
35221de791 | ||
|
|
46b6df1c2d | ||
|
|
16a8ae9ba3 | ||
|
|
72907f311a | ||
|
|
3873013383 |
@@ -3,7 +3,7 @@
|
|||||||
> ### BETA
|
> ### BETA
|
||||||
>
|
>
|
||||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||||
> Expect problems, and expect frequent updates. Upgrade whenever a newer
|
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||||
> release is available, and report what you find on the
|
> release is available, and report what you find on the
|
||||||
> [community forum](https://community.openglow.org).
|
> [community forum](https://community.openglow.org).
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from . import setup_sheet # noqa: F401,E402
|
|||||||
from . import logs # noqa: F401,E402
|
from . import logs # noqa: F401,E402
|
||||||
from . import motion # noqa: F401,E402
|
from . import motion # noqa: F401,E402
|
||||||
from . import cooling # noqa: F401,E402
|
from . import cooling # noqa: F401,E402
|
||||||
|
from . import cooling_intake # noqa: F401,E402
|
||||||
from . import laser # noqa: F401,E402
|
from . import laser # noqa: F401,E402
|
||||||
from . import camera # noqa: F401,E402
|
from . import camera # noqa: F401,E402
|
||||||
from . import update # noqa: F401,E402
|
from . import update # noqa: F401,E402
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""cooling.intake-one-fan - a machine built with one intake fan
|
||||||
|
(cool_intake_fans 1, which the setup's airflow check writes): the second
|
||||||
|
intake tach is published as not fitted and never judged, and intake 1
|
||||||
|
still holds the intake floor. On a machine with two intake fans the
|
||||||
|
setting alone proves both halves: intake 2 turns and is not judged, and
|
||||||
|
an intake floor no fan can meet trips with intake 2 still out of it."""
|
||||||
|
from ..catalog import test
|
||||||
|
from .cooling import _COOL_COVERS, _after_session, _cool, _fan_gate, _run_session, _set_gates
|
||||||
|
|
||||||
|
INTAKE_KEYS = ("cool_intake_fans", "cool_tach_intake_min_rpm", "cool_fan_grace_s")
|
||||||
|
INTAKE_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips
|
||||||
|
INTAKE_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline
|
||||||
|
|
||||||
|
|
||||||
|
@test("cooling.intake-one-fan", title="With one intake fan set, intake 2 is not judged and intake 1 still is",
|
||||||
|
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
|
||||||
|
covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.fan-gate-trips"],
|
||||||
|
steps=["Machine idle, fans quiet. The test sets one intake fan, the intake floor and the spin-up "
|
||||||
|
"grace and restores all three; three short M8/M9 cycles spin the fans."],
|
||||||
|
description="cool_intake_fans 1 must take intake 2 out of the airflow gates and leave intake 1 in "
|
||||||
|
"them. With it set, a session at the cut fan profile must publish intake_2 as not fitted "
|
||||||
|
"with no floor while it reads its real speed, run OK, and judge intake_1 ok at the intake "
|
||||||
|
"floor; an intake floor at its legal maximum must then trip AIRFLOW (hold, fire blocked) "
|
||||||
|
"with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the "
|
||||||
|
"session; restored, the next session judges intake_2 again and every fan reads at or "
|
||||||
|
"above its floor.")
|
||||||
|
def intake_one_fan(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
orig = {k: before.get(k, "") for k in INTAKE_KEYS}
|
||||||
|
ev["orig"] = orig
|
||||||
|
ctx.log("original: %s", orig)
|
||||||
|
inl = (before.get("gates") or {}).get("cool_tach_intake_min_rpm") or {}
|
||||||
|
ctx.check(inl.get("gate") == "intake" and inl.get("off") == "low",
|
||||||
|
"settings reply does not describe the intake floor as the intake gate, off at zero: %s", inl)
|
||||||
|
ctx.check(orig["cool_intake_fans"] != "1",
|
||||||
|
"cool_intake_fans is already 1: the test starts from a machine with both intakes judged")
|
||||||
|
c0 = _cool(fc)
|
||||||
|
ctx.check(c0.get("verdict") == "OK" and c0.get("gates_off") == [],
|
||||||
|
"engine is not at OK with every gate on before the test: %s", c0)
|
||||||
|
|
||||||
|
restored = False
|
||||||
|
with ctx.grbl() as grbl:
|
||||||
|
st = grbl.status_report()
|
||||||
|
ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"])
|
||||||
|
try:
|
||||||
|
# Leg 1: one intake fan. Intake 2 still turns on this machine; it
|
||||||
|
# is measured and published not fitted, and nothing judges it.
|
||||||
|
_set_gates(ctx, fc, {"cool_intake_fans": "1", "cool_fan_grace_s": INTAKE_GRACE_S})
|
||||||
|
c = _run_session(ctx, grbl, fc,
|
||||||
|
lambda c: c.get("verdict") == "OK" and _fan_gate(c, "intake_1").get("state") == "ok",
|
||||||
|
"one intake fan leg", wait=INTAKE_TRIP_WAIT_S + 10)
|
||||||
|
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||||
|
ev["one_fan"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||||
|
ctx.check(g2.get("state") == "not fitted" and (g2.get("floor") or 0) == 0,
|
||||||
|
"intake_2 is not published not fitted with no floor: %s", g2)
|
||||||
|
ctx.check((g2.get("reading") or 0) > 0,
|
||||||
|
"intake_2 reads %s: the test needs a machine whose second intake turns", g2.get("reading"))
|
||||||
|
ctx.check(c.get("verdict") == "OK", "the one intake fan session is not OK: %s", c)
|
||||||
|
ctx.check(g1.get("state") == "ok" and (g1.get("floor") or 0) > 0,
|
||||||
|
"intake_1 is not judged ok at the intake floor: %s", g1)
|
||||||
|
|
||||||
|
# Leg 2: the intake floor at its legal maximum. Intake 1 is the
|
||||||
|
# one intake left in the gates; intake 2 stays out of them.
|
||||||
|
_set_gates(ctx, fc, {"cool_tach_intake_min_rpm": str(inl.get("hi"))})
|
||||||
|
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "intake 1 trip leg",
|
||||||
|
wait=INTAKE_TRIP_WAIT_S)
|
||||||
|
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||||
|
ev["intake_1_trip"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||||
|
ctx.check(c.get("verdict") == "AIRFLOW", "an intake floor of %s rpm did not trip: %s", inl.get("hi"), c)
|
||||||
|
ctx.check(c.get("fire_ok") is False and c.get("hold") is True,
|
||||||
|
"AIRFLOW without fire blocked and a hold: %s", c)
|
||||||
|
ctx.check(g1.get("state") == "TRIPPED", "intake_1 does not read TRIPPED: %s", g1)
|
||||||
|
ctx.check(g2.get("state") == "not fitted", "intake_2 was judged with one intake fan set: %s", g2)
|
||||||
|
ctx.check("intake_1" in (c.get("reason") or ""), "the reason does not name intake_1: %r", c.get("reason"))
|
||||||
|
c = _after_session(ctx, fc)
|
||||||
|
ev["intake_1_trip_after"] = c
|
||||||
|
ctx.check(c.get("verdict") == "OK" and c.get("hold") is False,
|
||||||
|
"the fan fault outlived its run session: %s", c)
|
||||||
|
|
||||||
|
# Restore, and prove it: intake 2 judged again, every fan at or
|
||||||
|
# above its floor. The restored grace is the shipped one.
|
||||||
|
_set_gates(ctx, fc, orig)
|
||||||
|
restored = True
|
||||||
|
c = _run_session(ctx, grbl, fc,
|
||||||
|
lambda c: c.get("verdict") == "OK" and not c.get("gates_off")
|
||||||
|
and all(g.get("state") == "ok" for g in (c.get("fan_gates") or {}).values()),
|
||||||
|
"restored", wait=INTAKE_TRIP_WAIT_S + 20)
|
||||||
|
ev["restored"] = c.get("fan_gates")
|
||||||
|
ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [],
|
||||||
|
"engine did not return to OK with every gate on after the restore: %s", c)
|
||||||
|
bad = {k: g for k, g in (c.get("fan_gates") or {}).items() if g.get("state") != "ok"}
|
||||||
|
ctx.check(not bad, "fans not at or above their floors after the restore: %s", bad)
|
||||||
|
finally:
|
||||||
|
if not restored:
|
||||||
|
st, body = fc.post("/settings", params=orig)
|
||||||
|
ctx.log("restore on failure: POST /settings %s -> %s", orig, st)
|
||||||
|
try:
|
||||||
|
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
|
||||||
|
"restore on failure")
|
||||||
|
ctx.log("restore on failure: engine %s gates_off %s", c.get("verdict"), c.get("gates_off"))
|
||||||
|
except Exception as e: # the original failure is the one to report
|
||||||
|
ctx.log("restore on failure: run session did not complete (%s)", e)
|
||||||
|
after = fc.settings()
|
||||||
|
ctx.check(all(after.get(k, "") == orig[k] for k in INTAKE_KEYS),
|
||||||
|
"settings not restored: %s", {k: after.get(k) for k in INTAKE_KEYS})
|
||||||
|
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
|
||||||
@@ -49,8 +49,11 @@ def _reader(sock, seen, stop):
|
|||||||
|
|
||||||
|
|
||||||
@test("events.button-telemetry", title="The event stream tells a press of the button, and the telemetry",
|
@test("events.button-telemetry", title="The event stream tells a press of the button, and the telemetry",
|
||||||
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
|
subsystem="forgectrl", kind="operator", mode="grbl", est_min=2,
|
||||||
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
|
||||||
|
actions=["button"],
|
||||||
|
steps=["The machine idle; you will be told to press the button once.",
|
||||||
|
"Nothing moves and nothing fires: nothing is waiting for the press."],
|
||||||
description="A stream opened at forgectrl's read-only listener gets a telemetry.tick within 12 s, "
|
description="A stream opened at forgectrl's read-only listener gets a telemetry.tick within 12 s, "
|
||||||
"and it is what the daemon already holds: the cooling phase, the verdict, fire_ok, the "
|
"and it is what the daemon already holds: the cooling phase, the verdict, fire_ok, the "
|
||||||
"two coolant temperatures, the controller's state, and the lid, with no sensor read of "
|
"two coolant temperatures, the controller's state, and the lid, with no sensor read of "
|
||||||
|
|||||||
@@ -1542,10 +1542,12 @@ def hold_pause_tier(ctx):
|
|||||||
# ------------------------------------------------- the operator's door
|
# ------------------------------------------------- the operator's door
|
||||||
|
|
||||||
@test("exthost.events", title="The machine's events reach a package, over one subscription",
|
@test("exthost.events", title="The machine's events reach a package, over one subscription",
|
||||||
subsystem="exthost", kind="auto", hardware="takeover", est_min=6,
|
subsystem="exthost", kind="operator", hardware="takeover", est_min=6,
|
||||||
covers=[("forgeext", "src/evfeed.*"), ("forgeext", "src/api.*"), ("forgeext", "src/run.*"),
|
covers=[("forgeext", "src/evfeed.*"), ("forgeext", "src/api.*"), ("forgeext", "src/run.*"),
|
||||||
("forgectrl", "src/events.*"), ("forgectrl", "src/main.c")],
|
("forgectrl", "src/events.*"), ("forgectrl", "src/main.c")],
|
||||||
requires=["exthost.service"],
|
requires=["exthost.service"], actions=["lid"],
|
||||||
|
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||||
|
"Nothing moves and the laser is not involved."],
|
||||||
description="The reference package is installed with the events capability. The host holds one "
|
description="The reference package is installed with the events capability. The host holds one "
|
||||||
"subscription to forgectrl's stream for every package that wants events, and none when "
|
"subscription to forgectrl's stream for every package that wants events, and none when "
|
||||||
"no package does: with the service running the status file says the stream is connected "
|
"no package does: with the service running the status file says the stream is connected "
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||||
subsystem="setup", kind="operator", est_min=3,
|
subsystem="setup", kind="operator", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
|
||||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||||
@@ -191,20 +191,28 @@ def check_sensors(ctx):
|
|||||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
"for 35 s, the purge is measured on and off, and six settings are written: the "
|
||||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
"four floors at 55 percent of steady, the grace from the slowest spin-up, and the "
|
||||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
"intake fans fitted (2 when both intakes turn; 1 when intake 2 never reads a pulse "
|
||||||
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
"and intake 1 passes, and then intake 2 is reported not fitted and the intake floor "
|
||||||
"and the controller must be running again.")
|
"comes from intake 1). Each fan judged must read at least 1000 rpm. The six "
|
||||||
|
"settings are put back as found at the end, the purge fan is on again and drawing "
|
||||||
|
"over the floor the check just wrote, and the controller must be running again.")
|
||||||
def check_airflow(ctx):
|
def check_airflow(ctx):
|
||||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||||
with Restore(ctx, keys):
|
with Restore(ctx, keys + ["cool_intake_fans"]):
|
||||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||||
r = last.get("result") or {}
|
r = last.get("result") or {}
|
||||||
fans = r.get("fans") or {}
|
fans = r.get("fans") or {}
|
||||||
|
intakes = r.get("intake_fans")
|
||||||
|
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||||
|
ctx.log("intake fans fitted: %s", intakes)
|
||||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||||
f = fans.get(name) or {}
|
f = fans.get(name) or {}
|
||||||
|
if name == "intake 2" and intakes == 1:
|
||||||
|
ctx.check(f == {"fitted": False}, "intake 2 with one intake fan: %s", f)
|
||||||
|
continue
|
||||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||||
floors = r.get("floors") or {}
|
floors = r.get("floors") or {}
|
||||||
@@ -213,6 +221,8 @@ def check_airflow(ctx):
|
|||||||
s = ctx.forgectrl.settings() or {}
|
s = ctx.forgectrl.settings() or {}
|
||||||
for k in keys:
|
for k in keys:
|
||||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||||
|
ctx.check(s.get("cool_intake_fans") == str(intakes),
|
||||||
|
"cool_intake_fans reads %r, the check found %r", s.get("cool_intake_fans"), intakes)
|
||||||
ctx.log("floors: %s", json.dumps(floors))
|
ctx.log("floors: %s", json.dumps(floors))
|
||||||
# The check switches purge air off to read its off current. Leaving
|
# The check switches purge air off to read its off current. Leaving
|
||||||
# it off costs the next job an airflow hold mid-cut, judged against
|
# it off costs the next job an airflow hold mid-cut, judged against
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""cooling.intake-one-fan replayed host-side under the real runner
|
||||||
|
Context against a scripted engine: the fake reads cool_intake_fans and
|
||||||
|
the floors at every M8 (the engine reloads at run start), publishes
|
||||||
|
intake_2 not fitted with no floor when one intake fan is set, holds every
|
||||||
|
fan in grace for the configured seconds, then judges the bench readings
|
||||||
|
against the floors. The negative engines are the two ways the change
|
||||||
|
could go wrong: the setting ignored (intake 2 still judged) and the
|
||||||
|
setting taking intake 1 out with it."""
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers
|
||||||
|
from forgetest.runner import Context, Failed, Run
|
||||||
|
from forgetest.suite import cooling, cooling_intake
|
||||||
|
from test_cooling_suite import FakeGrbl
|
||||||
|
|
||||||
|
|
||||||
|
class IntakeOneFanTests(unittest.TestCase):
|
||||||
|
READINGS = {"exhaust": 11640, "intake_1": 4157, "intake_2": 4158, "air_assist": 11050, "purge": 625}
|
||||||
|
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 6400.0, 20000.0),
|
||||||
|
"intake_1": ("cool_tach_intake_min_rpm", 2290.0, 20000.0),
|
||||||
|
"intake_2": ("cool_tach_intake_min_rpm", 2290.0, 20000.0),
|
||||||
|
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
|
||||||
|
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
|
||||||
|
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
|
||||||
|
"air_assist": "air_assist", "purge": "purge"}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fc = helpers.FakeForgectrl().start()
|
||||||
|
self.grbl = FakeGrbl()
|
||||||
|
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling_intake.INTAKE_TRIP_WAIT_S)
|
||||||
|
cooling.VERDICT_WAIT_S = 4
|
||||||
|
cooling.SESSION_END_WAIT_S = 3
|
||||||
|
cooling_intake.INTAKE_TRIP_WAIT_S = 4
|
||||||
|
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
|
||||||
|
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
|
||||||
|
"gates_off": [], "fan_gates": {}}
|
||||||
|
for key, _d, _h in self.FLOORS.values():
|
||||||
|
self.fc.state["settings"].setdefault(key, "")
|
||||||
|
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
|
||||||
|
self.fc.state["settings"].setdefault("cool_intake_fans", "")
|
||||||
|
self.honors_setting = True # the engine takes intake 2 out with one intake fan
|
||||||
|
self.drops_both = False # the engine takes intake 1 out with it
|
||||||
|
self.grace_scale = 0.1 # seconds of fake grace per configured second
|
||||||
|
self._describe()
|
||||||
|
self.grbl.on_command = self._engine
|
||||||
|
self.fc.on_post = self._on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling_intake.INTAKE_TRIP_WAIT_S = self.saved
|
||||||
|
self.grbl.close()
|
||||||
|
self.fc.stop()
|
||||||
|
|
||||||
|
def setting(self, key, default):
|
||||||
|
v = self.fc.state["settings"].get(key) or ""
|
||||||
|
return float(v) if v else default
|
||||||
|
|
||||||
|
def _describe(self):
|
||||||
|
gates = {}
|
||||||
|
for fan, (key, default, hi) in self.FLOORS.items():
|
||||||
|
v = self.setting(key, default)
|
||||||
|
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
|
||||||
|
"band": [default * 0.9, default * 1.1], "off": "low", "value": v,
|
||||||
|
"state": "off" if v <= 0 else "ok"}
|
||||||
|
self.fc.state["settings"]["gates"] = gates
|
||||||
|
|
||||||
|
def _on_post(self, path, form):
|
||||||
|
if path != "/settings":
|
||||||
|
return None
|
||||||
|
self.fc.state["settings"].update(form)
|
||||||
|
self._describe()
|
||||||
|
return (200, self.fc.state["settings"])
|
||||||
|
|
||||||
|
def _engine(self, line):
|
||||||
|
self._describe()
|
||||||
|
cool = self.fc.state["cool"]
|
||||||
|
if line == "M9":
|
||||||
|
def end():
|
||||||
|
time.sleep(0.3)
|
||||||
|
cool["phase"] = "idle"
|
||||||
|
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
||||||
|
for g in cool.get("fan_gates", {}).values():
|
||||||
|
if g["state"] != "not fitted":
|
||||||
|
g["state"] = "idle"
|
||||||
|
threading.Thread(target=end, daemon=True).start()
|
||||||
|
return
|
||||||
|
if line != "M8":
|
||||||
|
return
|
||||||
|
cool["phase"] = "run"
|
||||||
|
one = self.fc.state["settings"].get("cool_intake_fans") == "1" and self.honors_setting
|
||||||
|
out = {"intake_2"} | ({"intake_1"} if self.drops_both else set()) if one else set()
|
||||||
|
floors = {fan: 0.0 if fan in out else self.setting(key, default)
|
||||||
|
for fan, (key, default, _h) in self.FLOORS.items()}
|
||||||
|
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=[],
|
||||||
|
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
||||||
|
"state": "not fitted" if f in out else "grace"} for f in floors})
|
||||||
|
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
|
||||||
|
|
||||||
|
def judge():
|
||||||
|
time.sleep(grace)
|
||||||
|
if cool["phase"] != "run":
|
||||||
|
return
|
||||||
|
for f in floors:
|
||||||
|
if f not in out:
|
||||||
|
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
||||||
|
time.sleep(0.3)
|
||||||
|
if cool["phase"] != "run":
|
||||||
|
return
|
||||||
|
for f in floors:
|
||||||
|
if f not in out and floors[f] > self.READINGS[f]:
|
||||||
|
cool["fan_gates"][f]["state"] = "TRIPPED"
|
||||||
|
if cool["verdict"] != "AIRFLOW":
|
||||||
|
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
|
||||||
|
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
|
||||||
|
% (f, self.READINGS[f], floors[f]))
|
||||||
|
threading.Thread(target=judge, daemon=True).start()
|
||||||
|
|
||||||
|
def run_test(self):
|
||||||
|
run = Run("test", "cooling.intake-one-fan", "t")
|
||||||
|
ctx = Context(run, None, helpers.make_test("cooling.intake-one-fan", []))
|
||||||
|
cooling_intake.intake_one_fan(ctx)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def settings_posts(self):
|
||||||
|
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||||
|
|
||||||
|
def restored(self):
|
||||||
|
return {"cool_intake_fans": "", "cool_tach_intake_min_rpm": "", "cool_fan_grace_s": ""}
|
||||||
|
|
||||||
|
def test_one_fan_then_intake_1_trip_then_restore_pass(self):
|
||||||
|
run = self.run_test()
|
||||||
|
ev = run.evidence
|
||||||
|
self.assertEqual(ev["one_fan"]["intake_2"]["state"], "not fitted")
|
||||||
|
self.assertEqual(ev["one_fan"]["intake_1"]["state"], "ok")
|
||||||
|
self.assertEqual(ev["intake_1_trip"]["intake_1"]["state"], "TRIPPED")
|
||||||
|
self.assertEqual(ev["intake_1_trip"]["intake_2"]["state"], "not fitted")
|
||||||
|
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
||||||
|
posts = self.settings_posts()
|
||||||
|
self.assertEqual(posts[0], {"cool_intake_fans": "1", "cool_fan_grace_s": "8"})
|
||||||
|
self.assertEqual(posts[1], {"cool_tach_intake_min_rpm": "20000.0"})
|
||||||
|
self.assertEqual(posts[-1], self.restored())
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||||
|
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||||
|
|
||||||
|
def test_an_engine_that_ignores_the_setting_fails_and_restores(self):
|
||||||
|
self.honors_setting = False
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("intake_2 is not published not fitted", str(cm.exception))
|
||||||
|
self.assertEqual(self.settings_posts()[-1], self.restored())
|
||||||
|
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||||
|
|
||||||
|
def test_an_engine_that_drops_intake_1_too_fails_and_restores(self):
|
||||||
|
self.drops_both = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("intake_1 is not judged ok at the intake floor", str(cm.exception))
|
||||||
|
self.assertEqual(self.settings_posts()[-1], self.restored())
|
||||||
|
|
||||||
|
def test_a_machine_already_at_one_intake_fan_is_refused_untouched(self):
|
||||||
|
self.fc.state["settings"]["cool_intake_fans"] = "1"
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
self.run_test()
|
||||||
|
self.assertIn("already 1", str(cm.exception))
|
||||||
|
self.assertEqual(self.settings_posts(), [])
|
||||||
|
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -314,6 +314,24 @@ class CatalogTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
||||||
|
|
||||||
|
def test_every_action_a_test_takes_is_declared(self):
|
||||||
|
# The queues route a test by its kind and its actions: an auto test
|
||||||
|
# runs unattended with or without a fixture, and an operator test
|
||||||
|
# whose actions the fixture covers runs unattended only while it
|
||||||
|
# does. A test that acts on the lid, the interlock or the button
|
||||||
|
# without saying so runs unattended with no fixture and waits for a
|
||||||
|
# person who is not there.
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from forgetest import catalog
|
||||||
|
bad = []
|
||||||
|
for t in catalog.all_tests(catalog.load_suite()):
|
||||||
|
src = inspect.getsource(t.fn)
|
||||||
|
taken = set(re.findall(r'ctx\.act\(\s*"(\w+)"', src))
|
||||||
|
if taken and (t.kind == "auto" or not taken <= set(t.actions)):
|
||||||
|
bad.append("%s (%s) acts on %s, declares %s" % (t.id, t.kind, sorted(taken), list(t.actions)))
|
||||||
|
self.assertEqual(bad, [])
|
||||||
|
|
||||||
|
|
||||||
class StubFixture:
|
class StubFixture:
|
||||||
"""What the runner needs of a fixture, scripted."""
|
"""What the runner needs of a fixture, scripted."""
|
||||||
|
|||||||
@@ -137,5 +137,124 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
|
|
||||||
|
|
||||||
|
class AirflowCheckTests(unittest.TestCase):
|
||||||
|
"""setup.check-airflow's judge against a scripted daemon: the check
|
||||||
|
writes its floors and the intake fans fitted, and the test reads the
|
||||||
|
fans the count says are there. A machine built with one intake fan
|
||||||
|
reports intake 2 not fitted; the bench reference has two."""
|
||||||
|
|
||||||
|
FLOORS = {"cool_tach_exhaust_min_rpm": "6400", "cool_tach_intake_min_rpm": "2290",
|
||||||
|
"cool_tach_air_assist_min_rpm": "6050", "cool_purge_min_current": "345",
|
||||||
|
"cool_fan_grace_s": "12"}
|
||||||
|
FAN = {"steady_rpm": 4150, "spinup_s": 6.8, "ok": True}
|
||||||
|
|
||||||
|
def result(self, intakes):
|
||||||
|
fans = {"exhaust": dict(self.FAN), "intake 1": dict(self.FAN), "intake 2": dict(self.FAN),
|
||||||
|
"air assist": dict(self.FAN)}
|
||||||
|
if intakes == 1:
|
||||||
|
fans["intake 2"] = {"fitted": False}
|
||||||
|
return {"fans": fans, "intake_fans": intakes, "purge_current_on": 628, "purge_current_off": 74,
|
||||||
|
"floors": dict(self.FLOORS)}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import tempfile
|
||||||
|
self.sysfs = tempfile.mkdtemp(prefix="forgetest-airflow-")
|
||||||
|
os.makedirs(os.path.join(self.sysfs, "head"))
|
||||||
|
with open(os.path.join(self.sysfs, "head", "purge_air"), "w") as f:
|
||||||
|
f.write("1\n")
|
||||||
|
self.saved_root = os.environ.get("GF_SYSFS_ROOT")
|
||||||
|
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
|
||||||
|
self.fake = helpers.FakeForgectrl().start()
|
||||||
|
self.fake.state["settings"].update({"cool_tach_intake_min_rpm": "2300"})
|
||||||
|
self.fake.state["cool"] = {"phase": "idle", "fan_gates": {"purge": {"reading": 628, "floor": 345}}}
|
||||||
|
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||||
|
"log": [], "prompt": None, "result": None, "error": "",
|
||||||
|
"shots": {"lid": False, "head": False}}
|
||||||
|
self.versions = {}
|
||||||
|
self.polls = 0
|
||||||
|
self.wizard_result = self.result(2)
|
||||||
|
self.wizard_writes = None # what the check writes; None = what its result says
|
||||||
|
|
||||||
|
def on_get(path, q):
|
||||||
|
if path == "/wiz/dark":
|
||||||
|
if self.dark["running"]:
|
||||||
|
self.polls += 1
|
||||||
|
if self.polls >= 2:
|
||||||
|
r = self.wizard_result
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
s.update(r["floors"])
|
||||||
|
s["cool_intake_fans"] = self.wizard_writes or str(r["intake_fans"])
|
||||||
|
self.dark.update({"running": False, "progress": 100, "result": r})
|
||||||
|
self.versions["airflow"] = 1
|
||||||
|
return 200, dict(self.dark)
|
||||||
|
if path == "/wiz":
|
||||||
|
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_post(path, form):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path == "/wiz/airflow/start":
|
||||||
|
self.dark.update({"id": "airflow", "running": True, "phase": "fans to the run profile"})
|
||||||
|
return 200, {"started": True, "id": "airflow"}
|
||||||
|
if path == "/settings":
|
||||||
|
for k, v in form.items():
|
||||||
|
if v == "":
|
||||||
|
s.pop(k, None)
|
||||||
|
else:
|
||||||
|
s[k] = v
|
||||||
|
return 200, dict(s)
|
||||||
|
return None
|
||||||
|
self.fake.on_get = on_get
|
||||||
|
self.fake.on_post = on_post
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
self.fake.stop()
|
||||||
|
if self.saved_root is None:
|
||||||
|
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||||
|
else:
|
||||||
|
os.environ["GF_SYSFS_ROOT"] = self.saved_root
|
||||||
|
shutil.rmtree(self.sysfs, ignore_errors=True)
|
||||||
|
|
||||||
|
def run_check(self):
|
||||||
|
t = catalog.load_suite()["setup.check-airflow"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
return run
|
||||||
|
|
||||||
|
def assert_put_back(self):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
self.assertEqual(s.get("cool_tach_intake_min_rpm"), "2300")
|
||||||
|
self.assertNotIn("cool_intake_fans", s)
|
||||||
|
self.assertNotIn("cool_tach_exhaust_min_rpm", s)
|
||||||
|
|
||||||
|
def test_two_intake_fans_pass_and_are_put_back(self):
|
||||||
|
self.run_check()
|
||||||
|
self.assert_put_back()
|
||||||
|
|
||||||
|
def test_one_intake_fan_passes_with_intake_2_not_fitted(self):
|
||||||
|
self.wizard_result = self.result(1)
|
||||||
|
self.run_check()
|
||||||
|
self.assert_put_back()
|
||||||
|
|
||||||
|
def test_one_intake_fan_with_the_setting_left_at_two_fails(self):
|
||||||
|
self.wizard_result = self.result(1)
|
||||||
|
self.wizard_writes = "2"
|
||||||
|
with self.assertRaises(Exception) as cm:
|
||||||
|
self.run_check()
|
||||||
|
self.assertIn("cool_intake_fans reads '2', the check found 1", str(cm.exception))
|
||||||
|
self.assert_put_back()
|
||||||
|
|
||||||
|
def test_a_result_without_the_count_fails(self):
|
||||||
|
r = self.result(2)
|
||||||
|
del r["intake_fans"]
|
||||||
|
self.wizard_result = r
|
||||||
|
self.wizard_writes = "2"
|
||||||
|
with self.assertRaises(Exception) as cm:
|
||||||
|
self.run_check()
|
||||||
|
self.assertIn("no intake fan count", str(cm.exception))
|
||||||
|
self.assert_put_back()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "4b3966396b19a333076a9b2b23cade833afd4bb0"
|
SRCREV = "c274f19b09f2c0b0b553edbbe56cb9d9c193703e"
|
||||||
PV = "0.1.29"
|
PV = "0.1.30"
|
||||||
|
|||||||
@@ -17,4 +17,4 @@
|
|||||||
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
||||||
# and the acceptance test image.health compares the stamp on the running
|
# and the acceptance test image.health compares the stamp on the running
|
||||||
# machine with the manifest's.
|
# machine with the manifest's.
|
||||||
FORGEFIRM_RELEASE ?= "0.0.7"
|
FORGEFIRM_RELEASE ?= "0.0.8"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
ForgeFIRM v0.0.7 is a beta release.
|
ForgeFIRM v0.0.7 is a beta release.
|
||||||
|
|
||||||
It adds extension packages: software that is not part of ForgeFIRM, which you install from the control panel and which runs in a sandbox with only what you grant it. Extensions are off until you turn them on. You can upload a package, or get one from OpenGlow's catalog, which lists Visual alignment. A package's own page is on the new Extensions tab.
|
ForgeFIRM is now extensible. New capabilities and features can be created as extensions without requiring major changes to the core firmware. This is intended to promote 3rd-party feature development while allowing the core firmware to remain light and stable. Each extension runs in a sandbox with only the permissions the user grants it. Packages can be uploaded or installed from a new catalog.
|
||||||
|
|
||||||
Also new:
|
Also new:
|
||||||
|
|
||||||
- Manual homing. Release the motors, push the head against the gantry stops by hand, and set home there or at an offset you choose. It needs no account, no service, and no camera.
|
- Manual homing: Allows you to home the machine by moving the gantry and head to their home positions against gantry stops. Cheap, repeatable and effective.
|
||||||
- The crumb tray. Take the tray out for taller work and switch the tray mode on the Lens card: Z is then the height above the floor of the machine.
|
- Crumb Tray Removal: Toggling whether or not the crumb tray is installed will adjust the Z height for accurate, calculation-free focus.
|
||||||
- API tokens let your own programs read the status, follow the machine's events, see a camera, jog the head with the laser off, or run jobs. A token reaches only what you tick when you make it, and a job it runs still waits for the button on the machine.
|
- A new API interface lets your own programs read the status, follow the machine's events, see a camera, jog the head with the laser off, or run jobs (operator button press still required).
|
||||||
- The Setup page can measure the bed size on a machine with the gantry stops.
|
- Improved the video pipeline and enabled hardware-accelerated TLS encryption to reduce CPU overhead.
|
||||||
- Live camera video is smoother and has less delay, and the control panel is faster over HTTPS.
|
- New Extension: Adjust the laser start point using the head camera for precision alignment.
|
||||||
|
|
||||||
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
|||||||
|
# ForgeFIRM acceptance - 20261010224118 (dev)
|
||||||
|
|
||||||
|
- Image: `20261010224118 (dev)` (forgefirm-image-dev)
|
||||||
|
- Manifest identity: `c25844db9b022c976fbc02f5db9dc46bc2cde776cd6a9c90e7d789214ebe257e`
|
||||||
|
- Catalog: `ee8534fed1b8e932ad5a9367f1b38844c9bdce1abe625ba9346bc042a5bd7b1d`
|
||||||
|
- Campaign: `c-20261010224522-439d` opened 2026-10-10T22:45:22Z
|
||||||
|
- Exported: 2026-10-10T23:40:44Z
|
||||||
|
- **Release authorized: YES**
|
||||||
|
- Tests: 128 total, 128 satisfied (80 inherited), 0 required
|
||||||
|
|
||||||
|
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `image.health` | auto, core | PASS | 2026-10-10T22:45:22Z | `c-20261010224522-439d` | no |
|
||||||
|
| `image.license-bundle` | auto | PASS | 2026-10-10T21:42:55Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `image.network-boot` | auto | PASS | 2026-09-28T16:23:38Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-10-10T22:45:23Z | `c-20261010224522-439d` | no |
|
||||||
|
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-10-10T22:45:45Z | `c-20261010224522-439d` | no |
|
||||||
|
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-10-10T22:45:55Z | `c-20261010224522-439d` | no |
|
||||||
|
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-10-10T22:46:15Z | `c-20261010224522-439d` | no |
|
||||||
|
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-10-10T22:46:59Z | `c-20261010224522-439d` | no |
|
||||||
|
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-28T16:25:37Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `kernel.pic-soc-load` | auto | PASS | 2026-09-28T16:25:41Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `forgectrl.auth` | auto | PASS | 2026-10-10T21:45:06Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `forgectrl.settings-bounds` | auto | PASS | 2026-10-10T21:45:13Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `forgectrl.panel-serves` | auto | PASS | 2026-10-10T21:42:46Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `events.stream` | auto | PASS | 2026-10-10T21:45:29Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `forgectrl.lease` | auto | PASS | 2026-10-10T21:45:39Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `forgectrl.tokens` | auto | PASS | 2026-10-10T21:45:48Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-10-10T21:46:11Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.override-until-reboot` | auto | PASS | 2026-10-10T21:46:18Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-10-10T21:46:33Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.account-login` | auto, takeover | PASS | 2026-10-10T21:47:28Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.https-only-writes` | auto | PASS | 2026-10-10T21:47:35Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.ssh-until-reboot` | auto | PASS | 2026-10-10T21:47:42Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.cloud-disabled-surface` | auto | PASS | 2026-10-10T21:47:49Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.extensions-consent` | auto, takeover | PASS | 2026-10-10T21:48:04Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.factory-return` | auto | PASS | 2026-10-10T21:48:11Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.machine-name` | auto | PASS | 2026-09-28T16:27:36Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `setup.first-run-flow` | operator, takeover | PASS | 2026-10-10T22:47:21Z | `c-20261010224522-439d` | no |
|
||||||
|
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-28T18:52:23Z | `c-20260928183105-64ee` | yes |
|
||||||
|
| `setup.cert-page` | auto | PASS | 2026-10-10T21:48:17Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.what-changed` | auto, takeover | PASS | 2026-10-10T21:48:33Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.record-export` | auto | PASS | 2026-10-10T21:50:38Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.mirror` | auto, takeover | PASS | 2026-10-10T21:51:21Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.check-switches` | operator | PASS | 2026-10-10T22:47:26Z | `c-20261010224522-439d` | no |
|
||||||
|
| `setup.check-sensors` | auto | PASS | 2026-10-10T21:50:55Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.check-airflow` | auto, takeover | PASS | 2026-10-10T21:53:11Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.check-cameras` | operator | PASS | 2026-10-10T22:47:39Z | `c-20261010224522-439d` | no |
|
||||||
|
| `setup.check-motion` | auto, takeover | PASS | 2026-10-10T21:53:38Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-10-10T22:01:19Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-10-10T23:04:58Z | `c-20261010224522-439d` | no |
|
||||||
|
| `setup.sheet` | live, takeover | PASS | 2026-10-10T23:18:06Z | `c-20261010224522-439d` | no |
|
||||||
|
| `logs.tree-tail-export` | auto | PASS | 2026-10-10T21:49:36Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `logs.routing` | auto | PASS | 2026-09-28T16:40:28Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `logs.level-settings` | auto | PASS | 2026-10-10T22:01:26Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `motion.pacing` | auto | PASS | 2026-09-28T16:32:45Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `motion.jog-roundtrip` | auto | PASS | 2026-09-28T16:40:44Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `motion.microstep-modes` | auto | PASS | 2026-10-10T22:01:57Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `motion.liveness-probe` | auto | PASS | 2026-09-28T16:41:18Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `motion.gate-waits-for-lid` | operator | PASS | 2026-10-10T22:47:56Z | `c-20261010224522-439d` | no |
|
||||||
|
| `motion.cancel-abort` | auto | PASS | 2026-09-28T16:41:41Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `motion.deadman` | auto | PASS | 2026-10-10T22:02:49Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `motion.respawn-gate` | operator | PASS | 2026-09-28T16:42:35Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `motion.soft-limits` | auto | PASS | 2026-09-28T16:53:34Z | `c-20260928165212-b92e` | yes |
|
||||||
|
| `motion.button-hold-resume` | operator | PASS | 2026-09-28T17:12:49Z | `c-20260928171100-3ff5` | yes |
|
||||||
|
| `motion.lid-cancel-home` | operator | PASS | 2026-09-28T17:13:04Z | `c-20260928171100-3ff5` | yes |
|
||||||
|
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-28T17:13:12Z | `c-20260928171100-3ff5` | yes |
|
||||||
|
| `motion.lid-policy-hold` | operator | PASS | 2026-09-28T17:13:26Z | `c-20260928171100-3ff5` | yes |
|
||||||
|
| `motion.step-timing-under-load` | auto | PASS | 2026-09-28T17:13:56Z | `c-20260928171100-3ff5` | yes |
|
||||||
|
| `motion.release` | auto, takeover | PASS | 2026-10-10T22:03:22Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `homing.manual` | auto | PASS | 2026-10-10T22:03:39Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `motion.port-jog` | auto | PASS | 2026-10-10T22:04:05Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `motion.job` | auto | PASS | 2026-10-10T22:04:21Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-10-10T21:55:16Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.flow-verify` | auto | PASS | 2026-10-10T21:58:05Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-10-10T22:05:23Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.gate-off` | auto | PASS | 2026-10-10T21:51:35Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.floor-and-warm-up` | auto | PASS | 2026-10-10T22:06:55Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.tec-drive` | auto | PASS | 2026-10-10T22:07:10Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.fire-watch-tiers` | auto | PASS | 2026-10-10T22:07:31Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-10-10T22:07:44Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.critical-tier` | auto | PASS | 2026-10-10T22:07:58Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.fan-gate-trips` | auto | PASS | 2026-10-10T21:52:25Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.flow-under-load` | live | PASS | 2026-10-10T23:20:48Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cooling.fan-duty-readback` | auto | PASS | 2026-10-10T22:08:19Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.fail-tier-stop` | operator | PASS | 2026-10-10T22:48:15Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cooling.report-channel` | auto | PASS | 2026-10-10T22:08:36Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cooling.intake-one-fan` | auto | PASS | 2026-10-10T22:09:20Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `laser.power-floor` | auto | PASS | 2026-10-10T22:09:27Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `laser.emission-witness` | live, core | PASS | 2026-10-10T23:05:49Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.m5-rapid-dark` | live | PASS | 2026-10-10T23:21:48Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.disarm-in-hold` | live | PASS | 2026-10-10T23:23:20Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.armed-kill` | live | PASS | 2026-10-10T23:24:03Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.verdict-cut` | live | PASS | 2026-10-10T23:25:09Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.arm-wait-lid` | operator | PASS | 2026-10-10T22:48:33Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-10-10T23:26:26Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.port-dark` | live | PASS | 2026-10-10T23:27:10Z | `c-20261010224522-439d` | no |
|
||||||
|
| `laser.recorder-dark` | auto | PASS | 2026-10-10T22:09:54Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `camera.snapshot` | auto | PASS | 2026-10-10T22:10:16Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `camera.sensor-profile` | auto | PASS | 2026-10-10T22:10:36Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `camera.h264-stream` | auto | PASS | 2026-10-10T22:10:55Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `camera.frame-health` | auto | PASS | 2026-10-10T22:11:19Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `camera.lid-privacy` | operator | PASS | 2026-10-10T22:48:49Z | `c-20261010224522-439d` | no |
|
||||||
|
| `camera.key-read` | auto | PASS | 2026-10-10T22:11:26Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `update.slots-and-signature` | auto | PASS | 2026-09-28T16:27:34Z | `c-20260928162331-3969` | yes |
|
||||||
|
| `update.product-gate` | auto | PASS | 2026-09-28T17:20:19Z | `c-20260928171100-3ff5` | yes |
|
||||||
|
| `update.release-check` | auto | PASS | 2026-10-10T22:11:33Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `cloud.mode-switch` | operator | PASS | 2026-10-10T22:51:05Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.service-protocol` | operator | PASS | 2026-10-10T23:28:24Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.lid-interlock-abort` | live | PASS | 2026-10-10T23:30:40Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.lid-during-button-wait` | operator | PASS | 2026-10-10T22:52:29Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.dark-print` | operator | PASS | 2026-10-10T22:53:25Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.verdict-refuse` | operator | PASS | 2026-10-10T22:54:47Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.pause-resume` | live | PASS | 2026-10-10T23:34:32Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.oversize-stream` | live | PASS | 2026-10-10T23:38:02Z | `c-20261010224522-439d` | no |
|
||||||
|
| `cloud.paused-lid-cancel` | live | PASS | 2026-10-10T23:38:55Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.platform` | auto | PASS | 2026-09-28T18:05:37Z | `c-20260928180313-75fb` | yes |
|
||||||
|
| `exthost.service` | auto, takeover | PASS | 2026-10-10T22:12:20Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-10-10T22:56:02Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.hold-pause-tier` | auto, takeover | PASS | 2026-10-10T22:14:00Z | `c-20261010214232-4790` | yes |
|
||||||
|
| `exthost.events` | operator, takeover | PASS | 2026-10-10T22:56:23Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.motion-jog` | auto, takeover | PASS | 2026-10-10T22:56:43Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.ui-delivery` | auto | PASS | 2026-10-10T22:56:52Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.motion-job` | auto, takeover | PASS | 2026-09-28T18:11:06Z | `c-20260928180313-75fb` | yes |
|
||||||
|
| `exthost.package-routes` | auto | PASS | 2026-10-10T22:56:54Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.panel-install` | operator | PASS | 2026-10-10T22:56:58Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.core-range` | auto | PASS | 2026-09-28T18:11:13Z | `c-20260928180313-75fb` | yes |
|
||||||
|
| `exthost.page-call` | auto | PASS | 2026-10-10T22:57:14Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.operator-destinations` | auto | PASS | 2026-10-10T22:57:36Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.lifecycle` | auto | PASS | 2026-09-28T18:12:06Z | `c-20260928180313-75fb` | yes |
|
||||||
|
| `events.button-telemetry` | operator | PASS | 2026-10-10T22:57:48Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.catalog` | auto | PASS | 2026-10-10T22:57:50Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.mcode` | auto | PASS | 2026-09-28T18:12:39Z | `c-20260928180313-75fb` | yes |
|
||||||
|
| `exthost.wizard` | auto | PASS | 2026-10-10T22:58:18Z | `c-20261010224522-439d` | no |
|
||||||
|
| `update.job-locks` | auto | PASS | 2026-09-28T18:13:11Z | `c-20260928180313-75fb` | yes |
|
||||||
|
| `homing.cloud-offsets` | auto | PASS | 2026-10-10T22:59:11Z | `c-20261010224522-439d` | no |
|
||||||
|
| `setup.check-envelope` | auto | PASS | 2026-10-10T23:00:57Z | `c-20261010224522-439d` | no |
|
||||||
|
| `exthost.sender-keep-out` | auto | PASS | 2026-10-10T23:01:30Z | `c-20261010224522-439d` | no |
|
||||||
|
| `motion.tray` | auto | PASS | 2026-10-10T23:01:33Z | `c-20261010224522-439d` | no |
|
||||||
|
| `forgectrl.tls-records` | auto | PASS | 2026-10-10T23:01:37Z | `c-20261010224522-439d` | no |
|
||||||
|
|
||||||
|
Artifact sha256: `c8b5e4c24a59e92337e0ec9607343b64b0d8193919efca7cd0b3d18b3c4d31f7`
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ForgeFIRM v0.0.8 is a beta release.
|
||||||
|
|
||||||
|
Machines with one intake fan: some Glowforge machines are built with a single intake fan instead of two. On those machines, the Airflow step of setup stopped with an error because the second fan never turned. Setup now detects a single intake fan, tells you so, and finishes. The second intake is then not checked while the machine runs. Machines with two intake fans work as before.
|
||||||
|
|
||||||
|
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||||
|
|
||||||
|
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||||
Reference in New Issue
Block a user