diff --git a/.gitignore b/.gitignore index a83c3ab..97ef37b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/reference/e10/README.md b/reference/e10/README.md new file mode 100644 index 0000000..0068d74 --- /dev/null +++ b/reference/e10/README.md @@ -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. diff --git a/reference/e10/snapshot-2026-08-27.sha256 b/reference/e10/snapshot-2026-08-27.sha256 new file mode 100644 index 0000000..1ec1207 --- /dev/null +++ b/reference/e10/snapshot-2026-08-27.sha256 @@ -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 diff --git a/reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo b/reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo new file mode 100644 index 0000000..904637f Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/C151TEST.marking b/reference/e10/snapshot-2026-08-27/C151TEST.marking new file mode 100644 index 0000000..73449c6 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/C151TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/C151ZATEST.marking b/reference/e10/snapshot-2026-08-27/C151ZATEST.marking new file mode 100644 index 0000000..01ac0f9 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/C151ZATEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/C153DTEST.marking b/reference/e10/snapshot-2026-08-27/C153DTEST.marking new file mode 100644 index 0000000..d16ba9c Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/C153DTEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/C153TEST.marking b/reference/e10/snapshot-2026-08-27/C153TEST.marking new file mode 100644 index 0000000..760c33f Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/C153TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking b/reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking new file mode 100644 index 0000000..9d48de8 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/C303TEST.marking b/reference/e10/snapshot-2026-08-27/C303TEST.marking new file mode 100644 index 0000000..957bafc Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/C303TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/CE.scribblelogo b/reference/e10/snapshot-2026-08-27/CE.scribblelogo new file mode 100644 index 0000000..50b7b2a Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/CE.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo b/reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo new file mode 100644 index 0000000..2875504 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo b/reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo new file mode 100644 index 0000000..25ac243 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/EURO.scribblelogo b/reference/e10/snapshot-2026-08-27/EURO.scribblelogo new file mode 100644 index 0000000..794ba60 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/EURO.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo b/reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo new file mode 100644 index 0000000..02654ee Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/I111DTEST.marking b/reference/e10/snapshot-2026-08-27/I111DTEST.marking new file mode 100644 index 0000000..3445cfa Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I111DTEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I111STEST.marking b/reference/e10/snapshot-2026-08-27/I111STEST.marking new file mode 100644 index 0000000..78a443a Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I111STEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I113DTEST.marking b/reference/e10/snapshot-2026-08-27/I113DTEST.marking new file mode 100644 index 0000000..63f8580 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I113DTEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I113STEST.marking b/reference/e10/snapshot-2026-08-27/I113STEST.marking new file mode 100644 index 0000000..d31c347 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I113STEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I141TEST.marking b/reference/e10/snapshot-2026-08-27/I141TEST.marking new file mode 100644 index 0000000..df55a7b Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I141TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I141_ATEST.marking b/reference/e10/snapshot-2026-08-27/I141_ATEST.marking new file mode 100644 index 0000000..2922df0 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I141_ATEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I52TEST.marking b/reference/e10/snapshot-2026-08-27/I52TEST.marking new file mode 100644 index 0000000..cd9f37f Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I52TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I61HATEST.marking b/reference/e10/snapshot-2026-08-27/I61HATEST.marking new file mode 100644 index 0000000..ebca1b5 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I61HATEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I61STEST.marking b/reference/e10/snapshot-2026-08-27/I61STEST.marking new file mode 100644 index 0000000..30b221a Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I61STEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I81-ATEST.marking b/reference/e10/snapshot-2026-08-27/I81-ATEST.marking new file mode 100644 index 0000000..a9685e0 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I81-ATEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/I81TEST.marking b/reference/e10/snapshot-2026-08-27/I81TEST.marking new file mode 100644 index 0000000..387014f Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/I81TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/MIRE.scribblelogo b/reference/e10/snapshot-2026-08-27/MIRE.scribblelogo new file mode 100644 index 0000000..8d47f73 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/MIRE.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/NEW.marking b/reference/e10/snapshot-2026-08-27/NEW.marking new file mode 100644 index 0000000..1a42e09 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/NEW.marking differ diff --git a/reference/e10/snapshot-2026-08-27/NF.scribblelogo b/reference/e10/snapshot-2026-08-27/NF.scribblelogo new file mode 100644 index 0000000..9759aa2 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/NF.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/P122TEST.marking b/reference/e10/snapshot-2026-08-27/P122TEST.marking new file mode 100644 index 0000000..36da5c4 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/P122TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/P122_40TEST.marking b/reference/e10/snapshot-2026-08-27/P122_40TEST.marking new file mode 100644 index 0000000..769cfc4 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/P122_40TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/P62TEST.marking b/reference/e10/snapshot-2026-08-27/P62TEST.marking new file mode 100644 index 0000000..e781a81 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/P62TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/PHONE.scribblelogo b/reference/e10/snapshot-2026-08-27/PHONE.scribblelogo new file mode 100644 index 0000000..7370770 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/PHONE.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/POWER.scribblelogo b/reference/e10/snapshot-2026-08-27/POWER.scribblelogo new file mode 100644 index 0000000..14412e5 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/POWER.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/RECYCLAG.scribblelogo b/reference/e10/snapshot-2026-08-27/RECYCLAG.scribblelogo new file mode 100644 index 0000000..c788b93 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/RECYCLAG.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/ROUND.scribblelogo b/reference/e10/snapshot-2026-08-27/ROUND.scribblelogo new file mode 100644 index 0000000..b59bff8 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/ROUND.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/RROUND.scribblelogo b/reference/e10/snapshot-2026-08-27/RROUND.scribblelogo new file mode 100644 index 0000000..c88f9f5 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/RROUND.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/RU.marking b/reference/e10/snapshot-2026-08-27/RU.marking new file mode 100644 index 0000000..13a5370 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/RU.marking differ diff --git a/reference/e10/snapshot-2026-08-27/SAVE_ACC_1.kind7 b/reference/e10/snapshot-2026-08-27/SAVE_ACC_1.kind7 new file mode 100644 index 0000000..9035f86 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/SAVE_ACC_1.kind7 differ diff --git a/reference/e10/snapshot-2026-08-27/SENSINTE1.scribblelogo b/reference/e10/snapshot-2026-08-27/SENSINTE1.scribblelogo new file mode 100644 index 0000000..4a9d841 --- /dev/null +++ b/reference/e10/snapshot-2026-08-27/SENSINTE1.scribblelogo @@ -0,0 +1,5 @@ +××##0> +M^n~ +�ž¬#¹0Ã>ÌMÒ^Ön×~Ö�Ҟ̬ù¹Ã¬ÌžÒ�Ö~×nÖ^ÒMÌ>Ã0¹#¬ž� +~n^M +>0##Ý"¸¹"ÝÝ \ No newline at end of file diff --git a/reference/e10/snapshot-2026-08-27/SIC.scribblelogo b/reference/e10/snapshot-2026-08-27/SIC.scribblelogo new file mode 100644 index 0000000..d961028 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/SIC.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/SQUARE.scribblelogo b/reference/e10/snapshot-2026-08-27/SQUARE.scribblelogo new file mode 100644 index 0000000..a400a0a Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/SQUARE.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/STAR.scribblelogo b/reference/e10/snapshot-2026-08-27/STAR.scribblelogo new file mode 100644 index 0000000..d4642fa Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/STAR.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/TENSION.scribblelogo b/reference/e10/snapshot-2026-08-27/TENSION.scribblelogo new file mode 100644 index 0000000..7f3d0ec Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TENSION.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/TERRE.scribblelogo b/reference/e10/snapshot-2026-08-27/TERRE.scribblelogo new file mode 100644 index 0000000..ade9ebb Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TERRE.scribblelogo differ diff --git a/reference/e10/snapshot-2026-08-27/TEST.marking b/reference/e10/snapshot-2026-08-27/TEST.marking new file mode 100644 index 0000000..e479d7c Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TEST.marking differ diff --git a/reference/e10/snapshot-2026-08-27/TESTP120.marking b/reference/e10/snapshot-2026-08-27/TESTP120.marking new file mode 100644 index 0000000..c830620 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TESTP120.marking differ diff --git a/reference/e10/snapshot-2026-08-27/TEST_DB1.marking b/reference/e10/snapshot-2026-08-27/TEST_DB1.marking new file mode 100644 index 0000000..26c9047 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TEST_DB1.marking differ diff --git a/reference/e10/snapshot-2026-08-27/TEST_DB2.marking b/reference/e10/snapshot-2026-08-27/TEST_DB2.marking new file mode 100644 index 0000000..48e1182 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TEST_DB2.marking differ diff --git a/reference/e10/snapshot-2026-08-27/TEST_DB3.marking b/reference/e10/snapshot-2026-08-27/TEST_DB3.marking new file mode 100644 index 0000000..d08fb94 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TEST_DB3.marking differ diff --git a/reference/e10/snapshot-2026-08-27/TEST_RU.marking b/reference/e10/snapshot-2026-08-27/TEST_RU.marking new file mode 100644 index 0000000..977fb19 Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TEST_RU.marking differ diff --git a/reference/e10/snapshot-2026-08-27/TRIANGLE.scribblelogo b/reference/e10/snapshot-2026-08-27/TRIANGLE.scribblelogo new file mode 100644 index 0000000..8dfa80d Binary files /dev/null and b/reference/e10/snapshot-2026-08-27/TRIANGLE.scribblelogo differ diff --git a/scripts/e10-client.py b/scripts/e10-client.py new file mode 100644 index 0000000..0075058 --- /dev/null +++ b/scripts/e10-client.py @@ -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 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] ... [ETX] + with checksum: [STX][VER] ... [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] ... [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) diff --git a/scripts/fake-telesis.py b/scripts/fake-telesis.py new file mode 100644 index 0000000..7147afa --- /dev/null +++ b/scripts/fake-telesis.py @@ -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 STX ETX CR + reply (controller -> host): + SOH ACK STX ETX CR + + BCC = (TYPE + sum of data bytes) & 0xFF, as three ASCII decimal digits. + + NOTE THE MISSING STATION ID. Backup470 framed requests as + EOT SOH <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 ";<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() diff --git a/scripts/make-e10-pattern.py b/scripts/make-e10-pattern.py new file mode 100644 index 0000000..d2a8c0d --- /dev/null +++ b/scripts/make-e10-pattern.py @@ -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() diff --git a/scripts/simulate-ednc.py b/scripts/simulate-ednc.py new file mode 100644 index 0000000..5d77751 --- /dev/null +++ b/scripts/simulate-ednc.py @@ -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 + part number serial number + dash + checksum char .. part number in 5/5/5/3 chunks + operator dimension operator text + reusable dimension - prompted once, reused after + + Wilmington takes only the last 5 characters of . That is a site + rule, not a format rule, so it lives behind --site. + +THE WIRE FRAME (disassembled, not guessed) + EOT SOH STX ETX CR + BCC = (TYPE + sum of data bytes) & 0xFF + + NO STATION ID. Backup470 sent EOT SOH <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, ";<6-digit size>;128" + +TRANSLATION TO THE e10 + 'P' -> LOAD FILE 'c' + '1'/'2'/... -> FILE SET VAR '7' into V01 / V02 / ... + 'V' -> FILE SET VAR '7' into V + '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 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 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) + out = out.replace("", ser) + out = out.replace("", "-" + serial_checksum(serial)) + out = out.replace("", partno[0:5]) + out = out.replace("", partno[5:10]) + out = out.replace("", partno[10:15]) + out = out.replace("", 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()