feat(e10): SIC e10 part marker client, pattern generator and eDNC simulator
Groundwork for replacing a Telesis pinstamp with a SIC Marking e10 while
keeping the existing eDNC/MarkTelesis operator workflow. MarkTelesis has a
hardcoded marker-type enum and eDNC is serial-only, so the plan is a
translator that impersonates a Telesis controller on a COM port. These are
the pieces that translator will be built from, all verified against a real
e10 (a P63, firmware "E10 P63 (20140924)").
scripts/e10-client.py
e10-V6 protocol client. Marking path and file transfer both use the BINARY
protocol: the text protocol uses space as its delimiter, so it cannot carry
a field cleared to a single space nor the space runs in real marked text,
and it has no checksum. Includes fault decoding against the manual's fig 1,
and a guard that refuses to overflow a variable's declared buffer.
scripts/make-e10-pattern.py
Builds marking files with DECLARED variables. The controller's own protocol
cannot create one: NEWFILE/INSERTTEXTLINE/SAVEFILE writes the reference but
leaves the variable count at zero, and SETVAR then fails. Template-based,
copying through byte-for-byte everything the format notes do not explain.
scripts/simulate-ednc.py
Parses a part program, substitutes tokens and emits the byte-exact frames
MarkTelesis would send, then translates them for the e10 and can drive one.
The wire format came from disassembling MarkTelesis.exe rather than from a
capture: frame builder at 0x401cf0, and the type letters at their call
sites. Note there is no station ID, unlike the Backup470 dialect.
scripts/fake-telesis.py
Answers as a Telesis controller so MarkTelesis will hold a conversation
long enough to be recorded, with switches to force NAK and timeout paths.
reference/e10/
52-file snapshot off the bench controller, used as the corpus for reversing
the MARK_V5.0 format and for testing without hardware. ConfigCtrl is
excluded and gitignored - it stores the supervisor password in cleartext.
GE part programs are excluded for the same reason.
Three hardware findings worth knowing:
- The controller TRUNCATES SILENTLY to a variable's declared buffer size
while SETVAR still answers ok, and GETVAR echoes the full value back so
it cannot detect this. Guarded client-side against the size in the file.
- It refuses to mark when a referenced variable is unset, which makes an
unset field fail loudly rather than marking the previous part's data.
- Setting variables does not modify the stored file, so backups will not
churn on every marked part.
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -118,3 +118,11 @@ playbook/shopfloor-setup/gea-shopfloor-partmarker/PartMarker/Mark/utilpassword.t
|
||||
# HeatTreat per-machine DNC .reg exports (6601-6604) - contain DNC FtpPasswd
|
||||
# credentials. Deployed via the enrollment share from the working tree.
|
||||
playbook/shopfloor-setup/gea-shopfloor-heattreat/reg/*.reg
|
||||
|
||||
# SIC e10 controller config files carry the programmer/supervisor password
|
||||
# in cleartext. Never commit them. See reference/e10/README.md
|
||||
reference/e10/**/ConfigCtrl*
|
||||
reference/e10/**/*.kind1
|
||||
# GE part programs carry part numbers and marking content. Keep them out of
|
||||
# git; they live in ~/pxe-images/e10-testing/partprograms/
|
||||
reference/e10/partprograms/
|
||||
|
||||
31
reference/e10/README.md
Normal file
31
reference/e10/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# SIC e10 reference snapshot
|
||||
|
||||
Pulled off the bench controller (a P63, firmware `E10 P63 (20140924)`) on
|
||||
2026-08-27 with `scripts/e10-client.py backup`. 52 files: marking files,
|
||||
scribble logos, and the controller's own config files.
|
||||
|
||||
Used as the reference corpus for reversing the `MARK_V5.0` marking file format
|
||||
and for testing the e10 client without hardware attached.
|
||||
|
||||
## Deliberately missing: ConfigCtrl
|
||||
|
||||
`ConfigCtrl` is the controller configuration file. **It stores the programmer
|
||||
and supervisor password in cleartext**, so it is not in this repo and must
|
||||
never be committed. It is kept outside git at
|
||||
`~/pxe-images/e10-ConfigCtrl-2026-08-27.kind1`, mode 0600, following the same
|
||||
convention as the other site-sensitive artefacts (ADR-015).
|
||||
|
||||
`.gitignore` blocks `ConfigCtrl*` and `*.kind1` under this directory so a
|
||||
future snapshot cannot reintroduce it by accident.
|
||||
|
||||
This has a direct consequence for the backup design: **any e10 snapshot that
|
||||
includes the controller config contains a live credential.** Whatever ACL
|
||||
protects the partmarker backup tree on the share has to account for that, and
|
||||
the ShopDB asset panel must never render the file's contents.
|
||||
|
||||
## Part programs
|
||||
|
||||
GE part programs are NOT in this repo - they carry part numbers and marking
|
||||
content. They live outside git at `~/pxe-images/e10-testing/partprograms/`,
|
||||
alongside the testing README. `.gitignore` blocks the path here so one cannot
|
||||
be added by accident.
|
||||
51
reference/e10/snapshot-2026-08-27.sha256
Normal file
51
reference/e10/snapshot-2026-08-27.sha256
Normal file
@@ -0,0 +1,51 @@
|
||||
70cce4c34a91027ed94bbe8b756598eee8af17f2d002f3460bd90d990fb436b4 AROBAS.scribblelogo
|
||||
10e2f1aa3071ca2d84a5349166ed3c660b8ff008eb944ef92cfc5274f005599e C151TEST.marking
|
||||
d3221a50bcaca36382797b7f6e8f8e8faa50324e37a9e3b4505b3642a048493d C151ZATEST.marking
|
||||
e091265e92eb2b6f205cf9df04eac49197e6af32c87b8c18142a56c2c7219b92 C153DTEST.marking
|
||||
64e0af0dc88768918c855ac79476eafd4f847a6457c3cc887a67965ccf1dc207 C153TEST.marking
|
||||
1e6e804d24110ae1e6737a02019ffa4b2fd5a439dbf63197e81761ff279a0042 C153ZA_TEST.marking
|
||||
3b11990968ddefa701de6fdcd3b2b6c444628aa131b883c800b6cb23cdde63de C303TEST.marking
|
||||
2e838035bea993f4b41ed3aef294492745d5d7ac09b46a40eca2b63e61f0e28b CE.scribblelogo
|
||||
477790ff20bf8e19dfc61091af720be628cabc861062b3bfbae573cf7d2e9eb0 DIAMETR1.scribblelogo
|
||||
af6bb7fc933a0204ae2d13428abf1e690b84875c77a6b135be702eff33a69cb5 DOLLAR.scribblelogo
|
||||
414e0eef39661ce6974c332ff87fd1e33e32dca16d4c3fcb180844d3f712fe27 EURO.scribblelogo
|
||||
e0084b66cb4a03e2aba6cc7342eead6a91610ad915d66f9ae2c734f99b772ac7 File-Select.kind1
|
||||
2c04cfccfe998cec4f2174b5f4afb38f674606b1fea4de0cf27762e9d1811de9 FLECHE.scribblelogo
|
||||
e54393d2f36be9c038b435cd378bef7841bdebecd2f5377ae061179250a60b46 FolderDef.kind1
|
||||
0979584b27575fd47098af4c247ce3753ca230682bf81e1b4981c3473ea31da2 I111DTEST.marking
|
||||
466c52d43773f34953db31812f041ed5607f6a439018382e5a4c632378609e1d I111STEST.marking
|
||||
faf4e6c890038fa694efb0c5e71b90882ef963f77b88eb78cbca6b9e3913c9ed I113DTEST.marking
|
||||
5c023699db1bc23f1486fbe1bd9dbf47020be0745eabe3d6a0f39f01af0d24bb I113STEST.marking
|
||||
82c4eb3df4fa3c999ac852dd76ffb115e6e183e1266efbeb5e59711ad054efe3 I141_ATEST.marking
|
||||
ca605734925a2369ad27370a628afc8d78197dd2916059413066282278aa064b I141TEST.marking
|
||||
f60c08c64e67ee15da84e6af251f103b1851d30aa64b72ae685bd0a7d635091b I52TEST.marking
|
||||
cb5c02fad724be06f5ca7ec2e07684ca1c6e876dbe9fc93d8c5b2a4d027b76e5 I61HATEST.marking
|
||||
ab7e80458d29f40643c5f29b028998ee461449c2fb60654b8eb7d1db0dac66d9 I61STEST.marking
|
||||
a1a649f60076a12af8cebf7dddd21a6a50b4679e2263a0959880fe634ee19395 I81-ATEST.marking
|
||||
276157a44ad7b0d5b658c0207b66344f1b6c7747d600435150b4f08cb3c2319d I81TEST.marking
|
||||
a3958d8271b395431cbbd9b70fbf992bec29d4fdc50aed3a89352b94040ce3ce MIRE.scribblelogo
|
||||
51f32036eafdc34ee296c7af0f99497961a85c88e9490698782f80d0f0bbd5a4 NEW.marking
|
||||
517b1254b6c7636d25cee78c41d4164d523069db6477119c7b3934c9953a3092 NF.scribblelogo
|
||||
e9c628286a4a5e8dd21e40524081dc16f56e7d252eff4a0bc60885153d677836 P122_40TEST.marking
|
||||
87899b8fcd02a53ce2b7cece87b22339a326e0d0e3a4471d405413623bf9f8ce P122TEST.marking
|
||||
fa1dc4effb6af039c47665597183030bf77939e68f0e6ee7c21dce6b258ed249 P62TEST.marking
|
||||
ed5c962db3376f43ec5a0b0bdeb3d42df0d08671d86d199995dbd065ef27cd72 PHONE.scribblelogo
|
||||
7a8749129203e4729a3f40b63d98963ce658c5c523d701a20817cc243375a598 POWER.scribblelogo
|
||||
b280f55cefb8467f2e5bc72bf573ac1b12e2c37aaed7b65cc01994cfd596a9bb RECYCLAG.scribblelogo
|
||||
49bf169537af2b2d830331dd8d263e684bc6a831cfcaf2dadd7d3005c80507f2 ROUND.scribblelogo
|
||||
f7d6a767b43d8d990937edc87c64fe16e3df48a2a62bdd182d8ee0570dbb6b65 RROUND.scribblelogo
|
||||
b63008e8a99d098774db34e5eb759ee7ce2cb6935061aad4f4639ad1d3101214 RU.marking
|
||||
befeb2d21947be021bfa8bcbacfa1a1a9410cf07a1502425d16dae0efedad3b3 SAVE_ACC_1.kind7
|
||||
d9cb28a5ca4742d2a99b302cada595171d9aba83892973f6c09a00f5a04b9be8 SENSINTE1.scribblelogo
|
||||
90d36b9d0167d6343fb71fbc65750d4f162a31d394bf6fd15700481608b66b43 SIC.scribblelogo
|
||||
5223b4860a45e531c04ea68038e5654504ff07968cb31ed409879dafc3dcd3d6 SQUARE.scribblelogo
|
||||
a3ffe9466ef36622242e9b878cf3a5059428db3580fe623f99a62b0f0bff6252 STAR.scribblelogo
|
||||
7fe8657ba86d3d048d5b1ff529bde7dcd138946a8aed54284d51f959d8d7815c TENSION.scribblelogo
|
||||
c0061f304587e65597a6a685d009f5e36dae49aa662f5cbc2ed8bc8d5c13c124 TERRE.scribblelogo
|
||||
6dacf2f4ce950e9a29bdc525b43966fe05e00166d668a6108e48a89a1e4463c4 TEST_DB1.marking
|
||||
e510e74205dc57d106c0ab5484200a7a7ba8616c3d9580bc45500be00b04dc6f TEST_DB2.marking
|
||||
3aefdb0845861635ba9c9bbe73ebb020736786ff688267a1e048f281481d20a5 TEST_DB3.marking
|
||||
95a4056868e686d14766d74158ac781b91903df158fde5691f003d3aca0324be TEST.marking
|
||||
de6574fa8579b4fe1d2d9f627f9f203354eafd2a2facbf183c1656d2f2d053d2 TESTP120.marking
|
||||
f6a519fb92aff6efdd59caaded3728f38b878b83fa51ac37ca081b452a8d960c TEST_RU.marking
|
||||
73aa19ae9f2feef8b85dc5de2659b097b8580470be69c2afd758fe30b6def398 TRIANGLE.scribblelogo
|
||||
BIN
reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C151TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C151TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C151ZATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C151ZATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C153DTEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C153DTEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C153TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C153TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C303TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C303TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/CE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/CE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/EURO.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/EURO.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I111DTEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I111DTEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I111STEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I111STEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I113DTEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I113DTEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I113STEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I113STEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I141TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I141TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I141_ATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I141_ATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I52TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I52TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I61HATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I61HATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I61STEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I61STEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I81-ATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I81-ATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I81TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I81TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/MIRE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/MIRE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/NEW.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/NEW.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/NF.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/NF.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/P122TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/P122TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/P122_40TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/P122_40TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/P62TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/P62TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/PHONE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/PHONE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/POWER.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/POWER.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/RECYCLAG.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/RECYCLAG.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/ROUND.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/ROUND.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/RROUND.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/RROUND.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/RU.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/RU.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/SAVE_ACC_1.kind7
Normal file
BIN
reference/e10/snapshot-2026-08-27/SAVE_ACC_1.kind7
Normal file
Binary file not shown.
5
reference/e10/snapshot-2026-08-27/SENSINTE1.scribblelogo
Normal file
5
reference/e10/snapshot-2026-08-27/SENSINTE1.scribblelogo
Normal file
@@ -0,0 +1,5 @@
|
||||
зз##0>
|
||||
M^n~
|
||||
<EFBFBD><10>Ќ#Й0У>ЬMв^жnз~ж<>в<EFBFBD>ЬЌУЙЙУЌЬ<D08C>в<EFBFBD>ж~зnж^вMЬ>У0Й#Ќ<19><10>
|
||||
~n^M
|
||||
>0##н"ИЙ"нн
|
||||
BIN
reference/e10/snapshot-2026-08-27/SIC.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/SIC.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/SQUARE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/SQUARE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/STAR.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/STAR.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TENSION.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/TENSION.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TERRE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/TERRE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TESTP120.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/TESTP120.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TEST_DB1.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/TEST_DB1.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TEST_DB2.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/TEST_DB2.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TEST_DB3.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/TEST_DB3.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TEST_RU.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/TEST_RU.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/TRIANGLE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/TRIANGLE.scribblelogo
Normal file
Binary file not shown.
829
scripts/e10-client.py
Normal file
829
scripts/e10-client.py
Normal file
@@ -0,0 +1,829 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Talk to a SIC Marking e10-V6 marking controller over RS-232.
|
||||
|
||||
Written against NOTPROTOE10V6US-R08 (the e10-V6 communication protocol manual)
|
||||
and VERIFIED against a real controller on 2026-08-27 - a P63, firmware
|
||||
"E10 P63 (20140924)". Listing, reading and the machine query all work over the
|
||||
HOST port; a 52-file snapshot came off it in one command.
|
||||
|
||||
Two things the hardware taught us that the manual gets wrong:
|
||||
- File names are CASE-SENSITIVE and not always uppercase (see encode_name).
|
||||
- File kinds 1 and 7 exist and are readable, though the manual lists only
|
||||
0/2/4/5. Kind 1 is where the controller configuration lives, which is what
|
||||
makes a complete settings snapshot possible.
|
||||
|
||||
Still unverified against hardware: the marking path itself (LOAD FILE,
|
||||
FILE SET VAR, START MARKING). Those move a stylus, so they wait for a
|
||||
deliberate session rather than a probe.
|
||||
|
||||
WHY BINARY AND NOT TEXT
|
||||
The manual defines two protocols. The TEXT one is easier to read and is
|
||||
what SIC's own support quoted at us, but it cannot carry the values this
|
||||
integration needs:
|
||||
|
||||
1. Space is the TEXT protocol's delimiter - data is defined as "the data
|
||||
of the command ( without Space char)". The GE marker software blanks
|
||||
every field to a SINGLE SPACE before setting values ("TmcClrFlds: Put
|
||||
a SPACE in each field."). A value that IS a space is unrepresentable.
|
||||
2. Real GE part programs mark text containing runs of spaces, e.g.
|
||||
"S/N <SERIALNO> 4AM79". TEXT protocol handling of embedded and
|
||||
consecutive spaces is undefined by the manual.
|
||||
3. TEXT frames carry NO checksum of any kind. The Telesis protocol this
|
||||
replaces has a BCC on every frame in both directions.
|
||||
|
||||
BINARY FILE SET VAR ('7') carries the value as length-framed bytes, 1 to
|
||||
127 of them, so a lone space is legal and space runs survive. And the
|
||||
binary framing has an optional XOR checksum. Hence: binary for anything
|
||||
that reaches a part, text for bench poking only.
|
||||
|
||||
FRAMING (manual section I.3.a)
|
||||
without checksum: [STX][NUL][VER] <command>... [ETX]
|
||||
with checksum: [STX][VER] <command>... [ETX][XOR]
|
||||
|
||||
VER is '5' (0x35). The XOR covers every byte of the string INCLUDING STX
|
||||
and ETX. A NUL directly after STX is what disables checksum control, which
|
||||
is why every worked example in the manual starts 02 00 35.
|
||||
|
||||
command: [code][size hi][size lo][data] (size is big-endian)
|
||||
|
||||
The manual also defines a break-code command form for when you do not want
|
||||
to count the data. Not implemented - the length form is unambiguous and we
|
||||
always know our own lengths.
|
||||
|
||||
RESPONSES (manual section I.3.b)
|
||||
Whole-string errors arrive as one bare byte:
|
||||
BS checksum error HT bad string syntax NAK timeout
|
||||
Otherwise: [STX] <answer>... [ETX]
|
||||
where answer is [original code][size hi][size lo][data], and data is very
|
||||
often a single return code:
|
||||
ACK ok HT wrong data syntax
|
||||
BEL file not found LF variable not found
|
||||
|
||||
MARKING COMPLETION IS ASYNCHRONOUS AND TWO-STAGE (manual, START MARKING 'g')
|
||||
After the ACK for the start command:
|
||||
[EOT] last impact done
|
||||
[ENQ] head back at home position
|
||||
[NAK][status(3)] fault, and the error must then be reset
|
||||
DONE MEANS ENQ, NOT EOT. Calling it done at EOT reports a finished cycle
|
||||
while the head is still travelling, which at a bay means an operator
|
||||
reaching for a part that is still moving.
|
||||
|
||||
PAUSE LINES ARE NOT OURS TO ANSWER
|
||||
At a PAUSE line the controller sends 'P' and waits for either 'p' on the
|
||||
serial line or the physical Start button. This client NEVER sends 'p'. A
|
||||
pause exists so a human can reposition the part; answering it from software
|
||||
resumes marking on a part nobody has touched. wait_for_mark() surfaces the
|
||||
pause to the caller and keeps waiting.
|
||||
|
||||
DELETE IS DELIBERATELY ABSENT
|
||||
DEL_FILE ('D') exists and is documented. It is not implemented here, for
|
||||
the same reason Telesis TYPE 'R' was left out of TelesisMarker.psm1: a
|
||||
backup tool that can delete is a backup tool that can be made to delete.
|
||||
|
||||
LINK LAYER
|
||||
e10 HOST port is fixed at 9600-8-N-1 and cannot be reconfigured. Pins
|
||||
4/6/7/8 are NC, so there are no handshake lines and hardware flow control
|
||||
is impossible - the port is opened with flow control off and the manual's
|
||||
own backpressure rule applies instead: 500-byte controller buffer, do not
|
||||
push faster than it drains, 40000 bytes maximum per string.
|
||||
|
||||
Both ends are DTE (each calls pin 2 RX and pin 3 TX), so the cable must be
|
||||
a crossover: 3 to 2, 2 to 3, 5 to 5.
|
||||
|
||||
Stdlib only, termios - no pyserial on the bench box.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
# framing
|
||||
STX = 0x02
|
||||
ETX = 0x03
|
||||
VERSION = 0x35 # '5', protocol version byte
|
||||
|
||||
# return codes and whole-string errors
|
||||
EOT = 0x04 # last impact marked
|
||||
ENQ = 0x05 # back at home position
|
||||
ACK = 0x06 # success
|
||||
BEL = 0x07 # file not found
|
||||
BS = 0x08 # checksum error (whole string)
|
||||
HT = 0x09 # bad syntax
|
||||
LF_ = 0x0A # variable not found
|
||||
NAK = 0x15 # timeout, or fault during marking
|
||||
PAUSE_ASK = 0x50 # 'P', controller waiting at a PAUSE line
|
||||
PAUSE_ANSWER = 0x70 # 'p'. deliberately never sent. see module docstring.
|
||||
|
||||
# command codes, manual section I.3.c
|
||||
CMD_LOAD_FILE = 0x63 # 'c'
|
||||
CMD_FILE_SET_VAR = 0x37 # '7'
|
||||
CMD_START_MARKING = 0x67 # 'g'
|
||||
CMD_RESET_ERROR = 0x45 # 'E'
|
||||
CMD_FILE_LIST = 0x4C # 'L'
|
||||
CMD_PC_TO_E10 = 0x47 # 'G'
|
||||
CMD_E10_TO_PC = 0x53 # 'S'
|
||||
CMD_GET_MACHINE = 0x81
|
||||
|
||||
# file kinds, manual FILE LIST
|
||||
KIND_ALL = 0
|
||||
|
||||
# 1 and 7 are UNDOCUMENTED - the manual lists only 0/2/4/5. Found on a real
|
||||
# controller: kind 1 holds ConfigCtrl (the controller configuration),
|
||||
# File-Select (the digital-input file selection map) and FolderDef; kind 7 held
|
||||
# SAVE_ACC_1, which looks like accessory or axis data. They pull fine over
|
||||
# E10_TO_PC, which is what makes a complete settings snapshot possible at all.
|
||||
KIND_CONFIG = 1
|
||||
KIND_MARKING = 2
|
||||
KIND_DOT_LOGO = 4
|
||||
KIND_SCRIBBLE_LOGO = 5
|
||||
KIND_ACCESSORY = 7
|
||||
|
||||
KIND_NAMES = {
|
||||
KIND_CONFIG: "config",
|
||||
KIND_MARKING: "marking",
|
||||
KIND_DOT_LOGO: "dotlogo",
|
||||
KIND_SCRIBBLE_LOGO: "scribblelogo",
|
||||
KIND_ACCESSORY: "accessory",
|
||||
}
|
||||
|
||||
# Files whose contents carry the programmer/supervisor password in cleartext.
|
||||
# They ARE captured - a config snapshot is the only thing that restores a
|
||||
# controller as it was - but anything that renders a snapshot to a human should
|
||||
# check this first and show the name only.
|
||||
SENSITIVE_NAMES = ("ConfigCtrl",)
|
||||
|
||||
|
||||
def is_sensitive(name):
|
||||
"""True if this file's contents hold a credential."""
|
||||
return name in SENSITIVE_NAMES
|
||||
|
||||
# name field is 11 bytes plus one reserved NUL byte
|
||||
NAME_LEN = 11
|
||||
NAME_FIELD_LEN = 12
|
||||
LIST_ENTRY_LEN = 19 # name 11 + reserved 1 + kind 1 + size 2 + reserved 4
|
||||
|
||||
RETURN_CODE_TEXT = {
|
||||
ACK: "ok",
|
||||
HT: "wrong data syntax",
|
||||
BEL: "file not found",
|
||||
LF_: "variable not found",
|
||||
}
|
||||
|
||||
STRING_ERROR_TEXT = {
|
||||
BS: "checksum error",
|
||||
HT: "bad string syntax",
|
||||
NAK: "timeout",
|
||||
}
|
||||
|
||||
|
||||
# Marking fault bits, manual fig 1. The status is 3 bytes, most significant
|
||||
# first, and the bits combine - 00 30 00 is out-of-bounds on X (10 00 + 20 00).
|
||||
FAULT_BITS = [
|
||||
(0, 0x01, "marking font"),
|
||||
(0, 0x02, "dot logo"),
|
||||
(0, 0x04, "vectorial logo"),
|
||||
(0, 0x08, "Ecc200"),
|
||||
(0, 0x10, "text zone syntax"),
|
||||
(0, 0x20, "variable"),
|
||||
(0, 0x40, "I/O"),
|
||||
(0, 0x80, "RS232"),
|
||||
(1, 0x01, "stop button activated"),
|
||||
(1, 0x02, "stylus"),
|
||||
(1, 0x04, "motor"),
|
||||
(1, 0x08, "sensor"),
|
||||
(1, 0x10, "out of marking window bounds"),
|
||||
(1, 0x20, "X axis"),
|
||||
(1, 0x40, "Y axis"),
|
||||
(1, 0x80, "accessory axis"),
|
||||
(2, 0x01, "blocked feeder, or autosensing found no part"),
|
||||
(2, 0x02, "empty feeder, part out of bounds, or binary axis"),
|
||||
(2, 0x04, "marking head lost steps"),
|
||||
(2, 0x08, "external motor"),
|
||||
(2, 0x10, "history full"),
|
||||
(2, 0x20, "duplicate detected for history"),
|
||||
(2, 0x40, "stylus needs changing"),
|
||||
(2, 0x80, "stylus has to be changed"),
|
||||
]
|
||||
|
||||
|
||||
def decode_fault(status):
|
||||
"""Turn a 3-byte marking status into readable reasons."""
|
||||
# the wire order is most significant byte first, so index 2 is Err1
|
||||
ordered = [status[2], status[1], status[0]] if len(status) == 3 else []
|
||||
reasons = []
|
||||
for index, mask, text in FAULT_BITS:
|
||||
if index < len(ordered) and ordered[index] & mask:
|
||||
reasons.append(text)
|
||||
return reasons or ["unknown status %s" % status.hex()]
|
||||
|
||||
|
||||
class E10Error(Exception):
|
||||
"""Any protocol-level failure talking to the controller."""
|
||||
|
||||
|
||||
class E10Fault(E10Error):
|
||||
"""Controller reported a fault. Carries the raw 3-byte machine status."""
|
||||
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
self.reasons = decode_fault(status)
|
||||
super().__init__("marking fault [%s]: %s"
|
||||
% (status.hex(" "), "; ".join(self.reasons)))
|
||||
|
||||
|
||||
def encode_name(name):
|
||||
"""Pack a file name into the 12-byte name field the controller expects.
|
||||
|
||||
No spaces, 11 characters maximum, NUL padded.
|
||||
|
||||
CASE IS PRESERVED EXACTLY, and that matters. The manual says names are
|
||||
"in upper-case", but a real controller lists files named ConfigCtrl,
|
||||
File-Select and FolderDef - mixed case, and asking for CONFIGCTRL gets
|
||||
nothing back. So the manual describes a convention, not a constraint, and
|
||||
the only safe thing to send is whatever the listing gave us.
|
||||
"""
|
||||
if " " in name:
|
||||
raise E10Error("e10 file names cannot contain spaces: %r" % name)
|
||||
if len(name) > NAME_LEN:
|
||||
raise E10Error("e10 file name over %d chars: %r" % (NAME_LEN, name))
|
||||
return name.encode("ascii").ljust(NAME_FIELD_LEN, b"\x00")
|
||||
|
||||
|
||||
def decode_name(raw):
|
||||
"""Pull a file name back out of a 12-byte name field."""
|
||||
return raw[:NAME_FIELD_LEN].split(b"\x00", 1)[0].decode("ascii", "replace")
|
||||
|
||||
|
||||
class E10:
|
||||
"""One serial connection to an e10 controller.
|
||||
|
||||
Use as a context manager. Every method that reaches a part goes through the
|
||||
binary protocol; the text_* helpers exist for bench work only.
|
||||
"""
|
||||
|
||||
def __init__(self, device="/dev/ttyUSB0", timeout=2.0, checksum=False,
|
||||
debug=False):
|
||||
self.device = device
|
||||
self.timeout = timeout
|
||||
self.checksum = checksum
|
||||
self.debug = debug
|
||||
self.fd = None
|
||||
self._var_sizes = {}
|
||||
self._loaded = None
|
||||
|
||||
# ---------------------------------------------------------------- port
|
||||
|
||||
def open(self):
|
||||
self.fd = os.open(self.device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
attrs = termios.tcgetattr(self.fd)
|
||||
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs
|
||||
|
||||
# raw. no canonical mode, no echo, no signal chars, no translation.
|
||||
iflag = 0
|
||||
oflag = 0
|
||||
lflag = 0
|
||||
# 8N1, receiver on, ignore modem lines. CLOCAL matters: HOST port has
|
||||
# no handshake pins, so waiting on carrier would hang forever.
|
||||
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
||||
ispeed = ospeed = termios.B9600
|
||||
cc = list(cc)
|
||||
cc[termios.VMIN] = 0
|
||||
cc[termios.VTIME] = 0
|
||||
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW,
|
||||
[iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
|
||||
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
||||
# Let anything still in flight from a previous session arrive, then
|
||||
# throw it away. Without this the first transaction reads someone
|
||||
# else's tail.
|
||||
settle = time.monotonic() + 0.25
|
||||
while time.monotonic() < settle:
|
||||
try:
|
||||
if not os.read(self.fd, 256):
|
||||
time.sleep(0.01)
|
||||
except BlockingIOError:
|
||||
time.sleep(0.01)
|
||||
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
self.fd = None
|
||||
self._var_sizes = {}
|
||||
self._loaded = None
|
||||
|
||||
def __enter__(self):
|
||||
return self.open()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
def _log(self, direction, data):
|
||||
if self.debug:
|
||||
sys.stderr.write("%s %s\n" % (direction, data.hex(" ")))
|
||||
|
||||
def _flush_input(self):
|
||||
"""Drop anything sitting in the receive buffer before we transmit.
|
||||
|
||||
Line noise accumulates between transactions and would otherwise be
|
||||
read back as the front of the next reply.
|
||||
"""
|
||||
try:
|
||||
termios.tcflush(self.fd, termios.TCIFLUSH)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _write(self, data):
|
||||
self._flush_input()
|
||||
self._log("TX", data)
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
offset += os.write(self.fd, data[offset:])
|
||||
|
||||
def _read_byte(self, deadline):
|
||||
"""One byte, or None once the deadline passes."""
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(self.fd, 1)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
return chunk[0]
|
||||
if time.monotonic() >= deadline:
|
||||
return None
|
||||
time.sleep(0.002)
|
||||
|
||||
# ------------------------------------------------------------- framing
|
||||
|
||||
def _frame(self, commands):
|
||||
"""Wrap encoded commands in a protocol string.
|
||||
|
||||
commands is already-encoded bytes, one or more [code][size][data].
|
||||
"""
|
||||
if self.checksum:
|
||||
body = bytes([STX, VERSION]) + commands + bytes([ETX])
|
||||
xor = 0
|
||||
for byte in body:
|
||||
xor ^= byte
|
||||
return body + bytes([xor])
|
||||
# NUL straight after STX is what turns checksum control off
|
||||
return bytes([STX, 0x00, VERSION]) + commands + bytes([ETX])
|
||||
|
||||
@staticmethod
|
||||
def _command(code, data=b""):
|
||||
if len(data) > 0xFFFF:
|
||||
raise E10Error("command data too long: %d bytes" % len(data))
|
||||
return bytes([code, (len(data) >> 8) & 0xFF, len(data) & 0xFF]) + data
|
||||
|
||||
def _read_response(self, timeout=None):
|
||||
"""Read one response string and return {code: data}.
|
||||
|
||||
Raises E10Error on a bare whole-string error byte.
|
||||
"""
|
||||
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
|
||||
|
||||
# HUNT FOR THE FRAME START rather than demanding it immediately.
|
||||
# Two things put junk in front of a good reply on this link: framing
|
||||
# errors from line noise arrive as 0x00, and the tail of an earlier
|
||||
# reply (an abandoned transaction, a killed script) is still in flight.
|
||||
# Neither should fail a transaction whose real answer is right behind
|
||||
# it. A frame starts with STX; a whole-string rejection is one bare
|
||||
# error byte. Anything else in between is noise worth logging and
|
||||
# skipping.
|
||||
skipped = bytearray()
|
||||
while True:
|
||||
first = self._read_byte(deadline)
|
||||
if first is None:
|
||||
if skipped:
|
||||
raise E10Error(
|
||||
"no valid frame; saw only %s" % bytes(skipped).hex(" "))
|
||||
raise E10Error("no response from controller (timeout)")
|
||||
if first == STX:
|
||||
break
|
||||
if first in STRING_ERROR_TEXT:
|
||||
raise E10Error("controller rejected the string: %s"
|
||||
% STRING_ERROR_TEXT[first])
|
||||
skipped.append(first)
|
||||
if len(skipped) > 256:
|
||||
raise E10Error("no frame start in 256 bytes: %s"
|
||||
% bytes(skipped[:32]).hex(" "))
|
||||
if skipped:
|
||||
self._log("RX skipped before STX", bytes(skipped))
|
||||
|
||||
answers = {}
|
||||
while True:
|
||||
code = self._read_byte(deadline)
|
||||
if code is None:
|
||||
raise E10Error("truncated response, no ETX")
|
||||
if code == ETX:
|
||||
break
|
||||
hi = self._read_byte(deadline)
|
||||
lo = self._read_byte(deadline)
|
||||
if hi is None or lo is None:
|
||||
raise E10Error("truncated response reading answer size")
|
||||
size = (hi << 8) | lo
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
byte = self._read_byte(deadline)
|
||||
if byte is None:
|
||||
raise E10Error("truncated response reading answer data")
|
||||
data.append(byte)
|
||||
answers[code] = bytes(data)
|
||||
|
||||
self._log("RX", b"".join(
|
||||
bytes([c]) + d for c, d in answers.items()))
|
||||
return answers
|
||||
|
||||
def _single(self, code, data=b"", timeout=None):
|
||||
"""Send one command, return its answer bytes."""
|
||||
self._write(self._frame(self._command(code, data)))
|
||||
answers = self._read_response(timeout=timeout)
|
||||
if code not in answers:
|
||||
raise E10Error("no answer for command 0x%02X" % code)
|
||||
return answers[code]
|
||||
|
||||
@staticmethod
|
||||
def _check_return_code(answer, what):
|
||||
if len(answer) != 1:
|
||||
raise E10Error("%s: expected a return code, got %d bytes"
|
||||
% (what, len(answer)))
|
||||
if answer[0] == ACK:
|
||||
return
|
||||
raise E10Error("%s: %s" % (
|
||||
what, RETURN_CODE_TEXT.get(answer[0], "code 0x%02X" % answer[0])))
|
||||
|
||||
# ------------------------------------------------------- marking path
|
||||
|
||||
def load_file(self, name, check_capacity=True):
|
||||
"""LOAD FILE 'c'. Select a marking file on the controller."""
|
||||
upper = name.upper()
|
||||
if " " in upper:
|
||||
raise E10Error("e10 file names cannot contain spaces: %r" % name)
|
||||
if not 1 <= len(upper) <= NAME_LEN:
|
||||
raise E10Error("e10 file name must be 1 to %d chars: %r"
|
||||
% (NAME_LEN, name))
|
||||
self._check_return_code(
|
||||
self._single(CMD_LOAD_FILE, upper.encode("ascii")),
|
||||
"load_file(%s)" % upper)
|
||||
self._loaded = name
|
||||
# pull the declared buffer sizes so set_var can refuse to overflow one
|
||||
if check_capacity:
|
||||
try:
|
||||
self._var_sizes = self.variable_capacities(name)
|
||||
except E10Error:
|
||||
self._var_sizes = {}
|
||||
|
||||
def set_var(self, var, value):
|
||||
"""FILE SET VAR '7'. Set one variable in the loaded file.
|
||||
|
||||
value is bytes or str. Spaces are fine - this is the whole reason the
|
||||
marking path is binary rather than text. A single space is the correct
|
||||
way to blank a field, matching what the Telesis side does.
|
||||
"""
|
||||
name = var.upper()
|
||||
if " " in name:
|
||||
raise E10Error("e10 variable names cannot contain spaces: %r" % var)
|
||||
if not 1 <= len(name) <= 20:
|
||||
raise E10Error("e10 variable name must be 1 to 20 chars: %r" % var)
|
||||
payload = value.encode("ascii") if isinstance(value, str) else bytes(value)
|
||||
if not 1 <= len(payload) <= 127:
|
||||
raise E10Error("e10 variable value must be 1 to 127 bytes, got %d"
|
||||
% len(payload))
|
||||
self._check_capacity(name, payload)
|
||||
data = name.encode("ascii") + b"=" + payload
|
||||
self._check_return_code(self._single(CMD_FILE_SET_VAR, data),
|
||||
"set_var(%s)" % name)
|
||||
|
||||
def _check_capacity(self, name, payload):
|
||||
"""Refuse to send more than the variable's declared buffer holds.
|
||||
|
||||
THE CONTROLLER TRUNCATES SILENTLY AT MARK TIME. Each variable is
|
||||
declared in the marking file with a value buffer size, and a longer
|
||||
value is cut to fit when the pattern renders - while SETVAR answers ok.
|
||||
Observed on hardware: a variable declared with a 10-byte buffer was
|
||||
given "07482 07482ABC" and the machine displayed "07482 0748".
|
||||
|
||||
GETVAR IS NO USE FOR DETECTING THIS. It echoes the full value back
|
||||
whatever the buffer size, so a read-back check passes while the mark
|
||||
would still come out short. The only honest check is against the size
|
||||
declared in the file, which is why load_file() reads it.
|
||||
|
||||
On a part marker this is the worst failure available: a truncated
|
||||
serial number engraved on a real part, reported as success.
|
||||
"""
|
||||
limit = self._var_sizes.get(name)
|
||||
if limit is None or len(payload) <= limit:
|
||||
return
|
||||
raise E10Error(
|
||||
"set_var(%s): value is %d bytes but the variable is declared with a"
|
||||
" %d-byte buffer in %s. The controller would truncate it silently"
|
||||
" and mark %r."
|
||||
% (name, len(payload), limit, self._loaded or "the loaded file",
|
||||
payload[:limit]))
|
||||
|
||||
def variable_capacities(self, name, kind=KIND_MARKING):
|
||||
"""Declared value buffer size per variable, read from the file itself.
|
||||
|
||||
Record layout: [name_len][value_size][class][00][name][value buffer].
|
||||
Records are located through the offset table at 0x0E; entry count is
|
||||
implied by the first offset. See make-e10-pattern.py for the full
|
||||
format notes.
|
||||
"""
|
||||
raw = self.read_file(name, kind=kind)
|
||||
if not raw.startswith(b"MARK_V5.0\x00"):
|
||||
return {}
|
||||
first = (raw[0x0E] << 8) | raw[0x0F]
|
||||
entries = (first - 0x0E) // 2
|
||||
if entries < 1:
|
||||
return {}
|
||||
offsets = [(raw[0x0E + 2 * i] << 8) | raw[0x0F + 2 * i]
|
||||
for i in range(entries)]
|
||||
sizes = {}
|
||||
for index, start in enumerate(offsets):
|
||||
end = offsets[index + 1] if index + 1 < len(offsets) else len(raw)
|
||||
if end <= start:
|
||||
end = len(raw)
|
||||
rec = raw[start:end]
|
||||
if (len(rec) >= 5 and rec[2] in (0x40, 0x50) and rec[3] == 0x00
|
||||
and len(rec) == 4 + rec[0] + rec[1]):
|
||||
sizes[rec[4:4 + rec[0]].decode("ascii", "replace")] = rec[1]
|
||||
return sizes
|
||||
|
||||
def clear_var(self, var):
|
||||
"""Blank a variable to a single space.
|
||||
|
||||
Mirrors the Telesis TmcClrFlds behaviour. Only reachable at all because
|
||||
the value is length-framed here.
|
||||
"""
|
||||
self.set_var(var, " ")
|
||||
|
||||
def start_marking(self, simulate=False):
|
||||
"""START MARKING 'g'. Returns once the controller has ACKed the start.
|
||||
|
||||
The cycle itself is asynchronous - call wait_for_mark() next.
|
||||
"""
|
||||
mode = b"\x01" if simulate else b"\x00"
|
||||
self._check_return_code(self._single(CMD_START_MARKING, mode),
|
||||
"start_marking")
|
||||
|
||||
def wait_for_mark(self, timeout=120.0, on_pause=None):
|
||||
"""Wait out a marking cycle. Returns only once the head is home.
|
||||
|
||||
Sequence is [EOT] last impact, then [ENQ] home. Anything that reports
|
||||
completion at EOT is lying about a head that is still moving.
|
||||
|
||||
A PAUSE line makes the controller send 'P' and wait. This NEVER answers
|
||||
it - on_pause (if given) is called so the caller can tell somebody, and
|
||||
the wait continues until a human presses Start.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
saw_eot = False
|
||||
while True:
|
||||
byte = self._read_byte(deadline)
|
||||
if byte is None:
|
||||
raise E10Error("timed out waiting for the cycle to finish"
|
||||
" (saw_eot=%s)" % saw_eot)
|
||||
if byte == EOT:
|
||||
saw_eot = True
|
||||
elif byte == ENQ:
|
||||
return # home. and only now is the cycle actually over.
|
||||
elif byte == NAK:
|
||||
status = bytearray()
|
||||
while len(status) < 3:
|
||||
part = self._read_byte(deadline)
|
||||
if part is None:
|
||||
raise E10Error("fault reported but status truncated")
|
||||
status.append(part)
|
||||
raise E10Fault(bytes(status))
|
||||
elif byte == PAUSE_ASK:
|
||||
if on_pause:
|
||||
on_pause()
|
||||
# deliberately no reply. the Start button is the only way on.
|
||||
else:
|
||||
self._log("RX unexpected", bytes([byte]))
|
||||
|
||||
def reset_error(self):
|
||||
"""RESET_ERREUR 'E'. Required after any fault before marking again."""
|
||||
self._check_return_code(self._single(CMD_RESET_ERROR), "reset_error")
|
||||
|
||||
# -------------------------------------------------------- backup path
|
||||
|
||||
def list_files(self, name=None, kind=KIND_ALL):
|
||||
"""FILE LIST 'L'. Returns [{name, kind, size}].
|
||||
|
||||
name None lists everything of the given kind.
|
||||
"""
|
||||
field = (encode_name(name) if name
|
||||
else b"\x00" * NAME_FIELD_LEN)
|
||||
answer = self._single(CMD_FILE_LIST, field + bytes([kind]))
|
||||
if len(answer) % LIST_ENTRY_LEN:
|
||||
raise E10Error("file list not a whole number of %d-byte entries"
|
||||
" (%d bytes)" % (LIST_ENTRY_LEN, len(answer)))
|
||||
files = []
|
||||
for offset in range(0, len(answer), LIST_ENTRY_LEN):
|
||||
entry = answer[offset:offset + LIST_ENTRY_LEN]
|
||||
files.append({
|
||||
"name": decode_name(entry),
|
||||
"kind": entry[12],
|
||||
"size": (entry[13] << 8) | entry[14],
|
||||
})
|
||||
return files
|
||||
|
||||
def read_file(self, name, kind=KIND_MARKING):
|
||||
"""E10_TO_PC 'S'. Pull one file whole. No block dance, unlike Telesis."""
|
||||
answer = self._single(CMD_E10_TO_PC, encode_name(name) + bytes([kind]),
|
||||
timeout=max(self.timeout, 10.0))
|
||||
if len(answer) <= NAME_FIELD_LEN + 1:
|
||||
raise E10Error("read_file(%s): controller returned no contents"
|
||||
" (file not found?)" % name)
|
||||
return answer[NAME_FIELD_LEN + 1:]
|
||||
|
||||
def write_file(self, name, contents, kind=KIND_MARKING):
|
||||
"""PC_TO_E10 'G'. Push one file back.
|
||||
|
||||
Restore path. Nothing calls this on a schedule - it exists so a
|
||||
controller swap can be put back the way it was.
|
||||
"""
|
||||
data = encode_name(name) + bytes([kind]) + bytes(contents)
|
||||
self._check_return_code(
|
||||
self._single(CMD_PC_TO_E10, data, timeout=max(self.timeout, 30.0)),
|
||||
"write_file(%s)" % name)
|
||||
|
||||
def get_machine(self):
|
||||
"""GET_MACHINE (129). Raw machine configuration bytes."""
|
||||
return self._single(CMD_GET_MACHINE)
|
||||
|
||||
# ---------------------------------------------------------- text mode
|
||||
|
||||
def text_command(self, line, timeout=None):
|
||||
"""Send one TEXT protocol line and return the reply.
|
||||
|
||||
BENCH ONLY. Do not build a marking path on this - see the module
|
||||
docstring for the three reasons. Useful for GETVERSION as a first
|
||||
contact test, and for settling whether this firmware answers SETVAR or
|
||||
SETTEXTVAR (the manual uses both, in different chapters).
|
||||
"""
|
||||
self._write(line.encode("ascii") + b"\n")
|
||||
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
|
||||
out = bytearray()
|
||||
while True:
|
||||
byte = self._read_byte(deadline)
|
||||
if byte is None:
|
||||
if out:
|
||||
break
|
||||
raise E10Error("no response to text command %r" % line)
|
||||
if byte == 0x0A: # LF ends the reply
|
||||
break
|
||||
if byte != 0x0D:
|
||||
out.append(byte)
|
||||
reply = bytes(out).decode("ascii", "replace")
|
||||
self._log("RX text", bytes(out))
|
||||
return reply
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bench client for a SIC Marking e10 controller.")
|
||||
parser.add_argument("-d", "--device", default="/dev/ttyUSB0")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=2.0)
|
||||
parser.add_argument("--checksum", action="store_true",
|
||||
help="enable the XOR checksum form of the framing")
|
||||
parser.add_argument("-v", "--debug", action="store_true",
|
||||
help="hex dump every frame")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("version", help="GETVERSION over text. first contact test.")
|
||||
|
||||
p_text = sub.add_parser("text", help="send one raw text protocol line")
|
||||
p_text.add_argument("line")
|
||||
|
||||
p_list = sub.add_parser("list", help="list files on the controller")
|
||||
p_list.add_argument("--kind", type=int, default=KIND_ALL)
|
||||
|
||||
p_get = sub.add_parser("get", help="read a file off the controller")
|
||||
p_get.add_argument("name")
|
||||
p_get.add_argument("-o", "--out")
|
||||
p_get.add_argument("--kind", type=int, default=KIND_MARKING)
|
||||
|
||||
p_put = sub.add_parser("put", help="write a file to the controller")
|
||||
p_put.add_argument("name")
|
||||
p_put.add_argument("path")
|
||||
p_put.add_argument("--kind", type=int, default=KIND_MARKING)
|
||||
|
||||
p_load = sub.add_parser("load", help="select a marking file")
|
||||
p_load.add_argument("name")
|
||||
|
||||
p_set = sub.add_parser("setvar", help="set a variable in the loaded file")
|
||||
p_set.add_argument("var")
|
||||
p_set.add_argument("value")
|
||||
|
||||
p_mark = sub.add_parser("mark", help="run the loaded file")
|
||||
p_mark.add_argument("--simulate", action="store_true",
|
||||
help="force 0, stylus stays up")
|
||||
|
||||
sub.add_parser("reset", help="clear a controller fault")
|
||||
sub.add_parser("machine", help="dump machine configuration bytes")
|
||||
|
||||
p_backup = sub.add_parser(
|
||||
"backup", help="pull every marking file and logo into a directory")
|
||||
p_backup.add_argument("outdir")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with E10(args.device, timeout=args.timeout, checksum=args.checksum,
|
||||
debug=args.debug) as e10:
|
||||
if args.cmd == "version":
|
||||
print(e10.text_command("GETVERSION"))
|
||||
|
||||
elif args.cmd == "text":
|
||||
print(e10.text_command(args.line))
|
||||
|
||||
elif args.cmd == "list":
|
||||
for entry in e10.list_files(kind=args.kind):
|
||||
print("%-12s kind=%-14s %6d bytes" % (
|
||||
entry["name"],
|
||||
KIND_NAMES.get(entry["kind"], entry["kind"]),
|
||||
entry["size"]))
|
||||
|
||||
elif args.cmd == "get":
|
||||
blob = e10.read_file(args.name, kind=args.kind)
|
||||
if args.out:
|
||||
with open(args.out, "wb") as handle:
|
||||
handle.write(blob)
|
||||
print("%s -> %s (%d bytes)" % (args.name, args.out, len(blob)))
|
||||
else:
|
||||
sys.stdout.buffer.write(blob)
|
||||
|
||||
elif args.cmd == "put":
|
||||
with open(args.path, "rb") as handle:
|
||||
blob = handle.read()
|
||||
e10.write_file(args.name, blob, kind=args.kind)
|
||||
print("%s -> controller (%d bytes)" % (args.name, len(blob)))
|
||||
|
||||
elif args.cmd == "load":
|
||||
e10.load_file(args.name)
|
||||
print("loaded %s" % args.name.upper())
|
||||
|
||||
elif args.cmd == "setvar":
|
||||
e10.set_var(args.var, args.value)
|
||||
print("%s = %r" % (args.var.upper(), args.value))
|
||||
|
||||
elif args.cmd == "mark":
|
||||
e10.start_marking(simulate=args.simulate)
|
||||
print("started, waiting for the head to come home...")
|
||||
e10.wait_for_mark(on_pause=lambda: print(
|
||||
"PAUSE line reached. press Start on the machine."))
|
||||
print("cycle complete")
|
||||
|
||||
elif args.cmd == "reset":
|
||||
e10.reset_error()
|
||||
print("fault cleared")
|
||||
|
||||
elif args.cmd == "machine":
|
||||
print(e10.get_machine().hex(" "))
|
||||
|
||||
elif args.cmd == "backup":
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
count = 0
|
||||
failed = []
|
||||
for entry in e10.list_files():
|
||||
suffix = KIND_NAMES.get(entry["kind"], "kind%s" % entry["kind"])
|
||||
# one unreadable file must not abandon the whole snapshot
|
||||
try:
|
||||
blob = e10.read_file(entry["name"], kind=entry["kind"])
|
||||
except E10Error as err:
|
||||
failed.append((entry["name"], str(err)))
|
||||
print("%-12s %6s FAILED: %s"
|
||||
% (entry["name"], "", err))
|
||||
continue
|
||||
path = os.path.join(
|
||||
args.outdir, "%s.%s" % (entry["name"], suffix))
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(blob)
|
||||
print("%-12s %6d bytes -> %s%s" % (
|
||||
entry["name"], len(blob), path,
|
||||
" [CONTAINS CREDENTIAL]" if is_sensitive(entry["name"])
|
||||
else ""))
|
||||
count += 1
|
||||
print("\n%d files captured, %d failed" % (count, len(failed)))
|
||||
for name, err in failed:
|
||||
print(" FAILED %-12s %s" % (name, err))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except E10Fault as fault:
|
||||
sys.stderr.write("controller fault: status %s\n"
|
||||
"run 'reset' before marking again\n"
|
||||
% fault.status.hex(" "))
|
||||
sys.exit(2)
|
||||
except E10Error as err:
|
||||
sys.stderr.write("e10: %s\n" % err)
|
||||
sys.exit(1)
|
||||
260
scripts/fake-telesis.py
Normal file
260
scripts/fake-telesis.py
Normal file
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pretend to be a Telesis marking controller, so MarkTelesis will talk to us.
|
||||
|
||||
WHY THIS EXISTS
|
||||
We need to know exactly what GE's MarkTelesis.exe puts on the wire before a
|
||||
translator can impersonate a Telesis controller in front of it. A passive
|
||||
tap is useless here: MarkTelesis waits for a reply to every command and
|
||||
gives up after the first timeout, so a listener that never answers captures
|
||||
one frame and nothing else.
|
||||
|
||||
So this ANSWERS. It decodes each frame, logs it, and sends back the ACK
|
||||
shape MarkTelesis is parsing for. That keeps the conversation going long
|
||||
enough to record a whole marking job.
|
||||
|
||||
It is also the first half of the real translator - the same decode, minus
|
||||
the part where the other side becomes a SIC e10.
|
||||
|
||||
THE PROTOCOL, from disassembly of MarkTelesis.exe (2026-08-27)
|
||||
request (host -> controller):
|
||||
EOT SOH <TYPE> STX <data> ETX <BCC:3 ascii digits> CR
|
||||
reply (controller -> host):
|
||||
SOH <TYPE> ACK STX <data> ETX <BCC:3 ascii digits> CR
|
||||
|
||||
BCC = (TYPE + sum of data bytes) & 0xFF, as three ASCII decimal digits.
|
||||
|
||||
NOTE THE MISSING STATION ID. Backup470 framed requests as
|
||||
EOT SOH <TYPE> <2-digit station> STX ... - MarkTelesis does NOT send those
|
||||
two digits. Same protocol family, different dialect. The frame builder at
|
||||
0x401cf0 pushes exactly EOT, SOH, TYPE, STX, data, ETX, BCC, CR and nothing
|
||||
else.
|
||||
|
||||
COMMAND TYPES, all confirmed at their call sites
|
||||
'P' pattern load, data is the pattern name
|
||||
'1'..'9' numbered line send - the 2Line/3Line/9Line dialect. The TYPE byte
|
||||
IS the line number: the loop at 0x4090b0 does `add bl, 0x31`.
|
||||
'V' set field, the TMC400 dialect. Matches Message Type"="V" in
|
||||
WJPRT.reg.
|
||||
'O' online, empty data
|
||||
'R' empty data, believed to be the mark/run trigger. INFERRED, not yet
|
||||
traced to a UI action - one of the things this capture settles.
|
||||
'D' pattern download, data is "<name>;<6-digit size>;128". Same write
|
||||
header as Backup470.
|
||||
|
||||
FORCING THE ERROR PATHS
|
||||
Happy-path cycles teach us almost nothing we cannot already read out of the
|
||||
binary. What we actually need is how MarkTelesis behaves when things go
|
||||
wrong, because the translator has to reproduce it:
|
||||
|
||||
--nak TYPE[,TYPE...] answer these types with NAK instead of ACK
|
||||
--drop TYPE[,TYPE...] do not answer these at all, to see the retry
|
||||
and timeout cadence
|
||||
--delay SECONDS stall before every reply
|
||||
|
||||
Run it once clean, then again with --nak P (pattern not found) and once
|
||||
with --drop R, and the resulting logs cover the cases that would otherwise
|
||||
only show up at a bay with an operator standing there.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
EOT = 0x04
|
||||
SOH = 0x01
|
||||
STX = 0x02
|
||||
ETX = 0x03
|
||||
ACK = 0x06
|
||||
NAK = 0x15
|
||||
CR = 0x0D
|
||||
|
||||
TYPE_MEANING = {
|
||||
"P": "pattern load",
|
||||
"V": "set field (TMC400 dialect)",
|
||||
"O": "online",
|
||||
"R": "run / mark (inferred)",
|
||||
"D": "pattern download",
|
||||
"I": "status request",
|
||||
}
|
||||
|
||||
|
||||
def describe(type_char):
|
||||
if type_char in TYPE_MEANING:
|
||||
return TYPE_MEANING[type_char]
|
||||
if type_char.isdigit() and type_char != "0":
|
||||
return "line %s text (2Line/3Line/9Line dialect)" % type_char
|
||||
return "unknown"
|
||||
|
||||
|
||||
def bcc(type_char, data):
|
||||
"""(TYPE + sum(data)) & 0xFF, as three ASCII decimal digits."""
|
||||
total = ord(type_char)
|
||||
for byte in data:
|
||||
total += byte
|
||||
return b"%03d" % (total & 0xFF)
|
||||
|
||||
|
||||
class Line:
|
||||
"""One serial endpoint, raw, no flow control."""
|
||||
|
||||
def __init__(self, device, baud=termios.B9600):
|
||||
self.fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
attrs = termios.tcgetattr(self.fd)
|
||||
cc = list(attrs[6])
|
||||
cc[termios.VMIN] = 0
|
||||
cc[termios.VTIME] = 0
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW,
|
||||
[0, 0,
|
||||
termios.CS8 | termios.CREAD | termios.CLOCAL,
|
||||
0, baud, baud, cc])
|
||||
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
||||
|
||||
def read_byte(self, timeout=None):
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(self.fd, 1)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
except OSError:
|
||||
return None
|
||||
if chunk:
|
||||
return chunk[0]
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
return None
|
||||
time.sleep(0.002)
|
||||
|
||||
def write(self, data):
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
offset += os.write(self.fd, data[offset:])
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Answer MarkTelesis as if we were a Telesis controller.")
|
||||
parser.add_argument("-d", "--device", default="/dev/pts/4",
|
||||
help="serial endpoint (the win11 VM's pty)")
|
||||
parser.add_argument("-l", "--log", help="also append the transcript here")
|
||||
parser.add_argument("--nak", default="",
|
||||
help="comma-separated TYPEs to answer with NAK")
|
||||
parser.add_argument("--drop", default="",
|
||||
help="comma-separated TYPEs to leave unanswered")
|
||||
parser.add_argument("--delay", type=float, default=0.0,
|
||||
help="seconds to stall before each reply")
|
||||
parser.add_argument("--status", default="0A;000",
|
||||
help="data to return for a Type I status request."
|
||||
" 0A;000 is a real TMC470 idle reply.")
|
||||
args = parser.parse_args()
|
||||
|
||||
nak_types = set(t.strip() for t in args.nak.split(",") if t.strip())
|
||||
drop_types = set(t.strip() for t in args.drop.split(",") if t.strip())
|
||||
|
||||
log_handle = open(args.log, "a") if args.log else None
|
||||
|
||||
def emit(text):
|
||||
stamp = time.strftime("%H:%M:%S")
|
||||
line = "%s %s" % (stamp, text)
|
||||
print(line, flush=True)
|
||||
if log_handle:
|
||||
log_handle.write(line + "\n")
|
||||
log_handle.flush()
|
||||
|
||||
line = Line(args.device)
|
||||
emit("listening on %s as a Telesis controller" % args.device)
|
||||
if nak_types:
|
||||
emit("will NAK: %s" % ", ".join(sorted(nak_types)))
|
||||
if drop_types:
|
||||
emit("will DROP (no reply): %s" % ", ".join(sorted(drop_types)))
|
||||
emit("waiting for MarkTelesis...")
|
||||
|
||||
frames = 0
|
||||
try:
|
||||
while True:
|
||||
# requests open with EOT. anything else is noise worth seeing.
|
||||
byte = line.read_byte(timeout=None)
|
||||
if byte is None:
|
||||
continue
|
||||
if byte != EOT:
|
||||
emit(" stray byte before EOT: 0x%02X" % byte)
|
||||
continue
|
||||
|
||||
soh = line.read_byte(timeout=2)
|
||||
if soh != SOH:
|
||||
emit(" expected SOH after EOT, got %s"
|
||||
% ("nothing" if soh is None else "0x%02X" % soh))
|
||||
continue
|
||||
|
||||
type_byte = line.read_byte(timeout=2)
|
||||
if type_byte is None:
|
||||
emit(" timed out waiting for TYPE")
|
||||
continue
|
||||
type_char = chr(type_byte)
|
||||
|
||||
stx = line.read_byte(timeout=2)
|
||||
if stx != STX:
|
||||
emit(" expected STX, got %s"
|
||||
% ("nothing" if stx is None else "0x%02X" % stx))
|
||||
continue
|
||||
|
||||
data = bytearray()
|
||||
while True:
|
||||
nxt = line.read_byte(timeout=2)
|
||||
if nxt is None:
|
||||
emit(" timed out reading data")
|
||||
break
|
||||
if nxt == ETX:
|
||||
break
|
||||
data.append(nxt)
|
||||
|
||||
checksum = bytearray()
|
||||
for _ in range(3):
|
||||
nxt = line.read_byte(timeout=2)
|
||||
if nxt is None:
|
||||
break
|
||||
checksum.append(nxt)
|
||||
line.read_byte(timeout=1) # trailing CR
|
||||
|
||||
frames += 1
|
||||
expected = bcc(type_char, data)
|
||||
ok = "ok" if bytes(checksum) == expected else (
|
||||
"MISMATCH, we computed %s" % expected.decode())
|
||||
emit("RX #%d TYPE '%s' (%s) data=%r bcc=%s [%s]"
|
||||
% (frames, type_char, describe(type_char), bytes(data),
|
||||
bytes(checksum).decode("ascii", "replace"), ok))
|
||||
|
||||
if type_char in drop_types:
|
||||
emit(" -> dropping, no reply (forcing a timeout)")
|
||||
continue
|
||||
|
||||
if args.delay:
|
||||
time.sleep(args.delay)
|
||||
|
||||
if type_char in nak_types:
|
||||
reply = bytes([SOH, ord(type_char), NAK, STX, ETX]) \
|
||||
+ bcc(type_char, b"") + bytes([CR])
|
||||
emit(" -> NAK")
|
||||
else:
|
||||
payload = b""
|
||||
if type_char == "I":
|
||||
payload = args.status.encode("ascii")
|
||||
reply = bytes([SOH, ord(type_char), ACK, STX]) + payload \
|
||||
+ bytes([ETX]) + bcc(type_char, payload) + bytes([CR])
|
||||
emit(" -> ACK%s" % (" data=%r" % payload if payload else ""))
|
||||
line.write(reply)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
emit("stopped after %d frames" % frames)
|
||||
finally:
|
||||
line.close()
|
||||
if log_handle:
|
||||
log_handle.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
279
scripts/make-e10-pattern.py
Normal file
279
scripts/make-e10-pattern.py
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build SIC e10 marking files with DECLARED variables.
|
||||
|
||||
WHY THIS EXISTS
|
||||
The e10's own protocol cannot create a variable. You can build a marking
|
||||
file over the wire with NEWFILE / INSERTTEXTLINE / SAVEFILE, and you can put
|
||||
"#(V, V01)" in a text line, but that is only a REFERENCE. The variable has
|
||||
to be DECLARED, and there is no INSERTVAR command. Proven the hard way: a
|
||||
file built entirely over the protocol came back with a variable count of
|
||||
zero, and SETVAR answered "variable not found".
|
||||
|
||||
Global variables are numbered 1-10, so #(V, NAME) cannot reach one either.
|
||||
|
||||
That left authoring dependent on SIC's GUI, which would have made migrating
|
||||
a fleet of Telesis patterns a hand operation. This does it from a template
|
||||
instead.
|
||||
|
||||
THE FILE FORMAT, reversed from 28 real files plus one we built over the wire
|
||||
|
||||
header
|
||||
0x00 "MARK_V5.0\\0"
|
||||
0x0A variable count <- the byte that matters. zero here means
|
||||
SETVAR will always fail.
|
||||
0x0B record count
|
||||
0x0C two bytes, purpose unknown, carried through unchanged
|
||||
0x0E offset table: 16-bit big-endian file offsets, one per record.
|
||||
The number of entries is implied - the first entry points at the
|
||||
first record, so entries = (first_offset - 0x0E) / 2.
|
||||
|
||||
variable declaration record
|
||||
[type][len-6][class][00][name, NUL-padded]
|
||||
type 0x02 and 0x04 both seen; class 0x50 ('P') and 0x40 ('@') both seen.
|
||||
Carried through unchanged except for the name, which is rewritten
|
||||
inside its fixed-width padded field so the record length never moves.
|
||||
|
||||
text line record
|
||||
[X(2)][Y(2)][Z(2)][W(2)][H(2)][2 bytes][textlen+13 (2)][2 bytes]
|
||||
[0x83][font name, 12 bytes NUL-padded][text]
|
||||
All coordinates are big-endian tenths of a mm. The field at offset 12
|
||||
is len(text) + 13 in every file checked, which is what makes rewriting
|
||||
the text safe.
|
||||
|
||||
trailer records
|
||||
Short fixed records at the end. Carried through untouched.
|
||||
|
||||
WHAT THIS DOES
|
||||
Takes a known-good file as a template, rewrites the variable names and the
|
||||
text lines, then rebuilds the offset table so the records still line up.
|
||||
Everything it does not understand is copied through byte for byte, which is
|
||||
the only honest way to edit a format nobody has documented.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
MAGIC = b"MARK_V5.0\x00"
|
||||
TEXT_HEADER_LEN = 29 # geometry + marker byte + 12-byte font field
|
||||
TEXT_LEN_OFFSET = 12 # where len(text)+13 lives inside a text record
|
||||
TEXT_LEN_BIAS = 13
|
||||
FONT_OFFSET = 17
|
||||
FONT_FIELD = 12
|
||||
NAME_OFFSET = 4 # where a variable's name starts in its record
|
||||
|
||||
|
||||
def be16(data, index):
|
||||
return (data[index] << 8) | data[index + 1]
|
||||
|
||||
|
||||
def pack_be16(value):
|
||||
return bytes([(value >> 8) & 0xFF, value & 0xFF])
|
||||
|
||||
|
||||
class Pattern:
|
||||
"""A parsed MARK_V5.0 file: a header, a list of records, nothing hidden."""
|
||||
|
||||
def __init__(self, raw):
|
||||
if not raw.startswith(MAGIC):
|
||||
raise ValueError("not a MARK_V5.0 file")
|
||||
self.raw = raw
|
||||
self.varcount = raw[0x0A]
|
||||
self.reccount = raw[0x0B]
|
||||
self.unknown = raw[0x0C:0x0E]
|
||||
|
||||
first = be16(raw, 0x0E)
|
||||
entries = (first - 0x0E) // 2
|
||||
if entries < 1:
|
||||
raise ValueError("implausible offset table")
|
||||
offsets = [be16(raw, 0x0E + 2 * i) for i in range(entries)]
|
||||
|
||||
self.records = []
|
||||
for index, start in enumerate(offsets):
|
||||
nxt = offsets[index + 1] if index + 1 < len(offsets) else len(raw)
|
||||
if nxt <= start:
|
||||
nxt = len(raw)
|
||||
self.records.append(bytearray(raw[start:nxt]))
|
||||
|
||||
# ------------------------------------------------------------ inspection
|
||||
|
||||
def is_variable(self, record):
|
||||
"""A declaration record, identified by its own length arithmetic.
|
||||
|
||||
Do NOT match on byte 0 being 0x02 or 0x04 - that is the name LENGTH,
|
||||
which changes the moment you rename anything, and matching on it makes
|
||||
a renamed variable invisible to the next edit.
|
||||
"""
|
||||
return (len(record) >= NAME_OFFSET + 1
|
||||
and record[2] in (0x40, 0x50)
|
||||
and record[3] == 0x00
|
||||
and len(record) == NAME_OFFSET + record[0] + record[1])
|
||||
|
||||
def is_text(self, record):
|
||||
return (len(record) > FONT_OFFSET
|
||||
and record[FONT_OFFSET - 1] == 0x83)
|
||||
|
||||
def variable_names(self):
|
||||
out = []
|
||||
for rec in self.records:
|
||||
if self.is_variable(rec):
|
||||
out.append(bytes(rec[NAME_OFFSET:]).split(b"\x00")[0]
|
||||
.decode("ascii", "replace"))
|
||||
return out
|
||||
|
||||
def variable_sizes(self):
|
||||
"""Value buffer size per variable - the silent truncation limit."""
|
||||
return [r[1] for r in self.records if self.is_variable(r)]
|
||||
|
||||
def texts(self):
|
||||
out = []
|
||||
for rec in self.records:
|
||||
if self.is_text(rec):
|
||||
out.append(bytes(rec[TEXT_HEADER_LEN:])
|
||||
.decode("ascii", "replace"))
|
||||
return out
|
||||
|
||||
# -------------------------------------------------------------- editing
|
||||
|
||||
def rename_variable(self, index, new_name, value_size=None):
|
||||
"""Rewrite a declaration's name.
|
||||
|
||||
THE RECORD LAYOUT, and the two length bytes both matter:
|
||||
|
||||
[name_len][value_size][class][00][name][value buffer]
|
||||
|
||||
SN 02 0a 50 00 "SN" 16 = 4 + 2 + 10
|
||||
PN 02 0e 50 00 "PN" 20 = 4 + 2 + 14
|
||||
OF 02 0f 40 00 "OF" 21 = 4 + 2 + 15
|
||||
TEMP 04 02 40 00 "TEMP" 10 = 4 + 4 + 2
|
||||
|
||||
Byte 0 is the length of the NAME. Byte 1 is the size of the VALUE
|
||||
buffer that follows it, which is the longest string SETVAR can put in
|
||||
this variable.
|
||||
|
||||
Both were found the hard way. Writing a three-character name while byte
|
||||
0 still said 2 produced a file that pushed cleanly, read back
|
||||
byte-identical and loaded without complaint - and then SETVAR answered
|
||||
"variable not found", because the controller had parsed a name of the
|
||||
wrong length. A silent, entirely plausible-looking failure.
|
||||
"""
|
||||
found = [i for i, r in enumerate(self.records) if self.is_variable(r)]
|
||||
if index >= len(found):
|
||||
raise ValueError("no variable %d in this template" % index)
|
||||
position = found[index]
|
||||
rec = self.records[position]
|
||||
encoded = new_name.encode("ascii")
|
||||
if not 1 <= len(encoded) <= 255:
|
||||
raise ValueError("implausible variable name %r" % new_name)
|
||||
|
||||
size = rec[1] if value_size is None else value_size
|
||||
rebuilt = bytearray(rec[:NAME_OFFSET])
|
||||
rebuilt[0] = len(encoded)
|
||||
rebuilt[1] = size
|
||||
rebuilt += encoded
|
||||
rebuilt += b"\x00" * size
|
||||
self.records[position] = rebuilt
|
||||
|
||||
def set_text(self, index, text):
|
||||
"""Replace a text line's content and fix its internal length field."""
|
||||
found = [i for i, r in enumerate(self.records) if self.is_text(r)]
|
||||
if index >= len(found):
|
||||
raise ValueError("no text line %d in this template" % index)
|
||||
rec = self.records[found[index]]
|
||||
encoded = text.encode("ascii")
|
||||
rec[TEXT_HEADER_LEN:] = encoded
|
||||
rec[TEXT_LEN_OFFSET:TEXT_LEN_OFFSET + 2] = pack_be16(
|
||||
len(encoded) + TEXT_LEN_BIAS)
|
||||
|
||||
def set_position(self, index, x=None, y=None):
|
||||
"""Move a text line. Coordinates are tenths of a mm."""
|
||||
found = [i for i, r in enumerate(self.records) if self.is_text(r)]
|
||||
rec = self.records[found[index]]
|
||||
if x is not None:
|
||||
rec[0:2] = pack_be16(x)
|
||||
if y is not None:
|
||||
rec[2:4] = pack_be16(y)
|
||||
|
||||
# ------------------------------------------------------------- assembly
|
||||
|
||||
def build(self):
|
||||
"""Reassemble, recomputing every offset from the record lengths."""
|
||||
entries = len(self.records)
|
||||
table_start = 0x0E
|
||||
body_start = table_start + 2 * entries
|
||||
|
||||
offsets, cursor = [], body_start
|
||||
for rec in self.records:
|
||||
offsets.append(cursor)
|
||||
cursor += len(rec)
|
||||
|
||||
out = bytearray(MAGIC)
|
||||
out.append(self.varcount)
|
||||
out.append(self.reccount)
|
||||
out += self.unknown
|
||||
for off in offsets:
|
||||
out += pack_be16(off)
|
||||
for rec in self.records:
|
||||
out += rec
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def describe(pattern, label):
|
||||
print("%s" % label)
|
||||
sizes = pattern.variable_sizes()
|
||||
print(" variables declared : %d %s"
|
||||
% (pattern.varcount,
|
||||
", ".join("%s(max %d)" % (n, s)
|
||||
for n, s in zip(pattern.variable_names(), sizes))
|
||||
or "-"))
|
||||
print(" records : %d" % len(pattern.records))
|
||||
for text in pattern.texts():
|
||||
print(" text line : %r" % text)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build an e10 marking file with declared variables.")
|
||||
parser.add_argument("--template", required=True,
|
||||
help="a known-good marking file with at least as many"
|
||||
" variables as you need. NEW.marking has 2.")
|
||||
parser.add_argument("--var", action="append", default=[],
|
||||
help="new name for the next variable. repeatable.")
|
||||
parser.add_argument("--var-size", type=int, default=127,
|
||||
help="value buffer size for each variable, 1-127."
|
||||
" THE CONTROLLER TRUNCATES SILENTLY to this and"
|
||||
" still answers SETVAR ok, so the default is the"
|
||||
" protocol maximum. Only lower it deliberately.")
|
||||
parser.add_argument("--line", action="append", default=[],
|
||||
help="text for the next line. repeatable."
|
||||
" Use #(V, NAME) to reference a variable.")
|
||||
parser.add_argument("-o", "--out", help="write the file here")
|
||||
parser.add_argument("--show", action="store_true",
|
||||
help="just describe the template and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
pattern = Pattern(open(args.template, "rb").read())
|
||||
describe(pattern, "TEMPLATE %s" % args.template)
|
||||
if args.show:
|
||||
return
|
||||
|
||||
if not 1 <= args.var_size <= 127:
|
||||
sys.exit("--var-size must be 1..127 (FILE SET VAR carries 1-127 bytes)")
|
||||
for index, name in enumerate(args.var):
|
||||
pattern.rename_variable(index, name, value_size=args.var_size)
|
||||
for index, text in enumerate(args.line):
|
||||
pattern.set_text(index, text)
|
||||
|
||||
built = Pattern(pattern.build())
|
||||
print()
|
||||
describe(built, "BUILT")
|
||||
|
||||
if args.out:
|
||||
with open(args.out, "wb") as handle:
|
||||
handle.write(pattern.build())
|
||||
print()
|
||||
print("wrote %s (%d bytes)" % (args.out, len(pattern.build())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
412
scripts/simulate-ednc.py
Normal file
412
scripts/simulate-ednc.py
Normal file
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Do what eDNC and MarkTelesis do, without eDNC or MarkTelesis.
|
||||
|
||||
WHAT THIS IS FOR
|
||||
The whole part-marker integration hinges on one question: what exactly does
|
||||
GE's MarkTelesis.exe put on the wire, and can a translator turn that into
|
||||
something a SIC e10 understands? Answering it by capture needs a Windows VM,
|
||||
an installed Mark 6.2.1, a real Telesis controller and a live eMX prescan.
|
||||
|
||||
We do not need any of that to make progress, because we already know all
|
||||
three pieces:
|
||||
|
||||
1. The part-program file format, from MarkFileSpec.doc.
|
||||
2. The token substitution rules, from the same document.
|
||||
3. The exact wire frame, from disassembling MarkTelesis.exe on
|
||||
2026-08-27 - the frame builder at 0x401cf0 and its callers.
|
||||
|
||||
So this reimplements that path. Feed it a real GE part program and the part
|
||||
number and serial that eDNC would have pulled from the router scan, and it
|
||||
emits the byte-exact Telesis frames MarkTelesis would have sent. Then it can
|
||||
translate those into e10 commands and, optionally, drive a real controller.
|
||||
|
||||
That makes the whole chain testable today, on a desk, with one e10 and no
|
||||
Windows anywhere.
|
||||
|
||||
PART PROGRAM FORMAT (MarkFileSpec.doc, first column is the command character)
|
||||
: file information, "name,snumb". optional.
|
||||
^ pattern name to select on the controller. optional.
|
||||
* menu item. items run in file order.
|
||||
@ operator instruction, max 78 chars.
|
||||
! comment. programmer only, never shown.
|
||||
$ prompt for operator input, max 60 chars.
|
||||
# one choice in a pick list, max 16 chars.
|
||||
/ command line, new-style: /NN/ addresses field NN (TMC100/TMC400).
|
||||
\\ command line, old-style: \\N\\ is line N (2Line/3Line/9Line).
|
||||
Backslash strips leading spaces, forward slash keeps them.
|
||||
|
||||
TOKENS
|
||||
<PARTNO> part number <SERIALNO> serial number
|
||||
<CHECKSUM> dash + checksum char <PN1>..<PN4> part number in 5/5/5/3 chunks
|
||||
<DIM> operator dimension <TEXT> operator text
|
||||
<RDIM1..3> reusable dimension - prompted once, reused after
|
||||
|
||||
Wilmington takes only the last 5 characters of <SERIALNO>. That is a site
|
||||
rule, not a format rule, so it lives behind --site.
|
||||
|
||||
THE WIRE FRAME (disassembled, not guessed)
|
||||
EOT SOH <TYPE> STX <data> ETX <BCC:3 ascii digits> CR
|
||||
BCC = (TYPE + sum of data bytes) & 0xFF
|
||||
|
||||
NO STATION ID. Backup470 sent EOT SOH <TYPE> <2 digits> STX; MarkTelesis
|
||||
does not. Same family, different dialect.
|
||||
|
||||
TYPE letters, each confirmed at its call site:
|
||||
'P' pattern load, data is the pattern name
|
||||
'1'..'9' line N text. The loop at 0x4090b0 does `add bl, 0x31`, so the
|
||||
TYPE byte IS the line number. This is the 2Line dialect, which
|
||||
is what WJPRT runs.
|
||||
'V' set field, TMC400 dialect, field number leads the data
|
||||
'O' online, empty data
|
||||
'R' empty data, believed to be the mark trigger
|
||||
'D' pattern download, "<name>;<6-digit size>;128"
|
||||
|
||||
TRANSLATION TO THE e10
|
||||
'P' <name> -> LOAD FILE 'c'
|
||||
'1'/'2'/... <text>-> FILE SET VAR '7' into V01 / V02 / ...
|
||||
'V' <n><text> -> FILE SET VAR '7' into V<n>
|
||||
'O' -> nothing on the e10. ACK it locally.
|
||||
'R' -> START MARKING 'g'
|
||||
|
||||
The e10 side is deliberately BINARY, never text: the text protocol uses
|
||||
space as a delimiter, and these values are full of spaces - look at line 2
|
||||
of the real GE program, which has four consecutive ones.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
EOT, SOH, STX, ETX, CR = 0x04, 0x01, 0x02, 0x03, 0x0D
|
||||
|
||||
|
||||
# --------------------------------------------------------------- part program
|
||||
|
||||
class Item:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.pattern = None
|
||||
self.instructions = []
|
||||
self.commands = [] # (field, template, keep_spaces)
|
||||
self.prompts = [] # prompt text in file order
|
||||
self.choices = {} # prompt index -> [choices]
|
||||
|
||||
|
||||
def parse_part_program(text):
|
||||
"""Parse a MarkEdit part program into items."""
|
||||
items = []
|
||||
general = []
|
||||
fileinfo = None
|
||||
current = None
|
||||
|
||||
for raw in text.splitlines():
|
||||
if not raw.strip():
|
||||
continue
|
||||
tag = raw[0]
|
||||
# NEVER strip a command line. The real GE program marks
|
||||
# "S/N <SERIALNO> 4AM79" - those four spaces are marked onto the
|
||||
# part, and stripping them silently changes what gets engraved.
|
||||
body = raw[1:] if tag in ("/", "\\") else raw[1:].strip()
|
||||
|
||||
if tag == ":":
|
||||
fileinfo = body
|
||||
elif tag == "!":
|
||||
continue # comment, programmer only
|
||||
elif tag == "*":
|
||||
current = Item(body)
|
||||
items.append(current)
|
||||
elif tag == "^":
|
||||
if current:
|
||||
current.pattern = body
|
||||
elif tag == "@":
|
||||
(current.instructions if current else general).append(body)
|
||||
elif tag == "$":
|
||||
if current:
|
||||
current.prompts.append(body)
|
||||
elif tag == "#":
|
||||
if current and current.prompts:
|
||||
current.choices.setdefault(
|
||||
len(current.prompts) - 1, []).append(body)
|
||||
elif tag in ("/", "\\"):
|
||||
# /NN/text or \N\text - the delimiter decides space handling
|
||||
m = re.match(r"([0-9]+)[/\\](.*)", body)
|
||||
if not m:
|
||||
continue
|
||||
field, template = int(m.group(1)), m.group(2)
|
||||
keep_spaces = (tag == "/")
|
||||
if not keep_spaces:
|
||||
template = template.lstrip(" ")
|
||||
if current:
|
||||
current.commands.append((field, template, keep_spaces))
|
||||
|
||||
return fileinfo, general, items
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- tokens
|
||||
|
||||
def serial_checksum(serial):
|
||||
"""Telesis serial checksum character.
|
||||
|
||||
MarkFileSpec describes <CHECKSUM> as a dash followed by the checksum
|
||||
character but never defines the algorithm, and the binary computes it in
|
||||
DNCdll. Until a real job's log shows one, this is clearly marked as
|
||||
unverified rather than quietly guessed at.
|
||||
"""
|
||||
if not serial:
|
||||
return ""
|
||||
total = sum(ord(c) for c in serial) % 36
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[total]
|
||||
|
||||
|
||||
def substitute(template, partno, serial, site, operator_inputs, reusable):
|
||||
"""Replace tokens the way MarkTelesis does, returning finished text."""
|
||||
out = template
|
||||
ser = serial[-5:] if site == "wilmington" else serial
|
||||
|
||||
out = out.replace("<PARTNO>", partno)
|
||||
out = out.replace("<SERIALNO>", ser)
|
||||
out = out.replace("<CHECKSUM>", "-" + serial_checksum(serial))
|
||||
out = out.replace("<PN1>", partno[0:5])
|
||||
out = out.replace("<PN2>", partno[5:10])
|
||||
out = out.replace("<PN3>", partno[10:15])
|
||||
out = out.replace("<PN4>", partno[15:18])
|
||||
|
||||
for name in ("RDIM1", "RDIM2", "RDIM3"):
|
||||
token = "<%s>" % name
|
||||
while token in out:
|
||||
if name not in reusable:
|
||||
reusable[name] = next_input(operator_inputs, name)
|
||||
out = out.replace(token, reusable[name], 1)
|
||||
|
||||
for name in ("DIM", "TEXT"):
|
||||
token = "<%s>" % name
|
||||
while token in out:
|
||||
out = out.replace(token, next_input(operator_inputs, name), 1)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def next_input(queue, label):
|
||||
"""Take the next operator-supplied value, or a visible placeholder."""
|
||||
if queue:
|
||||
return queue.pop(0)
|
||||
return "<%s?>" % label
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- frames
|
||||
|
||||
def bcc(type_char, data):
|
||||
total = ord(type_char) + sum(data)
|
||||
return b"%03d" % (total & 0xFF)
|
||||
|
||||
|
||||
def frame(type_char, data=b""):
|
||||
"""Build one MarkTelesis request frame, byte for byte."""
|
||||
if isinstance(data, str):
|
||||
data = data.encode("ascii")
|
||||
return (bytes([EOT, SOH, ord(type_char), STX]) + data
|
||||
+ bytes([ETX]) + bcc(type_char, data) + bytes([CR]))
|
||||
|
||||
|
||||
def build_sequence(item, partno, serial, site, operator_inputs,
|
||||
marker_type="2Line", clear_first=True):
|
||||
"""Everything MarkTelesis would send for one item, in order."""
|
||||
seq = []
|
||||
reusable = {}
|
||||
|
||||
seq.append(("online", frame("O")))
|
||||
|
||||
if item.pattern:
|
||||
seq.append(("load pattern %s" % item.pattern, frame("P", item.pattern)))
|
||||
|
||||
if clear_first:
|
||||
# TmcClrFlds - blank every field before setting values, so a field the
|
||||
# program does not set cannot mark the previous part's data.
|
||||
for field, _, _ in item.commands:
|
||||
if marker_type == "2Line":
|
||||
seq.append(("clear line %d" % field, frame(str(field), " ")))
|
||||
else:
|
||||
seq.append(("clear field %d" % field,
|
||||
frame("V", "%02d %s" % (field, " "))))
|
||||
|
||||
for field, template, _ in item.commands:
|
||||
text = substitute(template, partno, serial, site,
|
||||
operator_inputs, reusable)
|
||||
if marker_type == "2Line":
|
||||
# the TYPE byte IS the line number
|
||||
seq.append(("line %d = %r" % (field, text), frame(str(field), text)))
|
||||
else:
|
||||
seq.append(("field %d = %r" % (field, text),
|
||||
frame("V", "%02d%s" % (field, text))))
|
||||
|
||||
seq.append(("mark", frame("R")))
|
||||
return seq
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- translation
|
||||
|
||||
# Field number -> e10 variable name. Default is the V01/V02 convention we
|
||||
# intend for authored patterns; override when driving a file whose variables
|
||||
# were named by someone else's GUI.
|
||||
VAR_NAMES = {}
|
||||
|
||||
|
||||
def var_for(field):
|
||||
return VAR_NAMES.get(field, "V%02d" % field)
|
||||
|
||||
|
||||
def translate(type_char, data):
|
||||
"""Turn one Telesis frame into the e10 action it corresponds to.
|
||||
|
||||
Returns (action, args) where action is one of:
|
||||
load, setvar, mark, ack_locally
|
||||
"""
|
||||
text = data.decode("ascii", "replace")
|
||||
if type_char == "P":
|
||||
return ("load", text)
|
||||
if type_char == "O":
|
||||
return ("ack_locally", "online - the e10 has no equivalent")
|
||||
if type_char == "R":
|
||||
return ("mark", "")
|
||||
if type_char.isdigit():
|
||||
return ("setvar", (var_for(int(type_char)), text))
|
||||
if type_char == "V":
|
||||
return ("setvar", (var_for(int(text[:2])), text[2:]))
|
||||
return ("ack_locally", "unhandled type %r" % type_char)
|
||||
|
||||
|
||||
def decode_frame(raw):
|
||||
"""Pull (type, data) back out of a frame we built."""
|
||||
type_char = chr(raw[2])
|
||||
end = raw.index(ETX, 4)
|
||||
return type_char, raw[4:end]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- main
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Simulate what eDNC/MarkTelesis do with a part program.")
|
||||
parser.add_argument("partprogram")
|
||||
parser.add_argument("--partno", default="07482ABC",
|
||||
help="part number eDNC would get from the router scan")
|
||||
parser.add_argument("--serial", default="12345678",
|
||||
help="serial number from the router scan")
|
||||
parser.add_argument("--site", default="westjefferson",
|
||||
choices=["westjefferson", "wilmington"])
|
||||
parser.add_argument("--marker-type", default="2Line",
|
||||
choices=["2Line", "TMC400"],
|
||||
help="which dialect MarkTelesis would emit."
|
||||
" WJPRT runs 2Line.")
|
||||
parser.add_argument("--input", action="append", default=[],
|
||||
help="operator input, in prompt order. repeatable.")
|
||||
parser.add_argument("--no-clear", action="store_true",
|
||||
help="skip the clear-fields pass")
|
||||
parser.add_argument("--e10", metavar="DEVICE",
|
||||
help="also drive a real e10 on this serial device")
|
||||
parser.add_argument("--pattern",
|
||||
help="override the pattern name from the ^ line."
|
||||
" Useful when the e10 file is named differently"
|
||||
" from the Telesis pattern.")
|
||||
parser.add_argument("--var-names",
|
||||
help="comma-separated e10 variable names for fields"
|
||||
" 1,2,3... e.g. PN,SN. Default is V01,V02,...")
|
||||
parser.add_argument("--really-mark", action="store_true",
|
||||
help="fire the stylus for real. WITHOUT this, marking"
|
||||
" runs in simulation mode, force 0, stylus up.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.var_names:
|
||||
for index, name in enumerate(args.var_names.split(","), start=1):
|
||||
VAR_NAMES[index] = name.strip()
|
||||
|
||||
with open(args.partprogram) as handle:
|
||||
fileinfo, general, items = parse_part_program(handle.read())
|
||||
|
||||
print("=" * 72)
|
||||
print("PART PROGRAM: %s" % args.partprogram)
|
||||
if fileinfo:
|
||||
print(" file info %s" % fileinfo)
|
||||
print(" part number %s (from the router scan)" % args.partno)
|
||||
print(" serial %s" % args.serial)
|
||||
print(" marker type %s" % args.marker_type)
|
||||
for line in general:
|
||||
print(" instruction %s" % line)
|
||||
print("=" * 72)
|
||||
|
||||
for item in items:
|
||||
if args.pattern:
|
||||
item.pattern = args.pattern
|
||||
print("\nITEM %s" % item.name)
|
||||
if item.pattern:
|
||||
print(" pattern %s" % item.pattern)
|
||||
for line in item.instructions:
|
||||
print(" operator sees %s" % line)
|
||||
for field, template, keep in item.commands:
|
||||
print(" line %-2d %r%s"
|
||||
% (field, template, "" if keep else " (leading spaces stripped)"))
|
||||
|
||||
seq = build_sequence(item, args.partno, args.serial, args.site,
|
||||
list(args.input), args.marker_type,
|
||||
clear_first=not args.no_clear)
|
||||
|
||||
print("\n WHAT MARKTELESIS WOULD PUT ON THE WIRE")
|
||||
print(" %-28s %s" % ("meaning", "bytes"))
|
||||
print(" " + "-" * 68)
|
||||
for label, raw in seq:
|
||||
print(" %-28s %s" % (label, raw.hex(" ")))
|
||||
|
||||
print("\n TRANSLATED FOR THE e10")
|
||||
for label, raw in seq:
|
||||
type_char, data = decode_frame(raw)
|
||||
action, payload = translate(type_char, data)
|
||||
if action == "load":
|
||||
print(" %-28s LOAD FILE %r" % (label, payload))
|
||||
elif action == "setvar":
|
||||
print(" %-28s SETVAR %s = %r" % (label, payload[0], payload[1]))
|
||||
elif action == "mark":
|
||||
print(" %-28s START MARKING%s"
|
||||
% (label, "" if args.really_mark else " (SIMULATION)"))
|
||||
else:
|
||||
print(" %-28s [%s]" % (label, payload))
|
||||
|
||||
if args.e10:
|
||||
drive_e10(seq, args)
|
||||
|
||||
|
||||
def drive_e10(seq, args):
|
||||
"""Run the translated sequence against a real controller."""
|
||||
sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"e10", __file__.rsplit("/", 1)[0] + "/e10-client.py")
|
||||
e10mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(e10mod)
|
||||
|
||||
print("\n DRIVING THE REAL CONTROLLER on %s" % args.e10)
|
||||
with e10mod.E10(args.e10, timeout=5.0) as dev:
|
||||
for label, raw in seq:
|
||||
type_char, data = decode_frame(raw)
|
||||
action, payload = translate(type_char, data)
|
||||
try:
|
||||
if action == "load":
|
||||
dev.load_file(payload)
|
||||
print(" %-26s LOAD FILE ok" % label)
|
||||
elif action == "setvar":
|
||||
dev.set_var(payload[0], payload[1])
|
||||
print(" %-26s SETVAR ok" % label)
|
||||
elif action == "mark":
|
||||
dev.start_marking(simulate=not args.really_mark)
|
||||
print(" %-26s started, waiting for home..." % label)
|
||||
dev.wait_for_mark(on_pause=lambda: print(
|
||||
" PAUSE - press Start on the machine"))
|
||||
print(" %-26s cycle complete" % label)
|
||||
else:
|
||||
print(" %-26s (nothing to send)" % label)
|
||||
except e10mod.E10Error as err:
|
||||
print(" %-26s FAILED: %s" % (label, err))
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user