From bff6d70b1763995d51d74cd2e0dc875cc061d523 Mon Sep 17 00:00:00 2001 From: scheianu Date: Sun, 18 Oct 2026 23:38:45 +0300 Subject: [PATCH] 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 --- .env.example | 29 + .gitignore | 18 + DEPLOY.md | 176 + README.md | 421 +++ cantarire/__init__.py | 60 + cantarire/config.py | 40 + cantarire/db.py | 172 + cantarire/designer.py | 681 ++++ cantarire/labels.py | 175 + cantarire/logging_setup.py | 57 + cantarire/maintenance.py | 407 +++ cantarire/probe.py | 184 ++ cantarire/repositories.py | 871 +++++ cantarire/routes/__init__.py | 1 + cantarire/routes/api.py | 277 ++ cantarire/routes/components.py | 58 + cantarire/routes/fields.py | 113 + cantarire/routes/history.py | 65 + cantarire/routes/label_templates.py | 217 ++ cantarire/routes/main.py | 135 + cantarire/routes/monitor.py | 85 + cantarire/routes/printing.py | 46 + cantarire/routes/recipes.py | 85 + cantarire/scale.py | 229 ++ cantarire/schema.sql | 117 + cantarire/static/css/app.css | 726 +++++ cantarire/static/css/designer.css | 467 +++ cantarire/static/js/designer.js | 1262 ++++++++ cantarire/static/js/qz-print.js | 489 +++ cantarire/static/js/vendor/README.md | 16 + cantarire/static/js/vendor/qz-tray.js | 2871 +++++++++++++++++ cantarire/static/js/weigh.js | 577 ++++ cantarire/templates/base.html | 77 + cantarire/templates/components/form.html | 47 + cantarire/templates/components/list.html | 47 + cantarire/templates/dashboard.html | 159 + cantarire/templates/fields/form.html | 120 + cantarire/templates/fields/list.html | 75 + cantarire/templates/history/list.html | 100 + cantarire/templates/label_print.html | 89 + cantarire/templates/monitor.html | 170 + cantarire/templates/recipes/form.html | 141 + cantarire/templates/recipes/list.html | 51 + cantarire/templates/settings.html | 194 ++ cantarire/templates/templates/designer.html | 154 + cantarire/templates/templates/list.html | 59 + cantarire/tools/__init__.py | 1 + cantarire/tools/admin.py | 122 + cantarire/tools/monitor.py | 89 + cantarire/units.py | 58 + deploy/cantarire.service | 29 + deploy/start-windows.bat | 34 + pyproject.toml | 15 + requirements-dev.txt | 2 + requirements.txt | 4 + run.py | 82 + tests/conftest.py | 131 + tests/test_app.py | 304 ++ tests/test_designer.py | 281 ++ tests/test_fields.py | 190 ++ tests/test_maintenance.py | 188 ++ tests/test_navigation.py | 70 + tests/test_probe.py | 63 + tests/test_recipe_template.py | 157 + tests/test_scale_integration.py | 54 + tests/test_template_names.py | 168 + vendor/syncpulse-scale-bridge/.gitattributes | 4 + .../.github/workflows/ci.yml | 53 + vendor/syncpulse-scale-bridge/.gitignore | 22 + vendor/syncpulse-scale-bridge/CHANGELOG.md | 51 + vendor/syncpulse-scale-bridge/CONTRIBUTING.md | 32 + vendor/syncpulse-scale-bridge/LICENSE | 674 ++++ vendor/syncpulse-scale-bridge/NOTICE.md | 51 + vendor/syncpulse-scale-bridge/README.fa.md | 212 ++ vendor/syncpulse-scale-bridge/README.md | 272 ++ vendor/syncpulse-scale-bridge/assets/icon.ico | Bin 0 -> 37056 bytes vendor/syncpulse-scale-bridge/assets/icon.png | Bin 0 -> 2546 bytes .../docs/screenshots/connect-fa.png | Bin 0 -> 108088 bytes .../docs/screenshots/find.png | Bin 0 -> 144666 bytes .../docs/screenshots/scales-fa.png | Bin 0 -> 41826 bytes .../docs/screenshots/scales.png | Bin 0 -> 41654 bytes vendor/syncpulse-scale-bridge/pyproject.toml | 46 + .../requirements-dev.txt | 3 + .../syncpulse-scale-bridge/requirements.txt | 1 + vendor/syncpulse-scale-bridge/run_bridge.py | 9 + .../scale_bridge/__init__.py | 42 + .../scale_bridge/__main__.py | 8 + .../scale_bridge/cli.py | 418 +++ .../scale_bridge/config.py | 195 ++ .../scale_bridge/detect.py | 339 ++ .../scale_bridge/labels.py | 146 + .../scale_bridge/profiles.py | 237 ++ .../scale_bridge/protocols.py | 619 ++++ .../scale_bridge/serialio.py | 431 +++ .../scale_bridge/server.py | 572 ++++ .../scale_bridge/simulator.py | 222 ++ .../scale_bridge/webui.py | 864 +++++ .../scale_bridge/winutil.py | 294 ++ .../syncpulse-scale-bridge/scripts/build.py | 77 + .../scripts/build_exe.bat | 26 + .../scripts/make_icon.py | 114 + .../tests/captures/ptc400_tspl_labels.hex | 12 + .../tests/test_detect.py | 112 + .../tests/test_labels.py | 137 + .../tests/test_profiles.py | 132 + .../tests/test_protocols.py | 193 ++ .../tests/test_server.py | 156 + 107 files changed, 21428 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 README.md create mode 100644 cantarire/__init__.py create mode 100644 cantarire/config.py create mode 100644 cantarire/db.py create mode 100644 cantarire/designer.py create mode 100644 cantarire/labels.py create mode 100644 cantarire/logging_setup.py create mode 100644 cantarire/maintenance.py create mode 100644 cantarire/probe.py create mode 100644 cantarire/repositories.py create mode 100644 cantarire/routes/__init__.py create mode 100644 cantarire/routes/api.py create mode 100644 cantarire/routes/components.py create mode 100644 cantarire/routes/fields.py create mode 100644 cantarire/routes/history.py create mode 100644 cantarire/routes/label_templates.py create mode 100644 cantarire/routes/main.py create mode 100644 cantarire/routes/monitor.py create mode 100644 cantarire/routes/printing.py create mode 100644 cantarire/routes/recipes.py create mode 100644 cantarire/scale.py create mode 100644 cantarire/schema.sql create mode 100644 cantarire/static/css/app.css create mode 100644 cantarire/static/css/designer.css create mode 100644 cantarire/static/js/designer.js create mode 100644 cantarire/static/js/qz-print.js create mode 100644 cantarire/static/js/vendor/README.md create mode 100755 cantarire/static/js/vendor/qz-tray.js create mode 100644 cantarire/static/js/weigh.js create mode 100644 cantarire/templates/base.html create mode 100644 cantarire/templates/components/form.html create mode 100644 cantarire/templates/components/list.html create mode 100644 cantarire/templates/dashboard.html create mode 100644 cantarire/templates/fields/form.html create mode 100644 cantarire/templates/fields/list.html create mode 100644 cantarire/templates/history/list.html create mode 100644 cantarire/templates/label_print.html create mode 100644 cantarire/templates/monitor.html create mode 100644 cantarire/templates/recipes/form.html create mode 100644 cantarire/templates/recipes/list.html create mode 100644 cantarire/templates/settings.html create mode 100644 cantarire/templates/templates/designer.html create mode 100644 cantarire/templates/templates/list.html create mode 100644 cantarire/tools/__init__.py create mode 100644 cantarire/tools/admin.py create mode 100644 cantarire/tools/monitor.py create mode 100644 cantarire/units.py create mode 100644 deploy/cantarire.service create mode 100644 deploy/start-windows.bat create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py create mode 100644 tests/test_designer.py create mode 100644 tests/test_fields.py create mode 100644 tests/test_maintenance.py create mode 100644 tests/test_navigation.py create mode 100644 tests/test_probe.py create mode 100644 tests/test_recipe_template.py create mode 100644 tests/test_scale_integration.py create mode 100644 tests/test_template_names.py create mode 100644 vendor/syncpulse-scale-bridge/.gitattributes create mode 100644 vendor/syncpulse-scale-bridge/.github/workflows/ci.yml create mode 100644 vendor/syncpulse-scale-bridge/.gitignore create mode 100644 vendor/syncpulse-scale-bridge/CHANGELOG.md create mode 100644 vendor/syncpulse-scale-bridge/CONTRIBUTING.md create mode 100644 vendor/syncpulse-scale-bridge/LICENSE create mode 100644 vendor/syncpulse-scale-bridge/NOTICE.md create mode 100644 vendor/syncpulse-scale-bridge/README.fa.md create mode 100644 vendor/syncpulse-scale-bridge/README.md create mode 100644 vendor/syncpulse-scale-bridge/assets/icon.ico create mode 100644 vendor/syncpulse-scale-bridge/assets/icon.png create mode 100644 vendor/syncpulse-scale-bridge/docs/screenshots/connect-fa.png create mode 100644 vendor/syncpulse-scale-bridge/docs/screenshots/find.png create mode 100644 vendor/syncpulse-scale-bridge/docs/screenshots/scales-fa.png create mode 100644 vendor/syncpulse-scale-bridge/docs/screenshots/scales.png create mode 100644 vendor/syncpulse-scale-bridge/pyproject.toml create mode 100644 vendor/syncpulse-scale-bridge/requirements-dev.txt create mode 100644 vendor/syncpulse-scale-bridge/requirements.txt create mode 100644 vendor/syncpulse-scale-bridge/run_bridge.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/__init__.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/__main__.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/cli.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/config.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/detect.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/labels.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/profiles.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/protocols.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/serialio.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/server.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/simulator.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/webui.py create mode 100644 vendor/syncpulse-scale-bridge/scale_bridge/winutil.py create mode 100644 vendor/syncpulse-scale-bridge/scripts/build.py create mode 100644 vendor/syncpulse-scale-bridge/scripts/build_exe.bat create mode 100644 vendor/syncpulse-scale-bridge/scripts/make_icon.py create mode 100644 vendor/syncpulse-scale-bridge/tests/captures/ptc400_tspl_labels.hex create mode 100644 vendor/syncpulse-scale-bridge/tests/test_detect.py create mode 100644 vendor/syncpulse-scale-bridge/tests/test_labels.py create mode 100644 vendor/syncpulse-scale-bridge/tests/test_profiles.py create mode 100644 vendor/syncpulse-scale-bridge/tests/test_protocols.py create mode 100644 vendor/syncpulse-scale-bridge/tests/test_server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94dcc3a --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Variabile de mediu pentru Cântărire & Etichetare. +# Copiază acest fișier în `.env` (sau exportă variabilele în sistem) și completează valorile. +# Fișierul `.env` este ignorat de git. + +# -- obligatoriu în producție ------------------------------------------------- +# Generează una cu: python -c "import secrets; print(secrets.token_urlsafe(48))" +CANTARIRE_SECRET_KEY= + +# -- rețea -------------------------------------------------------------------- +# 127.0.0.1 = doar stația locală; 0.0.0.0 = accesibil în rețeaua locală +CANTARIRE_HOST=127.0.0.1 +CANTARIRE_PORT=5000 +# flask (dezvoltare) sau waitress (producție) +CANTARIRE_SERVER=flask + +# -- date --------------------------------------------------------------------- +# Folderul cu baza SQLite, bridge.json și backup-uri (implicit ./instance) +# CANTARIRE_INSTANCE=/var/lib/cantarire + +# -- cântar ------------------------------------------------------------------- +# Simulatoare pentru teste fără hardware, separate prin virgulă. +# Ex.: CANTARIRE_SIMULATE=mahak_mds sau toledo_cont:4800:7E1 +# CANTARIRE_SIMULATE= +CANTARIRE_MAX_AGE_MS=1500 +CANTARIRE_WAIT_MS=2500 + +# -- tipărire ----------------------------------------------------------------- +# 0 dezactivează global tipărirea automată (rămâne butonul manual) +CANTARIRE_AUTO_PRINT=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fca776f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.pytest_cache/ +.coverage +htmlcov/ + +# Runtime data (SQLite DB, bridge.json, label logs, backups) +instance/ +data/ + +# OS +.DS_Store +Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..d1c6f3c --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,176 @@ +# Punere în funcțiune + +Ghid pas cu pas pentru o stație de producție (cântar + imprimantă de etichete). +Aplicația rulează local, fără servicii externe: Flask + SQLite, fișierele stau în `instance/`. + +## 1. Cerințe pe stație + +| Ce | Detaliu | +|---|---| +| Python | 3.10+ (testat pe 3.13) | +| Cântar | Bizerba BC800 pe RJ11/10P4C → adaptor USB-serial (apare ca `COMx`) | +| Imprimantă | termică de etichete, separată de cântar | +| QZ Tray | 2.2.x instalat și pornit (`localhost:8181`) pentru tipărire directă | +| Rețea | nu e nevoie de internet; doar rețeaua locală dacă vrei acces din alt PC | + +## 2. Instalare + +```bash +git clone cantarire-etichetare +cd cantarire-etichetare +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +Configurare (recomandat): copiază `.env.example` în `.env` și completează cel puțin cheia: + +```bash +python -c "import secrets; print(secrets.token_urlsafe(48))" +``` + +## 3. Prima pornire + +```bash +python run.py +``` + +La prima rulare se creează automat `instance/cantarire.db`, cu schema completă, setările +implicite și un șablon de etichetă. Sumarul din consolă arată calea bazei și ce mai lipsește: + +``` +Cantarire & etichetare - server flask + URL http://127.0.0.1:5000/ + Baza /.../instance/cantarire.db (64.0 kB, schema v5) + Date 0 componente / 0 produse finite / 1 sabloane + De completat: Datele firmei, Componente, Produse finite (lista apare pe pagina principală) +``` + +Pagina **Cântărire** afișează un checklist până când totul e pregătit. Ordinea firească: + +1. **Setări → Date firmă** — denumirea care apare pe etichetă, telefon. +2. **Setări → Componente** — ce se cântărește (Fasole, Carne, ...). +3. **Setări → Produse finite** — denumirea de pe etichetă + componentele în ordinea cântăririi, + opțional șablonul de etichetă propriu. +4. **Setări → Șabloane** — dacă vrei alt design decât cel implicit (designer vizual). +5. **Setări → Câmpuri** — opțional, texte reutilizabile pentru etichete. +6. **Setări → Cântar** — alege profilul activ din `instance/bridge.json`. + +Verificare rapidă, din consolă: + +```bash +python -m cantarire.tools.admin status +python -m cantarire.tools.admin check +``` + +`check` întoarce cod 0 doar când elementele obligatorii sunt completate (util în scripturi). + +## 4. Cântarul + +Profilul serial stă în `instance/bridge.json`. Dacă nu ai încă unul: + +```bash +python -m cantarire.tools.monitor --list +python -m cantarire.tools.monitor COM7 --baud 9600 +``` + +Monitorul doar ascultă (nu scrie pe port), decodează cadrele cunoscute și poate salva o captură +pentru analiză. Când ai o captură, profilul se adaugă în `bridge.json` (sau din +**Setări → Monitor serial**). Pentru teste fără hardware: + +```bash +CANTARIRE_SIMULATE=mahak_mds python run.py +``` + +Fără profil activ aplicația funcționează normal, cu introducere manuală a greutății. + +## 5. Imprimanta (QZ Tray) + +1. Instalează QZ Tray pe stație și pornește-l. +2. Deschide aplicația: pastila **QZ Tray** din dreapta sus trebuie să devină verde. +3. **Setări → Imprimantă implicită** → alege imprimanta termică. +4. **Tipărește test** — trebuie să iasă o etichetă demo. + +Dacă QZ nu răspunde, aplicația cade automat pe tipărirea din browser (fereastră de print), +deci eticheta iese oricum. + +## 6. Producție + +```bash +python run.py --production # waitress, 0.0.0.0:5000 +python run.py --production --port 8080 +``` + +Serverul de dezvoltare Flask nu se folosește în producție. `--production` pornește +**waitress** (WSGI, 8 fire) și afișează un avertisment dacă `CANTARIRE_SECRET_KEY` lipsește. + +### Linux (systemd) + +```bash +sudo useradd -r -s /usr/sbin/nologin cantarire +sudo mkdir -p /opt/cantarire-etichetare /var/lib/cantarire +sudo chown -R cantarire:cantarire /opt/cantarire-etichetare /var/lib/cantarire +# copiază proiectul în /opt/cantarire-etichetare, apoi: +sudo cp deploy/cantarire.service /etc/systemd/system/ +sudo systemctl edit --full cantarire # verifică utilizatorul și calea +sudo systemctl enable --now cantarire +``` + +`EnvironmentFile=-/etc/cantarire.env` conține variabilele din `.env.example`; fișierul e opțional. +Serviciul rulează cu `ProtectSystem=full` și scrie doar în `/var/lib/cantarire`. + +### Windows (stația de producție) + +Rulează `deploy\start-windows.bat`. Scriptul creează `.venv` dacă lipsește, generează o cheie de +sesiune în `instance\secret.key` (refolosită la următoarele porniri) și pornește serverul. + +Pentru pornire automată: creează un shortcut către acel `.bat` în +`shell:startup` (Win+R → `shell:startup`). + +## 7. Backup și reset + +```bash +python -m cantarire.tools.admin backup # instance/backups/cantarire-.db +python -m cantarire.tools.admin backup --keep 30 # păstrează ultimele 30 +python -m cantarire.tools.admin reset --yes # golește datele (face backup automat) +python -m cantarire.tools.admin seed-demo # repune datele demo pentru instruire +``` + +Backup-ul folosește API-ul SQLite (consistent și cu WAL activ), nu o simplă copiere de fișier. +Din interfață: **Setări → Descarcă backup** produce un backup proaspăt și îl descarcă. + +`reset` șterge componente, produse finite, pachete, șabloane și câmpuri, readuce setările +implicite și recreează șablonul de etichetă standard — exact starea de după o instalare nouă. + +## 8. Monitorizare + +| Endpoint | Rol | +|---|---| +| `GET /healthz` | JSON cu starea bazei (integritate, schema, număr de înregistrări) și `setup.ready`; `503` dacă baza e inaccesibilă | +| `GET /setari` | cardul **Sistem**: cale, mărime, integritate, backup-uri | + +Jurnalele ajung la stdout (systemd: `journalctl -u cantarire -f`). Polling-ul greutății +(`/api/weight`) este filtrat din jurnal ca să nu umple discul. + +## 9. Actualizare + +```bash +python -m cantarire.tools.admin backup +git pull +pip install -r requirements.txt +sudo systemctl restart cantarire # Windows: închide și redeschide start-windows.bat +``` + +Migrările de schemă rulează automat la pornire (v1 → v5); coloanele noi se adaugă fără pierdere +de date. Rulează `check` după actualizare. + +## 10. Dacă ceva nu merge + +| Simptom | Cauză probabilă | +|---|---| +| Lista de pornire nu dispare | lipsește un produs finit **cu componente** active | +| Pastila QZ e roșie | agentul QZ Tray nu e pornit; pornește-l și apasă **Conectează** | +| Greutatea nu se mișcă | profil greșit sau port ocupat — verifică în **Monitor serial** | +| `Database is locked` | două instanțe pe aceeași bază; oprește serviciul vechi | +| Eticheta iese goală | produsul finit e legat de un șablon șters — verifică **Șabloane** și resetează designul | +| `503` la `/healthz` | baza e coruptă sau fără drepturi de scriere; restaurează ultimul backup | diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcef411 --- /dev/null +++ b/README.md @@ -0,0 +1,421 @@ +# Cântărire & Etichetare — produs finit + +Aplicație internă pentru industria alimentară: cântărește **un produs finit din 1–2 componente** +(ex. fasole + carne), calculează totalul, generează eticheta și o tipărește pe imprimanta termică +prin **QZ Tray**. Cântarul este citit direct prin biblioteca vendorizată SyncPulse Scale Bridge. + +Eticheta tipică: + +``` + Moto Adv SRL + Fasole cu carne + 450 g + 250 g Fasole + 200 g Carne + Lot: LOT-2610-A · 18.10.2026 20:15 + Operator: Maria +``` + +## Fluxul de lucru + +Aplicația are două taburi: **Cântărire** (ecranul de producție) și **Setări** (administrare: +componente, produse finite, șabloane, câmpuri, istoric, monitor serial). În Setări, secțiunile +sunt accesibile din bara secundară de sub antet sau din cardurile paginii principale. + +1. Se pregătesc caserolele. +2. Se alege **produsul finit** (ex. *Fasole cu carne*) și lotul. +3. Se pune prima componentă pe cântar; greutatea stabilă se acceptă cu **Enter**. +4. Se adaugă a doua componentă; când greutatea este stabilă, **Enter**. +5. Aplicația calculează totalul, salvează pachetul și **tipărește eticheta**. +6. Eticheta se aplică pe caserolă după sigilare. **Enter** pornește următorul pachet. + +Dacă cântarul nu este încă conectat, același flux merge cu introducere manuală a greutății +(butonul *Introducere manuală*). Conectivitatea cântarului poate fi activată ulterior, fără +modificări în aplicație. + +## Model de date + +| Entitate | Rol | +|---|---| +| **Componentă** | Ce se pune pe cântar: fasole, carne, garnitură. Cod, nume, unitate. | +| **Produs finit (rețetă)** | Denumirea de pe etichetă + lista ordonată de componente, cu greutăți țintă opționale; poate avea propriul șablon de etichetă. | +| **Pachet** | O etichetă: produsul finit, lotul, operatorul, componentele cântărite, totalul. | +| **Componentele pachetului** | Fiecare cântărire, în ordine, cu greutate, unitate, sursă (cântar/manual). | + +O rețetă poate avea 1–4 componente (fluxul curent folosește 1–2). Ordinea din rețetă este +ordinea în care aplicația cere cântăririle. + +Fiecare produs finit poate fi **legat de un șablon de etichetă** (câmpul *Șablon de etichetă* +din formular). La pornirea unui pachet, șablonul se alege în ordinea: + +1. șablonul cerut explicit în momentul pornirii (dacă există); +2. șablonul produsului finit; +3. șablonul implicit din Setări; +4. primul șablon disponibil. + +Un șablon legat de un produs finit **activ** nu poate fi șters, ca să nu rămână produse fără +etichetă. + +Unitățile sunt convertite automat: dacă rețeta este în **g** și cântarul raportează **kg**, +valoarea este transformată înainte de salvare. + +## Cerințe + +- Python 3.10+ +- Un profil de cântar în `instance/bridge.json` (opțional; fără el se folosește introducerea manuală) +- **QZ Tray 2.2.x** pe stație, cu agentul pornit (`localhost:8181`), pentru tipărire directă +- Imprimantă termică de etichete separată de cântar + +## Instalare și pornire + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt # sau requirements-dev.txt pentru teste +python run.py # dezvoltare: http://127.0.0.1:5000 +python run.py --production # producție: waitress, 0.0.0.0:5000 +python run.py --host 0.0.0.0 --port 5000 +``` + +Baza de date, `bridge.json` și capturile se creează automat în `instance/`. La pornire, consola +arată URL-ul, calea bazei și ce mai lipsește pentru prima cântărire. + +Ghid complet de punere în funcțiune (systemd, Windows, QZ Tray, backup, actualizare): +**[DEPLOY.md](DEPLOY.md)**. + +Fără hardware, pentru demonstrație: + +```bash +CANTARIRE_SIMULATE=toledo_cont python run.py +``` + +> **Securitate:** aplicația nu are autentificare proprie. Implicit ascultă doar pe `127.0.0.1`. +> Dacă o expui în rețea, folosește-o doar într-o rețea de încredere sau pune un reverse proxy cu +> autentificare în față. În producție setează `CANTARIRE_SECRET_KEY` (`python run.py --production` +> te avertizează dacă lipsește). + +## Configurare + +| Variabilă | Implicit | Semnificație | +|---|---|---| +| `CANTARIRE_SECRET_KEY` | `dev-only-change-me` | cheia Flask | +| `CANTARIRE_HOST` / `CANTARIRE_PORT` | `127.0.0.1` / `5000` | adresa de ascultare | +| `CANTARIRE_INSTANCE` | `./instance` | folderul de date | +| `CANTARIRE_DATABASE` | `instance/cantarire.db` | baza SQLite | +| `CANTARIRE_BRIDGE_CONFIG` | `instance/bridge.json` | configurația SyncPulse | +| `CANTARIRE_SIMULATE` | gol | cântare simulate | +| `CANTARIRE_MAX_AGE_MS` | `1500` | cât de veche poate fi o citire | +| `CANTARIRE_WAIT_MS` | `2500` | cât așteptăm o citire proaspătă | +| `CANTARIRE_AUTO_PRINT` | `1` | permite auto-printul (combinat cu setarea din UI) | +| `CANTARIRE_SERVER` | `flask` | `waitress` pentru producție | + +Din **Setări** se configurează: datele firmei, profilul de cântar activ, șablonul implicit, +operatorul implicit și tipărirea automată. + +## Administrare + +```bash +python -m cantarire.tools.admin status # cale, mărime, integritate, număr de înregistrări +python -m cantarire.tools.admin check # e aplicația gata de cântărit? (cod 1 dacă lipsește ceva) +python -m cantarire.tools.admin backup # backup în instance/backups/ (păstrează ultimele 20) +python -m cantarire.tools.admin reset --yes # golește datele, reface setările + șablonul (cu backup) +python -m cantarire.tools.admin seed-demo # date demo pentru instruire +``` + +`GET /healthz` întoarce starea bazei și `setup.ready` (util pentru monitorizare); din interfață, +**Setări → Sistem** arată aceleași informații și oferă **Descarcă backup**. + +## Cântarul: Bizerba BC800 + +BC800 este folosit **doar ca cântar**, conectat prin RJ11 / 10P4C la un adaptor USB–serial. +Etichetele se tipăresc pe o imprimantă termică separată, prin QZ Tray. + +Biblioteca vendorizată nu are un parser Bizerba. Ca să afli ce trimite cântarul, fără a scrie +nimic către el: + +```bash +python -m cantarire.tools.monitor --list +python -m cantarire.tools.monitor /dev/ttyUSB0 --seconds 8 +python -m cantarire.tools.monitor COM3 --scan +python -m cantarire.tools.monitor COM3 --baud 9600 --framing 7E1 --save bizerba +``` + +sau din interfață: **Setări → Monitor serial** (`/monitor`). Pagina arată cadrele hex/text, +încearcă toate parserele existente și salvează captura în `instance/captures/`. + +Setări de încercat: `9600 8N1`, `9600 7E1`, `4800 7E1`, `9600 7O1`. Dacă BC800 trimite doar la +apăsarea **Print** pe indicator, ține monitorul pornit și apasă. + +**Cablaj:** adaptorul USB–RJ11 funcționează doar dacă priza cântarului are TX/RX/GND la nivel +RS-232, în aceeași ordine cu adaptorul. Unele prize modulare sunt TTL sau current-loop. Verifică +pinoutul în manualul BC800 înainte de conectare. Preferă adaptoare FTDI/CP210x. + +Odată ce există o captură, se adaugă parserul în `scale_bridge/protocols.py` (sau, dacă formatul +este text simplu, se configurează un profil `custom_ascii`). + +## Etichete + +Etichetele se definesc **exclusiv în designerul vizual**, la dimensiune fizică în milimetri +(implicit 100 × 60 mm). Fiecare șablon este un set de blocuri poziționate pe etichetă; vechiul +mod HTML/CSS (Jinja) a fost scos, ca să existe un singur fel de a construi o etichetă. + +Șabloanele vechi care aveau doar HTML primesc automat designul implicit la prima pornire, deci +nicio etichetă nu rămâne fără conținut. + +### Designer vizual + +Din **Șabloane → + Șablon nou** (sau **Designer** pe un șablon existent): + +- la creare scrii numele șablonului în câmpul de lângă buton; dacă lași gol, se generează + „Sablon designer" / „Sablon designer 2" etc. Numele trebuie să fie unic (fără diferență între + majuscule și minuscule) +- numele poate fi schimbat oricând direct din antetul designerului: îl editezi pe loc și apeși + **Redenumește** +- bara de sus: butoanele de adaugare (**Câmp variabil**, Text, Cod QR, Linie, Imagine), +dimensiunile etichetei în mm, zoom-ul și comutatorul pentru zona sigură +- **Câmp variabil** deschide un popup cu toate datele disponibile (grupate pe Produs finit / +Pachet / Firmă / Câmpuri proprii) plus intrarea „Defalcare componente"; click pe o intrare adaugă +blocul corespunzător (`field` sau `items`) cu câmpul deja setat. Popup-ul are căutare, se închide cu +Escape sau click pe fundal, marchează „ultimul" câmp folosit și are link direct către +**Câmpuri** (administrarea câmpurilor proprii). +- coloana din stânga listează elementele deja adăugate, cu numărul lor; un click selectează + elementul, care se editează în coloana din dreapta +- poziționare în mm, grid de 10 mm, mutare cu mouse-ul sau cu săgețile (Shift = 2 mm) +- redimensionare din colțul din dreapta-jos; proprietăți pentru mărime, aldine, aliniere, culoare +- previzualizare live cu date demo, identică cu eticheta tipărită +- **Salvează** scrie designul ca JSON în `label_templates.design_json` + +#### Zona sigură + +Conturul roșu întrerupt de la 3 mm de margine (`designer.SAFE_MARGIN_MM`) arată zona în care +imprimanta termică poate tipări sigur. Elementele care ies din el sunt marcate cu roșu, iar în +bara de sus apare numărul lor („2 elemente ies din zona sigură"); la salvare primești un +avertisment, dar designul este salvat. Este o recomandare, nu o constrângere: limitele dure rămân +marginile fizice ale etichetei. Conturul poate fi ascuns din caseta „Contur roșu" din bara de sus +(alegerea se ține minte în browser). + +Când eticheta este micșorată (de exemplu de la 150 × 80 mm la 60 × 40 mm), elementele nu mai sunt +mutate automat — rămân acolo unde au fost proiectate și sunt marcate ca fiind în afara zonei +sigure. Pentru a le readuce, apasă **„Rearanjează în zona sigură"** (apare doar când există +elemente în afara conturului): întregul aranjament este scalat proporțional (poziții, mărimi și +fonturi) până încape în contur și este translatat în colțul zonei sigure, apoi rotunjit la 0,1 mm. +Se păstrează deci aspectul etichetei, doar la o scară mai mică, iar elementele nu se suprapun; +mesajul de confirmare arată procentul folosit („8 elemente aduse în zona sigură (38% din mărime)"). +Scalarea nu mărește niciodată designul, deci la refacerea unei etichete mai mari elementele rămân +la dimensiunea lor. + +Zoom-ul redimensionează canvas-ul, iar toate pozițiile și mărimile de font se recalculează în +pixeli la scara curentă, deci ce vezi pe ecran corespunde 1:1 cu eticheta la tipărire. + +### Câmpuri variabile proprii + +Pagina **Câmpuri** (`/campuri`) administrează câmpurile care apar în designer lângă cele fixe. +Un câmp propriu este un text liber care poate prelua date existente, scrise între acolade: + +| Denumire | Text | Rezultat pe etichetă | +|---|---|---| +| Contact firmă | `Tel. {{company.phone}}` | `Tel. 0700 000 000` | +| Etichetă produs | `{{recipe.name}} · {{package.lot}}` | `Fasole cu carne · LOT-2610-A` | +| Alergeni | `Poate conține urme de gluten` | text fix | + +- cheia (`custom.contact_firma`) este generată automat din denumire, transliterată (ă→a, ș→s, + ț→t) și rămâne aceeași la editare, ca designurile să nu se strice +- formularul are previzualizare live cu date demo și butoane pentru inserarea unui câmp +- lista arată rezultatul demo și în câte șabloane este folosit câmpul +- ștergerea unui câmp lasă designurile valide, dar blocurile respective apar goale (avertisment + la ștergere dacă e folosit) +- câmpurile proprii pot referi **doar câmpuri fixe**, deci nu pot intra în buclă; un nume + necunoscut este respins la salvare +- câmpurile inactive rămân definite, dar nu mai apar în popup-ul din designer + +Serverul expandează textul la fiecare randare, deci eticheta tipărită folosește datele reale ale +pachetului, nu valorile demo. + +Blocurile și câmpurile disponibile: + +| Tip bloc | Ce afișează | +|---|---| +| `text` | text liber (ex. „PRODUS FINIT") | +| `field` | un câmp din tabelul de mai jos, cu prefix/sufix opțional | +| `items` | toate componentele cântărite (ex. `250 g Fasole`, `200 g Carne`), pe linii sau inline | +| `qr` | cod QR generat pe server (SVG); conținutul se compune din câmpuri alese | +| `line` | linie de separare | +| `image` | logo din `/static/…`, `https://…` sau `data:image/…` | + +Câmpuri pentru blocul `field`: `recipe.name`, `recipe.code`, `package.total_weight`, +`package.weight_unit`, `package.unit`, `package.lot`, `package.operator`, +`package.completed_at`, `package.id`, `company.name`, `company.address`, `company.phone`. + +#### Conținutul codului QR + +Un bloc `qr` nu mai e legat de un singur câmp: are o **listă ordonată de părți**, fiecare fiind +un câmp variabil sau un text liber, plus un separator. + +- **+ Adaugă câmp** deschide selectorul în mod multiplu: bifezi câte câmpuri vrei, în ordinea în + care le adaugi, și confirmi cu **Adaugă (n)**. Intrarea „Conținut complet” adaugă tot pachetul + (produs, lot, greutate, dată) ca o singură parte. +- **+ Text liber** adaugă o parte fixă pe care o editezi direct în listă (ex. `LOT`). +- Fiecare parte are **↑ / ↓** pentru ordine și **×** pentru ștergere. +- **Separator**: bară verticală, liniuță, slash, spațiu, rând nou sau niciunul. +- Sub listă apare textul rezultat, calculat cu datele demo — exact ce va conține codul. + +Exemplu: `PF-001` + text `LOT` + `package.lot`, separator liniuță → `PF-001 - LOT - LOT-2610-A`. +Părțile goale (ex. un lot necompletat) sunt sărite automat. Dacă lista rămâne fără părți, se +folosește conținutul complet implicit, ca să nu iasă un cod gol. + +Modelul salvat: `{"type": "qr", "separator": "pipe", "parts": [{"field": "recipe.code"}, +{"text": "LOT"}, {"field": "package.lot"}]}`. Designurile vechi, cu un singur `field`, sunt +convertite automat într-o singură parte. + +Designul este revalidat pe server la salvare și la randare: coordonatele sunt limitate la +etichetă, textul este escapat, imaginile externe sunt restricționate la adrese permise. + +Butonul **Resetează designul** readuce șablonul la aranjamentul implicit, la dimensiunile lui +curente — util dacă vrei să o iei de la zero fără să creezi alt șablon. + +Codul QR este generat pe server (`qrcode`, SVG inline), deci eticheta nu depinde de servicii +externe și funcționează și fără internet. + +### Tipărire prin QZ Tray + +Biblioteca `static/js/vendor/qz-tray.js` este QZ Tray Connector 2.2.4, **patch-uită** pentru +autentificare cu pairing key (fără schimb de certificat), la fel ca în proiectul de referință +`quality_app-v2`. `static/js/qz-print.js` expune: + +```js +window.CantarirePrint.print(payload) // Promise<{ok, method:'qz'|'browser', printer?}> +window.CantarireQZ = { getState, connect, disconnect, refreshPrinters, setPrinter, printTest } +``` + +Eticheta este trimisă direct la imprimantă, la dimensiunea șablonului: + +```js +qz.configs.create(printer, { + scaleContent: false, rasterize: true, + size: { width: 100, height: 60 }, units: 'mm', + margins: { top: 0, right: 0, bottom: 0, left: 0 } +}); +await qz.print(config, [{ type: 'html', format: 'plain', data: fullLabelHtml }]); +``` + +Dacă agentul QZ nu răspunde, eticheta se tipărește prin dialogul browserului (pagina de etichetă), +fără a bloca postul de lucru. Selecția imprimantei se face din **Setări** sau din cardul de +cântărire și se păstrează în `localStorage`. + +Pentru QZ Tray standard (certificat + semnătură), definește înainte de `qz-print.js`: + +```html + +``` + +## API JSON + +| Metodă | Rută | Descriere | +|---|---|---| +| `GET` | `/api/scale` | stare bibliotecă, profile, profil activ | +| `GET` | `/api/weight` | citire curentă a cântarului | +| `GET` | `/api/package` | pachetul deschis (dacă există) | +| `POST` | `/api/packages` | începe un pachet: `{recipe_id, lot, operator}` | +| `POST` | `/api/packages//items` | adaugă următoarea cântărire | +| `POST` | `/api/packages//undo` | anulează ultima cântărire | +| `POST` | `/api/packages//void` | renunță la pachet | +| `GET` | `/api/packages//label` | payload-ul de tipărire | +| `POST` | `/api/packages//printed` | confirmă tipărirea | + +`POST /items` acceptă: + +- `{"weight": "250"}` — introducere manuală; +- `{}` — citește cântarul (profilul activ); `force: true` acceptă o greutate instabilă. + +Răspunsul conține starea completă și, după ultima componentă, `label`: + +```json +{ + "ok": true, + "package": { "id": 1, "status": "done", "total_weight": 450, "unit": "g" }, + "items": [ + { "position": 1, "component_name": "Fasole", "weight": 250, "unit": "g" }, + { "position": 2, "component_name": "Carne", "weight": 200, "unit": "g" } + ], + "step": null, + "complete": true, + "label": { + "html": "
…
", + "css": ".label { … }", + "fields": { "breakdown": "250 g Fasole, 200 g Carne", "total_weight": "450" }, + "print_url": "/labels/1/print", + "ack_url": "/api/packages/1/printed" + } +} +``` + +Erori: `409` pachet deja început / citire veche, `422` greutate instabilă, `400` date invalide. + +## Structura proiectului + +``` +cantarire-etichetare/ +├── run.py +├── cantarire/ +│ ├── __init__.py # create_app +│ ├── config.py # configurare din mediu +│ ├── db.py / schema.sql # SQLite, migrare v1→v5, seed +│ ├── repositories.py # componente, rețete, pachete, șabloane, câmpuri proprii +│ ├── units.py # unități de masă și conversii +│ ├── scale.py # adaptor peste vendor/scale_bridge +│ ├── probe.py # monitor serial / identificare cântar nou +│ ├── maintenance.py # backup, reset, verificare de pregătire, stare bază +│ ├── designer.py # modelul designerului, validare, randare HTML + QR +│ ├── logging_setup.py # jurnal de acces fără zgomotul polling-ului +│ ├── labels.py # randare etichete (din design) + payload QZ +│ ├── routes/ # main, components, recipes, history, printing, api, monitor, +│ │ # label_templates, fields (câmpuri variabile) +│ ├── tools/monitor.py # CLI monitor serial +│ ├── tools/admin.py # CLI: status, check, backup, reset, seed-demo +│ ├── templates/ # Jinja (fără build frontend) +│ └── static/ # CSS + JS; js/designer.js; js/vendor/qz-tray.js (LGPL-2.1) +├── instance/ # date de rulare (generat, ignorat de git) +├── deploy/ # cantarire.service (systemd), start-windows.bat +├── DEPLOY.md # ghid de punere în funcțiune +├── tests/ +└── vendor/syncpulse-scale-bridge/ +``` + +Migrarea de la modelul vechi este automată: `products` → `components`, `weighings` → +`legacy_weighings` (arhivă), coloanele `design_json` și `recipes.template_id` sunt adăugate dacă +lipsesc, tabelul `label_fields` este creat automat, iar șablonul implicit este înlocuit cu cel +pentru produs finit. + +## Teste + +```bash +pip install -r requirements-dev.txt +pytest +``` + +Testele folosesc un cântar fals și o bază SQLite temporară. Acoperă: crearea componentelor și +rețetelor, fluxul în doi pași, conversia de unități, starea de instabilitate, anulare/renunțare, +istoric + CSV, validarea șabloanelor, designerul vizual (validare, salvare, randare, QR, zonă +sigură), câmpurile variabile proprii (CRUD, chei, expandare, randare pe etichetă), navigația +(cele două taburi și subnavigația de administrare), legarea produs → șablon, backup/reset și +verificarea de pregătire, monitorul serial și activele QZ. + +## Licență și atribuire + +Codul aplicației aparține proiectului `cantarire-etichetare`. + +Designerul vizual este inspirat de modelul de date al +[`luomus/label-designer`](https://github.com/luomus/label-designer) (MIT, LUOMUS — Finnish Museum +of Natural History): blocuri poziționate în milimetri, separate de datele afișate. Implementarea +de aici este independentă (fără Angular, fără build frontend), iar randarea se face pe server. +Dacă vrei să extinzi designerul, proiectul original rămâne o referință bună pentru funcții +avansate (pagini multiple, import Excel, coduri QR cu nivel de corecție). + +- `vendor/syncpulse-scale-bridge` este **GPL-3.0-or-later**, Copyright (C) 2026 Farshad Zamani — + Cloudtart, inclus nemodificat. Păstrează `LICENSE`, `NOTICE.md` și atribuirea autorului în + interfață (condiție GPLv3 §7(b)). +- `cantarire/static/js/vendor/qz-tray.js` este **QZ Tray Connector 2.2.4**, **LGPL-2.1-only**, + copiat din `quality_app-v2` cu patch-ul pentru pairing key. Header-ul de licență este păstrat; + vezi și `cantarire/static/js/vendor/README.md`. diff --git a/cantarire/__init__.py b/cantarire/__init__.py new file mode 100644 index 0000000..9e5c66f --- /dev/null +++ b/cantarire/__init__.py @@ -0,0 +1,60 @@ +"""Aplicatia Flask: fabrica, blueprint-uri, filtre Jinja.""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from flask import Flask + +from . import db +from .config import Config +from .labels import _format_datetime, _format_weight +from .logging_setup import configure_logging +from .scale import create_scale + + +def create_app(test_config: dict | None = None) -> Flask: + app = Flask(__name__, instance_relative_config=False) + app.config.from_object(Config) + if test_config: + app.config.update(test_config) + + instance_dir = Path(app.config["INSTANCE_DIR"]) + instance_dir.mkdir(parents=True, exist_ok=True) + Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True) + + db.register(app) + create_scale(app) + if not app.config.get("TESTING"): + configure_logging() + + with app.app_context(): + db.init_db() + + from .routes import (api, components, fields, history, label_templates, main, monitor, + printing, recipes) + + app.register_blueprint(main.bp) + app.register_blueprint(components.bp) + app.register_blueprint(recipes.bp) + app.register_blueprint(label_templates.bp) + app.register_blueprint(fields.bp) + app.register_blueprint(printing.bp) + app.register_blueprint(history.bp) + app.register_blueprint(monitor.bp) + app.register_blueprint(api.bp) + + app.jinja_env.filters["weight"] = _format_weight + app.jinja_env.filters["dt"] = _format_datetime + + @app.context_processor + def inject_globals(): + from .db import all_settings + + return { + "app_settings": all_settings(), + "current_year": datetime.now().year, + "app_version": "0.1.0", + } + + return app diff --git a/cantarire/config.py b/cantarire/config.py new file mode 100644 index 0000000..6abeb5b --- /dev/null +++ b/cantarire/config.py @@ -0,0 +1,40 @@ +"""Configurarea aplicatiei (variabile de mediu, fara secrete in cod).""" +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Cheia folosita cand nu exista CANTARIRE_SECRET_KEY; NU se foloseste in productie. +DEV_SECRET_KEY = "dev-only-change-me" + + +def _env_flag(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +class Config: + # Flask + SECRET_KEY = os.environ.get("CANTARIRE_SECRET_KEY", DEV_SECRET_KEY) + MAX_CONTENT_LENGTH = 2 * 1024 * 1024 + + # Date de rulare: baza SQLite, bridge.json, jurnale de etichete + INSTANCE_DIR = Path(os.environ.get("CANTARIRE_INSTANCE", BASE_DIR / "instance")) + DATABASE = INSTANCE_DIR / "cantarire.db" + BRIDGE_CONFIG = Path(os.environ.get("CANTARIRE_BRIDGE_CONFIG", INSTANCE_DIR / "bridge.json")) + + # CANTARIRE_SIMULATE="mahak_mds,toledo_cont:4800:7E1" porneste cantoare simulate + SIMULATE = os.environ.get("CANTARIRE_SIMULATE", "").strip() + + # Cat de proaspata / cat de mult asteptam o citire de la cantar + WEIGHT_MAX_AGE_MS = int(os.environ.get("CANTARIRE_MAX_AGE_MS", "1500")) + WEIGHT_WAIT_MS = int(os.environ.get("CANTARIRE_WAIT_MS", "2500")) + + # Printarea automata din browser (plugin extern); se poate dezactiva global + AUTO_PRINT = _env_flag("CANTARIRE_AUTO_PRINT", True) + + JSON_AS_ASCII = False diff --git a/cantarire/db.py b/cantarire/db.py new file mode 100644 index 0000000..be22100 --- /dev/null +++ b/cantarire/db.py @@ -0,0 +1,172 @@ +"""Conexiunea SQLite, migrarea de la modelul vechi si setarile aplicatiei.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any, Optional + +import click +from flask import current_app, g + +SCHEMA_PATH = Path(__file__).with_name("schema.sql") +SCHEMA_VERSION = 5 + +DEFAULT_SETTINGS: dict[str, str] = { + "company_name": "Firma SRL", + "company_address": "", + "company_phone": "", + "active_scale_profile": "", + "default_template_id": "", + "auto_print": "1", + "default_operator": "", +} + +DEFAULT_TEMPLATE_NAME = "Eticheta produs finit" + + +def _default_design_json(width: float = 100.0, height: float = 60.0) -> str: + """Designul implicit (import lazy: `designer` depinde de `repositories`).""" + from . import designer + + return designer.dump_design(designer.default_design(width, height)) + + +def get_db() -> sqlite3.Connection: + if "db" not in g: + path = Path(current_app.config["DATABASE"]) + path.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(path, detect_types=sqlite3.PARSE_DECLTYPES) + db.row_factory = sqlite3.Row + db.execute("PRAGMA foreign_keys = ON") + db.execute("PRAGMA journal_mode = WAL") + g.db = db + return g.db + + +def close_db(_exc: Optional[BaseException] = None) -> None: + db = g.pop("db", None) + if db is not None: + db.close() + + +def _table_names(db: sqlite3.Connection) -> set[str]: + return {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + + +def _migrate_legacy(db: sqlite3.Connection) -> None: + """Modelul vechi (`products` + `weighings`) devine `components` + istoric arhivat.""" + tables = _table_names(db) + if "components" not in tables and "products" in tables: + db.execute("ALTER TABLE products RENAME TO components") + if "packages" not in tables and "weighings" in tables: + db.execute("ALTER TABLE weighings RENAME TO legacy_weighings") + + +def _schema_version(db: sqlite3.Connection) -> int: + """Versiunea schemei salvata in setari (0 daca lipseste).""" + if "settings" not in _table_names(db): + return 0 + row = db.execute("SELECT value FROM settings WHERE key = 'schema_version'").fetchone() + try: + return int(row["value"]) if row is not None else 0 + except (TypeError, ValueError): + return 0 + + +def _seed_settings(db: sqlite3.Connection) -> None: + for key, value in DEFAULT_SETTINGS.items(): + db.execute("INSERT OR IGNORE INTO settings(key, value) VALUES (?, ?)", (key, value)) + + +def _seed_template(db: sqlite3.Connection) -> None: + row = db.execute( + "SELECT id FROM label_templates ORDER BY is_default DESC, id LIMIT 1" + ).fetchone() + if row is None: + cur = db.execute( + "INSERT INTO label_templates(name, width_mm, height_mm, body_html, css," + " design_json, is_default) VALUES (?, ?, ?, ' ', '', ?, 1)", + (DEFAULT_TEMPLATE_NAME, 100.0, 60.0, _default_design_json(100.0, 60.0)), + ) + db.execute( + "INSERT OR REPLACE INTO settings(key, value) VALUES ('default_template_id', ?)", + (str(cur.lastrowid),), + ) + return + + # Sabloanele vechi (doar HTML/CSS) primesc designul implicit, ca eticheta sa iasa mereu. + legacy = db.execute( + "SELECT id, width_mm, height_mm FROM label_templates" + " WHERE design_json IS NULL OR TRIM(design_json) = ''" + ).fetchall() + for item in legacy: + width = float(item["width_mm"] or 100.0) + height = float(item["height_mm"] or 60.0) + db.execute( + "UPDATE label_templates SET design_json = ?, updated_at = datetime('now')" + " WHERE id = ?", + (_default_design_json(width, height), item["id"]), + ) + + +def _add_missing_columns(db: sqlite3.Connection) -> None: + """Coloane adaugate dupa prima versiune a schemei.""" + tables = _table_names(db) + if "label_templates" in tables: + columns = {row[1] for row in db.execute("PRAGMA table_info(label_templates)")} + if "design_json" not in columns: + db.execute( + "ALTER TABLE label_templates ADD COLUMN design_json TEXT NOT NULL DEFAULT ''" + ) + if "recipes" in tables: + columns = {row[1] for row in db.execute("PRAGMA table_info(recipes)")} + # Sablonul de eticheta al fiecarui produs finit (v5). + if "template_id" not in columns: + db.execute( + "ALTER TABLE recipes ADD COLUMN template_id INTEGER" + " REFERENCES label_templates(id) ON DELETE SET NULL" + ) + + +def init_db() -> None: + """Creeaza/migreaza tabelele, setarile si sablonul implicit.""" + db = get_db() + _migrate_legacy(db) + db.executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + _add_missing_columns(db) + _seed_settings(db) + _seed_template(db) + db.execute( + "INSERT OR REPLACE INTO settings(key, value) VALUES ('schema_version', ?)", + (str(SCHEMA_VERSION),), + ) + db.commit() + + +def get_setting(key: str, default: str = "") -> str: + row = get_db().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row is not None else default + + +def set_setting(key: str, value: Any) -> None: + db = get_db() + db.execute( + "INSERT INTO settings(key, value) VALUES (?, ?)" + " ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, str(value)), + ) + db.commit() + + +def all_settings() -> dict[str, str]: + return {row["key"]: row["value"] for row in get_db().execute("SELECT key, value FROM settings")} + + +def init_db_command() -> None: + init_db() + click.echo("Baza de date a fost initializata.") + + +def register(app) -> None: + app.teardown_appcontext(close_db) + app.cli.add_command(click.command("init-db")(init_db_command)) diff --git a/cantarire/designer.py b/cantarire/designer.py new file mode 100644 index 0000000..56207aa --- /dev/null +++ b/cantarire/designer.py @@ -0,0 +1,681 @@ +"""Designer de etichete: model, validare si randare HTML. + +Un sablon este descris prin `design_json` — blocuri pozitionate in milimetri, +editate vizual in browser. Randarea se face integral pe server. + +Modelul de design (v1): + + { + "version": 1, + "width_mm": 100, "height_mm": 60, + "blocks": [ + {"id": "b1", "type": "text", "x": 4, "y": 4, "w": 60, "h": 6, + "text": "PRODUS FINIT", "style": {"size": 3.2, "bold": true}}, + {"id": "b2", "type": "field", "x": 4, "y": 10, "w": 60, "h": 12, + "field": "package.total_weight", "style": {"size": 14, "bold": true}}, + {"id": "b3", "type": "items", "x": 4, "y": 26, "w": 60, "h": 20, + "style": {"size": 3.4}}, + {"id": "b4", "type": "qr", "x": 72, "y": 30, "w": 22, "h": 22}, + {"id": "b5", "type": "line", "x": 4, "y": 50, "w": 92, "h": 0.4} + ] + } + +Randarea este facuta integral pe server, in HTML cu pozitionare absoluta in +mm, ca eticheta sa fie identica in previzualizare si la tiparirea QZ Tray. +""" +from __future__ import annotations + +import base64 +import html +import io +import json +import math +import re +from datetime import datetime +from functools import lru_cache +from typing import Any, Iterable, Optional + +from .repositories import ValidationError +from .units import format_weight + +DESIGN_VERSION = 1 +MAX_BLOCKS = 60 +MIN_SIZE_MM = 0.3 +# Marja pe care imprimantele termice nu o pot acoperi, afisata in editor ca +# contur rosu. Nu este impusa la validare, doar recomandata. +SAFE_MARGIN_MM = 3.0 +MAX_COLOR = re.compile(r"^#[0-9A-Fa-f]{3,8}$") + +# Campuri disponibile in designer. `sample` este folosit pentru previzualizare. +FIELD_CATALOG: list[tuple[str, str, str]] = [ + ("recipe.name", "Produs finit — denumire", "Fasole cu carne"), + ("recipe.code", "Produs finit — cod", "PF-001"), + ("package.total_weight", "Greutate totală", "450"), + ("package.weight_unit", "Greutate cu unitate", "450 g"), + ("package.unit", "Unitate de măsură", "g"), + ("package.lot", "Lot", "LOT-2610-A"), + ("package.operator", "Operator", "Maria"), + ("package.completed_at", "Data finalizării", "18.10.2026 20:15"), + ("package.id", "Număr pachet", "124"), + ("company.name", "Firmă — denumire", "Moto Adv SRL"), + ("company.address", "Firmă — adresă", "Str. Exemplu 1"), + ("company.phone", "Firmă — telefon", "0700 000 000"), +] + +FIELD_KEYS = {key for key, _label, _sample in FIELD_CATALOG} + +# Campuri proprii (definite din pagina "Campuri"): `custom.`. +CUSTOM_PREFIX = "custom." +CUSTOM_KEY = re.compile(r"^custom\.[a-z0-9_]{1,48}$") +PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_.]+)\s*\}\}") +MAX_CUSTOM_TEMPLATE = 300 +MAX_CUSTOM_LABEL = 60 + +BLOCK_TYPES = ("text", "field", "items", "qr", "line", "image") + +# Continutul codului QR: lista ordonata de campuri si/sau text liber. +MAX_QR_PARTS = 12 +MAX_QR_TEXT = 60 +QR_SEPARATORS: dict[str, str] = { + "pipe": " | ", + "dash": " - ", + "slash": "/", + "space": " ", + "newline": "\n", + "none": "", +} +# Chei acceptate doar in QR (nu sunt campuri de afisat ca text). +QR_EXTRA_KEYS = {"package.qr"} + +DEFAULT_STYLES: dict[str, dict[str, Any]] = { + "text": {"size": 3.2, "bold": False, "italic": False, "align": "left", + "uppercase": False, "color": "#000000"}, + "field": {"size": 4.0, "bold": False, "italic": False, "align": "left", + "uppercase": False, "color": "#000000"}, + "items": {"size": 3.4, "bold": False, "italic": False, "align": "left", + "uppercase": False, "color": "#000000", "separator": "stack"}, +} + + +# --------------------------------------------------------------------------- +# Validare +# --------------------------------------------------------------------------- + +def _number(value: Any, *, default: float, low: float, high: float) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return default + if not math.isfinite(number): + return default + return round(min(max(number, low), high), 3) + + +def _style(raw: Any, kind: str) -> dict[str, Any]: + base = dict(DEFAULT_STYLES.get(kind, DEFAULT_STYLES["text"])) + if isinstance(raw, dict): + base["size"] = _number(raw.get("size"), default=base["size"], low=1.5, high=40) + for flag in ("bold", "italic", "uppercase"): + if flag in raw: + base[flag] = bool(raw[flag]) + align = str(raw.get("align") or base["align"]).lower() + base["align"] = align if align in {"left", "center", "right"} else "left" + color = str(raw.get("color") or base["color"]) + base["color"] = color if MAX_COLOR.match(color) else "#000000" + if kind == "items": + separator = str(raw.get("separator") or base.get("separator", "stack")) + base["separator"] = separator if separator in {"stack", "inline"} else "stack" + return base + + +def _clean_block(raw: Any, index: int, width: float, height: float) -> Optional[dict[str, Any]]: + if not isinstance(raw, dict): + return None + kind = str(raw.get("type") or "").lower() + if kind not in BLOCK_TYPES: + return None + + block = { + "id": str(raw.get("id") or f"b{index + 1}")[:40], + "type": kind, + "x": _number(raw.get("x"), default=0, low=0, high=width), + "y": _number(raw.get("y"), default=0, low=0, high=height), + "w": _number(raw.get("w"), default=max(10.0, width / 2), low=1, high=width), + "h": _number(raw.get("h"), default=6, low=MIN_SIZE_MM, high=height), + "style": _style(raw.get("style"), kind), + } + # Sa nu iasa din eticheta. + block["w"] = min(block["w"], max(1.0, width - block["x"])) + block["h"] = min(block["h"], max(MIN_SIZE_MM, height - block["y"])) + + if kind == "text": + block["text"] = str(raw.get("text") or "")[:200] + elif kind == "field": + field = str(raw.get("field") or "recipe.name")[:60] + if field in FIELD_KEYS or CUSTOM_KEY.match(field): + block["field"] = field + else: + block["field"] = "recipe.name" + block["prefix"] = str(raw.get("prefix") or "")[:40] + block["suffix"] = str(raw.get("suffix") or "")[:40] + elif kind == "qr": + block["separator"], block["parts"] = _clean_qr_parts(raw) + elif kind == "image": + src = str(raw.get("src") or "")[:2000] + if not _image_src_ok(src): + src = "" + block["src"] = src + return block + + +def _image_src_ok(src: str) -> bool: + if not src: + return False + if src.startswith("data:image/"): + return len(src) <= 400_000 + return src.startswith(("http://", "https://", "/static/")) + + +def qr_key_ok(key: str) -> bool: + return key in FIELD_KEYS or key in QR_EXTRA_KEYS or bool(CUSTOM_KEY.match(key)) + + +def _clean_qr_parts(raw: dict[str, Any]) -> tuple[str, list[dict[str, str]]]: + """Continutul QR: separator + lista de `{field}` / `{text}`.""" + separator = str(raw.get("separator") or "pipe") + if separator not in QR_SEPARATORS: + separator = "pipe" + + parts: list[dict[str, str]] = [] + raw_parts = raw.get("parts") + if isinstance(raw_parts, list): + for item in raw_parts: + if len(parts) >= MAX_QR_PARTS: + break + if not isinstance(item, dict): + continue + if item.get("field"): + key = str(item.get("field"))[:60] + if qr_key_ok(key): + parts.append({"field": key}) + elif "text" in item: + text = str(item.get("text") or "")[:MAX_QR_TEXT] + if text: + parts.append({"text": text}) + + # Designuri vechi: doar `field`; il pastram ca o singura parte. + if not parts: + legacy = str(raw.get("field") or "package.qr")[:60] + parts = [{"field": legacy if qr_key_ok(legacy) else "package.qr"}] + return separator, parts + + +def qr_payload(block: dict[str, Any], fields: dict[str, Any]) -> str: + """Textul codat in QR, din partile configurate (goale sunt sarite).""" + parts = block.get("parts") or [] + if not parts: + return str(fields.get(block.get("field") or "package.qr") or "") + separator = QR_SEPARATORS.get(str(block.get("separator") or "pipe"), " | ") + pieces: list[str] = [] + for part in parts: + if "text" in part: + piece = str(part.get("text") or "") + else: + piece = str(fields.get(part.get("field") or "") or "") + if piece: + pieces.append(piece) + return separator.join(pieces) + + +def validate_design(raw: Any, width_mm: float, height_mm: float) -> dict[str, Any]: + """Curata si limiteaza un design primit din browser.""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except ValueError as exc: + raise ValidationError("Designul nu este un JSON valid.") from exc + if not isinstance(raw, dict): + raise ValidationError("Designul trebuie sa fie un obiect JSON.") + + width = _number(raw.get("width_mm"), default=width_mm, low=10, high=500) + height = _number(raw.get("height_mm"), default=height_mm, low=10, high=500) + + blocks_raw = raw.get("blocks") + if not isinstance(blocks_raw, list): + blocks_raw = [] + if len(blocks_raw) > MAX_BLOCKS: + raise ValidationError(f"Maxim {MAX_BLOCKS} elemente pe eticheta.") + + blocks = [] + for index, item in enumerate(blocks_raw): + block = _clean_block(item, index, width, height) + if block is not None: + blocks.append(block) + + return { + "version": DESIGN_VERSION, + "width_mm": width, + "height_mm": height, + "blocks": blocks, + } + + +def dump_design(design: dict[str, Any]) -> str: + return json.dumps(design, ensure_ascii=False, separators=(",", ":")) + + +def load_design(text: str, width_mm: float, height_mm: float) -> Optional[dict[str, Any]]: + if not text: + return None + try: + return validate_design(json.loads(text), width_mm, height_mm) + except (ValueError, ValidationError): + return None + + +# --------------------------------------------------------------------------- +# Valori +# --------------------------------------------------------------------------- + +def _date(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str: + if not value: + return "" + try: + return datetime.fromisoformat(str(value)).strftime(fmt) + except (TypeError, ValueError): + return str(value) + + +def resolve_fields( + package: dict[str, Any], + items: Iterable[dict[str, Any]], + recipe: Optional[dict[str, Any]], + settings: dict[str, str], +) -> dict[str, str]: + """Valorile afisate pe eticheta (deja formatate).""" + recipe = recipe or {} + item_list = list(items) + unit = package.get("unit") or recipe.get("unit") or "" + return { + "recipe.name": str(recipe.get("name") or package.get("recipe_name") or ""), + "recipe.code": str(recipe.get("code") or package.get("recipe_code") or ""), + "package.total_weight": format_weight(package.get("total_weight")), + "package.weight_unit": ( + f"{format_weight(package.get('total_weight'))} {unit}".strip() + ), + "package.unit": str(unit), + "package.lot": str(package.get("lot") or ""), + "package.operator": str(package.get("operator") or ""), + "package.completed_at": _date(package.get("completed_at") or package.get("created_at")), + "package.id": str(package.get("id") or ""), + "company.name": str(settings.get("company_name") or ""), + "company.address": str(settings.get("company_address") or ""), + "company.phone": str(settings.get("company_phone") or ""), + # Text folosit in codul QR: produs, lot, greutate, data. + "package.qr": " | ".join(part for part in ( + str(recipe.get("code") or package.get("recipe_code") or ""), + str(package.get("lot") or ""), + f"{format_weight(package.get('total_weight'))} {unit}".strip(), + _date(package.get("completed_at") or package.get("created_at"), "%Y-%m-%d"), + ) if part), + "_items": item_list, # type: ignore[dict-item] + } + + +def demo_fields() -> dict[str, Any]: + """Aceleasi valori ca in designer, ca previzualizarea sa fie fidela.""" + demo_package = { + "id": 124, + "recipe_code": "PF-001", + "recipe_name": "Fasole cu carne", + "unit": "g", + "total_weight": 450, + "lot": "LOT-2610-A", + "operator": "Maria", + "completed_at": "2026-10-18T20:15:00+03:00", + } + demo_items = [ + {"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"}, + {"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"}, + ] + demo_recipe = {"code": "PF-001", "name": "Fasole cu carne", "unit": "g"} + demo_settings = { + "company_name": "Moto Adv SRL", + "company_address": "Str. Exemplu 1", + "company_phone": "0700 000 000", + } + return resolve_fields(demo_package, demo_items, demo_recipe, demo_settings) + + +def demo_items() -> list[dict[str, Any]]: + return [ + {"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"}, + {"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"}, + ] + + +# --------------------------------------------------------------------------- +# Campuri proprii (custom.*) +# --------------------------------------------------------------------------- + +def custom_placeholders(text: str) -> list[str]: + """Numele de campuri referite prin {{...}} intr-un sablon de camp propriu.""" + return PLACEHOLDER.findall(str(text or "")) + + +def custom_template_errors(text: str) -> list[str]: + """Mesaje pentru sablonul unui camp propriu (gol, prea lung, campuri necunoscute).""" + value = str(text or "").strip() + if not value: + return ["Textul campului este obligatoriu."] + if len(value) > MAX_CUSTOM_TEMPLATE: + return [f"Textul campului poate avea maxim {MAX_CUSTOM_TEMPLATE} caractere."] + unknown = sorted({name for name in custom_placeholders(value) if name not in FIELD_KEYS}) + if unknown: + return [ + "Campuri necunoscute: " + + ", ".join(f"{{{{{name}}}}}" for name in unknown) + + ". Sunt acceptate doar campurile fixe din lista." + ] + return [] + + +def validate_custom_field(data: dict[str, Any]) -> None: + """Verifica un camp propriu inainte de salvare.""" + label = str(data.get("label") or "").strip() + if not label: + raise ValidationError("Denumirea campului este obligatorie.") + if len(label) > MAX_CUSTOM_LABEL: + raise ValidationError(f"Denumirea poate avea maxim {MAX_CUSTOM_LABEL} caractere.") + errors = custom_template_errors(str(data.get("template") or "")) + if errors: + raise ValidationError(errors[0]) + + +def expand_custom(text: str, values: dict[str, Any]) -> str: + """Inlocuieste {{camp}} cu valoarea deja calculata; necunoscutele devin gol.""" + def replace(match: "re.Match[str]") -> str: + value = values.get(match.group(1)) + if value is None or isinstance(value, (list, dict)): + return "" + return str(value) + + return PLACEHOLDER.sub(replace, str(text or "")) + + +def apply_custom_fields( + values: dict[str, Any], + custom: Optional[Iterable[dict[str, Any]]] = None, +) -> dict[str, Any]: + """Adauga valorile campurilor proprii peste cele fixe. + + Campurile proprii se pot referi doar la campuri fixe, deci nu pot intra in bucla. + """ + result = dict(values) + for row in custom or []: + key = str(row.get("key") or "") + if not CUSTOM_KEY.match(key): + continue + result[key] = expand_custom(row.get("template") or "", result) + return result + + +def custom_field_catalog( + custom: Optional[Iterable[dict[str, Any]]] = None, + demo: Optional[dict[str, Any]] = None, +) -> list[dict[str, Any]]: + """Intrarile pentru popup-ul de alegere a campului, inclusiv cele proprii.""" + samples = demo if demo is not None else demo_fields() + entries: list[dict[str, Any]] = [] + for row in custom or []: + key = str(row.get("key") or "") + if not CUSTOM_KEY.match(key): + continue + entries.append({ + "key": key, + "label": row.get("label") or key, + "sample": str(samples.get(key) or ""), + "group": "Câmpuri proprii", + "custom": True, + }) + return entries + + +# --------------------------------------------------------------------------- +# Randare +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=64) +def qr_svg(payload: str) -> str: + """Cod QR inline (SVG), fara dependinte de imagini externe.""" + import qrcode + import qrcode.image.svg + + image = qrcode.make( + payload or " ", + image_factory=qrcode.image.svg.SvgPathImage, + box_size=10, + border=2, + ) + buffer = io.BytesIO() + image.save(buffer) + svg = buffer.getvalue().decode("utf-8") + # Dimensiunea o da CSS-ul; pastram doar viewBox-ul. + return re.sub(r']*?)\swidth="[^"]*"\sheight="[^"]*"', + r" float: + return float(style.get("size") or 3.2) + + +def _text_style(style: dict[str, Any], *, height: float) -> str: + parts = [ + f"font-size:{_font_size(style):.2f}mm", + f"font-weight:{'700' if style.get('bold') else '400'}", + f"font-style:{'italic' if style.get('italic') else 'normal'}", + f"text-align:{style.get('align', 'left')}", + f"color:{style.get('color', '#000000')}", + "line-height:1.15", + f"height:{height:.2f}mm", + "overflow:hidden", + "white-space:pre-wrap", + "word-break:break-word", + ] + if style.get("uppercase"): + parts.append("text-transform:uppercase") + return ";".join(parts) + + +def _block_position(block: dict[str, Any]) -> str: + return ( + f"left:{block['x']:.2f}mm;top:{block['y']:.2f}mm;" + f"width:{block['w']:.2f}mm;height:{block['h']:.2f}mm" + ) + + +def render_block( + block: dict[str, Any], + fields: dict[str, Any], + items: list[dict[str, Any]], +) -> str: + kind = block["type"] + style = block.get("style") or {} + position = _block_position(block) + + if kind == "line": + return ( + f'
' + ) + + if kind == "text": + text = html.escape(block.get("text") or "") + if style.get("uppercase"): + text = text.upper() + return (f'
{text}
') + + if kind == "field": + value = str(fields.get(block.get("field") or "") or "") + prefix = block.get("prefix") or "" + suffix = block.get("suffix") or "" + text = html.escape(f"{prefix}{value}{suffix}") + if style.get("uppercase"): + text = text.upper() + return (f'
{text}
') + + if kind == "items": + if not items: + return f'
' + lines = [] + for item in items: + weight = format_weight(item.get("weight")) + unit = html.escape(str(item.get("unit") or "")) + name = html.escape(str(item.get("component_name") or "")) + if style.get("separator") == "inline": + lines.append( + f'{weight} {unit} {name}' + ) + else: + lines.append( + f'
{weight} {unit} {name}
' + ) + separator = " · " if style.get("separator") == "inline" else "" + body = separator.join(lines) + return (f'
{body}
') + + if kind == "qr": + value = qr_payload(block, fields) + svg = qr_svg(value) + return (f'
' + f'
{svg}
') + + if kind == "image": + src = block.get("src") or "" + if not src: + return (f'
') + return (f'
' + f'
') + + return "" + + +def render_design( + design: dict[str, Any], + fields: dict[str, Any], + items: list[dict[str, Any]], +) -> str: + width = float(design.get("width_mm") or 100) + height = float(design.get("height_mm") or 60) + blocks = "".join(render_block(block, fields, items) + for block in design.get("blocks") or []) + return ( + f'
' + f"{blocks}
" + ) + + +DESIGN_CSS = """\ +.design-label { font-family: Arial, Helvetica, sans-serif; } +.d-block { position: absolute; box-sizing: border-box; } +.d-text, .d-field, .d-items { overflow: hidden; } +.d-item { white-space: nowrap; } +.d-item-w { font-weight: 700; } +.d-qr-inner { width: 100%; height: 100%; } +.d-qr-inner svg { width: 100%; height: 100%; display: block; } +.d-image img { width: 100%; height: 100%; object-fit: contain; display: block; } +.d-image-empty { border: 0.3mm dashed #94a3b8; } +""" + + +# --------------------------------------------------------------------------- +# Sablon nou +# --------------------------------------------------------------------------- + +def default_design(width_mm: float = 100.0, height_mm: float = 60.0) -> dict[str, Any]: + """Un design de pornire, cu elementele deja asezate in zona sigura. + + Proportiile se adapteaza la inaltimea etichetei, ca elementele sa nu iasa + din margini nici pe formate mici (ex. 60 x 40 mm). + """ + width = float(width_mm) + height = float(height_mm) + margin = SAFE_MARGIN_MM + k = min(1.0, max(0.55, height / 60.0)) + + def r(value: float) -> float: + return round(value, 2) + + qr = min(22.0, width * 0.28, max(8.0, height - 2 * margin - 2)) + qr_x = r(width - margin - qr) + column = r(max(6.0, qr_x - 2 - margin)) + + company_h = 4 * k + name_h = 8 * k + weight_h = 15 * k + meta_h = 4 * k + meta_step = 4.5 * k + + company_y = margin + line_y = company_y + company_h + name_y = line_y + 1 + weight_y = name_y + name_h + 1 + items_y = weight_y + weight_h + 1 + operator_y = height - margin - meta_h + lot_y = operator_y - meta_step + items_h = max(3 * k, lot_y - 1 - items_y) + + return { + "version": DESIGN_VERSION, + "width_mm": width, + "height_mm": height, + "blocks": [ + {"id": "b1", "type": "field", "x": r(margin), "y": r(company_y), + "w": column, "h": r(company_h), + "field": "company.name", + "style": _style({"size": 3.0 * k, "bold": True, "uppercase": True}, "field")}, + {"id": "b2", "type": "field", "x": r(margin), "y": r(name_y), + "w": column, "h": r(name_h), + "field": "recipe.name", "style": _style({"size": 5.0 * k, "bold": True}, "field")}, + {"id": "b3", "type": "field", "x": r(margin), "y": r(weight_y), + "w": column, "h": r(weight_h), + "field": "package.weight_unit", "suffix": "", + "style": _style({"size": 14.0 * k, "bold": True}, "field")}, + {"id": "b4", "type": "items", "x": r(margin), "y": r(items_y), + "w": column, "h": r(items_h), "style": _style({"size": 3.4 * k}, "items")}, + {"id": "b5", "type": "field", "x": r(margin), "y": r(lot_y), + "w": column, "h": r(meta_h), + "field": "package.lot", "prefix": "Lot: ", + "style": _style({"size": 3.0 * k}, "field")}, + {"id": "b6", "type": "field", "x": r(margin), "y": r(operator_y), + "w": column, "h": r(meta_h), + "field": "package.operator", "prefix": "Operator: ", + "style": _style({"size": 3.0 * k}, "field")}, + {"id": "b7", "type": "qr", "x": qr_x, "y": r(margin + 1), + "w": r(qr), "h": r(qr), + "field": "package.qr", "separator": "pipe", + "parts": [{"field": "package.qr"}], + "style": _style({}, "qr")}, + {"id": "b8", "type": "line", "x": r(margin), "y": r(line_y), + "w": column, "h": 0.3, + "style": _style({"color": "#000000"}, "line")}, + ], + } + + +def render_preview( + design: dict[str, Any], + custom: Optional[Iterable[dict[str, Any]]] = None, +) -> tuple[str, str]: + """(html, css) randate cu date demo — pentru previzualizarea din editor.""" + fields = apply_custom_fields(demo_fields(), custom) + items = demo_items() + return render_design(design, fields, items), DESIGN_CSS + + +def design_to_base64(design: dict[str, Any]) -> str: + return base64.b64encode(dump_design(design).encode("utf-8")).decode("ascii") diff --git a/cantarire/labels.py b/cantarire/labels.py new file mode 100644 index 0000000..01f9d7d --- /dev/null +++ b/cantarire/labels.py @@ -0,0 +1,175 @@ +"""Randarea etichetelor de produs finit si contractul de printare. + +Eticheta este generata din designul vizual (blocuri in milimetri) si contine: + - denumirea produsului finit si greutatea totala + - defalcarea pe componente (ex. 250 g Fasole, 200 g Carne) + - lot, data, operator +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from . import designer +from .repositories import ValidationError, list_label_fields +from .units import format_weight as _format_weight + + +def _format_datetime(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str: + if not value: + return "" + try: + return datetime.fromisoformat(str(value)).strftime(fmt) + except (TypeError, ValueError): + return str(value) + + +class LabelTemplateError(ValidationError): + pass + + +def sample_package() -> dict[str, Any]: + now = datetime.now().astimezone().isoformat(timespec="seconds") + return { + "id": 0, + "created_at": now, + "completed_at": now, + "status": "done", + "recipe_code": "PF-DEMO", + "recipe_name": "Fasole cu carne", + "unit": "g", + "target_weight": 450, + "total_weight": 450, + "lot": "LOT-DEMO", + "operator": "Operator", + "notes": "", + "print_count": 0, + "items": [ + {"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"}, + {"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"}, + ], + } + + +def sample_recipe() -> dict[str, Any]: + return { + "id": 0, + "code": "PF-DEMO", + "name": "Fasole cu carne", + "unit": "g", + "target_weight": 450, + "note": "", + "components": [ + {"position": 1, "name": "Fasole", "code": "FAS", "target_weight": 250}, + {"position": 2, "name": "Carne", "code": "CAR", "target_weight": 200}, + ], + } + + +def build_context( + package: dict[str, Any], + items: list[dict[str, Any]], + recipe: Optional[dict[str, Any]], + settings: dict[str, str], + template: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Contextul vechi Jinja, pastrat pentru compatibilitate cu integrarile existente.""" + return { + "package": package, + "items": items, + "recipe": recipe or { + "id": None, + "code": package.get("recipe_code", ""), + "name": package.get("recipe_name", ""), + "unit": package.get("unit", ""), + "components": [], + }, + "company": { + "name": settings.get("company_name", ""), + "address": settings.get("company_address", ""), + "phone": settings.get("company_phone", ""), + }, + "label": { + "width_mm": float((template or {}).get("width_mm", 100)), + "height_mm": float((template or {}).get("height_mm", 60)), + }, + "settings": settings, + } + + +def design_for(template: dict[str, Any]) -> dict[str, Any]: + """Designul sablonului; daca lipseste (sablon vechi), se foloseste cel implicit.""" + width = float(template.get("width_mm") or 100) + height = float(template.get("height_mm") or 60) + design = designer.load_design(template.get("design_json") or "", width, height) + if design is None: + return designer.default_design(width, height) + return design + + +def render_body( + template: dict[str, Any], + package: dict[str, Any], + items: Optional[list[dict[str, Any]]] = None, + recipe: Optional[dict[str, Any]] = None, + settings: Optional[dict[str, str]] = None, +) -> str: + item_list = items if items is not None else package.get("items") or [] + design = design_for(template) + fields = designer.resolve_fields(package, item_list, recipe, settings or {}) + fields = designer.apply_custom_fields(fields, list_label_fields()) + return designer.render_design(design, fields, item_list) + + +def template_css(template: dict[str, Any]) -> str: + css = template.get("css") or "" + return f"{designer.DESIGN_CSS}\n{css}".strip() + + +def validate_template(template: dict[str, Any], settings: Optional[dict[str, str]] = None) -> None: + """Revalideaza designul si il randeaza cu date demo, ca sa prindem erorile.""" + design = designer.validate_design( + design_for(template), + float(template.get("width_mm") or 100), + float(template.get("height_mm") or 60), + ) + demo = designer.apply_custom_fields(designer.demo_fields(), list_label_fields()) + designer.render_design(design, demo, designer.demo_items()) + + +def print_payload( + package: dict[str, Any], + template: dict[str, Any], + settings: dict[str, str], + recipe: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + items = package.get("items") or [] + html = render_body(template, package, items, recipe, settings) + breakdown = ", ".join( + f"{_format_weight(item['weight'])} {item['unit']} {item['component_name']}" + for item in items + ) + return { + "job_id": f"p{package['id']}-{int(package.get('print_count') or 0) + 1}", + "package_id": package["id"], + "template": { + "id": template["id"], + "name": template["name"], + "width_mm": float(template["width_mm"]), + "height_mm": float(template["height_mm"]), + }, + "html": html, + "css": template_css(template), + "fields": { + "recipe_code": package.get("recipe_code", ""), + "recipe_name": package.get("recipe_name", ""), + "lot": package.get("lot", ""), + "operator": package.get("operator", ""), + "total_weight": _format_weight(package.get("total_weight")), + "unit": package.get("unit", ""), + "completed_at": package.get("completed_at", ""), + "breakdown": breakdown, + }, + "print_url": f"/labels/{package['id']}/print", + "ack_url": f"/api/packages/{package['id']}/printed", + } diff --git a/cantarire/logging_setup.py b/cantarire/logging_setup.py new file mode 100644 index 0000000..618c529 --- /dev/null +++ b/cantarire/logging_setup.py @@ -0,0 +1,57 @@ +"""Logging: jurnal de acces lizibil, fara zgomotul polling-ului. + +Flask/werkzeug scrie un rand pentru fiecare cerere. Pagina de cantarire +interogheaza greutatea de mai multe ori pe secunda, deci jurnalul devine +inutilizabil. Aici: + - cererile de polling (/api/weight) si fisierele statice nu sunt logate; + - erorile (4xx/5xx) sunt pastrate intotdeauna; + - jurnalul aplicatiei merge la stderr, cu timestamp. +""" +from __future__ import annotations + +import logging +import re +import sys + +QUIET_PREFIXES = ( + "GET /api/weight", + "GET /static/", + "GET /favicon.ico", +) + +# Werkzeug access line: ... "GET /path HTTP/1.1" 200 - +_STATUS_RE = re.compile(r'"\s(\d{3})\s') + + +class _QuietPolling(logging.Filter): + """Drop access-log records for polling and static assets, keep errors.""" + + def filter(self, record: logging.LogRecord) -> bool: + try: + message = record.getMessage() + except Exception: # noqa: BLE001 - never let logging break a request + return True + if not any(prefix in message for prefix in QUIET_PREFIXES): + return True + match = _STATUS_RE.search(message) + if match is None: + return True + # Keep only failures (4xx / 5xx); 2xx and 304 are noise. + return not match.group(1).startswith(("2", "3")) + + +def configure_logging(level: int = logging.INFO) -> None: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s", + "%H:%M:%S")) + root = logging.getLogger() + if not root.handlers: + root.addHandler(handler) + root.setLevel(level) + + werkzeug = logging.getLogger("werkzeug") + werkzeug.setLevel(logging.INFO) + if not any(isinstance(f, _QuietPolling) for f in werkzeug.filters): + werkzeug.addFilter(_QuietPolling()) + + logging.getLogger("scale_bridge").setLevel(logging.WARNING) diff --git a/cantarire/maintenance.py b/cantarire/maintenance.py new file mode 100644 index 0000000..c01e306 --- /dev/null +++ b/cantarire/maintenance.py @@ -0,0 +1,407 @@ +"""Backup, reset si verificarea starii pentru punerea in productie. + +Toate functiile se apeleaza in contextul aplicatiei Flask, deoarece folosesc +conexiunea SQLite tinuta de `g` (vezi `db.get_db`). +""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any + +from flask import current_app + +from .config import DEV_SECRET_KEY +from .db import DEFAULT_SETTINGS, SCHEMA_VERSION, _table_names, get_db, init_db + +BACKUP_PREFIX = "cantarire-" +BACKUP_SUFFIX = ".db" +KEEP_BACKUPS = 20 + +# Tabele golite la reset, in ordinea impusa de cheile straine. +DATA_TABLES = ( + "package_items", + "packages", + "recipe_components", + "recipes", + "components", + "label_fields", + "label_templates", +) + + +# --------------------------------------------------------------------------- +# Backup +# --------------------------------------------------------------------------- + +def backup_dir() -> Path: + path = Path(current_app.config["INSTANCE_DIR"]) / "backups" + path.mkdir(parents=True, exist_ok=True) + return path + + +def backup_database(reason: str = "") -> Path: + """Copiaza baza intr-un fisier consistent (API-ul de backup SQLite, WAL-safe).""" + source = Path(current_app.config["DATABASE"]) + if not source.exists(): + raise FileNotFoundError(f"Baza de date nu exista: {source}") + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + label = f"-{reason}" if reason else "" + target = backup_dir() / f"{BACKUP_PREFIX}{stamp}{label}{BACKUP_SUFFIX}" + counter = 2 + while target.exists(): + target = backup_dir() / f"{BACKUP_PREFIX}{stamp}-{counter}{label}{BACKUP_SUFFIX}" + counter += 1 + + destination = sqlite3.connect(target) + try: + get_db().backup(destination) + destination.execute("PRAGMA wal_checkpoint(TRUNCATE)") + finally: + destination.close() + return target + + +def list_backups() -> list[dict[str, Any]]: + """Backup-urile existente, cele mai noi primele.""" + items: list[dict[str, Any]] = [] + for path in backup_dir().glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}"): + stat = path.stat() + items.append({ + "name": path.name, + "path": str(path), + "size": stat.st_size, + "size_human": _human_size(stat.st_size), + "created_at": datetime.fromtimestamp(stat.st_mtime), + }) + return sorted(items, key=lambda item: item["created_at"], reverse=True) + + +def prune_backups(keep: int = KEEP_BACKUPS) -> int: + """Sterge backup-urile peste limita; intoarce cate au fost sterse.""" + backups = list_backups() + removed = 0 + for item in backups[keep:]: + try: + Path(item["path"]).unlink() + removed += 1 + except OSError: + pass + return removed + + +def _human_size(size: int) -> str: + value = float(size) + for unit in ("B", "kB", "MB", "GB"): + if value < 1024 or unit == "GB": + return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} GB" + + +# --------------------------------------------------------------------------- +# Starea bazei +# --------------------------------------------------------------------------- + +def _safe_count(table: str) -> int: + db = get_db() + if table not in _table_names(db): + return 0 + return int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + + +def database_status() -> dict[str, Any]: + path = Path(current_app.config["DATABASE"]) + db = get_db() + try: + integrity = db.execute("PRAGMA quick_check").fetchone()[0] + except sqlite3.DatabaseError as exc: # pragma: no cover - doar la fisiere corupte + integrity = f"eroare: {exc}" + size = path.stat().st_size if path.exists() else 0 + return { + "path": str(path), + "exists": path.exists(), + "size": size, + "size_human": _human_size(size), + "integrity": integrity, + "schema_version": SCHEMA_VERSION, + "counts": { + "components": _safe_count("components"), + "recipes": _safe_count("recipes"), + "packages": _safe_count("packages"), + "package_items": _safe_count("package_items"), + "templates": _safe_count("label_templates"), + "fields": _safe_count("label_fields"), + }, + "backups": len(list_backups()), + "backup_dir": str(backup_dir()), + } + + +# --------------------------------------------------------------------------- +# Verificare de pregatire pentru productie +# --------------------------------------------------------------------------- + +def readiness() -> list[dict[str, Any]]: + """Checklist pentru prima punere in functiune. + + Fiecare element are `ok`, un mesaj scurt si (optional) link-ul unde se rezolva. + """ + from .repositories import list_components, list_recipes, list_templates + from .db import all_settings + from .scale import get_scale + + settings = all_settings() + scale = get_scale(current_app) + profiles = scale.profiles() + components = list_components() + recipes = list_recipes() + templates = list_templates() + + company = (settings.get("company_name") or "").strip() + company_ok = bool(company) and company != DEFAULT_SETTINGS["company_name"] + + active_profile = (settings.get("active_scale_profile") or "").strip() + profile_ok = bool(profiles) and active_profile in {p["name"] for p in profiles} + + secret = str(current_app.config.get("SECRET_KEY") or "") + secret_ok = bool(secret) and secret != DEV_SECRET_KEY + + ready_recipes = [r for r in recipes if (r.get("component_count") or 0) > 0] + + return [ + { + "key": "database", + "label": "Baza de date", + "ok": True, + "message": database_status()["path"], + }, + { + "key": "company", + "label": "Datele firmei", + "ok": company_ok, + "message": company if company_ok else "Completează denumirea firmei pe etichetă.", + "url": "/setari", + "action": "Deschide Setări", + }, + { + "key": "components", + "label": "Componente", + "ok": bool(components), + "message": (f"{len(components)} active" if components + else "Adaugă ce se cântărește (ex. fasole, carne)."), + "url": "/componente/nou", + "action": "Adaugă component", + }, + { + "key": "recipes", + "label": "Produse finite", + "ok": bool(ready_recipes), + "message": (f"{len(ready_recipes)} cu componente" if ready_recipes + else "Creează un produs finit cu 1–2 componente."), + "url": "/retete/nou", + "action": "Adaugă produs finit", + }, + { + "key": "template", + "label": "Șablon de etichetă", + "ok": bool(templates), + "message": (templates[0]["name"] if templates + else "Fără șablon, eticheta nu poate fi tipărită."), + "url": "/sabloane", + "action": "Deschide șabloane", + }, + { + "key": "company_contact", + "label": "Contact pe etichetă", + "ok": bool((settings.get("company_phone") or "").strip()), + "message": (settings.get("company_phone") or "Opțional: telefonul firmei."), + "url": "/setari", + "action": "Completează", + "optional": True, + }, + { + "key": "scale", + "label": "Cântar", + "ok": profile_ok, + "message": (active_profile if profile_ok + else "Fără profil activ se cântărește manual."), + "url": "/setari", + "action": "Alege profilul", + "optional": True, + }, + { + "key": "secret", + "label": "Cheie de sesiune", + "ok": secret_ok, + "message": ("Generată" if secret_ok + else "Setează CANTARIRE_SECRET_KEY înainte de producție."), + "optional": True, + }, + ] + + +def readiness_summary() -> dict[str, Any]: + items = readiness() + required = [item for item in items if not item.get("optional")] + missing = [item for item in required if not item["ok"]] + return { + "checks": items, + "missing": missing, + "ready": not missing, + } + + +# --------------------------------------------------------------------------- +# Reset +# --------------------------------------------------------------------------- + +def reset_database(*, keep_backup: bool = True) -> dict[str, Any]: + """Goleste datele de productie si readuce setarile/etalonul la valorile initiale. + + Pastreaza un backup inainte, ca operatia sa poata fi anulata manual. + """ + backup_path = backup_database("reset") if keep_backup else None + + db = get_db() + db.execute("PRAGMA foreign_keys = OFF") + removed: dict[str, int] = {} + try: + for table in DATA_TABLES: + if table in _table_names(db): + removed[table] = int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + db.execute(f"DELETE FROM {table}") + db.execute("DELETE FROM settings") + db.commit() + finally: + db.execute("PRAGMA foreign_keys = ON") + + # `init_db` reseaza setarile implicite si sablonul de pornire. + init_db() + # Recupereaza spatiul fisierului dupa stergere. + get_db().execute("VACUUM") + + return { + "removed": removed, + "backup": str(backup_path) if backup_path else None, + "status": database_status(), + } + + +# --------------------------------------------------------------------------- +# Date demo (pentru instruire / testare) +# --------------------------------------------------------------------------- + +DEMO_COMPONENTS = [ + ("FAS", "Fasole", "g"), + ("CAR", "Carne", "g"), + ("GAR", "Garnitură", "g"), +] + +DEMO_RECIPES = [ + { + "code": "PF-001", + "name": "Fasole cu carne", + "target_weight": 450.0, + "components": [("FAS", 250.0), ("CAR", 200.0)], + }, + { + "code": "PF-002", + "name": "Ciorbă de legume", + "target_weight": 350.0, + "components": [("GAR", 350.0)], + }, +] + + +def seed_demo() -> dict[str, Any]: + """Recreaza datele demo (componente, produse finite, firma, operator).""" + from . import designer + from .db import all_settings, set_setting + from .repositories import ( + create_component, + create_recipe, + get_component_by_code, + list_components, + list_recipes, + list_templates, + save_template_design, + ) + + created = {"components": 0, "recipes": 0} + for code, name, unit in DEMO_COMPONENTS: + if get_component_by_code(code) is None: + create_component({"code": code, "name": name, "unit": unit, "active": "on"}) + created["components"] += 1 + + existing_codes = {recipe["code"] for recipe in list_recipes()} + for spec in DEMO_RECIPES: + if spec["code"] in existing_codes: + continue + data: dict[str, Any] = { + "code": spec["code"], + "name": spec["name"], + "unit": "g", + "target_weight": spec["target_weight"], + "active": "on", + } + for slot, (code, target) in enumerate(spec["components"], start=1): + component = get_component_by_code(code) + if component is None: + continue + data[f"component_{slot}_id"] = str(component["id"]) + data[f"component_{slot}_target"] = str(target) + create_recipe(data) + created["recipes"] += 1 + + settings = all_settings() + if not (settings.get("company_name") or "").strip() or \ + settings.get("company_name") == DEFAULT_SETTINGS["company_name"]: + set_setting("company_name", "Moto Adv SRL") + if not (settings.get("company_phone") or "").strip(): + set_setting("company_phone", "0700 000 000") + if not (settings.get("default_operator") or "").strip(): + set_setting("default_operator", "Maria") + + templates = list_templates() + if templates and not (templates[0].get("design_json") or "").strip(): + template = templates[0] + save_template_design( + template["id"], + designer.dump_design(designer.default_design(template["width_mm"], template["height_mm"])), + ) + + return { + "created": created, + "recipes": [recipe["code"] for recipe in list_recipes()], + "components": [row["code"] for row in list_components(include_inactive=True)], + } + + +# --------------------------------------------------------------------------- +# Utilitare pentru afisare +# --------------------------------------------------------------------------- + +def reset_report(result: dict[str, Any]) -> str: + """Rezumat lizibil pentru CLI.""" + lines = ["Date sterse:"] + for table, count in result["removed"].items(): + lines.append(f" {table:<20} {count}") + if result["backup"]: + lines.append(f"Backup: {result['backup']}") + else: + lines.append("Backup: (nu s-a facut)") + status = result["status"] + lines.append( + f"Baza: {status['path']} · {status['size_human']} · schema v{status['schema_version']}" + ) + return "\n".join(lines) + + +def ensure_instance_dirs() -> None: + """Creeaza folderele de rulare daca lipsesc (util la prima pornire).""" + root = Path(current_app.config["INSTANCE_DIR"]) + (root / "backups").mkdir(parents=True, exist_ok=True) + (root / "captures").mkdir(parents=True, exist_ok=True) diff --git a/cantarire/probe.py b/cantarire/probe.py new file mode 100644 index 0000000..c01a8ae --- /dev/null +++ b/cantarire/probe.py @@ -0,0 +1,184 @@ +"""Monitor de port serial: ce trimite de fapt cantarul. + +Instrument pentru un model nou (ex. Bizerba BC800): deschide portul in mod +ascultare (nu scrie nimic catre cantar), strange cadrele brute si le trece +prin toate protocoalele cunoscute. Rezultatul spune daca formatul este deja +suportat sau daca trebuie adaugat un protocol nou. +""" +from __future__ import annotations + +import re +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + +VENDOR_DIR = Path(__file__).resolve().parent.parent / "vendor" / "syncpulse-scale-bridge" + +BAUD_RATES = (9600, 4800, 19200, 2400, 38400, 1200, 57600, 115200) +FRAMINGS = ("8N1", "7E1", "7O1", "8E1", "8O1") +MAX_FRAMES = 60 + + +class ProbeError(RuntimeError): + """Portul nu poate fi deschis sau setarile sunt invalide.""" + + +def _bridge(): + path = str(VENDOR_DIR) + if path not in sys.path: + sys.path.insert(0, path) + from scale_bridge import serialio # noqa: PLC0415 + + return serialio + + +def valid_framing(text: str) -> str: + value = (text or "").strip().upper() + if not re.fullmatch(r"[5678][NEOMS][12]", value): + raise ProbeError(f"framing invalid: {text!r} (ex. 8N1, 7E1)") + return value + + +def list_ports() -> list[dict[str, Any]]: + serialio = _bridge() + return serialio.list_serial_ports() + + +def decode(data: bytes) -> list[dict[str, Any]]: + """Ce protocoale din biblioteca recunosc datele primite.""" + _bridge() + from scale_bridge.protocols import PROTOCOLS # noqa: PLC0415 + + guesses: list[dict[str, Any]] = [] + if not data: + return guesses + for pid, proto in PROTOCOLS.items(): + if pid.startswith("custom"): + continue + try: + readings = [r for r in proto.readings(data) if r.value is not None] + except Exception: # noqa: BLE001 - un parser nu trebuie sa rupa monitorul + continue + if not readings: + continue + last = readings[-1] + guesses.append({ + "protocol": pid, + "name": proto.name, + "kind": proto.kind, + "count": len(readings), + "value": last.value, + "unit": last.unit, + "stable": last.stable, + "specificity": proto.specificity, + }) + guesses.sort(key=lambda g: (-g["specificity"], -g["count"])) + return guesses + + +def probe_port( + port: str, + baud: int = 9600, + framing: str = "8N1", + seconds: float = 5.0, + opener: Optional[Any] = None, +) -> dict[str, Any]: + """Asculta portul `seconds` secunde si intoarce cadrele brute.""" + serialio = _bridge() + framing = valid_framing(framing) + if not port: + raise ProbeError("Alege portul serial.") + if not 50 <= int(baud) <= 921600: + raise ProbeError(f"baud invalid: {baud}") + seconds = min(max(float(seconds), 0.5), 60.0) + + serial_cfg = { + "port": port, + "baudrate": int(baud), + "bytesize": int(framing[0]), + "parity": framing[1], + "stopbits": int(framing[2]), + } + try: + settings = serialio.LineSettings.from_dicts(serial_cfg, {}) + except ValueError as exc: + raise ProbeError(str(exc)) from exc + + worker = serialio.PortWorker(settings, opener or serialio.open_real) + worker.start() + deadline = time.monotonic() + seconds + try: + while time.monotonic() < deadline: + if worker.last_error and not worker.is_open: + break + time.sleep(0.05) + finally: + worker.stop() + worker.join(timeout=3) + + chunks = worker.recent(MAX_FRAMES) + joined = b"".join(data for _, data in chunks) + now = serialio.now_ms() + return { + "ok": bool(chunks) or bool(worker.is_open), + "port": settings.port, + "settings": settings.describe(), + "baud": int(baud), + "framing": framing, + "seconds": seconds, + "error": None if (chunks or worker.is_open) else worker.last_error, + "bytes": len(joined), + "frames": [ + {"age_ms": max(0, now - t), "hex": serialio.format_hex(data), + "text": serialio.printable(data), "size": len(data)} + for t, data in chunks + ], + "guesses": decode(joined), + "raw": joined, + } + + +def sweep( + port: str, + seconds: float = 2.0, + framings: tuple[str, ...] = ("8N1", "7E1"), + bauds: tuple[int, ...] = BAUD_RATES, + opener: Optional[Any] = None, +) -> list[dict[str, Any]]: + """Incearca mai multe setari de linie si pastreaza doar cele cu date.""" + results = [] + for framing in framings: + for baud in bauds: + try: + result = probe_port(port, baud, framing, seconds, opener) + except ProbeError as exc: + results.append({"baud": baud, "framing": framing, "ok": False, + "error": str(exc), "bytes": 0, "guesses": []}) + continue + results.append({ + "baud": baud, + "framing": framing, + "ok": result["ok"], + "error": result["error"], + "bytes": result["bytes"], + "frames": len(result["frames"]), + "guesses": result["guesses"], + "raw": result["raw"], + }) + if result["bytes"] and result["guesses"]: + # Un protocol recunoscut este un rezultat bun: nu mai insistam. + pass + return results + + +def save_capture(folder: str | Path, name: str, frames: list[dict[str, Any]]) -> Path: + """Scrie cadrele ca hex, un cadru pe linie (formatul din tests/captures).""" + safe = re.sub(r"[^A-Za-z0-9_-]+", "-", str(name or "capture")).strip("-") or "capture" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + target = Path(folder) / f"{safe}-{stamp}.hex" + target.parent.mkdir(parents=True, exist_ok=True) + lines = [f["hex"] for f in frames if f.get("hex")] + target.write_text("\n".join(lines) + "\n", encoding="ascii") + return target diff --git a/cantarire/repositories.py b/cantarire/repositories.py new file mode 100644 index 0000000..4a30a2d --- /dev/null +++ b/cantarire/repositories.py @@ -0,0 +1,871 @@ +"""Accesul la date: componente, retete (produse finite) si pachete cantarite.""" +from __future__ import annotations + +import math +import re +from datetime import datetime +from typing import Any, Iterable, Optional + +from .db import get_db +from .units import UNITS, convert, format_weight, normalize_unit + +MAX_RECIPE_COMPONENTS = 4 + + +class ValidationError(ValueError): + """Date introduse de utilizator invalide.""" + + +def _now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _text(value: Any, limit: int) -> str: + return str(value or "").strip()[:limit] + + +def _number( + value: Any, + *, + label: str, + required: bool = False, + minimum: float = 0.0, +) -> Optional[float]: + if value in (None, ""): + if required: + raise ValidationError(f"{label} este obligatorie.") + return None + if isinstance(value, str): + value = value.strip().replace(",", ".") + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValidationError(f"{label} trebuie sa fie un numar.") from exc + if not math.isfinite(number) or number < minimum: + raise ValidationError(f"{label} trebuie sa fie un numar pozitiv.") + return round(number, 3) + + +def _flag(value: Any) -> int: + return 1 if value in (True, 1, "1", "on", "true", "yes") else 0 + + +def _optional_id(value: Any) -> Optional[int]: + """Id numeric optional: '', None si 0 devin None; altfel int sau eroare.""" + if value in (None, "", 0, "0"): + return None + if isinstance(value, bool): + raise ValidationError("Identificator invalid.") + try: + number = int(value) + except (TypeError, ValueError) as exc: + raise ValidationError("Identificator invalid.") from exc + return number if number > 0 else None + + +# --------------------------------------------------------------------------- +# Componente (ce se pune pe cantar) +# --------------------------------------------------------------------------- + +def list_components(include_inactive: bool = False) -> list[dict[str, Any]]: + sql = "SELECT * FROM components" + if not include_inactive: + sql += " WHERE active = 1" + sql += " ORDER BY name COLLATE NOCASE" + return [dict(r) for r in get_db().execute(sql)] + + +def get_component(component_id: int) -> Optional[dict[str, Any]]: + row = get_db().execute("SELECT * FROM components WHERE id = ?", (component_id,)).fetchone() + return dict(row) if row else None + + +def get_component_by_code(code: str) -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT * FROM components WHERE code = ? COLLATE NOCASE", (_text(code, 40),) + ).fetchone() + return dict(row) if row else None + + +def _clean_component(data: dict[str, Any], component_id: Optional[int] = None) -> dict[str, Any]: + code = _text(data.get("code"), 40) + name = _text(data.get("name"), 120) + unit = normalize_unit(data.get("unit"), "g") + if not code: + raise ValidationError("Codul componentului este obligatoriu.") + if not name: + raise ValidationError("Denumirea componentului este obligatorie.") + if unit not in UNITS: + raise ValidationError(f"Unitate de masura necunoscuta: {unit}.") + + db = get_db() + sql = "SELECT id FROM components WHERE code = ? COLLATE NOCASE" + params: list[Any] = [code] + if component_id is not None: + sql += " AND id <> ?" + params.append(component_id) + if db.execute(sql, params).fetchone(): + raise ValidationError(f"Exista deja un component cu codul {code!r}.") + + return { + "code": code, + "name": name, + "unit": unit, + "description": _text(data.get("description"), 500), + "active": _flag(data.get("active", True)), + } + + +def create_component(data: dict[str, Any]) -> int: + item = _clean_component(data) + db = get_db() + cur = db.execute( + "INSERT INTO components(code, name, unit, description, active)" + " VALUES (?, ?, ?, ?, ?)", + (item["code"], item["name"], item["unit"], item["description"], item["active"]), + ) + db.commit() + return int(cur.lastrowid) + + +def update_component(component_id: int, data: dict[str, Any]) -> None: + if get_component(component_id) is None: + raise ValidationError("Componentul nu exista.") + item = _clean_component(data, component_id) + db = get_db() + db.execute( + "UPDATE components SET code = ?, name = ?, unit = ?, description = ?, active = ?," + " updated_at = datetime('now') WHERE id = ?", + (item["code"], item["name"], item["unit"], item["description"], item["active"], + component_id), + ) + db.commit() + + +def delete_component(component_id: int) -> None: + """Sterge componentul daca nu este folosit; altfel il dezactiveaza.""" + db = get_db() + used = db.execute( + "SELECT COUNT(*) FROM recipe_components WHERE component_id = ?", (component_id,) + ).fetchone()[0] + if used: + db.execute( + "UPDATE components SET active = 0, updated_at = datetime('now') WHERE id = ?", + (component_id,), + ) + else: + db.execute("DELETE FROM components WHERE id = ?", (component_id,)) + db.commit() + + +# --------------------------------------------------------------------------- +# Retete (produsul finit) +# --------------------------------------------------------------------------- + +def _recipe_components(recipe_id: int) -> list[dict[str, Any]]: + rows = get_db().execute( + "SELECT rc.*, c.code AS component_code, c.unit AS component_unit" + " FROM recipe_components rc LEFT JOIN components c ON c.id = rc.component_id" + " WHERE rc.recipe_id = ? ORDER BY rc.position", + (recipe_id,), + ) + return [ + { + "position": row["position"], + "component_id": row["component_id"], + "code": row["component_code"] or "", + "name": row["name"] or (row["component_code"] or ""), + "target_weight": row["target_weight"], + "unit": row["component_unit"] or "", + } + for row in rows + ] + + +def list_recipes(include_inactive: bool = False) -> list[dict[str, Any]]: + sql = ( + "SELECT r.*, t.name AS template_name," + " (SELECT COUNT(*) FROM recipe_components rc WHERE rc.recipe_id = r.id) AS component_count" + " FROM recipes r LEFT JOIN label_templates t ON t.id = r.template_id" + ) + if not include_inactive: + sql += " WHERE r.active = 1" + sql += " ORDER BY r.name COLLATE NOCASE" + return [dict(r) for r in get_db().execute(sql)] + + +def get_recipe(recipe_id: int) -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT r.*, t.name AS template_name FROM recipes r" + " LEFT JOIN label_templates t ON t.id = r.template_id WHERE r.id = ?", + (recipe_id,), + ).fetchone() + if row is None: + return None + recipe = dict(row) + recipe["components"] = _recipe_components(recipe_id) + recipe["component_count"] = len(recipe["components"]) + return recipe + + +def _parse_recipe_components(data: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + seen: set[int] = set() + for slot in range(1, MAX_RECIPE_COMPONENTS + 1): + raw_id = data.get(f"component_{slot}_id") + if raw_id in (None, "", "0", 0): + continue + try: + component_id = int(raw_id) + except (TypeError, ValueError) as exc: + raise ValidationError(f"Componentul {slot} nu este valid.") from exc + component = get_component(component_id) + if component is None or not component["active"]: + raise ValidationError(f"Componentul {slot} nu exista sau este inactiv.") + if component_id in seen: + raise ValidationError(f"Componentul {component['name']!r} apare de doua ori.") + seen.add(component_id) + items.append({ + "component_id": component_id, + "code": component["code"], + "name": component["name"], + "target_weight": _number( + data.get(f"component_{slot}_target"), + label=f"Greutatea tinta pentru {component['name']}", + ), + }) + + if not items: + raise ValidationError("O reteta are nevoie de cel putin un component.") + for position, item in enumerate(items, start=1): + item["position"] = position + return items + + +def _clean_recipe(data: dict[str, Any], recipe_id: Optional[int] = None) -> dict[str, Any]: + code = _text(data.get("code"), 40) + name = _text(data.get("name"), 120) + unit = normalize_unit(data.get("unit"), "g") + if not code: + raise ValidationError("Codul produsului finit este obligatoriu.") + if not name: + raise ValidationError("Denumirea produsului finit este obligatorie.") + if unit not in UNITS: + raise ValidationError(f"Unitate de masura necunoscuta: {unit}.") + + db = get_db() + sql = "SELECT id FROM recipes WHERE code = ? COLLATE NOCASE" + params: list[Any] = [code] + if recipe_id is not None: + sql += " AND id <> ?" + params.append(recipe_id) + if db.execute(sql, params).fetchone(): + raise ValidationError(f"Exista deja un produs finit cu codul {code!r}.") + + template_id = _optional_id(data.get("template_id")) + if template_id is not None and get_template(template_id) is None: + raise ValidationError("Sablonul de eticheta ales nu exista.") + + return { + "code": code, + "name": name, + "unit": unit, + "target_weight": _number(data.get("target_weight"), label="Greutatea totala tinta"), + "note": _text(data.get("note"), 500), + "active": _flag(data.get("active", True)), + "template_id": template_id, + "components": _parse_recipe_components(data), + } + + +def create_recipe(data: dict[str, Any]) -> int: + recipe = _clean_recipe(data) + db = get_db() + cur = db.execute( + "INSERT INTO recipes(code, name, unit, target_weight, note, active, template_id)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + (recipe["code"], recipe["name"], recipe["unit"], recipe["target_weight"], + recipe["note"], recipe["active"], recipe["template_id"]), + ) + recipe_id = int(cur.lastrowid) + _write_recipe_components(recipe_id, recipe["components"]) + db.commit() + return recipe_id + + +def update_recipe(recipe_id: int, data: dict[str, Any]) -> None: + if get_recipe(recipe_id) is None: + raise ValidationError("Produsul finit nu exista.") + recipe = _clean_recipe(data, recipe_id) + db = get_db() + db.execute( + "UPDATE recipes SET code = ?, name = ?, unit = ?, target_weight = ?, note = ?," + " active = ?, template_id = ?, updated_at = datetime('now') WHERE id = ?", + (recipe["code"], recipe["name"], recipe["unit"], recipe["target_weight"], + recipe["note"], recipe["active"], recipe["template_id"], recipe_id), + ) + db.execute("DELETE FROM recipe_components WHERE recipe_id = ?", (recipe_id,)) + _write_recipe_components(recipe_id, recipe["components"]) + db.commit() + + +def _write_recipe_components(recipe_id: int, items: list[dict[str, Any]]) -> None: + db = get_db() + for item in items: + db.execute( + "INSERT INTO recipe_components(recipe_id, position, component_id, name, target_weight)" + " VALUES (?, ?, ?, ?, ?)", + (recipe_id, item["position"], item["component_id"], item["name"], + item["target_weight"]), + ) + + +def delete_recipe(recipe_id: int) -> None: + """Sterge reteta daca nu are pachete; altfel o dezactiveaza.""" + db = get_db() + used = db.execute( + "SELECT COUNT(*) FROM packages WHERE recipe_id = ?", (recipe_id,) + ).fetchone()[0] + if used: + db.execute("UPDATE recipes SET active = 0, updated_at = datetime('now') WHERE id = ?", + (recipe_id,)) + else: + db.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) + db.commit() + + +# --------------------------------------------------------------------------- +# Pachete (o eticheta de produs finit) +# --------------------------------------------------------------------------- + +def _package_row(package_id: int) -> Optional[dict[str, Any]]: + row = get_db().execute("SELECT * FROM packages WHERE id = ?", (package_id,)).fetchone() + return dict(row) if row else None + + +def get_package(package_id: int) -> Optional[dict[str, Any]]: + package = _package_row(package_id) + if package is None: + return None + package["items"] = [ + dict(row) for row in get_db().execute( + "SELECT * FROM package_items WHERE package_id = ? ORDER BY position", + (package_id,), + ) + ] + return package + + +def get_open_package() -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT id FROM packages WHERE status = 'open' ORDER BY id DESC LIMIT 1" + ).fetchone() + return get_package(row["id"]) if row else None + + +def start_package( + recipe_id: int, + *, + operator: str = "", + lot: str = "", + notes: str = "", + template_id: Optional[int] = None, +) -> int: + recipe = get_recipe(recipe_id) + if recipe is None or not recipe["active"]: + raise ValidationError("Produsul finit nu exista sau este inactiv.") + if not recipe["components"]: + raise ValidationError("Produsul finit nu are componente configurate.") + if get_open_package() is not None: + raise ValidationError("Exista deja un pachet inceput. Finalizeaza-l sau renunta la el.") + + db = get_db() + cur = db.execute( + "INSERT INTO packages(created_at, status, recipe_id, recipe_code, recipe_name, unit," + " target_weight, operator, lot, notes, template_id)" + " VALUES (?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (_now_iso(), recipe["id"], recipe["code"], recipe["name"], recipe["unit"], + recipe["target_weight"], _text(operator, 64), _text(lot, 64), _text(notes, 500), + template_id), + ) + db.commit() + return int(cur.lastrowid) + + +def add_package_item( + package_id: int, + *, + weight: float, + unit: str, + source: str = "manual", + stable: bool = True, + scale_profile: str = "", + raw_hex: str = "", +) -> dict[str, Any]: + """Adauga urmatoarea cantarire din reteta si intoarce pachetul actualizat.""" + package = get_package(package_id) + if package is None: + raise ValidationError("Pachetul nu exista.") + if package["status"] != "open": + raise ValidationError("Pachetul este deja finalizat.") + + recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None + plan = recipe["components"] if recipe else [] + position = len(package["items"]) + 1 + if position > len(plan): + raise ValidationError("Toate componentele au fost cantarite.") + planned = plan[position - 1] + + normalized = normalize_unit(unit, package["unit"]) + converted = convert(float(weight), normalized, package["unit"]) + if converted is None: + raise ValidationError(f"Unitatea {normalized!r} nu poate fi convertita in {package['unit']!r}.") + value = round(converted, 3) + if value <= 0: + raise ValidationError("Greutatea trebuie sa fie mai mare decat zero.") + + db = get_db() + db.execute( + "INSERT INTO package_items(package_id, position, component_id, component_code," + " component_name, weight, unit, target_weight, stable, source, scale_profile, raw_hex," + " created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (package_id, position, planned.get("component_id"), planned.get("code") or "", + planned.get("name") or "", value, package["unit"], planned.get("target_weight"), + 1 if stable else 0, "scale" if source == "scale" else "manual", + _text(scale_profile, 64), _text(raw_hex, 500), _now_iso()), + ) + db.commit() + return get_package(package_id) or package + + +def undo_package_item(package_id: int) -> dict[str, Any]: + package = get_package(package_id) + if package is None: + raise ValidationError("Pachetul nu exista.") + if package["status"] != "open": + raise ValidationError("Pachetul este finalizat; nu se mai poate reveni.") + if not package["items"]: + raise ValidationError("Nu exista nicio cantarire de anulat.") + db = get_db() + db.execute( + "DELETE FROM package_items WHERE package_id = ? AND position = ?", + (package_id, max(item["position"] for item in package["items"])), + ) + db.commit() + return get_package(package_id) or package + + +def complete_package(package_id: int) -> dict[str, Any]: + package = get_package(package_id) + if package is None: + raise ValidationError("Pachetul nu exista.") + if package["status"] == "done": + return package + if package["status"] != "open": + raise ValidationError("Pachetul nu mai poate fi finalizat.") + recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None + planned = len(recipe["components"]) if recipe else 0 + if len(package["items"]) != planned or planned == 0: + raise ValidationError("Nu toate componentele au fost cantarite.") + + total = round(sum(float(item["weight"]) for item in package["items"]), 3) + db = get_db() + db.execute( + "UPDATE packages SET status = 'done', completed_at = ?, total_weight = ? WHERE id = ?", + (_now_iso(), total, package_id), + ) + db.commit() + return get_package(package_id) or package + + +def void_package(package_id: int) -> None: + db = get_db() + db.execute( + "UPDATE packages SET status = 'void', completed_at = COALESCE(completed_at, ?)" + " WHERE id = ? AND status = 'open'", + (_now_iso(), package_id), + ) + db.commit() + + +def mark_printed(package_id: int) -> Optional[dict[str, Any]]: + db = get_db() + db.execute( + "UPDATE packages SET printed_at = ?, print_count = print_count + 1 WHERE id = ?", + (_now_iso(), package_id), + ) + db.commit() + return get_package(package_id) + + +def package_state(package_id: int) -> Optional[dict[str, Any]]: + """Starea fluxului: pachet, reteta, pasul curent si totalul.""" + package = get_package(package_id) + if package is None: + return None + recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None + plan = recipe["components"] if recipe else [] + done = len(package["items"]) + step = None + if package["status"] == "open" and done < len(plan): + planned = plan[done] + step = { + "position": done + 1, + "total": len(plan), + "component_id": planned.get("component_id"), + "component_code": planned.get("code") or "", + "component_name": planned.get("name") or "", + "target_weight": planned.get("target_weight"), + "unit": package["unit"], + } + return { + "package": package, + "recipe": recipe, + "items": package["items"], + "step": step, + "planned_total": len(plan), + "total_weight": round(sum(float(item["weight"]) for item in package["items"]), 3), + "complete": package["status"] == "done", + "open": package["status"] == "open", + } + + +def list_packages( + *, + date_from: str = "", + date_to: str = "", + recipe_id: Optional[int] = None, + status: str = "", + query: str = "", + limit: int = 500, +) -> list[dict[str, Any]]: + sql = ( + "SELECT p.*," + " (SELECT COUNT(*) FROM package_items i WHERE i.package_id = p.id) AS item_count," + " (SELECT GROUP_CONCAT(i.component_name || ' ' || printf('%g', i.weight) || ' ' || i.unit, '; ')" + " FROM package_items i WHERE i.package_id = p.id) AS item_summary" + " FROM packages p WHERE 1 = 1" + ) + params: list[Any] = [] + if date_from: + sql += " AND COALESCE(p.completed_at, p.created_at) >= ?" + params.append(date_from) + if date_to: + sql += " AND COALESCE(p.completed_at, p.created_at) <= ?" + params.append(date_to + "T23:59:59.999999" if len(date_to) == 10 else date_to) + if recipe_id: + sql += " AND p.recipe_id = ?" + params.append(recipe_id) + if status: + sql += " AND p.status = ?" + params.append(status) + if query: + sql += (" AND (p.recipe_name LIKE ? OR p.recipe_code LIKE ? OR p.lot LIKE ?" + " OR p.operator LIKE ?)") + like = f"%{query}%" + params.extend([like, like, like, like]) + sql += " ORDER BY COALESCE(p.completed_at, p.created_at) DESC, p.id DESC LIMIT ?" + params.append(max(1, min(int(limit), 5000))) + return [dict(row) for row in get_db().execute(sql, params)] + + +def recent_packages(limit: int = 6) -> list[dict[str, Any]]: + return list_packages(limit=limit) + + +def package_stats() -> dict[str, Any]: + db = get_db() + today = db.execute( + "SELECT COUNT(*) FROM packages WHERE status = 'done'" + " AND date(COALESCE(completed_at, created_at)) = date('now', 'localtime')" + ).fetchone()[0] + units = db.execute( + "SELECT unit, SUM(total_weight) AS total FROM packages WHERE status = 'done'" + " AND date(COALESCE(completed_at, created_at)) = date('now', 'localtime')" + " GROUP BY unit ORDER BY unit" + ).fetchall() + recipes = db.execute("SELECT COUNT(*) FROM recipes WHERE active = 1").fetchone()[0] + return { + "today_count": today, + "today_total": ", ".join( + f"{format_weight(row['total'] or 0)} {row['unit']}" for row in units + ) or "0", + "recipes": recipes, + } + + +def to_csv_rows(packages: Iterable[dict[str, Any]]) -> list[list[str]]: + rows = [[ + "ID", "Data", "Cod produs finit", "Produs finit", "Lot", "Operator", + "Greutate totala", "UM", "Componente", "Status", "Tiparit la", "Numar tipariri", + ]] + for package in packages: + rows.append([ + str(package["id"]), + package.get("completed_at") or package.get("created_at") or "", + package.get("recipe_code") or "", + package.get("recipe_name") or "", + package.get("lot") or "", + package.get("operator") or "", + f"{float(package.get('total_weight') or 0):.3f}", + package.get("unit") or "", + package.get("item_summary") or "", + package.get("status") or "", + package.get("printed_at") or "", + str(package.get("print_count") or 0), + ]) + return rows + + +def slugify(text: str) -> str: + text = re.sub(r"[^A-Za-z0-9_-]+", "-", text.strip().lower()) + return text.strip("-_") or "eticheta" + + +# --------------------------------------------------------------------------- +# Sabloane de eticheta +# --------------------------------------------------------------------------- + +def list_templates() -> list[dict[str, Any]]: + return [dict(row) for row in get_db().execute( + "SELECT * FROM label_templates ORDER BY is_default DESC, name COLLATE NOCASE" + )] + + +def get_template(template_id: int) -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT * FROM label_templates WHERE id = ?", (template_id,) + ).fetchone() + return dict(row) if row else None + + +def get_default_template() -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT * FROM label_templates ORDER BY is_default DESC, id LIMIT 1" + ).fetchone() + return dict(row) if row else None + + +def _clean_template(data: dict[str, Any], template_id: Optional[int] = None) -> dict[str, Any]: + name = _text(data.get("name"), 80) + # `body_html`/`css` sunt coloane vechi (modul HTML/CSS scos); le pastram goale + # pentru compatibilitatea schemei, fara sa cerem continut de la utilizator. + body = str(data.get("body_html") or "").strip() + css = str(data.get("css") or "").strip() + design_json = data.get("design_json") + design_json = str(design_json) if design_json is not None else None + if not name: + raise ValidationError("Numele sablonului este obligatoriu.") + if not body and not design_json: + raise ValidationError("Continutul etichetei este obligatoriu.") + + width = _number(data.get("width_mm"), label="Latimea etichetei") or 100.0 + height = _number(data.get("height_mm"), label="Inaltimea etichetei") or 60.0 + if not (10 <= width <= 500 and 10 <= height <= 500): + raise ValidationError("Dimensiunile etichetei trebuie sa fie intre 10 si 500 mm.") + + db = get_db() + sql = "SELECT id FROM label_templates WHERE name = ? COLLATE NOCASE" + params: list[Any] = [name] + if template_id is not None: + sql += " AND id <> ?" + params.append(template_id) + if db.execute(sql, params).fetchone(): + raise ValidationError(f"Exista deja un sablon numit {name!r}.") + + return { + "name": name, + "width_mm": width, + "height_mm": height, + "body_html": body, + "css": css, + "design_json": design_json, + "is_default": _flag(data.get("is_default")), + } + + +def create_template(data: dict[str, Any]) -> int: + template = _clean_template(data) + db = get_db() + if template["is_default"]: + db.execute("UPDATE label_templates SET is_default = 0") + cur = db.execute( + "INSERT INTO label_templates(name, width_mm, height_mm, body_html, css, design_json," + " is_default) VALUES (?, ?, ?, ?, ?, ?, ?)", + (template["name"], template["width_mm"], template["height_mm"], template["body_html"], + template["css"], template["design_json"] or "", template["is_default"]), + ) + db.commit() + return int(cur.lastrowid) + + +def update_template(template_id: int, data: dict[str, Any]) -> None: + existing = get_template(template_id) + if existing is None: + raise ValidationError("Sabloul nu exista.") + payload = dict(data) + # Formularul HTML nu trimite design; pastram designul existent. + if "design_json" not in payload: + payload["design_json"] = existing.get("design_json") or "" + template = _clean_template(payload, template_id) + db = get_db() + if template["is_default"]: + db.execute("UPDATE label_templates SET is_default = 0") + db.execute( + "UPDATE label_templates SET name = ?, width_mm = ?, height_mm = ?, body_html = ?," + " css = ?, design_json = ?, is_default = ?, updated_at = datetime('now') WHERE id = ?", + (template["name"], template["width_mm"], template["height_mm"], template["body_html"], + template["css"], template["design_json"] or "", template["is_default"], template_id), + ) + db.commit() + + +def save_template_design(template_id: int, design_json: str) -> None: + """Salveaza designul vizual (JSON) pentru un sablon.""" + if get_template(template_id) is None: + raise ValidationError("Sabloul nu exista.") + db = get_db() + db.execute( + "UPDATE label_templates SET design_json = ?, updated_at = datetime('now') WHERE id = ?", + (design_json, template_id), + ) + db.commit() + + +def delete_template(template_id: int) -> None: + db = get_db() + template = get_template(template_id) + if template is None: + return + used = db.execute( + "SELECT COUNT(*) FROM packages WHERE template_id = ?", (template_id,) + ).fetchone()[0] + if used: + raise ValidationError("Sabloul este folosit de pachete existente si nu poate fi sters.") + bound = db.execute( + "SELECT COUNT(*) FROM recipes WHERE template_id = ? AND active = 1", (template_id,) + ).fetchone()[0] + if bound: + raise ValidationError( + "Sabloul este atribuit unor produse finite active. Schimba sablonul acestora" + " inainte de stergere." + ) + db.execute("DELETE FROM label_templates WHERE id = ?", (template_id,)) + if template["is_default"]: + row = db.execute("SELECT id FROM label_templates ORDER BY id LIMIT 1").fetchone() + if row: + db.execute("UPDATE label_templates SET is_default = 1 WHERE id = ?", (row["id"],)) + db.commit() + + +# --------------------------------------------------------------------------- +# Campuri variabile proprii (custom.*) +# --------------------------------------------------------------------------- + +CUSTOM_KEY_PREFIX = "custom." + +# Diacriticele romanesti nu se descompun in NFKD (s/ț au virgula dedesubt), +# deci le transliteram explicit inainte de a construi cheia. +_KEY_TRANSLIT = str.maketrans({ + "ă": "a", "â": "a", "á": "a", "à": "a", "ä": "a", "å": "a", + "î": "i", "í": "i", "ì": "i", "ï": "i", + "ș": "s", "ş": "s", "ś": "s", + "ț": "t", "ţ": "t", + "é": "e", "è": "e", "ë": "e", + "ó": "o", "ò": "o", "ö": "o", + "ú": "u", "ù": "u", "ü": "u", + "ç": "c", "ñ": "n", +}) + + +def _key_slug(text: str) -> str: + lowered = str(text or "").strip().lower().translate(_KEY_TRANSLIT) + return re.sub(r"[^a-z0-9]+", "_", lowered).strip("_")[:40] + + + +def list_label_fields(include_inactive: bool = False) -> list[dict[str, Any]]: + sql = "SELECT * FROM label_fields" + if not include_inactive: + sql += " WHERE active = 1" + sql += " ORDER BY label COLLATE NOCASE" + return [dict(row) for row in get_db().execute(sql)] + + +def get_label_field(field_id: int) -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT * FROM label_fields WHERE id = ?", (field_id,) + ).fetchone() + return dict(row) if row else None + + +def get_label_field_by_key(key: str) -> Optional[dict[str, Any]]: + row = get_db().execute( + "SELECT * FROM label_fields WHERE key = ?", (key,) + ).fetchone() + return dict(row) if row else None + + +def _new_field_key(label: str) -> str: + """`Contact firmă` -> `custom.contact_firma`, cu sufix numeric daca exista deja.""" + base = _key_slug(label) or "camp" + key = f"{CUSTOM_KEY_PREFIX}{base}" + db = get_db() + counter = 2 + while db.execute("SELECT id FROM label_fields WHERE key = ?", (key,)).fetchone(): + key = f"{CUSTOM_KEY_PREFIX}{base}_{counter}" + counter += 1 + return key + + +def _clean_label_field(data: dict[str, Any]) -> dict[str, Any]: + label = _text(data.get("label"), 60) + template = str(data.get("template") or "").strip() + if not label: + raise ValidationError("Denumirea campului este obligatorie.") + if not template: + raise ValidationError("Textul campului este obligatoriu.") + if len(template) > 300: + raise ValidationError("Textul campului poate avea maxim 300 de caractere.") + return {"label": label, "template": template, "active": _flag(data.get("active"))} + + +def create_label_field(data: dict[str, Any]) -> int: + field = _clean_label_field(data) + key = _new_field_key(field["label"]) + db = get_db() + cur = db.execute( + "INSERT INTO label_fields(key, label, template, active) VALUES (?, ?, ?, ?)", + (key, field["label"], field["template"], field["active"]), + ) + db.commit() + return int(cur.lastrowid) + + +def update_label_field(field_id: int, data: dict[str, Any]) -> None: + if get_label_field(field_id) is None: + raise ValidationError("Campul nu exista.") + field = _clean_label_field(data) + db = get_db() + # Cheia ramane neschimbata: designurile o refera deja. + db.execute( + "UPDATE label_fields SET label = ?, template = ?, active = ?," + " updated_at = datetime('now') WHERE id = ?", + (field["label"], field["template"], field["active"], field_id), + ) + db.commit() + + +def count_label_field_usage(key: str) -> int: + """In cate sabloane (design) apare campul dat.""" + needle = f'"{key}"' + row = get_db().execute( + "SELECT COUNT(*) FROM label_templates WHERE design_json LIKE ?", (f"%{needle}%",) + ).fetchone() + return int(row[0]) if row else 0 + + +def delete_label_field(field_id: int) -> None: + db = get_db() + db.execute("DELETE FROM label_fields WHERE id = ?", (field_id,)) + db.commit() diff --git a/cantarire/routes/__init__.py b/cantarire/routes/__init__.py new file mode 100644 index 0000000..95a3ff3 --- /dev/null +++ b/cantarire/routes/__init__.py @@ -0,0 +1 @@ +"""Blueprint-urile aplicatiei sunt importate explicit in `create_app`.""" diff --git a/cantarire/routes/api.py b/cantarire/routes/api.py new file mode 100644 index 0000000..8329ff5 --- /dev/null +++ b/cantarire/routes/api.py @@ -0,0 +1,277 @@ +"""API JSON pentru fluxul de cantarire si pentru plugin-ul de printare.""" +from __future__ import annotations + +import math +from typing import Any + +from flask import Blueprint, current_app, jsonify, request + +from ..db import all_settings, get_setting +from ..labels import LabelTemplateError, print_payload, validate_template +from ..repositories import ( + ValidationError, + add_package_item, + complete_package, + get_default_template, + get_open_package, + get_package, + get_recipe, + get_template, + mark_printed, + package_state, + start_package, + undo_package_item, + void_package, +) +from ..scale import get_scale + +bp = Blueprint("api", __name__, url_prefix="/api") + + +def _error(message: str, status: int = 400, **extra: Any): + return jsonify({"ok": False, "error": message, **extra}), status + + +def _active_profile() -> str: + return get_setting("active_scale_profile", "") + + +def _state(package_id: int) -> dict[str, Any]: + state = package_state(package_id) + if state is None: + raise ValidationError("Pachetul nu exista.") + return state + + +def _parse_weight(raw: Any) -> float: + if isinstance(raw, str): + raw = raw.strip().replace(",", ".") + try: + value = float(raw) + except (TypeError, ValueError) as exc: + raise ValidationError("Greutatea introdusa nu este un numar valid.") from exc + if not math.isfinite(value) or value <= 0: + raise ValidationError("Greutatea trebuie sa fie un numar pozitiv.") + return round(value, 3) + + +def _resolve_template(template_id: Any, recipe: dict | None = None) -> dict | None: + """Prioritate: cerere explicita -> sablonul produsului finit -> setare -> implicit.""" + if template_id not in (None, ""): + try: + template = get_template(int(template_id)) + except (TypeError, ValueError): + template = None + if template: + return template + recipe_template = (recipe or {}).get("template_id") + if recipe_template: + template = get_template(int(recipe_template)) + if template: + return template + configured = get_setting("default_template_id", "") + if configured.isdigit(): + template = get_template(int(configured)) + if template: + return template + return get_default_template() + + +def _label_for(package_id: int, template_id: Any = None) -> dict | None: + package = get_package(package_id) + if package is None: + return None + recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None + template = _resolve_template(template_id or package.get("template_id"), recipe) + if template is None: + raise LabelTemplateError("Nu exista niciun sablon de eticheta configurat.") + validate_template(template, all_settings()) + payload = print_payload(package, template, all_settings(), recipe) + if package.get("template_id") != template["id"]: + from ..db import get_db + + db = get_db() + db.execute("UPDATE packages SET template_id = ? WHERE id = ?", (template["id"], package_id)) + db.commit() + return payload + + +# -- scale ------------------------------------------------------------------- + +@bp.get("/scale") +def scale_status(): + scale = get_scale(current_app) + return jsonify({ + "ok": scale.available, + "error": scale.error, + "simulate": bool(current_app.config["SIMULATE"]), + "profiles": scale.profiles(), + "active_profile": _active_profile(), + }) + + +@bp.get("/scale/profiles") +def scale_profiles(): + return jsonify({"ok": True, "profiles": get_scale(current_app).profiles()}) + + +@bp.get("/weight") +def weight(): + scale = get_scale(current_app) + profile = request.args.get("profile", "").strip() or _active_profile() + result = scale.read( + profile, + max_age_ms=int(request.args.get("max_age_ms") or current_app.config["WEIGHT_MAX_AGE_MS"]), + wait_ms=int(request.args.get("wait_ms") or current_app.config["WEIGHT_WAIT_MS"]), + ) + return jsonify(result) + + +# -- packages ---------------------------------------------------------------- + +@bp.get("/package") +def current_package(): + package = get_open_package() + if package is None: + return jsonify({"ok": True, "state": None}) + return jsonify({"ok": True, "state": _state(package["id"])}) + + +@bp.get("/package/") +def package_detail(package_id: int): + try: + return jsonify({"ok": True, "state": _state(package_id)}) + except ValidationError as exc: + return _error(str(exc), 404) + + +@bp.post("/packages") +def package_start(): + data = request.get_json(silent=True) or {} + try: + recipe = get_recipe(int(data.get("recipe_id") or 0)) + except (TypeError, ValueError): + recipe = None + if recipe is None or not recipe["active"]: + return _error("Alege un produs finit activ.") + + template = _resolve_template(data.get("template_id"), recipe) + try: + package_id = start_package( + recipe["id"], + operator=str(data.get("operator") or ""), + lot=str(data.get("lot") or ""), + notes=str(data.get("notes") or ""), + template_id=template["id"] if template else None, + ) + except ValidationError as exc: + return _error(str(exc), 409) + return jsonify({"ok": True, **_state(package_id)}), 201 + + +@bp.post("/packages//items") +def package_add_item(package_id: int): + data = request.get_json(silent=True) or {} + package = get_package(package_id) + if package is None: + return _error("Pachetul nu exista.", 404) + if package["status"] != "open": + return _error("Pachetul este deja finalizat.", 409) + + force = bool(data.get("force")) + raw_weight = data.get("weight") + reading: dict[str, Any] | None = None + unit = str(data.get("unit") or package["unit"]).strip().lower() + source = "manual" + stable = True + scale_profile = "" + raw_hex = "" + + if raw_weight in (None, ""): + scale = get_scale(current_app) + reading = scale.read( + str(data.get("profile") or "").strip() or _active_profile(), + max_age_ms=current_app.config["WEIGHT_MAX_AGE_MS"], + wait_ms=current_app.config["WEIGHT_WAIT_MS"], + ) + if not reading.get("ok"): + return _error(reading.get("error") or "Cantarul nu poate fi citit.", 409, + reading=reading) + if reading.get("stale"): + return _error("Citirea cantarului este prea veche. Asteapta o citire noua.", 409, + reading=reading) + if not reading.get("stable") and not force: + return _error("Greutatea nu este stabila. Asteapta stabilizarea sau accepta valoarea.", + 422, reading=reading) + try: + value = _parse_weight(reading.get("value")) + except ValidationError: + return _error("Cantarul nu a returnat o greutate valida.", 409, reading=reading) + unit = str(reading.get("unit") or package["unit"]) + source = "scale" + stable = bool(reading.get("stable")) + scale_profile = reading.get("profile") or "" + raw_hex = reading.get("raw_hex") or "" + else: + try: + value = _parse_weight(raw_weight) + except ValidationError as exc: + return _error(str(exc)) + + try: + add_package_item( + package_id, + weight=value, + unit=unit, + source=source, + stable=stable, + scale_profile=scale_profile, + raw_hex=raw_hex, + ) + state = _state(package_id) + label = None + if state["step"] is None and state["open"]: + complete_package(package_id) + state = _state(package_id) + label = _label_for(package_id) + except (ValidationError, LabelTemplateError) as exc: + return _error(str(exc), 409, reading=reading) + + response = {"ok": True, **state, "reading": reading, "label": label} + return jsonify(response), 201 + + +@bp.post("/packages//undo") +def package_undo(package_id: int): + try: + undo_package_item(package_id) + return jsonify({"ok": True, **_state(package_id)}) + except ValidationError as exc: + return _error(str(exc), 409) + + +@bp.post("/packages//void") +def package_void(package_id: int): + if get_package(package_id) is None: + return _error("Pachetul nu exista.", 404) + void_package(package_id) + return jsonify({"ok": True, "state": None}) + + +@bp.get("/packages//label") +def package_label(package_id: int): + try: + label = _label_for(package_id, request.args.get("template_id")) + except LabelTemplateError as exc: + return _error(str(exc), 409) + if label is None: + return _error("Pachetul nu exista.", 404) + return jsonify({"ok": True, "label": label}) + + +@bp.post("/packages//printed") +def package_printed(package_id: int): + package = mark_printed(package_id) + if package is None: + return _error("Pachetul nu exista.", 404) + return jsonify({"ok": True, "package": package}) diff --git a/cantarire/routes/components.py b/cantarire/routes/components.py new file mode 100644 index 0000000..d84cb2e --- /dev/null +++ b/cantarire/routes/components.py @@ -0,0 +1,58 @@ +"""Administrarea componentelor cantarite.""" +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from ..repositories import ( + ValidationError, + create_component, + delete_component, + get_component, + list_components, + update_component, +) + +bp = Blueprint("components", __name__, url_prefix="/componente") + + +@bp.get("") +def index(): + return render_template("components/list.html", + components=list_components(include_inactive=True)) + + +@bp.route("/nou", methods=["GET", "POST"]) +def create(): + if request.method == "POST": + try: + create_component(request.form) + flash("Componentul a fost adaugat.", "success") + return redirect(url_for("components.index")) + except ValidationError as exc: + flash(str(exc), "error") + return render_template("components/form.html", component=dict(request.form), is_new=True) + + +@bp.route("//editare", methods=["GET", "POST"]) +def edit(component_id: int): + component = get_component(component_id) + if component is None: + flash("Componentul nu exista.", "error") + return redirect(url_for("components.index")) + + if request.method == "POST": + try: + update_component(component_id, request.form) + flash("Componentul a fost actualizat.", "success") + return redirect(url_for("components.index")) + except ValidationError as exc: + flash(str(exc), "error") + component = {**component, **dict(request.form)} + return render_template("components/form.html", component=component, is_new=False) + + +@bp.post("//stergere") +def remove(component_id: int): + delete_component(component_id) + flash("Componentul a fost sters sau dezactivat (daca e folosit intr-o reteta).", "success") + return redirect(url_for("components.index")) diff --git a/cantarire/routes/fields.py b/cantarire/routes/fields.py new file mode 100644 index 0000000..4c6619f --- /dev/null +++ b/cantarire/routes/fields.py @@ -0,0 +1,113 @@ +"""Administrarea campurilor variabile proprii (custom.*). + +Un camp propriu este un text care poate include alte campuri fixe, de exemplu +`Tel. {{company.phone}}`. La randarea etichetei, textul este expandat cu +valorile reale ale pachetului. +""" +from __future__ import annotations + +from flask import (Blueprint, jsonify, flash, redirect, render_template, request, url_for) + +from .. import designer +from ..repositories import ( + ValidationError, + count_label_field_usage, + create_label_field, + delete_label_field, + get_label_field, + list_label_fields, + update_label_field, +) + +bp = Blueprint("fields", __name__, url_prefix="/campuri") + + +def _form_context(field: dict, *, is_new: bool) -> dict: + return { + "field": field, + "is_new": is_new, + "catalog": [ + {"key": key, "label": label, "sample": sample} + for key, label, sample in designer.FIELD_CATALOG + ], + "max_label": designer.MAX_CUSTOM_LABEL, + "max_template": designer.MAX_CUSTOM_TEMPLATE, + } + + +@bp.get("") +def index(): + rows = list_label_fields(include_inactive=True) + for row in rows: + row["usage"] = count_label_field_usage(row["key"]) + row["preview"] = designer.expand_custom( + row.get("template") or "", + designer.apply_custom_fields(designer.demo_fields(), [row]), + ) + return render_template("fields/list.html", fields=rows) + + +@bp.route("/nou", methods=["GET", "POST"]) +def create(): + if request.method == "POST": + data = dict(request.form) + try: + designer.validate_custom_field(data) + create_label_field(data) + flash("Campul a fost adaugat.", "success") + return redirect(url_for("fields.index")) + except ValidationError as exc: + flash(str(exc), "error") + return render_template("fields/form.html", **_form_context(data, is_new=True)) + return render_template("fields/form.html", + **_form_context({"active": 1}, is_new=True)) + + +@bp.route("//editare", methods=["GET", "POST"]) +def edit(field_id: int): + field = get_label_field(field_id) + if field is None: + flash("Campul nu exista.", "error") + return redirect(url_for("fields.index")) + + if request.method == "POST": + data = dict(request.form) + try: + designer.validate_custom_field(data) + update_label_field(field_id, data) + flash("Campul a fost actualizat.", "success") + return redirect(url_for("fields.index")) + except ValidationError as exc: + flash(str(exc), "error") + field = {**field, **data} + return render_template("fields/form.html", **_form_context(field, is_new=False)) + + +@bp.post("//stergere") +def remove(field_id: int): + field = get_label_field(field_id) + if field is None: + flash("Campul nu exista.", "error") + return redirect(url_for("fields.index")) + delete_label_field(field_id) + usage = count_label_field_usage(field["key"]) + if usage: + flash( + f"Campul {field['label']} a fost sters. Atentie: {usage} sabloane il folosesc " + "si il vor afisa gol pana il inlocuiesti.", + "error", + ) + else: + flash(f"Campul {field['label']} a fost sters.", "success") + return redirect(url_for("fields.index")) + + +@bp.get("/previzualizare") +def preview(): + """Valoarea rezultata pentru textul introdus, cu datele demo.""" + text = (request.args.get("template") or "")[: designer.MAX_CUSTOM_TEMPLATE * 2] + errors = designer.custom_template_errors(text) + if errors: + return jsonify({"ok": False, "error": errors[0]}) + demo = designer.demo_fields() + return jsonify({"ok": True, "value": designer.expand_custom(text, demo)}) diff --git a/cantarire/routes/history.py b/cantarire/routes/history.py new file mode 100644 index 0000000..a624c32 --- /dev/null +++ b/cantarire/routes/history.py @@ -0,0 +1,65 @@ +"""Istoricul pachetelor si exportul CSV.""" +from __future__ import annotations + +import csv +import io + +from flask import Blueprint, Response, render_template, request + +from ..repositories import get_recipe, list_packages, list_recipes, to_csv_rows +from ..units import format_weight + +bp = Blueprint("history", __name__, url_prefix="/istoric") + + +def _filters() -> dict: + recipe_id = request.args.get("recipe_id", "").strip() + status = request.args.get("status", "").strip() + return { + "date_from": request.args.get("date_from", "").strip(), + "date_to": request.args.get("date_to", "").strip(), + "recipe_id": int(recipe_id) if recipe_id.isdigit() else None, + "status": status if status in {"open", "done", "void"} else "", + "query": request.args.get("q", "").strip(), + "limit": 1000, + } + + +def _totals(packages: list[dict]) -> str: + groups: dict[str, float] = {} + for package in packages: + if package.get("status") != "done": + continue + unit = package.get("unit") or "" + groups[unit] = groups.get(unit, 0.0) + float(package.get("total_weight") or 0) + return ", ".join( + f"{format_weight(value)} {unit}" for unit, value in sorted(groups.items()) + ) or "0" + + +@bp.get("") +def index(): + filters = _filters() + packages = list_packages(**filters) + recipe = get_recipe(filters["recipe_id"]) if filters["recipe_id"] else None + return render_template( + "history/list.html", + packages=packages, + filters=filters, + recipes=list_recipes(include_inactive=True), + total=_totals(packages), + selected_recipe=recipe, + ) + + +@bp.get("/export.csv") +def export_csv(): + packages = list_packages(**_filters()) + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n") + writer.writerows(to_csv_rows(packages)) + return Response( + buffer.getvalue(), + mimetype="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="produse-finite.csv"'}, + ) diff --git a/cantarire/routes/label_templates.py b/cantarire/routes/label_templates.py new file mode 100644 index 0000000..47f364b --- /dev/null +++ b/cantarire/routes/label_templates.py @@ -0,0 +1,217 @@ +"""Administrarea sabloanelor de eticheta (designer vizual).""" +from __future__ import annotations + +from typing import Any + +from flask import (Blueprint, Response, flash, jsonify, redirect, render_template, request, + url_for) + +from .. import designer +from ..db import all_settings +from ..labels import print_payload, sample_package, sample_recipe +from ..repositories import ( + ValidationError, + create_template, + delete_template, + get_template, + list_label_fields, + list_templates, + save_template_design, + update_template, +) + +bp = Blueprint("label_templates", __name__, url_prefix="/sabloane") + + +@bp.get("") +def index(): + return render_template("templates/list.html", templates=list_templates()) + + +@bp.post("//stergere") +def remove(template_id: int): + try: + delete_template(template_id) + flash("Sabloul a fost sters.", "success") + except ValidationError as exc: + flash(str(exc), "error") + return redirect(url_for("label_templates.index")) + + +@bp.get("//previzualizare") +def preview(template_id: int): + template = get_template(template_id) + if template is None: + flash("Sabloul nu exista.", "error") + return redirect(url_for("label_templates.index")) + demo = sample_package() + payload = print_payload(demo, template, all_settings(), sample_recipe()) + return render_template("label_print.html", payload=payload, auto=False, preview=True) + + +# --------------------------------------------------------------------------- +# Designer vizual +# --------------------------------------------------------------------------- + +def _designer_context(template: dict) -> dict: + width = float(template.get("width_mm") or 100) + height = float(template.get("height_mm") or 60) + design = designer.load_design(template.get("design_json") or "", width, height) + if design is None: + design = designer.default_design(width, height) + custom_rows = list_label_fields() + demo_fields = designer.apply_custom_fields(designer.demo_fields(), custom_rows) + fields = [ + {"key": key, "label": label, "sample": sample} + for key, label, sample in designer.FIELD_CATALOG + ] + fields += designer.custom_field_catalog(custom_rows, demo_fields) + return { + "template": template, + "design": design, + "fields": fields, + "demo_fields": demo_fields, + "demo_items": designer.demo_items(), + "block_types": designer.BLOCK_TYPES, + "safe_mm": designer.SAFE_MARGIN_MM, + "custom_count": len(custom_rows), + "has_design": bool(template.get("design_json")), + } + + +@bp.get("//designer") +def designer_page(template_id: int): + template = get_template(template_id) + if template is None: + flash("Sabloul nu exista.", "error") + return redirect(url_for("label_templates.index")) + return render_template("templates/designer.html", **_designer_context(template)) + + +@bp.post("//designer") +def designer_save(template_id: int): + template = get_template(template_id) + if template is None: + return jsonify({"ok": False, "error": "Sabloul nu exista."}), 404 + + data = request.get_json(silent=True) or {} + try: + design = designer.validate_design( + data.get("design"), + float(template.get("width_mm") or 100), + float(template.get("height_mm") or 60), + ) + except ValidationError as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + save_template_design(template_id, designer.dump_design(design)) + + # Dimensiunea din designer devine dimensiunea sablonului. + width = float(design["width_mm"]) + height = float(design["height_mm"]) + if (width, height) != (float(template["width_mm"]), float(template["height_mm"])): + update_template(template_id, { + "name": template["name"], + "width_mm": width, + "height_mm": height, + "body_html": template["body_html"] or " ", + "css": template["css"], + "design_json": designer.dump_design(design), + "is_default": template["is_default"], + }) + + html, css = designer.render_preview(design, list_label_fields()) + return jsonify({"ok": True, "blocks": len(design["blocks"]), "html": html, "css": css}) + + +@bp.post("//designer/stergere") +@bp.post("//designer/reset") +def designer_reset(template_id: int): + """Reseteaza designul la varianta implicita (nu lasă șablonul fără etichetă).""" + template = get_template(template_id) + if template is None: + flash("Sabloul nu exista.", "error") + return redirect(url_for("label_templates.index")) + width = float(template.get("width_mm") or 100) + height = float(template.get("height_mm") or 60) + design = designer.default_design(width, height) + save_template_design(template_id, designer.dump_design(design)) + flash("Designul a fost resetat la varianta implicita.", "success") + return redirect(url_for("label_templates.designer_page", template_id=template_id)) + + +@bp.get("/designer/qr.svg") +def designer_qr(): + """Cod QR inline pentru previzualizarea din editor.""" + data = (request.args.get("data") or "")[:500] + svg = designer.qr_svg(data) + return Response(svg, mimetype="image/svg+xml", + headers={"Cache-Control": "public, max-age=3600"}) + + +@bp.post("/designer/nou") +def designer_create(): + """Creeaza un sablon nou cu designer, cu numele ales de utilizator.""" + width = _size_or_default(request.form.get("width_mm"), 100.0) + height = _size_or_default(request.form.get("height_mm"), 60.0) + design = designer.default_design(width, height) + + name = (request.form.get("name") or "").strip()[:80] + existing = {t["name"].strip().lower() for t in list_templates()} + if name: + if name.lower() in existing: + flash(f"Exista deja un sablon numit {name!r}. Alege alt nume.", "error") + return redirect(url_for("label_templates.index")) + else: + # Fara nume: generam unul disponibil, ca sa nu blocam crearea. + name = "Sablon designer" + base = name + counter = 2 + while name.lower() in existing: + name = f"{base} {counter}" + counter += 1 + + template_id = create_template({ + "name": name, + "width_mm": design["width_mm"], + "height_mm": design["height_mm"], + "body_html": " ", + "css": designer.DESIGN_CSS, + "design_json": designer.dump_design(design), + "is_default": request.form.get("is_default"), + }) + flash(f"Sablonul {name} a fost creat.", "success") + return redirect(url_for("label_templates.designer_page", template_id=template_id)) + + +def _size_or_default(value: Any, default: float) -> float: + try: + number = float(str(value).replace(",", ".")) + except (TypeError, ValueError): + return default + return number if 10 <= number <= 500 else default + + +@bp.post("//redenumire") +def rename(template_id: int): + """Schimba numele sablonului (din pagina de designer sau din lista).""" + template = get_template(template_id) + if template is None: + flash("Sabloul nu exista.", "error") + return redirect(url_for("label_templates.index")) + + name = (request.form.get("name") or "").strip() + if not name: + flash("Numele sablonului este obligatoriu.", "error") + elif name == template["name"]: + pass + else: + try: + update_template(template_id, {**template, "name": name}) + flash(f"Sabloul a fost redenumit in {name}.", "success") + except ValidationError as exc: + flash(str(exc), "error") + + if request.form.get("next") == "designer": + return redirect(url_for("label_templates.designer_page", template_id=template_id)) + return redirect(url_for("label_templates.index")) diff --git a/cantarire/routes/main.py b/cantarire/routes/main.py new file mode 100644 index 0000000..4f36f5e --- /dev/null +++ b/cantarire/routes/main.py @@ -0,0 +1,135 @@ +"""Pagina de cantarire si setarile aplicatiei.""" +from __future__ import annotations + +from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template, request, + send_file, url_for) + +from .. import maintenance +from ..db import all_settings, get_setting, set_setting +from ..repositories import ( + get_open_package, + get_recipe, + get_template, + list_components, + list_label_fields, + list_recipes, + list_templates, + package_state, + package_stats, + recent_packages, +) +from ..scale import get_scale + +bp = Blueprint("main", __name__) + + +@bp.get("/") +def dashboard(): + scale = get_scale(current_app) + profiles = scale.profiles() + active_profile = get_setting("active_scale_profile") + if active_profile not in {p["name"] for p in profiles}: + active_profile = profiles[0]["name"] if profiles else "" + + open_package = get_open_package() + initial = package_state(open_package["id"]) if open_package else None + + settings = all_settings() + template_id = settings.get("default_template_id") or "" + if template_id and get_template(int(template_id)) is None: + template_id = "" + + recipes = [get_recipe(item["id"]) for item in list_recipes()] + recipes = [recipe for recipe in recipes if recipe is not None] + + return render_template( + "dashboard.html", + recipes=recipes, + components=list_components(), + initial=initial, + recent=recent_packages(6), + stats=package_stats(), + profiles=profiles, + active_profile=active_profile, + scale_ready=bool(profiles), + default_operator=settings.get("default_operator", ""), + default_template_id=template_id, + auto_print=settings.get("auto_print", "1") == "1" and current_app.config["AUTO_PRINT"], + poll_ms=700, + scale_error=scale.error, + setup=maintenance.readiness_summary(), + ) + + +@bp.get("/healthz") +def healthz(): + """Verificare simpla pentru monitorizare / reverse proxy.""" + status = maintenance.database_status() + summary = maintenance.readiness_summary() + healthy = status["integrity"] == "ok" + payload = { + "ok": healthy, + "version": "0.1.0", + "database": { + "path": status["path"], + "size": status["size"], + "integrity": status["integrity"], + "schema_version": status["schema_version"], + "counts": status["counts"], + }, + "setup": { + "ready": summary["ready"], + "missing": [item["label"] for item in summary["missing"]], + }, + } + return jsonify(payload), (200 if healthy else 503) + + +@bp.get("/setari") +def settings_page(): + scale = get_scale(current_app) + return render_template( + "settings.html", + profiles=scale.profiles(), + scale_available=scale.available, + scale_error=scale.error, + templates=list_templates(), + components=list_components(include_inactive=True), + recipes=list_recipes(include_inactive=True), + fields_count=len(list_label_fields(include_inactive=True)), + db_status=maintenance.database_status(), + backups=maintenance.list_backups()[:5], + setup=maintenance.readiness_summary(), + ) + + +@bp.get("/setari/backup") +def settings_backup(): + """Descarca un backup proaspat al bazei (inainte de o actualizare).""" + path = maintenance.backup_database("manual") + maintenance.prune_backups() + return send_file(path, as_attachment=True, download_name=path.name) + + +@bp.post("/setari") +def settings_save(): + scale = get_scale(current_app) + valid_profiles = {p["name"] for p in scale.profiles()} + active = (request.form.get("active_scale_profile") or "").strip() + if active and active not in valid_profiles: + flash("Profilul de cantar selectat nu exista.", "error") + return redirect(url_for("main.settings_page")) + + template_id = (request.form.get("default_template_id") or "").strip() + if template_id and (not template_id.isdigit() or get_template(int(template_id)) is None): + flash("Sabloul implicit selectat nu exista.", "error") + return redirect(url_for("main.settings_page")) + + for key in ("company_name", "company_address", "company_phone", "default_operator"): + set_setting(key, (request.form.get(key) or "").strip()[:200]) + set_setting("active_scale_profile", active) + set_setting("default_template_id", template_id) + set_setting("auto_print", "1" if request.form.get("auto_print") else "0") + + flash("Setarile au fost salvate.", "success") + return redirect(url_for("main.settings_page")) diff --git a/cantarire/routes/monitor.py b/cantarire/routes/monitor.py new file mode 100644 index 0000000..238d291 --- /dev/null +++ b/cantarire/routes/monitor.py @@ -0,0 +1,85 @@ +"""Monitor de port serial: identifica formatul unui cantar necunoscut.""" +from __future__ import annotations + +import math + +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for + +from ..probe import BAUD_RATES, FRAMINGS, ProbeError, list_ports, save_capture, sweep +from ..scale import get_scale + +bp = Blueprint("monitor", __name__, url_prefix="/monitor") + + +def _form_values() -> dict: + def number(name: str, default: float) -> float: + raw = (request.form.get(name) or "").strip().replace(",", ".") + try: + value = float(raw) + except ValueError: + return default + return value if math.isfinite(value) else default + + return { + "port": (request.form.get("port") or "").strip(), + "baud": int(number("baud", 9600)), + "framing": (request.form.get("framing") or "8N1").strip().upper(), + "seconds": min(max(number("seconds", 5), 0.5), 30), + "save": bool(request.form.get("save")), + "name": (request.form.get("name") or "capture").strip()[:60], + "scan": bool(request.form.get("scan")), + } + + +@bp.route("", methods=["GET", "POST"]) +def index(): + scale = get_scale(current_app) + values = _form_values() if request.method == "POST" else { + "port": "", "baud": 9600, "framing": "8N1", "seconds": 5.0, + "save": False, "name": "capture", "scan": False, + } + + result = None + scan_hits = None + saved_path = None + error = None + + if request.method == "POST": + if not values["port"]: + error = "Alege portul serial." + elif values["scan"]: + try: + rows = sweep(values["port"], seconds=min(values["seconds"], 2.0), + framings=(values["framing"],), bauds=BAUD_RATES) + scan_hits = [r for r in rows if r.get("bytes")] + if not scan_hits: + error = "Niciun byte primit la vitezele incercate. Verifica cablul si setarile cantarului." + except ProbeError as exc: + error = str(exc) + else: + result = scale.probe(values["port"], values["baud"], values["framing"], values["seconds"]) + if not result.get("ok"): + error = result.get("error") or "Portul nu a putut fi deschis." + elif values["save"] and result.get("frames"): + try: + saved_path = save_capture( + current_app.config["INSTANCE_DIR"] / "captures", + values["name"], + result["frames"], + ) + flash(f"Captura a fost salvata in {saved_path}.", "success") + except OSError as exc: + error = f"Captura nu a putut fi scrisa: {exc}" + + return render_template( + "monitor.html", + values=values, + ports=list_ports(), + profiles=scale.profiles(), + result=result, + scan_hits=scan_hits, + saved_path=saved_path, + error=error, + baud_rates=BAUD_RATES, + framings=FRAMINGS, + ) diff --git a/cantarire/routes/printing.py b/cantarire/routes/printing.py new file mode 100644 index 0000000..d853a23 --- /dev/null +++ b/cantarire/routes/printing.py @@ -0,0 +1,46 @@ +"""Pagina de printare a unei etichete de produs finit.""" +from __future__ import annotations + +from flask import Blueprint, abort, current_app, render_template, request + +from ..db import all_settings, get_setting +from ..labels import LabelTemplateError, print_payload +from ..repositories import get_default_template, get_package, get_recipe, get_template + +bp = Blueprint("printing", __name__) + + +@bp.get("/labels//print") +def print_label(package_id: int): + package = get_package(package_id) + if package is None: + abort(404) + + template_id = request.args.get("template_id") or package.get("template_id") + configured = get_setting("default_template_id", "") + template = None + if template_id: + try: + template = get_template(int(template_id)) + except (TypeError, ValueError): + template = None + if template is None and configured.isdigit(): + template = get_template(int(configured)) + template = template or get_default_template() + if template is None: + abort(409, "Nu exista niciun sablon de eticheta configurat.") + + recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None + try: + payload = print_payload(package, template, all_settings(), recipe) + except LabelTemplateError as exc: + abort(500, str(exc)) + + settings = all_settings() + auto = request.args.get("auto") + if auto is None: + auto = settings.get("auto_print", "1") == "1" and current_app.config["AUTO_PRINT"] + else: + auto = auto not in {"0", "false", "no"} + + return render_template("label_print.html", payload=payload, auto=bool(auto), preview=False) diff --git a/cantarire/routes/recipes.py b/cantarire/routes/recipes.py new file mode 100644 index 0000000..12bea22 --- /dev/null +++ b/cantarire/routes/recipes.py @@ -0,0 +1,85 @@ +"""Administrarea produselor finite (retetelor).""" +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from ..repositories import ( + MAX_RECIPE_COMPONENTS, + ValidationError, + create_recipe, + delete_recipe, + get_recipe, + list_components, + list_recipes, + list_templates, + update_recipe, +) + +bp = Blueprint("recipes", __name__, url_prefix="/retete") + + +def _slot_rows(source: dict | None) -> list[dict]: + source = source or {} + components = source.get("components") or [] + rows = [] + for slot in range(1, MAX_RECIPE_COMPONENTS + 1): + existing = next((c for c in components if c.get("position") == slot), None) + rows.append({ + "slot": slot, + "component_id": existing.get("component_id") if existing else source.get(f"component_{slot}_id", ""), + "target": existing.get("target_weight") if existing else source.get(f"component_{slot}_target", ""), + }) + return rows + + +def _render(template, is_new: bool, recipe: dict | None = None): + return render_template( + template, + recipe=recipe or {}, + is_new=is_new, + components=list_components(), + templates=list_templates(), + slot_rows=_slot_rows(recipe), + ) + + +@bp.get("") +def index(): + return render_template("recipes/list.html", recipes=list_recipes(include_inactive=True)) + + +@bp.route("/nou", methods=["GET", "POST"]) +def create(): + if request.method == "POST": + try: + create_recipe(request.form) + flash("Produsul finit a fost adaugat.", "success") + return redirect(url_for("recipes.index")) + except ValidationError as exc: + flash(str(exc), "error") + return _render("recipes/form.html", True, dict(request.form) if request.method == "POST" else {}) + + +@bp.route("//editare", methods=["GET", "POST"]) +def edit(recipe_id: int): + recipe = get_recipe(recipe_id) + if recipe is None: + flash("Produsul finit nu exista.", "error") + return redirect(url_for("recipes.index")) + + if request.method == "POST": + try: + update_recipe(recipe_id, request.form) + flash("Produsul finit a fost actualizat.", "success") + return redirect(url_for("recipes.index")) + except ValidationError as exc: + flash(str(exc), "error") + recipe = {**recipe, **dict(request.form)} + return _render("recipes/form.html", False, recipe) + + +@bp.post("//stergere") +def remove(recipe_id: int): + delete_recipe(recipe_id) + flash("Produsul finit a fost sters sau dezactivat (daca are pachete in istoric).", "success") + return redirect(url_for("recipes.index")) diff --git a/cantarire/scale.py b/cantarire/scale.py new file mode 100644 index 0000000..806b29a --- /dev/null +++ b/cantarire/scale.py @@ -0,0 +1,229 @@ +"""Adaptor pentru pachetul vendorizat `scale_bridge`. + +Aplicatia importa direct biblioteca SyncPulse Scale Bridge (nu trece prin HTTP): +bridge-ul tine portul serial deschis si ne da citirea deja parsata, cu unitate, +stare de stabilitate si valoarea bruta. +""" +from __future__ import annotations + +import atexit +import sys +import threading +from pathlib import Path +from typing import Any, Optional + +from .config import BASE_DIR + +VENDOR_DIR = BASE_DIR / "vendor" / "syncpulse-scale-bridge" + + +class ScaleError(RuntimeError): + """Cantarul nu poate fi citit (port ocupat, profile lipsa, etc.).""" + + +def _vendor_on_path() -> None: + path = str(VENDOR_DIR) + if path not in sys.path: + sys.path.insert(0, path) + + +class ScaleService: + """O singura instanta per proces; porturile rămân deschise intre cereri.""" + + def __init__(self, bridge_config: str | Path, simulate: str = ""): + self.bridge_config = str(bridge_config) + self.simulate = simulate + self._lock = threading.Lock() + self._loaded = False + self._config: Any = None + self._registry: Any = None + self._simulator: Any = None + self._opener: Any = None + self._error: Optional[str] = None + self._closed = False + + # -- initializare ------------------------------------------------------ + def _load(self) -> None: + if self._loaded: + return + with self._lock: + if self._loaded: + return + try: + _vendor_on_path() + from scale_bridge.config import Config as BridgeConfig + from scale_bridge.serialio import Registry, open_real + from scale_bridge.simulator import SimulatorBank + + Path(self.bridge_config).parent.mkdir(parents=True, exist_ok=True) + self._config = BridgeConfig(self.bridge_config) + if self.simulate: + self._simulator = SimulatorBank(self.simulate) + self._opener = self._simulator.opener(open_real) + self._registry = Registry(self._opener) + else: + self._opener = open_real + self._registry = Registry(self._opener) + except Exception as exc: # noqa: BLE001 - afisam motivul in UI + self._error = f"Scale bridge indisponibil: {exc}" + finally: + self._loaded = True + + @property + def available(self) -> bool: + self._load() + return self._registry is not None and self._config is not None + + @property + def error(self) -> Optional[str]: + self._load() + return self._error + + # -- profile ----------------------------------------------------------- + def profiles(self) -> list[dict[str, Any]]: + self._load() + if self._config is None: + return [] + return [ + { + "name": p["name"], + "label": p.get("label") or p["name"], + "port": p.get("port", ""), + "protocol": p.get("protocol", ""), + } + for p in self._config.profiles() + ] + + def resolve_profile(self, name: str = "") -> Optional[dict[str, Any]]: + self._load() + if self._config is None: + return None + if name: + return self._config.profile(name) + profiles = self._config.profiles() + return profiles[0] if profiles else None + + # -- citire ------------------------------------------------------------ + def read( + self, + profile_name: str = "", + *, + max_age_ms: int = 1500, + wait_ms: int = 2500, + ) -> dict[str, Any]: + """Citire normalizata, gata de trimis catre UI/API.""" + self._load() + if self._config is None or self._registry is None: + return self._failure(self._error or "Scale bridge indisponibil.") + + try: + from scale_bridge import profiles as bridge_profiles + from scale_bridge.serialio import PortBusy, now_ms + except Exception as exc: # noqa: BLE001 + return self._failure(f"Scale bridge indisponibil: {exc}") + + profile = self.resolve_profile(profile_name) + if profile is None: + if profile_name: + return self._failure(f"Profilul de cantar {profile_name!r} nu exista.") + return self._failure("Nu exista niciun profil de cantar configurat.") + + try: + worker = self._registry.worker_for(bridge_profiles.line_settings(profile)) + result = bridge_profiles.wait_for_reading( + profile, worker, int(max_age_ms), int(wait_ms), now_ms + ) + worker.touch() + except PortBusy as exc: + return self._failure(str(exc), profile=profile["name"]) + except Exception as exc: # noqa: BLE001 + return self._failure(f"Eroare la citirea cantarului: {exc}", profile=profile["name"]) + + return { + "ok": bool(result.get("ok")), + "error": result.get("error"), + "profile": result.get("profile") or profile["name"], + "port": result.get("port") or profile.get("port", ""), + "protocol": result.get("protocol") or profile.get("protocol", ""), + "open": result.get("open"), + "value": result.get("value"), + "unit": result.get("unit") or profile.get("unit") or "", + "stable": bool(result.get("stable")), + "stale": bool(result.get("stale")), + "overload": bool(result.get("overload")), + "age_ms": result.get("age_ms"), + "measured_at": result.get("measured_at"), + "raw_hex": result.get("raw_hex") or "", + "net": result.get("net"), + "fields": result.get("fields") or {}, + } + + @staticmethod + def _failure(message: str, profile: str = "") -> dict[str, Any]: + return { + "ok": False, + "error": message, + "profile": profile, + "value": None, + "unit": "", + "stable": False, + "stale": False, + "overload": False, + "age_ms": None, + "measured_at": None, + "raw_hex": "", + "fields": {}, + } + + # -- diagnostic -------------------------------------------------------- + def probe( + self, + port: str, + baud: int = 9600, + framing: str = "8N1", + seconds: float = 5.0, + ) -> dict[str, Any]: + """Asculta un port (fara sa scrie nimic) si raporteaza ce sosește. + + Citirea normala a cantarului tine portul deschis; pentru monitor il + eliberam intai, apoi registrul il redeschide la urmatoarea citire. + """ + self._load() + if self._registry is None: + return {"ok": False, "port": port, "error": self._error or + "Scale bridge indisponibil.", "frames": [], "guesses": [], "bytes": 0} + try: + from .probe import probe_port + + self._registry.release(port) + return probe_port(port, baud, framing, seconds, opener=self._opener) + except Exception as exc: # noqa: BLE001 - eroarea ajunge in pagina de monitor + return {"ok": False, "port": port, "error": str(exc), + "frames": [], "guesses": [], "bytes": 0} + + # -- oprire ------------------------------------------------------------ + def close(self) -> None: + if self._closed: + return + self._closed = True + registry = self._registry + if registry is not None: + try: + registry.stop_all() + except Exception: # noqa: BLE001 + pass + + +def get_scale(app) -> ScaleService: + service = app.extensions.get("scale") + if service is None: # pragma: no cover - create_app il creeaza mereu + service = ScaleService(app.config["BRIDGE_CONFIG"], app.config["SIMULATE"]) + app.extensions["scale"] = service + return service + + +def create_scale(app) -> ScaleService: + service = ScaleService(app.config["BRIDGE_CONFIG"], app.config["SIMULATE"]) + app.extensions["scale"] = service + atexit.register(service.close) + return service diff --git a/cantarire/schema.sql b/cantarire/schema.sql new file mode 100644 index 0000000..bad0f27 --- /dev/null +++ b/cantarire/schema.sql @@ -0,0 +1,117 @@ +-- Schema SQLite: produse finite din 1..N componente cantarite. +PRAGMA foreign_keys = ON; + +-- Componentele cantarite (materii prime / semifabricate). +CREATE TABLE IF NOT EXISTS components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'g', + description TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Produsul finit: reteta si ordinea componentelor. +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'g', + target_weight REAL, + note TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + -- Sablonul de eticheta folosit pentru acest produs finit. + template_id INTEGER REFERENCES label_templates(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS recipe_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + component_id INTEGER REFERENCES components(id) ON DELETE SET NULL, + name TEXT NOT NULL DEFAULT '', + target_weight REAL, + UNIQUE(recipe_id, position) +); + +CREATE INDEX IF NOT EXISTS idx_recipe_components_recipe ON recipe_components(recipe_id, position); + +-- Un pachet = o eticheta de produs finit. +CREATE TABLE IF NOT EXISTS packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL DEFAULT 'open', -- open | done | void + recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL, + recipe_code TEXT NOT NULL DEFAULT '', + recipe_name TEXT NOT NULL DEFAULT '', + unit TEXT NOT NULL DEFAULT 'g', + target_weight REAL, + total_weight REAL, + operator TEXT NOT NULL DEFAULT '', + lot TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + template_id INTEGER REFERENCES label_templates(id) ON DELETE SET NULL, + printed_at TEXT, + print_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status); +CREATE INDEX IF NOT EXISTS idx_packages_completed ON packages(completed_at DESC); + +-- Cantaririle pachetului, in ordinea retetei. +CREATE TABLE IF NOT EXISTS package_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + component_id INTEGER, + component_code TEXT NOT NULL DEFAULT '', + component_name TEXT NOT NULL, + weight REAL NOT NULL, + unit TEXT NOT NULL, + target_weight REAL, + stable INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'manual', -- manual | scale + scale_profile TEXT NOT NULL DEFAULT '', + raw_hex TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(package_id, position) +); + +CREATE INDEX IF NOT EXISTS idx_package_items_package ON package_items(package_id, position); + +CREATE TABLE IF NOT EXISTS label_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + width_mm REAL NOT NULL DEFAULT 100, + height_mm REAL NOT NULL DEFAULT 60, + body_html TEXT NOT NULL, + css TEXT NOT NULL DEFAULT '', + design_json TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Campuri variabile proprii, disponibile in designer langa cele fixe. +-- `template` este text liber cu referinte {{campiexistent}} la campurile fixe. +CREATE TABLE IF NOT EXISTS label_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + template TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_label_fields_active ON label_fields(active, label); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/cantarire/static/css/app.css b/cantarire/static/css/app.css new file mode 100644 index 0000000..89c62e6 --- /dev/null +++ b/cantarire/static/css/app.css @@ -0,0 +1,726 @@ +:root { + --bg: #eef2f6; + --panel: #ffffff; + --panel-soft: #f7f9fb; + --ink: #0f172a; + --muted: #64748b; + --line: #dbe3ec; + --brand: #0f766e; + --brand-dark: #115e59; + --brand-soft: #ccfbf1; + --ok: #15803d; + --ok-soft: #dcfce7; + --warn: #b45309; + --warn-soft: #fef3c7; + --err: #b91c1c; + --err-soft: #fee2e2; + --radius: 12px; + --shadow: 0 1px 2px rgba(15, 23, 42, .05), 0 8px 24px rgba(15, 23, 42, .06); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +a { color: var(--brand-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +code, .code, pre, .code-block { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: .9em; +} + +/* -- layout -------------------------------------------------------------- */ +.topbar { + display: flex; + align-items: center; + gap: 24px; + padding: 12px 24px; + background: #0b1220; + color: #e2e8f0; + position: sticky; + top: 0; + z-index: 20; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: #fff; + font-weight: 700; + letter-spacing: .2px; +} +.brand:hover { text-decoration: none; } + +.brand-mark { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, #14b8a6, #0f766e); + color: #04211d; + font-size: 13px; + font-weight: 800; +} + +.nav { display: flex; gap: 4px; flex-wrap: wrap; } +.nav a { + color: #cbd5e1; + padding: 8px 12px; + border-radius: 8px; + font-weight: 500; +} +.nav a:hover { background: #1e293b; color: #fff; text-decoration: none; } +.nav a.active { background: #14532d; color: #d1fae5; } + +/* -- subnavigatie pentru sectiunea Setari -------------------------------- */ +.subnav { + position: sticky; + top: 62px; /* inaltimea topbar-ului */ + z-index: 19; + background: #f8fafc; + border-bottom: 1px solid var(--line); +} +.subnav-inner { + max-width: 1180px; + margin: 0 auto; + padding: 8px 24px; + display: flex; + gap: 4px; + overflow-x: auto; + scrollbar-width: thin; +} +.subnav a { + flex: none; + padding: 7px 14px; + border-radius: 999px; + font-size: 13.5px; + font-weight: 600; + color: #475569; + white-space: nowrap; +} +.subnav a:hover { background: #e2e8f0; color: var(--ink); text-decoration: none; } +.subnav a.active { background: var(--brand); color: #fff; } + +/* -- carduri de administrare (hub in Setari) ----------------------------- */ +.hub-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 12px; + margin: 0 0 18px; +} +.hub-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + color: inherit; +} +.hub-card:hover { border-color: var(--brand); background: #fff; text-decoration: none; } +.hub-title { font-weight: 700; font-size: 15px; } +.hub-meta { font-size: 12.5px; color: var(--muted); } + +/* -- QZ Tray status ------------------------------------------------------ */ +.qz-pill { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 13px; + border: 1px solid #334155; + border-radius: 999px; + background: #111c2e; + color: #cbd5e1; + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} +.qz-pill:hover { background: #1e293b; } +.qz-pill:disabled { cursor: default; opacity: 1; } +.qz-pill[data-qz-state="ready"] { border-color: #15803d; color: #bbf7d0; } +.qz-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #94a3b8; + flex: none; +} +.qz-pill[data-qz-state="ready"] .qz-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .2); } +.qz-pill[data-qz-state="connecting"] .qz-dot { background: #f59e0b; } +.qz-pill[data-qz-state="offline"] .qz-dot, +.qz-pill[data-qz-state="missing"] .qz-dot { background: #ef4444; } + +.qz-hint { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding-top: 12px; + margin-top: 10px; + border-top: 1px dashed var(--line); + color: var(--muted); + font-size: 13px; +} +.qz-state-line { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; } + +.container { + max-width: 1240px; + margin: 0 auto; + padding: 24px 24px 56px; +} + +.footer { + max-width: 1240px; + margin: 0 auto; + padding: 20px 24px 32px; + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--muted); + font-size: 12px; +} + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 18px; + flex-wrap: wrap; +} +.page-head h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -.4px; } +.subtitle { margin: 0; color: var(--muted); } + +.stats { display: flex; gap: 10px; } +.stat { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 10px 16px; + min-width: 110px; + box-shadow: var(--shadow); +} +.stat-value { display: block; font-size: 22px; font-weight: 800; line-height: 1.1; } +.stat-label { display: block; color: var(--muted); font-size: 12px; } + +/* -- cards --------------------------------------------------------------- */ +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + min-width: 0; +} +.card h2 { margin: 0 0 14px; font-size: 17px; } +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} +.card-head h2 { margin: 0; } +.card.narrow { max-width: 620px; } + +.grid { display: grid; gap: 18px; } +.weigh-grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); margin-bottom: 18px; } +.template-grid { grid-template-columns: minmax(0, 1.4fr) minmax(0, .8fr); align-items: start; } +.settings-grid { grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); align-items: start; } + +@media (max-width: 900px) { + .weigh-grid, .template-grid, .settings-grid { grid-template-columns: 1fr; } + /* cele doua taburi principale raman vizibile si pe ecrane mici */ + .topbar { gap: 12px; padding: 10px 14px; flex-wrap: wrap; } + .brand-text { display: none; } + .nav a { padding: 7px 10px; font-size: 14px; } + .subnav { top: 54px; } + .subnav-inner { padding: 6px 14px; } + .subnav a { padding: 6px 11px; font-size: 12.5px; } +} + +/* -- forms --------------------------------------------------------------- */ +.form, .filters { display: flex; flex-direction: column; gap: 14px; } +.filters { flex-direction: row; flex-wrap: wrap; align-items: flex-end; } + +.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.field > label { font-weight: 600; font-size: 13px; color: #334155; } +.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.field.grow { flex: 1 1 220px; } +.actions-field { flex-direction: row; gap: 8px; } + +input[type="text"], input[type="search"], input[type="date"], input[type="number"], +select, textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid #c8d3e0; + border-radius: 8px; + background: #fff; + color: var(--ink); + font: inherit; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(15, 118, 110, .16); +} +textarea { resize: vertical; } +textarea.code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.45; } + +.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #334155; } +.req { color: var(--err); } +.inline-select { width: auto; padding: 6px 10px; font-size: 13px; } +.inline { display: inline; } + +.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; } + +/* Formularul de creare sablon: nume + buton, pe un singur rand. */ +.new-template-form { display: flex; align-items: center; gap: 8px; } +.new-template-form input[type="text"] { width: 190px; padding: 9px 11px; } +.new-template-form .btn { flex: none; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + border: 1px solid #c8d3e0; + border-radius: 8px; + background: #fff; + color: var(--ink); + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} +.btn:hover { background: var(--panel-soft); text-decoration: none; } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; } +.btn.primary:hover { background: var(--brand-dark); } +.btn.ghost { background: transparent; } +.btn.danger { color: var(--err); } +.btn.warn { + background: #fffbeb; + border-color: #f59e0b; + color: #92400e; + font-weight: 600; +} +.btn.warn:hover { background: #fef3c7; border-color: #d97706; } + +.link { + background: none; + border: 0; + padding: 0 6px; + color: var(--brand-dark); + font: inherit; + font-weight: 600; + cursor: pointer; +} +.link:hover { text-decoration: underline; } +.link.danger { color: var(--err); } + +/* -- scale display ------------------------------------------------------- */ +.scale-card { display: flex; flex-direction: column; } +.scale-head { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + margin-bottom: 12px; +} +.dot { width: 10px; height: 10px; border-radius: 50%; background: #94a3b8; } +.dot.ok { background: #22c55e; box-shadow: 0 0 0 4px rgba(34, 197, 94, .18); } +.dot.off { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); } +.chip { + margin-left: auto; + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 10px; + font-size: 12px; + color: var(--muted); + font-weight: 600; +} + +.weight-display { + display: flex; + align-items: baseline; + justify-content: center; + gap: 12px; + padding: 26px 12px 18px; + background: + radial-gradient(circle at 50% 0%, rgba(20, 184, 166, .12), transparent 62%), + var(--panel-soft); + border: 1px solid var(--line); + border-radius: var(--radius); +} +#weight-value { + font-size: clamp(48px, 9vw, 88px); + font-weight: 800; + letter-spacing: -2px; + line-height: 1; + font-variant-numeric: tabular-nums; +} +#weight-value.unstable { color: #b45309; } +#weight-value.error { color: #b91c1c; } +.weight-unit { font-size: 24px; font-weight: 700; color: var(--muted); } + +.weight-meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin: 12px 0 4px; + min-height: 28px; +} +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: #e2e8f0; + color: #334155; +} +.badge.ok { background: var(--ok-soft); color: var(--ok); } +.badge.warn { background: var(--warn-soft); color: var(--warn); } +.badge.err { background: var(--err-soft); color: var(--err); } +.badge.off { background: #e2e8f0; color: var(--muted); } +.raw { margin-left: auto; color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; } + +.scale-hint { + margin-top: auto; + padding-top: 14px; + border-top: 1px dashed var(--line); + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 13px; +} + +/* -- messages ------------------------------------------------------------ */ +.message { margin: 12px 0 0; min-height: 20px; font-weight: 500; } +.message.error { color: var(--err); } +.message.success { color: var(--ok); } +.message.info { color: var(--muted); } +.muted { color: var(--muted); } +.small { font-size: 12.5px; } + +.notice { + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--line); + margin-bottom: 16px; + background: var(--panel); +} +.notice.warning { background: var(--warn-soft); border-color: #fde68a; color: #78350f; } +.notice a { color: inherit; text-decoration: underline; } + +.flash-stack { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } +.flash { + padding: 12px 16px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--panel); + font-weight: 500; +} +.flash-success { background: var(--ok-soft); border-color: #bbf7d0; color: #14532d; } +.flash-error { background: var(--err-soft); border-color: #fecaca; color: #7f1d1d; } + +/* -- tables -------------------------------------------------------------- */ +.table-wrap { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { + text-align: left; + padding: 11px 12px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} +.table th { + font-size: 12px; + text-transform: uppercase; + letter-spacing: .4px; + color: var(--muted); + background: var(--panel-soft); +} +.table tbody tr:hover { background: #f8fafc; } +.table.compact th, .table.compact td { padding: 8px 10px; } +.table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.table .right { text-align: right; } +.nowrap { white-space: nowrap; } +.row-muted td { color: var(--muted); } + +/* -- help / settings ----------------------------------------------------- */ +.help-card { background: var(--panel-soft); } +.help-card h2:not(:first-child) { margin-top: 22px; } +.var-list { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; font-size: 13.5px; } +.var-list code { background: #e2e8f0; padding: 1px 5px; border-radius: 4px; } +.code-block { + margin: 0; + padding: 12px; + background: #0b1220; + color: #d1fae5; + border-radius: 8px; + overflow-x: auto; + font-size: 12.5px; + line-height: 1.5; +} +.profile-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.profile-list li { padding: 10px 12px; background: #fff; border: 1px solid var(--line); border-radius: 8px; } + +/* -- monitor serial ------------------------------------------------------ */ +.monitor-options { + display: flex; + gap: 18px; + flex-wrap: wrap; + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--line); +} +.monitor-options .check { font-size: 13px; } +.card h3 { margin: 20px 0 10px; font-size: 15px; color: #1e293b; } +code.hex { + display: inline-block; + max-width: 420px; + overflow-wrap: anywhere; + font-size: 11.5px; + color: #0f766e; +} + +/* -- fluxul de cântărire ------------------------------------------------ */ +.flow-card { display: flex; flex-direction: column; } +.flow-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.flow-head h2 { margin: 0; } +.flow-body { flex: 1; display: flex; flex-direction: column; gap: 14px; } + +.step { + padding: 18px; + border-radius: var(--radius); + background: + radial-gradient(circle at 50% 0%, rgba(20, 184, 166, .14), transparent 65%), + var(--panel-soft); + border: 1px solid var(--line); + text-align: center; +} +.step-label { + font-size: 12px; + font-weight: 700; + letter-spacing: .6px; + text-transform: uppercase; + color: var(--muted); +} +.step-component { + font-size: clamp(28px, 4.4vw, 44px); + font-weight: 800; + letter-spacing: -.6px; + line-height: 1.1; + margin: 4px 0 2px; +} +.step-target { color: var(--brand-dark); font-weight: 700; } + +.plan { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + color: #334155; +} +.plan li { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 10px; + border-radius: 8px; + background: var(--panel-soft); + border: 1px solid var(--line); +} +.plan .w { + font-variant-numeric: tabular-nums; + font-weight: 700; + min-width: 74px; + color: var(--ink); +} +.plan.accepted li { background: #f0fdf4; border-color: #bbf7d0; } + +.done { text-align: center; padding: 8px 0 2px; } +.done-mark { + width: 52px; + height: 52px; + margin: 0 auto 8px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--ok-soft); + color: var(--ok); + font-size: 26px; + font-weight: 800; +} +.done-total { display: flex; align-items: baseline; justify-content: center; gap: 8px; } +.done-total .value { font-size: 40px; font-weight: 800; letter-spacing: -1px; } +.done-total .unit { font-size: 18px; font-weight: 700; color: var(--muted); } + +.component-row { + display: flex; + align-items: flex-end; + gap: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-soft); +} +.component-index { + flex: none; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--brand); + color: #fff; + font-weight: 800; + font-size: 13px; + margin-bottom: 8px; +} +.component-target { flex: none; width: 110px; } + +.hint { margin: 0; } + +/* -- print / toast ------------------------------------------------------- */ +.print-frame { + position: fixed; + width: 1px; + height: 1px; + left: -9999px; + top: -9999px; + border: 0; + visibility: hidden; +} +.hidden { display: none !important; } + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + max-width: 420px; + padding: 12px 16px; + border-radius: 10px; + background: #0b1220; + color: #e2e8f0; + box-shadow: 0 12px 32px rgba(15, 23, 42, .3); + opacity: 0; + transform: translateY(8px); + transition: opacity .18s ease, transform .18s ease; + pointer-events: none; + font-weight: 500; + z-index: 50; +} +.toast.show { opacity: 1; transform: translateY(0); } +.toast.error { background: #7f1d1d; color: #fee2e2; } +.toast.warn { background: #78350f; color: #fef3c7; } +.toast.success { background: #14532d; color: #dcfce7; } + +/* -- campuri variabile --------------------------------------------------- */code.field-template { + display: inline-block; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; + padding: 2px 6px; + border-radius: 5px; + background: var(--panel-soft); + font-size: 12px; +} +.field-preview { + padding: 10px 12px; + border: 1px dashed #c8d3e0; + border-radius: 8px; + background: var(--panel-soft); + font-size: 15px; + min-height: 42px; + white-space: pre-wrap; + word-break: break-word; +} +.field-preview.error { border-color: #fca5a5; background: #fef2f2; color: #b91c1c; font-size: 13px; } +.field-chips { display: flex; flex-wrap: wrap; gap: 8px; } +.field-chip { + display: flex; + flex-direction: column; + gap: 2px; + padding: 7px 11px; + border: 1px solid #c8d3e0; + border-radius: 8px; + background: #fff; + font: inherit; + text-align: left; + cursor: pointer; +} +.field-chip:hover { border-color: var(--brand); background: var(--brand-soft); } +.chip-label { font-size: 12.5px; font-weight: 600; } +.chip-sample { font-size: 11px; color: var(--muted); } + +/* -- checklist de pornire ------------------------------------------------ */ +.setup-card { margin-bottom: 18px; border-left: 4px solid #f59e0b; } +.setup-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.setup-head h2 { margin: 0 0 2px; } +.setup-head p { margin: 0; } +.setup-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; } +.setup-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-soft); +} +.setup-mark { + flex: none; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: #fde68a; + color: #92400e; + font-weight: 800; + font-size: 13px; +} +.setup-body { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; } +.setup-item .btn { flex: none; padding: 7px 12px; font-size: 13px; } + +.system-list { list-style: none; margin: 6px 0 12px; padding: 0; display: grid; gap: 4px; } +.system-list code { word-break: break-all; font-size: 11.5px; } diff --git a/cantarire/static/css/designer.css b/cantarire/static/css/designer.css new file mode 100644 index 0000000..b2c64bd --- /dev/null +++ b/cantarire/static/css/designer.css @@ -0,0 +1,467 @@ +/* Designer de etichete: layout, canvas, panouri. */ + +.designer { display: flex; flex-direction: column; gap: 14px; } + +.designer-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} +.designer-head h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -.3px; } +.designer-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + +/* Numele sablonului se editeaza direct din antet. */ +.designer-head-main { min-width: 0; flex: 1 1 320px; } +.rename-form { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; } +.rename-form .btn { padding: 7px 12px; font-size: 13px; flex: none; } +.title-input { + width: min(420px, 100%); + padding: 6px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + font: inherit; + font-size: 24px; + font-weight: 700; + letter-spacing: -.3px; + color: var(--ink); +} +.title-input:hover { border-color: var(--line); background: #fff; } +.title-input:focus { + border-color: var(--brand); + background: #fff; + outline: none; + box-shadow: 0 0 0 3px rgba(15, 118, 110, .16); +} + +.designer-grid { + display: grid; + grid-template-columns: 200px minmax(0, 1fr) 260px; + gap: 14px; + align-items: start; +} + +/* -- bara de sus: adaugare, dimensiuni, zoom, zona sigura ---------------- */ +.designer-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 22px; + padding: 12px 16px 14px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.designer-toolbar h2 { + margin: 0 0 7px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--muted); + white-space: nowrap; +} +.tool-group { + display: flex; + flex-direction: column; + min-width: 0; +} +.tool-grow { flex: 1 1 320px; } +.tool-group + .tool-group { + padding-left: 22px; + border-left: 1px solid var(--line); +} +.tool-fields { display: flex; gap: 10px; } +.tool-fields .field { gap: 4px; } +.tool-fields .field > label { font-size: 11.5px; } +.tool-fields input { width: 82px; padding: 7px 9px; } +.zoom-slider { width: 130px; margin: 6px 0 0; } +.tool-safe { min-width: 168px; } +.tool-safe .check { font-size: 13px; } +.tool-safe .safe-hint { max-width: 210px; margin-top: 6px; } +.tool-safe #safe-arrange { + margin-top: 8px; + width: 100%; + justify-content: center; + padding: 7px 10px; + font-size: 12.5px; + line-height: 1.25; +} + +@media (max-width: 900px) { + .tool-group + .tool-group { padding-left: 0; border-left: 0; } +} + +@media (max-width: 1100px) { + .designer-grid { grid-template-columns: 1fr; } + .designer-canvas-wrap { order: 1; } + .designer-elements { order: 2; position: static; } + #props { order: 3; position: static; } + .designer-elements .block-list { max-height: 260px; } +} + +.designer-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); + position: sticky; + top: 72px; +} +.designer-panel h2 { + margin: 0 0 10px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--muted); +} +.designer-panel h2 .count { + display: inline-block; + min-width: 20px; + margin-left: 4px; + padding: 1px 6px; + border-radius: 999px; + background: var(--panel-soft); + color: #334155; + font-size: 11px; + text-align: center; +} +.designer-panel h2:not(:first-child) { margin-top: 18px; } +.designer-panel h3 { + margin: 16px 0 8px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--muted); +} +.designer-panel .field { margin-bottom: 8px; } +.designer-panel .field > label { font-size: 12px; } +.designer-panel .btn { padding: 8px 12px; font-size: 13px; } + +.palette { display: flex; flex-wrap: wrap; gap: 6px; } +.palette-btn { + padding: 8px 12px; + border: 1px solid #c8d3e0; + border-radius: 8px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} +.palette-btn:hover { background: var(--brand-soft); border-color: var(--brand); } + +.designer-elements .block-list { + max-height: calc(100vh - 330px); + overflow-y: auto; + padding-right: 2px; +} + +.block-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } +.block-item { + width: 100%; + text-align: left; + padding: 7px 9px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--panel-soft); + font: inherit; + font-size: 12.5px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.block-item:hover { background: #eef2f6; } +.block-item.active { border-color: var(--brand); background: var(--brand-soft); font-weight: 700; } + +.designer-canvas-wrap { min-width: 0; } +.canvas-scroll { + overflow: auto; + padding: 18px; + background: + linear-gradient(#f1f5f9, #f1f5f9) padding-box, + var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.canvas-stage { display: flex; justify-content: center; } +.design-canvas { + position: relative; + background: #fff; + box-shadow: 0 4px 18px rgba(15, 23, 42, .16); + outline: 1px solid #cbd5e1; + flex: none; + touch-action: none; +} +.canvas-grid { + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgba(15, 23, 42, .07) 1px, transparent 1px), + linear-gradient(to bottom, rgba(15, 23, 42, .07) 1px, transparent 1px); + background-size: var(--grid, 60px) var(--grid, 60px); +} +.canvas-blocks { position: absolute; inset: 0; } + +/* Conturul zonei sigure: 3 mm de la marginea etichetei. Doar vizual. */ +.canvas-safe { + position: absolute; + border: 1px dashed #dc2626; + pointer-events: none; + z-index: 3; +} +.canvas-safe span { + position: absolute; + top: 2px; + left: 3px; + font-size: 8px; + line-height: 1; + letter-spacing: .3px; + text-transform: uppercase; + color: #dc2626; + opacity: .65; + white-space: nowrap; +} + +.safe-hint { margin: 4px 0 0; font-size: 12px; } +.safe-hint.ok { color: #15803d; } +.safe-hint.warn { color: #b91c1c; font-weight: 600; } + +.c-block { + position: absolute; + box-sizing: border-box; + cursor: move; + user-select: none; + outline: 1px solid transparent; +} +.c-block:hover { outline-color: rgba(15, 118, 110, .45); } +.c-block.selected { outline: 2px solid var(--brand); } +.c-block.outside-safe { outline: 1px dashed #dc2626; } +.c-block.outside-safe.selected { outline: 2px solid var(--brand); } +.c-block.outside-safe::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(220, 38, 38, .08); + pointer-events: none; +} +.c-block.c-line { min-height: 0; } +.c-block.c-media { overflow: hidden; } +.c-block.c-media img { width: 100%; height: 100%; object-fit: contain; display: block; } +.c-block.c-empty { background: repeating-linear-gradient(45deg, #f8fafc, #f8fafc 4px, #e2e8f0 4px, #e2e8f0 8px); } +.c-block .d-item { white-space: nowrap; } +.c-block .d-item-w { font-weight: 700; } + +/* Manerul sta in interiorul blocului: blocurile media au overflow:hidden si + * ar taia un maner pozitionat in afara (nu ar mai putea fi apucat). */ +.c-resize { + position: absolute; + right: 0; + bottom: 0; + width: 11px; + height: 11px; + border-radius: 2px; + background: var(--brand); + border: 1px solid #fff; + box-shadow: 0 0 0 1px rgba(15, 23, 42, .15); + cursor: nwse-resize; + display: none; + z-index: 2; +} +.c-block.selected .c-resize { display: block; } + +.props-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.props-row { display: flex; gap: 8px; } +.props-checks { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; } +.props-checks .check { font-size: 12.5px; } +.designer-panel input[type="color"] { width: 100%; height: 34px; padding: 2px; } +.designer-clear { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--line); } + +/* -- popup: alegerea campului variabil ----------------------------------- */ +.picker { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} +.picker-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, .45); } +.picker-card { + position: relative; + width: min(520px, 100%); + max-height: min(640px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 24px 60px rgba(15, 23, 42, .28); +} +.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.picker-head h2 { margin: 0; font-size: 16px; } +.picker-close { + padding: 2px 8px; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 22px; + line-height: 1; + color: var(--muted); + cursor: pointer; +} +.picker-close:hover { background: var(--panel-soft); color: var(--ink); } +.picker-list { + list-style: none; + margin: 0; + padding: 0; + min-height: 120px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 3px; +} +.picker-group { + padding: 10px 4px 3px; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--muted); +} +.picker-item { + width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: var(--panel-soft); + font: inherit; + text-align: left; + cursor: pointer; +} +.picker-item:hover, .picker-item:focus-visible { + border-color: var(--brand); + background: var(--brand-soft); + outline: none; +} +.picker-label { font-size: 13.5px; font-weight: 600; } +.picker-item.recent .picker-label::after { + content: ' · ultimul'; + font-size: 11px; + font-weight: 400; + color: var(--brand); +} +.picker-sample { font-size: 11.5px; color: var(--muted); } +.picker-empty { padding: 18px 6px; text-align: center; font-size: 13px; color: var(--muted); } +.picker-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.picker-foot-actions { display: flex; align-items: center; gap: 12px; } +.picker-item.custom .picker-label::before { + content: '★ '; + color: var(--brand); +} +.picker-item.picked { + border-color: var(--brand); + background: var(--brand-soft); +} +.picker-check { + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + margin-right: 6px; + border: 1px solid #94a3b8; + border-radius: 3px; + background: #fff; + font-size: 10px; + line-height: 1; + color: var(--brand); + vertical-align: middle; +} +.picker-item.picked .picker-check { border-color: var(--brand); } + +/* -- continutul codului QR ------------------------------------------------ */ +.qr-parts { margin-top: 6px; } +.qr-parts-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + font-size: 12px; + font-weight: 600; + color: #334155; +} +.qr-part-list { list-style: none; margin: 0 0 8px; padding: 0; display: grid; gap: 4px; } +.qr-part { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel-soft); +} +.qr-part-num { + flex: none; + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: #e2e8f0; + font-size: 10.5px; + font-weight: 700; + color: #475569; +} +.qr-part-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; } +.qr-part-body strong { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qr-part-sample { font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qr-part input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 5px 7px; font-size: 12px; } +.qr-part-tools { display: flex; gap: 2px; flex: none; } +.qr-part-btn { + width: 22px; + height: 22px; + border: 1px solid var(--line); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 12px; + line-height: 1; + color: #475569; + cursor: pointer; +} +.qr-part-btn:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); } +.qr-part-btn:disabled { opacity: .35; cursor: default; } +.qr-part-btn.danger:hover { border-color: #dc2626; color: #dc2626; } +.qr-part-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.qr-part-actions .btn { padding: 6px 10px; font-size: 12.5px; } +.qr-preview-line { + margin: 0 0 8px; + padding: 5px 7px; + border: 1px dashed var(--line); + border-radius: 6px; + font-size: 12px; + word-break: break-all; + white-space: pre-wrap; +} + +.hidden { display: none !important; } diff --git a/cantarire/static/js/designer.js b/cantarire/static/js/designer.js new file mode 100644 index 0000000..6e4668d --- /dev/null +++ b/cantarire/static/js/designer.js @@ -0,0 +1,1262 @@ +/* Designer vizual de etichete. + * + * Model identic cu `cantarire/designer.py`: blocuri pozitionate in mm. + * Previzualizarea foloseste datele demo trimise de server, deci arata ca la + * tiparire. Serverul revalideaza si randeaza acelasi model. + */ +(function () { + 'use strict'; + + var root = document.getElementById('designer'); + if (!root) return; + + var cfg = window.DESIGNER || {}; + var saveUrl = root.dataset.saveUrl; + var qrUrl = root.dataset.qrUrl; + var fields = cfg.fields || []; + var demoFields = cfg.demoFields || {}; + var demoItems = cfg.demoItems || []; + + var design = cfg.design || { width_mm: 100, height_mm: 60, blocks: [] }; + design.blocks = design.blocks || []; + + // Designuri vechi: blocul QR avea doar `field`; il aratam ca o singura parte. + design.blocks.forEach(function (block) { + if (block.type !== 'qr') return; + if (!Array.isArray(block.parts)) { + block.parts = [{ field: block.field || 'package.qr' }]; + } + if (!block.separator) block.separator = 'pipe'; + }); + + var state = { selected: null, zoom: 6, dirty: false, seq: 1 }; + var dragging = null; + var toastTimer = null; + + // Marja de siguranta a imprimantei termice (mm), afisata ca contur rosu. + var SAFE_MM = Number(cfg.safeMm) || 3; + var SAFE_STORAGE = 'cantarire.designer.safe'; + + // Popup-ul de alegere a campului variabil. + var ITEMS_KEY = '__items__'; + var LAST_FIELD_STORAGE = 'cantarire.designer.lastField'; + var FIELD_GROUPS = { + recipe: 'Produs finit', + package: 'Pachet', + company: 'Firmă', + custom: 'Câmpuri proprii' + }; + + // Continutul codului QR: parti in ordine, separate printr-un text. + var QR_SEPARATORS = { + pipe: ' | ', dash: ' - ', slash: '/', space: ' ', newline: '\n', none: '' + }; + var QR_EXTRA_LABELS = { + 'package.qr': 'Conținut complet (produs + lot + greutate + dată)' + }; + var MAX_QR_PARTS = 12; + + var canvas = document.getElementById('canvas'); + var layer = document.getElementById('canvas-blocks'); + var blockList = document.getElementById('block-list'); + var blockCount = document.getElementById('block-count'); + var propsBody = document.getElementById('props-body'); + var propsEmpty = document.getElementById('props-empty'); + var stateBadge = document.getElementById('designer-state'); + + var elWidth = document.getElementById('label-width'); + var elHeight = document.getElementById('label-height'); + var elZoom = document.getElementById('designer-zoom'); + var elToast = document.getElementById('toast'); + var safeEl = document.getElementById('canvas-safe'); + var safeToggle = document.getElementById('safe-toggle'); + var safeHint = document.getElementById('safe-hint'); + var safeArrange = document.getElementById('safe-arrange'); + var picker = document.getElementById('field-picker'); + var pickerList = document.getElementById('field-picker-list'); + var pickerSearch = document.getElementById('field-picker-search'); + var pickerTitle = document.getElementById('field-picker-title'); + var pickerHint = document.getElementById('field-picker-hint'); + var pickerConfirm = document.getElementById('field-picker-confirm'); + var pickerOpen = false; + var pickerMode = 'single'; + var picked = []; + + // -- utilitare ----------------------------------------------------------- + function toast(text, kind) { + if (!elToast) return; + elToast.textContent = text; + elToast.className = 'toast show ' + (kind || ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { elToast.className = 'toast ' + (kind || ''); }, 3000); + } + + function markDirty() { + state.dirty = true; + if (stateBadge) { + stateBadge.textContent = 'nesalvat'; + stateBadge.className = 'badge warn'; + } + } + + function markClean() { + state.dirty = false; + if (stateBadge) { + stateBadge.textContent = 'salvat'; + stateBadge.className = 'badge ok'; + } + } + + function esc(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); + } + + function clamp(value, low, high) { + return Math.min(Math.max(value, low), high); + } + + function fmt(value) { + var number = Number(value); + return isFinite(number) ? number.toFixed(3) : '0'; + } + + function short(value) { + var text = Number(value); + return (Math.round(text * 100) / 100).toString(); + } + + function nextId() { + var id; + do { + id = 'b' + (state.seq++); + } while (findBlock(id)); + return id; + } + + function findBlock(id) { + return design.blocks.filter(function (block) { return block.id === id; })[0] || null; + } + + function selectedBlock() { + return state.selected ? findBlock(state.selected) : null; + } + + function defaultStyle(type) { + var base = { + size: type === 'items' ? 3.4 : (type === 'field' && false ? 4 : 3.2), + bold: false, italic: false, align: 'left', uppercase: false, color: '#000000' + }; + if (type === 'field') base.size = 4; + if (type === 'items') base.separator = 'stack'; + return base; + } + + // -- randare ------------------------------------------------------------- + function fontCss(style, heightMm) { + var parts = [ + 'font-size:' + px(style.size || 3.2).toFixed(2) + 'px', + 'font-weight:' + (style.bold ? '700' : '400'), + 'font-style:' + (style.italic ? 'italic' : 'normal'), + 'text-align:' + (style.align || 'left'), + 'color:' + (style.color || '#000000'), + 'line-height:1.15', + 'height:' + px(heightMm).toFixed(2) + 'px', + 'overflow:hidden', + 'white-space:pre-wrap', + 'word-break:break-word' + ]; + if (style.uppercase) parts.push('text-transform:uppercase'); + return parts.join(';'); + } + + /* 1 mm din model = `zoom` px pe canvas. NU se foloseste unitatea CSS `mm` + * aici: aceea este fixa (96/25.4 px) si ar rupe potrivirea cu canvas-ul, + * facand blocurile sa para mai mici si imposibil de tras pana la margine. */ + function px(mm) { + return Math.round((Number(mm) || 0) * state.zoom * 100) / 100; + } + + function positionCss(block) { + return 'left:' + px(block.x) + 'px;top:' + px(block.y) + 'px;width:' + + px(block.w) + 'px;height:' + px(block.h) + 'px'; + } + + function isOutsideSafe(block) { + var width = Number(design.width_mm) || 100; + var height = Number(design.height_mm) || 60; + var eps = 0.001; + return block.x < SAFE_MM - eps || block.y < SAFE_MM - eps || + block.x + block.w > width - SAFE_MM + eps || + block.y + block.h > height - SAFE_MM + eps; + } + + function outsideSafeCount() { + return design.blocks.filter(isOutsideSafe).length; + } + + // -- aranjare automata in zona sigura ------------------------------------ + function safeBox() { + var width = Number(design.width_mm) || 100; + var height = Number(design.height_mm) || 60; + var box = { + left: SAFE_MM, + top: SAFE_MM, + right: width - SAFE_MM, + bottom: height - SAFE_MM + }; + // Eticheta e prea mica pentru marja: folosim marginile fizice. + if (box.right - box.left < 2 || box.bottom - box.top < 2) { + box = { left: 0, top: 0, right: width, bottom: height, tight: true }; + } + box.width = box.right - box.left; + box.height = box.bottom - box.top; + return box; + } + + function layoutBounds(blocks) { + var minX = Infinity; + var minY = Infinity; + var maxX = -Infinity; + var maxY = -Infinity; + blocks.forEach(function (block) { + minX = Math.min(minX, block.x); + minY = Math.min(minY, block.y); + maxX = Math.max(maxX, block.x + block.w); + maxY = Math.max(maxY, block.y + block.h); + }); + return { minX: minX, minY: minY, maxX: maxX, maxY: maxY, + w: Math.max(0, maxX - minX), h: Math.max(0, maxY - minY) }; + } + + function round1(value) { + return Math.round((Number(value) || 0) * 10) / 10; + } + + /* Aduce tot aranjamentul in zona sigura, pastrand proportiile. + * + * Se scaleaza ansamblul (pozitii, marimi si fonturi) cat sa incapa in + * contur, apoi se translateaza in coltul zonei sigure. Asa elementele nu se + * suprapun si eticheta arata la fel, doar mai mica. */ + function arrangeInsideSafe() { + if (!design.blocks.length) { + return { moved: 0, scale: 1, remaining: 0, tight: false }; + } + var box = safeBox(); + var bounds = layoutBounds(design.blocks); + + var scale = 1; + if (bounds.w > box.width && bounds.w > 0) scale = Math.min(scale, box.width / bounds.w); + if (bounds.h > box.height && bounds.h > 0) scale = Math.min(scale, box.height / bounds.h); + scale = clamp(scale, 0.1, 1); + + var moved = 0; + design.blocks.forEach(function (block) { + var x = box.left + (block.x - bounds.minX) * scale; + var y = box.top + (block.y - bounds.minY) * scale; + var w = block.w * scale; + var h = Math.max(0.3, block.h * scale); + + // Plasare cu precizie de 0,1 mm, apoi limite dure in zona sigura. + w = clamp(round1(w), 1, Math.max(1, box.width)); + h = clamp(round1(h), 0.3, Math.max(0.3, box.height)); + x = clamp(round1(x), box.left, Math.max(box.left, box.right - w)); + y = clamp(round1(y), box.top, Math.max(box.top, box.bottom - h)); + + if (Math.abs(x - block.x) > 0.001 || Math.abs(y - block.y) > 0.001 || + Math.abs(w - block.w) > 0.001 || Math.abs(h - block.h) > 0.001) { + moved += 1; + } + block.x = x; + block.y = y; + block.w = w; + block.h = h; + + if (scale < 1 && block.style && block.style.size) { + block.style.size = Math.max(1.5, round1(block.style.size * scale)); + } + }); + + return { moved: moved, scale: scale, remaining: outsideSafeCount(), + tight: Boolean(box.tight) }; + } + + function fieldValue(block) { + var value = demoFields[block.field || ''] || ''; + return (block.prefix || '') + value + (block.suffix || ''); + } + + // -- continutul codului QR ---------------------------------------------- + function fieldLabel(key) { + if (QR_EXTRA_LABELS[key]) return QR_EXTRA_LABELS[key]; + var found = fields.filter(function (field) { return field.key === key; })[0]; + return found ? (found.label || key) : key; + } + + function fieldSample(key) { + if (key === 'package.qr') { + return [demoFields['recipe.code'], demoFields['package.lot'], + demoFields['package.weight_unit'], + demoFields['package.completed_at']].filter(Boolean).join(' | '); + } + var value = demoFields[key]; + return value === undefined || value === null ? '' : String(value); + } + + function qrPayload(block) { + var parts = block.parts || []; + if (!parts.length) { + return demoFields[block.field || 'package.qr'] || ''; + } + var separator = QR_SEPARATORS[block.separator || 'pipe']; + if (separator === undefined) separator = ' | '; + return parts.map(function (part) { + return part.text !== undefined && part.text !== null + ? String(part.text) : fieldSample(part.field); + }).filter(function (piece) { return piece; }).join(separator); + } + + function qrPartsHtml(block) { + var parts = block.parts || []; + var rows = parts.map(function (part, index) { + var body = part.text !== undefined && part.text !== null + ? '' + : '' + esc(fieldLabel(part.field)) + '' + + '' + esc(fieldSample(part.field)) + ''; + return '
  • ' + + '' + (index + 1) + '' + body + + '' + + '' + + '' + + '' + + '
  • '; + }).join(''); + + return '
    ' + + '
    Conținut cod QR' + + '' + parts.length + ' / ' + MAX_QR_PARTS + '
    ' + + (rows ? '
      ' + rows + '
    ' + : '

    Niciun câmp; codul conține textul complet implicit.

    ') + + '

    Rezultat: ' + + esc(qrPayload(block) || '(gol)') + '

    ' + + '
    ' + + '' + + '' + + '
    '; + } + + function blockInner(block) { + var style = block.style || {}; + if (block.type === 'text') { + return esc(block.text || ''); + } if (block.type === 'field') { + return esc(fieldValue(block)); + } + if (block.type === 'items') { + var inline = style.separator === 'inline'; + return demoItems.map(function (item) { + var weight = '' + esc(item.weight) + ' ' + esc(item.unit) + ''; + return inline + ? '' + weight + ' ' + esc(item.component_name) + '' + : '
    ' + weight + ' ' + esc(item.component_name) + '
    '; + }).join(inline ? ' · ' : ''); + } + if (block.type === 'qr') { + var data = qrPayload(block) || 'PF-001 | LOT | 450 g'; + return ''; + } + if (block.type === 'image') { + return block.src ? '' : ''; + } + return ''; + } + + function renderBlocks() { + if (!layer) return; + layer.innerHTML = ''; + + design.blocks.forEach(function (block) { + var node = document.createElement('div'); + node.className = 'c-block c-' + block.type + (state.selected === block.id ? ' selected' : ''); + node.dataset.id = block.id; + node.style.cssText = positionCss(block); + + if (block.type === 'line') { + node.style.background = (block.style && block.style.color) || '#000'; + node.style.minHeight = '1px'; + } else if (block.type === 'field' || block.type === 'text' || block.type === 'items') { + node.style.cssText += ';' + fontCss(block.style || {}, block.h); + } else if (block.type === 'qr' || block.type === 'image') { + node.classList.add('c-media'); + } + if (block.type === 'image' && !block.src) node.classList.add('c-empty'); + if (isOutsideSafe(block)) node.classList.add('outside-safe'); + + node.innerHTML = blockInner(block); + + var handle = document.createElement('span'); + handle.className = 'c-resize'; + handle.dataset.handle = 'resize'; + node.appendChild(handle); + + layer.appendChild(node); + }); + + renderBlockList(); + } + + function blockLabel(block) { + if (block.type === 'text') { + return 'Text: ' + (block.text || '').slice(0, 24); + } + if (block.type === 'field') { + var found = fields.filter(function (f) { return f.key === block.field; })[0]; + return 'Câmp: ' + (found ? found.label : block.field); + } + if (block.type === 'items') return 'Defalcare componente'; + if (block.type === 'qr') { + var parts = block.parts || []; + var preview = qrPayload(block).slice(0, 22); + return 'Cod QR (' + parts.length + '): ' + (preview || 'gol'); + } + if (block.type === 'line') return 'Linie'; + return 'Imagine'; + } + + function renderBlockList() { + if (!blockList) return; + blockList.innerHTML = design.blocks.map(function (block) { + return '
  • '; + }).join('') || '
  • Niciun element încă.
  • '; + if (blockCount) blockCount.textContent = String(design.blocks.length); + } + + function applySize() { + if (!canvas) return; + var w = Number(design.width_mm) || 100; + var h = Number(design.height_mm) || 60; + canvas.style.width = (w * state.zoom) + 'px'; + canvas.style.height = (h * state.zoom) + 'px'; + canvas.style.setProperty('--grid', (10 * state.zoom) + 'px'); + if (safeEl) { + var inset = px(SAFE_MM); + safeEl.style.left = inset + 'px'; + safeEl.style.top = inset + 'px'; + safeEl.style.right = inset + 'px'; + safeEl.style.bottom = inset + 'px'; + } + updateSafeHint(); + if (elWidth) elWidth.value = w; + if (elHeight) elHeight.value = h; + } + + function updateSafeHint() { + if (!safeHint) return; + if (!design.blocks.length) { + safeHint.textContent = 'Zona sigură: ' + SAFE_MM + ' mm de la fiecare margine.'; + safeHint.className = 'safe-hint muted'; + if (safeArrange) safeArrange.classList.add('hidden'); + return; + } + var count = outsideSafeCount(); + if (safeArrange) safeArrange.classList.toggle('hidden', count === 0); + if (count === 0) { + safeHint.textContent = 'Toate elementele sunt în zona sigură.'; + safeHint.className = 'safe-hint ok'; + } else { + safeHint.textContent = count + (count === 1 ? ' element iese' : ' elemente ies') + + ' din zona sigură. Folosește „Rearanjează în zona sigură”.'; + safeHint.className = 'safe-hint warn'; + } + } + + // -- proprietati --------------------------------------------------------- + function input(label, name, value, attrs) { + return '
    ' + + '
    '; + } + + function checkbox(label, name, checked) { + return ''; + } + + function select(label, name, value, options) { + return '
    ' + + '
    '; + } + + function renderProps() { + var block = selectedBlock(); + if (!block) { + if (propsEmpty) propsEmpty.classList.remove('hidden'); + if (propsBody) { + propsBody.classList.add('hidden'); + propsBody.innerHTML = ''; + } + return; + } + if (propsEmpty) propsEmpty.classList.add('hidden'); + if (!propsBody) return; + propsBody.classList.remove('hidden'); + + var style = block.style || {}; + var html = ''; + + if (block.type === 'text') { + html += '
    ' + + '
    '; + } + if (block.type === 'field') { + html += select('Câmp', 'field', block.field, fields.map(function (f) { + return { value: f.key, label: f.label }; + })); + html += input('Prefix', 'prefix', block.prefix || '', 'maxlength="40"'); + html += input('Sufix', 'suffix', block.suffix || '', 'maxlength="40"'); + } + if (block.type === 'qr') { + html += select('Separator', 'separator', block.separator || 'pipe', [ + { value: 'pipe', label: 'Bară verticală ( | )' }, + { value: 'dash', label: 'Liniuță ( - )' }, + { value: 'slash', label: 'Slash ( / )' }, + { value: 'space', label: 'Spațiu' }, + { value: 'newline', label: 'Rând nou' }, + { value: 'none', label: 'Fără separator' } + ]); + html += qrPartsHtml(block); + } + if (block.type === 'items') { + html += select('Aranjare', 'separator', style.separator || 'stack', [ + { value: 'stack', label: 'Pe linii' }, + { value: 'inline', label: 'Pe un rând' } + ]); + } + if (block.type === 'image') { + html += input('Adresă imagine', 'src', block.src || '', + 'placeholder="/static/img/logo.png sau https://…"'); + html += '

    Se acceptă /static/…, http(s)://… sau data:image/…

    '; + } + + if (block.type !== 'line' && block.type !== 'qr' && block.type !== 'image') { + html += '
    ' + + input('Mărime (mm)', 'size', style.size || 3.2, 'type="number" step="0.2" min="1.5" max="40"') + + '
    '; + html += '
    ' + + checkbox('Aldin', 'bold', style.bold) + + checkbox('Cursiv', 'italic', style.italic) + + checkbox('Majuscule', 'uppercase', style.uppercase) + + '
    '; + html += select('Aliniere', 'align', style.align || 'left', [ + { value: 'left', label: 'Stânga' }, + { value: 'center', label: 'Centru' }, + { value: 'right', label: 'Dreapta' } + ]); + } + if (block.type === 'line') { + html += '
    ' + + '
    '; + } + + html += '

    Poziție și mărime (mm)

    ' + + input('X', 'x', short(block.x), 'type="number" step="0.5" min="0"') + + input('Y', 'y', short(block.y), 'type="number" step="0.5" min="0"') + + input('Lățime', 'w', short(block.w), 'type="number" step="0.5" min="1"') + + input('Înălțime', 'h', short(block.h), 'type="number" step="0.5" min="0.3"') + + '
    '; + + html += '
    ' + + '' + + '' + + '
    '; + + propsBody.innerHTML = html; + } + + function renderAll() { + applySize(); + renderBlocks(); + renderProps(); + } + + // -- adaugare ------------------------------------------------------------ + function addBlock(type, preset) { + var width = Number(design.width_mm) || 100; + var height = Number(design.height_mm) || 60; + var block = { + id: nextId(), + type: type, + x: 4, + y: Math.min(height - 8, 4 + design.blocks.length * 3), + w: type === 'qr' ? 20 : Math.min(width - 8, 60), + h: type === 'qr' ? 20 : (type === 'line' ? 0.4 : 6), + style: defaultStyle(type) + }; + if (type === 'text') block.text = 'Text nou'; + if (type === 'field') { + block.field = 'recipe.name'; + block.prefix = ''; + block.suffix = ''; + } + if (type === 'items') block.h = Math.min(20, height - block.y); + if (type === 'qr') { + block.field = 'package.qr'; + block.separator = 'pipe'; + block.parts = [{ field: 'package.qr' }]; + if (block.x + block.w > width) block.x = Math.max(0, width - block.w - 2); + } + if (type === 'line') block.w = Math.max(10, width - 8); + + if (preset) { + Object.keys(preset).forEach(function (key) { block[key] = preset[key]; }); + } + + design.blocks.push(block); + state.selected = block.id; + markDirty(); + renderAll(); + } + + // -- popup: alegerea campului variabil ---------------------------------- + function lastField() { + try { + return window.localStorage.getItem(LAST_FIELD_STORAGE) || ''; + } catch (error) { + return ''; + } + } + + function rememberField(key) { + try { + window.localStorage.setItem(LAST_FIELD_STORAGE, key); + } catch (error) { /* localStorage poate fi blocat */ } + } + + function pickerOptions(mode) { + var options = []; + if (mode !== 'multi') { + options.push({ + key: ITEMS_KEY, + label: 'Defalcare componente', + sample: demoItems.map(function (item) { + return item.weight + ' ' + item.unit + ' ' + item.component_name; + }).join(' · '), + group: 'Componente cântărite' + }); + } + fields.forEach(function (field) { + options.push({ + key: field.key, + label: field.label || field.key, + sample: field.sample || '', + group: field.group || FIELD_GROUPS[String(field.key).split('.')[0]] || 'Altele', + custom: Boolean(field.custom) + }); + }); + if (mode === 'multi') { + // „Continut complet” sta in grupul Pachet, langa celelalte campuri de pachet. + var insertAt = 0; + options.forEach(function (option, index) { + if (String(option.key).indexOf('package.') === 0) insertAt = index + 1; + }); + options.splice(insertAt, 0, { + key: 'package.qr', + label: QR_EXTRA_LABELS['package.qr'], + sample: fieldSample('package.qr'), + group: 'Pachet' + }); + } + return options; + } + + function renderPickerList(query) { + if (!pickerList) return; + var needle = String(query || '').trim().toLowerCase(); + var recent = lastField(); + var multi = pickerMode === 'multi'; + var group = null; + var html = ''; + pickerOptions(pickerMode).forEach(function (option) { + if (needle) { + var haystack = (option.label + ' ' + option.key + ' ' + option.sample).toLowerCase(); + if (haystack.indexOf(needle) < 0) return; + } + if (option.group !== group) { + group = option.group; + html += '
  • ' + esc(group) + '
  • '; + } + var isPicked = multi && picked.indexOf(option.key) >= 0; + html += '
  • '; + }); + pickerList.innerHTML = html || '
  • Niciun câmp găsit.
  • '; + } + + function updatePickerFoot() { + if (!pickerConfirm) return; + var multi = pickerMode === 'multi'; + pickerConfirm.classList.toggle('hidden', !multi); + pickerConfirm.disabled = !picked.length; + pickerConfirm.textContent = picked.length + ? 'Adaugă (' + picked.length + ')' : 'Adaugă'; + if (pickerHint) { + pickerHint.textContent = multi + ? 'Bifează câmpurile dorite, apoi Adaugă; ordinea alegerii contează.' + : 'Click pe un câmp pentru a-l adăuga pe etichetă.'; + } + } + + function openFieldPicker(mode) { + if (!picker) { + addBlock('field'); + return; + } + pickerMode = mode === 'multi' ? 'multi' : 'single'; + picked = []; + pickerOpen = true; + if (pickerTitle) { + pickerTitle.textContent = pickerMode === 'multi' + ? 'Adaugă câmpuri în codul QR' : 'Alege câmpul variabil'; + } + if (pickerSearch) pickerSearch.value = ''; + renderPickerList(''); + updatePickerFoot(); + picker.classList.remove('hidden'); + window.setTimeout(function () { + if (pickerSearch) pickerSearch.focus(); + }, 20); + } + + function closeFieldPicker() { + if (!picker) return; + pickerOpen = false; + pickerMode = 'single'; + picked = []; + picker.classList.add('hidden'); + } + + function togglePicked(key) { + var at = picked.indexOf(key); + if (at >= 0) picked.splice(at, 1); + else picked.push(key); + renderPickerList(pickerSearch ? pickerSearch.value : ''); + updatePickerFoot(); + } + + function addPickedParts() { + var block = selectedBlock(); + if (!block || block.type !== 'qr' || !picked.length) { + closeFieldPicker(); + return; + } + var parts = block.parts || []; + picked.forEach(function (key) { + if (parts.length >= MAX_QR_PARTS) return; + parts.push({ field: key }); + }); + block.parts = parts; + if (!block.separator) block.separator = 'pipe'; + markDirty(); + closeFieldPicker(); + refreshQrBlock(block); + renderProps(); + } + + function chooseField(key) { + if (!key) return; + rememberField(key); + closeFieldPicker(); + if (key === ITEMS_KEY) addBlock('items'); + else addBlock('field', { field: key }); + } + + // -- editarea partilor din QR ------------------------------------------ + function refreshQrBlock(block) { + var node = layer && layer.querySelector('.c-block[data-id="' + block.id + '"]'); + if (node) { + node.innerHTML = blockInner(block); + var handle = document.createElement('span'); + handle.className = 'c-resize'; + handle.dataset.handle = 'resize'; + node.appendChild(handle); + } + updateQrPreview(block); + renderBlockList(); + } + + function updateQrPreview(block) { + var line = propsBody && propsBody.querySelector('.qr-preview-line'); + if (!line) return; + line.innerHTML = 'Rezultat: ' + + esc(qrPayload(block) || '(gol)'); + } + + function removeQrPart(index) { + var block = selectedBlock(); + if (!block || block.type !== 'qr') return; + block.parts = (block.parts || []).filter(function (_item, position) { + return position !== index; + }); + markDirty(); + refreshQrBlock(block); + renderProps(); + } + + function moveQrPart(index, direction) { + var block = selectedBlock(); + if (!block || block.type !== 'qr') return; + var parts = block.parts || []; + var target = direction === 'up' ? index - 1 : index + 1; + if (target < 0 || target >= parts.length) return; + var moved = parts.splice(index, 1)[0]; + parts.splice(target, 0, moved); + block.parts = parts; + markDirty(); + refreshQrBlock(block); + renderProps(); + } + + function addQrTextPart() { + var block = selectedBlock(); + if (!block || block.type !== 'qr') return; + var parts = block.parts || []; + if (parts.length >= MAX_QR_PARTS) return; + parts.push({ text: 'text' }); + block.parts = parts; + markDirty(); + refreshQrBlock(block); + renderProps(); + var input = propsBody && propsBody.querySelector('[data-part-text="' + + (parts.length - 1) + '"]'); + if (input) { input.focus(); input.select(); } + } + + function setQrPartText(index, value) { + var block = selectedBlock(); + if (!block || block.type !== 'qr') return; + var part = (block.parts || [])[index]; + if (!part || part.text === undefined) return; + part.text = String(value).slice(0, 60); + markDirty(); + refreshQrBlock(block); + } + + function deleteSelected() { + var block = selectedBlock(); + if (!block) return; + design.blocks = design.blocks.filter(function (item) { return item.id !== block.id; }); + state.selected = null; + markDirty(); + renderAll(); + } + + function duplicateSelected() { + var block = selectedBlock(); + if (!block) return; + var copy = JSON.parse(JSON.stringify(block)); + copy.id = nextId(); + copy.x = clamp(block.x + 2, 0, (Number(design.width_mm) || 100) - block.w); + copy.y = clamp(block.y + 2, 0, (Number(design.height_mm) || 60) - block.h); + design.blocks.push(copy); + state.selected = copy.id; + markDirty(); + renderAll(); + } + + // -- drag & resize ------------------------------------------------------- + function mmFromEvent(event) { + var rect = canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) / state.zoom, + y: (event.clientY - rect.top) / state.zoom + }; + } + + function selectBlock(id) { + state.selected = id; + if (layer) { + layer.querySelectorAll('.c-block').forEach(function (node) { + node.classList.toggle('selected', node.dataset.id === id); + }); + } + renderBlockList(); + renderProps(); + } + + function beginDrag(event, block, mode) { + event.preventDefault(); + event.stopPropagation(); + var point = mmFromEvent(event); + dragging = { + mode: mode, + id: block.id, + startX: point.x, + startY: point.y, + origin: { x: block.x, y: block.y, w: block.w, h: block.h } + }; + selectBlock(block.id); + } + + if (layer) { + layer.addEventListener('pointerdown', function (event) { + var node = event.target.closest('.c-block'); + if (!node) return; + var block = findBlock(node.dataset.id); + if (!block) return; + var isHandle = event.target.dataset && event.target.dataset.handle === 'resize'; + beginDrag(event, block, isHandle ? 'resize' : 'move'); + }); + } + + document.addEventListener('pointermove', function (event) { + if (!dragging) return; + var block = findBlock(dragging.id); + if (!block) return; + var point = mmFromEvent(event); + var dx = Math.round((point.x - dragging.startX) * 2) / 2; + var dy = Math.round((point.y - dragging.startY) * 2) / 2; + var width = Number(design.width_mm) || 100; + var height = Number(design.height_mm) || 60; + + if (dragging.mode === 'move') { + block.x = clamp(Math.round((dragging.origin.x + dx) * 2) / 2, 0, width - block.w); + block.y = clamp(Math.round((dragging.origin.y + dy) * 2) / 2, 0, height - block.h); + } else { + block.w = clamp(Math.round((dragging.origin.w + dx) * 2) / 2, 2, width - block.x); + block.h = clamp(Math.round((dragging.origin.h + dy) * 2) / 2, 0.3, height - block.y); + } + var node = layer.querySelector('.c-block[data-id="' + block.id + '"]'); + if (node) { + node.style.cssText = positionCss(block) + + (block.type === 'line' ? ';min-height:1px' : ';' + fontCss(block.style || {}, block.h)); + if (block.type === 'line') { + node.style.background = (block.style && block.style.color) || '#000'; + } + node.classList.toggle('outside-safe', isOutsideSafe(block)); + } + updateSafeHint(); + markDirty(); + syncPositionInputs(block); + }); + + function syncPositionInputs(block) { + ['x', 'y', 'w', 'h'].forEach(function (prop) { + var input = propsBody && propsBody.querySelector('[data-prop="' + prop + '"]'); + if (input && document.activeElement !== input) input.value = short(block[prop]); + }); + } + + document.addEventListener('pointercancel', function () { + dragging = null; + }); + + document.addEventListener('pointerup', function () { + if (!dragging) return; + dragging = null; + markDirty(); + renderProps(); + renderBlockList(); + }); + + // -- evenimente UI ------------------------------------------------------- + document.querySelectorAll('[data-add]').forEach(function (button) { + button.addEventListener('click', function () { addBlock(button.dataset.add); }); + }); + + document.querySelectorAll('[data-add-picker]').forEach(function (button) { + button.addEventListener('click', function () { openFieldPicker('single'); }); + }); + + if (picker) { + picker.addEventListener('click', function (event) { + if (event.target.closest('[data-picker-close]')) { + closeFieldPicker(); + return; + } + var item = event.target.closest('[data-pick]'); + if (!item) return; + if (pickerMode === 'multi') togglePicked(item.dataset.pick); + else chooseField(item.dataset.pick); + }); + } + + if (pickerConfirm) { + pickerConfirm.addEventListener('click', addPickedParts); + } + + if (pickerSearch) { + pickerSearch.addEventListener('input', function () { + renderPickerList(pickerSearch.value); + }); + pickerSearch.addEventListener('keydown', function (event) { + if (event.key !== 'Enter') return; + event.preventDefault(); + if (pickerMode === 'multi') { + addPickedParts(); + return; + } + var first = pickerList && pickerList.querySelector('[data-pick]'); + if (first) chooseField(first.dataset.pick); + }); + } + + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && pickerOpen) closeFieldPicker(); + }); + + if (blockList) { + blockList.addEventListener('click', function (event) { + var button = event.target.closest('[data-select]'); + if (!button) return; + selectBlock(button.dataset.select); + }); + } + + if (propsBody) { + propsBody.addEventListener('input', function (event) { + var block = selectedBlock(); + if (!block) return; + var dataset = event.target.dataset || {}; + if (dataset.partText !== undefined) { + setQrPartText(Number(dataset.partText), event.target.value); + return; + } + var prop = dataset.prop; + if (!prop) return; + var value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + applyProp(block, prop, value); + }); + propsBody.addEventListener('click', function (event) { + var dataset = event.target.dataset || {}; + if (dataset.partRemove !== undefined) { + removeQrPart(Number(dataset.partRemove)); + return; + } + if (dataset.partMove) { + moveQrPart(Number(dataset.index), dataset.partMove); + return; + } + if (event.target.closest('[data-qr-add]')) { + openFieldPicker('multi'); + return; + } + if (event.target.closest('[data-qr-add-text]')) { + addQrTextPart(); + return; + } + var action = dataset.action; + if (action === 'delete') deleteSelected(); + if (action === 'duplicate') duplicateSelected(); + }); + } + + function applyProp(block, prop, value) { + var numeric = ['x', 'y', 'w', 'h', 'size']; + if (numeric.indexOf(prop) >= 0) { + var number = Number(value); + if (!isFinite(number)) return; + if (prop === 'size') block.style.size = clamp(number, 1.5, 40); + else if (prop === 'x') block.x = clamp(number, 0, (Number(design.width_mm) || 100) - block.w); + else if (prop === 'y') block.y = clamp(number, 0, (Number(design.height_mm) || 60) - block.h); + else if (prop === 'w') block.w = clamp(number, 2, (Number(design.width_mm) || 100) - block.x); + else if (prop === 'h') block.h = clamp(number, 0.3, (Number(design.height_mm) || 60) - block.y); + } else if (['bold', 'italic', 'uppercase'].indexOf(prop) >= 0) { + block.style[prop] = Boolean(value); + } else if (prop === 'separator' && block.type === 'qr') { + block.separator = value; + } else if (['align', 'color', 'separator'].indexOf(prop) >= 0) { + if (!block.style) block.style = {}; + block.style[prop] = value; + } else { + block[prop] = value; + } + markDirty(); + + var node = layer && layer.querySelector('.c-block[data-id="' + block.id + '"]'); + if (node) { + node.style.cssText = positionCss(block) + (block.type === 'line' ? ';min-height:1px' : ';' + + (block.type === 'field' || block.type === 'text' || block.type === 'items' + ? fontCss(block.style || {}, block.h) : '')); + if (block.type === 'line') node.style.background = block.style.color || '#000'; + node.innerHTML = blockInner(block); + var handle = document.createElement('span'); + handle.className = 'c-resize'; + handle.dataset.handle = 'resize'; + node.appendChild(handle); + node.classList.toggle('outside-safe', isOutsideSafe(block)); + } + if (block.type === 'qr') updateQrPreview(block); + updateSafeHint(); + renderBlockList(); + } + + if (elWidth) { + elWidth.addEventListener('change', function () { + design.width_mm = clamp(Number(elWidth.value) || 100, 10, 500); + // Nu mutam elementele aici: cele care ies din zona sigura sunt marcate + // si pot fi aduse inapoi cu „Rearanjează în zona sigură”. + markDirty(); + renderAll(); + }); + } + if (elHeight) { + elHeight.addEventListener('change', function () { + design.height_mm = clamp(Number(elHeight.value) || 60, 10, 500); + markDirty(); + renderAll(); + }); + } + if (elZoom) { + elZoom.addEventListener('input', function () { + state.zoom = Number(elZoom.value) || 6; + applySize(); + renderBlocks(); + }); + } + + if (safeToggle) { + var showSafe = true; + try { + showSafe = window.localStorage.getItem(SAFE_STORAGE) !== '0'; + } catch (error) { /* localStorage poate fi blocat */ } + safeToggle.checked = showSafe; + if (safeEl) safeEl.classList.toggle('hidden', !showSafe); + safeToggle.addEventListener('change', function () { + if (safeEl) safeEl.classList.toggle('hidden', !safeToggle.checked); + try { + window.localStorage.setItem(SAFE_STORAGE, safeToggle.checked ? '1' : '0'); + } catch (error) { /* ignorat */ } + }); + } + + if (safeArrange) { + safeArrange.addEventListener('click', function () { + var result = arrangeInsideSafe(); + markDirty(); + renderBlocks(); + renderProps(); + updateSafeHint(); + if (result.remaining) { + toast(result.remaining + ' elemente nu au încăput în zona sigură. ' + + 'Micșorează-le sau mută-le manual.', 'warn'); + } else if (result.moved) { + var percent = Math.round(result.scale * 100); + var suffix = result.scale < 1 ? ' (' + percent + '% din mărime)' : ''; + toast((result.moved === 1 ? '1 element adus' : result.moved + ' elemente aduse') + + ' în zona sigură' + suffix + '.', 'success'); + } else { + toast('Elementele erau deja în zona sigură.', ''); + } + }); + } + + document.addEventListener('keydown', function (event) { + var tag = (event.target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + var block = selectedBlock(); + if (!block) return; + + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + deleteSelected(); + return; + } + var step = event.shiftKey ? 2 : 0.5; + var moved = true; + if (event.key === 'ArrowLeft') block.x = clamp(block.x - step, 0, design.width_mm - block.w); + else if (event.key === 'ArrowRight') block.x = clamp(block.x + step, 0, design.width_mm - block.w); + else if (event.key === 'ArrowUp') block.y = clamp(block.y - step, 0, design.height_mm - block.h); + else if (event.key === 'ArrowDown') block.y = clamp(block.y + step, 0, design.height_mm - block.h); + else moved = false; + + if (moved) { + event.preventDefault(); + markDirty(); + renderBlocks(); + renderProps(); + } + }); + + // -- salvare ------------------------------------------------------------- + var saveButton = document.getElementById('designer-save'); + if (saveButton) { + saveButton.addEventListener('click', function () { + saveButton.disabled = true; + saveButton.textContent = 'Se salvează…'; + fetch(saveUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + design: { + version: design.version || 1, + width_mm: design.width_mm, + height_mm: design.height_mm, + blocks: design.blocks + } + }) + }) + .then(function (response) { + return response.json().then(function (data) { + return { status: response.status, data: data }; + }); + }) + .then(function (result) { + if (!result.data.ok) { + toast(result.data.error || 'Salvarea a eșuat.', 'error'); + return; + } + markClean(); + var outside = outsideSafeCount(); + if (outside) { + toast('Design salvat, dar ' + outside + + (outside === 1 ? ' element este' : ' elemente sunt') + + ' în afara zonei sigure.', 'warn'); + } else { + toast('Design salvat (' + result.data.blocks + ' elemente).', 'success'); + } + }) + .catch(function () { toast('Eroare de rețea la salvare.', 'error'); }) + .finally(function () { + saveButton.disabled = false; + saveButton.textContent = 'Salvează'; + }); + }); + } + + window.addEventListener('beforeunload', function (event) { + if (!state.dirty) return; + event.preventDefault(); + event.returnValue = ''; + }); + + // -- start --------------------------------------------------------------- + if (elZoom) elZoom.value = String(state.zoom); + markClean(); + renderAll(); +})(); diff --git a/cantarire/static/js/qz-print.js b/cantarire/static/js/qz-print.js new file mode 100644 index 0000000..e5a9db3 --- /dev/null +++ b/cantarire/static/js/qz-print.js @@ -0,0 +1,489 @@ +/** + * QZ Tray printing for Cantarire & Etichetare. + * + * Mirrors the working integration from `quality_app-v2`: + * - the patched `qz-tray.js` (2.2.4) that skips the certificate exchange and + * talks to a custom QZ Tray service using pairing-key auth; + * - `qz.printers.find()` + auto-selection of the thermal/label printer; + * - `qz.configs.create(printer, { scaleContent:false, rasterize:.., size, units:'mm' })`; + * - `qz.print(config, [{ type:'pdf'|'html', format:'base64'|'plain', data }])`. + * + * Public API (kept simple so the rest of the app does not care about QZ): + * window.CantarirePrint.print(payload) -> Promise<{ok, method, printer?}> + * window.CantarireQZ -> status / connect / printers / test + * + * `payload` is produced by `cantarire/labels.py::print_payload`: + * { job_id, weighing_id, template:{width_mm,height_mm}, html, css, + * fields, print_url, ack_url, pdf_url? } + * + * If `pdf_url` is present the label is fetched as PDF and sent with + * `type:'pdf'` (the exact flow used by quality_app-v2). Otherwise the HTML + * label is rasterized by QZ Tray at the physical size from the template. + */ +(function () { + 'use strict'; + + var STORAGE_KEY = 'cantarire.qz.printer'; + + var config = Object.assign({ + autoConnect: true, + fallbackToBrowser: true, + connectTimeoutMs: 6000, + storageKey: STORAGE_KEY, + thermalHints: ['thermal', 'label', 'zebra', 'godex', 'argox', 'tsc', 'citizen', + 'xprinter', 'bixolon', 'epson', 'datamax', 'sato'], + // Optional: standard QZ Tray (with certificate + signature) instead of the + // patched pairing-key service. Set via window.CANTARIRE_QZ before this file. + certificateUrl: '', + signatureUrl: '' + }, window.CANTARIRE_QZ || {}); + + var state = { + status: 'idle', // idle | connecting | ready | offline | missing + connected: false, + printers: [], + printer: '', + version: '', + error: null, + lastFailureAt: 0 + }; + + var connectPromise = null; + var lastRendered = ''; + + // -- helpers ------------------------------------------------------------- + function lib() { return window.qz; } + + function snapshot() { + return { + status: state.status, + connected: state.connected, + printers: state.printers.slice(), + printer: state.printer, + version: state.version, + error: state.error + }; + } + + function messageOf(error) { + if (!error) return 'Eroare necunoscuta'; + return error.message || String(error); + } + + function setState(patch) { + Object.assign(state, patch); + emit(); + } + + function emit() { + var detail = snapshot(); + try { + document.dispatchEvent(new CustomEvent('cantarire:qz', { detail: detail })); + } catch (error) { /* browsere foarte vechi */ } + syncUi(); + } + + function savedPrinter() { + try { return localStorage.getItem(config.storageKey) || ''; } catch (error) { return ''; } + } + + function savePrinter(name) { + try { + if (name) localStorage.setItem(config.storageKey, name); + else localStorage.removeItem(config.storageKey); + } catch (error) { /* mod privat */ } + } + + function choosePrinter(printers) { + var saved = savedPrinter(); + if (saved && printers.indexOf(saved) >= 0) return saved; + for (var i = 0; i < printers.length; i++) { + var name = printers[i].toLowerCase(); + for (var j = 0; j < config.thermalHints.length; j++) { + if (name.indexOf(config.thermalHints[j]) >= 0) return printers[i]; + } + } + return printers.length ? printers[0] : ''; + } + + function setupSecurity(qz) { + if (config.certificateUrl && qz.security && qz.security.setCertificatePromise) { + qz.security.setCertificatePromise(function (resolve, reject) { + fetch(config.certificateUrl, { cache: 'no-store' }).then(function (response) { + if (!response.ok) throw new Error('certificat: HTTP ' + response.status); + return response.text(); + }).then(resolve, reject); + }, { rejectOnFailure: true }); + } + if (config.signatureUrl && qz.security && qz.security.setSignaturePromise) { + qz.security.setSignaturePromise(function (toSign) { + return function (resolve, reject) { + fetch(config.signatureUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request: toSign }) + }).then(function (response) { + if (!response.ok) throw new Error('semnatura: HTTP ' + response.status); + return response.text(); + }).then(resolve, reject); + }; + }); + } + } + + // -- connection ---------------------------------------------------------- + function connect() { + if (state.connected) return Promise.resolve(true); + if (connectPromise) return connectPromise; + + var qz = lib(); + if (!qz) { + setState({ + status: 'missing', + connected: false, + error: 'Biblioteca qz-tray.js nu este incarcata.', + version: '' + }); + return Promise.resolve(false); + } + + setState({ status: 'connecting', error: null }); + + // The QZ client sweeps several hosts/ports before giving up; reflect a + // failure in the UI early. A connection that succeeds later still wins. + var timeout = Number(config.connectTimeoutMs) || 6000; + var watchdog = setTimeout(function () { + if (!state.connected && state.status === 'connecting') { + setState({ + status: 'offline', + error: 'QZ Tray nu a raspuns in ' + timeout + ' ms (agentul este pornit?).' + }); + } + }, timeout); + + connectPromise = Promise.resolve() + .then(function () { + try { setupSecurity(qz); } catch (error) { console.warn('[QZ] security:', error); } + return qz.websocket.isActive() ? null : qz.websocket.connect(); + }) + .then(function () { return qz.api.getVersion(); }) + .then(function (version) { + setState({ status: 'ready', connected: true, version: version, error: null }); + return loadPrinters(); + }) + .then(function () { return true; }) + .catch(function (error) { + setState({ + status: 'offline', + connected: false, + version: '', + error: messageOf(error), + lastFailureAt: Date.now() + }); + return false; + }) + .finally(function () { + clearTimeout(watchdog); + connectPromise = null; + }); + + return connectPromise; + } + + function disconnect() { + var qz = lib(); + var done = qz && qz.websocket.isActive() ? qz.websocket.disconnect() : Promise.resolve(); + return Promise.resolve(done).catch(function () { /* ignoram */ }).then(function () { + setState({ status: 'idle', connected: false, version: '', printers: [] }); + return false; + }); + } + + function loadPrinters() { + var qz = lib(); + if (!qz || !state.connected) return Promise.resolve([]); + + return qz.printers.find().then(function (found) { + var list = Array.isArray(found) ? found.map(String) : (found ? [String(found)] : []); + var chosen = choosePrinter(list); + state.printers = list; + state.printer = chosen; + if (chosen) savePrinter(chosen); + emit(); + return list; + }).catch(function (error) { + setState({ error: messageOf(error) }); + return []; + }); + } + + function setPrinter(name) { + state.printer = name || ''; + savePrinter(state.printer); + emit(); + } + + // -- payload ------------------------------------------------------------- + function dimensions(payload) { + var template = payload.template || {}; + var width = Number(template.width_mm); + var height = Number(template.height_mm); + return { + width: isFinite(width) && width > 0 ? width : 100, + height: isFinite(height) && height > 0 ? height : 60 + }; + } + + function labelDocument(payload) { + var size = dimensions(payload); + return '
    ' + + (payload.html || '') + + '
    '; + } + + function fetchBase64(url) { + return fetch(url, { cache: 'no-store' }).then(function (response) { + if (!response.ok) throw new Error('PDF: HTTP ' + response.status); + return response.blob(); + }).then(function (blob) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onloadend = function () { + resolve(String(reader.result).split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }); + } + + function printPdf(qz, payload) { + var size = dimensions(payload); + var printerConfig = qz.configs.create(state.printer || '', { + scaleContent: false, + rasterize: false, + size: { width: size.width, height: size.height }, + units: 'mm', + margins: { top: 0, right: 0, bottom: 0, left: 0 } + }); + return fetchBase64(payload.pdf_url).then(function (base64) { + return qz.print(printerConfig, [{ type: 'pdf', format: 'base64', data: base64 }]); + }); + } + + function printHtml(qz, payload) { + var size = dimensions(payload); + var printerConfig = qz.configs.create(state.printer || '', { + scaleContent: false, + rasterize: true, + size: { width: size.width, height: size.height }, + units: 'mm', + margins: { top: 0, right: 0, bottom: 0, left: 0 }, + interpolation: 'optimizeQuality' + }); + return qz.print(printerConfig, [{ + type: 'html', + format: 'plain', + data: labelDocument(payload) + }]); + } + + function browserPrint(payload) { + // On the label page itself, the browser dialog is exactly what we want. + if (document.getElementById('label-sheet')) { + window.print(); + return { ok: true, method: 'browser' }; + } + + // Anywhere else (dashboard), print the label page, not the current page. + var url = payload && payload.print_url; + if (url) { + var frame = document.getElementById('print-frame'); + if (frame) { + frame.src = url + '?auto=1'; + return { ok: true, method: 'browser', framed: true }; + } + window.open(url + '?auto=1', '_blank', 'noopener'); + return { ok: true, method: 'browser' }; + } + + window.print(); + return { ok: true, method: 'browser' }; + } + + /** + * Do not hold the operator for the full host/port sweep of the QZ client: + * wait at most `connectTimeoutMs`, then let the caller fall back. + * A connection that succeeds later is still used by the next print. + */ + function ensureConnected() { + if (state.connected) return Promise.resolve(true); + + // QZ was not running a moment ago: do not make every label wait again. + if (state.status === 'offline' && state.lastFailureAt && + Date.now() - state.lastFailureAt < 30000) { + return Promise.resolve(false); + } + + var timeout = Number(config.connectTimeoutMs) || 6000; + return Promise.race([ + connect(), + new Promise(function (resolve) { setTimeout(function () { resolve(false); }, timeout); }) + ]); + } + + // -- public print -------------------------------------------------------- + function print(payload) { + payload = payload || {}; + + return ensureConnected().then(function (connected) { + if (!connected) { + if (!config.fallbackToBrowser) { + throw new Error(state.error || 'QZ Tray nu este disponibil.'); + } + console.warn('[QZ] fallback la tiparirea din browser:', state.error); + return browserPrint(payload); + } + + var qz = lib(); + var job = payload.pdf_url + ? printPdf(qz, payload).catch(function (error) { + // PDF-ul poate lipsi (endpoint nedisponibil): incercam HTML-ul. + console.warn('[QZ] PDF indisponibil, folosesc eticheta HTML:', error); + return printHtml(qz, payload); + }) + : printHtml(qz, payload); + + return job.then(function () { + if (state.error) setState({ error: null }); + return { ok: true, method: 'qz', printer: state.printer }; + }).catch(function (error) { + setState({ error: messageOf(error), lastFailureAt: Date.now() }); + if (!config.fallbackToBrowser) throw error; + console.warn('[QZ] tiparirea a esuat, folosesc browserul:', error); + return browserPrint(payload); + }); + }); + } + + function printTest() { + var stamp = new Date().toLocaleString('ro-RO'); + var payload = { + template: { width_mm: 60, height_mm: 40 }, + css: '.t{font-family:Arial,sans-serif;text-align:center;padding:4mm;color:#000}' + + '.t h1{font-size:5mm;margin:0 0 2mm}.t p{font-size:3.5mm;margin:1mm 0}', + html: '

    TEST IMPRIMANTA

    ' + + '

    ' + (state.printer || 'imprimanta implicita') + '

    ' + + '

    ' + stamp + '

    ' + + '

    Cantarire & Etichetare

    ' + }; + return print(payload); + } + + // -- UI binding ---------------------------------------------------------- + function statusText() { + if (state.status === 'ready') return 'QZ Tray: pregatit'; + if (state.status === 'connecting') return 'QZ Tray: conectare…'; + if (state.status === 'missing') return 'QZ Tray: biblioteca lipsa'; + if (state.status === 'offline') return 'QZ Tray: deconectat'; + return 'QZ Tray: neinitializat'; + } + + function renderPrinterSelect(select) { + var signature = state.printers.join('|') + '#' + state.printer; + if (select.getAttribute('data-qz-rendered') === signature) return; + select.setAttribute('data-qz-rendered', signature); + + var current = state.printer || ''; + select.innerHTML = ''; + var auto = document.createElement('option'); + auto.value = ''; + auto.textContent = state.printers.length ? 'Implicit (sistem)' : 'Nicio imprimanta detectata'; + select.appendChild(auto); + + state.printers.forEach(function (name) { + var option = document.createElement('option'); + option.value = name; + option.textContent = name; + if (name === current) option.selected = true; + select.appendChild(option); + }); + select.value = current; + select.disabled = state.printers.length === 0; + } + + function syncUi() { + if (typeof document === 'undefined') return; + + document.querySelectorAll('[data-qz-status]').forEach(function (node) { + node.textContent = statusText(); + var pill = node.closest('[data-qz-pill]'); + if (pill) pill.setAttribute('data-qz-state', state.status); + if (state.error) node.title = state.error; + else node.removeAttribute('title'); + }); + + document.querySelectorAll('[data-qz-printer]').forEach(renderPrinterSelect); + + document.querySelectorAll('[data-qz-version]').forEach(function (node) { + node.textContent = state.version ? 'v' + state.version : ''; + }); + + document.querySelectorAll('[data-qz-connect]').forEach(function (node) { + node.disabled = state.connected || state.status === 'connecting'; + }); + } + + function bindUi() { + document.addEventListener('change', function (event) { + var select = event.target.closest ? event.target.closest('[data-qz-printer]') : null; + if (select) setPrinter(select.value); + }); + + document.addEventListener('click', function (event) { + var target = event.target.closest ? event.target.closest('[data-qz-connect],[data-qz-test],[data-qz-refresh]') : null; + if (!target) return; + if (target.hasAttribute('data-qz-connect')) { + event.preventDefault(); + connect(); + } else if (target.hasAttribute('data-qz-test')) { + event.preventDefault(); + printTest().catch(function (error) { alert('Test esuat: ' + messageOf(error)); }); + } else if (target.hasAttribute('data-qz-refresh')) { + event.preventDefault(); + if (!state.connected) connect(); + else loadPrinters(); + } + }); + } + + // -- boot ---------------------------------------------------------------- + window.CantarireQZ = { + getState: snapshot, + connect: connect, + disconnect: disconnect, + refreshPrinters: loadPrinters, + setPrinter: setPrinter, + printTest: printTest + }; + window.CantarirePrint = { print: print }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + bindUi(); + syncUi(); + if (config.autoConnect) connect(); + }); + } else { + bindUi(); + syncUi(); + if (config.autoConnect) connect(); + } +})(); diff --git a/cantarire/static/js/vendor/README.md b/cantarire/static/js/vendor/README.md new file mode 100644 index 0000000..1646191 --- /dev/null +++ b/cantarire/static/js/vendor/README.md @@ -0,0 +1,16 @@ +# QZ Tray connector (`qz-tray.js`) + +- **Version:** 2.2.4 +- **License:** LGPL-2.1-only (see the header of `qz-tray.js`) +- **Source:** copied unmodified from + `quality_app-v2/app/static/js/qz-tray.js` (Gitea: `ske087/quality_app-v2`). +- **Local patch:** it **skips the certificate exchange** and connects to the + custom QZ Tray service that authenticates with a **pairing key** + (`PATCHED` markers around the original `_qz.websocket.connection.sendData({ certificate … })`). + +Because the file is a modified LGPL-2.1 library, keep it under +`static/js/vendor/` and ship this notice plus the license header with it. +If you later use stock QZ Tray (certificate + signature), set +`window.CANTARIRE_QZ = { certificateUrl, signatureUrl }` before loading +`qz-print.js`; `qz-print.js` wires `qz.security.setCertificatePromise` and +`setSignaturePromise` when those URLs are provided. diff --git a/cantarire/static/js/vendor/qz-tray.js b/cantarire/static/js/vendor/qz-tray.js new file mode 100755 index 0000000..71146c8 --- /dev/null +++ b/cantarire/static/js/vendor/qz-tray.js @@ -0,0 +1,2871 @@ +'use strict'; + +/** + * @version 2.2.4 + * @overview QZ Tray Connector + * @license LGPL-2.1-only + *

    + * Connects a web client to the QZ Tray software. + * Enables printing and device communication from javascript. + */ +var qz = (function() { + +///// POLYFILLS ///// + + if (!Array.isArray) { + Array.isArray = function(arg) { + return Object.prototype.toString.call(arg) === '[object Array]'; + }; + } + + if (!Number.isInteger) { + Number.isInteger = function(value) { + return typeof value === 'number' && isFinite(value) && Math.floor(value) === value; + }; + } + +///// PRIVATE METHODS ///// + + var _qz = { + VERSION: "2.2.4", //must match @version above + DEBUG: false, + + log: { + /** Debugging messages */ + trace: function() { if (_qz.DEBUG) { console.log.apply(console, arguments); } }, + /** General messages */ + info: function() { console.info.apply(console, arguments); }, + /** General warnings */ + warn: function() { console.warn.apply(console, arguments); }, + /** Debugging errors */ + allay: function() { if (_qz.DEBUG) { console.warn.apply(console, arguments); } }, + /** General errors */ + error: function() { console.error.apply(console, arguments); } + }, + + + //stream types + streams: { + serial: 'SERIAL', usb: 'USB', hid: 'HID', printer: 'PRINTER', file: 'FILE', socket: 'SOCKET' + }, + + + websocket: { + /** The actual websocket object managing the connection. */ + connection: null, + /** Track if a connection attempt is being cancelled. */ + shutdown: false, + + /** Default parameters used on new connections. Override values using options parameter on {@link qz.websocket.connect}. */ + connectConfig: { + host: ["localhost", "localhost.qz.io"], //hosts QZ Tray can be running on + hostIndex: 0, //internal var - index on host array + usingSecure: true, //boolean use of secure protocol + protocol: { + secure: "wss://", //secure websocket + insecure: "ws://" //insecure websocket + }, + port: { + secure: [8181, 8282, 8383, 8484], //list of secure ports QZ Tray could be listening on + insecure: [8182, 8283, 8384, 8485], //list of insecure ports QZ Tray could be listening on + portIndex: 0 //internal var - index on active port array + }, + keepAlive: 60, //time between pings to keep connection alive, in seconds + retries: 0, //number of times to reconnect before failing + delay: 0 //seconds before firing a connection + }, + + setup: { + /** Loop through possible ports to open connection, sets web socket calls that will settle the promise. */ + findConnection: function(config, resolve, reject) { + if (_qz.websocket.shutdown) { + reject(new Error("Connection attempt cancelled by user")); + return; + } + + //force flag if missing ports + if (!config.port.secure.length) { + if (!config.port.insecure.length) { + reject(new Error("No ports have been specified to connect over")); + return; + } else if (config.usingSecure) { + _qz.log.error("No secure ports specified - forcing insecure connection"); + config.usingSecure = false; + } + } else if (!config.port.insecure.length && !config.usingSecure) { + _qz.log.trace("No insecure ports specified - forcing secure connection"); + config.usingSecure = true; + } + + var deeper = function() { + if (_qz.websocket.shutdown) { + //connection attempt was cancelled, bail out + reject(new Error("Connection attempt cancelled by user")); + return; + } + + config.port.portIndex++; + + if ((config.usingSecure && config.port.portIndex >= config.port.secure.length) + || (!config.usingSecure && config.port.portIndex >= config.port.insecure.length)) { + if (config.hostIndex >= config.host.length - 1) { + //give up, all hope is lost + reject(new Error("Unable to establish connection with QZ")); + return; + } else { + config.hostIndex++; + config.port.portIndex = 0; + } + } + + // recursive call until connection established or all ports are exhausted + _qz.websocket.setup.findConnection(config, resolve, reject); + }; + + var address; + if (config.usingSecure) { + address = config.protocol.secure + config.host[config.hostIndex] + ":" + config.port.secure[config.port.portIndex]; + } else { + address = config.protocol.insecure + config.host[config.hostIndex] + ":" + config.port.insecure[config.port.portIndex]; + } + + try { + _qz.log.trace("Attempting connection", address); + _qz.websocket.connection = new _qz.tools.ws(address); + } + catch(err) { + _qz.log.error(err); + deeper(); + return; + } + + if (_qz.websocket.connection != null) { + _qz.websocket.connection.established = false; + + //called on successful connection to qz, begins setup of websocket calls and resolves connect promise after certificate is sent + _qz.websocket.connection.onopen = function(evt) { + if (!_qz.websocket.connection.established) { + _qz.log.trace(evt); + _qz.log.info("Established connection with QZ Tray on " + address); + + _qz.websocket.setup.openConnection({ resolve: resolve, reject: reject }); + + if (config.keepAlive > 0) { + var interval = setInterval(function() { + if (!_qz.tools.isActive() || _qz.websocket.connection.interval !== interval) { + clearInterval(interval); + return; + } + + _qz.websocket.connection.send("ping"); + }, config.keepAlive * 1000); + + _qz.websocket.connection.interval = interval; + } + } + }; + + //called during websocket close during setup + _qz.websocket.connection.onclose = function() { + // Safari compatibility fix to raise error event + if (_qz.websocket.connection && typeof navigator !== 'undefined' && navigator.userAgent.indexOf('Safari') != -1 && navigator.userAgent.indexOf('Chrome') == -1) { + _qz.websocket.connection.onerror(); + } + }; + + //called for errors during setup (such as invalid ports), reject connect promise only if all ports have been tried + _qz.websocket.connection.onerror = function(evt) { + _qz.log.trace(evt); + + _qz.websocket.connection = null; + + deeper(); + }; + } else { + reject(new Error("Unable to create a websocket connection")); + } + }, + + /** Finish setting calls on successful connection, sets web socket calls that won't settle the promise. */ + openConnection: function(openPromise) { + _qz.websocket.connection.established = true; + + //called when an open connection is closed + _qz.websocket.connection.onclose = function(evt) { + _qz.log.trace(evt); + + _qz.websocket.connection = null; + _qz.websocket.callClose(evt); + _qz.log.info("Closed connection with QZ Tray"); + + for(var uid in _qz.websocket.pendingCalls) { + if (_qz.websocket.pendingCalls.hasOwnProperty(uid)) { + _qz.websocket.pendingCalls[uid].reject(new Error("Connection closed before response received")); + } + } + + //if this is set, then an explicit close call was made + if (this.promise != undefined) { + this.promise.resolve(); + } + }; + + //called for any errors with an open connection + _qz.websocket.connection.onerror = function(evt) { + _qz.websocket.callError(evt); + }; + + //send JSON objects to qz + _qz.websocket.connection.sendData = function(obj) { + _qz.log.trace("Preparing object for websocket", obj); + + if (obj.timestamp == undefined) { + obj.timestamp = Date.now(); + if (typeof obj.timestamp !== 'number') { + obj.timestamp = new Date().getTime(); + } + } + if (obj.promise != undefined) { + obj.uid = _qz.websocket.setup.newUID(); + _qz.websocket.pendingCalls[obj.uid] = obj.promise; + } + + // track requesting monitor + obj.position = { + x: typeof screen !== 'undefined' ? ((screen.availWidth || screen.width) / 2) + (screen.left || screen.availLeft || 0) : 0, + y: typeof screen !== 'undefined' ? ((screen.availHeight || screen.height) / 2) + (screen.top || screen.availTop || 0) : 0 + }; + + try { + if (obj.call != undefined && obj.signature == undefined && _qz.security.needsSigned(obj.call)) { + var signObj = { + call: obj.call, + params: obj.params, + timestamp: obj.timestamp + }; + + //make a hashing promise if not already one + var hashing = _qz.tools.hash(_qz.tools.stringify(signObj)); + if (!hashing.then) { + hashing = _qz.tools.promise(function(resolve) { + resolve(hashing); + }); + } + + hashing.then(function(hashed) { + return _qz.security.callSign(hashed); + }).then(function(signature) { + _qz.log.trace("Signature for call", signature); + obj.signature = signature || ""; + obj.signAlgorithm = _qz.security.signAlgorithm; + + _qz.signContent = undefined; + _qz.websocket.connection.send(_qz.tools.stringify(obj)); + }).catch(function(err) { + _qz.log.error("Signing failed", err); + + if (obj.promise != undefined) { + obj.promise.reject(new Error("Failed to sign request")); + delete _qz.websocket.pendingCalls[obj.uid]; + } + }); + } else { + _qz.log.trace("Signature for call", obj.signature); + + //called for pre-signed content and (unsigned) setup calls + _qz.websocket.connection.send(_qz.tools.stringify(obj)); + } + } + catch(err) { + _qz.log.error(err); + + if (obj.promise != undefined) { + obj.promise.reject(err); + delete _qz.websocket.pendingCalls[obj.uid]; + } + } + }; + + //receive message from qz + _qz.websocket.connection.onmessage = function(evt) { + var returned = JSON.parse(evt.data); + + if (returned.uid == null) { + if (returned.type == null) { + //incorrect response format, likely connected to incompatible qz version + _qz.websocket.connection.close(4003, "Connected to incompatible QZ Tray version"); + + } else { + //streams (callbacks only, no promises) + switch(returned.type) { + case _qz.streams.serial: + if (!returned.event) { + returned.event = JSON.stringify({ portName: returned.key, output: returned.data }); + } + + _qz.serial.callSerial(JSON.parse(returned.event)); + break; + case _qz.streams.socket: + _qz.socket.callSocket(JSON.parse(returned.event)); + break; + case _qz.streams.usb: + if (!returned.event) { + returned.event = JSON.stringify({ vendorId: returned.key[0], productId: returned.key[1], output: returned.data }); + } + + _qz.usb.callUsb(JSON.parse(returned.event)); + break; + case _qz.streams.hid: + _qz.hid.callHid(JSON.parse(returned.event)); + break; + case _qz.streams.printer: + _qz.printers.callPrinter(JSON.parse(returned.event)); + break; + case _qz.streams.file: + _qz.file.callFile(JSON.parse(returned.event)); + break; + default: + _qz.log.allay("Cannot determine stream type for callback", returned); + break; + } + } + + return; + } + + _qz.log.trace("Received response from websocket", returned); + + var promise = _qz.websocket.pendingCalls[returned.uid]; + if (promise == undefined) { + _qz.log.allay('No promise found for returned response'); + } else { + if (returned.error != undefined) { + promise.reject(new Error(returned.error)); + } else { + promise.resolve(returned.result); + } + } + + delete _qz.websocket.pendingCalls[returned.uid]; + }; + + + //send up the certificate before making any calls + //also gives the user a chance to deny the connection + // PATCHED: Skip certificate sending for custom QZ Tray with pairing key authentication + function sendCert(cert) { + if (cert === undefined) { cert = null; } + + //websocket setup, query what version is connected + // PATCHED: Don't send certificate - custom server uses pairing keys only + qz.api.getVersion().then(function(version) { + _qz.websocket.connection.version = version; + _qz.websocket.connection.semver = version.toLowerCase().replace(/-rc\./g, "-rc").split(/[\\+\\.-]/g); + for(var i = 0; i < _qz.websocket.connection.semver.length; i++) { + try { + if (i == 3 && _qz.websocket.connection.semver[i].toLowerCase().indexOf("rc") == 0) { + // Handle "rc1" pre-release by negating build info + _qz.websocket.connection.semver[i] = -(_qz.websocket.connection.semver[i].replace(/\D/g, "")); + continue; + } + _qz.websocket.connection.semver[i] = parseInt(_qz.websocket.connection.semver[i]); + } + catch(ignore) {} + + if (_qz.websocket.connection.semver.length < 4) { + _qz.websocket.connection.semver[3] = 0; + } + } + + //algorithm can be declared before a connection, check for incompatibilities now that we have one + _qz.compatible.algorithm(true); + }).then(function() { + // PATCHED: Don't send certificate to server - custom QZ Tray uses pairing keys + // Original: _qz.websocket.connection.sendData({ certificate: cert, promise: openPromise }); + // Just resolve the promise immediately without sending certificate + openPromise.resolve(); + }); + } + + // PATCHED: Skip certificate retrieval for custom QZ Tray with pairing keys + // Just call sendCert with null immediately + sendCert(null); + + // Original code below (commented out): + /* + _qz.security.callCert().then(sendCert).catch(function(error) { + _qz.log.warn("Failed to get certificate:", error); + + if (_qz.security.rejectOnCertFailure) { + openPromise.reject(error); + } else { + sendCert(null); + } + }); + */ + }, + + /** Generate unique ID used to map a response to a call. */ + newUID: function() { + var len = 6; + return (new Array(len + 1).join("0") + (Math.random() * Math.pow(36, len) << 0).toString(36)).slice(-len) + } + }, + + dataPromise: function(callName, params, signature, signingTimestamp) { + return _qz.tools.promise(function(resolve, reject) { + var msg = { + call: callName, + promise: { resolve: resolve, reject: reject }, + params: params, + signature: signature, + timestamp: signingTimestamp + }; + + _qz.websocket.connection.sendData(msg); + }); + }, + + /** Library of promises awaiting a response, uid -> promise */ + pendingCalls: {}, + + /** List of functions to call on error from the websocket. */ + errorCallbacks: [], + /** Calls all functions registered to listen for errors. */ + callError: function(evt) { + if (Array.isArray(_qz.websocket.errorCallbacks)) { + for(var i = 0; i < _qz.websocket.errorCallbacks.length; i++) { + _qz.websocket.errorCallbacks[i](evt); + } + } else { + _qz.websocket.errorCallbacks(evt); + } + }, + + /** List of function to call on closing from the websocket. */ + closedCallbacks: [], + /** Calls all functions registered to listen for closing. */ + callClose: function(evt) { + if (Array.isArray(_qz.websocket.closedCallbacks)) { + for(var i = 0; i < _qz.websocket.closedCallbacks.length; i++) { + _qz.websocket.closedCallbacks[i](evt); + } + } else { + _qz.websocket.closedCallbacks(evt); + } + } + }, + + + printing: { + /** Default options used for new printer configs. Can be overridden using {@link qz.configs.setDefaults}. */ + defaultConfig: { + //value purposes are explained in the qz.configs.setDefaults docs + + bounds: null, + colorType: 'color', + copies: 1, + density: 0, + duplex: false, + fallbackDensity: null, + interpolation: 'bicubic', + jobName: null, + legacy: false, + margins: 0, + orientation: null, + paperThickness: null, + printerTray: null, + rasterize: false, + rotation: 0, + scaleContent: true, + size: null, + units: 'in', + + forceRaw: false, + encoding: null, + spool: null + } + }, + + + serial: { + /** List of functions called when receiving data from serial connection. */ + serialCallbacks: [], + /** Calls all functions registered to listen for serial events. */ + callSerial: function(streamEvent) { + if (Array.isArray(_qz.serial.serialCallbacks)) { + for(var i = 0; i < _qz.serial.serialCallbacks.length; i++) { + _qz.serial.serialCallbacks[i](streamEvent); + } + } else { + _qz.serial.serialCallbacks(streamEvent); + } + } + }, + + + socket: { + /** List of functions called when receiving data from network socket connection. */ + socketCallbacks: [], + /** Calls all functions registered to listen for network socket events. */ + callSocket: function(socketEvent) { + if (Array.isArray(_qz.socket.socketCallbacks)) { + for(var i = 0; i < _qz.socket.socketCallbacks.length; i++) { + _qz.socket.socketCallbacks[i](socketEvent); + } + } else { + _qz.socket.socketCallbacks(socketEvent); + } + } + }, + + + usb: { + /** List of functions called when receiving data from usb connection. */ + usbCallbacks: [], + /** Calls all functions registered to listen for usb events. */ + callUsb: function(streamEvent) { + if (Array.isArray(_qz.usb.usbCallbacks)) { + for(var i = 0; i < _qz.usb.usbCallbacks.length; i++) { + _qz.usb.usbCallbacks[i](streamEvent); + } + } else { + _qz.usb.usbCallbacks(streamEvent); + } + } + }, + + + hid: { + /** List of functions called when receiving data from hid connection. */ + hidCallbacks: [], + /** Calls all functions registered to listen for hid events. */ + callHid: function(streamEvent) { + if (Array.isArray(_qz.hid.hidCallbacks)) { + for(var i = 0; i < _qz.hid.hidCallbacks.length; i++) { + _qz.hid.hidCallbacks[i](streamEvent); + } + } else { + _qz.hid.hidCallbacks(streamEvent); + } + } + }, + + + printers: { + /** List of functions called when receiving data from printer connection. */ + printerCallbacks: [], + /** Calls all functions registered to listen for printer events. */ + callPrinter: function(streamEvent) { + if (Array.isArray(_qz.printers.printerCallbacks)) { + for(var i = 0; i < _qz.printers.printerCallbacks.length; i++) { + _qz.printers.printerCallbacks[i](streamEvent); + } + } else { + _qz.printers.printerCallbacks(streamEvent); + } + } + }, + + + file: { + /** List of functions called when receiving info regarding file changes. */ + fileCallbacks: [], + /** Calls all functions registered to listen for file events. */ + callFile: function(streamEvent) { + if (Array.isArray(_qz.file.fileCallbacks)) { + for(var i = 0; i < _qz.file.fileCallbacks.length; i++) { + _qz.file.fileCallbacks[i](streamEvent); + } + } else { + _qz.file.fileCallbacks(streamEvent); + } + } + }, + + + security: { + /** Function used to resolve promise when acquiring site's public certificate. */ + certHandler: function(resolve, reject) { reject(); }, + /** Called to create new promise (using {@link _qz.security.certHandler}) for certificate retrieval. */ + callCert: function() { + if (typeof _qz.security.certHandler.then === 'function') { + //already a promise + return _qz.security.certHandler; + } else if (_qz.security.certHandler.constructor.name === "AsyncFunction") { + //already callable as a promise + return _qz.security.certHandler(); + } else { + //turn into a promise + return _qz.tools.promise(_qz.security.certHandler); + } + }, + + /** Function used to create promise resolver when requiring signed calls. */ + signatureFactory: function() { return function(resolve) { resolve(); } }, + /** Called to create new promise (using {@link _qz.security.signatureFactory}) for signed calls. */ + callSign: function(toSign) { + if (_qz.security.signatureFactory.constructor.name === "AsyncFunction") { + //use directly + return _qz.security.signatureFactory(toSign); + } else { + //use in a promise + return _qz.tools.promise(_qz.security.signatureFactory(toSign)); + } + }, + + /** Signing algorithm used on signatures */ + signAlgorithm: "SHA1", + + rejectOnCertFailure: false, + + needsSigned: function(callName) { + const undialoged = [ + "printers.getStatus", + "printers.stopListening", + "usb.isClaimed", + "usb.closeStream", + "usb.releaseDevice", + "hid.stopListening", + "hid.isClaimed", + "hid.closeStream", + "hid.releaseDevice", + "file.stopListening", + "getVersion" + ]; + + return callName != null && undialoged.indexOf(callName) === -1; + } + }, + + + tools: { + /** Create a new promise */ + promise: function(resolver) { + //prefer global object for historical purposes + if (typeof RSVP !== 'undefined') { + return new RSVP.Promise(resolver); + } else if (typeof Promise !== 'undefined') { + return new Promise(resolver); + } else { + _qz.log.error("Promise/A+ support is required. See qz.api.setPromiseType(...)"); + } + }, + + /** Stub for rejecting with an Error from withing a Promise */ + reject: function(error) { + return _qz.tools.promise(function(resolve, reject) { + reject(error); + }); + }, + + stringify: function(object) { + //old versions of prototype affect stringify + var pjson = Array.prototype.toJSON; + delete Array.prototype.toJSON; + + function skipKeys(key, value) { + if (key === "promise") { + return undefined; + } + + return value; + } + + var result = JSON.stringify(object, skipKeys); + + if (pjson) { + Array.prototype.toJSON = pjson; + } + + return result; + }, + + hash: function(data) { + //prefer global object for historical purposes + if (typeof Sha256 !== 'undefined') { + return Sha256.hash(data); + } else { + return _qz.SHA.hash(data); + } + }, + + ws: typeof WebSocket !== 'undefined' ? WebSocket : null, + + absolute: function(loc) { + if (typeof window !== 'undefined' && typeof document.createElement === 'function') { + var a = document.createElement("a"); + a.href = loc; + return a.href; + } else if (typeof exports === 'object') { + //node.js + require('path').resolve(loc); + } + return loc; + }, + + relative: function(data) { + for(var i = 0; i < data.length; i++) { + if (data[i].constructor === Object) { + var absolute = false; + + if (data[i].data && data[i].data.search && data[i].data.search(/data:image\/\w+;base64,/) === 0) { + //upgrade from old base64 behavior + data[i].flavor = "base64"; + data[i].data = data[i].data.replace(/^data:image\/\w+;base64,/, ""); + } else if (data[i].flavor) { + //if flavor is known, we can directly check for absolute flavor types + if (["FILE", "XML"].indexOf(data[i].flavor.toUpperCase()) > -1) { + absolute = true; + } + } else if (data[i].format && ["HTML", "IMAGE", "PDF", "FILE", "XML"].indexOf(data[i].format.toUpperCase()) > -1) { + //if flavor is not known, all valid pixel formats default to file flavor + //previous v2.0 data also used format as what is now flavor, so we check for those values here too + absolute = true; + } else if (data[i].type && ((["PIXEL", "IMAGE", "PDF"].indexOf(data[i].type.toUpperCase()) > -1 && !data[i].format) + || (["HTML", "PDF"].indexOf(data[i].type.toUpperCase()) > -1 && (!data[i].format || data[i].format.toUpperCase() === "FILE")))) { + //if all we know is pixel type, then it is image's file flavor + //previous v2.0 data also used type as what is now format, so we check for those value here too + absolute = true; + } + + if (absolute) { + //change relative links to absolute + data[i].data = _qz.tools.absolute(data[i].data); + } + if (data[i].options && typeof data[i].options.overlay === 'string') { + data[i].options.overlay = _qz.tools.absolute(data[i].options.overlay); + } + } + } + }, + + /** Performs deep copy to target from remaining params */ + extend: function(target) { + //special case when reassigning properties as objects in a deep copy + if (typeof target !== 'object') { + target = {}; + } + + for(var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + if (!source) { continue; } + + for(var key in source) { + if (source.hasOwnProperty(key)) { + if (target === source[key]) { continue; } + + if (source[key] && source[key].constructor && source[key].constructor === Object) { + var clone; + if (Array.isArray(source[key])) { + clone = target[key] || []; + } else { + clone = target[key] || {}; + } + + target[key] = _qz.tools.extend(clone, source[key]); + } else if (source[key] !== undefined) { + target[key] = source[key]; + } + } + } + } + + return target; + }, + + versionCompare: function(major, minor, patch, build) { + if (_qz.tools.assertActive()) { + var semver = _qz.websocket.connection.semver; + if (semver[0] != major) { + return semver[0] - major; + } + if (minor != undefined && semver[1] != minor) { + return semver[1] - minor; + } + if (patch != undefined && semver[2] != patch) { + return semver[2] - patch; + } + if (build != undefined && semver.length > 3 && semver[3] != build) { + return Number.isInteger(semver[3]) && Number.isInteger(build) ? semver[3] - build : semver[3].toString().localeCompare(build.toString()); + } + return 0; + } + }, + + isVersion: function(major, minor, patch, build) { + return _qz.tools.versionCompare(major, minor, patch, build) == 0; + }, + + isActive: function() { + return !_qz.websocket.shutdown && _qz.websocket.connection != null + && (_qz.websocket.connection.readyState === _qz.tools.ws.OPEN + || _qz.websocket.connection.readyState === _qz.tools.ws.CONNECTING); + }, + + assertActive: function() { + if (_qz.tools.isActive()) { + return true; + } + // Promise won't reject on throw; yet better than 'undefined' + throw new Error("A connection to QZ has not been established yet"); + }, + + uint8ArrayToHex: function(uint8) { + return Array.from(uint8) + .map(function(i) { return i.toString(16).padStart(2, '0'); }) + .join(''); + }, + + uint8ArrayToBase64: function(uint8) { + /** + * Adapted from Egor Nepomnyaschih's code under MIT Licence (C) 2020 + * see https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 + */ + var map = [ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", + "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", + "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/" + ]; + + var result = '', i, l = uint8.length; + for (i = 2; i < l; i += 3) { + result += map[uint8[i - 2] >> 2]; + result += map[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; + result += map[((uint8[i - 1] & 0x0F) << 2) | (uint8[i] >> 6)]; + result += map[uint8[i] & 0x3F]; + } + if (i === l + 1) { // 1 octet yet to write + result += map[uint8[i - 2] >> 2]; + result += map[(uint8[i - 2] & 0x03) << 4]; + result += "=="; + } + if (i === l) { // 2 octets yet to write + result += map[uint8[i - 2] >> 2]; + result += map[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; + result += map[(uint8[i - 1] & 0x0F) << 2]; + result += "="; + } + return result; + }, + }, + + compatible: { + /** Converts message format to a previous version's */ + data: function(printData) { + // special handling for Uint8Array + for(var i = 0; i < printData.length; i++) { + if (printData[i].constructor === Object && printData[i].data instanceof Uint8Array) { + if (printData[i].flavor) { + var flavor = printData[i].flavor.toString().toUpperCase(); + switch(flavor) { + case 'BASE64': + printData[i].data = _qz.tools.uint8ArrayToBase64(printData[i].data); + break; + case 'HEX': + printData[i].data = _qz.tools.uint8ArrayToHex(printData[i].data); + break; + default: + throw new Error("Uint8Array conversion to '" + flavor + "' is not supported."); + } + } + } + } + + if(_qz.tools.versionCompare(2, 2, 4) < 0) { + for(var i = 0; i < printData.length; i++) { + if (printData[i].constructor === Object) { + // dotDensity: "double-legacy|single-legacy" since 2.2.4. Fallback to "double|single" + if (printData[i].options && typeof printData[i].options.dotDensity === 'string') { + printData[i].options.dotDensity = printData[i].options.dotDensity.toLowerCase().replace("-legacy", ""); + } + } + } + } + + if (_qz.tools.isVersion(2, 0)) { + /* + 2.0.x conversion + ----- + type=pixel -> use format as 2.0 type (unless 'command' format, which forces 2.0 'raw' type) + type=raw -> 2.0 type has to be 'raw' + if format is 'image' -> force 2.0 'image' format, ignore everything else (unsupported in 2.0) + + flavor translates straight to 2.0 format (unless forced to 'raw'/'image') + */ + _qz.log.trace("Converting print data to v2.0 for " + _qz.websocket.connection.version); + for(var i = 0; i < printData.length; i++) { + if (printData[i].constructor === Object) { + if (printData[i].type && printData[i].type.toUpperCase() === "RAW" && printData[i].format && printData[i].format.toUpperCase() === "IMAGE") { + if (printData[i].flavor && printData[i].flavor.toUpperCase() === "BASE64") { + //special case for raw base64 images + printData[i].data = "data:image/compat;base64," + printData[i].data; + } + printData[i].flavor = "IMAGE"; //forces 'image' format when shifting for conversion + } + if ((printData[i].type && printData[i].type.toUpperCase() === "RAW") || (printData[i].format && printData[i].format.toUpperCase() === "COMMAND")) { + printData[i].format = "RAW"; //forces 'raw' type when shifting for conversion + } + + printData[i].type = printData[i].format; + printData[i].format = printData[i].flavor; + delete printData[i].flavor; + } + } + } + }, + + /* Converts config defaults to match previous version */ + config: function(config, dirty) { + if (_qz.tools.isVersion(2, 0)) { + if (!dirty.rasterize) { + config.rasterize = true; + } + } + if(_qz.tools.versionCompare(2, 2) < 0) { + if(config.forceRaw !== 'undefined') { + config.altPrinting = config.forceRaw; + delete config.forceRaw; + } + } + if(_qz.tools.versionCompare(2, 1, 2, 11) < 0) { + if(config.spool) { + if(config.spool.size) { + config.perSpool = config.spool.size; + delete config.spool.size; + } + if(config.spool.end) { + config.endOfDoc = config.spool.end; + delete config.spool.end; + } + delete config.spool; + } + } + return config; + }, + + /** Compat wrapper with previous version **/ + networking: function(hostname, port, signature, signingTimestamp, mappingCallback) { + // Use 2.0 + if (_qz.tools.isVersion(2, 0)) { + return _qz.tools.promise(function(resolve, reject) { + _qz.websocket.dataPromise('websocket.getNetworkInfo', { + hostname: hostname, + port: port + }, signature, signingTimestamp).then(function(data) { + if (typeof mappingCallback !== 'undefined') { + resolve(mappingCallback(data)); + } else { + resolve(data); + } + }, reject); + }); + } + // Wrap 2.1 + return _qz.tools.promise(function(resolve, reject) { + _qz.websocket.dataPromise('networking.device', { + hostname: hostname, + port: port + }, signature, signingTimestamp).then(function(data) { + resolve({ ipAddress: data.ip, macAddress: data.mac }); + }, reject); + }); + }, + + /** Check if QZ version supports chosen algorithm */ + algorithm: function(quiet) { + //if not connected yet we will assume compatibility exists for the time being + if (_qz.tools.isActive()) { + if (_qz.tools.isVersion(2, 0)) { + if (!quiet) { + _qz.log.warn("Connected to an older version of QZ, alternate signature algorithms are not supported"); + } + return false; + } + } + + return true; + } + }, + + /** + * Adapted from Chris Veness's code under MIT Licence (C) 2002 + * see http://www.movable-type.co.uk/scripts/sha256.html + */ + SHA: { + //@formatter:off - keep this block compact + hash: function(msg) { + // add trailing '1' bit (+ 0's padding) to string [§5.1.1] + msg = _qz.SHA._utf8Encode(msg) + String.fromCharCode(0x80); + + // constants [§4.2.2] + var K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + // initial hash value [§5.3.1] + var H = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ]; + + // convert string msg into 512-bit/16-integer blocks arrays of ints [§5.2.1] + var l = msg.length / 4 + 2; // length (in 32-bit integers) of msg + ‘1’ + appended length + var N = Math.ceil(l / 16); // number of 16-integer-blocks required to hold 'l' ints + var M = new Array(N); + + for(var i = 0; i < N; i++) { + M[i] = new Array(16); + for(var j = 0; j < 16; j++) { // encode 4 chars per integer, big-endian encoding + M[i][j] = (msg.charCodeAt(i * 64 + j * 4) << 24) | (msg.charCodeAt(i * 64 + j * 4 + 1) << 16) | + (msg.charCodeAt(i * 64 + j * 4 + 2) << 8) | (msg.charCodeAt(i * 64 + j * 4 + 3)); + } // note running off the end of msg is ok 'cos bitwise ops on NaN return 0 + } + // add length (in bits) into final pair of 32-bit integers (big-endian) [§5.1.1] + // note: most significant word would be (len-1)*8 >>> 32, but since JS converts + // bitwise-op args to 32 bits, we need to simulate this by arithmetic operators + M[N-1][14] = ((msg.length - 1) * 8) / Math.pow(2, 32); + M[N-1][14] = Math.floor(M[N-1][14]); + M[N-1][15] = ((msg.length - 1) * 8) & 0xffffffff; + + // HASH COMPUTATION [§6.1.2] + var W = new Array(64); var a, b, c, d, e, f, g, h; + for(var i = 0; i < N; i++) { + // 1 - prepare message schedule 'W' + for(var t = 0; t < 16; t++) { W[t] = M[i][t]; } + for(var t = 16; t < 64; t++) { W[t] = (_qz.SHA._dev1(W[t-2]) + W[t-7] + _qz.SHA._dev0(W[t-15]) + W[t-16]) & 0xffffffff; } + // 2 - initialise working variables a, b, c, d, e, f, g, h with previous hash value + a = H[0]; b = H[1]; c = H[2]; d = H[3]; e = H[4]; f = H[5]; g = H[6]; h = H[7]; + // 3 - main loop (note 'addition modulo 2^32') + for(var t = 0; t < 64; t++) { + var T1 = h + _qz.SHA._sig1(e) + _qz.SHA._ch(e, f, g) + K[t] + W[t]; + var T2 = _qz.SHA._sig0(a) + _qz.SHA._maj(a, b, c); + h = g; g = f; f = e; e = (d + T1) & 0xffffffff; + d = c; c = b; b = a; a = (T1 + T2) & 0xffffffff; + } + // 4 - compute the new intermediate hash value (note 'addition modulo 2^32') + H[0] = (H[0]+a) & 0xffffffff; H[1] = (H[1]+b) & 0xffffffff; H[2] = (H[2]+c) & 0xffffffff; H[3] = (H[3]+d) & 0xffffffff; + H[4] = (H[4]+e) & 0xffffffff; H[5] = (H[5]+f) & 0xffffffff; H[6] = (H[6]+g) & 0xffffffff; H[7] = (H[7]+h) & 0xffffffff; + } + + return _qz.SHA._hexStr(H[0]) + _qz.SHA._hexStr(H[1]) + _qz.SHA._hexStr(H[2]) + _qz.SHA._hexStr(H[3]) + + _qz.SHA._hexStr(H[4]) + _qz.SHA._hexStr(H[5]) + _qz.SHA._hexStr(H[6]) + _qz.SHA._hexStr(H[7]); + }, + + // Rotates right (circular right shift) value x by n positions + _rotr: function(n, x) { return (x >>> n) | (x << (32 - n)); }, + // logical functions + _sig0: function(x) { return _qz.SHA._rotr(2, x) ^ _qz.SHA._rotr(13, x) ^ _qz.SHA._rotr(22, x); }, + _sig1: function(x) { return _qz.SHA._rotr(6, x) ^ _qz.SHA._rotr(11, x) ^ _qz.SHA._rotr(25, x); }, + _dev0: function(x) { return _qz.SHA._rotr(7, x) ^ _qz.SHA._rotr(18, x) ^ (x >>> 3); }, + _dev1: function(x) { return _qz.SHA._rotr(17, x) ^ _qz.SHA._rotr(19, x) ^ (x >>> 10); }, + _ch: function(x, y, z) { return (x & y) ^ (~x & z); }, + _maj: function(x, y, z) { return (x & y) ^ (x & z) ^ (y & z); }, + // note can't use toString(16) as it is implementation-dependant, and in IE returns signed numbers when used on full words + _hexStr: function(n) { var s = "", v; for(var i = 7; i >= 0; i--) { v = (n >>> (i * 4)) & 0xf; s += v.toString(16); } return s; }, + // implementation of deprecated unescape() based on https://cwestblog.com/2011/05/23/escape-unescape-deprecated/ (and comments) + _unescape: function(str) { + return str.replace(/%(u[\da-f]{4}|[\da-f]{2})/gi, function(seq) { + if (seq.length - 1) { + return String.fromCharCode(parseInt(seq.substring(seq.length - 3 ? 2 : 1), 16)) + } else { + var code = seq.charCodeAt(0); + return code < 256 ? "%" + (0 + code.toString(16)).slice(-2).toUpperCase() : "%u" + ("000" + code.toString(16)).slice(-4).toUpperCase() + } + }); + }, + _utf8Encode: function(str) { + return _qz.SHA._unescape(encodeURIComponent(str)); + } + //@formatter:on + }, + }; + + +///// CONFIG CLASS //// + + /** Object to handle configured printer options. */ + function Config(printer, opts) { + + this.config = _qz.tools.extend({}, _qz.printing.defaultConfig); //create a copy of the default options + this._dirtyOpts = {}; //track which config options have changed from the defaults + + /** + * Set the printer assigned to this config. + * @param {string|Object} newPrinter Name of printer. Use object type to specify printing to file or host. + * @param {string} [newPrinter.name] Name of printer to send printing. + * @param {string} [newPrinter.file] DEPRECATED: Name of file to send printing. + * @param {string} [newPrinter.host] IP address or host name to send printing. + * @param {string} [newPrinter.port] Port used by <printer.host>. + */ + this.setPrinter = function(newPrinter) { + if (typeof newPrinter === 'string') { + newPrinter = { name: newPrinter }; + } + this.printer = newPrinter; + }; + + /** + * @returns {Object} The printer currently assigned to this config. + */ + this.getPrinter = function() { + return this.printer; + }; + + /** + * Alter any of the printer options currently applied to this config. + * @param newOpts {Object} The options to change. See qz.configs.setDefaults docs for available values. + * + * @see qz.configs.setDefaults + */ + this.reconfigure = function(newOpts) { + for(var key in newOpts) { + if (newOpts[key] !== undefined) { + this._dirtyOpts[key] = true; + } + } + + _qz.tools.extend(this.config, newOpts); + }; + + /** + * @returns {Object} The currently applied options on this config. + */ + this.getOptions = function() { + return _qz.compatible.config(this.config, this._dirtyOpts); + }; + + // init calls for new config object + this.setPrinter(printer); + this.reconfigure(opts); + } + + /** + * Shortcut method for calling qz.print with a particular config. + * @param {Array} data Array of data being sent to the printer. See qz.print docs for available values. + * @param {boolean} [signature] Pre-signed signature of JSON string containing call, params, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @example + * qz.print(myConfig, ...); // OR + * myConfig.print(...); + * + * @see qz.print + */ + Config.prototype.print = function(data, signature, signingTimestamp) { + qz.print(this, data, signature, signingTimestamp); + }; + + +///// PUBLIC METHODS ///// + + /** @namespace qz */ + var qz = { + + /** + * Calls related specifically to the web socket connection. + * @namespace qz.websocket + */ + websocket: { + /** + * Check connection status. Active connection is necessary for other calls to run. + * + * @returns {boolean} If there is an active connection with QZ Tray. + * + * @see connect + * + * @memberof qz.websocket + */ + isActive: function() { + return _qz.tools.isActive(); + }, + + /** + * Call to setup connection with QZ Tray on user's system. + * + * @param {Object} [options] Configuration options for the web socket connection. + * @param {string|Array} [options.host=['localhost', 'localhost.qz.io']] Host running the QZ Tray software. + * @param {Object} [options.port] Config options for ports to cycle. + * @param {Array} [options.port.secure=[8181, 8282, 8383, 8484]] Array of secure (WSS) ports to try + * @param {Array} [options.port.insecure=[8182, 8283, 8384, 8485]] Array of insecure (WS) ports to try + * @param {boolean} [options.usingSecure=true] If the web socket should try to use secure ports for connecting. + * @param {number} [options.keepAlive=60] Seconds between keep-alive pings to keep connection open. Set to 0 to disable. + * @param {number} [options.retries=0] Number of times to reconnect before failing. + * @param {number} [options.delay=0] Seconds before firing a connection. Ignored if options.retries is 0. + * + * @returns {Promise} + * + * @memberof qz.websocket + */ + connect: function(options) { + return _qz.tools.promise(function(resolve, reject) { + if (_qz.websocket.connection) { + const state = _qz.websocket.connection.readyState; + + if (state === _qz.tools.ws.OPEN) { + reject(new Error("An open connection with QZ Tray already exists")); + return; + } else if (state === _qz.tools.ws.CONNECTING) { + reject(new Error("The current connection attempt has not returned yet")); + return; + } else if (state === _qz.tools.ws.CLOSING) { + reject(new Error("Waiting for previous disconnect request to complete")); + return; + } + } + + if (!_qz.tools.ws) { + reject(new Error("WebSocket not supported by this browser")); + return; + } else if (!_qz.tools.ws.CLOSED || _qz.tools.ws.CLOSED == 2) { + reject(new Error("Unsupported WebSocket version detected: HyBi-00/Hixie-76")); + return; + } + + //ensure some form of options exists for value checks + if (options == undefined) { options = {}; } + + //disable secure ports if page is not secure + if (typeof location === 'undefined' || location.protocol !== 'https:') { + //respect forcing secure ports if it is defined, otherwise disable + if (typeof options.usingSecure === 'undefined') { + _qz.log.trace("Disabling secure ports due to insecure page"); + options.usingSecure = false; + } + } + + //ensure any hosts are passed to internals as an array + if (typeof options.host !== 'undefined' && !Array.isArray(options.host)) { + options.host = [options.host]; + } + + _qz.websocket.shutdown = false; //reset state for new connection attempt + var attempt = function(count) { + var tried = false; + var nextAttempt = function() { + if (!tried) { + tried = true; + + if (options && count < options.retries) { + attempt(count + 1); + } else { + _qz.websocket.connection = null; + reject.apply(null, arguments); + } + } + }; + + var delayed = function() { + var config = _qz.tools.extend({}, _qz.websocket.connectConfig, options); + _qz.websocket.setup.findConnection(config, resolve, nextAttempt) + }; + if (count == 0) { + delayed(); // only retries will be called with a delay + } else { + setTimeout(delayed, options.delay * 1000); + } + }; + + attempt(0); + }); + }, + + /** + * Stop any active connection with QZ Tray. + * + * @returns {Promise} + * + * @memberof qz.websocket + */ + disconnect: function() { + return _qz.tools.promise(function(resolve, reject) { + if (_qz.websocket.connection != null) { + if (_qz.tools.isActive()) { + // handles closing both 'connecting' and 'connected' states + _qz.websocket.shutdown = true; + _qz.websocket.connection.promise = { resolve: resolve, reject: reject }; + _qz.websocket.connection.close(); + } else { + reject(new Error("Current connection is still closing")); + } + } else { + reject(new Error("No open connection with QZ Tray")); + } + }); + }, + + /** + * List of functions called for any connections errors outside of an API call.

    + * Also called if {@link websocket#connect} fails to connect. + * + * @param {Function|Array} calls Single or array of Function({Event} event) calls. + * + * @memberof qz.websocket + */ + setErrorCallbacks: function(calls) { + _qz.websocket.errorCallbacks = calls; + }, + + /** + * List of functions called for any connection closing event outside of an API call.

    + * Also called when {@link websocket#disconnect} is called. + * + * @param {Function|Array} calls Single or array of Function({Event} event) calls. + * + * @memberof qz.websocket + */ + setClosedCallbacks: function(calls) { + _qz.websocket.closedCallbacks = calls; + }, + + /** + * @deprecated Since 2.1.0. Please use qz.networking.device() instead + * + * @param {string} [hostname] Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] Port to use with custom hostname, defaults to 443 + * @param {string} [signature] Pre-signed signature of hashed JSON string containing call='websocket.getNetworkInfo', params object, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @returns {Promise|Error>} Connected system's network information. + * + * @memberof qz.websocket + */ + getNetworkInfo: _qz.compatible.networking, + + /** + * @returns {Object<{socket: String, host: String, port: Number}>} Details of active websocket connection + * + * @memberof qz.websocket + */ + getConnectionInfo: function() { + if (_qz.tools.assertActive()) { + var url = _qz.websocket.connection.url.split(/[:\/]+/g); + return { socket: url[0], host: url[1], port: +url[2] }; + } + } + }, + + + /** + * Calls related to getting printer information from the connection. + * @namespace qz.printers + */ + printers: { + /** + * @param {string} [signature] Pre-signed signature of hashed JSON string containing call='printers.getDefault, params, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @returns {Promise} Name of the connected system's default printer. + * + * @memberof qz.printers + */ + getDefault: function(signature, signingTimestamp) { + return _qz.websocket.dataPromise('printers.getDefault', null, signature, signingTimestamp); + }, + + /** + * @param {string} [query] Search for a specific printer. All printers are returned if not provided. + * @param {string} [signature] Pre-signed signature of hashed JSON string containing call='printers.find', params, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @returns {Promise|string|Error>} The matched printer name if query is provided. + * Otherwise an array of printer names found on the connected system. + * + * @memberof qz.printers + */ + find: function(query, signature, signingTimestamp) { + return _qz.websocket.dataPromise('printers.find', { query: query }, signature, signingTimestamp); + }, + + /** + * Provides a list, with additional information, for each printer available to QZ. + * + * @returns {Promise|Object|Error>} + * + * @memberof qz.printers + */ + details: function() { + return _qz.websocket.dataPromise('printers.detail'); + }, + + /** + * Start listening for printer status events, such as paper_jam events. + * Reported under the ACTION type in the streamEvent on callbacks. + * + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.printers.setPrinterCallbacks + * + * @param {null|string|Array} printers Printer or list of printers to listen to, null listens to all. + * @param {Object|null} [options] Printer listener options + * @param {null|boolean} [options.jobData=false] Flag indicating if raw spool file content should be return as well as status information (Windows only) + * @param {null|number} [options.maxJobData=-1] Maximum number of bytes to returns for raw spooled file content (Windows only) + * @param {null|string} [options.flavor="plain"] Flavor of data format returned. Valid flavors are [base64 | hex | plain*] (Windows only) + * + * @memberof qz.printers + */ + startListening: function(printers, options) { + if (!Array.isArray(printers)) { + printers = [printers]; + } + var params = { + printerNames: printers + }; + if (options && options.jobData == true) params.jobData = true; + if (options && options.maxJobData) params.maxJobData = options.maxJobData; + if (options && options.flavor) params.flavor = options.flavor; + return _qz.websocket.dataPromise('printers.startListening', params); + }, + + /** + * Clear the queue of a specified printer or printers. Does not delete retained jobs. + * + * @param {string|Object} [options] Name of printer to clear + * @param {string} [options.printerName] Name of printer to clear + * @param {number} [options.jobId] Cancel a job of a specific JobId instead of canceling all. Must include a printerName. + * + * @returns {Promise} + * @since 2.2.4 + * + * @memberof qz.printers + */ + clearQueue: function(options) { + if (typeof options !== 'object') { + options = { + printerName: options + }; + } + return _qz.websocket.dataPromise('printers.clearQueue', options); + }, + + /** + * Stop listening for printer status actions. + * + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.printers.setPrinterCallbacks + * + * @memberof qz.printers + */ + stopListening: function() { + return _qz.websocket.dataPromise('printers.stopListening'); + }, + + /** + * Retrieve current printer status from any active listeners. + * + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.printers.startListening + * + * @memberof qz.printers + */ + getStatus: function() { + return _qz.websocket.dataPromise('printers.getStatus'); + }, + + /** + * List of functions called for any printer status change. + * Event data will contain {string} printerName and {string} status for all types. + * For RECEIVE types, {Array} output (in hexadecimal format). + * For ERROR types, {string} exception. + * For ACTION types, {string} actionType. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * @since 2.1.0 + * + * @memberof qz.printers + */ + setPrinterCallbacks: function(calls) { + _qz.printers.printerCallbacks = calls; + } + }, + + /** + * Calls related to setting up new printer configurations. + * @namespace qz.configs + */ + configs: { + /** + * Default options used by new configs if not overridden. + * Setting a value to NULL will use the printer's default options. + * Updating these will not update the options on any created config. + * + * @param {Object} options Default options used by printer configs if not overridden. + * + * @param {Object} [options.bounds=null] Bounding box rectangle. + * @param {number} [options.bounds.x=0] Distance from left for bounding box starting corner + * @param {number} [options.bounds.y=0] Distance from top for bounding box starting corner + * @param {number} [options.bounds.width=0] Width of bounding box + * @param {number} [options.bounds.height=0] Height of bounding box + * @param {string} [options.colorType='color'] Valid values [color | grayscale | blackwhite | default] + * @param {number} [options.copies=1] Number of copies to be printed. + * @param {number|Array|Object|Array|string} [options.density=0] Pixel density (DPI, DPMM, or DPCM depending on [options.units]). + * If provided as an array, uses the first supported density found (or the first entry if none found). + * If provided as a string, valid values are [best | draft], corresponding to highest or lowest reported density respectively. + * @param {number} [options.density.cross=0] Asymmetric pixel density for the cross feed direction. + * @param {number} [options.density.feed=0] Asymmetric pixel density for the feed direction. + * @param {boolean|string} [options.duplex=false] Double sided printing, Can specify duplex style by passing a string value: [one-sided | duplex | long-edge | tumble | short-edge] + * @param {number} [options.fallbackDensity=null] Value used when default density value cannot be read, or in cases where reported as "Normal" by the driver, (in DPI, DPMM, or DPCM depending on [options.units]). + * @param {string} [options.interpolation='bicubic'] Valid values [bicubic | bilinear | nearest-neighbor]. Controls how images are handled when resized. + * @param {string} [options.jobName=null] Name to display in print queue. + * @param {boolean} [options.legacy=false] If legacy style printing should be used. + * @param {Object|number} [options.margins=0] If just a number is provided, it is used as the margin for all sides. + * @param {number} [options.margins.top=0] + * @param {number} [options.margins.right=0] + * @param {number} [options.margins.bottom=0] + * @param {number} [options.margins.left=0] + * @param {string} [options.orientation=null] Valid values [portrait | landscape | reverse-landscape | null]. + * If set to null, orientation will be determined automatically. + * @param {number} [options.paperThickness=null] + * @param {string|number} [options.printerTray=null] Printer tray to pull from. The number N assumes string equivalent of 'Tray N'. Uses printer default if NULL. + * @param {boolean} [options.rasterize=false] Whether documents should be rasterized before printing. + * Specifying [options.density] for PDF print formats will set this to true. + * @param {number} [options.rotation=0] Image rotation in degrees. + * @param {boolean} [options.scaleContent=true] Scales print content to page size, keeping ratio. + * @param {Object} [options.size=null] Paper size. + * @param {number} [options.size.width=null] Page width. + * @param {number} [options.size.height=null] Page height. + * @param {string} [options.units='in'] Page units, applies to paper size, margins, and density. Valid value [in | cm | mm] + * + * @param {boolean} [options.forceRaw=false] Print the specified raw data using direct method, skipping the driver. Not yet supported on Windows. + * @param {string|Object} [options.encoding=null] Character set for commands. Can be provided as an object for converting encoding types for RAW types. + * @param {string} [options.encoding.from] If this encoding type is provided, RAW type commands will be parsed from this for the purpose of being converted to the encoding.to value. + * @param {string} [options.encoding.to] Encoding RAW type commands will be converted into. If encoding.from is not provided, this will be treated as if a string was passed for encoding. + * @param {string} [options.endOfDoc=null] DEPRECATED Raw only: Character(s) denoting end of a page to control spooling. + * @param {number} [options.perSpool=1] DEPRECATED: Raw only: Number of pages per spool. + * @param {boolean} [options.retainTemp=false] Retain any temporary files used. Ignored unless forceRaw true. + * @param {Object} [options.spool=null] Advanced spooling options. + * @param {number} [options.spool.size=null] Number of pages per spool. Default is no limit. If spool.end is provided, defaults to 1 + * @param {string} [options.spool.end=null] Raw only: Character(s) denoting end of a page to control spooling. + * + * @memberof qz.configs + */ + setDefaults: function(options) { + _qz.tools.extend(_qz.printing.defaultConfig, options); + }, + + /** + * Creates new printer config to be used in printing. + * + * @param {string|object} printer Name of printer. Use object type to specify printing to file or host. + * @param {string} [printer.name] Name of printer to send printing. + * @param {string} [printer.file] Name of file to send printing. + * @param {string} [printer.host] IP address or host name to send printing. + * @param {string} [printer.port] Port used by <printer.host>. + * @param {Object} [options] Override any of the default options for this config only. + * + * @returns {Config} The new config. + * + * @see configs.setDefaults + * + * @memberof qz.configs + */ + create: function(printer, options) { + return new Config(printer, options); + } + }, + + + /** + * Send data to selected config for printing. + * The promise for this method will resolve when the document has been sent to the printer. Actual printing may not be complete. + *

    + * Optionally, print requests can be pre-signed: + * Signed content consists of a JSON object string containing no spacing, + * following the format of the "call" and "params" keys in the API call, with the addition of a "timestamp" key in milliseconds + * ex. '{"call":"","params":{...},"timestamp":1450000000}' + * + * @param {Object|Array>} configs Previously created config object or objects. + * @param {Array|Array>} data Array of data being sent to the printer.
    + * String values are interpreted as {type: 'raw', format: 'command', flavor: 'plain', data: <string>}. + * @param {string} data.data + * @param {string} data.type Printing type. Valid types are [pixel | raw*]. *Default + * @param {string} data.format Format of data type used. *Default per type

    + * For [pixel] types, valid formats are [html | image* | pdf].

    + * For [raw] types, valid formats are [command* | html | image | pdf]. + * @param {string} data.flavor Flavor of data format used. *Default per format

    + * For [command] formats, valid flavors are [base64 | file | hex | plain* | xml].

    + * For [html] formats, valid flavors are [file* | plain].

    + * For [image] formats, valid flavors are [base64 | file*].

    + * For [pdf] formats, valid flavors are [base64 | file*]. + * @param {Object} [data.options] + * @param {string} [data.options.language] Required with [raw] type + [image] format. Printer language. + * @param {number} [data.options.x] Optional with [raw] type + [image] format. The X position of the image. + * @param {number} [data.options.y] Optional with [raw] type + [image] format. The Y position of the image. + * @param {string|number} [data.options.dotDensity] Optional with [raw] type + [image] format. + * @param {number} [data.precision=128] Optional with [raw] type [image] format. Bit precision of the ribbons. + * @param {boolean|string|Array>} [data.options.overlay=false] Optional with [raw] type [image] format. + * Boolean sets entire layer, string sets mask image, Array sets array of rectangles in format [x1,y1,x2,y2]. + * @param {string} [data.options.xmlTag] Required with [xml] flavor. Tag name containing base64 formatted data. + * @param {number} [data.options.pageWidth] Optional with [html | pdf] formats. Width of the rendering. + * Defaults to paper width. + * @param {number} [data.options.pageHeight] Optional with [html | pdf] formats. Height of the rendering. + * Defaults to paper height for [pdf], or auto sized for [html]. + * @param {string} [data.options.pageRanges] Optional with [pdf] formats. Comma-separated list of page ranges to include. + * @param {boolean} [data.options.ignoreTransparency=false] Optional with [pdf] formats. Instructs transparent PDF elements to be ignored. + * Transparent PDF elements are known to degrade performance and quality when printing. + * @param {boolean} [data.options.altFontRendering=false] Optional with [pdf] formats. Instructs PDF to be rendered using PDFBOX 1.8 techniques. + * Drastically improves low-DPI PDF print quality on Windows. + * @param {...*} [arguments] Additionally three more parameters can be specified:

    + * {boolean} [resumeOnError=false] Whether the chain should continue printing if it hits an error on one the the prints.

    + * {string|Array} [signature] Pre-signed signature(s) of the JSON string for containing call, params, and timestamp.

    + * {number|Array} [signingTimestamps] Required to match with signature. Timestamps for each of the passed pre-signed content. + * + * @returns {Promise} + * + * @see qz.configs.create + * + * @memberof qz + */ + print: function(configs, data) { + var resumeOnError = false, + signatures = [], + signaturesTimestamps = []; + + //find optional parameters + if (arguments.length >= 3) { + if (typeof arguments[2] === 'boolean') { + resumeOnError = arguments[2]; + + if (arguments.length >= 5) { + signatures = arguments[3]; + signaturesTimestamps = arguments[4]; + } + } else if (arguments.length >= 4) { + signatures = arguments[2]; + signaturesTimestamps = arguments[3]; + } + + //ensure values are arrays for consistency + if (signatures && !Array.isArray(signatures)) { signatures = [signatures]; } + if (signaturesTimestamps && !Array.isArray(signaturesTimestamps)) { signaturesTimestamps = [signaturesTimestamps]; } + } + + if (!Array.isArray(configs)) { configs = [configs]; } //single config -> array of configs + if (!Array.isArray(data[0])) { data = [data]; } //single data array -> array of data arrays + + //clean up data formatting + for(var d = 0; d < data.length; d++) { + _qz.tools.relative(data[d]); + _qz.compatible.data(data[d]); + } + + var sendToPrint = function(mapping) { + var params = { + printer: mapping.config.getPrinter(), + options: mapping.config.getOptions(), + data: mapping.data + }; + + return _qz.websocket.dataPromise('print', params, mapping.signature, mapping.timestamp); + }; + + //chain instead of Promise.all, so resumeOnError can collect each error + var chain = []; + for(var i = 0; i < configs.length || i < data.length; i++) { + (function(i_) { + var map = { + config: configs[Math.min(i_, configs.length - 1)], + data: data[Math.min(i_, data.length - 1)], + signature: signatures[i_], + timestamp: signaturesTimestamps[i_] + }; + + chain.push(function() { return sendToPrint(map) }); + })(i); + } + + //setup to catch errors if needed + var fallThrough = null; + if (resumeOnError) { + var fallen = []; + fallThrough = function(err) { fallen.push(err); }; + + //final promise to reject any errors as a group + chain.push(function() { + return _qz.tools.promise(function(resolve, reject) { + fallen.length ? reject(fallen) : resolve(); + }); + }); + } + + var last = null; + chain.reduce(function(sequence, link) { + last = sequence.catch(fallThrough).then(link); //catch is ignored if fallThrough is null + return last; + }, _qz.tools.promise(function(r) { r(); })); //an immediately resolved promise to start off the chain + + //return last promise so users can chain off final action or catch when stopping on error + return last; + }, + + + /** + * Calls related to interaction with serial ports. + * @namespace qz.serial + */ + serial: { + /** + * @returns {Promise|Error>} Communication (RS232, COM, TTY) ports available on connected system. + * + * @memberof qz.serial + */ + findPorts: function() { + return _qz.websocket.dataPromise('serial.findPorts'); + }, + + /** + * List of functions called for any response from open serial ports. + * Event data will contain {string} portName for all types. + * For RECEIVE types, {string} output. + * For ERROR types, {string} exception. + * + * @param {Function|Array} calls Single or array of Function({object} streamEvent) calls. + * + * @memberof qz.serial + */ + setSerialCallbacks: function(calls) { + _qz.serial.serialCallbacks = calls; + }, + + /** + * Opens a serial port for sending and receiving data + * + * @param {string} port Name of serial port to open. + * @param {Object} [options] Serial port configurations. + * @param {number} [options.baudRate=9600] Serial port speed. Set to 0 for auto negotiation. + * @param {number} [options.dataBits=8] Serial port data bits. Set to 0 for auto negotiation. + * @param {number} [options.stopBits=1] Serial port stop bits. Set to 0 for auto negotiation. + * @param {string} [options.parity='NONE'] Serial port parity. Set to AUTO for auto negotiation. Valid values [NONE | EVEN | ODD | MARK | SPACE | AUTO] + * @param {string} [options.flowControl='NONE'] Serial port flow control. Set to AUTO for auto negotiation. Valid values [NONE | XONXOFF | XONXOFF_OUT | XONXOFF_IN | RTSCTS | RTSCTS_OUT | RTSCTS_IN | AUTO] + * @param {string} [options.encoding='UTF-8'] Character set for communications. + * @param {string} [options.start=0x0002] DEPRECATED: Legacy character denoting start of serial response. Use options.rx.start instead. + * @param {string} [options.end=0x000D] DEPRECATED: Legacy character denoting end of serial response. Use options.rx.end instead. + * @param {number} [options.width] DEPRECATED: Legacy use for fixed-width response serial communication. Use options.rx.width instead. + * @param {Object} [options.rx] Serial communications response definitions. If an object is passed but no options are defined, all response data will be sent back as it is received unprocessed. + * @param {string|Array} [options.rx.start] Character(s) denoting start of response bytes. Used in conjunction with `end`, `width`, or `lengthbit` property. + * @param {string} [options.rx.end] Character denoting end of response bytes. Used in conjunction with `start` property. + * @param {number} [options.rx.width] Fixed width size of response bytes (not including header if `start` is set). Used alone or in conjunction with `start` property. + * @param {boolean} [options.rx.untilNewline] Returns data between newline characters (`\n` or `\r`) Truncates empty responses. Overrides `start`, `end`, `width`. + * @param {number|Object} [options.rx.lengthBytes] If a number is passed it is treated as the length index. Other values are left as their defaults. + * @param {number} [options.rx.lengthBytes.index=0] Position of the response byte (not including response `start` bytes) used to denote the length of the remaining response data. + * @param {number} [options.rx.lengthBytes.length=1] Length of response length bytes after response header. + * @param {string} [options.rx.lengthBytes.endian='BIG'] Byte endian for multi-byte length values. Valid values [BIG | LITTLE] + * @param {number|Object} [options.rx.crcBytes] If a number is passed it is treated as the crc length. Other values are left as their defaults. + * @param {number} [options.rx.crcBytes.index=0] Position after the response data (not including length or data bytes) used to denote the crc. + * @param {number} [options.rx.crcBytes.length=1] Length of response crc bytes after the response data length. + * @param {boolean} [options.rx.includeHeader=false] Whether any of the header bytes (`start` bytes and any length bytes) should be included in the processed response. + * @param {string} [options.rx.encoding] Override the encoding used for response data. Uses the same value as options.encoding otherwise. + * + * @returns {Promise} + * + * @memberof qz.serial + */ + openPort: function(port, options) { + var params = { + port: port, + options: options + }; + return _qz.websocket.dataPromise('serial.openPort', params); + }, + + /** + * Send commands over a serial port. + * Any responses from the device will be sent to serial callback functions. + * + * @param {string} port An open serial port to send data. + * @param {string|Array|Object} data Data to be sent to the serial device. + * @param {string} [data.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @param {string|Array} data.data Data to be sent to the serial device. + * @param {Object} options Serial port configuration updates. See qz.serial.openPort `options` docs for available values. + * For best performance, it is recommended to only set these values on the port open call. + * + * @returns {Promise} + * + * @see qz.serial.setSerialCallbacks + * + * @memberof qz.serial + */ + sendData: function(port, data, options) { + if (_qz.tools.versionCompare(2, 1, 0, 12) >= 0) { + if (typeof data !== 'object') { + data = { + data: data, + type: "PLAIN" + } + } + + if (data.type && data.type.toUpperCase() == "FILE") { + data.data = _qz.tools.absolute(data.data); + } + } + + var params = { + port: port, + data: data, + options: options + }; + return _qz.websocket.dataPromise('serial.sendData', params); + }, + + /** + * @param {string} port Name of port to close. + * + * @returns {Promise} + * + * @memberof qz.serial + */ + closePort: function(port) { + return _qz.websocket.dataPromise('serial.closePort', { port: port }); + } + }, + + /** + * Calls related to interaction with communication sockets. + * @namespace qz.socket + */ + socket: { + /** + * Opens a network port for sending and receiving data. + * + * @param {string} host The connection hostname. + * @param {number} port The connection port number. + * @param {Object} [options] Network socket configuration. + * @param {string} [options.encoding='UTF-8'] Character set for communications. + * + * @memberof qz.socket + */ + open: function(host, port, options) { + var params = { + host: host, + port: port, + options: options + }; + return _qz.websocket.dataPromise("socket.open", params); + }, + + /** + * @param {string} host The connection hostname. + * @param {number} port The connection port number. + * + * @memberof qz.socket + */ + close: function(host, port) { + var params = { + host: host, + port: port + }; + return _qz.websocket.dataPromise("socket.close", params); + }, + + /** + * Send data over an open socket. + * + * @param {string} host The connection hostname. + * @param {number} port The connection port number. + * @param {string|Object} data Data to be sent over the port. + * @param {string} [data.type='PLAIN'] Valid values [PLAIN] + * @param {string} data.data Data to be sent over the port. + * + * @memberof qz.socket + */ + sendData: function(host, port, data) { + if (typeof data !== 'object') { + data = { + data: data, + type: "PLAIN" + }; + } + + var params = { + host: host, + port: port, + data: data + }; + return _qz.websocket.dataPromise("socket.sendData", params); + }, + + /** + * List of functions called for any response from open network sockets. + * Event data will contain {string} host and {number} port for all types. + * For RECEIVE types, {string} response. + * For ERROR types, {string} exception. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * + * @memberof qz.socket + */ + setSocketCallbacks: function(calls) { + _qz.socket.socketCallbacks = calls; + } + }, + + /** + * Calls related to interaction with USB devices. + * @namespace qz.usb + */ + usb: { + /** + * List of available USB devices. Includes (hexadecimal) vendor ID, (hexadecimal) product ID, and hub status. + * If supported, also returns manufacturer and product descriptions. + * + * @param includeHubs Whether to include USB hubs. + * @returns {Promise|Error>} Array of JSON objects containing information on connected USB devices. + * + * @memberof qz.usb + */ + listDevices: function(includeHubs) { + return _qz.websocket.dataPromise('usb.listDevices', { includeHubs: includeHubs }); + }, + + /** + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @returns {Promise|Error>} List of available (hexadecimal) interfaces on a USB device. + * + * @memberof qz.usb + */ + listInterfaces: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('usb.listInterfaces', deviceInfo); + }, + + /** + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.iface Hex string of interface on the USB device to search. + * @returns {Promise|Error>} List of available (hexadecimal) endpoints on a USB device's interface. + * + * @memberof qz.usb + */ + listEndpoints: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + interface: arguments[2] + }; + } + + return _qz.websocket.dataPromise('usb.listEndpoints', deviceInfo); + }, + + /** + * List of functions called for any response from open usb devices. + * Event data will contain {string} vendorId and {string} productId for all types. + * For RECEIVE types, {Array} output (in hexadecimal format). + * For ERROR types, {string} exception. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * + * @memberof qz.usb + */ + setUsbCallbacks: function(calls) { + _qz.usb.usbCallbacks = calls; + }, + + /** + * Claim a USB device's interface to enable sending/reading data across an endpoint. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.interface Hex string of interface on the USB device to claim. + * @returns {Promise} + * + * @memberof qz.usb + */ + claimDevice: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + interface: arguments[2] + }; + } + + return _qz.websocket.dataPromise('usb.claimDevice', deviceInfo); + }, + + /** + * Check the current claim state of a USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @returns {Promise} + * + * @since 2.0.2 + * @memberOf qz.usb + */ + isClaimed: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('usb.isClaimed', deviceInfo); + }, + + /** + * Send data to a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @param deviceInfo.data Bytes to send over specified endpoint. + * @param {string} [deviceInfo.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @returns {Promise} + * + * @memberof qz.usb + */ + sendData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2], + data: arguments[3] + }; + } + + if (_qz.tools.versionCompare(2, 1, 0, 12) >= 0) { + if (typeof deviceInfo.data !== 'object') { + deviceInfo.data = { + data: deviceInfo.data, + type: "PLAIN" + } + } + + if (deviceInfo.data.type && deviceInfo.data.type.toUpperCase() == "FILE") { + deviceInfo.data.data = _qz.tools.absolute(deviceInfo.data.data); + } + } + + return _qz.websocket.dataPromise('usb.sendData', deviceInfo); + }, + + /** + * Read data from a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @returns {Promise|Error>} List of (hexadecimal) bytes received from the USB device. + * + * @memberof qz.usb + */ + readData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2], + responseSize: arguments[3] + }; + } + + return _qz.websocket.dataPromise('usb.readData', deviceInfo); + }, + + /** + * Provides a continuous stream of read data from a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @param deviceInfo.interval=100 Frequency to send read data back, in milliseconds. + * @returns {Promise} + * + * @see qz.usb.setUsbCallbacks + * + * @memberof qz.usb + */ + openStream: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2], + responseSize: arguments[3], + interval: arguments[4] + }; + } + + return _qz.websocket.dataPromise('usb.openStream', deviceInfo); + }, + + /** + * Stops the stream of read data from a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @returns {Promise} + * + * @memberof qz.usb + */ + closeStream: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2] + }; + } + + return _qz.websocket.dataPromise('usb.closeStream', deviceInfo); + }, + + /** + * Release a claimed USB device to free resources after sending/reading data. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @returns {Promise} + * + * @memberof qz.usb + */ + releaseDevice: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('usb.releaseDevice', deviceInfo); + } + }, + + + /** + * Calls related to interaction with HID USB devices
    + * Many of these calls can be accomplished from the qz.usb namespace, + * but HID allows for simpler interaction + * @namespace qz.hid + * @since 2.0.1 + */ + hid: { + /** + * List of available HID devices. Includes (hexadecimal) vendor ID and (hexadecimal) product ID. + * If available, also returns manufacturer and product descriptions. + * + * @returns {Promise|Error>} Array of JSON objects containing information on connected HID devices. + * @since 2.0.1 + * + * @memberof qz.hid + */ + listDevices: function() { + return _qz.websocket.dataPromise('hid.listDevices'); + }, + + /** + * Start listening for HID device actions, such as attach / detach events. + * Reported under the ACTION type in the streamEvent on callbacks. + * + * @returns {Promise} + * @since 2.0.1 + * + * @see qz.hid.setHidCallbacks + * + * @memberof qz.hid + */ + startListening: function() { + return _qz.websocket.dataPromise('hid.startListening'); + }, + + /** + * Stop listening for HID device actions. + * + * @returns {Promise} + * @since 2.0.1 + * + * @see qz.hid.setHidCallbacks + * + * @memberof qz.hid + */ + stopListening: function() { + return _qz.websocket.dataPromise('hid.stopListening'); + }, + + /** + * List of functions called for any response from open usb devices. + * Event data will contain {string} vendorId and {string} productId for all types. + * For RECEIVE types, {Array} output (in hexadecimal format). + * For ERROR types, {string} exception. + * For ACTION types, {string} actionType. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * @since 2.0.1 + * + * @memberof qz.hid + */ + setHidCallbacks: function(calls) { + _qz.hid.hidCallbacks = calls; + }, + + /** + * Claim a HID device to enable sending/reading data across. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + claimDevice: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.claimDevice', deviceInfo); + }, + + /** + * Check the current claim state of a HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * + * @since 2.0.2 + * @memberOf qz.hid + */ + isClaimed: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.isClaimed', deviceInfo); + }, + + /** + * Send data to a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.data Bytes to send over specified endpoint. + * @param deviceInfo.endpoint=0x00 First byte of the data packet signifying the HID report ID. + * Must be 0x00 for devices only supporting a single report. + * @param deviceInfo.reportId=0x00 Alias for deviceInfo.endpoint. Not used if endpoint is provided. + * @param {string} [deviceInfo.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + sendData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + data: arguments[2], + endpoint: arguments[3] + }; + } + + if (_qz.tools.versionCompare(2, 1, 0, 12) >= 0) { + if (typeof deviceInfo.data !== 'object') { + deviceInfo.data = { + data: deviceInfo.data, + type: "PLAIN" + } + } + + if (deviceInfo.data.type && deviceInfo.data.type.toUpperCase() == "FILE") { + deviceInfo.data.data = _qz.tools.absolute(deviceInfo.data.data); + } + } else { + if (typeof deviceInfo.data === 'object') { + if (deviceInfo.data.type.toUpperCase() !== "PLAIN" + || typeof deviceInfo.data.data !== "string") { + return _qz.tools.reject(new Error("Data format is not supported with connected QZ Tray version " + _qz.websocket.connection.version)); + } + + deviceInfo.data = deviceInfo.data.data; + } + } + + return _qz.websocket.dataPromise('hid.sendData', deviceInfo); + }, + + /** + * Read data from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @returns {Promise|Error>} List of (hexadecimal) bytes received from the HID device. + * @since 2.0.1 + * + * @memberof qz.hid + */ + readData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + responseSize: arguments[2] + }; + } + + return _qz.websocket.dataPromise('hid.readData', deviceInfo); + }, + + /** + * Send a feature report to a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.data Bytes to send over specified endpoint. + * @param deviceInfo.endpoint=0x00 First byte of the data packet signifying the HID report ID. + * Must be 0x00 for devices only supporting a single report. + * @param deviceInfo.reportId=0x00 Alias for deviceInfo.endpoint. Not used if endpoint is provided. + * @param {string} [deviceInfo.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @returns {Promise} + * + * @memberof qz.hid + */ + sendFeatureReport: function(deviceInfo) { + return _qz.websocket.dataPromise('hid.sendFeatureReport', deviceInfo); + }, + + /** + * Get a feature report from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @returns {Promise|Error>} List of (hexadecimal) bytes received from the HID device. + * + * @memberof qz.hid + */ + getFeatureReport: function(deviceInfo) { + return _qz.websocket.dataPromise('hid.getFeatureReport', deviceInfo); + }, + + /** + * Provides a continuous stream of read data from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @param deviceInfo.interval=100 Frequency to send read data back, in milliseconds. + * @returns {Promise} + * @since 2.0.1 + * + * @see qz.hid.setHidCallbacks + * + * @memberof qz.hid + */ + openStream: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + responseSize: arguments[2], + interval: arguments[3] + }; + } + + return _qz.websocket.dataPromise('hid.openStream', deviceInfo); + }, + + /** + * Stops the stream of read data from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + closeStream: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.closeStream', deviceInfo); + }, + + /** + * Release a claimed HID device to free resources after sending/reading data. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + releaseDevice: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.releaseDevice', deviceInfo); + } + }, + + + /** + * Calls related to interactions with the filesystem + * @namespace qz.file + * @since 2.1 + */ + file: { + /** + * List of files available at the given directory.
    + * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute directory path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @returns {Promise|Error>} Array of files at the given path + * + * @memberof qz.file + */ + list: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.list', param); + }, + + /** + * Reads contents of file at the given path.
    + * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute file path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @param {string} [params.flavor='plain'] Flavor of data format used, valid flavors are [base64 | hex | plain]. + * @returns {Promise} String containing the file contents + * + * @memberof qz.file + */ + read: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.read', param); + }, + + /** + * Writes data to the file at the given path.
    + * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute file path. Must reside in qz data directory or a white-listed location. + * @param {Object} params Object containing file access parameters + * @param {string} params.data File data to be written + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @param {boolean} [params.append=false] Appends to the end of the file if set, otherwise overwrites existing contents + * @param {string} [params.flavor='plain'] Flavor of data format used, valid flavors are [base64 | file | hex | plain]. + * @returns {Promise} + * + * @memberof qz.file + */ + write: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.write', param); + }, + + /** + * Deletes a file at given path.
    + * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute file path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @returns {Promise} + * + * @memberof qz.file + */ + remove: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.remove', param); + }, + + /** + * Provides a continuous stream of events (and optionally data) from a local file. + * + * @param {string} path Relative or absolute directory path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @param {Object} [params.listener] If defined, file data will be returned on events + * @param {number} [params.listener.bytes=-1] Number of bytes to return or -1 for all + * @param {number} [params.listener.lines=-1] Number of lines to return or -1 for all + * @param {boolean} [params.listener.reverse] Controls whether data should be returned from the bottom of the file. Default value is true for line mode and false for byte mode. + * @param {string|Array} [params.include] File patterns to match. Blank values will be ignored. + * @param {string|Array} [params.exclude] File patterns to exclude. Blank values will be ignored. Takes priority over params.include. + * @param {boolean} [params.ignoreCase=true] Whether params.include or params.exclude are case-sensitive. + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.file.setFileCallbacks + * + * @memberof qz.file + */ + startListening: function(path, params) { + if (params && typeof params.include !== 'undefined' && !Array.isArray(params.include)) { + params.include = [params.include]; + } + if (params && typeof params.exclude !== 'undefined' && !Array.isArray(params.exclude)) { + params.exclude = [params.exclude]; + } + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.startListening', param); + }, + + /** + * Closes listeners with the provided settings. Omitting the path parameter will result in all listeners closing. + * + * @param {string} [path] Previously opened directory path of listener to close, or omit to close all. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @returns {Promise} + * + * @memberof qz.file + */ + stopListening: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.stopListening', param); + }, + + /** + * List of functions called for any response from a file listener. + * For ERROR types event data will contain, {string} message. + * For ACTION types event data will contain, {string} file {string} eventType {string} [data]. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * @since 2.1.0 + * + * @memberof qz.file + */ + setFileCallbacks: function(calls) { + _qz.file.fileCallbacks = calls; + } + }, + + /** + * Calls related to networking information + * @namespace qz.networking + * @since 2.1.0 + */ + networking: { + /** + * @param {string} [hostname] Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] Port to use with custom hostname, defaults to 443 + * @returns {Promise} Connected system's network information. + * + * @memberof qz.networking + * @since 2.1.0 + */ + device: function(hostname, port) { + // Wrap 2.0 + if (_qz.tools.isVersion(2, 0)) { + return _qz.compatible.networking(hostname, port, null, null, function(data) { + return { ip: data.ipAddress, mac: data.macAddress }; + }); + } + // Use 2.1 + return _qz.websocket.dataPromise('networking.device', { + hostname: hostname, + port: port + }); + }, + + /** + * Get computer hostname + * + * @param {string} [hostname] DEPRECATED Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] DEPRECATED Port to use with custom hostname, defaults to 443 + * @returns {Promise} Connected system's hostname. + * + * @memberof qz.networking + * @since 2.2.2 + */ + hostname: function(hostname, port) { + // Wrap < 2.2.2 + if (_qz.tools.versionCompare(2, 2, 2) < 0) { + return _qz.tools.promise(function(resolve, reject) { + _qz.websocket.dataPromise('networking.device', { hostname: hostname, port: port }).then(function(device) { + console.log(device); + resolve(device.hostname); + }); + }); + } else { + return _qz.websocket.dataPromise('networking.hostname'); + } + }, + + /** + * @param {string} [hostname] Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] Port to use with custom hostname, defaults to 443 + * @returns {Promise|Error>} Connected system's network information. + * + * @memberof qz.networking + * @since 2.1.0 + */ + devices: function(hostname, port) { + // Wrap 2.0 + if (_qz.tools.isVersion(2, 0)) { + return _qz.compatible.networking(hostname, port, null, null, function(data) { + return [{ ip: data.ipAddress, mac: data.macAddress }]; + }); + } + // Use 2.1 + return _qz.websocket.dataPromise('networking.devices', { + hostname: hostname, + port: port + }); + } + }, + + + /** + * Calls related to signing connection requests. + * @namespace qz.security + */ + security: { + /** + * Set promise resolver for calls to acquire the site's certificate. + * + * @param {Function|AsyncFunction|Promise} promiseHandler Either a function that will be used as a promise resolver (of format Function({function} resolve, {function}reject)), + * an async function, or a promise. Any of which should return the public certificate via their respective resolve call. + * @param {Object} [options] Configuration options for the certificate resolver + * @param {boolean} [options.rejectOnFailure=[false]] Overrides default behavior to call resolve with a blank certificate on failure. + * @memberof qz.security + */ + setCertificatePromise: function(promiseHandler, options) { + _qz.security.certHandler = promiseHandler; + _qz.security.rejectOnCertFailure = !!(options && options.rejectOnFailure); + }, + + /** + * Set promise factory for calls to sign API calls. + * + * @param {Function|AsyncFunction} promiseFactory Either a function that accepts a string parameter of the data to be signed + * and returns a function to be used as a promise resolver (of format Function({function} resolve, {function}reject)), + * or an async function that can take a string parameter of the data to be signed. Either of which should return the signed contents of + * the passed string parameter via their respective resolve call. + * + * @example + * qz.security.setSignaturePromise(function(dataToSign) { + * return function(resolve, reject) { + * $.ajax("/signing-url?data=" + dataToSign).then(resolve, reject); + * } + * }) + * + * @memberof qz.security + */ + setSignaturePromise: function(promiseFactory) { + _qz.security.signatureFactory = promiseFactory; + }, + + /** + * Set which signing algorithm QZ will check signatures against. + * + * @param {string} algorithm The algorithm used in signing. Valid values: [SHA1 | SHA256 | SHA512] + * @since 2.1.0 + * + * @memberof qz.security + */ + setSignatureAlgorithm: function(algorithm) { + //warn for incompatibilities if known + if (!_qz.compatible.algorithm()) { + return; + } + + if (["SHA1", "SHA256", "SHA512"].indexOf(algorithm.toUpperCase()) < 0) { + _qz.log.error("Signing algorithm '" + algorithm + "' is not supported."); + } else { + _qz.security.signAlgorithm = algorithm; + } + }, + + /** + * Get the signing algorithm QZ will be checking signatures against. + * + * @returns {string} The algorithm used in signing. + * @since 2.1.0 + * + * @memberof qz.security + */ + getSignatureAlgorithm: function() { + return _qz.security.signAlgorithm; + } + }, + + /** + * Calls related to compatibility adjustments + * @namespace qz.api + */ + api: { + /** + * Show or hide QZ api debugging statements in the browser console. + * + * @param {boolean} show Whether the debugging logs for QZ should be shown. Hidden by default. + * @returns {boolean} Value of debugging flag + * @memberof qz.api + */ + showDebug: function(show) { + return (_qz.DEBUG = show); + }, + + /** + * Get version of connected QZ Tray application. + * + * @returns {Promise} Version number of QZ Tray. + * + * @memberof qz.api + */ + getVersion: function() { + return _qz.websocket.dataPromise('getVersion'); + }, + + /** + * Checks for the specified version of connected QZ Tray application. + * + * @param {string|number} [major] Major version to check + * @param {string|number} [minor] Minor version to check + * @param {string|number} [patch] Patch version to check + * + * @memberof qz.api + */ + isVersion: _qz.tools.isVersion, + + /** + * Checks if the connected QZ Tray application is greater than the specified version. + * + * @param {string|number} major Major version to check + * @param {string|number} [minor] Minor version to check + * @param {string|number} [patch] Patch version to check + * @param {string|number} [build] Build version to check + * @returns {boolean} True if connected version is greater than the version specified. + * + * @memberof qz.api + * @since 2.1.0-4 + */ + isVersionGreater: function(major, minor, patch, build) { + return _qz.tools.versionCompare(major, minor, patch, build) > 0; + }, + + /** + * Checks if the connected QZ Tray application is less than the specified version. + * + * @param {string|number} major Major version to check + * @param {string|number} [minor] Minor version to check + * @param {string|number} [patch] Patch version to check + * @param {string|number} [build] Build version to check + * @returns {boolean} True if connected version is less than the version specified. + * + * @memberof qz.api + * @since 2.1.0-4 + */ + isVersionLess: function(major, minor, patch, build) { + return _qz.tools.versionCompare(major, minor, patch, build) < 0; + }, + + /** + * Change the promise library used by QZ API. + * Should be called before any initialization to avoid possible errors. + * + * @param {Function} promiser Function({function} resolver) called to create new promises. + * + * @memberof qz.api + */ + setPromiseType: function(promiser) { + _qz.tools.promise = promiser; + }, + + /** + * Change the SHA-256 hashing function used by QZ API. + * Should be called before any initialization to avoid possible errors. + * + * @param {Function} hasher Function({function} message) called to create hash of passed string. + * + * @memberof qz.api + */ + setSha256Type: function(hasher) { + _qz.tools.hash = hasher; + }, + + /** + * Change the WebSocket handler. + * Should be called before any initialization to avoid possible errors. + * + * @param {Function} ws Function({function} WebSocket) called to override the internal WebSocket handler. + * + * @memberof qz.api + */ + setWebSocketType: function(ws) { + _qz.tools.ws = ws; + } + }, + + /** + * Version of this JavaScript library + * + * @constant {string} + * + * @memberof qz + */ + version: _qz.VERSION + }; + + return qz; +})(); + + +(function() { + if (typeof define === 'function' && define.amd) { + define(qz); + } else if (typeof exports === 'object') { + module.exports = qz; + } else { + window.qz = qz; + } +})(); diff --git a/cantarire/static/js/weigh.js b/cantarire/static/js/weigh.js new file mode 100644 index 0000000..8cb4747 --- /dev/null +++ b/cantarire/static/js/weigh.js @@ -0,0 +1,577 @@ +/* Fluxul de cântărire a unui produs finit din 1..N componente. + * + * State machine: + * fără pachet -> alegi produsul finit și începi + * pas curent -> cântărești componenta (cântar sau manual), Enter acceptă + * ultimul pas -> serverul finalizează pachetul și întoarce eticheta + * finalizat -> eticheta se tipărește; Enter începe următorul pachet + */ +(function () { + 'use strict'; + + var cfg = window.CANTARIRE || {}; + var $ = function (id) { return document.getElementById(id); }; + + // Polling: rapid cat timp se cantareste, rar cand pagina doar asteapta. + var POLL_ACTIVE_MS = Number(cfg.pollMs) || 700; + var POLL_IDLE_MS = 2500; + var pollTimer = null; + var el = { + flow: $('flow-body'), + title: $('flow-title'), + progress: $('flow-progress'), + message: $('flow-message'), + weightValue: $('weight-value'), + weightUnit: $('weight-unit'), + stability: $('stability'), + age: $('age'), + raw: $('raw'), + scaleDot: $('scale-dot'), + scaleState: $('scale-state'), + scaleProfile: $('scale-profile'), + scaleError: $('scale-error'), + profileSelect: $('profile-select'), + toast: $('toast'), + frame: $('print-frame') + }; + + var state = cfg.initial || null; + var recipes = cfg.recipes || []; + var activeProfile = cfg.activeProfile || ''; + var scaleReady = Boolean(cfg.scaleReady); + var manualMode = !scaleReady; + var busy = false; + var polling = false; + var reading = null; + var forceArmedAt = 0; + var lastLabel = null; + var lastRecipeId = null; + var lastLot = ''; + var lastOperator = cfg.defaultOperator || ''; + var toastTimer = null; + + // -- utilitare ----------------------------------------------------------- + function esc(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); + } + + function fmt(value) { + var number = Number(value); + if (!isFinite(number)) return '--'; + var text = number.toFixed(3); + return text.replace(/\.?0+$/, '') || '0'; + } + + function message(text, kind) { + if (!el.message) return; + el.message.textContent = text || ''; + el.message.className = 'message ' + (kind || ''); + } + + function toast(text, kind) { + if (!el.toast) return; + el.toast.textContent = text; + el.toast.className = 'toast show ' + (kind || ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { el.toast.className = 'toast ' + (kind || ''); }, 3200); + } + + function setProgress(text) { + if (el.progress) el.progress.textContent = text || ''; + } + + function getJSON(url) { + return fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }) + .then(function (response) { return response.json(); }); + } + + function postJSON(url, body) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body || {}) + }).then(function (response) { + return response.json().then(function (data) { + return { status: response.status, data: data }; + }); + }); + } + + function setBusy(value) { + busy = value; + document.querySelectorAll('#flow-body button, #flow-body input, #flow-body select') + .forEach(function (node) { node.disabled = value; }); + } + + // -- stare --------------------------------------------------------------- + function adopt(data) { + if (!data) return; + if (data.state === null) { + state = null; + } else if (data.package) { + state = { + package: data.package, + recipe: data.recipe, + items: data.items || [], + step: data.step, + planned_total: data.planned_total, + total_weight: data.total_weight, + complete: Boolean(data.complete), + open: Boolean(data.open) + }; + if (data.recipe) lastRecipeId = data.recipe.id; + if (data.package) { + lastLot = data.package.lot || lastLot; + lastOperator = data.package.operator || lastOperator; + } + } + render(); + } + + async function refresh() { + try { + var data = await getJSON('/api/package'); + adopt(data); + } catch (error) { + message('Nu pot citi starea pachetului: ' + error, 'error'); + } + } + + // -- randare ------------------------------------------------------------- + function render() { + if (!el.flow) return; + if (!state) { + renderStart(); + } else if (state.complete) { + renderDone(); + } else { + renderStep(); + } + } + + function recipeOptions(selected) { + return recipes.map(function (recipe) { + var suffix = ' · ' + recipe.components.length + ' comp.'; + return ''; + }).join(''); + } + + function breakdown(recipe) { + return (recipe.components || []).map(function (part) { + return '

  • ' + (part.target_weight ? fmt(part.target_weight) + ' ' + esc(recipe.unit) : '—') + + ' ' + esc(part.name) + '
  • '; + }).join(''); + } + + function renderStart() { + if (el.title) el.title.textContent = 'Pachet nou'; + setProgress(''); + + if (!recipes.length) { + el.flow.innerHTML = '

    Nu există produse finite active. ' + + 'Creează unul.

    '; + return; + } + + var selected = lastRecipeId || (recipes[0] && recipes[0].id) || null; + var recipe = recipes.filter(function (r) { return r.id === selected; })[0] || recipes[0]; + + el.flow.innerHTML = + '
    ' + + ' ' + + ' ' + + '
    ' + + '
      ' + breakdown(recipe) + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + '

    Enter pornește pachetul. Apoi cântărești fiecare componentă în ordinea de mai sus.

    '; + + var select = $('start-recipe'); + if (select) { + select.addEventListener('change', function () { + var chosen = recipes.filter(function (r) { return String(r.id) === select.value; })[0]; + if (chosen) { + lastRecipeId = chosen.id; + var plan = $('start-plan'); + if (plan) plan.innerHTML = breakdown(chosen); + } + }); + } + var button = $('btn-start'); + if (button) button.addEventListener('click', startPackage); + } + + function renderStep() { + var step = state.step; + if (!step) { refresh(); return; } + if (el.title) el.title.textContent = state.recipe ? state.recipe.name : 'Cântărire'; + setProgress('Pas ' + step.position + ' din ' + step.total); + + var accepted = (state.items || []).map(function (item) { + return '
  • ' + fmt(item.weight) + ' ' + esc(item.unit) + ' ' + + esc(item.component_name) + '
  • '; + }).join(''); + + el.flow.innerHTML = + '
    ' + + '
    Pune pe cântar
    ' + + '
    ' + esc(step.component_name) + '
    ' + + (step.target_weight + ? '
    Țintă: ' + fmt(step.target_weight) + ' ' + esc(step.unit) + '
    ' + : '') + + '
    ' + + (manualMode + ? '
    ' + + '' + + '
    ' + : '
    ') + + '
    ' + + ' ' + + (accepted ? ' ' : '') + + ' ' + + '
    ' + + (accepted ? '

    Acceptate

      ' + accepted + '
    ' : ''); + + if (manualMode) { + var input = $('manual-weight'); + if (input) { input.focus(); input.select(); } + } + var accept = $('btn-accept'); + if (accept) accept.addEventListener('click', acceptStep); + var undo = $('btn-undo'); + if (undo) undo.addEventListener('click', undoStep); + var voidButton = $('btn-void'); + if (voidButton) voidButton.addEventListener('click', voidPackage); + var toggle = $('btn-manual-toggle'); + if (toggle) { + toggle.addEventListener('click', function () { + manualMode = true; + renderStep(); + }); + } + } + + function renderDone() { + var pkg = state.package || {}; + if (el.title) el.title.textContent = pkg.recipe_name || 'Pachet finalizat'; + setProgress('Finalizat'); + + var accepted = (state.items || []).map(function (item) { + return '
  • ' + fmt(item.weight) + ' ' + esc(item.unit) + ' ' + + esc(item.component_name) + '
  • '; + }).join(''); + + el.flow.innerHTML = + '
    ' + + '
    ✓
    ' + + '
    ' + fmt(pkg.total_weight) + '' + + ' ' + esc(pkg.unit) + '
    ' + + '
    Pachet #' + pkg.id + (pkg.lot ? ' · Lot ' + esc(pkg.lot) : '') + '
    ' + + '
    ' + + '
      ' + accepted + '
    ' + + '
    ' + + ' ' + + (lastLabel ? ' ' : '') + + ' Vezi eticheta' + + '
    '; + + var next = $('btn-next'); + if (next) next.addEventListener('click', startPackage); + var reprint = $('btn-reprint'); + if (reprint) { + reprint.addEventListener('click', function () { + if (lastLabel) printLabel(lastLabel); + }); + } + if (next) next.focus(); + } + + // -- acțiuni ------------------------------------------------------------- + async function startPackage() { + if (busy) return; + var select = $('start-recipe'); + var recipeId = select ? Number(select.value) : lastRecipeId; + if (!recipeId) { + toast('Alege produsul finit.', 'error'); + return; + } + + setBusy(true); + message('Se pregătește pachetul…', 'info'); + try { + var result = await postJSON('/api/packages', { + recipe_id: recipeId, + operator: ($('start-operator') ? $('start-operator').value.trim() : '') || lastOperator, + lot: ($('start-lot') ? $('start-lot').value.trim() : '') || lastLot, + // Serverul alege sablonul: produsul finit -> setarea implicita -> primul sablon. + template_id: null + }); + if (!result.data.ok) { + message(result.data.error || 'Pachetul nu a putut fi început.', 'error'); + toast(result.data.error || 'Eroare.', 'error'); + return; + } + lastLabel = null; + adopt(result.data); + message('Pachet început. Cântărește prima componentă.', 'info'); + } catch (error) { + message('Eroare de rețea: ' + error, 'error'); + } finally { + setBusy(false); + render(); + } + } + + function manualWeight() { + var input = $('manual-weight'); + if (!input) return null; + var raw = String(input.value || '').trim().replace(',', '.'); + return raw === '' ? null : raw; + } + + async function acceptStep() { + if (busy || !state || !state.package) return; + var force = forceArmedAt && Date.now() - forceArmedAt < 5000; + var body = {}; + if (force) body.force = true; + if (manualMode) { + var value = manualWeight(); + if (value === null) { + toast('Introdu greutatea sau conectează cântarul.', 'error'); + return; + } + body.weight = value; + } + + setBusy(true); + message('Se înregistrează…', 'info'); + try { + var result = await postJSON('/api/packages/' + state.package.id + '/items', body); + var data = result.data; + + if (!result.status || result.status >= 400 || !data.ok) { + if (data.reading) applyReading(data.reading); + var unstable = /stabil/i.test(data.error || ''); + if (unstable) { + forceArmedAt = Date.now(); + message((data.error || 'Greutate instabilă.') + + ' Apasă din nou pe Acceptă pentru a folosi valoarea curentă.', 'error'); + } else { + message(data.error || 'Cântărirea nu a putut fi salvată.', 'error'); + toast(data.error || 'Eroare la salvare.', 'error'); + } + return; + } + + forceArmedAt = 0; + adopt(data); + if (data.label) { + lastLabel = data.label; + if (cfg.autoPrint !== false) { + await printLabel(data.label); + message('Etichetă tipărită. Apasă Enter pentru următorul pachet.', 'success'); + } else { + message('Pachet finalizat. Apasă „Tipărește eticheta” sau Enter pentru următorul pachet.', 'success'); + } + updateStats(); + } else { + message('Acceptat: ' + fmt(state.total_weight) + ' ' + (state.package.unit || ''), 'success'); + } + } catch (error) { + message('Eroare de rețea la salvare.', 'error'); + console.error(error); + } finally { + setBusy(false); + render(); + } + } + + async function undoStep() { + if (busy || !state || !state.package) return; + setBusy(true); + try { + var result = await postJSON('/api/packages/' + state.package.id + '/undo', {}); + if (result.data.ok) { + adopt(result.data); + message('Ultima cântărire a fost anulată.', 'info'); + } else { + message(result.data.error || 'Nu se poate anula.', 'error'); + } + } finally { + setBusy(false); + render(); + } + } + + async function voidPackage() { + if (busy || !state || !state.package) return; + if (!confirm('Renunți la pachetul început? Cântăririle lui nu vor apărea în istoric.')) return; + setBusy(true); + try { + await postJSON('/api/packages/' + state.package.id + '/void', {}); + state = null; + lastLabel = null; + message('Pachet anulat.', 'info'); + } finally { + setBusy(false); + render(); + } + } + + // -- tipărire ------------------------------------------------------------ + function acknowledge(payload) { + if (!payload || !payload.ack_url) return; + fetch(payload.ack_url, { method: 'POST', headers: { Accept: 'application/json' } }) + .catch(function () { /* confirmarea este auxiliară */ }); + } + + function printLabel(payload) { + if (!payload) return Promise.resolve(); + + if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') { + return Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) { + if (result && result.method === 'qz') acknowledge(payload); + return result; + }).catch(function (error) { + console.error('Tipărirea a eșuat:', error); + toast('Imprimanta nu a răspuns.', 'error'); + }); + } + + if (el.frame) { + el.frame.src = payload.print_url + '?auto=1'; + return Promise.resolve(); + } + window.open(payload.print_url, '_blank', 'noopener'); + return Promise.resolve(); + } + + function updateStats() { + var node = $('stat-today-count'); + if (!node) return; + var value = Number(node.textContent) || 0; + node.textContent = String(value + 1); + } + + // -- citirea cântarului -------------------------------------------------- + function applyReading(data) { + reading = data; + if (!data) return; + var ok = Boolean(data.ok); + var stale = Boolean(data.stale); + var stable = Boolean(data.stable); + var hasValue = data.value !== null && data.value !== undefined; + + if (el.weightValue) { + el.weightValue.textContent = hasValue ? fmt(data.value) : '--'; + el.weightValue.className = !ok ? 'error' : (stable && !stale ? '' : 'unstable'); + } + if (el.weightUnit) el.weightUnit.textContent = data.unit || ''; + + if (!ok) { + if (el.scaleDot) el.scaleDot.className = 'dot off'; + if (el.scaleState) el.scaleState.textContent = 'Cântar indisponibil'; + if (el.stability) { el.stability.textContent = 'fără date'; el.stability.className = 'badge off'; } + if (el.scaleError) el.scaleError.textContent = data.error || 'Cântarul nu poate fi citit.'; + } else if (stale) { + if (el.scaleDot) el.scaleDot.className = 'dot off'; + if (el.scaleState) el.scaleState.textContent = 'Citire veche'; + if (el.stability) { el.stability.textContent = 'citire veche'; el.stability.className = 'badge warn'; } + if (el.scaleError) el.scaleError.textContent = ''; + } else { + if (el.scaleDot) el.scaleDot.className = 'dot ok'; + if (el.scaleState) el.scaleState.textContent = 'Cântar conectat'; + if (el.stability) { + el.stability.textContent = stable ? 'stabil' : 'instabil'; + el.stability.className = 'badge ' + (stable ? 'ok' : 'warn'); + } + if (el.scaleError) el.scaleError.textContent = ''; + } + + if (el.scaleProfile) el.scaleProfile.textContent = (data.profile || activeProfile || 'fără profil') + + (data.port ? ' · ' + data.port : ''); + if (el.age) { + el.age.textContent = typeof data.age_ms === 'number' + ? (data.age_ms < 1000 ? '<1 s' : (data.age_ms / 1000).toFixed(1) + ' s') + : ''; + } + if (el.raw) el.raw.textContent = data.raw_hex ? 'raw: ' + data.raw_hex.slice(0, 40) : ''; + } + + function poll() { + if (!scaleReady || polling || document.hidden) return; + polling = true; + var controller = new AbortController(); + var timer = setTimeout(function () { controller.abort(); }, 6000); + var params = new URLSearchParams(); + if (activeProfile) params.set('profile', activeProfile); + + fetch('/api/weight?' + params.toString(), { + headers: { Accept: 'application/json' }, cache: 'no-store', signal: controller.signal + }) + .then(function (response) { return response.json(); }) + .then(applyReading) + .catch(function (error) { + if (error && error.name !== 'AbortError') { + if (el.scaleDot) el.scaleDot.className = 'dot off'; + if (el.scaleState) el.scaleState.textContent = 'Conexiune întreruptă'; + } + }) + .finally(function () { clearTimeout(timer); polling = false; }); + } + + // -- tastatură ----------------------------------------------------------- + function onKeyDown(event) { + if (event.key !== 'Enter' || event.ctrlKey || event.metaKey) return; + var tag = (event.target && event.target.tagName || '').toLowerCase(); + if (tag === 'textarea' || tag === 'button' || tag === 'a') return; + + if (!state) { + event.preventDefault(); + startPackage(); + } else if (state.complete) { + event.preventDefault(); + startPackage(); + } else if (state.step) { + event.preventDefault(); + acceptStep(); + } + } + + // -- start --------------------------------------------------------------- + function schedulePoll() { + clearTimeout(pollTimer); + var delay = (state && state.open && state.step) ? POLL_ACTIVE_MS : POLL_IDLE_MS; + pollTimer = setTimeout(function () { + poll(); + schedulePoll(); + }, delay); + } + + if (el.profileSelect) { + el.profileSelect.addEventListener('change', function () { + activeProfile = el.profileSelect.value; + poll(); + }); + } + document.addEventListener('keydown', onKeyDown); + document.addEventListener('visibilitychange', function () { + if (!document.hidden) poll(); + }); + + render(); + if (scaleReady) { + poll(); + schedulePoll(); + } +})(); diff --git a/cantarire/templates/base.html b/cantarire/templates/base.html new file mode 100644 index 0000000..c5d6ce6 --- /dev/null +++ b/cantarire/templates/base.html @@ -0,0 +1,77 @@ + + + + + + {% block title %}Cântărire și etichetare{% endblock %} + + {% block head %}{% endblock %} + + +{% set admin_blueprints = ['history', 'recipes', 'components', 'label_templates', 'fields', + 'monitor'] %} +{% set in_settings = request.blueprint in admin_blueprints + or request.endpoint == 'main.settings_page' %} +
    + + CE + Cântărire & Etichetare + + + +
    + +{% if in_settings %} + +{% endif %} + +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
    {{ message }}
    + {% endfor %} +
    + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
    + +
    + Cântărire & Etichetare v{{ app_version }} + Produse finite · SyncPulse Scale Bridge · QZ Tray +
    + +{% block scripts %}{% endblock %} + + + + diff --git a/cantarire/templates/components/form.html b/cantarire/templates/components/form.html new file mode 100644 index 0000000..af3f0d4 --- /dev/null +++ b/cantarire/templates/components/form.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}{{ "Component nou" if is_new else "Editare component" }}{% endblock %} + +{% block content %} +
    +
    +

    {{ "Component nou" if is_new else "Editare component" }}

    +

    Numele apare pe etichetă, lângă greutate.

    +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + + Renunță +
    +
    +
    +{% endblock %} diff --git a/cantarire/templates/components/list.html b/cantarire/templates/components/list.html new file mode 100644 index 0000000..879c0c3 --- /dev/null +++ b/cantarire/templates/components/list.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Componente{% endblock %} + +{% block content %} +
    +
    +

    Componente

    +

    Ce se cântărește: fasole, carne, garnitură etc.

    +
    + + Component nou +
    + +
    + {% if components %} +
    + + + + + + {% for c in components %} + + + + + + + + + {% endfor %} + +
    CodDenumireUMDescriereStare
    {{ c.code }}{{ c.name }}{{ c.unit }}{{ c.description or "-" }} + {% if c.active %}activ + {% else %}inactiv{% endif %} + + Editează +
    + +
    +
    +
    + {% else %} +

    Nu există componente. Adaugă primul component.

    + {% endif %} +
    +{% endblock %} diff --git a/cantarire/templates/dashboard.html b/cantarire/templates/dashboard.html new file mode 100644 index 0000000..14e76cb --- /dev/null +++ b/cantarire/templates/dashboard.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} +{% block title %}Cântărire produs finit{% endblock %} + +{% block content %} +
    +
    +

    Cântărire produs finit

    +

    Alege produsul, cântărește componentele în ordine și tipărește eticheta.

    +
    +
    +
    {{ stats.today_count }}pachete azi
    +
    {{ stats.today_total }}total azi
    +
    {{ stats.recipes }}produse finite
    +
    +
    + +{% if not setup.ready %} +
    +
    +
    +

    Pregătire pentru prima cântărire

    +

    Completează pe rând; poți cântări după ce lista e goală.

    +
    + {{ setup.missing|length }} de completat +
    +
      + {% for item in setup.checks %} + {% if not item.ok and not item.optional %} +
    • + +
      + {{ item.label }} + {{ item.message }} +
      + {% if item.url %} + {{ item.action or 'Deschide' }} + {% endif %} +
    • + {% endif %} + {% endfor %} +
    +
    +{% endif %} + +
    +
    +
    +

    Pachet nou

    + +
    +
    +

    +
    + +
    +
    + + {{ "Se conectează…" if scale_ready else "Introducere manuală" }} + {{ active_profile or "fără profil" }} +
    + +
    + -- + +
    + +
    + — + + +
    + +

    {% if scale_error %}{{ scale_error }}{% endif %}

    + + {% if profiles %} +
    + Profil: + +
    + {% else %} +
    + Cântarul nu este configurat — cântărește manual. + Monitor +
    + {% endif %} + +
    + Imprimantă: + + + + +
    +
    +
    + +
    +
    +

    Ultimele pachete

    + Vezi tot istoricul → +
    + {% if recent %} +
    + + + + + + {% for p in recent %} + + + + + + + + + + {% endfor %} + +
    FinalizatProdus finitComponenteTotalLotTipărit
    {{ (p.completed_at or p.created_at)|dt }}{{ p.recipe_code }} · {{ p.recipe_name }}{{ p.item_summary or "-" }}{{ (p.total_weight or 0)|weight }} {{ p.unit }}{{ p.lot or "-" }}{{ "da" if p.printed_at else "nu" }} + {% if p.status == "done" %} + Etichetă + {% endif %} +
    +
    + {% else %} +

    Niciun pachet finalizat încă.

    + {% endif %} +
    + + +
    +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/cantarire/templates/fields/form.html b/cantarire/templates/fields/form.html new file mode 100644 index 0000000..6bf0f77 --- /dev/null +++ b/cantarire/templates/fields/form.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} +{% block title %}{{ "Câmp nou" if is_new else "Editare câmp" }}{% endblock %} + +{% block content %} +
    +
    +

    {{ "Câmp nou" if is_new else "Editare câmp" }}

    +

    + Scrie textul etichetei; între acolade poți referi un câmp existent. +

    +
    + Înapoi la listă +
    + +
    +
    +
    + + +

    Apare în popup-ul din designer.

    +
    + +
    + + +

    + Poți folosi orice câmp din tabelul de mai jos, scris între {{ '{{' }} și + {{ '}}' }}. +

    +
    + +
    + +
    —
    +
    + + + +
    + + Renunță +
    +
    +
    + +
    +

    Câmpuri disponibile

    +

    Click pe un câmp pentru a-l insera în text.

    +
    + {% for c in catalog %} + + {% endfor %} +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/cantarire/templates/fields/list.html b/cantarire/templates/fields/list.html new file mode 100644 index 0000000..cb25b63 --- /dev/null +++ b/cantarire/templates/fields/list.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Câmpuri variabile{% endblock %} + +{% block content %} +
    +
    +

    Câmpuri variabile

    +

    + Text gata de pus pe etichetă, care poate prelua date din pachet, produs sau firmă. +

    +
    + + Câmp nou +
    + +
    + {% if fields %} +
    + + + + + + + + + + + + + + {% for f in fields %} + + + + + + + + + + {% endfor %} + +
    DenumireTextRezultat (demo)CheieȘabloaneStare
    {{ f.label }}{{ f.template }}{{ f.preview or "-" }}{{ f.key }} + {% if f.usage %}{{ f.usage }} + {% else %}-{% endif %} + + {% if f.active %}activ + {% else %}inactiv{% endif %} + + Editează +
    + +
    +
    +
    + {% else %} +

    + Nu există câmpuri proprii. Adaugă primul câmp + sau folosește câmpurile fixe din designer. +

    + {% endif %} +
    + +
    +

    Cum funcționează

    +

    + În textul câmpului poți scrie orice, iar între acolade poți pune un câmp existent: + Tel. {{ '{{company.phone}}' }} sau + {{ '{{recipe.name}}' }} · lot {{ '{{package.lot}}' }}. + La tipărire, acestea sunt înlocuite cu datele reale. Câmpurile proprii apar în designer + în grupul Câmpuri proprii. +

    +
    +{% endblock %} diff --git a/cantarire/templates/history/list.html b/cantarire/templates/history/list.html new file mode 100644 index 0000000..7569729 --- /dev/null +++ b/cantarire/templates/history/list.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% block title %}Istoric produse finite{% endblock %} + +{% block content %} +
    +
    +

    Istoric produse finite

    +

    Filtrează, verifică și exportă pachetele finalizate.

    +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Resetează +
    +
    +
    + +
    +
    +

    {{ packages|length }} pachete · total {{ total }}

    + Export CSV +
    + + {% if packages %} +
    + + + + + + + + + {% for p in packages %} + + + + + + + + + + + + {% endfor %} + +
    DataProdus finitComponenteTotalLotOperatorStareTipărit
    {{ (p.completed_at or p.created_at)|dt }}{{ p.recipe_code }} · {{ p.recipe_name }}{{ p.item_summary or "-" }}{{ (p.total_weight or 0)|weight }} {{ p.unit }}{{ p.lot or "-" }}{{ p.operator or "-" }} + {% if p.status == "done" %}finalizat + {% elif p.status == "open" %}în lucru + {% else %}anulat{% endif %} + + {% if p.printed_at %}{{ p.print_count }}× + {% else %}—{% endif %} + + {% if p.status == "done" %} + Etichetă + {% endif %} +
    +
    + {% else %} +

    Nicio înregistrare pentru filtrele selectate.

    + {% endif %} +
    +{% endblock %} diff --git a/cantarire/templates/label_print.html b/cantarire/templates/label_print.html new file mode 100644 index 0000000..94dc510 --- /dev/null +++ b/cantarire/templates/label_print.html @@ -0,0 +1,89 @@ + + + + + + Etichetă {{ payload.fields.recipe_code }} · {{ payload.fields.total_weight }} {{ payload.fields.unit }} + + + + {% if preview %} +
    + Previzualizare șablon + {{ payload.template.name }} · {{ payload.template.width_mm|weight }} × {{ payload.template.height_mm|weight }} mm + +
    + {% endif %} + +
    +
    + {{ payload.html|safe }} +
    +
    + + + + + + + diff --git a/cantarire/templates/monitor.html b/cantarire/templates/monitor.html new file mode 100644 index 0000000..bf2be77 --- /dev/null +++ b/cantarire/templates/monitor.html @@ -0,0 +1,170 @@ +{% extends "base.html" %} +{% block title %}Monitor serial{% endblock %} + +{% block content %} +
    +
    +

    Monitor serial

    +

    Ascultă portul cântarului și află ce format trimite. Nu se scrie nimic către cântar.

    +
    +
    + +
    + Folosire: conectează cântarul, pornește o cântărire pe el (sau apasă Print) și rulează + monitorul. Dacă nu apar bytes, încearcă Scanează vitezele sau altă setare de linie. + O captură salvată aici este exact ce trebuie ca să adăugăm un protocol nou. +
    + +
    +
    +
    + + + + {% for p in ports %} + + {% endfor %} + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + {% if ports %} +

    + Porturi detectate: + {% for p in ports %}{{ p.device }}{% if p.chip %} ({{ p.chip }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %} +

    + {% else %} +

    Nu a fost detectat niciun port. Verifică adaptorul USB-RJ11 (driver, cablu).

    + {% endif %} +
    + +{% if error %} +
    {{ error }}
    +{% endif %} + +{% if saved_path %} +
    Cauptră salvată: {{ saved_path }}
    +{% endif %} + +{% if scan_hits %} +
    +

    Viteze cu date

    +
    + + + + {% for hit in scan_hits %} + + + + + + + {% endfor %} + +
    BaudFramingBytesProtocoale recunoscute
    {{ hit.baud }}{{ hit.framing }}{{ hit.bytes }} + {% if hit.guesses %} + {% for g in hit.guesses %}{{ g.protocol }} {% endfor %} + {% else %}necunoscut{% endif %} +
    +
    +
    +{% endif %} + +{% if result %} +
    +
    +

    Rezultat: {{ result.port }} · {{ result.baud }} {{ result.framing }}

    + {{ result.bytes }} bytes +
    + + {% if result.guesses %} +

    Protocoale recunoscute

    +
    + + + + {% for g in result.guesses %} + + + + + + + + {% endfor %} + +
    IDNumeTipValoareCadre
    {{ g.protocol }}{{ g.name }}{{ g.kind }}{{ g.value }} {{ g.unit }}{{ g.count }}
    +
    + {% else %} +

    Niciun protocol cunoscut nu recunoaște datele. Salvează captura și o folosim ca să scriem parserul.

    + {% endif %} + +

    Cadre brute ({{ result.frames|length }})

    + {% if result.frames %} +
    + + + + {% for f in result.frames %} + + + + + + + {% endfor %} + +
    AcumBytesHexText
    {{ f.age_ms }} ms{{ f.size }}{{ f.hex }}{{ f.text }}
    +
    + {% else %} +

    Niciun byte primit. Cântarul poate fi setat să nu trimită nimic pe această interfață.

    + {% endif %} +
    +{% endif %} + +
    +

    Setări de încercat

    +
      +
    • 9600 8N1 — cel mai comun pentru cântare de magazin.
    • +
    • 9600 7E1 / 4800 7E1 — frecvent la indicatoarele vechi.
    • +
    • 9600 7O1 — dacă textul apare cu bitul 8 schimbat.
    • +
    • Unele cântare trimit doar la apăsarea Print: ține monitorul pornit și apasă.
    • +
    +

    Cablaj modular (RJ11 / 10P4C)

    +

    + Un adaptor USB–RJ11 funcționează doar dacă cei patru ași ai prizei cântarului sunt + TX, RX, GND la nivel RS-232 și adaptorul are aceeași ordine. Verifică pinoutul în manualul + BC800 înainte de a conecta; unele prize modulare de cântar sunt TTL sau current-loop și nu se leagă + direct la un adaptor RS-232. +

    +
    +{% endblock %} diff --git a/cantarire/templates/recipes/form.html b/cantarire/templates/recipes/form.html new file mode 100644 index 0000000..d225b2f --- /dev/null +++ b/cantarire/templates/recipes/form.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}{{ "Produs finit nou" if is_new else "Editare produs finit" }}{% endblock %} + +{% block content %} +
    +
    +

    {{ "Produs finit nou" if is_new else "Editare produs finit" }}

    +

    Ordinea componentelor este ordinea în care se cântăresc.

    +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + +

    Doar orientativă; eticheta afișează greutatea reală cântărită.

    +
    + +

    Etichetă

    +
    + + +

    + {% if templates %} + Eticheta acestui produs finit se randează cu șablonul ales aici. + Vezi șabloanele · + creează unul nou + {% else %} + Nu există șabloane. Creează întâi un șablon, + altfel eticheta nu poate fi tipărită. + {% endif %} +

    +
    + +

    Componente

    +

    Completează cel puțin primul rând. Ordinea rândurilor = ordinea cântăririi.

    + + {% for row in slot_rows %} +
    + {{ row.slot }} +
    + + +
    +
    + + +
    +
    + {% endfor %} + +
    + + +
    + + + +
    + + Renunță +
    +
    +
    + + +
    +{% endblock %} diff --git a/cantarire/templates/recipes/list.html b/cantarire/templates/recipes/list.html new file mode 100644 index 0000000..afad7c5 --- /dev/null +++ b/cantarire/templates/recipes/list.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Produse finite{% endblock %} + +{% block content %} +
    +
    +

    Produse finite

    +

    Rețetele care se cântăresc și se etichetează.

    +
    + + Produs finit +
    + +
    + {% if recipes %} +
    + + + + + + {% for r in recipes %} + + + + + + + + + + {% endfor %} + +
    CodDenumireComponenteȚintăEtichetăStare
    {{ r.code }}{{ r.name }}{{ r.component_count }}{{ (r.target_weight|weight ~ ' ' ~ r.unit) if r.target_weight else "—" }} + {% if r.template_name %}{{ r.template_name }} + {% else %}implicit{% endif %} + + {% if r.active %}activ + {% else %}inactiv{% endif %} + + Editează +
    + +
    +
    +
    + {% else %} +

    Nu există produse finite. Creează primul.

    + {% endif %} +
    +{% endblock %} diff --git a/cantarire/templates/settings.html b/cantarire/templates/settings.html new file mode 100644 index 0000000..c0cc559 --- /dev/null +++ b/cantarire/templates/settings.html @@ -0,0 +1,194 @@ +{% extends "base.html" %} +{% block title %}Setări{% endblock %} + +{% block content %} +
    +
    +

    Setări

    +

    + Datele firmei, cântarul și imprimanta; de aici ajungi la evidențele aplicației. +

    +
    +
    + + + +
    +
    +
    +

    Date firmă

    +
    + + +
    +
    + + +
    +
    + + +
    + +

    Cântar

    +
    + + + {% if not profiles %} +

    + Nu există profile. Configurează cântarul în SyncPulse Scale Bridge sau folosește + monitorul serial. +

    + {% endif %} +
    + +

    Etichete

    +
    + + +
    +
    + + +
    + + +
    + +
    +
    +
    + + +
    +{% endblock %} diff --git a/cantarire/templates/templates/designer.html b/cantarire/templates/templates/designer.html new file mode 100644 index 0000000..a1d9281 --- /dev/null +++ b/cantarire/templates/templates/designer.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block title %}Designer etichetă · {{ template.name }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    + +
    +
    +
    + + + +
    +

    + Trage elementele pe etichetă. Previzualizarea folosește date demo; la tipărire se folosesc datele reale. +

    +
    +
    + nesalvat + Previzualizare + +
    +
    + +
    +
    +

    Adaugă element

    +
    + + + + + +
    +
    + +
    +

    Etichetă (mm)

    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +

    Zoom

    + +
    + +
    +

    Zonă sigură

    + +

    Zona sigură: {{ safe_mm }} mm de la fiecare margine.

    + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    zonă sigură
    +
    +
    +
    +

    + Trage de element pentru mutare, de colțul din dreapta-jos pentru redimensionare. + Săgețile mută cu 0,5 mm (Shift: 2 mm). Delete șterge elementul. +

    +

    + Recomandat: păstrează toate elementele în interiorul conturului roșu — imprimantele + termice nu pot tipări până la marginea hârtiei. +

    +
    + + +
    + + + +
    +
    +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/cantarire/templates/templates/list.html b/cantarire/templates/templates/list.html new file mode 100644 index 0000000..c6517a2 --- /dev/null +++ b/cantarire/templates/templates/list.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Șabloane etichete{% endblock %} + +{% block content %} +
    +
    +

    Șabloane etichete

    +

    Desenează eticheta în designer; fiecare produs finit poate folosi alt șablon.

    +
    +
    +
    + + +
    +
    +
    + +
    + {% if templates %} +
    + + + + + + {% for t in templates %} + + + + + + + + + {% endfor %} + +
    NumeDimensiuneElementeImplicitActualizat
    {{ t.name }}{{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm + {% if t.design_json %} + designer + {% else %} + fără design + {% endif %} + {% if t.is_default %}implicit{% else %}—{% endif %}{{ t.updated_at|dt }} + Designer + Previzualizare +
    + +
    +
    +
    + {% else %} +

    Nu există șabloane. Creează unul.

    + {% endif %} +
    +{% endblock %} diff --git a/cantarire/tools/__init__.py b/cantarire/tools/__init__.py new file mode 100644 index 0000000..23a5757 --- /dev/null +++ b/cantarire/tools/__init__.py @@ -0,0 +1 @@ +"""Utilitare de linie de comanda.""" diff --git a/cantarire/tools/admin.py b/cantarire/tools/admin.py new file mode 100644 index 0000000..90a8673 --- /dev/null +++ b/cantarire/tools/admin.py @@ -0,0 +1,122 @@ +"""Utilitar de administrare: stare, backup, reset, verificare, date demo. + +Exemple: + + python -m cantarire.tools.admin status + python -m cantarire.tools.admin check + python -m cantarire.tools.admin backup --keep 10 + python -m cantarire.tools.admin reset --yes + python -m cantarire.tools.admin seed-demo +""" +from __future__ import annotations + +import argparse +import sys + +from cantarire import create_app +from cantarire import maintenance + + +def _status() -> int: + status = maintenance.database_status() + print(f"Baza de date : {status['path']}") + print(f"Marime : {status['size_human']}") + print(f"Integritate : {status['integrity']}") + print(f"Schema : v{status['schema_version']}") + print(f"Backup-uri : {status['backups']} in {status['backup_dir']}") + print("Inregistrari :") + for table, count in status["counts"].items(): + print(f" {table:<14} {count}") + return 0 + + +def _check(as_json: bool = False) -> int: + summary = maintenance.readiness_summary() + if as_json: + import json + + print(json.dumps(summary, indent=2, ensure_ascii=False, default=str)) + return 0 if summary["ready"] else 1 + + print("Verificare pregatire productie:\n") + for item in summary["checks"]: + mark = "OK " if item["ok"] else ("OPT " if item.get("optional") else "LIPSA") + print(f"[{mark}] {item['label']:<22} {item['message']}") + print() + if summary["ready"]: + print("Gata de cantarit: toate elementele obligatorii sunt completate.") + return 0 + missing = ", ".join(item["label"] for item in summary["missing"]) + print(f"De completat: {missing}") + return 1 + + +def _backup(keep: int) -> int: + path = maintenance.backup_database("manual") + removed = maintenance.prune_backups(keep) + print(f"Backup creat: {path}") + if removed: + print(f"Backup-uri vechi sterse: {removed} (pastrez ultimele {keep})") + return 0 + + +def _reset(confirmed: bool, keep_backup: bool) -> int: + if not confirmed: + print("Operatia goleste toate datele (componente, produse finite, pachete, sabloane).") + print("Ruleaz-o din nou cu --yes daca esti sigur.") + return 2 + result = maintenance.reset_database(keep_backup=keep_backup) + print(maintenance.reset_report(result)) + return 0 + + +def _seed_demo() -> int: + result = maintenance.seed_demo() + created = result["created"] + print(f"Componente create: {created['components']} · produse finite create: {created['recipes']}") + print(f"Componente: {', '.join(result['components']) or '-'}") + print(f"Produse finite: {', '.join(result['recipes']) or '-'}") + return 0 + + +def main(argv: list[str] | None = None, app=None) -> int: + parser = argparse.ArgumentParser( + prog="cantarire-admin", + description="Administrare: stare, backup, reset, verificare.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("status", help="starea bazei si numarul de inregistrari") + check = sub.add_parser("check", help="verifica daca aplicatia e gata de cantarit") + check.add_argument("--json", action="store_true", help="iesire JSON") + + backup = sub.add_parser("backup", help="creeaza un backup al bazei") + backup.add_argument("--keep", type=int, default=maintenance.KEEP_BACKUPS, + help="cate backup-uri pastreaza (implicit 20)") + + reset = sub.add_parser("reset", help="goleste datele si readuce setarile initiale") + reset.add_argument("--yes", action="store_true", help="confirma stergerea") + reset.add_argument("--no-backup", action="store_true", help="nu face backup inainte") + + sub.add_parser("seed-demo", help="adauga datele demo (instruire/testare)") + + args = parser.parse_args(argv) + + application = app or create_app() + with application.app_context(): + maintenance.ensure_instance_dirs() + if args.command == "status": + return _status() + if args.command == "check": + return _check(args.json) + if args.command == "backup": + return _backup(args.keep) + if args.command == "reset": + return _reset(args.yes, not args.no_backup) + if args.command == "seed-demo": + return _seed_demo() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cantarire/tools/monitor.py b/cantarire/tools/monitor.py new file mode 100644 index 0000000..d75c43e --- /dev/null +++ b/cantarire/tools/monitor.py @@ -0,0 +1,89 @@ +"""Utilitar CLI pentru identificarea unui cantar nou. + + python -m cantarire.tools.monitor --list + python -m cantarire.tools.monitor /dev/ttyUSB0 --seconds 8 + python -m cantarire.tools.monitor COM3 --scan --seconds 2 + python -m cantarire.tools.monitor COM3 --baud 9600 --framing 7E1 --save bizerba +""" +from __future__ import annotations + +import argparse +import sys + +from ..probe import BAUD_RATES, FRAMINGS, ProbeError, list_ports, probe_port, save_capture, sweep + + +def _print_ports() -> int: + ports = list_ports() + if not ports: + print("Niciun port serial detectat.") + return 1 + for p in ports: + chip = f" [{p['chip']}]" if p.get("chip") else "" + print(f"{p['device']:<20} {p.get('description') or ''}{chip}") + if p.get("note"): + print(f"{'':<20} ! {p['note']}") + return 0 + + +def _print_result(result: dict) -> None: + print(f"\n{result['settings']} ({result['seconds']}s)") + print(f"bytes: {result['bytes']} cadre: {len(result['frames'])}") + if result.get("error"): + print(f"eroare: {result['error']}") + for frame in result["frames"]: + print(f" {frame['hex']:<56} {frame['text']}") + if result["guesses"]: + print("\nprotocoale recunoscute:") + for g in result["guesses"]: + print(f" {g['protocol']:<18} {g['kind']:<10} {g['value']} {g['unit']} " + f"({g['count']} cadre)") + elif result["bytes"]: + print("\nNiciun protocol cunoscut. Salveaza captura si adaugam parserul.") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Monitor port serial cantar") + parser.add_argument("port", nargs="?", help="ex. /dev/ttyUSB0 sau COM3") + parser.add_argument("--list", action="store_true", help="listeaza porturile si iese") + parser.add_argument("--baud", type=int, default=9600) + parser.add_argument("--framing", default="8N1", choices=FRAMINGS) + parser.add_argument("--seconds", type=float, default=5.0) + parser.add_argument("--scan", action="store_true", + help="incearca toate vitezele la framing-ul dat") + parser.add_argument("--save", metavar="NUME", help="scrie captura in instance/captures") + args = parser.parse_args(argv) + + if args.list or not args.port: + return _print_ports() + + try: + if args.scan: + rows = sweep(args.port, seconds=min(args.seconds, 3.0), + framings=(args.framing,), bauds=BAUD_RATES) + hits = [r for r in rows if r.get("bytes")] + if not hits: + print("Niciun byte primit la vitezele incercate.") + for row in rows: + if row.get("error"): + print(f" {row['baud']} {row['framing']}: {row['error']}") + return 1 + for hit in hits: + names = ", ".join(g["protocol"] for g in hit["guesses"]) or "necunoscut" + print(f"{hit['baud']:>7} {hit['framing']} {hit['bytes']:>6} bytes {names}") + return 0 + + result = probe_port(args.port, args.baud, args.framing, args.seconds) + except ProbeError as exc: + print(f"eroare: {exc}", file=sys.stderr) + return 2 + + _print_result(result) + if args.save and result["frames"]: + target = save_capture("instance/captures", args.save, result["frames"]) + print(f"\ncaptura: {target}") + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cantarire/units.py b/cantarire/units.py new file mode 100644 index 0000000..42460cc --- /dev/null +++ b/cantarire/units.py @@ -0,0 +1,58 @@ +"""Unitati de masa, normalizare si conversii. + +Etichetele de produs finit sunt exprimate de obicei in grame, iar cantarul +poate raporta kilograme. Toate cantaririle sunt convertite in unitatea retetei +inainte de a fi salvate. +""" +from __future__ import annotations + +from typing import Optional + +# Unitati acceptate, in ordinea afisata in interfata. +UNITS: tuple[str, ...] = ("g", "kg", "mg", "t", "lb", "oz") + +GRAMS_PER: dict[str, float] = { + "g": 1.0, + "kg": 1000.0, + "mg": 0.001, + "t": 1_000_000.0, + "lb": 453.59237, + "oz": 28.349523125, +} + +ALIASES: dict[str, str] = { + "gr": "g", "gram": "g", "grame": "g", "grams": "g", + "kg": "kg", "kgs": "kg", "kilogram": "kg", "kilograme": "kg", + "mg": "mg", "miligram": "mg", "miligrame": "mg", + "t": "t", "tona": "t", "ton": "t", "to": "t", + "lb": "lb", "lbs": "lb", "pound": "lb", + "oz": "oz", "ounce": "oz", +} + + +def normalize_unit(value: object, default: str = "g") -> str: + text = str(value or "").strip().lower() + if not text: + return default + return ALIASES.get(text, text if text in GRAMS_PER else default) + + +def convert(value: float, from_unit: str, to_unit: str) -> Optional[float]: + """Converteste intre unitati de masa; None daca una nu este unitate de masa.""" + source = GRAMS_PER.get(from_unit) + target = GRAMS_PER.get(to_unit) + if source is None or target is None: + return None + return value * source / target + + +def format_weight(value: object, decimals: int = 3) -> str: + """Formatare pentru etichete: fara zerouri finale inutile.""" + try: + number = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return str(value) + text = f"{number:.{int(decimals)}f}" + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" diff --git a/deploy/cantarire.service b/deploy/cantarire.service new file mode 100644 index 0000000..8897efe --- /dev/null +++ b/deploy/cantarire.service @@ -0,0 +1,29 @@ +[Unit] +Description=Cantarire si etichetare (Flask/waitress) +Documentation=file:///opt/cantarire-etichetare/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=cantarire +Group=cantarire +# Necesar pentru accesul la adaptorul USB-serial al cantarului (daca e cazul). +SupplementaryGroups=dialout +WorkingDirectory=/opt/cantarire-etichetare +Environment=PYTHONUNBUFFERED=1 +EnvironmentFile=-/etc/cantarire.env +ExecStart=/opt/cantarire-etichetare/.venv/bin/python run.py --production --port 5000 +Restart=on-failure +RestartSec=3 +TimeoutStopSec=15 + +# Scriere doar in folderul de date si in /tmp. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/var/lib/cantarire + +[Install] +WantedBy=multi-user.target diff --git a/deploy/start-windows.bat b/deploy/start-windows.bat new file mode 100644 index 0000000..41c8990 --- /dev/null +++ b/deploy/start-windows.bat @@ -0,0 +1,34 @@ +@echo off +REM Pornire pe Windows (statia cu cantarul si imprimanta termica). +REM Ruleaza o singura data: python -m venv .venv +REM .venv\Scripts\pip install -r requirements.txt + +setlocal +cd /d "%~dp0.." + +if not exist ".venv\Scripts\python.exe" ( + echo [!] Nu exista .venv. Ruleaza intai: + echo python -m venv .venv + echo .venv\Scripts\pip install -r requirements.txt + pause + exit /b 1 +) + +REM Cheia de sesiune: genereaz-o o data si pastreaz-o intre reporniri. +if "%CANTARIRE_SECRET_KEY%"=="" ( + if exist "instance\secret.key" ( + set /p CANTARIRE_SECRET_KEY= instance\secret.key echo %CANTARIRE_SECRET_KEY% + ) +) + +echo. +echo Cantarire ^& etichetare - http://127.0.0.1:5000/ +echo Inchide fereastra pentru a opri serverul. +echo. + +.venv\Scripts\python.exe run.py --production --host 127.0.0.1 --port 5000 +pause diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7ddf694 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "cantarire-etichetare" +version = "0.1.0" +description = "Aplicatie de cantarire si etichetare cu citire directa din SyncPulse Scale Bridge" +requires-python = ">=3.10" +dependencies = [ + "Flask>=3.0,<4", + "pyserial>=3.5,<4", + "qrcode>=7.4,<9", + "waitress>=3.0,<4", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f361bc4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.0,<4 +pyserial>=3.5,<4 +qrcode>=7.4,<9 +waitress>=3.0,<4 diff --git a/run.py b/run.py new file mode 100644 index 0000000..7fe91d8 --- /dev/null +++ b/run.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Pornirea aplicatiei de cantarire si etichetare. + + python run.py # dezvoltare: http://127.0.0.1:5000 + python run.py --host 0.0.0.0 # accesibil in reteaua locala + python run.py --production # server WSGI (waitress), fara debug + +Variabile de mediu utile: + CANTARIRE_HOST, CANTARIRE_PORT, CANTARIRE_SERVER=waitress + CANTARIRE_SECRET_KEY cheia de sesiune (obligatorie in productie) + CANTARIRE_INSTANCE folderul de date (implicit ./instance) +""" +from __future__ import annotations + +import argparse +import os +import sys + +from cantarire import create_app +from cantarire.config import DEV_SECRET_KEY + + +def _print_banner(app, host: str, port: int, server: str, production: bool) -> None: + from cantarire import maintenance + + status = maintenance.database_status() + summary = maintenance.readiness_summary() + shown = "127.0.0.1" if host in ("0.0.0.0", "::") else host + print(f"\nCantarire & etichetare - server {server}", flush=True) + print(f" URL http://{shown}:{port}/", flush=True) + print(f" Baza {status['path']} ({status['size_human']}, schema v{status['schema_version']})", flush=True) + print(f" Date {status['counts']['components']} componente / " + f"{status['counts']['recipes']} produse finite / " + f"{status['counts']['templates']} sabloane", flush=True) + if summary["ready"]: + print(" Pregatit: gata de cantarit.", flush=True) + else: + missing = ", ".join(item["label"] for item in summary["missing"]) + print(f" De completat: {missing} (lista apare pe pagina principala)", flush=True) + if production and app.config.get("SECRET_KEY") == DEV_SECRET_KEY: + print(" ATENTIE: seteaza CANTARIRE_SECRET_KEY inainte de productie.", flush=True) + print(flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cantarire si etichetare") + parser.add_argument("--host", default=os.environ.get("CANTARIRE_HOST", "")) + parser.add_argument("--port", type=int, default=int(os.environ.get("CANTARIRE_PORT", "5000"))) + parser.add_argument("--server", choices=("flask", "waitress"), + default=os.environ.get("CANTARIRE_SERVER", "flask")) + parser.add_argument("--production", action="store_true", + help="server WSGI (waitress), host 0.0.0.0, fara debug") + parser.add_argument("--debug", action="store_true", default=bool(os.environ.get("CANTARIRE_DEBUG"))) + parser.add_argument("--quiet", action="store_true", help="nu afisa sumarul de pornire") + args = parser.parse_args() + + production = args.production or args.server == "waitress" + host = args.host or ("0.0.0.0" if production else "127.0.0.1") + server = "waitress" if production else "flask" + debug = args.debug and not production + + app = create_app() + + if not args.quiet: + with app.app_context(): + _print_banner(app, host, args.port, server, production) + + if server == "waitress": + try: + from waitress import serve + except ImportError: + print("Lipseste waitress. Instaleaza: pip install -r requirements.txt", file=sys.stderr) + return 1 + serve(app, host=host, port=args.port, threads=8, ident="cantarire") + return 0 + + app.run(host=host, port=args.port, debug=debug, threaded=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2d69f24 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from cantarire import create_app # noqa: E402 + +STABLE_READING = { + "ok": True, + "error": None, + "profile": "test-scale", + "port": "COM1", + "protocol": "mt_sics", + "open": True, + "value": 250.0, + "unit": "g", + "stable": True, + "stale": False, + "overload": False, + "age_ms": 120, + "measured_at": "2026-10-18T10:00:00.000+03:00", + "raw_hex": "53 20 53 20 20 20 20 32 35 30 2E 30", + "fields": {}, +} + + +class FakeScale: + available = True + error = None + + def __init__(self): + self.reading = dict(STABLE_READING) + self.calls = 0 + self.probed = None + + def profiles(self): + return [{"name": "test-scale", "label": "Cântar test", "port": "COM1", "protocol": "mt_sics"}] + + def read(self, profile_name="", **_kwargs): + self.calls += 1 + return dict(self.reading) + + def probe(self, port, baud=9600, framing="8N1", seconds=5.0): + self.probed = {"port": port, "baud": baud, "framing": framing, "seconds": seconds} + return { + "ok": True, + "port": port, + "settings": f"{port} {baud} {framing}, listen only", + "baud": baud, + "framing": framing, + "seconds": seconds, + "error": None, + "bytes": 13, + "frames": [{"age_ms": 10, "hex": "02 2D 30 20 30 30 30 30 30 30 30 30 30 30 0D", + "text": "-0 000000000000", "size": 13}], + "guesses": [{"protocol": "toledo_cont", "name": "Toledo Continuous", + "kind": "continuous", "count": 2, "value": 12.345, + "unit": "kg", "stable": True, "specificity": 3}], + } + + +@pytest.fixture() +def app(tmp_path): + application = create_app({ + "TESTING": True, + "INSTANCE_DIR": tmp_path, + "DATABASE": tmp_path / "test.db", + "BRIDGE_CONFIG": tmp_path / "bridge.json", + "SIMULATE": "", + "AUTO_PRINT": False, + "SECRET_KEY": "test", + }) + application.extensions["scale"] = FakeScale() + return application + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def scale(app): + return app.extensions["scale"] + + +def _create_component(client, code: str, name: str, unit: str = "g") -> int: + response = client.post("/componente/nou", data={ + "code": code, "name": name, "unit": unit, "active": "on", + }) + assert response.status_code == 302, response.get_data(as_text=True) + with client.application.app_context(): + from cantarire.repositories import get_component_by_code + + return get_component_by_code(code)["id"] + + +@pytest.fixture() +def fasole(client): + return _create_component(client, "FAS", "Fasole") + + +@pytest.fixture() +def carne(client): + return _create_component(client, "CAR", "Carne") + + +@pytest.fixture() +def recipe(client, fasole, carne): + response = client.post("/retete/nou", data={ + "code": "PF-001", + "name": "Fasole cu carne", + "unit": "g", + "target_weight": "450", + "component_1_id": str(fasole), + "component_1_target": "250", + "component_2_id": str(carne), + "component_2_target": "200", + "active": "on", + }) + assert response.status_code == 302, response.get_data(as_text=True) + with client.application.app_context(): + from cantarire.repositories import list_recipes + + return list_recipes()[0] diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..7727c82 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import pytest + +from cantarire.repositories import list_packages + + +def test_pages_load(client): + for path in ("/", "/componente", "/componente/nou", "/retete", "/retete/nou", + "/istoric", "/setari", "/sabloane", "/sabloane/1/designer", "/monitor"): + response = client.get(path) + assert response.status_code == 200, path + + +def test_html_css_pages_are_gone(client): + """Modul HTML/CSS a fost scos; rutele vechi nu mai exista.""" + for path in ("/sabloane/nou", "/sabloane/1/editare"): + assert client.get(path).status_code == 404, path + html = client.get("/sabloane").get_data(as_text=True) + assert "HTML/CSS" not in html + + +def test_dashboard_lists_recipes(client, recipe): + html = client.get("/").get_data(as_text=True) + assert "PF-001" in html + assert "Fasole cu carne" in html + assert "Începe pachetul" in html or "Pachet nou" in html + + +def test_component_crud(client): + created = client.post("/componente/nou", data={ + "code": "GAR", "name": "Garnitura", "unit": "g", "active": "on", + }) + assert created.status_code == 302 + listing = client.get("/componente").get_data(as_text=True) + assert "GAR" in listing and "Garnitura" in listing + + duplicate = client.post("/componente/nou", data={"code": "gar", "name": "Alt", "unit": "g"}) + assert duplicate.status_code == 200 + assert "Exista deja un component" in duplicate.get_data(as_text=True) + + +def test_recipe_has_ordered_components(client, recipe): + with client.application.app_context(): + from cantarire.repositories import get_recipe + + stored = get_recipe(recipe["id"]) + assert [c["name"] for c in stored["components"]] == ["Fasole", "Carne"] + assert [c["target_weight"] for c in stored["components"]] == [250, 200] + + +def test_recipe_requires_a_component(client): + response = client.post("/retete/nou", data={ + "code": "PF-EMPTY", "name": "Fara componente", "unit": "g", + }) + assert response.status_code == 200 + assert "cel putin un component" in response.get_data(as_text=True) + + +def test_two_step_weighing_completes_and_builds_label(client, recipe): + started = client.post("/api/packages", json={"recipe_id": recipe["id"], "lot": "LOT-1"}) + assert started.status_code == 201, started.get_json() + data = started.get_json() + package_id = data["package"]["id"] + assert data["step"]["component_name"] == "Fasole" + assert data["step"]["position"] == 1 + assert data["planned_total"] == 2 + + first = client.post(f"/api/packages/{package_id}/items", json={"weight": "250"}) + assert first.status_code == 201, first.get_json() + body = first.get_json() + assert body["step"]["component_name"] == "Carne" + assert body["step"]["position"] == 2 + assert body["total_weight"] == 250 + + second = client.post(f"/api/packages/{package_id}/items", json={"weight": "200"}) + assert second.status_code == 201, second.get_json() + done = second.get_json() + assert done["complete"] is True + assert done["step"] is None + assert done["package"]["total_weight"] == 450 + assert done["package"]["status"] == "done" + + label = done["label"] + assert label is not None + assert "Fasole cu carne" in label["html"] + assert "250 g" in label["html"] and "Fasole" in label["html"] + assert "200 g" in label["html"] and "Carne" in label["html"] + assert label["fields"]["breakdown"] == "250 g Fasole, 200 g Carne" + assert label["print_url"] == f"/labels/{package_id}/print" + + page = client.get(label["print_url"] + "?auto=0") + assert page.status_code == 200 + html = page.get_data(as_text=True) + assert "Fasole cu carne" in html and "450" in html + + +def test_scale_reading_is_used_when_no_weight_is_sent(client, recipe, scale): + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + response = client.post(f"/api/packages/{package_id}/items", json={}) + assert response.status_code == 201, response.get_json() + body = response.get_json() + assert body["package"]["items"][0]["weight"] == 250 + assert body["package"]["items"][0]["source"] == "scale" + assert scale.calls >= 1 + + +def test_unstable_scale_requires_force(client, recipe, scale): + scale.reading.update({"stable": False}) + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + + refused = client.post(f"/api/packages/{package_id}/items", json={}) + assert refused.status_code == 422 + assert "stabila" in refused.get_json()["error"].lower() + + forced = client.post(f"/api/packages/{package_id}/items", json={"force": True}) + assert forced.status_code == 201 + + +def test_stale_reading_is_rejected(client, recipe, scale): + scale.reading.update({"stale": True}) + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + response = client.post(f"/api/packages/{package_id}/items", json={"force": True}) + assert response.status_code == 409 + assert "veche" in response.get_json()["error"].lower() + + +def test_undo_removes_last_component(client, recipe): + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + client.post(f"/api/packages/{package_id}/items", json={"weight": "250"}) + undone = client.post(f"/api/packages/{package_id}/undo", json={}) + assert undone.status_code == 200 + body = undone.get_json() + assert body["items"] == [] + assert body["step"]["position"] == 1 + + +def test_void_package(client, recipe): + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + voided = client.post(f"/api/packages/{package_id}/void", json={}) + assert voided.status_code == 200 + with client.application.app_context(): + rows = list_packages(status="void") + assert [row["id"] for row in rows] == [package_id] + assert client.get("/api/package").get_json()["state"] is None + + +def test_only_one_open_package(client, recipe): + client.post("/api/packages", json={"recipe_id": recipe["id"]}) + second = client.post("/api/packages", json={"recipe_id": recipe["id"]}) + assert second.status_code == 409 + assert "deja un pachet" in second.get_json()["error"].lower() + + +def test_weight_is_converted_to_recipe_unit(client, fasole): + client.post("/retete/nou", data={ + "code": "PF-KG", "name": "Amestec kg", "unit": "kg", + "component_1_id": str(fasole), "active": "on", + }) + with client.application.app_context(): + from cantarire.repositories import list_recipes + + recipe = [r for r in list_recipes() if r["code"] == "PF-KG"][0] + + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + response = client.post(f"/api/packages/{package_id}/items", + json={"weight": "0.25", "unit": "kg"}) + assert response.status_code == 201 + assert response.get_json()["complete"] is True + assert response.get_json()["package"]["total_weight"] == 0.25 + + +def test_invalid_weight(client, recipe): + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + response = client.post(f"/api/packages/{package_id}/items", json={"weight": "abc"}) + assert response.status_code == 400 + + +def test_history_filter_and_csv(client, recipe): + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"], "lot": "LOT-A"}).get_json()["package"]["id"] + client.post(f"/api/packages/{package_id}/items", json={"weight": "250"}) + client.post(f"/api/packages/{package_id}/items", json={"weight": "200"}) + + filtered = client.get("/istoric?q=LOT-A").get_data(as_text=True) + assert "LOT-A" in filtered + assert "Fasole cu carne" in filtered + + empty = client.get("/istoric?q=NOPE").get_data(as_text=True) + assert "Nicio înregistrare" in empty + + export = client.get("/istoric/export.csv?q=LOT-A") + assert export.status_code == 200 + assert export.mimetype.startswith("text/csv") + body = export.get_data(as_text=True) + assert "LOT-A" in body + assert "Fasole" in body + assert "Cod produs finit" in body + + +def test_template_crud_and_preview(client): + preview = client.get("/sabloane/1/previzualizare").get_data(as_text=True) + assert "Fasole cu carne" in preview + assert "250" in preview and "200" in preview + + created = client.post("/sabloane/designer/nou", data={ + "name": "Etichetă mică", + "width_mm": "60", + "height_mm": "40", + }) + assert created.status_code == 302 + assert "Etichetă mică" in client.get("/sabloane").get_data(as_text=True) + + +def test_every_template_renders_a_label(client): + """Orice sablon produce o eticheta, chiar si unul vechi fara design.""" + with client.application.app_context(): + from cantarire.db import get_db + + get_db().execute( + "INSERT INTO label_templates(name, width_mm, height_mm, body_html, css," + " design_json) VALUES ('Vechi', 100, 60, '
    x
    ', '', '')" + ) + get_db().commit() + + html = client.get("/sabloane").get_data(as_text=True) + assert "Vechi" in html + assert "fără design" in html + + +def test_printed_acknowledgement(client, recipe): + package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"] + client.post(f"/api/packages/{package_id}/items", json={"weight": "250"}) + client.post(f"/api/packages/{package_id}/items", json={"weight": "200"}) + + ack = client.post(f"/api/packages/{package_id}/printed") + assert ack.status_code == 200 + package = ack.get_json()["package"] + assert package["print_count"] == 1 + assert package["printed_at"] + + +def test_api_scale_status_and_weight(client): + status = client.get("/api/scale").get_json() + assert status["ok"] is True + assert status["profiles"][0]["name"] == "test-scale" + + reading = client.get("/api/weight").get_json() + assert reading["ok"] is True + assert reading["value"] == 250.0 + + +def test_qz_tray_assets_are_served(client): + lib = client.get("/static/js/vendor/qz-tray.js") + assert lib.status_code == 200 + assert "PATCHED" in lib.get_data(as_text=True) + + module = client.get("/static/js/qz-print.js") + assert module.status_code == 200 + script = module.get_data(as_text=True) + assert "window.CantarirePrint" in script + assert "qz.printers.find()" in script + assert "type: 'pdf'" in script + + +def test_qz_ui_is_wired(client): + dashboard = client.get("/").get_data(as_text=True) + assert "vendor/qz-tray.js" in dashboard + assert "js/qz-print.js" in dashboard + assert "data-qz-printer" in dashboard + assert "js/weigh.js" in dashboard + + +def test_monitor_page_runs_probe(client, scale): + page = client.get("/monitor") + assert page.status_code == 200 + assert "Monitor serial" in page.get_data(as_text=True) + + result = client.post("/monitor", data={ + "port": "/dev/ttyUSB0", "baud": "9600", "framing": "8N1", "seconds": "2", + }) + assert result.status_code == 200 + html = result.get_data(as_text=True) + assert scale.probed == {"port": "/dev/ttyUSB0", "baud": 9600, "framing": "8N1", + "seconds": 2.0} + assert "toledo_cont" in html + + +def test_monitor_requires_port(client): + page = client.post("/monitor", data={"port": "", "baud": "9600", "framing": "8N1"}) + assert page.status_code == 200 + assert "Alege portul serial" in page.get_data(as_text=True) + + +def test_settings_save(client): + response = client.post("/setari", data={ + "company_name": "Firma Test", + "active_scale_profile": "test-scale", + "default_operator": "Maria", + "auto_print": "on", + }) + assert response.status_code == 302 + page = client.get("/setari").get_data(as_text=True) + assert "Firma Test" in page + assert "Maria" in page diff --git a/tests/test_designer.py b/tests/test_designer.py new file mode 100644 index 0000000..3bdf5f1 --- /dev/null +++ b/tests/test_designer.py @@ -0,0 +1,281 @@ +"""Designer vizual: validare, salvare si randare pe eticheta.""" +from __future__ import annotations + +import json + +import pytest + +from cantarire import designer + + +def _designer_template(client, name="Designer test") -> int: + response = client.post("/sabloane/designer/nou", data={"name": name}) + assert response.status_code == 302 + with client.application.app_context(): + from cantarire.repositories import list_templates + + return [t for t in list_templates() if t["name"] == name][0]["id"] + + +def test_default_design_has_expected_blocks(): + design = designer.default_design(100, 60) + types = [block["type"] for block in design["blocks"]] + assert "field" in types and "items" in types and "qr" in types + + +@pytest.mark.parametrize("width,height", [(100, 60), (80, 50), (60, 40), (150, 100)]) +def test_default_design_stays_inside_safe_margin(width, height): + """Elementele implicite trebuie sa fie in interiorul zonei sigure.""" + margin = designer.SAFE_MARGIN_MM + design = designer.default_design(width, height) + for block in design["blocks"]: + assert block["x"] >= margin - 0.001, block + assert block["y"] >= margin - 0.001, block + assert block["x"] + block["w"] <= width - margin + 0.001, block + assert block["y"] + block["h"] <= height - margin + 0.001, block + + +def test_validate_design_clamps_out_of_bounds(): + raw = { + "width_mm": 100, + "height_mm": 60, + "blocks": [ + {"type": "text", "x": 200, "y": -5, "w": 500, "h": 999, "text": "X"}, + {"type": "nope"}, + {"type": "field", "field": "does.not.exist"}, + ], + } + design = designer.validate_design(raw, 100, 60) + assert len(design["blocks"]) == 2 # tipul necunoscut este ignorat + block = design["blocks"][0] + assert block["x"] <= 100 and block["y"] >= 0 + assert block["w"] <= 100 and block["h"] <= 60 + assert design["blocks"][1]["field"] == "recipe.name" + + +def test_validate_design_rejects_bad_json(): + with pytest.raises(designer.ValidationError): + designer.validate_design("{not json", 100, 60) + + +def test_render_design_escapes_text_and_builds_fields(): + design = designer.validate_design({ + "width_mm": 100, "height_mm": 60, + "blocks": [ + {"type": "text", "x": 1, "y": 1, "w": 50, "h": 5, "text": ""}, + {"type": "field", "x": 1, "y": 7, "w": 50, "h": 8, + "field": "recipe.name", "prefix": "", "suffix": ""}, + {"type": "items", "x": 1, "y": 16, "w": 50, "h": 20}, + {"type": "qr", "x": 60, "y": 16, "w": 20, "h": 20, "field": "package.qr"}, + ], + }, 100, 60) + html = designer.render_design(design, designer.demo_fields(), designer.demo_items()) + assert "<script>" in html and ""}])], + }, 100, 60) + html = designer.render_design(design, designer.demo_fields(), designer.demo_items()) + assert "") + + +def remote_page(port: int = 8765) -> str: + values = {"__ICON__": ICON, "__VERSION__": ABOUT["version"], "__REPO__": ABOUT["repository"], + "__PORT__": str(port), "__AUTHOR__": f'{ABOUT["author"]} — {ABOUT["company"]}', + "__WEBSITE__": ABOUT["website"], "__GITHUB__": ABOUT["github"], + "__LINKEDIN__": ABOUT["linkedin"], "__LICENSE__": ABOUT["license"]} + html = _REMOTE + for k, v in values.items(): + html = html.replace(k, v) + return html diff --git a/vendor/syncpulse-scale-bridge/scale_bridge/winutil.py b/vendor/syncpulse-scale-bridge/scale_bridge/winutil.py new file mode 100644 index 0000000..e46f997 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/scale_bridge/winutil.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com) +""" +Windows chores the bridge can do by itself, each behind one button: + +* open the firewall for the bridge port (so other machines can reach it), +* start the bridge with Windows, before anyone logs in (a scheduled task that + runs as SYSTEM, never stops after 72 hours, and restarts if it fails). + +Both need administrator rights; Windows shows its usual UAC prompt. +Everything returns plain dictionaries for the user interface. +""" +from __future__ import annotations + +import ctypes +import logging +import os +import socket +import subprocess +import sys +import tempfile +import webbrowser +from typing import Optional + +from . import PRODUCT + +log = logging.getLogger("scale_bridge") + +RULE_NAME = PRODUCT +TASK_NAME = PRODUCT +CREATE_NO_WINDOW = 0x08000000 +ERROR_CANCELLED = 1223 + + +def is_windows() -> bool: + return sys.platform == "win32" + + +def is_admin() -> bool: + if not is_windows(): + return os.geteuid() == 0 if hasattr(os, "geteuid") else False + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: # noqa: BLE001 + return False + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def program_command(extra: str) -> tuple[str, str, str]: + """(executable, arguments, working directory) that start this program again.""" + if is_frozen(): + exe = os.path.abspath(sys.executable) + return exe, extra, os.path.dirname(exe) + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.abspath(sys.executable), f"-m scale_bridge {extra}", root + + +def _run(args: list[str], timeout: float = 20) -> subprocess.CompletedProcess: + return subprocess.run(args, capture_output=True, timeout=timeout, + creationflags=CREATE_NO_WINDOW if is_windows() else 0) + + +# --------------------------------------------------------------------------- +# Elevation +# --------------------------------------------------------------------------- + +def run_elevated(exe: str, params: str, timeout: float = 120) -> dict: + """Run a command as administrator (UAC prompt) and wait for it.""" + if not is_windows(): + return {"ok": False, "error": "Windows only"} + if is_admin(): + try: + proc = subprocess.run(f'"{exe}" {params}', capture_output=True, timeout=timeout, + creationflags=CREATE_NO_WINDOW) + return {"ok": proc.returncode == 0, "code": proc.returncode} + except (OSError, subprocess.SubprocessError) as e: + return {"ok": False, "error": str(e)} + + from ctypes import wintypes + + class SHELLEXECUTEINFOW(ctypes.Structure): + _fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND), + ("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), + ("lpParameters", wintypes.LPCWSTR), ("lpDirectory", wintypes.LPCWSTR), + ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE), + ("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), + ("hkeyClass", wintypes.HKEY), ("dwHotKey", wintypes.DWORD), + ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)] + + SEE_MASK_NOCLOSEPROCESS = 0x00000040 + SEE_MASK_NO_CONSOLE = 0x00008000 + info = SHELLEXECUTEINFOW() + info.cbSize = ctypes.sizeof(info) + info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE + info.lpVerb = "runas" + info.lpFile = exe + info.lpParameters = params + info.nShow = 0 # SW_HIDE + shell32 = ctypes.windll.shell32 + kernel32 = ctypes.windll.kernel32 + shell32.ShellExecuteExW.argtypes = [ctypes.POINTER(SHELLEXECUTEINFOW)] + if not shell32.ShellExecuteExW(ctypes.byref(info)): + err = ctypes.GetLastError() + if err == ERROR_CANCELLED: + return {"ok": False, "cancelled": True, "error": "cancelled at the Windows prompt"} + return {"ok": False, "error": f"Windows error {err}"} + try: + kernel32.WaitForSingleObject(info.hProcess, int(timeout * 1000)) + code = wintypes.DWORD() + kernel32.GetExitCodeProcess(info.hProcess, ctypes.byref(code)) + return {"ok": code.value == 0, "code": code.value} + finally: + kernel32.CloseHandle(info.hProcess) + + +# --------------------------------------------------------------------------- +# Firewall +# --------------------------------------------------------------------------- + +def firewall_status(port: int) -> dict: + if not is_windows(): + return {"supported": False} + try: + proc = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_NAME}"]) + except (OSError, subprocess.SubprocessError) as e: + return {"supported": True, "exists": False, "error": str(e)} + exists = proc.returncode == 0 + return {"supported": True, "exists": exists, + "port_ok": exists and str(port).encode() in proc.stdout, "rule": RULE_NAME} + + +def firewall_open(port: int, remote_ip: Optional[str] = None) -> dict: + """Allow inbound TCP `port` on every network profile (optionally from one address only). + + When Windows asked "allow access?" the first time the program listened and + somebody clicked Cancel, Windows added BLOCK rules for the program, and a + block rule beats any allow rule — so those are removed as well. + """ + if not is_windows(): + return {"ok": False, "error": "Windows only"} + port = int(port) + remote = f" remoteip={remote_ip}" if remote_ip else "" + steps = [f'netsh advfirewall firewall delete rule name="{RULE_NAME}" >nul 2>&1'] + if is_frozen(): + steps.append(f'netsh advfirewall firewall delete rule name=all program="{sys.executable}" >nul 2>&1') + steps.append(f'netsh advfirewall firewall add rule name="{RULE_NAME}" dir=in action=allow ' + f'protocol=TCP localport={port} profile=any{remote} ' + f'description="{PRODUCT}: lets other machines read the scales on this PC"') + result = run_elevated(os.environ.get("COMSPEC", "cmd.exe"), "/c " + " & ".join(steps)) + result["status"] = firewall_status(port) + result["ok"] = bool(result["status"].get("port_ok")) + return result + + +def firewall_close() -> dict: + if not is_windows(): + return {"ok": False, "error": "Windows only"} + return run_elevated("netsh", f'advfirewall firewall delete rule name="{RULE_NAME}"') + + +# --------------------------------------------------------------------------- +# Start with Windows +# --------------------------------------------------------------------------- + +def _xml_escape(text: str) -> str: + return (text.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def task_xml(exe: str, arguments: str, workdir: str) -> str: + return f""" + + + {_xml_escape(PRODUCT)}: serves the scales on this PC to the network. + + + + true + PT20S + + + + + S-1-5-18 + HighestAvailable + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + PT0S + 6 + + PT1M + 999 + + + + + {_xml_escape(exe)} + {_xml_escape(arguments)} + {_xml_escape(workdir)} + + + +""" + + +def autostart_status() -> dict: + if not is_windows(): + return {"supported": False} + try: + proc = _run(["schtasks", "/Query", "/TN", TASK_NAME, "/XML"]) + except (OSError, subprocess.SubprocessError) as e: + return {"supported": True, "enabled": False, "error": str(e)} + if proc.returncode != 0: + return {"supported": True, "enabled": False, "task": TASK_NAME} + xml = proc.stdout.decode("utf-16", errors="ignore") if proc.stdout[:2] in (b"\xff\xfe", b"\xfe\xff") \ + else proc.stdout.decode("utf-8", errors="ignore") + exe, _, _ = program_command("") + return {"supported": True, "enabled": True, "task": TASK_NAME, + "this_program": exe.lower() in xml.lower()} + + +def autostart_enable(config_path: str) -> dict: + if not is_windows(): + return {"ok": False, "error": "Windows only"} + exe, arguments, workdir = program_command(f'--service --config "{config_path}"') + fd, xml_path = tempfile.mkstemp(prefix="scale-bridge-task-", suffix=".xml") + with os.fdopen(fd, "wb") as f: + f.write(task_xml(exe, arguments, workdir).encode("utf-16")) + try: + result = run_elevated("schtasks", f'/Create /TN "{TASK_NAME}" /XML "{xml_path}" /F') + finally: + try: + os.remove(xml_path) + except OSError: + pass + result["status"] = autostart_status() + result["ok"] = bool(result["status"].get("enabled")) + return result + + +def autostart_disable() -> dict: + if not is_windows(): + return {"ok": False, "error": "Windows only"} + result = run_elevated("schtasks", f'/Delete /TN "{TASK_NAME}" /F') + result["status"] = autostart_status() + result["ok"] = not result["status"].get("enabled") + return result + + +# --------------------------------------------------------------------------- +# Network and desktop +# --------------------------------------------------------------------------- + +def lan_addresses() -> list[str]: + found = set() + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("10.255.255.255", 1)) + found.add(s.getsockname()[0]) + except OSError: + pass + try: + for ip in socket.gethostbyname_ex(socket.gethostname())[2]: + if not ip.startswith("127."): + found.add(ip) + except OSError: + pass + return sorted(found) + + +def open_browser(url: str) -> None: + try: + webbrowser.open(url, new=1) + except Exception: # noqa: BLE001 + log.info("open %s in a browser", url) diff --git a/vendor/syncpulse-scale-bridge/scripts/build.py b/vendor/syncpulse-scale-bridge/scripts/build.py new file mode 100644 index 0000000..5aacba3 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/scripts/build.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Farshad Zamani - Cloudtart (https://cloudtart.com) +""" +Build dist/SyncPulseScaleBridge.exe — one file, nothing to install. + + python scripts/build.py (needs: pip install -r requirements-dev.txt) +""" +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from scale_bridge import ABOUT, COMPANY, EXE_NAME, PRODUCT, __version__ # noqa: E402 + + +def version_file(path: str) -> None: + parts = [int(p) for p in __version__.split(".")[:3]] + [0] + numbers = ", ".join(str(n) for n in (parts + [0, 0, 0, 0])[:4]) + strings = { + "CompanyName": COMPANY, + "FileDescription": f"{PRODUCT} - RS-232 scale reader and network bridge", + "FileVersion": __version__, + "InternalName": EXE_NAME, + "LegalCopyright": f"{ABOUT['copyright']}. Licensed under {ABOUT['license']}.", + "OriginalFilename": f"{EXE_NAME}.exe", + "ProductName": PRODUCT, + "ProductVersion": __version__, + "Comments": f"Author: {ABOUT['author']} - {ABOUT['website']} - {ABOUT['repository']}", + } + entries = ",\n".join(f" StringStruct({k!r}, {v!r})" for k, v in strings.items()) + text = f"""# UTF-8 +VSVersionInfo( + ffi=FixedFileInfo(filevers=({numbers}), prodvers=({numbers}), mask=0x3f, flags=0x0, + OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0)), + kids=[ + StringFileInfo([ + StringTable('040904B0', [ +{entries} + ]) + ]), + VarFileInfo([VarStruct('Translation', [1033, 1200])]) + ] +) +""" + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +def main() -> int: + build = os.path.join(ROOT, "build") + os.makedirs(build, exist_ok=True) + icon = os.path.join(ROOT, "assets", "icon.ico") + if not os.path.exists(icon): + subprocess.check_call([sys.executable, os.path.join(ROOT, "scripts", "make_icon.py")]) + vfile = os.path.join(build, "version_info.txt") + version_file(vfile) + cmd = [sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile", "--console", + "--name", EXE_NAME, "--icon", icon, "--version-file", vfile, + "--distpath", os.path.join(ROOT, "dist"), "--workpath", os.path.join(build, "pyinstaller"), + "--specpath", build, os.path.join(ROOT, "run_bridge.py")] + subprocess.check_call(cmd, cwd=ROOT) + exe = os.path.join(ROOT, "dist", EXE_NAME + (".exe" if os.name == "nt" else "")) + with open(exe, "rb") as f: + digest = hashlib.sha256(f.read()).hexdigest() + with open(exe + ".sha256", "w", encoding="ascii", newline="\n") as f: + f.write(f"{digest} {os.path.basename(exe)}\n") + print(f"\nbuilt {exe}\nSHA-256 {digest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vendor/syncpulse-scale-bridge/scripts/build_exe.bat b/vendor/syncpulse-scale-bridge/scripts/build_exe.bat new file mode 100644 index 0000000..97a388f --- /dev/null +++ b/vendor/syncpulse-scale-bridge/scripts/build_exe.bat @@ -0,0 +1,26 @@ +@echo off +rem SyncPulse Scale Bridge - build dist\SyncPulseScaleBridge.exe +rem Copyright (C) 2026 Farshad Zamani - Cloudtart (https://cloudtart.com) - GPL-3.0-or-later +setlocal +cd /d "%~dp0\.." + +if "%PYTHON%"=="" ( + where py >nul 2>&1 && (set "PYTHON=py -3") || (set "PYTHON=python") +) + +if not exist .venv\Scripts\python.exe ( + echo Creating .venv ... + %PYTHON% -m venv .venv || goto :error +) +.venv\Scripts\python.exe -m pip install --quiet --upgrade pip || goto :error +.venv\Scripts\python.exe -m pip install --quiet -r requirements-dev.txt || goto :error +.venv\Scripts\python.exe -m pytest -q || goto :error +.venv\Scripts\python.exe scripts\build.py || goto :error +echo. +echo Done: dist\SyncPulseScaleBridge.exe +exit /b 0 + +:error +echo. +echo Build failed. +exit /b 1 diff --git a/vendor/syncpulse-scale-bridge/scripts/make_icon.py b/vendor/syncpulse-scale-bridge/scripts/make_icon.py new file mode 100644 index 0000000..50ebe53 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/scripts/make_icon.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Farshad Zamani - Cloudtart (https://cloudtart.com) +"""Draw the program icon (a platform scale with a display) into assets/icon.ico — standard library only.""" +from __future__ import annotations + +import os +import struct +import sys +import zlib + +TEAL = (15, 118, 110) +DARK = (8, 64, 60) +WHITE = (255, 255, 255) + + +def _in_round_rect(x, y, x0, y0, x1, y1, r): + if not (x0 <= x <= x1 and y0 <= y <= y1): + return False + cx = min(max(x, x0 + r), x1 - r) + cy = min(max(y, y0 + r), y1 - r) + return (x - cx) ** 2 + (y - cy) ** 2 <= r * r + + +def _in_trapezoid(x, y, top, bottom, top_x0, top_x1, bottom_x0, bottom_x1): + if not (top <= y <= bottom): + return False + f = (y - top) / (bottom - top) + return top_x0 + (bottom_x0 - top_x0) * f <= x <= top_x1 + (bottom_x1 - top_x1) * f + + +def colour_at(x, y): + """Colour of the 64x64 design at (x, y), or None outside the icon.""" + if not _in_round_rect(x, y, 0, 0, 64, 64, 14): + return None + if _in_round_rect(x, y, 23, 34, 41, 42, 2): # display window + if _in_round_rect(x, y, 26, 37, 38, 39, 1): # the reading + return WHITE + return DARK + if _in_round_rect(x, y, 11, 13, 53, 19, 2.5): # weighing plate + return WHITE + if 29 <= x <= 35 and 19 <= y <= 28: # column + return WHITE + if _in_trapezoid(x, y, 27, 51, 18, 46, 13, 51): # body + return WHITE + return TEAL + + +def render(size: int, samples: int = 4) -> list[list[tuple[int, int, int, int]]]: + rows = [] + step = 64 / size + for py in range(size): + row = [] + for px in range(size): + acc = [0, 0, 0] + covered = 0 + for sy in range(samples): + for sx in range(samples): + c = colour_at((px + (sx + 0.5) / samples) * step, (py + (sy + 0.5) / samples) * step) + if c is not None: + covered += 1 + acc[0] += c[0] + acc[1] += c[1] + acc[2] += c[2] + if covered: + row.append((acc[0] // covered, acc[1] // covered, acc[2] // covered, + round(255 * covered / samples ** 2))) + else: + row.append((0, 0, 0, 0)) + rows.append(row) + return rows + + +def png(rows) -> bytes: + h, w = len(rows), len(rows[0]) + raw = b"".join(b"\x00" + bytes(v for px in row for v in px) for row in rows) + + def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + + +def dib(rows) -> bytes: + h, w = len(rows), len(rows[0]) + header = struct.pack(" bytes: + images = [(s, png(render(s)) if s >= 256 else dib(render(s))) for s in sizes] + out = struct.pack(" int: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + target = os.path.join(root, "assets", "icon.ico") + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as f: + f.write(ico()) + with open(os.path.join(root, "assets", "icon.png"), "wb") as f: + f.write(png(render(256))) + print(target) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vendor/syncpulse-scale-bridge/tests/captures/ptc400_tspl_labels.hex b/vendor/syncpulse-scale-bridge/tests/captures/ptc400_tspl_labels.hex new file mode 100644 index 0000000..b606b7b --- /dev/null +++ b/vendor/syncpulse-scale-bridge/tests/captures/ptc400_tspl_labels.hex @@ -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 diff --git a/vendor/syncpulse-scale-bridge/tests/test_detect.py b/vendor/syncpulse-scale-bridge/tests/test_detect.py new file mode 100644 index 0000000..030a621 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/tests/test_detect.py @@ -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 diff --git a/vendor/syncpulse-scale-bridge/tests/test_labels.py b/vendor/syncpulse-scale-bridge/tests/test_labels.py new file mode 100644 index 0000000..b056885 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/tests/test_labels.py @@ -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() diff --git a/vendor/syncpulse-scale-bridge/tests/test_profiles.py b/vendor/syncpulse-scale-bridge/tests/test_profiles.py new file mode 100644 index 0000000..6e1f76d --- /dev/null +++ b/vendor/syncpulse-scale-bridge/tests/test_profiles.py @@ -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] diff --git a/vendor/syncpulse-scale-bridge/tests/test_protocols.py b/vendor/syncpulse-scale-bridge/tests/test_protocols.py new file mode 100644 index 0000000..0e631f3 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/tests/test_protocols.py @@ -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") diff --git a/vendor/syncpulse-scale-bridge/tests/test_server.py b/vendor/syncpulse-scale-bridge/tests/test_server.py new file mode 100644 index 0000000..b962199 --- /dev/null +++ b/vendor/syncpulse-scale-bridge/tests/test_server.py @@ -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