mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b29fee01 | ||
|
|
f306c9983f | ||
|
|
dc7170d0d1 | ||
|
|
0ceb4abc4a | ||
|
|
aa628d684c | ||
|
|
29b00cc056 | ||
|
|
d482e76402 | ||
|
|
8232c8c9fe | ||
|
|
260f466400 | ||
|
|
cf7b51fb32 | ||
|
|
e2be3ca4c5 | ||
|
|
cd4c176a87 | ||
|
|
8850adcc5e | ||
|
|
f9f4de31c1 | ||
|
|
9ddab969e0 | ||
|
|
12e7d50746 | ||
|
|
b3f0cd54d9 | ||
|
|
fbac17a485 | ||
|
|
0eb764bf75 | ||
|
|
07158e5d7f | ||
|
|
091702c26f | ||
|
|
9a288b175b | ||
|
|
147393322f |
@@ -11,6 +11,7 @@ name: fixture-ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'fixture/**'
|
- 'fixture/**'
|
||||||
- '.github/workflows/fixture-ci.yml'
|
- '.github/workflows/fixture-ci.yml'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ name: forgetest-ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'forgetest/**'
|
- 'forgetest/**'
|
||||||
- 'scripts/acceptance-gate.py'
|
- 'scripts/acceptance-gate.py'
|
||||||
|
|||||||
@@ -325,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
|
|||||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
||||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||||
the file already has. The copyright notices of other holders stay
|
the file already has. The copyright notices of other holders stay
|
||||||
unchanged.
|
unchanged. Those two lines are for files this project authors: an
|
||||||
|
outside contributor who writes a new file puts their own name there, or
|
||||||
|
leaves the lines out, and keeps their copyright in what they wrote.
|
||||||
|
Editing an existing file changes no header - a contribution is recorded
|
||||||
|
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||||
under the license of its repository.
|
under the license of its repository.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Patches are welcome. How the project works, what proof a change needs, and the
|
||||||
|
writing rules are on the documentation site:
|
||||||
|
<https://docs.forgefirm.org/developers/contributing/>.
|
||||||
|
|
||||||
|
## Sign your work
|
||||||
|
|
||||||
|
Every commit carries a `Signed-off-by` line:
|
||||||
|
|
||||||
|
Signed-off-by: Your Name <your.email@example.com>
|
||||||
|
|
||||||
|
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
|
||||||
|
name; an anonymous or pseudonymous sign-off certifies nothing.
|
||||||
|
|
||||||
|
The line certifies the Developer Certificate of Origin, version 1.1, below. It
|
||||||
|
is the same statement the Linux kernel and grblHAL use. It is not a copyright
|
||||||
|
assignment: you keep the copyright in what you write, and your contribution
|
||||||
|
comes in under the license of the repository you are contributing to.
|
||||||
|
|
||||||
|
You do not add a copyright line for a patch. A change to an existing file
|
||||||
|
leaves its header alone. If you write a whole new file, put your own name in
|
||||||
|
its copyright line, or leave the line out; either way the file carries an
|
||||||
|
`SPDX-License-Identifier` for the repository's license.
|
||||||
|
|
||||||
|
## Developer Certificate of Origin 1.1
|
||||||
|
|
||||||
|
By making a contribution to this project, I certify that:
|
||||||
|
|
||||||
|
(a) The contribution was created in whole or in part by me and I
|
||||||
|
have the right to submit it under the open source license
|
||||||
|
indicated in the file; or
|
||||||
|
|
||||||
|
(b) The contribution is based upon previous work that, to the best
|
||||||
|
of my knowledge, is covered under an appropriate open source
|
||||||
|
license and I have the right under that license to submit that
|
||||||
|
work with modifications, whether created in whole or in part
|
||||||
|
by me, under the same open source license (unless I am
|
||||||
|
permitted to submit under a different license), as indicated
|
||||||
|
in the file; or
|
||||||
|
|
||||||
|
(c) The contribution was provided directly to me by some other
|
||||||
|
person who certified (a), (b) or (c) and I have not modified
|
||||||
|
it.
|
||||||
|
|
||||||
|
(d) I understand and agree that this project and the contribution
|
||||||
|
are public and that a record of the contribution (including all
|
||||||
|
personal information I submit with it, including my sign-off) is
|
||||||
|
maintained indefinitely and may be redistributed consistent with
|
||||||
|
this project or the open source license(s) involved.
|
||||||
|
|
||||||
|
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||||
|
This DCO text is licensed under the terms of the Creative Commons
|
||||||
|
Attribution-ShareAlike 3.0 Unported license.
|
||||||
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
|
|||||||
See:
|
See:
|
||||||
COPYING.MIT (MIT)
|
COPYING.MIT (MIT)
|
||||||
|
|
||||||
|
The code in this repository that is not layer metadata is MIT licensed:
|
||||||
|
|
||||||
|
forgetest/ the release acceptance tool (see forgetest/LICENSE)
|
||||||
|
scripts/ the release, install and bench scripts
|
||||||
|
fixture/ the bench actuator firmware, excluding
|
||||||
|
fixture/managed_components/, which carries its own licenses
|
||||||
|
|
||||||
|
Every file in those directories states MIT in its own header.
|
||||||
|
|
||||||
License information for any other files is either explicitly stated
|
License information for any other files is either explicitly stated
|
||||||
or defaults to GPL version 2.
|
or defaults to GPL version 2.
|
||||||
|
|||||||
@@ -83,4 +83,5 @@ before you install.
|
|||||||
**This is experimental software. Use of it could seriously maim or kill you or
|
**This is experimental software. Use of it could seriously maim or kill you or
|
||||||
others, and it may void your warranty. Use it at your own risk.**
|
others, and it may void your warranty. Use it at your own risk.**
|
||||||
|
|
||||||
This project is not affiliated with or endorsed by Glowforge.
|
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||||
|
with, authorized by, or endorsed by Glowforge, Inc.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
# forgefixture: build, flash and watch the bench actuator's firmware.
|
# forgefixture: build, flash and watch the bench actuator's firmware.
|
||||||
#
|
#
|
||||||
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
* Written by Scott Wiederhold
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
/* forgefixture firmware version, reported by GET /. */
|
/* forgefixture firmware version, reported by GET /. */
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
# The host test of the fixture's policy: needs only a C compiler.
|
# The host test of the fixture's policy: needs only a C compiler.
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
|
||||||
|
drives (scripts/bench/).
|
||||||
|
|
||||||
|
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
Written by Scott Wiederhold
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgetest
|
# Provides: forgetest
|
||||||
# Required-Start: $network forgectrl
|
# Required-Start: $network forgectrl
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgetest - the ForgeFIRM release acceptance tool.
|
"""forgetest - the ForgeFIRM release acceptance tool.
|
||||||
|
|
||||||
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgetest daemon entry point.
|
"""forgetest daemon entry point.
|
||||||
|
|
||||||
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
||||||
|
|
||||||
|
Every test record is redacted before it enters the artifact (redact.py), so
|
||||||
|
the committed file carries no identity of the machine that ran the campaign.
|
||||||
|
It happens inside build(), ahead of the self-hash, because verify() rejects a
|
||||||
|
file edited after the fact. The gate reads none of the redacted fields.
|
||||||
|
|
||||||
The exporter serializes the campaign state with, for every catalog test,
|
The exporter serializes the campaign state with, for every catalog test,
|
||||||
the winning result record (same-campaign PASS or inherited PASS, with its
|
the winning result record (same-campaign PASS or inherited PASS, with its
|
||||||
origin) and the fingerprint it was recorded under. The gate
|
origin) and the fingerprint it was recorded under. The gate
|
||||||
@@ -15,6 +25,7 @@ from . import VERSION
|
|||||||
from . import campaign as _campaign
|
from . import campaign as _campaign
|
||||||
from . import manifest as _manifest
|
from . import manifest as _manifest
|
||||||
from .log import now_ts
|
from .log import now_ts
|
||||||
|
from .redact import scrub
|
||||||
|
|
||||||
FORMAT = 1
|
FORMAT = 1
|
||||||
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
||||||
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
|
|||||||
"fingerprint": st["fingerprint"],
|
"fingerprint": st["fingerprint"],
|
||||||
"satisfied": st["satisfied"],
|
"satisfied": st["satisfied"],
|
||||||
"inherited": st["status"] == "inherited",
|
"inherited": st["status"] == "inherited",
|
||||||
"record": _record(rec) if rec else None,
|
"record": scrub(_record(rec)) if rec else None,
|
||||||
})
|
})
|
||||||
tests_out.append(entry)
|
tests_out.append(entry)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
"""The fresh-boot idle state: what every test starts from and leaves behind.
|
||||||
|
|
||||||
The runner checks the machine against this baseline before a run and
|
The runner checks the machine against this baseline before a run and
|
||||||
@@ -48,7 +53,7 @@ from .log import now_ts
|
|||||||
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
||||||
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
# guards it), step_freq its machine tick and ramp_rate the kernel stop
|
||||||
# ramp, both derived from the XY microstep mode (the values here are the
|
# ramp, both derived from the XY microstep mode (the values here are the
|
||||||
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1
|
# x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
|
||||||
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
||||||
# loop heater and TEC are the diagnostics' tools, off at idle.
|
# loop heater and TEC are the diagnostics' tools, off at idle.
|
||||||
FIXED_SYSFS = [
|
FIXED_SYSFS = [
|
||||||
@@ -133,6 +138,7 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
|
|||||||
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
|
||||||
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
|
||||||
COOL_IDLE_S = 120 # cooldown after motion
|
COOL_IDLE_S = 120 # cooldown after motion
|
||||||
|
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
|
||||||
IDLE_S = 30 # cnc/state back to idle after a job
|
IDLE_S = 30 # cnc/state back to idle after a job
|
||||||
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
|
||||||
|
|
||||||
@@ -147,11 +153,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
|
|||||||
POSITION_DEADBAND_MM = 0.1
|
POSITION_DEADBAND_MM = 0.1
|
||||||
|
|
||||||
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
||||||
# 8). The GRBL controller reads it at its start and derives its scale,
|
# 32). The GRBL controller reads it at its start and derives its scale,
|
||||||
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
|
||||||
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
|
||||||
XY_MODES = (8, 16, 32)
|
XY_MODES = (8, 16, 32)
|
||||||
XY_MODE_DEFAULT = 8
|
XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
|
||||||
|
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
|
||||||
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
|
||||||
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
XY_TICK_X8_HZ = 28160 # the x8 machine tick
|
||||||
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
|
||||||
@@ -175,7 +182,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
|
|||||||
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
|
||||||
the tick and the ramp scale with the mode (x16 doubles both, x32
|
the tick and the ramp scale with the mode (x16 doubles both, x32
|
||||||
quadruples them)."""
|
quadruples them)."""
|
||||||
k = mode // XY_MODE_DEFAULT
|
k = mode // XY_MODE_BASE
|
||||||
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
|
||||||
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
|
||||||
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
|
||||||
@@ -607,36 +614,60 @@ class Baseline:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover("laser_locked", False, True, act))
|
left.append(Leftover("laser_locked", False, True, act))
|
||||||
# cooling engine idle, unarmed
|
self._cool_side(left)
|
||||||
|
|
||||||
|
def _cool_side(self, left):
|
||||||
|
"""The cooling engine idle, unarmed, holding nothing."""
|
||||||
st, c = self.fc_get("/cool/status")
|
st, c = self.fc_get("/cool/status")
|
||||||
if st == 200 and isinstance(c, dict):
|
if st != 200 or not isinstance(c, dict):
|
||||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
return
|
||||||
|
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
|
||||||
|
return
|
||||||
|
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||||
|
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||||
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
|
# The engine publishes once a tick (1 Hz). A read inside the tick
|
||||||
|
# after a run ended still shows the run: a diagnostic's hold until
|
||||||
|
# the engine has the hardware back, a fail tier's hold until the
|
||||||
|
# session ends with the controller. What the engine's next tick
|
||||||
|
# clears was never left behind, so an arm or a hold is read again
|
||||||
|
# past that tick before it is called the run's doing. One that a
|
||||||
|
# run did leave stands for a job that is never coming back, and it
|
||||||
|
# is still there.
|
||||||
|
if c.get("armed") or c.get("hold"):
|
||||||
|
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
|
||||||
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
|
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
|
||||||
|
st, c2 = self.fc_get("/cool/status")
|
||||||
|
c = c2 if st == 200 and isinstance(c2, dict) else {}
|
||||||
|
self.log("cool: %s was the engine's last word on the run; its next tick reads "
|
||||||
|
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
|
||||||
|
if c.get("phase") == "idle":
|
||||||
|
return
|
||||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||||
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
# Armed, or holding, is the run's doing. A phase alone is
|
||||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
# not: the engine clears smoke at run duty after an armed
|
||||||
# Armed, or holding, is the run's doing. A phase alone is
|
# session and cools down after a hot one, both timed and
|
||||||
# not: the engine clears smoke at run duty after an armed
|
# both ending on their own. Waiting for that is right;
|
||||||
# session and cools down after a hot one, both timed and
|
# calling it a leftover is not, because nothing was left -
|
||||||
# both ending on their own. Waiting for that is right;
|
# the machine was still finishing.
|
||||||
# calling it a leftover is not, because nothing was left -
|
dirt = bool(c.get("armed") or c.get("hold"))
|
||||||
# the machine was still finishing.
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
dirt = bool(c.get("armed") or c.get("hold"))
|
if w is not None and not dirt:
|
||||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||||
if w is not None and not dirt:
|
"work, not a leftover" % (found, w))
|
||||||
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
return
|
||||||
"work, not a leftover" % (found, w))
|
# The engine holds for a job. A run that ended without
|
||||||
else:
|
# ending its job leaves one alive, and no amount of
|
||||||
# The engine holds for a job. A run that ended without
|
# waiting ends it: stand the machine down and let the
|
||||||
# ending its job leaves one alive, and no amount of
|
# supervisor bring the controller back clean.
|
||||||
# waiting ends it: stand the machine down and let the
|
act = "waited"
|
||||||
# supervisor bring the controller back clean.
|
if w is None:
|
||||||
act = "waited"
|
self.stand_down("the cooling engine is still %s" % found)
|
||||||
if w is None:
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
self.stand_down("the cooling engine is still %s" % found)
|
act = "restored (stood the machine down)"
|
||||||
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||||
act = "restored (stood the machine down)"
|
act if w is not None else "failed: still %s" % found))
|
||||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
|
||||||
act if w is not None else "failed: still %s" % found))
|
|
||||||
|
|
||||||
def _lamp_side(self, left):
|
def _lamp_side(self, left):
|
||||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench diagnostics page: registry of the bench tools and the
|
"""The bench diagnostics page: registry of the bench tools and the
|
||||||
subprocess runner behind the #bench tab.
|
subprocess runner behind the #bench tab.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Campaign rules: which results apply, what is required, and whether a
|
"""Campaign rules: which results apply, what is required, and whether a
|
||||||
release is authorized.
|
release is authorized.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The acceptance catalog: test definitions and the registry.
|
"""The acceptance catalog: test definitions and the registry.
|
||||||
|
|
||||||
A test is a function decorated with @test(...). The decorator records
|
A test is a function decorated with @test(...). The decorator records
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Coverage lint: every source path in the manifest must be selected by
|
"""Coverage lint: every source path in the manifest must be selected by
|
||||||
some test's coverage globs, except the allowlisted non-behavioral paths,
|
some test's coverage globs, except the allowlisted non-behavioral paths,
|
||||||
and every coverage glob must select at least one path of its component.
|
and every coverage glob must select at least one path of its component.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench actuator (forgefixture): the box on the bench network that
|
"""The bench actuator (forgefixture): the box on the bench network that
|
||||||
opens the lid loop, pulls the interlock loop and presses the button on
|
opens the lid loop, pulls the interlock loop and presses the button on
|
||||||
request, so an operator test can run with nobody in the room.
|
request, so an operator test can run with nobody in the room.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
||||||
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The append-only result log (JSONL) and the export helpers.
|
"""The append-only result log (JSONL) and the export helpers.
|
||||||
|
|
||||||
One JSON object per line under the data directory (default
|
One JSON object per line under the data directory (default
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The image manifest and the domain fingerprint.
|
"""The image manifest and the domain fingerprint.
|
||||||
|
|
||||||
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||||
one self-contained response (no external assets). Bootstrap and the
|
one self-contained response (no external assets). Bootstrap and the
|
||||||
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Pulse files for the offline cloud service: a job the machine will run
|
"""Pulse files for the offline cloud service: a job the machine will run
|
||||||
without the service having cut it.
|
without the service having cut it.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Redaction for the release artifact.
|
||||||
|
|
||||||
|
The artifact is committed to a public repository, so it carries no
|
||||||
|
identity of the machine that ran the campaign. This applies the same two
|
||||||
|
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
|
||||||
|
same order, to every value the exporter writes:
|
||||||
|
|
||||||
|
1. Known keys: a value held under a key that names identifying data -
|
||||||
|
the serial, the machine id, the hostname, the wireless credentials,
|
||||||
|
a token - is replaced whole. Naming the key is far more reliable
|
||||||
|
than guessing the shape of what is under it.
|
||||||
|
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
|
||||||
|
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
|
||||||
|
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
|
||||||
|
value in a class keeps a stable number for the life of one export, so
|
||||||
|
"<IP-2>" is the same host wherever it appears and the artifact stays
|
||||||
|
readable as evidence.
|
||||||
|
|
||||||
|
Integrity values are never touched: the self-hash, the manifest and
|
||||||
|
catalog hashes, each test's source hash and fingerprint. Those are what
|
||||||
|
the gate recomputes, and a long hex blob is exactly what they look like.
|
||||||
|
|
||||||
|
Over-redaction is accepted where the alternative is a leak.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Layer 1: the value under one of these keys is replaced whole.
|
||||||
|
REDACT_KEYS = frozenset((
|
||||||
|
"gf_serial", "machine_id", "serial", "serial_number",
|
||||||
|
"hostname", "hostname_file", "host",
|
||||||
|
"ssid", "psk", "passphrase", "password", "passwd",
|
||||||
|
"token", "panel_token", "secret", "api_key", "key",
|
||||||
|
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
|
||||||
|
"gf_username", "gf_password", "username", "user", "email",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Never touched, by key: the gate recomputes these and they look like
|
||||||
|
# exactly what layer 2 hunts for.
|
||||||
|
KEEP_KEYS = frozenset((
|
||||||
|
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
|
||||||
|
"source_sha", "fingerprint", "content_sha", "srcrev",
|
||||||
|
))
|
||||||
|
|
||||||
|
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
|
||||||
|
|
||||||
|
_CLASSES = (
|
||||||
|
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
|
||||||
|
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
||||||
|
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
||||||
|
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
|
||||||
|
# Three or more colons, or a "::" run: two colons is a clock time, and
|
||||||
|
# tagging every timestamp would make the evidence unreadable.
|
||||||
|
("IP6", re.compile(
|
||||||
|
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
|
||||||
|
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
|
||||||
|
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
||||||
|
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Redactor:
|
||||||
|
"""Stable per-export numbering, one instance per artifact."""
|
||||||
|
|
||||||
|
# A known value shorter than this is too generic to replace blind.
|
||||||
|
MIN_KNOWN = 4
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._n = {}
|
||||||
|
self._known = {}
|
||||||
|
|
||||||
|
def learn(self, obj, key=None):
|
||||||
|
"""Collect the values held under the layer-1 keys, so they are
|
||||||
|
replaced wherever else they appear - a hostname in a log line is
|
||||||
|
the same leak as a hostname in its own field."""
|
||||||
|
if key is not None and key in KEEP_KEYS:
|
||||||
|
return
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
self.learn(v, k)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for v in obj:
|
||||||
|
self.learn(v)
|
||||||
|
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
|
||||||
|
if obj not in self._known:
|
||||||
|
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
|
||||||
|
|
||||||
|
def _tag(self, cls, value):
|
||||||
|
seen = self._n.setdefault(cls, {})
|
||||||
|
if value not in seen:
|
||||||
|
seen[value] = len(seen) + 1
|
||||||
|
return "<%s-%d>" % (cls, seen[value])
|
||||||
|
|
||||||
|
def text(self, s):
|
||||||
|
for known in sorted(self._known, key=len, reverse=True):
|
||||||
|
if known in s:
|
||||||
|
s = s.replace(known, self._known[known])
|
||||||
|
for cls, rx in _CLASSES:
|
||||||
|
def sub(m, cls=cls):
|
||||||
|
v = m.group(0)
|
||||||
|
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
|
||||||
|
return v
|
||||||
|
if cls == "IP6" and v in ("::", "::1"):
|
||||||
|
return v
|
||||||
|
return self._tag(cls, v)
|
||||||
|
s = rx.sub(sub, s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def value(self, v, key=None):
|
||||||
|
if key is not None and key in KEEP_KEYS:
|
||||||
|
return v
|
||||||
|
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, str) and v in self._known:
|
||||||
|
return self._known[v]
|
||||||
|
return self._tag("REDACTED", "%s=%s" % (key, v))
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return {k: self.value(x, k) for k, x in v.items()}
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [self.value(x) for x in v]
|
||||||
|
if isinstance(v, str):
|
||||||
|
return self.text(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def scrub(obj):
|
||||||
|
"""Redact a value tree with one export's numbering."""
|
||||||
|
r = Redactor()
|
||||||
|
r.learn(obj)
|
||||||
|
return r.value(obj)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The runner: executes one acceptance test or one bench tool at a time.
|
"""The runner: executes one acceptance test or one bench tool at a time.
|
||||||
|
|
||||||
Acceptance tests run in a worker thread with a Context: log lines, an
|
Acceptance tests run in a worker thread with a Context: log lines, an
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""HTTP: the page, the JSON API, and the access rules.
|
"""HTTP: the page, the JSON API, and the access rules.
|
||||||
|
|
||||||
Access follows forgectrl's panel (auth.c): the Host header must be an
|
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The acceptance suite: one module per subsystem, imported in display
|
"""The acceptance suite: one module per subsystem, imported in display
|
||||||
order. Each module registers its tests with @catalog.test."""
|
order. Each module registers its tests with @catalog.test."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
"""cloud.* - the controller mode switch and the optional Glowforge web-service
|
||||||
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
|
||||||
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
||||||
diagnostics runner (the same check the fire gate runs), the fan profile
|
diagnostics runner (the same check the fire gate runs), the fan profile
|
||||||
returning to idle after motion, the gate settings (a value inside the
|
returning to idle after motion, the gate settings (a value inside the
|
||||||
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
|
|||||||
value, and both are said out loud: the settings reply, /status, the
|
value, and both are said out loud: the settings reply, /status, the
|
||||||
engine's run-start log line), and the airflow gates (a fan under its
|
engine's run-start log line), and the airflow gates (a fan under its
|
||||||
floor past the spin-up grace is a fault for the rest of the run)."""
|
floor past the spin-up grace is a fault for the rest of the run)."""
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
@@ -1241,7 +1247,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
|
|||||||
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
||||||
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
||||||
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
||||||
LOAD_LOG_LINES = "300"
|
|
||||||
_LOAD_LINE_RX = None
|
_LOAD_LINE_RX = None
|
||||||
|
|
||||||
|
|
||||||
@@ -1265,9 +1270,19 @@ def _load_fill():
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _forgectrl_tail(fc):
|
def _log_since(path, offset):
|
||||||
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES})
|
"""The text a log gained since byte `offset`. A count of lines in a
|
||||||
return body.get("text", "") if st == 200 and isinstance(body, dict) else ""
|
tail of fixed length cannot tell a new line from an old one: the new
|
||||||
|
line comes in at the bottom as an old one leaves at the top. A file now
|
||||||
|
shorter than the offset was rotated under the test, and all of it is
|
||||||
|
newer."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
if os.fstat(f.fileno()).st_size >= offset:
|
||||||
|
f.seek(offset)
|
||||||
|
return f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
||||||
@@ -1285,6 +1300,7 @@ def _forgectrl_tail(fc):
|
|||||||
"the tube's share off and read its baseline as a mean.")
|
"the tube's share off and read its baseline as a mean.")
|
||||||
def flow_under_load(ctx):
|
def flow_under_load(ctx):
|
||||||
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
||||||
|
from .motion import FORGECTRL_LOG, _log_offset
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
||||||
@@ -1292,7 +1308,7 @@ def flow_under_load(ctx):
|
|||||||
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
||||||
ctx.check(limit, "cool_flow_rise unreadable")
|
ctx.check(limit, "cool_flow_rise unreadable")
|
||||||
ev.update({"check_s": check_s, "limit_c": limit})
|
ev.update({"check_s": check_s, "limit_c": limit})
|
||||||
before = _forgectrl_tail(fc).count("heater rise")
|
off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
|
||||||
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
||||||
# the job brings the head back so the baseline finds it where it began.
|
# the job brings the head back so the baseline finds it where it began.
|
||||||
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
||||||
@@ -1327,12 +1343,11 @@ def flow_under_load(ctx):
|
|||||||
ctx.checkpoint()
|
ctx.checkpoint()
|
||||||
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
||||||
heater_seen = True
|
heater_seen = True
|
||||||
text = _forgectrl_tail(fc)
|
m = None
|
||||||
if text.count("heater rise") > before:
|
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
|
||||||
m = None
|
pass
|
||||||
for m in _load_verdict_rx().finditer(text):
|
if m:
|
||||||
pass
|
line = m.group(0)
|
||||||
line = m.group(0) if m else None
|
|
||||||
break
|
break
|
||||||
ctx.sleep(1)
|
ctx.sleep(1)
|
||||||
ev["heater_seen"] = heater_seen
|
ev["heater_seen"] = heater_seen
|
||||||
@@ -1437,7 +1452,9 @@ def fan_duty_readback(ctx):
|
|||||||
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
||||||
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
||||||
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
||||||
"latch is locked, the kernel idle, no emission, and the thresholds are put back.")
|
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
|
||||||
|
"session (the engine's next tick after the controller is gone), and the "
|
||||||
|
"thresholds are put back.")
|
||||||
def fail_tier_stop(ctx):
|
def fail_tier_stop(ctx):
|
||||||
from .laser import prepare, stream, sample
|
from .laser import prepare, stream, sample
|
||||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
@@ -1496,6 +1513,16 @@ def fail_tier_stop(ctx):
|
|||||||
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||||
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||||
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||||
|
# The crash fault is the run session's: the engine ends the session
|
||||||
|
# on its next 1 Hz tick after the controller is gone, and the fault
|
||||||
|
# and its hold go with it. The supervisor has the new controller up
|
||||||
|
# inside that second, so a test that returned here would hand the
|
||||||
|
# machine back with the fault it raised still standing.
|
||||||
|
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
|
||||||
|
ev["result"]["fault_cleared_s"] = cleared
|
||||||
|
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
|
||||||
|
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
|
||||||
|
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
|
||||||
finally:
|
finally:
|
||||||
fc.wait_idle(30, abort=ctx.aborted)
|
fc.wait_idle(30, abort=ctx.aborted)
|
||||||
st, body = fc.post("/settings", params=orig)
|
st, body = fc.post("/settings", params=orig)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""image.* - the post-flash health of the running image (always-required)."""
|
"""image.* - the post-flash health of the running image (always-required)."""
|
||||||
import glob
|
import glob
|
||||||
import gzip
|
import gzip
|
||||||
import mmap
|
import mmap
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
@@ -395,3 +401,140 @@ def license_bundle(ctx):
|
|||||||
text = bytes(body).decode("utf-8", "replace") if body else ""
|
text = bytes(body).decode("utf-8", "replace") if body else ""
|
||||||
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
||||||
"the Licenses page lacks the download link or the manifest")
|
"the Licenses page lacks the download link or the manifest")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- the network boot
|
||||||
|
|
||||||
|
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
|
||||||
|
INTERFACES = "/etc/network/interfaces"
|
||||||
|
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
|
||||||
|
|
||||||
|
|
||||||
|
def ifstate_names(text):
|
||||||
|
"""The interfaces ifup finished configuring: one name=name line each
|
||||||
|
in busybox's state file, written when ifup returns for the name."""
|
||||||
|
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
|
||||||
|
|
||||||
|
|
||||||
|
def dhcp6_in_interfaces(text):
|
||||||
|
"""The lines of an interfaces file that start a DHCPv6 client: an
|
||||||
|
inet6 stanza with a method that runs one, or a command that names
|
||||||
|
one. A comment starts nothing."""
|
||||||
|
bad = []
|
||||||
|
for raw in (text or "").splitlines():
|
||||||
|
words = raw.split("#", 1)[0].split()
|
||||||
|
if not words:
|
||||||
|
continue
|
||||||
|
if words[0] == "iface":
|
||||||
|
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
|
||||||
|
bad.append(" ".join(words))
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stat(text):
|
||||||
|
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
|
||||||
|
sits in parentheses and may itself hold spaces and parentheses."""
|
||||||
|
try:
|
||||||
|
head, tail = text.split("(", 1)
|
||||||
|
comm, rest = tail.rsplit(")", 1)
|
||||||
|
return int(head), comm, int(rest.split()[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _procs():
|
||||||
|
"""(pid, ppid, comm, command line) of every process."""
|
||||||
|
out = []
|
||||||
|
for name in os.listdir("/proc"):
|
||||||
|
if not name.isdigit():
|
||||||
|
continue
|
||||||
|
st = parse_stat(_read("/proc/%s/stat" % name, ""))
|
||||||
|
if st is None:
|
||||||
|
continue
|
||||||
|
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
|
||||||
|
out.append((st[0], st[2], st[1], cmd))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def link_addrs(text, iface):
|
||||||
|
"""(link-local, global) IPv6 addresses of one interface, as the hex
|
||||||
|
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
|
||||||
|
flags, name; scope 20 is the link, 00 the world)."""
|
||||||
|
local, world = [], []
|
||||||
|
for line in (text or "").splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) == 6 and f[5] == iface:
|
||||||
|
if f[3] == "20":
|
||||||
|
local.append(f[0])
|
||||||
|
elif f[3] == "00":
|
||||||
|
world.append(f[0])
|
||||||
|
return local, world
|
||||||
|
|
||||||
|
|
||||||
|
@test("image.network-boot", title="The boot does not wait on the network",
|
||||||
|
subsystem="image", kind="auto", est_min=1,
|
||||||
|
description="The network comes up without the boot waiting on a server. init starts the "
|
||||||
|
"rest of the boot, the console login last, only after the networking script "
|
||||||
|
"returns, so a client that waits in ifup's foreground on a server's answer "
|
||||||
|
"holds the whole machine: no login, no SSH, no control panel. ifup has "
|
||||||
|
"returned for wlan0 (it is in the state file and no ifup is running) and the "
|
||||||
|
"console login is up; the DHCP client is the bounded one (-b, which leaves "
|
||||||
|
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
|
||||||
|
"is named in the interfaces file, running, or on the image; and IPv6 is the "
|
||||||
|
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||||
|
"address up. A global address is evidence only, because a network whose "
|
||||||
|
"router advertisement offers no SLAAC prefix gives none. What a hostile "
|
||||||
|
"server does to a client is a bench drill, not a test. No component covers "
|
||||||
|
"this: the interfaces file is layer content, in the platform identity of "
|
||||||
|
"every fingerprint.")
|
||||||
|
def network_boot(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
procs = _procs()
|
||||||
|
|
||||||
|
# 1. ifup came back, and the boot went on to the console login
|
||||||
|
state = ifstate_names(_read(IFSTATE, ""))
|
||||||
|
ev["ifstate"] = sorted(state)
|
||||||
|
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
|
||||||
|
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||||
|
ev["ifup_running"] = [p[3] for p in stuck]
|
||||||
|
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||||
|
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||||
|
ev["getty"] = logins
|
||||||
|
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||||
|
|
||||||
|
# 2. the DHCP client is the bounded one, and it has left ifup
|
||||||
|
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||||
|
ev["udhcpc"] = [p[3] for p in v4]
|
||||||
|
ctx.check(v4, "no DHCP client is running")
|
||||||
|
for pid, ppid, _comm, cmd in v4:
|
||||||
|
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||||
|
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||||
|
|
||||||
|
# 3. no DHCPv6 client: not started, not running, not on the image
|
||||||
|
text = _read(INTERFACES)
|
||||||
|
ctx.check(text is not None, "%s is missing", INTERFACES)
|
||||||
|
named = dhcp6_in_interfaces(text)
|
||||||
|
ev["interfaces_dhcp6"] = named
|
||||||
|
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
|
||||||
|
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||||
|
ev["dhcp6_running"] = alive
|
||||||
|
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||||
|
for name in DHCP6_CLIENTS:
|
||||||
|
ctx.check(shutil.which(name) is None, "%s is on the image", name)
|
||||||
|
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
|
||||||
|
"the DHCPv6 hook script is on the image")
|
||||||
|
|
||||||
|
# 4. IPv6 is the kernel's own
|
||||||
|
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||||
|
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||||
|
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||||
|
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||||
|
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||||
|
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||||
|
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||||
|
ev["link_local"] = local
|
||||||
|
ev["global"] = world
|
||||||
|
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||||
|
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
||||||
|
|
||||||
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||||
|
|
||||||
Every test here can emit, except `laser.power-floor`, which only reads
|
Every test here can emit, except `laser.power-floor`, which only reads
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
|
||||||
the routing path (emitters, relays, rendered rules); the level settings."""
|
the routing path (emitters, relays, rendered rules); the level settings."""
|
||||||
import gzip
|
import gzip
|
||||||
@@ -22,14 +27,46 @@ def _read(path, default=None):
|
|||||||
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
|
||||||
("forgectrl", "src/main.c")]
|
("forgectrl", "src/main.c")]
|
||||||
|
|
||||||
|
LOGS_ROOT = "/data/log/forgefirm"
|
||||||
|
# What the export test plants in the installer's directory: a line to find
|
||||||
|
# again, and two addresses (documentation ranges) the sanitizer must take.
|
||||||
|
_PROBE_MARK = "forgetest export probe"
|
||||||
|
_PROBE_MAC = "02:00:5e:10:20:30"
|
||||||
|
_PROBE_IP = "192.0.2.77"
|
||||||
|
|
||||||
|
|
||||||
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
||||||
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||||
"bundle that contains neither the panel token nor the camera key.")
|
"bundle that contains neither the panel token nor the camera key. The bundle "
|
||||||
|
"also carries the installer's directory of the tree (logs/install/), which no "
|
||||||
|
"logger feeds: a probe file planted there for the export comes back in the "
|
||||||
|
"bundle with its addresses redacted, and an install log that the installer "
|
||||||
|
"left is in the bundle too.")
|
||||||
def tree_tail_export(ctx):
|
def tree_tail_export(ctx):
|
||||||
|
install_dir = os.path.join(LOGS_ROOT, "install")
|
||||||
|
probe = os.path.join(install_dir, "forgetest-probe.txt")
|
||||||
|
made_dir = not os.path.isdir(install_dir)
|
||||||
|
try:
|
||||||
|
os.makedirs(install_dir, exist_ok=True)
|
||||||
|
with open(probe, "w") as f:
|
||||||
|
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
|
||||||
|
_tree_tail_export(ctx, probe)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(probe)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if made_dir:
|
||||||
|
try:
|
||||||
|
os.rmdir(install_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_tail_export(ctx, probe):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
st, body = fc.get("/logs")
|
st, body = fc.get("/logs")
|
||||||
@@ -82,6 +119,21 @@ def tree_tail_export(ctx):
|
|||||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||||
|
|
||||||
|
# the installer's directory: no logger feeds it, the export carries it
|
||||||
|
by_name = dict(contents)
|
||||||
|
want = "logs/install/" + os.path.basename(probe)
|
||||||
|
got = [name for name in by_name if name.endswith(want)]
|
||||||
|
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
|
||||||
|
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
|
||||||
|
if got:
|
||||||
|
text = by_name[got[0]].decode("utf-8", "replace")
|
||||||
|
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
|
||||||
|
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
|
||||||
|
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
|
||||||
|
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
|
||||||
|
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
|
||||||
|
"the machine has an install log and the bundle does not")
|
||||||
|
|
||||||
|
|
||||||
# The routing test proves the whole path every logger takes: emitter (or
|
# The routing test proves the whole path every logger takes: emitter (or
|
||||||
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
|
||||||
@@ -95,8 +147,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
|
|||||||
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
|
||||||
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
("forgefirm-app", "forgefirm-app/gfhome.py")]
|
||||||
|
|
||||||
LOGS_ROOT = "/data/log/forgefirm"
|
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
||||||
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
|
|
||||||
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
|
||||||
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
|
||||||
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
"""motion.* - the motion controller under grblHAL: dry motion, no emission.
|
||||||
|
|
||||||
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""setup.* - the first-run setup: the record and the
|
"""setup.* - the first-run setup: the record and the
|
||||||
controller gate, the advisories, the account and its login, the HTTPS
|
controller gate, the advisories, the account and its login, the HTTPS
|
||||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||||
the prompts answered from here (the bench fixture works the lid and the
|
the prompts answered from here (the bench fixture works the lid and the
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
||||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||||
polled, the prompts answered from here, each burn armed by a press, the
|
polled, the prompts answered from here, each burn armed by a press, the
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||||
catalog entries that never touch hardware."""
|
catalog entries that never touch hardware."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The baseline: fixed resting values are restored, preserved values are
|
"""The baseline: fixed resting values are restored, preserved values are
|
||||||
handed back, every deviation is a recorded leftover. Runs against a fake
|
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||||
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||||
@@ -10,6 +15,11 @@ import unittest
|
|||||||
|
|
||||||
from forgetest import baseline
|
from forgetest import baseline
|
||||||
|
|
||||||
|
# The fixed machine tick at the mode an unset xy_microsteps reads as.
|
||||||
|
# Taken from baseline rather than typed, so a change to the default mode
|
||||||
|
# moves the expectations with it.
|
||||||
|
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||||
|
|
||||||
|
|
||||||
class BaselineTests(unittest.TestCase):
|
class BaselineTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -21,8 +31,11 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||||
os.makedirs(self.leds + name)
|
os.makedirs(self.leds + name)
|
||||||
self._led(name, "0")
|
self._led(name, "0")
|
||||||
# a clean machine
|
# A clean machine at the mode an unset xy_microsteps reads as:
|
||||||
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
|
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
|
||||||
|
# literals in FIXED_SYSFS, and that is what enforce() compares
|
||||||
|
# against.
|
||||||
|
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||||
self._attr(attr, val)
|
self._attr(attr, val)
|
||||||
self._attr("cnc/interlock_circuit", "45")
|
self._attr("cnc/interlock_circuit", "45")
|
||||||
self._attr("pic/lid_led", "0")
|
self._attr("pic/lid_led", "0")
|
||||||
@@ -90,7 +103,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
for x in left:
|
for x in left:
|
||||||
self.assertEqual(x.action, "restored", str(x))
|
self.assertEqual(x.action, "restored", str(x))
|
||||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||||
@@ -153,14 +166,16 @@ class BaselineTests(unittest.TestCase):
|
|||||||
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||||
b = self.bl()
|
b = self.bl()
|
||||||
cap = b.capture()
|
cap = b.capture()
|
||||||
self._pos(221, 0, 0) # 4.144 mm into the held move
|
# 4.144 mm into the held move, in the steps of the mode in force
|
||||||
|
held_mm = 4.144
|
||||||
|
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
|
||||||
left = b.enforce("post", captured=cap)
|
left = b.enforce("post", captured=cap)
|
||||||
items = {x.item: x for x in left}
|
items = {x.item: x for x in left}
|
||||||
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||||
self.assertEqual(dev.sent[0], "^X")
|
self.assertEqual(dev.sent[0], "^X")
|
||||||
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||||
self.assertEqual(len(jogs), 1)
|
self.assertEqual(len(jogs), 1)
|
||||||
self.assertIn("X-4.144", jogs[0])
|
self.assertIn("X-%.3f" % held_mm, jogs[0])
|
||||||
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||||
finally:
|
finally:
|
||||||
dev.stop()
|
dev.stop()
|
||||||
@@ -245,7 +260,7 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def test_fixed_constants_checked_against_a_dump(self):
|
def test_fixed_constants_checked_against_a_dump(self):
|
||||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
|
||||||
|
|
||||||
|
|
||||||
# -- the reference is taken after the controller applied its config -----
|
# -- the reference is taken after the controller applied its config -----
|
||||||
@@ -264,7 +279,8 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def sleep(_s):
|
def sleep(_s):
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
if calls["n"] == 3: # the controller's init writes land
|
if calls["n"] == 3: # the controller's init writes land
|
||||||
for attr, val in baseline.CONFIGURED_MARKERS:
|
# at the mode wait_controller_configured watches by default
|
||||||
|
for attr, val in baseline.configured_markers():
|
||||||
self._attr(attr, val)
|
self._attr(attr, val)
|
||||||
ok = baseline.wait_controller_configured(
|
ok = baseline.wait_controller_configured(
|
||||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||||
@@ -302,11 +318,15 @@ class BaselineTests(unittest.TestCase):
|
|||||||
def test_preconfig_reference_is_recognized(self):
|
def test_preconfig_reference_is_recognized(self):
|
||||||
self.assertTrue(baseline.reference_preconfig(
|
self.assertTrue(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||||
|
# The markers are read at the reference's own mode, so a dump taken
|
||||||
|
# on an x8 machine carries the setting that says so.
|
||||||
self.assertFalse(baseline.reference_preconfig(
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||||
# a genuinely different single constant is a machine fact, not pre-config
|
# a genuinely different single constant is a machine fact, not pre-config
|
||||||
self.assertFalse(baseline.reference_preconfig(
|
self.assertFalse(baseline.reference_preconfig(
|
||||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
|
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
|
||||||
|
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||||
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||||
|
|
||||||
# -- the XY microstep mode drives the fixed values ------------------------
|
# -- the XY microstep mode drives the fixed values ------------------------
|
||||||
@@ -325,16 +345,18 @@ class BaselineTests(unittest.TestCase):
|
|||||||
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||||
|
|
||||||
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||||
|
# An unset or unusable key reads as the driver's default, x32.
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
|
||||||
self.assertEqual(baseline.xy_mode_of({}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of(None), 8)
|
self.assertEqual(baseline.xy_mode_of({}), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
|
self.assertEqual(baseline.xy_mode_of(None), 32)
|
||||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
|
||||||
|
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
|
||||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
||||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
|
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
|
||||||
self.assertEqual(baseline.ref_xy_mode({}), 8)
|
self.assertEqual(baseline.ref_xy_mode({}), 32)
|
||||||
|
|
||||||
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
||||||
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||||
@@ -415,6 +437,81 @@ class TransientNotLeftoverTests(BaselineTests):
|
|||||||
self.assertEqual(items[0].found, "1014")
|
self.assertEqual(items[0].found, "1014")
|
||||||
|
|
||||||
|
|
||||||
|
class CoolPublishTests(unittest.TestCase):
|
||||||
|
"""The cooling engine publishes once a tick. A status read inside the
|
||||||
|
tick after a run ended is the engine's last word on the run, not the
|
||||||
|
machine as the run left it. Found on the bench reference: a diagnostic
|
||||||
|
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
|
||||||
|
did its work (cooling.fail-tier-stop) both failed their hand-back on a
|
||||||
|
hold the engine's next tick cleared."""
|
||||||
|
|
||||||
|
IDLE = {"phase": "idle", "armed": False, "hold": False}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def bl(self, statuses):
|
||||||
|
"""A baseline whose /cool/status answers play in order (the last
|
||||||
|
one repeats) and whose waits count reads, not seconds: three for
|
||||||
|
the engine's next tick, as the real wait makes, and enough for the
|
||||||
|
cooldown to let a slow script run out."""
|
||||||
|
b = baseline.Baseline(self.lines.append)
|
||||||
|
seq = list(statuses)
|
||||||
|
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
|
||||||
|
|
||||||
|
def wait(what, pred, timeout):
|
||||||
|
for i in range(3 if what == "cool publish" else 50):
|
||||||
|
if pred():
|
||||||
|
return float(i)
|
||||||
|
return None
|
||||||
|
b._wait = wait
|
||||||
|
b.stood_down = []
|
||||||
|
b.stand_down = b.stood_down.append
|
||||||
|
return b
|
||||||
|
|
||||||
|
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
|
||||||
|
|
||||||
|
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "run", "armed": False, "hold": True},
|
||||||
|
{"phase": "smoke", "armed": False, "hold": False},
|
||||||
|
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
|
||||||
|
|
||||||
|
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
|
||||||
|
left = []
|
||||||
|
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual([x.item for x in left], ["cool"])
|
||||||
|
self.assertEqual(left[0].found, "run/armed=True/hold=True")
|
||||||
|
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
|
||||||
|
self.assertEqual(len(b.stood_down), 1)
|
||||||
|
|
||||||
|
def test_a_hold_that_clears_only_later_is_still_recorded(self):
|
||||||
|
left = []
|
||||||
|
held = {"phase": "run", "armed": False, "hold": True}
|
||||||
|
b = self.bl([held] * 10 + [self.IDLE])
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual([x.item for x in left], ["cool"])
|
||||||
|
self.assertEqual(left[0].action, "waited")
|
||||||
|
self.assertEqual(b.stood_down, [])
|
||||||
|
|
||||||
|
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
|
||||||
|
left = []
|
||||||
|
self.bl([self.IDLE])._cool_side(left)
|
||||||
|
b = baseline.Baseline(self.lines.append)
|
||||||
|
b.fc_get = lambda path: (None, None)
|
||||||
|
b._cool_side(left)
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
|
||||||
|
|
||||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||||
"""In cloud mode the cloud client configures the machine from the
|
"""In cloud mode the cloud client configures the machine from the
|
||||||
pulse header it is playing, the lens included: z_mode comes from
|
pulse header it is playing, the lens included: z_mode comes from
|
||||||
@@ -571,7 +668,7 @@ class BaselineModeTests(BaselineTests):
|
|||||||
self._attr("pic/lid_led", "77")
|
self._attr("pic/lid_led", "77")
|
||||||
left = self.bl().enforce("pre", captured=None)
|
left = self.bl().enforce("pre", captured=None)
|
||||||
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
||||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||||
|
|
||||||
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench registry against scripts/bench: every python tool is
|
"""The bench registry against scripts/bench: every python tool is
|
||||||
registered (or named as not-a-tool), every registered script exists,
|
registered (or named as not-a-tool), every registered script exists,
|
||||||
every argument spec builds a command line, every script compiles, and
|
every argument spec builds a command line, every script compiles, and
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import helpers
|
import helpers
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
||||||
phrase the pinned cloud app can actually log.
|
phrase the pinned cloud app can actually log.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||||
gfcloud log: the real test functions run under the real runner Context,
|
gfcloud log: the real test functions run under the real runner Context,
|
||||||
the operator prompts answered - and the standing notices of the machine
|
the operator prompts answered - and the standing notices of the machine
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
||||||
a queue runs it first: a heater trial leaves a warm slug circulating past
|
a queue runs it first: a heater trial leaves a warm slug circulating past
|
||||||
the coolant sensors for minutes, and the tool's edges read it as
|
the coolant sensors for minutes, and the tool's edges read it as
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||||
under the real runner Context against a scripted machine: a fake
|
under the real runner Context against a scripted machine: a fake
|
||||||
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||||
@@ -729,3 +734,51 @@ class FanGateTests(unittest.TestCase):
|
|||||||
self.run_test()
|
self.run_test()
|
||||||
self.assertIn("lacks exhaust", str(cm.exception))
|
self.assertIn("lacks exhaust", str(cm.exception))
|
||||||
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
|
||||||
|
|
||||||
|
|
||||||
|
class FlowVerdictLogTests(unittest.TestCase):
|
||||||
|
"""cooling.flow-under-load finds the engine's verdict in what the log
|
||||||
|
gained since the job began, not in a count over a tail of fixed length:
|
||||||
|
there a new verdict line comes in at the bottom as an old one leaves at
|
||||||
|
the top, the count does not move, and a check that verified reads as
|
||||||
|
one that never judged."""
|
||||||
|
|
||||||
|
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
|
||||||
|
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
|
||||||
|
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
|
||||||
|
"(heater rise 9.5 C, dT 8.7 C)\n")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp(prefix="flowlog.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||||
|
self.path = os.path.join(self.dir, "forgectrl.log")
|
||||||
|
|
||||||
|
def write(self, text, mode="w"):
|
||||||
|
with open(self.path, mode, newline="\n") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def test_only_the_text_after_the_offset_comes_back(self):
|
||||||
|
self.write(self.OLD + "filler\n" * 400)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.assertEqual(cooling._log_since(self.path, off), "")
|
||||||
|
self.write(self.VERDICT, mode="a")
|
||||||
|
text = cooling._log_since(self.path, off)
|
||||||
|
self.assertEqual(text, self.VERDICT)
|
||||||
|
m = cooling._load_verdict_rx().search(text)
|
||||||
|
self.assertEqual(m.group(1), "coolant flow verified")
|
||||||
|
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
|
||||||
|
|
||||||
|
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
|
||||||
|
self.write(self.OLD)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
|
||||||
|
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
|
||||||
|
|
||||||
|
def test_a_log_rotated_under_the_test_is_read_whole(self):
|
||||||
|
self.write(self.OLD + "filler\n" * 400)
|
||||||
|
off = os.path.getsize(self.path)
|
||||||
|
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||||
|
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||||
|
|
||||||
|
def test_a_missing_log_reads_as_nothing(self):
|
||||||
|
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The bench actuator as the tool sees it.
|
"""The bench actuator as the tool sees it.
|
||||||
|
|
||||||
The fixture is a box on the network that opens the lid loop, pulls the
|
The fixture is a box on the network that opens the lid loop, pulls the
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from forgetest import catalog
|
||||||
from forgetest.suite import image
|
from forgetest.suite import image
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +26,61 @@ class KernelIdentTests(unittest.TestCase):
|
|||||||
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkBootTests(unittest.TestCase):
|
||||||
|
STANZA = ("auto wlan0\n"
|
||||||
|
"iface wlan0 inet dhcp\n"
|
||||||
|
"\twpa-driver nl80211\n"
|
||||||
|
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||||
|
|
||||||
|
def test_the_test_is_registered_and_covers_nothing_by_design(self):
|
||||||
|
t = catalog.load_suite()["image.network-boot"]
|
||||||
|
self.assertEqual(t.kind, "auto")
|
||||||
|
self.assertEqual(t.covers, ()) # the interfaces file is layer content
|
||||||
|
|
||||||
|
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||||
|
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||||
|
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||||
|
self.assertEqual(image.ifstate_names(""), set())
|
||||||
|
self.assertEqual(image.ifstate_names(None), set())
|
||||||
|
|
||||||
|
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(None), [])
|
||||||
|
|
||||||
|
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
|
||||||
|
text = self.STANZA + ("iface wlan0 inet6 manual\n"
|
||||||
|
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(text),
|
||||||
|
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
|
||||||
|
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
|
||||||
|
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
|
||||||
|
|
||||||
|
def test_an_inet6_method_that_runs_a_client_is_named(self):
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
|
||||||
|
["iface wlan0 inet6 dhcp"])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
|
||||||
|
|
||||||
|
def test_a_comment_starts_nothing(self):
|
||||||
|
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
||||||
|
self.assertEqual(image.dhcp6_in_interfaces(text), [])
|
||||||
|
|
||||||
|
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
|
||||||
|
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
||||||
|
(371, "udhcpc", 1))
|
||||||
|
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
|
||||||
|
self.assertIsNone(image.parse_stat(""))
|
||||||
|
self.assertIsNone(image.parse_stat("12 (x) S"))
|
||||||
|
|
||||||
|
def test_link_addrs_sorts_one_interface_by_scope(self):
|
||||||
|
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
|
||||||
|
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
|
||||||
|
"00000000000000000000000000000001 01 80 10 80 lo\n")
|
||||||
|
local, world = image.link_addrs(text, "wlan0")
|
||||||
|
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
|
||||||
|
self.assertEqual(world, ["26001700000000000000000000001234"])
|
||||||
|
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""scripts/install-forgefirm.sh - the download that retries, and the log.
|
||||||
|
|
||||||
|
The installer runs once per machine, on factory firmware, over whatever
|
||||||
|
network the owner has. These tests run its own functions under sh with a
|
||||||
|
scripted curl in front of them: a transfer that fails resumes and is
|
||||||
|
tried again, the failures that waiting cannot fix end the tries at once,
|
||||||
|
and every step leaves a line in the install log in the log tree's own
|
||||||
|
format."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import helpers # noqa: F401 (sys.path)
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
||||||
|
MARK = "# The install log, before anything can fail."
|
||||||
|
|
||||||
|
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
|
||||||
|
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
|
||||||
|
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
|
||||||
|
|
||||||
|
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||||
|
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||||
|
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||||
|
# would, records its arguments, and exits rc.
|
||||||
|
FAKE_CURL = r"""#!/bin/sh
|
||||||
|
echo "$*" >> "$CURL_CALLS"
|
||||||
|
OUT=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
[ "$1" = "--output" ] && OUT="$2"
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
LINE=$(sed -n 1p "$CURL_PLAN")
|
||||||
|
sed -i 1d "$CURL_PLAN"
|
||||||
|
set -- $LINE
|
||||||
|
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
|
||||||
|
printf '%s' "$2"
|
||||||
|
exit "$1"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def functions():
|
||||||
|
"""The installer up to its first action: constants and functions."""
|
||||||
|
with open(SCRIPT, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
head, mark, _ = text.partition(MARK)
|
||||||
|
assert mark, "the installer lost the line the tests cut it at"
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
|
||||||
|
class InstallerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
|
self.bin = os.path.join(self.tmp, "bin")
|
||||||
|
os.mkdir(self.bin)
|
||||||
|
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
|
||||||
|
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
|
||||||
|
path = os.path.join(self.bin, name)
|
||||||
|
with open(path, "w", newline="\n") as f:
|
||||||
|
f.write(body)
|
||||||
|
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
||||||
|
self.fw = os.path.join(self.tmp, "forgefirm.fw")
|
||||||
|
self.log = os.path.join(self.tmp, "log", "install.log")
|
||||||
|
self.calls = os.path.join(self.tmp, "curl.calls")
|
||||||
|
self.plan = os.path.join(self.tmp, "curl.plan")
|
||||||
|
|
||||||
|
def run_sh(self, body, plan=()):
|
||||||
|
with open(self.plan, "w", newline="\n") as f:
|
||||||
|
f.write("".join("%s\n" % line for line in plan))
|
||||||
|
open(self.calls, "w").close()
|
||||||
|
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
|
||||||
|
functions(), self.fw, os.path.dirname(self.log), self.log, body)
|
||||||
|
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
|
||||||
|
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
|
||||||
|
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
def download(self, plan):
|
||||||
|
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
|
||||||
|
with open(self.calls) as f:
|
||||||
|
calls = f.read().splitlines()
|
||||||
|
return r.stdout, calls, self.log_lines()
|
||||||
|
|
||||||
|
def log_lines(self):
|
||||||
|
try:
|
||||||
|
with open(self.log) as f:
|
||||||
|
return f.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def test_a_clean_download_takes_one_try_and_its_name(self):
|
||||||
|
out, calls, log = self.download(["0 200 5000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000)
|
||||||
|
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||||
|
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
|
||||||
|
|
||||||
|
def test_a_failed_transfer_resumes_and_is_tried_again(self):
|
||||||
|
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
for c in calls: # every try resumes, bounded in time
|
||||||
|
self.assertIn("-C -", c)
|
||||||
|
self.assertIn("--connect-timeout", c)
|
||||||
|
self.assertIn("--speed-time", c)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
|
||||||
|
warns = [line for line in log if " WARNING download: try " in line]
|
||||||
|
self.assertEqual(len(warns), 2, log)
|
||||||
|
self.assertIn("did not resolve", warns[0])
|
||||||
|
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
|
||||||
|
self.assertIn("Trying again in 5s (try 2 of 5)", out)
|
||||||
|
self.assertIn("Trying again in 15s (try 3 of 5)", out)
|
||||||
|
|
||||||
|
def test_the_tries_run_out(self):
|
||||||
|
out, calls, log = self.download(["28 000 0"] * 5)
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("timed out or stalled", out)
|
||||||
|
self.assertEqual(len(calls), 5)
|
||||||
|
self.assertFalse(os.path.exists(self.fw))
|
||||||
|
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||||
|
# each failed try leaves what the network looked like
|
||||||
|
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
|
||||||
|
|
||||||
|
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
|
||||||
|
out, calls, _ = self.download(["22 404 0"])
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("HTTP 404", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
out, calls, _ = self.download(["23 200 100"])
|
||||||
|
self.assertIn("rc=1", out)
|
||||||
|
self.assertIn("could not be written", out)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
|
||||||
|
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
|
||||||
|
self.assertIn("rc=0", out)
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||||
|
|
||||||
|
def test_a_tls_failure_names_the_clock(self):
|
||||||
|
out, _, _ = self.download(["60 000 0"] * 5)
|
||||||
|
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
|
||||||
|
|
||||||
|
def test_every_log_line_is_in_the_tree_format(self):
|
||||||
|
self.download(["7 000 0", "0 200 10"])
|
||||||
|
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
|
||||||
|
lines = self.log_lines()
|
||||||
|
self.assertGreater(len(lines), 5)
|
||||||
|
for line in lines:
|
||||||
|
self.assertRegex(line, LINE_RE)
|
||||||
|
|
||||||
|
def test_die_leaves_the_reason_in_the_log(self):
|
||||||
|
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||||
|
self.assertEqual(r.returncode, 1)
|
||||||
|
self.assertNotIn("not-reached", r.stdout)
|
||||||
|
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
|
||||||
|
for line in self.log_lines()))
|
||||||
|
|
||||||
|
def test_no_writable_log_never_fails_the_install(self):
|
||||||
|
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||||
|
'log INFO "y"; echo "still-running rc=$?"')
|
||||||
|
self.assertIn("still-running rc=0", r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerShapeTests(unittest.TestCase):
|
||||||
|
"""What holds on any host, with no shell to run."""
|
||||||
|
|
||||||
|
def test_the_log_lives_in_the_tree_the_export_carries(self):
|
||||||
|
head = functions()
|
||||||
|
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
|
||||||
|
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
|
||||||
|
|
||||||
|
def test_the_download_is_the_retrying_one(self):
|
||||||
|
with open(SCRIPT, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
body = text.partition(MARK)[2]
|
||||||
|
self.assertIn("download_fw", body)
|
||||||
|
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
||||||
releases inside the window passes, a chain held past it is the refusal,
|
releases inside the window passes, a chain held past it is the refusal,
|
||||||
and a chain already off costs no wait."""
|
and a chain already off costs no wait."""
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
||||||
the button latch judged from the first emission through every sample
|
the button latch judged from the first emission through every sample
|
||||||
whose readback word shows the laser latch unlocked, both bits from that
|
whose readback word shows the laser latch unlocked, both bits from that
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The controller mode a test declares: the runner puts the machine there
|
"""The controller mode a test declares: the runner puts the machine there
|
||||||
before the test and the baseline keeps it there afterward.
|
before the test and the baseline keeps it there afterward.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
||||||
|
|
||||||
A test asks for the operator's part in a way the page can show before
|
A test asks for the operator's part in a way the page can show before
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
||||||
(what the dev image carries) assembling to the same bytes as a checkout,
|
(what the dev image carries) assembling to the same bytes as a checkout,
|
||||||
the token placeholder carried exactly once, and a missing marker refused
|
the token placeholder carried exactly once, and a missing marker refused
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The two queues: what they select, the order they run in, and where
|
"""The two queues: what they select, the order they run in, and where
|
||||||
they stop.
|
they stop.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""The release artifact carries no machine identity (redact.py).
|
||||||
|
|
||||||
|
The artifact is committed to a public repository. These hold the exporter
|
||||||
|
to that, and to not redacting what the gate recomputes.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from forgetest.redact import scrub
|
||||||
|
|
||||||
|
|
||||||
|
class RedactTests(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_keyed_identity_is_replaced(self):
|
||||||
|
out = scrub({"gf_serial": "15232020", "machine_id": "JTY-876",
|
||||||
|
"hostname": "forgefirm-b00a"})
|
||||||
|
self.assertNotIn("15232020", json.dumps(out))
|
||||||
|
self.assertNotIn("JTY-876", json.dumps(out))
|
||||||
|
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||||
|
|
||||||
|
def test_a_known_value_is_replaced_in_free_text_too(self):
|
||||||
|
# A hostname in a log line is the same leak as one in its own field.
|
||||||
|
out = scrub({"evidence": {"hostname": "forgefirm-b00a"},
|
||||||
|
"log": ["set hostname 'forgefirm-b00a' ok"]})
|
||||||
|
self.assertNotIn("forgefirm-b00a", json.dumps(out))
|
||||||
|
|
||||||
|
def test_addresses_and_macs_go(self):
|
||||||
|
out = scrub(["ping 172.16.1.97", "wlan0 2C:6B:7D:0D:B0:0A up"])
|
||||||
|
t = json.dumps(out)
|
||||||
|
self.assertNotIn("172.16.1.97", t)
|
||||||
|
self.assertNotIn("2C:6B:7D:0D:B0:0A", t)
|
||||||
|
|
||||||
|
def test_loopback_is_kept(self):
|
||||||
|
self.assertIn("127.0.0.1", json.dumps(scrub(["curl http://127.0.0.1:8090/"])))
|
||||||
|
|
||||||
|
def test_numbering_is_stable_within_an_export(self):
|
||||||
|
out = scrub(["a 10.0.0.5", "b 10.0.0.6", "c 10.0.0.5"])
|
||||||
|
self.assertEqual(out[0].split()[1], out[2].split()[1])
|
||||||
|
self.assertNotEqual(out[0].split()[1], out[1].split()[1])
|
||||||
|
|
||||||
|
def test_integrity_values_survive(self):
|
||||||
|
# The gate recomputes these; a long hex blob is what they look like.
|
||||||
|
rec = {"fingerprint": "a" * 64, "manifest_sha": "b" * 64,
|
||||||
|
"source_sha": "c" * 64, "sha256": "d" * 64,
|
||||||
|
"catalog_hash": "e" * 64, "identity_sha": "f" * 64}
|
||||||
|
self.assertEqual(scrub(rec), rec)
|
||||||
|
|
||||||
|
def test_a_clock_time_is_not_an_address(self):
|
||||||
|
out = scrub(["2026-09-18T18:50:11Z baseline: pre: clean"])
|
||||||
|
self.assertIn("18:50:11", out[0])
|
||||||
|
|
||||||
|
def test_result_fields_the_gate_reads_are_untouched(self):
|
||||||
|
rec = {"result": "PASS", "ts": "2026-09-18T18:50:11Z",
|
||||||
|
"campaign": "c-1", "fingerprint": "a" * 64}
|
||||||
|
self.assertEqual(scrub(rec), rec)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""What keeps the page answering the operator instead of the timer.
|
"""What keeps the page answering the operator instead of the timer.
|
||||||
|
|
||||||
Two things went wrong on the bench and are pinned here:
|
Two things went wrong on the bench and are pinned here:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
"""Runner + HTTP API end to end on localhost with a fake catalog and a
|
||||||
fake bench tool. No hardware, no forgectrl."""
|
fake bench tool. No hardware, no forgectrl."""
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup's checks in the catalog: registered as automatic tests with
|
"""The setup's checks in the catalog: registered as automatic tests with
|
||||||
no precheck, the takeover ones marked so, every covers map naming the
|
no precheck, the takeover ones marked so, every covers map naming the
|
||||||
check runner; and the check driver itself against the fake daemon: a
|
check runner; and the check driver itself against the fake daemon: a
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup's sheet in the catalog: one live test on the button with a
|
"""The setup's sheet in the catalog: one live test on the button with a
|
||||||
piece of wood at hand, its covers map naming the live runner, the
|
piece of wood at hand, its covers map naming the live runner, the
|
||||||
renderer, the sender, the page, and the driver's laser module; and the
|
renderer, the sender, the page, and the driver's laser module; and the
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""The setup.* suite on the host: the registration (ids, kinds, the
|
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||||
takeover tests, the operator tests' hands), the record builders, the
|
takeover tests, the operator tests' hands), the record builders, the
|
||||||
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""scripts/source-bundle.py - the source that a release publishes.
|
"""scripts/source-bundle.py - the source that a release publishes.
|
||||||
|
|
||||||
The bundle must hold the source of every recipe of the image whose license
|
The bundle must hold the source of every recipe of the image whose license
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image
|
||||||
manifest: recipe pins are read through their pin files, and a layer's
|
manifest: recipe pins are read through their pin files, and a layer's
|
||||||
content hash leaves the pin files out (a component bump is not a platform
|
content hash leaves the pin files out (a component bump is not a platform
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ overrides:
|
|||||||
meta-freescale-distro:
|
meta-freescale-distro:
|
||||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||||
meta-openglow:
|
meta-openglow:
|
||||||
commit: dd8f74c2d58e998913e77e28450e8dca5e164a48
|
commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
|
||||||
#
|
#
|
||||||
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
|
||||||
#
|
#
|
||||||
# A component recipe that inherits this class records what source it was
|
# A component recipe that inherits this class records what source it was
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
require conf/distro/include/fslc-base.inc
|
require conf/distro/include/fslc-base.inc
|
||||||
|
|
||||||
DISTRO = "forgefirm"
|
DISTRO = "forgefirm"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# We have a conf and classes directory, add to BBPATH
|
# We have a conf and classes directory, add to BBPATH
|
||||||
BBPATH .= ":${LAYERDIR}"
|
BBPATH .= ":${LAYERDIR}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
|
||||||
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
|
||||||
# carry the license text with them.
|
# carry the license text with them.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM SSH policy, set in the installed files so the release image
|
# ForgeFIRM SSH policy, set in the installed files so the release image
|
||||||
# carries it as built:
|
# carries it as built:
|
||||||
# PermitRootLogin no root logs in at the serial console only
|
# PermitRootLogin no root logs in at the serial console only
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
# ForgeFIRM image manifest: source fingerprint of the hardware library
|
||||||
# (forgefirm-manifest.bbclass in this layer).
|
# (forgefirm-manifest.bbclass in this layer).
|
||||||
inherit forgefirm-manifest
|
inherit forgefirm-manifest
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service
|
||||||
# layer (forgefirm-manifest.bbclass in this layer).
|
# layer (forgefirm-manifest.bbclass in this layer).
|
||||||
inherit forgefirm-manifest
|
inherit forgefirm-manifest
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
|
||||||
# client and the machine glue talk TLS through the standard library's ssl
|
# client and the machine glue talk TLS through the standard library's ssl
|
||||||
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
SUMMARY = "Configurable embedded Linux firmware update creator and runner"
|
||||||
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
|
||||||
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
|
||||||
HOMEPAGE = "https://github.com/babelouest/orcania"
|
HOMEPAGE = "https://github.com/babelouest/orcania"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# /etc/rsyslog.conf - ForgeFIRM
|
# /etc/rsyslog.conf - ForgeFIRM
|
||||||
#
|
#
|
||||||
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
# rsyslog is the only log writer on the machine. Every ForgeFIRM process
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
# rsyslog is the ForgeFIRM system logger: the only log writer, one
|
||||||
# directory per logger under /data/log/forgefirm. This appends the
|
# directory per logger under /data/log/forgefirm. This appends the
|
||||||
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
|
||||||
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
HOMEPAGE = "https://github.com/babelouest/ulfius"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Logging library for C applications (babelouest)"
|
DESCRIPTION = "Logging library for C applications (babelouest)"
|
||||||
HOMEPAGE = "https://github.com/babelouest/yder"
|
HOMEPAGE = "https://github.com/babelouest/yder"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
|
||||||
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
|
||||||
the boot target by rewriting the saved U-Boot environment with read-back \
|
the boot target by rewriting the saved U-Boot environment with read-back \
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
|
||||||
# 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 = "da3eddcc0f4324dc6ec8a0042ab7db0f1b16fd4f"
|
SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
|
||||||
PV = "0.1.25"
|
PV = "0.1.27"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
|
||||||
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
HOMEPAGE = "https://github.com/openglow-org/forgectrl"
|
||||||
|
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=fe9dfc6caad62544b4b1d0fa55c4b92f"
|
LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
|
||||||
|
|
||||||
PE = "1"
|
PE = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
|
||||||
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
|
||||||
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
# here - the image manifest leaves *-pin.inc out of the layer content hash
|
||||||
# because the component entry already identifies the pinned source
|
# because the component entry already identifies the pinned source
|
||||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||||
# meta-glowforge-bsp pins the same repository; move both together.
|
# meta-glowforge-bsp pins the same repository; move both together.
|
||||||
SRCREV = "5ca279a1f6008ddf6e19a17c6d6375972d27ed7c"
|
SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
|
||||||
|
|
||||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
# 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
|
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||||
# backwards.
|
# backwards.
|
||||||
PV = "0.1.29+git"
|
PV = "0.1.30+git"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
|
||||||
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
# gfcloud). Their sources live in the forgefirm-app/ directory of the
|
||||||
# python3-gfhardware repository.
|
# python3-gfhardware repository.
|
||||||
@@ -7,7 +9,7 @@ HOMEPAGE = "https://github.com/openglow-org/python3-gfhardware"
|
|||||||
# repository's LGPL component (the Bayer decoder compiled into
|
# repository's LGPL component (the Bayer decoder compiled into
|
||||||
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
# gfhardware._cam) is not part of them; python3-gfhardware declares it.
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://LICENSE;md5=7029670f29302d9352647c6c89614bc2"
|
LIC_FILES_CHKSUM = "file://LICENSE;md5=314d21c23b626bdadbb4801c47b16266"
|
||||||
|
|
||||||
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
|
||||||
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
|
||||||
|
|
||||||
require forgefirm-app.inc
|
require forgefirm-app.inc
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgefirm-banner
|
# Provides: forgefirm-banner
|
||||||
# Required-Start: $network
|
# Required-Start: $network
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||||
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgefirm-hostname
|
# Provides: forgefirm-hostname
|
||||||
# Required-Start: $local_fs
|
# Required-Start: $local_fs
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
||||||
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
||||||
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
Glowforge factory keyring
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Eleven Ed25519 *public* keys, 32 raw bytes each, recovered from Glowforge
|
||||||
|
factory firmware. They are the factory trust anchors: ForgeFIRM ships them so
|
||||||
|
the machine can verify the signature on a factory `.fw` archive during a
|
||||||
|
factory restore. They verify archives and nothing else.
|
||||||
|
|
||||||
|
Public key material only. No private or secret key belongs in this directory,
|
||||||
|
and nothing here can be used to sign anything. ForgeFIRM's own release-signing
|
||||||
|
public key is `../forgefirm-release.pub`; its private half is held offline by
|
||||||
|
the operator and is not in this repository, in CI, or in any cloud-synced
|
||||||
|
store.
|
||||||
|
|
||||||
|
Provenance and rights
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
These keys are Glowforge, Inc.'s. They are bare cryptographic public keys --
|
||||||
|
algorithm output, carrying no authorship -- so no copyright subsists in them
|
||||||
|
and none is claimed. 514 LLC d/b/a OpenGlow asserts no rights over them and
|
||||||
|
grants none; the recipe's `LICENSE = "MIT"` covers the recipe, not this key
|
||||||
|
material. Glowforge has not published these keys itself, so there is no
|
||||||
|
upstream license to point to and none is implied.
|
||||||
|
|
||||||
|
For how the trust anchors are used, see "The update manager" and "Decisions"
|
||||||
|
on the documentation site:
|
||||||
|
https://docs.forgefirm.org/technical/forgefirm/install-and-update/
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
SUMMARY = "Firmware verification public keys"
|
SUMMARY = "Firmware verification public keys"
|
||||||
DESCRIPTION = "Trust anchors for firmware archive verification: the \
|
DESCRIPTION = "Trust anchors for firmware archive verification: the \
|
||||||
ForgeFIRM release-signing public key (verifies release downloads and \
|
ForgeFIRM release-signing public key (verifies release downloads and \
|
||||||
uploads) and the Glowforge factory keyring (verifies factory .fw \
|
uploads) and the Glowforge factory keyring (verifies factory .fw \
|
||||||
archives for cloud restore). Public keys only."
|
archives for cloud restore). Public keys only."
|
||||||
|
# LICENSE covers this recipe, not the key material. The Glowforge factory
|
||||||
|
# keyring in files/gf/ is Glowforge, Inc.'s: bare Ed25519 public keys, in which
|
||||||
|
# no copyright subsists and over which OpenGlow claims nothing and grants
|
||||||
|
# nothing. files/gf/README records that and the public-key-only boundary.
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||||
|
# Written by Scott Wiederhold
|
||||||
|
# https://community.openglow.org
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
### BEGIN INIT INFO
|
### BEGIN INIT INFO
|
||||||
# Provides: forgefirm-logging
|
# Provides: forgefirm-logging
|
||||||
# Required-Start: $local_fs
|
# Required-Start: $local_fs
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user