Initial commit: aplicatie de cantarire si etichetare

Aplicatie Flask + SQLite pentru productie alimentara: cantarirea
componentelor unui produs finit si tiparirea etichetei prin QZ Tray.

- produse finite (retete) formate din componente cantarite
- designer vizual de etichete (mm/px, zona sigura, campuri variabile,
  cod QR cu continut din mai multe bucati)
- legare produs finit -> sablon de eticheta
- navigare pe doua taburi: Cantarire / Setari
- administrare: componente, produse finite, sabloane, campuri, istoric
- backup, resetare baza de date, checklist de pregatire, /healthz
- CLI de administrare si serviciu systemd pentru productie
- vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
@@ -0,0 +1,12 @@
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 33 39 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 31 30 32 2E 33 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 31 30 32 2E 33 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 20 20 30 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 31 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 31 34 31 2E 38 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 20 20 30 09 31 34 31 2E 38 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 20 20 30 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 31 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 33 2E 33 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 31 34 31 2E 32 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 20 20 30 09 31 34 31 2E 32 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 34 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 34 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 34 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 34 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 34 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 34 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 34 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 33 35 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 33 35 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 34 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 33 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 33 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 35 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 32 35 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 32 35 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 35 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 30 2E 39 35 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 30 2E 39 35 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 20 20 30 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 35 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 32 30 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 20 20 30 09 20 20 31 2E 32 30 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 35 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 32 35 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 32 35 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
43 4C 53 0D 0A 53 49 5A 45 20 35 37 20 6D 6D 2C 20 34 30 20 6D 6D 0D 0A 47 41 50 20 32 20 6D 6D 2C 30 0D 0A 44 49 52 45 43 54 49 4F 4E 20 31 0D 0A 52 45 46 45 52 45 4E 43 45 20 30 2C 30 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 33 30 30 2C 32 35 30 2C 22 31 30 22 2C 32 37 30 2C 31 2C 31 2C 22 4E 6F 3A 20 36 20 53 48 49 46 54 3A 32 22 0D 0A 54 45 58 54 20 30 33 30 2C 32 33 35 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 4F 72 64 65 72 2E 43 6F 20 3A 20 20 20 32 32 32 0D 0A 54 45 58 54 20 30 33 30 2C 30 38 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 31 34 30 35 2F 30 37 2F 31 39 20 2D 20 31 36 3A 34 35 22 0D 0A 54 45 58 54 20 30 33 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 54 68 69 63 6B 6E 65 73 73 20 3A 20 30 2E 30 22 0D 0A 43 4F 44 45 50 41 47 45 20 31 32 35 33 0D 0A 54 45 58 54 20 32 32 30 2C 31 36 30 2C 22 31 30 22 2C 30 2C 31 2C 32 2C 22 E6 22 0D 0A 43 4F 44 45 50 41 47 45 20 49 72 61 6E 49 49 0D 0A 54 45 58 54 20 30 32 30 2C 30 30 30 2C 22 31 30 22 2C 30 2C 32 2C 32 2C 22 20 20 31 2E 31 35 30 22 0D 0A 54 45 58 54 20 32 30 30 2C 30 31 35 2C 22 31 30 22 2C 30 2C 32 2C 31 2C 22 28 4B 67 29 22 0D 0A 42 41 52 43 4F 44 45 20 33 35 30 2C 32 38 30 2C 20 22 31 32 38 22 2C 30 37 30 2C 31 2C 32 37 30 2C 32 2C 32 2C 20 22 20 20 32 32 32 09 20 20 31 2E 31 35 30 22 0D 0A 50 52 49 4E 54 20 31 0D 0A 0D 0A
+112
View File
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Auto-detection against simulated scales that only understand their own settings."""
import time
import pytest
from scale_bridge.detect import Detector, Timings, readability
from scale_bridge.simulator import SimulatorBank
FAST = Timings(passive_s=0.5, probe_s=0.15, confirm_s=0.4, drain_s=0.02)
class Silent:
"""A port with nothing connected."""
in_waiting = 0
def __init__(self, settings):
self.settings = settings
def read(self, n=1):
time.sleep(0.01)
return b""
def write(self, data):
return len(data)
def close(self):
pass
class Chatter(Silent):
"""Readable text without any weight in it."""
def read(self, n=1):
time.sleep(0.01)
return b"HELLO WORLD\r\n" if self.settings.baudrate == 9600 else b""
@property
def in_waiting(self):
return 0
def detect(spec, port="COM90", thorough=False):
bank = SimulatorBank(spec)
events = []
result = Detector(bank.opener(), log=lambda code, **kw: events.append(code), thorough=thorough,
timings=FAST).run_port(port)
return result, events
@pytest.mark.parametrize("spec,protocol,baud,framing,mode", [
("mahak_mds", "mahak_mds", 9600, "8N1", "poll"),
("toledo_cont:4800:7E1", "toledo_cont", 4800, "7E1", "continuous"),
("xk3190_tf0:2400", "xk3190_tf0", 2400, "8N1", "continuous"),
("xk3190_reversed:1200", "xk3190_reversed", 1200, "8N1", "continuous"),
("mt_sics", "mt_sics", 9600, "8N1", "poll"),
("and_std:19200", "and_std", 19200, "8N1", "poll"),
("sma:9600:7E1", "sma", 9600, "7E1", "poll"),
("sartorius:1200:7E1", "sartorius", 1200, "7E1", "poll"),
("cas_enq", "cas_enq", 9600, "8N1", "poll"),
])
def test_finds_protocol_speed_and_framing(spec, protocol, baud, framing, mode):
result, events = detect(spec)
assert result["found"], events
assert result["protocol"] == protocol
assert result["serial"]["baudrate"] == baud
s = result["serial"]
assert f"{s['bytesize']}{s['parity']}{s['stopbits']}" == framing
assert result["mode"] == mode
assert 0 < result["confidence"] <= 0.99
assert events[0] == "port_start" and "found" in events
def test_thorough_search_reaches_7o1():
result, _ = detect("ohaus:9600:7O1", thorough=True)
assert result["found"] and result["protocol"] == "ohaus"
assert result["serial"]["parity"] == "O"
def test_nothing_connected():
detector = Detector(lambda s: Silent(s), timings=FAST)
result = detector.run_port("COM5")
assert result["found"] is False and result["unknown"] is None
def test_unknown_readable_data_is_reported():
detector = Detector(lambda s: Chatter(s), timings=FAST)
result = detector.run_port("COM5")
assert result["found"] is False
assert result["unknown"]["baud"] == 9600
assert "HELLO" in result["unknown"]["sample_text"]
def test_cancel_stops_quickly():
detector = Detector(lambda s: Silent(s), cancelled=lambda: True, timings=FAST)
started = time.monotonic()
result = detector.run_port("COM5")
assert time.monotonic() - started < 0.5
assert result["found"] is False and result["cancelled"] is True
def test_open_failure_is_reported():
def refuse(settings):
raise OSError("Access is denied")
result = Detector(refuse, timings=FAST).run_port("COM5")
assert result["found"] is False and "Access is denied" in result["error"]
def test_readability():
assert readability(b"ST,+0.55 kg\r\n") == 1.0
assert readability(bytes([0xD3, 0xD4])) == 0.0
assert readability(bytes([0xD3, 0xD4]), mask7=True) == 1.0
+137
View File
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Indicators that print labels: the PTC400 capture, other label languages, the label log."""
import json
import os
import time
import pytest
from scale_bridge import profiles
from scale_bridge.detect import Detector, Timings
from scale_bridge.labels import LabelLog
from scale_bridge.protocols import PROTOCOLS
from scale_bridge.simulator import SimulatorBank
HERE = os.path.dirname(os.path.abspath(__file__))
FAST = Timings(passive_s=0.4, probe_s=0.1, confirm_s=0.3, drain_s=0.02)
def capture() -> list[bytes]:
with open(os.path.join(HERE, "captures", "ptc400_tspl_labels.hex"), encoding="ascii") as f:
return [bytes.fromhex(line) for line in f if line.strip()]
def test_ptc400_capture_all_labels():
# Recorded from a PTC400 indicator (TSPL to a TSC label printer), a 1.4 kg
# test weight moved around the platform after three other weighings.
readings = PROTOCOLS["label_print"].readings(b"".join(capture()))
assert [r.value for r in readings] == [102.3, 141.8, 141.2, 1.4, 1.4, 1.35, 1.3, 1.25, 0.95,
1.2, 1.25, 1.15]
first = readings[0]
assert first.unit == "kg" and first.stable is True
assert first.fields["Order.Co"] == "222" and first.fields["SHIFT"] == "2"
assert first.fields["date"] == "1405/07/19" and first.fields["time"] == "16:39"
assert first.fields["Thickness"] == "0.0" and first.fields["No"] == "6"
assert readings[2].fields["Thickness"] == "3.3"
def test_label_split_over_two_chunks_is_read_once():
label = capture()[0]
chunks = [(1000, label[:200]), (1020, label[200:])]
timed = profiles.timed_readings("label_print", None, chunks)
assert len(timed) == 1 and timed[0].t_ms == 1020 and timed[0].reading.value == 102.3
@pytest.mark.parametrize("label,value,unit", [
(b'^XA^FO50,50^A0N,40,40^FDNet: 12.34 kg^FS^FO50,120^FDLOT 7^FS^XZ', 12.34, "kg"),
(b'N\r\nA50,0,0,4,1,1,N,"Weight"\r\nA50,40,0,4,1,1,N,"2.500"\r\nA200,40,0,4,1,1,N,"kg"\r\nP1\r\n',
2.5, "kg"),
(b'CLS\r\nTEXT 10,10,"3",0,1,1,"GROSS 15.6 kg"\r\nPRINT 1,1\r\n', 15.6, "kg"),
])
def test_other_label_languages(label, value, unit):
r = PROTOCOLS["label_print"].readings(label)[-1]
assert (r.value, r.unit) == (value, unit)
def test_label_without_weight_is_ignored():
assert PROTOCOLS["label_print"].readings(b'CLS\r\nTEXT 1,1,"3",0,1,1,"Hello"\r\nPRINT 1\r\n') == []
@pytest.mark.parametrize("spec,baud,parity,wrong", [
("label_print", 9600, "N", 0), ("label_print:4800", 4800, "N", 2), ("label_print:9600:7E1", 9600, "E", 0),
])
def test_wait_for_print_detection(spec, baud, parity, wrong):
bank = SimulatorBank(spec)
events = []
detector = Detector(bank.opener(), log=lambda code, **kw: events.append(code), timings=FAST,
print_wait_s=5, listen_only=True)
result = detector.run_port("COM90")
assert result["found"] and result["protocol"] == "label_print" and result["mode"] == "print"
assert result["serial"]["baudrate"] == baud and result["serial"]["parity"] == parity
assert result["reading"]["fields"]["Order.Co"] == "222"
assert events.count("print_wrong_speed") == wrong
assert "ask" not in events # listen only: nothing was sent
def test_listen_only_sends_nothing():
written = []
class Quiet:
in_waiting = 0
def __init__(self, s):
pass
def read(self, n=1):
time.sleep(0.01)
return b""
def write(self, data):
written.append(data)
return len(data)
def close(self):
pass
result = Detector(lambda s: Quiet(s), timings=FAST, print_wait_s=0.3, listen_only=True).run_port("COM5")
assert result["found"] is False and written == []
def test_label_log_feeds_pieces_and_writes_files(tmp_path):
log = LabelLog(str(tmp_path))
profile = profiles.validate({"name": "label-1", "port": "COM8", "protocol": "label_print",
"serial": {"baudrate": 9600}})
a, b = capture()[3], capture()[4]
assert log.feed(profile, a[:100]) == []
got = log.feed(profile, a[100:] + b)
assert [r["value"] for r in got] == [1.4, 1.4] and [r["seq"] for r in got] == [1, 2]
assert [r["seq"] for r in log.list("label-1", after=1)] == [2]
files = list(tmp_path.iterdir())
assert len(files) == 1 and files[0].name.startswith("label-1-")
lines = files[0].read_text(encoding="utf-8").splitlines()
assert json.loads(lines[0])["fields"]["Order.Co"] == "222"
def test_bridge_keeps_print_scale_open_and_logs_labels(tmp_path):
from scale_bridge.config import Config
from scale_bridge.server import Bridge
config = Config(str(tmp_path / "bridge.json"))
bridge = Bridge(config, simulate="label_print")
try:
config.save_profile({"name": "label-1", "port": "COM90", "protocol": "label_print",
"serial": {"baudrate": 9600}})
bridge.keeper.tick()
deadline = time.time() + 12
while time.time() < deadline and not bridge.labels.list("label-1"):
time.sleep(0.3)
listed = bridge.label_list({"profile": "label-1"})
assert listed["labels"] and listed["listening"] == ["label-1"]
assert listed["labels"][-1]["fields"]["SHIFT"] == "2"
started = time.monotonic()
answer = bridge.weight(config.profile("label-1"), max_age_ms=60000, wait_ms=2500)
assert time.monotonic() - started < 1.5 # a print scale never holds the caller
assert answer["ok"] and answer["on_print"] and answer["stable"] and answer["unit"] == "kg"
assert (tmp_path / "labels").is_dir()
finally:
bridge.keeper.stop()
bridge.registry.stop_all()
+132
View File
@@ -0,0 +1,132 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import pytest
from scale_bridge import profiles
from scale_bridge.config import Config
from scale_bridge.protocols import Reading
def base(**over):
raw = {"name": "packing-1", "port": "com7", "protocol": "mahak_mds",
"serial": {"baudrate": 9600}, "exchange": {"request_hex": "4d090407"}}
raw.update(over)
return raw
def test_validate_normalises():
p = profiles.validate(base())
assert p["port"] == "COM7"
assert p["serial"]["baudrate"] == 9600 and p["serial"]["parity"] == "N"
assert p["exchange"]["request_hex"] == "4D 09 04 07"
assert p["stable_samples"] == 3
@pytest.mark.parametrize("bad", [
{"name": ""}, {"name": "bad name"}, {"name": "-x"}, {"protocol": "nope"}, {"port": "LPT1"},
{"serial": {"baudrate": 9}}, {"exchange": {"request_hex": "zz"}}, {"output_unit": "pcs"},
{"protocol": "custom_ascii", "params": {"regex": "("}}, {"protocol": "custom_ascii", "params": {}},
])
def test_validate_rejects(bad):
with pytest.raises(ValueError):
profiles.validate(base(**bad))
class FakeWorker:
def __init__(self, chunks, is_open=True, last_error=None):
self.chunks = chunks
self.is_open = is_open
self.last_error = last_error
def recent(self, count):
return self.chunks[-count:]
MAHAK = lambda grams: bytes([0x58, 0x51, 7, 7]) + grams.to_bytes(3, "big") # noqa: E731
def test_evaluate_flagless_stability_needs_equal_recent_readings():
p = profiles.validate(base())
now = 100_000
moving = FakeWorker([(now - 1000, MAHAK(540)), (now - 500, MAHAK(550)), (now - 10, MAHAK(550))])
r = profiles.evaluate(p, moving, 1500, now)
assert r["ok"] and r["value"] == 550 and r["unit"] == "g" and r["stable"] is False
steady = FakeWorker([(now - 1000, MAHAK(550)), (now - 500, MAHAK(550)), (now - 10, MAHAK(550))])
assert profiles.evaluate(p, steady, 1500, now)["stable"] is True
old = FakeWorker([(now - 60_000, MAHAK(550)), (now - 59_000, MAHAK(550)), (now - 10, MAHAK(550))])
assert profiles.evaluate(p, old, 1500, now)["stable"] is False
def test_evaluate_stale_and_conversion():
p = profiles.validate(base(output_unit="kg"))
now = 100_000
r = profiles.evaluate(p, FakeWorker([(now - 5000, MAHAK(550))] * 3), 1500, now)
assert r["stale"] is True and r["stable"] is False
assert r["value"] == pytest.approx(0.55) and r["unit"] == "kg"
def test_evaluate_unit_fallback_for_unitless_protocols():
p = profiles.validate(base(protocol="xk3190_reversed", unit="kg", exchange={}))
r = profiles.evaluate(p, FakeWorker([(1000, b"05.21000=05.21000=05.21000=")]), 1500, 1010)
assert r["value"] == 12.5 and r["unit"] == "kg"
def test_evaluate_errors():
p = profiles.validate(base())
r = profiles.evaluate(p, FakeWorker([], is_open=False, last_error="could not open port COM7"), 1500, 0)
assert r["ok"] is False and "COM7" in r["error"]
r = profiles.evaluate(p, FakeWorker([(0, b"garbage")]), 1500, 10)
assert r["ok"] is False and "protocol" in r["error"]
def test_from_detection_and_ad_hoc():
detection = {"port": "COM9", "protocol": "xk3190_tf0",
"serial": {"port": "COM9", "baudrate": 2400, "bytesize": 8, "parity": "N", "stopbits": 1},
"exchange": {"request_hex": "", "poll_interval_ms": 500}, "reading": {"unit": ""}}
p = profiles.from_detection(detection, "line-2")
assert p["unit"] == "kg" and p["serial"]["baudrate"] == 2400
q = profiles.ad_hoc("COM3", "mt_sics")
assert q["exchange"]["request_hex"] == "53 49 0D 0A"
def test_config_round_trip_and_legacy_file(tmp_path):
path = tmp_path / "bridge.json"
# a file written by an earlier version (1.x)
path.write_text(json.dumps({"listen_host": "0.0.0.0", "listen_port": 8765, "token": "abc",
"allowed_clients": []}), encoding="utf-8")
c = Config(str(path))
assert c.token == "abc" and c.profiles() == []
c.save_profile(base())
assert Config(str(path)).profile("PACKING-1")["port"] == "COM7"
assert c.delete_profile("packing-1") and not c.delete_profile("packing-1")
with pytest.raises(ValueError):
c.set_allowed_clients(["not-an-ip"])
def test_config_creates_token_and_survives_a_broken_file(tmp_path):
path = tmp_path / "bridge.json"
path.write_text("{broken", encoding="utf-8")
c = Config(str(path))
assert len(c.token) >= 24
assert (tmp_path / "bridge.json.broken").exists()
old = c.token
assert c.rotate_token() != old
def test_reversed_stream_one_frame_per_chunk():
# A Yaohua YH-T7E sends "=081.100" about nine times a second with a pause
# between frames, so every frame lands in its own chunk (field capture, 1.18 kg).
p = profiles.validate(base(protocol="xk3190_reversed", unit="kg", exchange={}))
now = 10_000
chunks = [(now - 400 + i * 100, b"=081.100") for i in range(5)]
timed = profiles.timed_readings("xk3190_reversed", None, chunks)
assert [x.reading.value for x in timed] == [1.18] * 4
r = profiles.evaluate(p, FakeWorker(chunks), 1500, now)
assert r["ok"] and r["value"] == 1.18 and r["unit"] == "kg" and r["stable"] is True
def test_chunk_boundaries_do_not_repeat_readings():
frame = bytes([0x58, 0x51, 7, 7]) + (550).to_bytes(3, "big")
timed = profiles.timed_readings("mahak_mds", None, [(1, frame), (2, frame), (3, frame)])
assert [x.t_ms for x in timed] == [1, 2, 3]
+193
View File
@@ -0,0 +1,193 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Every parser against sample frames in the shapes the manufacturers document."""
import pytest
from scale_bridge.protocols import PROTOCOLS, convert, get_protocol, lines, norm_unit
from scale_bridge.simulator import frame_for
def one(protocol_id, data, params=None):
readings = get_protocol(protocol_id, params).readings(data)
assert readings, f"{protocol_id} read nothing from {data!r}"
return readings[-1]
def toledo_frame(swa, swb, swc, digits, tare=b"000000"):
body = bytes([0x02, swa, swb, swc]) + digits + tare + b"\r"
return body + bytes([(-sum(b & 0x7F for b in body)) & 0x7F])
# -- Mettler Toledo ----------------------------------------------------------
def test_mt_sics_stable_and_dynamic():
r = one("mt_sics", b"S S 0.550 kg\r\n")
assert (r.value, r.unit, r.stable) == (0.55, "kg", True)
r = one("mt_sics", b"S D 12.345 g\r\n")
assert (r.value, r.unit, r.stable) == (12.345, "g", False)
def test_mt_sics_overload_and_error():
assert one("mt_sics", b"S +\r\n").overload
assert PROTOCOLS["mt_sics"].readings(b"S I\r\nES\r\n") == []
def test_toledo_continuous_decimal_point_units_and_checksum():
# SWA: bit5 + count-by x1 (bit3) + dp code 3 (XXXXX.X); SWB: bit5 + kg (bit4)
r = one("toledo_cont", toledo_frame(0x2B, 0x30, 0x20, b"012345"))
assert (r.value, r.unit, r.stable, r.net, r.checksum_ok) == (1234.5, "kg", True, False, True)
# motion + negative + net, grams in SWC
r = one("toledo_cont", toledo_frame(0x2A, 0x20 | 0x08 | 0x02 | 0x01, 0x21, b"000550"))
assert (r.value, r.unit, r.stable, r.net) == (-550, "g", False, True)
# lb when SWB bit4 is clear, dp code 5 (X.XXX)
r = one("toledo_cont", toledo_frame(0x2D, 0x20, 0x20, b"001210"))
assert (r.value, r.unit) == (1.21, "lb")
def test_toledo_continuous_bad_checksum_is_flagged():
frame = bytearray(toledo_frame(0x2B, 0x30, 0x20, b"012345"))
frame[-1] ^= 0x01
assert one("toledo_cont", bytes(frame)).checksum_ok is False
# -- A&D and friends -----------------------------------------------------------
@pytest.mark.parametrize("frame,value,unit,stable,net", [
(b"ST,+00000.550 kg\r\n", 0.55, "kg", True, None),
(b"US,-00001.20 kg\r\n", -1.2, "kg", False, None),
(b"ST,GS,+0012.34kg\r\n", 12.34, "kg", True, False),
(b"ST,NT,+0001.500 lb\r\n", 1.5, "lb", True, True),
(b"QT,+000123.4 g\r\n", 123.4, "g", True, None),
])
def test_and_standard(frame, value, unit, stable, net):
r = one("and_std", frame)
assert (r.value, r.unit, r.stable, r.net) == (value, unit, stable, net)
def test_and_standard_overload():
r = one("and_std", b"OL,+9999999 kg\r\n")
assert r.overload and r.value is None
def test_sma_frames():
r = one("sma", b"\n 1G 0.550kg \r") # s r n m f, then the weight in 10 characters
assert (r.value, r.unit, r.stable, r.net) == (0.55, "kg", True, False)
r = one("sma", b"\n 1NM 12.500lb \r")
assert (r.value, r.unit, r.stable, r.net) == (12.5, "lb", False, True)
assert one("sma", b"\nO1G ----------lb \r").overload
def test_nci():
r = one("nci", b"\n 1.23lb\r\n00\r\x03")
assert (r.value, r.unit, r.stable) == (1.23, "lb", True)
r = one("nci", b"\n 1.23lb\r\n10\r\x03")
assert r.stable is False
def test_ohaus():
r = one("ohaus", b" 0.550 kg \r\n")
assert (r.value, r.unit, r.stable) == (0.55, "kg", True)
assert one("ohaus", b" 0.550 kg ? \r\n").stable is False
assert one("ohaus", b" 1.234 kg N\r\n").net is True
def test_sartorius_unit_missing_means_unstable():
r = one("sartorius", b"+ 0.550 kg \r\n")
assert (r.value, r.unit, r.stable) == (0.55, "kg", True)
r = one("sartorius", b"+ 0.550 \r\n")
assert (r.value, r.stable) == (0.55, False)
r = one("sartorius", b"N + 1.234 kg\r\n")
assert (r.value, r.net) == (1.234, True)
def test_cas_enq():
r = one("cas_enq", b"\x06\x01\x02S 0.550kg\x03\x04")
assert (r.value, r.unit, r.stable) == (0.55, "kg", True)
# -- XK3190 ---------------------------------------------------------------------
def test_xk3190_tf0_with_checksum():
body = b"+0012342"
x = 0
for b in body:
x ^= b
frame = b"\x02" + body + f"{x >> 4:X}{x & 0xF:X}".encode() + b"\x03"
r = one("xk3190_tf0", frame * 2)
assert (r.value, r.checksum_ok) == (12.34, True)
r = one("xk3190_tf0", b"\x02-0005003" + b"00" + b"\x03")
assert r.value == -0.5 and r.checksum_ok is False
def test_xk3190_reversed_stream():
r = one("xk3190_reversed", b"05.21000=05.21000=05.21000=")
assert r.value == 12.5
assert PROTOCOLS["xk3190_reversed"].readings(b"05.21000=") == []
# -- Mahak (field-tested) -----------------------------------------------------------
def test_mahak_field_capture():
# Captured on a Mahak MDS 13000 with a 550 g weight: 58 ?? 07 07 00 02 26
r = one("mahak_mds", bytes.fromhex("58 51 07 07 00 02 26"))
assert (r.value, r.unit) == (550.0, "g")
r = one("mahak_mds", bytes.fromhex("58 A3 07 07 00 02 EE"))
assert r.value == 750.0
def test_mahak_ignores_boot_message():
assert PROTOCOLS["mahak_mds"].readings(bytes.fromhex("42 03 04 01 42 4F 4F 54 0C 04 8C")) == []
# -- generic and custom ----------------------------------------------------------------
def test_generic_ascii():
r = one("ascii_generic", b"G 12.50 kg\r\n")
assert (r.value, r.unit) == (12.5, "kg")
assert one("ascii_generic", b"US 3.2 kg\r\n").stable is False
def test_custom_binary_and_ascii():
params = {"pattern": "58 ?? 07 07", "value_offset": 4, "value_length": 3, "unit": "g"}
r = one("custom_binary", bytes.fromhex("58 51 07 07 00 02 26"), params)
assert (r.value, r.unit) == (550.0, "g")
r = one("custom_ascii", b"W: 12.5\r\n", {"regex": r"W:\s*([-\d.]+)", "unit": "kg"})
assert (r.value, r.unit) == (12.5, "kg")
assert get_protocol("custom_ascii").readings(b"W: 1\r\n") == []
# -- the simulator speaks what the parsers read --------------------------------------------
@pytest.mark.parametrize("pid", ["mt_sics", "and_std", "toledo_cont", "sma", "nci", "ohaus", "sartorius",
"cas_enq", "xk3190_tf0", "xk3190_reversed", "mahak_mds"])
@pytest.mark.parametrize("kg,stable", [(0.55, True), (13.1, False)])
def test_simulator_round_trip(pid, kg, stable):
frame = frame_for(pid, kg, stable)
data = frame * 3 if pid == "xk3190_reversed" else frame
r = PROTOCOLS[pid].readings(data)[-1]
expected = kg * 1000 if pid == "mahak_mds" else kg
assert r.value == pytest.approx(expected)
if r.stable is not None and pid != "sartorius":
assert r.stable == stable
# -- helpers ----------------------------------------------------------------------------
def test_units():
assert norm_unit(b" KG ") == "kg" and norm_unit("lbs") == "lb" and norm_unit(None) == ""
assert convert(550, "g", "kg") == pytest.approx(0.55)
assert convert(1, "lb", "g") == pytest.approx(453.59237)
assert convert(1, "kg", "pcs") is None
def test_lines_split_on_terminators():
assert lines(b"\x02abc\r\ndef\x03\r\n\r\n") == [b"abc", b"def"]
def test_catalogue_is_consistent():
from scale_bridge.protocols import CONTINUOUS_IDS, POLL_IDS
for pid in CONTINUOUS_IDS + POLL_IDS:
assert pid in PROTOCOLS
for pid in POLL_IDS:
assert PROTOCOLS[pid].request, pid
# read-only: never zero / tare / reset / print-off letters on their own
assert PROTOCOLS[pid].request.strip(b"\r\n") not in (b"Z", b"T", b"R", b"P")
+156
View File
@@ -0,0 +1,156 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""The HTTP interface, end to end, with simulated scales."""
import json
import threading
import time
import urllib.error
import urllib.request
import pytest
from scale_bridge.config import Config
from scale_bridge.detect import Timings
from scale_bridge.server import Bridge, make_server
LOCAL = {"X-Requested-With": "ScaleBridge"}
@pytest.fixture(scope="module")
def bridge_url(tmp_path_factory):
config = Config(str(tmp_path_factory.mktemp("bridge") / "bridge.json"))
bridge = Bridge(config, simulate="mahak_mds,toledo_cont:4800:7E1")
bridge.detect_timings = Timings(passive_s=0.5, probe_s=0.15, confirm_s=0.4, drain_s=0.02)
server = make_server(bridge, "127.0.0.1", 0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{server.server_address[1]}", config
server.shutdown()
bridge.registry.stop_all()
def call(url, method="GET", body=None, headers=None):
req = urllib.request.Request(url, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Content-Type": "application/json", **(headers or {})})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
def test_health_is_open_and_identifies_the_author(bridge_url):
url, _ = bridge_url
code, body = call(url + "/health")
assert code == 200 and body["service"] == "syncpulse-scale-bridge"
assert body["author"] == "Farshad Zamani" and "weight" in body["capabilities"]
def test_key_is_required(bridge_url):
url, config = bridge_url
assert call(url + "/ports")[0] == 401
assert call(url + "/ports", headers={"X-Bridge-Token": "wrong"})[0] == 401
code, body = call(url + "/ports", headers={"X-Bridge-Token": config.token})
assert code == 200 and any(p["device"] == "COM90" for p in body["ports"])
def test_legacy_read_contract(bridge_url):
url, config = bridge_url
code, body = call(url + "/read", "POST", {
"serial": {"port": "COM90", "baudrate": 9600, "bytesize": 8, "parity": "N", "stopbits": 1},
"exchange": {"request_hex": "4D 09 04 07", "poll_interval_ms": 300}, "frames": 3},
headers={"X-Bridge-Token": config.token})
assert code == 200 and body["ok"] and body["open"]
assert body["frames"] and body["frames"][-1]["hex"].startswith("58")
assert {"t", "hex"} <= set(body["frames"][-1]) and "now" in body
def test_weight_by_port_and_protocol(bridge_url):
url, config = bridge_url
code, body = call(url + "/weight?port=COM91&protocol=toledo_cont&baud=4800&framing=7E1",
headers={"X-Bridge-Token": config.token})
assert code == 200 and body["ok"] and body["unit"] == "kg" and body["checksum_ok"] is True
assert isinstance(body["value"], float) and "measured_at" in body
def test_weight_errors(bridge_url):
url, config = bridge_url
h = {"X-Bridge-Token": config.token}
assert call(url + "/weight?profile=missing", headers=h)[0] == 404
assert call(url + "/weight?port=COM90", headers=h)[0] == 400
assert call(url + "/weight?port=COM90&protocol=mt_sics&framing=9X9", headers=h)[0] == 400
def test_local_api_needs_header_and_local_host(bridge_url):
url, _ = bridge_url
assert call(url + "/api/state")[0] == 403
assert call(url + "/api/state", headers={**LOCAL, "Host": "evil.example"})[0] == 403
code, state = call(url + "/api/state", headers=LOCAL)
assert code == 200 and state["about"]["author"] == "Farshad Zamani" and state["token"]
def test_page_is_served_with_signature(bridge_url):
url, _ = bridge_url
with urllib.request.urlopen(url + "/", timeout=5) as r:
html = r.read().decode("utf-8")
assert "Content-Security-Policy" in r.headers
assert "Farshad Zamani" in html and "linkedin.com/in/farshad-zamani92" in html
assert "cloudtart.com" in html and "SyncPulse" in html
def test_detect_save_read_delete(bridge_url):
url, config = bridge_url
code, body = call(url + "/api/detect", "POST", {"ports": ["COM90"]}, headers=LOCAL)
assert code == 200 and body["job"]["running"]
# a second search while the first runs is refused, and the port is reserved
assert call(url + "/api/detect", "POST", {"ports": ["COM90"]}, headers=LOCAL)[0] == 409
busy = call(url + "/weight?port=COM90&protocol=mahak_mds", headers={"X-Bridge-Token": config.token})
assert busy[0] == 409
deadline = time.time() + 60
job = None
while time.time() < deadline:
job = call(url + "/api/detect", headers=LOCAL)[1]["job"]
if not job["running"]:
break
time.sleep(0.3)
assert job and job["results"][0]["found"] and job["results"][0]["protocol"] == "mahak_mds"
assert job["progress"] == 1.0
code, saved = call(url + "/api/profiles", "POST",
{"detection": job["results"][0], "name": "packing-1", "label": "Packing"},
headers=LOCAL)
assert code == 200 and saved["profile"]["name"] == "packing-1"
code, listed = call(url + "/profiles", headers={"X-Bridge-Token": config.token})
assert [p["name"] for p in listed["profiles"]] == ["packing-1"]
deadline = time.time() + 10
body = {}
while time.time() < deadline:
body = call(url + "/weight?profile=packing-1", headers={"X-Bridge-Token": config.token})[1]
if body.get("stable"):
break
time.sleep(0.4)
assert body["ok"] and body["unit"] == "g" and body["stable"] is True
code, _ = call(url + "/api/profiles/packing-1", "DELETE", headers=LOCAL)
assert code == 200 and config.profiles() == []
def test_allowed_clients_do_not_lock_out_this_pc(bridge_url):
url, config = bridge_url
code, _ = call(url + "/api/allowed", "POST", {"clients": ["10.1.2.3"]}, headers=LOCAL)
assert code == 200
try:
assert call(url + "/ports", headers={"X-Bridge-Token": config.token})[0] == 200
finally:
call(url + "/api/allowed", "POST", {"clients": []}, headers=LOCAL)
def test_bad_json_is_a_400(bridge_url):
url, config = bridge_url
req = urllib.request.Request(url + "/read", method="POST", data=b"{nope",
headers={"X-Bridge-Token": config.token})
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req, timeout=5)
assert e.value.code == 400