Skip to content

Acceptance run against the box, drip-feed, and FOCAS telemetry - #2

Merged
LucasSKrewer merged 7 commits into
mainfrom
acceptance-dripfeed-telemetry
Aug 17, 2026
Merged

Acceptance run against the box, drip-feed, and FOCAS telemetry#2
LucasSKrewer merged 7 commits into
mainfrom
acceptance-dripfeed-telemetry

Conversation

@LucasSKrewer

Copy link
Copy Markdown
Owner

Four things: one that makes the rest verifiable, one that automates the
suite, and two features that have never touched hardware and say so.

Acceptance run — hardware_check.py

The integrity work merged in #1 left three questions that only the firmware can
answer, and they have been open ever since:

  • Q1 does the box store bytes exactly as sent, or does its FatFs normalise
    line endings? VERIFY_UPLOAD depends on it — if it normalises, verification
    fires on perfectly good programs.
  • Q2 does a listing really end with the 0xFFFF terminator? list_dir()
    raises on silence, which is right only if the terminator is real.
  • Q3 does block accounting hold under real UDP traffic, including the
    512-byte boundary?

One command settles all three and writes a report.

Safety, since it gets run standing next to a machine: it never issues RunFile or
StopDnc, so nothing is started, stopped or sent to the CNC. It refuses to
overwrite an existing file, uses a canary name, cleans up after itself, and
--read-only writes nothing at all while still answering Q2.

CI

113 tests, no hardware. A second job imports every module on Linux with no Fanuc
DLL and no serial port — guarding the fix that stopped focas.py and
serial_adapter.py calling sys.exit() at import time, a regression that would
only surface on the Pi, in production.

Drip-feed

"Copy to memory" needs the program to fit in the control's memory. A mould or a
3D finishing path does not fit in 64 KB, and running those is the actual reason
shops buy the commercial boxes. This streams the program while the control
executes it.

The port lock is held for the whole job, so the listener cannot interleave reads
and eat the flow-control bytes. write_timeout is raised to 300 s for the
duration — a control can hold XOFF for minutes on a slow pass — and restored
afterwards, so a dead cable does not look like a hang on the next send.

⚠️ Never run against a machine yet. The machine is cutting while this
streams: if the feed stops it stops mid-cut, if the buffer overflows it
executes garbage. Flow control is the safety mechanism, not a nicety.

FOCAS telemetry

Read-only: run state, mode, motion, program numbers, the executing block, alarms
decoded from their bit mask, feed and spindle. Every reading is independent, so
a control lacking cnc_rdexecprog still reports its run state instead of
showing nothing.

⚠️ Never run against a control yet. The ctypes structure layouts are the
part no test can check: ODBST field order differs across control series, and
the wrong layout does not crash — it returns plausible nonsense.

Verification

113 tests, all off-hardware. The acceptance script is itself driven against a
stateful simulator of the box, including simulated firmware that normalises line
endings and firmware that goes quiet instead of terminating a listing — each
test asserting the diagnosis, not the exit code. Those tests caught main()
returning None on the success path, which made the exit code 0 even when
checks had failed.

Drip-feed is covered on the paths that matter when a job goes wrong: abort stops
the wire, reports how far it got, and restores the raised write timeout even on
abort.

Not validated

Drip-feed and telemetry have never touched hardware, by construction — the
acceptance script is the thing meant to change that for the DNC box, and there
is no equivalent for a cutting machine short of a scrap part and a hand on feed
hold.

The 113 tests need no hardware - the wire format is built by pure functions
and the transfer logic runs against a scripted fake device and a fake
serial port. That is exactly why it is worth automating: the golden byte
strings in tests/test_protocol.py are the only copy of the
reverse-engineered protocol outside the physical box, and nothing else
would catch a refactor that quietly changed them.

A second job imports every module on Linux with no Fanuc DLL and no serial
port present. That guards the fix that stopped focas.py and
serial_adapter.py calling sys.exit() at import time - the kind of
regression that only shows up on the Raspberry Pi, in production.

Evidence: matrix over Python 3.9 / 3.11 / 3.13; checked locally that the
code uses no 3.10+ syntax and that all seven modules import with neither
DLL nor serial port available.
Everything in this toolkit is tested against a fake device. That proves
the logic is self-consistent; it cannot prove the firmware agrees, and
three open questions have been sitting unanswered since the integrity
work went in:

  Q1 does the box store bytes exactly as sent, or does its FatFs
     normalise line endings? VERIFY_UPLOAD depends on the answer, and if
     it normalises, verification fires on perfectly good programs.
  Q2 does a listing really end with the 0xFFFF terminator? list_dir()
     raises on silence, which is correct only if the terminator is real.
  Q3 does block accounting hold under real UDP traffic, including the
     512-byte boundary where an empty final block is sent?

hardware_check.py settles all three in one run and writes a report.

Safety, since this gets run standing next to a machine: it never issues
RunFile or StopDnc, so nothing is started, stopped or sent to the CNC -
the box is treated purely as storage. It refuses to overwrite an existing
file, uses a canary name, cleans up after itself, and --read-only
performs no writes at all while still answering Q2.

Evidence: 8 tests drive the script against a stateful simulator of the
box, including simulated firmware that normalises line endings and
firmware that goes quiet instead of terminating a listing. Each asserts
the DIAGNOSIS, not the exit code - a run that fails without saying which
question it settled would be useless at the machine. Those tests also
caught main() returning None on the success path, which made the exit
code 0 even when checks had failed.
"Copy to memory" needs the whole program to fit in the control's memory.
A mould or a 3D finishing path does not fit in 64 KB, and running those is
the actual reason shops buy the commercial DNC boxes. Drip-feed streams
the program while the control executes it, so size stops being the limit.

The port lock is held for the entire job - minutes, not milliseconds.
That is deliberate: it is one wire and the machine is cutting from it, so
the listener must not interleave reads and eat the flow-control bytes.
Automatic receiving is paused until the job finishes.

write_timeout is raised to 300s for the duration and restored afterwards.
A control can legitimately hold XOFF for minutes during a slow finishing
pass, so the 15s default used elsewhere would abort a healthy job - but
leaving 300s in place afterwards would make a dead cable look like a hang
on the next ordinary send.

*** THIS HAS NEVER RUN AGAINST A MACHINE. *** The machine is cutting
while it streams: if the feed stops it stops mid-cut, and if the control's
buffer overflows it executes garbage. Flow control is not a nicety here,
it is the safety mechanism. The full warning, including how to try it for
the first time, is at the top of the drip-feed section in
serial_adapter.py.

Evidence: 9 tests covering the paths that decide what happens when a job
goes wrong - that an abort actually stops the wire, that it reports how
far it got, that the raised write timeout is restored even on abort, and
that a drip-feed is refused while a program is arriving.
Not validated: everything about the machine's side of it.
The README has claimed since the first commit that FOCAS gets telemetry
for free, because the same library that pushes a program also reports run
state, alarms, feedrate and spindle speed. This makes good on it: run
state, selected mode, motion, program numbers, the block being executed,
active alarms decoded from their bit mask, feed and spindle.

Strictly read-only. Nothing here writes a parameter, starts a cycle or
clears an alarm.

Every reading is taken independently and a failure costs only its own row.
Controls differ in which calls they support, and a panel that shows
nothing because this particular control lacks cnc_rdexecprog would be
worse than useless on a shop floor.

*** THIS HAS NEVER RUN AGAINST A CONTROL. *** The ctypes structure
layouts are the part no test can check: ODBST field order differs across
control series, and the wrong layout does not crash - it returns
plausible nonsense. If one field reads as garbage while its neighbours
look sane, the layout is the first suspect. Marked # CHECK? in the source.

Evidence: 20 tests on the parts that are checkable - alarm bit decoding
against the specification order, unknown status codes being surfaced
rather than silently passing as normal, error codes becoming exceptions,
and a snapshot surviving every single reading failing.
README gains the three new capabilities, a CI badge, and - for the two
that have never touched hardware - the warning next to the feature rather
than buried in a source file. The drip-feed section says plainly that the
machine is cutting while it streams and how to try it the first time.

run.bat grows entries for the telemetry watch and both acceptance modes,
read-only first, so the safe one is the easier one to pick.

Evidence: docs and menus only, no behaviour change - 113 tests still pass.
CI caught this on its first run: the test forced a logging failure with
"Z:\nope\cannot\write.csv", which is impossible to write on Windows but a
perfectly valid RELATIVE FILENAME on Linux. The write succeeded there, a
row was logged, and the assertion that the log stayed empty failed.

The test was wrong, not transfer_log. The property it means to check -
that a logging failure never aborts a transfer that already succeeded -
now gets an unwritable path that holds on any OS: a regular file put where
a directory would have to be, so opening a path beneath it raises
NotADirectoryError everywhere.

Evidence: 113 tests pass locally on Windows; the point of the change is
that they should now also pass on the Linux runner, which is what the
previous run disproved.
CI found both on its first real run; neither is a defect in the toolkit.

socket.timeout is not the builtin TimeoutError before Python 3.10. The
box simulator raised the builtin, which sails straight past the
`except socket.timeout` in _exchange, so under 3.9 the acceptance script
reported a bare "TimeoutError:" instead of its own DncTimeout and the
guidance about local UDP port 69. Production code was right; the fake was
wrong, and it was wrong in the direction that hides a real diagnostic.

The busy-line guard was tested by starting a listener thread, feeding it
half a program, and asserting on _receiving from the main thread. That
asserts on a state another thread is free to leave at any moment. It
passed locally every time and failed on a loaded runner, which is the
signature of a race in the test rather than in the code - and I could not
reproduce it here, so rather than stretch a timeout until it went quiet,
the race is gone: the listener publishing the flag and the guard honouring
the flag are two properties and now have two tests, neither of which
depends on catching another thread mid-stride.

Evidence: 114 tests pass locally, and the two formerly racy files were run
five times in a row with no variation. Whether the 3.9 fix holds is for
the runner to say - that is the whole point of having one.
@LucasSKrewer
LucasSKrewer merged commit ad647a7 into main Aug 17, 2026
4 checks passed
@LucasSKrewer
LucasSKrewer deleted the acceptance-dripfeed-telemetry branch August 17, 2026 00:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant