diff --git a/INSTALL.md b/INSTALL.md index fedc8c4..853c4e2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -30,6 +30,28 @@ software could seriously maim or kill you or others, and voids your warranty. It is not affiliated with or endorsed by Glowforge. Use it at your own risk.** +## Regulatory and legal + +Installing ForgeFIRM replaces the firmware of a certified laser product. +This is disclosure, not legal advice — but understand the categories +before you install: + +- **Laser product certification (US).** The factory machine is + certified under FDA/CDRH 21 CFR 1040.10 and 1040.11. Modifying it + makes **you** the manufacturer of a modified laser product for + regulatory purposes; the original accession no longer describes the + article you operate. +- **CE/UKCA (EU/UK).** The manufacturer's declaration of conformity no + longer covers the modified machine. +- **Safety listing.** Any UL/ETL or equivalent listing applies to the + product as shipped, not as modified. +- **Insurance.** Property and liability policies commonly exclude fire + loss involving modified equipment. Check yours before running jobs. + +The hardware safety interlocks (lid switches, interlock, power-fault +chain) remain active under ForgeFIRM — but the regulatory status of the +machine is yours to own once you flash it. + ## Install Log in at the factory console and run: @@ -75,9 +97,12 @@ Switch targets are probed first — `ffboot` refuses to select a slot that does not look bootable (`-f` overrides). Reverting to factory firmware and back requires no reinstall. -To reinstall or update ForgeFIRM before the built-in updater ships: -switch to factory firmware and run the installer again — it skips -archives it already has and simply rewrites the ForgeFIRM slot. +**Routine updates do not use the installer.** Update from the web +control panel's System page, which downloads (or accepts an upload of) +a signed `forgefirm.fw` release, verifies it, and applies it to the +inactive slot. Rerunning the installer is only for recovering a broken +ForgeFIRM install: switch to factory firmware and run it again — it +skips archives it already has and simply rewrites the ForgeFIRM slot. ## Upgrading from a legacy dual-partition install diff --git a/README.md b/README.md index a79cc7e..a862148 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ The control board is common to Glowforge Basic, Plus, and Pro. The 5 MP * Cloud mode: stream jobs into the motion ring during the run, lifting the job-length cap that buffering the whole job imposes. +## Safety + +**This machine contains a Class 4 CO₂ laser: it burns, blinds, and starts +fires.** Never defeat the lid switches or interlock, always vent the exhaust +outdoors, and never cut PVC or other chlorinated plastics. Never leave a +running job unattended — keep a fire extinguisher within reach. Read +[Before you cut — safety](docs/LIGHTBURN.md#before-you-cut--safety-read-this-first) +before your first job, and the +[Regulatory and legal](INSTALL.md#regulatory-and-legal) section before +installing. + **A very important warning: this is experimental software. Use of this software could seriously maim or kill you or others, and voids your warranty. It is not affiliated with or endorsed by Glowforge. Use it at your own risk.** diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index d6f2109..9a04c83 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -1,7 +1,42 @@ # ForgeFIRM bring-up status & cold-start runbook -Last updated: **2026-08-13** — **shared machine services complete and -closed out.** forgectrl is the one machine-services daemon behind both +Last updated: **2026-08-14** — **audit remediation Phases 0 + 1 landed** +(from an independent whole-tree audit dated 2026-08-13; the remediation +is sequenced behind two gates — GATE A, uncommanded energy, before any +further live-fire; GATE B, control surface + release, before any +published release). Phase 0: user-facing laser-safety and +regulatory text is in place (LIGHTBURN.md "Before you cut", README, +INSTALL.md "Regulatory and legal" + updater-first update path, a +persistent panel safety banner), the walkthrough no longer claims the +laser cannot fire, bench-machine identity and the signing-key location +are scrubbed from tracked files (bench scripts take `GF_HOST`), and +every repo has a commit-msg hook enforcing commit attribution. Phase 1 +(GATE A, uncommanded energy) is **code-complete and host-verified**: +the stream engine records the cycle-end laser-off so idle-gap pads ship +dark and every stream terminates FIRE-clear (G-1), latch writes are +serialized against the shipper's relight (G-5) with the arm-state and +verdict caches made properly atomic (G-19/G-20/G-21), the cooling +report path moved to a bounded-connect reporter thread off the protocol +thread (A-3/G-7), and gf.lock is priority-inheriting with PIC-SPI and +rail-settle work moved outside it (G-8). Kernel fixes K-1 (saturating +decel ramp + EPIT divisor clamp), K-2 (resume-waypoint latch guard) and +K-3 (latch writes under status_lock; FIRE drive never restored mid-run +or mid-ramp) are code-complete and **ride the pending full-image +flash** with the platform-hygiene batch. `scripts/bench/ +laser_stream_test.py` now asserts the termination and zero-step-gap +rules across M4, M3-to-stream-end, and cycle-churn sessions (with a +hermetic cooling-verdict publisher): all PASS on the fixed controller +(the M4 session reproduces the recorded baseline byte-for-byte: +28 354 fire ticks, X peak 533 net 0, 534 dark return steps), and a +build with only the G-1 hunks reverted FAILS on the M3 termination +rule — the harness catches the defect class. **GATE A stays open — no +live-fire — until the flashed image passes the bench drills** +(controlled stop decelerates at the default cloud tick, resume with the +latch locked stays laser-less, mid-ramp latch writes do not re-arm +FIRE) and the harness is wired into CI. + +Previously — **shared machine services complete and +closed out (2026-08-13).** forgectrl is the one machine-services daemon behind both controller modes: the cooling engine (single owner of the thermal hardware), controller-mode supervision, the pulse-device broker, and the motion-liveness gate. Both controllers are cooling-engine clients @@ -121,9 +156,8 @@ item 8. ## The bench -- **Board**: SSH `root@172.16.1.97` (fixed DHCP lease since 2026-08-02; - was .130), empty password - (`ssh -o PreferredAuthentications=none` logs straight in). The bench +- **Board**: SSH `root@` (dev images permit passwordless + root login). The bench machine is a **Basic/Plus** (the control board is common to Basic/Plus/Pro). Dev image (`forgefirm-image-dev`) on SD; BusyBox userland + python3 + gdb/strace. @@ -167,11 +201,9 @@ item 8. proven both ways (modern-packed signed archives apply with 0.14.2; modern fwup verifies+applies the factory .fw — signer key 2017-05-001.pub). The production signing-key ceremony - (UPDATE-SYSTEM.md gate 8) was executed 2026-08-08. - **The production release key lives at - `~/forgefirm-release-key/fwup-key.priv`** (0600; `fwup-key.pub` + - `fwup-key-raw.pub` beside it; offline backups held by the operator) — - the installer embeds its pubkey, so releases sign with THIS key only. + (UPDATE-SYSTEM.md gate 8) was executed 2026-08-08. **The production + release key is held offline by the operator** — the installer embeds + its public key, so releases sign with that key only. Pack releases with `scripts/mkfw.sh`; the full pipeline is `scripts/release.sh`, invoked on this host as: `FWUP=~/fwup-lab/bin/fwup-v1.16.0 FWUP_COMPAT=~/fwup-lab/bin/fwup-0.14.2 @@ -235,7 +267,7 @@ hardware I/O — host testing). since the last run, `$RST=$` once (stored settings win). Each motion run logs a producer-stats line to stderr (callbacks, µs/call, max-behind, clamped) — clamped should stay 0. -4. Connect LightBurn/UGS to `172.16.1.97:23`, or jog raw: +4. Connect LightBurn/UGS to `:23`, or jog raw: `$J=G91X40F1200`. `^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an alarm; TCP disconnects never kill the process (the deadman fd stays held). @@ -1412,8 +1444,8 @@ accordingly ("Automatic — AP country, else World"). needs `-f` from factory). The **bench board now runs ForgeFIRM v0.1.0 from eMMC slot 2** (factory 2024 in slot 1, archives in /data/forgefirm/archive, dev image still on SD via `ffboot -s`). - Remaining Phase 2 nicety: the installer's embedded pubkey is the - DEV key until the production ceremony. + The installer's embedded pubkey is the **production release key** + (ceremony executed 2026-08-08; `release.sh` enforces the match). **Post-test: the bench rests on the SD dev image again** (`ffboot -s`; slot 1 = factory 2024, slot 2 = ForgeFIRM v0.1.0, archives in /data/forgefirm/archive). Platform fact pinned by experiment while @@ -1513,3 +1545,18 @@ accordingly ("Automatic — AP country, else World"). - The uniprocessor locking assumption and the panic/dead-man safe states are documented in `kernel-module-glowforge/UAPI.md`; no bench item. + - **GATE A kernel fixes added to the same flash (2026-08-14):** + the controlled-deceleration ramp now floors at the minimum step + frequency with a saturating decrement, and `epit_hz_to_divisor()` + can no longer return the degenerate divisor 0 (a 0 Hz request maps + to the slowest achievable tick); the resume waypoint re-enables + the FIRE drive only when the laser latch is unlocked; and + `laser_latch` writes run under `status_lock`, restoring the FIRE + output drive only when no run or ramp is in flight. + **Bench (GATE A stays open — no live-fire — until these pass):** + a controlled-stop drill at the default cloud tick (10 kHz, ramp + 125000) shows a decelerating tail rather than a max-rate burst; + feed-hold, jog-cancel and `^X` each land in a controlled stop with + position preserved; a resume waypoint with the latch locked stays + laser-less; `laser_latch=0` written mid-ramp does not re-arm FIRE + (probe the PSU-connector LASER_ON line as in `fire_test.py`). diff --git a/docs/LIGHTBURN.md b/docs/LIGHTBURN.md index 9fedf7d..cf27fd7 100644 --- a/docs/LIGHTBURN.md +++ b/docs/LIGHTBURN.md @@ -1,5 +1,28 @@ # LightBurn setup & operation (ForgeFIRM) +## Before you cut — safety (read this first) + +ForgeFIRM replaces the factory software, **not** the factory safety rules. The +machine contains a Class 4 CO₂ laser emitting invisible 10.6 µm infrared at +roughly 45 W. **Jobs sent from LightBurn fire the laser.** + +- **Eyes.** The enclosure and lid glass are the eye-safety barrier. Never + defeat the lid switches or the Pro's remote-interlock plug, and never + operate with any cover removed. Direct or reflected 10.6 µm radiation + blinds and burns. +- **Fumes.** Vent the exhaust to the outdoors, always. Laser-cutting fumes + are toxic and flammable. +- **Materials.** Never cut PVC, vinyl, or any chlorinated plastic — they + release chlorine gas that corrodes the machine and injures your lungs. + Know what your material is before you cut it. +- **Fire.** Never leave a running job unattended. Small flare-ups are normal + with some materials; sustained flame is not. Keep a fire extinguisher + (CO₂ preferred) within reach and know how you will open the lid and + smother a fire before you start. +- **Stop means stop.** The big button, LightBurn's Stop, and opening the + lid each halt the job. If anything looks wrong, stop first and diagnose + second. + The laser fires only inside an operator-armed window: - **Starting a job that fires: press the button.** At the first @@ -26,21 +49,30 @@ The laser fires only inside an operator-armed window: ## One-time device setup Prerequisite: the controller is running on the board (see BRINGUP.md; -`grblHAL_glowforge` on TCP port 23 at 172.16.1.97). +`grblHAL_glowforge` on TCP port 23 at your machine's IP address, shown +below as ``). 1. **Laser window → Devices → Create Manually** (skip auto-find; it scans serial ports). 2. Device type: **grblHAL** if your LightBurn version lists it, otherwise **GRBL** — both speak the right protocol. -3. Connection: **Ethernet/TCP**. IP address: **172.16.1.97** (LightBurn - uses TCP port 23 for GRBL devices, which is exactly where the - controller listens). +3. Connection: **Ethernet/TCP**. IP address: **``** + (LightBurn uses TCP port 23 for GRBL devices, which is exactly where + the controller listens). 4. Name: e.g. `Glowforge ForgeFIRM`. Work area: **X 495 mm, Y 279 mm**. 5. **Origin**: pick the corner where the head sits after parking at home — **rear-left as you face the machine** (the top-left dot in the selector). This is what keeps jobs un-mirrored: machine +X runs right, +Y runs from the rear rail toward you. -6. **Auto-home on startup: NO.** Homing is not wired yet; `$H` errors. +6. **Auto-home on startup: NO** — LightBurn would issue `$H` at every + connect, and the working homing method runs a multi-minute session. + `$H` itself works and is selected by the `homing_mode` setting in the + machine's web control panel: `gfcloud` (Glowforge web-service vision + homing — the working method; X/Y home to the factory corner, Z to the + hall sensor; requires a signed-in Glowforge session), `switches` + (physical limit switches, once installed), or `none` (`$H` is + rejected). Run `$H` deliberately from the Console tab when you want a + true machine origin. 7. Finish. If a stale device profile already exists, edit its IP instead of creating a new one. 8. Device Settings (wrench icon): **S-Value Max = 1000** (matches $30). @@ -54,12 +86,12 @@ In the Laser window set **Start From: Current Position**, and set the **Job Origin** dot to the same corner as the machine origin (top-left dot). The job then runs into the bed from wherever the head currently sits — absolute machine zero never matters, which is the forgiving mode -while the machine has no homing switches. +when you have not homed. -(`Absolute Coords` also works, but only if the head was parked at the -home corner when the controller started; after any Stop/alarm the -absolute frame is stale until the controller is restarted with the head -re-parked.) +(`Absolute Coords` also works after a successful `$H`, or if the head +was parked at the home corner when the controller started. After any +Stop/alarm the absolute frame is stale until you re-home with `$H` or +restart the controller with the head re-parked.) ## Operating basics @@ -90,15 +122,38 @@ full, exhaust and intake fans to factory run speeds, then a ~15 s cooldown after the layer before returning to idle. Leave it ON for anything that will eventually involve the beam; expect real fan noise. +## Dry runs (motion only, no fire) + +**Setting a low power value does NOT make a job inert — any laser layer +prompts for the arm button and then fires.** The motion-only modes are: + +- **Frame** and jogging — never fire. +- A job whose layers emit no laser-on command: turn the layer's + **Output** off in the cut settings, or send gcode that stays in `M5`. +- A job run with the laser latch left locked (never press the arm + button): the job pauses at the white-button prompt and aborts after + `laser_button_timeout_s` — useful only to confirm the prompt itself. + +If the white arm prompt appears and you did not intend to fire, press +**Stop** in LightBurn. + ## A good first job +First a dry run, then a light cut on scrap: + 1. Draw a rectangle (~100 × 60 mm) with a circle inside. 2. Double-click the layer color bar (bottom): mode **Line**, speed **50 mm/s** (= 3000 mm/min; check Edit → Settings for your speed - units), power anything (ignored — nothing fires). + units). For the dry run turn the layer's **Output** off. 3. Park the head where the job's rear-left corner should be (or leave it at home), **Frame**, watch the perimeter trace, then **Start**. Expected behavior: darting travels at up to 200 mm/s, smooth 50 mm/s tracing of the shapes, silky and near-silent motion (factory currents + decay mode), and the head finishing per the job's return setting. + +4. For the live pass: put scrap material on the bed (never an empty + honeycomb over the fan grill), re-enable the layer's **Output**, set + power to **30 %** or more (below ~30 % the tube barely marks), turn + the layer's **Air Assist** on, close the lid, **Frame**, **Start**, + and press the white button when it lights. Watch the whole job. diff --git a/scripts/bench/bench_m2.py b/scripts/bench/bench_m2.py index 7c1ba01..4bcee72 100644 --- a/scripts/bench/bench_m2.py +++ b/scripts/bench/bench_m2.py @@ -2,7 +2,7 @@ """Milestone-2 motion-quality bench: factory-true rates/accels over TCP. Runs a bounded, return-to-start jog sequence against grblHAL on the board -(default 172.16.1.97:23) and reports peak feed reached, state transitions, +(argv[1] or GF_HOST, port 23) and reports peak feed reached, state transitions, and final position drift. Every move is relative and round-trip, so the head ends where it started; the laser stays latched (motion-only backend). @@ -10,11 +10,14 @@ Sequence: sanity jogs (X, Y, 40 mm out/back at 2400 mm/min), max-rate X out/back (60 mm at F12000 - peaks ~200 mm/s mid-move), diagonal out/back, then a G1 move with a feed-hold/resume in the middle. """ +import os import socket import sys import time -HOST = sys.argv[1] if len(sys.argv) > 1 else '172.16.1.97' +HOST = sys.argv[1] if len(sys.argv) > 1 else os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('pass the machine IP as argv[1] or set GF_HOST') PORT = 23 diff --git a/scripts/bench/fan_test.py b/scripts/bench/fan_test.py index 3cee90c..6d858a7 100644 --- a/scripts/bench/fan_test.py +++ b/scripts/bench/fan_test.py @@ -1,6 +1,9 @@ import socket, subprocess, time +import os -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') def board(cmd): r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh', diff --git a/scripts/bench/flow_characterize.py b/scripts/bench/flow_characterize.py index 8b9801e..c7cc9b1 100644 --- a/scripts/bench/flow_characterize.py +++ b/scripts/bench/flow_characterize.py @@ -15,11 +15,14 @@ driver only writes the heater on M8/M9 transitions, so an idle driver leaves this alone. """ import math +import os import subprocess import sys import time -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') # Factory B-equation conversion (see kernel-module-glowforge/UAPI.md). F = 1024.0 * 1.3 diff --git a/scripts/bench/flow_matrix.py b/scripts/bench/flow_matrix.py index 6c73f58..e05c8fa 100644 --- a/scripts/bench/flow_matrix.py +++ b/scripts/bench/flow_matrix.py @@ -35,7 +35,9 @@ import subprocess import sys import time -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') HERE = os.path.dirname(os.path.abspath(__file__)) RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json')) diff --git a/scripts/bench/flow_recheck_char.py b/scripts/bench/flow_recheck_char.py index 7e8a16c..e80c7c2 100644 --- a/scripts/bench/flow_recheck_char.py +++ b/scripts/bench/flow_recheck_char.py @@ -37,11 +37,14 @@ Runs both flow and no-flow cases from a comparable loop state and prints the differential separation. Aborts if downstream passes 45 C. """ import math +import os import subprocess import sys import time -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') F = 1024.0 * 1.3 RD, BETA = 10000.0, 3380.0 RINF = 10000.0 * math.exp(-3380.0 / 298.15) diff --git a/scripts/bench/flow_sustained.py b/scripts/bench/flow_sustained.py index 5a2df9d..0cb2720 100644 --- a/scripts/bench/flow_sustained.py +++ b/scripts/bench/flow_sustained.py @@ -9,13 +9,16 @@ cut-profile fans, logging bulk coolant temperature and every verdict. Usage: flow_sustained.py [minutes] (default 30) """ import math +import os import re import socket import subprocess import sys import time -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') F = 1024.0 * 1.3 RD, BETA = 10000.0, 3380.0 RINF = 10000.0 * math.exp(-3380.0 / 298.15) diff --git a/scripts/bench/flow_warm_validate.py b/scripts/bench/flow_warm_validate.py index cb382b2..c8337fb 100644 --- a/scripts/bench/flow_warm_validate.py +++ b/scripts/bench/flow_warm_validate.py @@ -20,7 +20,9 @@ import subprocess import sys import time -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') HERE = os.path.dirname(os.path.abspath(__file__)) RESULTS = os.path.join(HERE, 'flow_warm_results.json') diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index ae03d6a..e36f9e5 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -2,8 +2,8 @@ """Host-side verification of the laser pulse-stream emission. Runs the native grblHAL_glowforge binary in null-sink mode with -GFSINK_DUMP capturing the shipped byte stream, drives a small M4 laser -job over TCP, then checks the dump against the kernel feeder contract: +GFSINK_DUMP capturing the shipped byte stream, drives small laser jobs +over TCP, then checks the dumps against the kernel feeder contract: 1. a power byte (bit 7) leads the stream, before any tick byte 2. no two consecutive power bytes (the SDMA script drops the second) @@ -13,6 +13,15 @@ job over TCP, then checks the dump against the kernel feeder contract: the G0 return, none at the tail 6. step accounting survives the insertions: X returns to net zero and peaks at the programmed 10 mm + 7. termination: every stream ends with FIRE clear, including an M3 + (constant-power) job whose core never issues a laser-off update - + the stream must never lean on the kernel's end-of-data backstop + 8. no FIRE bit ever rides a zero-step gap: a stepless run of stream + bytes carrying FIRE longer than any legitimate between-step + interval is a stationary dwell burn + 9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve + shaped jobs), where the FIRE state of the previous cycle must not + leak into the idle-gap pad bytes Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge) """ @@ -24,13 +33,24 @@ import socket import subprocess import sys import tempfile +import threading import time BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge") PORT = 2399 STEPS_PER_MM = 53.333 -JOB = [ +# Longest stepless run allowed to carry FIRE, in machine ticks. The +# slowest legitimate between-step interval in these jobs is the first +# step of an accel-from-rest: sqrt(2 * (1/53.333 mm) / 700 mm/s^2) +# = 7.3 ms = ~206 ticks at 28160 Hz. 500 gives >2x margin while staying +# far below any idle-gap pad run. +FIRE_GAP_LIMIT_TICKS = 500 + +WAIT_IDLE = ("wait_idle",) + +# Session A: the original M4 dynamic-power job (rules 1-6). +JOB_M4 = [ "M4 S0", "G1 X5 F600 S500", "G1 X10 S1000", @@ -38,6 +58,29 @@ JOB = [ "M5", ] +# Session B: M3 constant power to the end of the stream. The core never +# issues a laser-off update for M3, so the stream engine itself must +# terminate the cycle dark (rule 7). +JOB_M3_TERM = [ + "M3 S1000", + "G1 X5 F600", + WAIT_IDLE, + ("sleep", 1.0), + "M5", +] + +# Session C: rapid cycle churn - many tiny laser moves sent one at a +# time with small gaps, so cycles stop and restart the way a planner +# starve produces them (rules 8-9). +JOB_CHURN = [] +for _ in range(30): + JOB_CHURN.append("G1 X0.2 F600 S800") + JOB_CHURN.append(("sleep", 0.02)) + JOB_CHURN.append("G1 X0 S800") + JOB_CHURN.append(("sleep", 0.02)) +JOB_CHURN.insert(0, "M4 S0") +JOB_CHURN.append("M5") + def fail(msg): print("FAIL: %s" % msg) @@ -76,12 +119,43 @@ def read_avail(sock, log, timeout, until=None): return None -def main(): +def wait_idle(sock, log): + for _ in range(100): + sock.sendall(b"?") + read_avail(sock, log, 0.3) + if re.search(r"= FIRE_GAP_LIMIT_TICKS: + fail("[%s] FIRE carried across a %d-tick zero-step gap " + "ending at tick %d (stationary dwell burn)" + % (name, run, tick)) + else: + run = 0 + return worst + + +def check_termination(name, data): + """Rule 7: the stream's final tick byte must carry FIRE clear.""" + ticks = tick_bytes(data) + if not ticks: + fail("[%s] no tick bytes in the stream" % name) + if ticks[-1] & 0x10: + fail("[%s] stream ends with FIRE set (0x%02x) - termination " + "rule violated, relies on the end-of-data backstop" + % (name, ticks[-1])) + + +def check_m4_job(data): + """Rules 1-6 on the original M4 job.""" if not data[0] & 0x80: fail("stream does not lead with a power byte (first byte 0x%02x)" % data[0]) @@ -184,10 +293,8 @@ def main(): last_fire = fire_ticks[-1][0] tail_steps = 0 tick = 0 - prev_power = False for b in data: if b & 0x80: - prev_power = True continue if tick > last_fire and b & 0x01: tail_steps += 1 @@ -195,11 +302,43 @@ def main(): if tail_steps < 400: fail("only %d fire-free steps after the last FIRE bit - G0 return not dark" % tail_steps) - print("PASS: %d bytes, %d power bytes, %d fire ticks, powers %s, " - "X peak %d steps net 0, %d dark return steps" + return fire_ticks, powers, x_max, tail_steps + + +def count_fire(data): + return sum(1 for b in tick_bytes(data) if b & 0x10) + + +def main(): + # --- session A: M4 dynamic power, rules 1-6 + 7-8 ------------------- + data = run_session("m4", JOB_M4) + fire_ticks, powers, x_max, tail_steps = check_m4_job(data) + check_termination("m4", data) + gap_a = check_fire_gaps("m4", data) + print("PASS [m4]: %d bytes, %d power bytes, %d fire ticks, powers %s, " + "X peak %d steps net 0, %d dark return steps, max fire gap %d" % (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks), - powers, x_max, tail_steps)) - shutil.rmtree(workdir, ignore_errors=True) + powers, x_max, tail_steps, gap_a)) + + # --- session B: M3 constant power to stream end, rule 7 ------------- + data = run_session("m3-term", JOB_M3_TERM) + if not count_fire(data): + fail("[m3-term] no FIRE bits in the stream") + check_termination("m3-term", data) + gap_b = check_fire_gaps("m3-term", data) + print("PASS [m3-term]: %d bytes, %d fire ticks end dark, max fire gap %d" + % (len(data), count_fire(data), gap_b)) + + # --- session C: cycle churn, rules 8-9 ------------------------------ + data = run_session("churn", JOB_CHURN) + if not count_fire(data): + fail("[churn] no FIRE bits in the stream") + check_termination("churn", data) + gap_c = check_fire_gaps("churn", data) + print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d" + % (len(data), count_fire(data), gap_c)) + + print("PASS: all stream emission rules hold") if __name__ == "__main__": diff --git a/scripts/bench/temp_calibrate.py b/scripts/bench/temp_calibrate.py index ed4c79a..9866d5f 100644 --- a/scripts/bench/temp_calibrate.py +++ b/scripts/bench/temp_calibrate.py @@ -25,7 +25,9 @@ import subprocess import sys import time -HOST = '172.16.1.97' +HOST = os.environ.get('GF_HOST') +if not HOST: + raise SystemExit('set GF_HOST to the machine IP address') STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json')