Initial commit: aplicatie de cantarire si etichetare
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
@@ -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
|
||||
+18
@@ -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
|
||||
@@ -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 <repo> 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-<data>.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 |
|
||||
@@ -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
|
||||
<script>
|
||||
window.CANTARIRE_QZ = { certificateUrl: '/qz/certificate', signatureUrl: '/qz/sign' };
|
||||
</script>
|
||||
```
|
||||
|
||||
## 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/<id>/items` | adaugă următoarea cântărire |
|
||||
| `POST` | `/api/packages/<id>/undo` | anulează ultima cântărire |
|
||||
| `POST` | `/api/packages/<id>/void` | renunță la pachet |
|
||||
| `GET` | `/api/packages/<id>/label` | payload-ul de tipărire |
|
||||
| `POST` | `/api/packages/<id>/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": "<div class=\"label\">…</div>",
|
||||
"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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
+172
@@ -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))
|
||||
@@ -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.<slug>`.
|
||||
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'<svg([^>]*?)\swidth="[^"]*"\sheight="[^"]*"',
|
||||
r"<svg\1", svg, count=1)
|
||||
|
||||
|
||||
def _font_size(style: dict[str, Any]) -> 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'<div class="d-block d-line" style="{position};background:'
|
||||
f'{style.get("color", "#000000")}"></div>'
|
||||
)
|
||||
|
||||
if kind == "text":
|
||||
text = html.escape(block.get("text") or "")
|
||||
if style.get("uppercase"):
|
||||
text = text.upper()
|
||||
return (f'<div class="d-block d-text" style="{position};'
|
||||
f'{_text_style(style, height=block["h"])}">{text}</div>')
|
||||
|
||||
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'<div class="d-block d-field" style="{position};'
|
||||
f'{_text_style(style, height=block["h"])}">{text}</div>')
|
||||
|
||||
if kind == "items":
|
||||
if not items:
|
||||
return f'<div class="d-block d-items" style="{position}"></div>'
|
||||
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'<span class="d-item"><span class="d-item-w">{weight} {unit}</span> {name}</span>'
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f'<div class="d-item"><span class="d-item-w">{weight} {unit}</span> {name}</div>'
|
||||
)
|
||||
separator = " · " if style.get("separator") == "inline" else ""
|
||||
body = separator.join(lines)
|
||||
return (f'<div class="d-block d-items" style="{position};'
|
||||
f'{_text_style(style, height=block["h"])}">{body}</div>')
|
||||
|
||||
if kind == "qr":
|
||||
value = qr_payload(block, fields)
|
||||
svg = qr_svg(value)
|
||||
return (f'<div class="d-block d-qr" style="{position}">'
|
||||
f'<div class="d-qr-inner">{svg}</div></div>')
|
||||
|
||||
if kind == "image":
|
||||
src = block.get("src") or ""
|
||||
if not src:
|
||||
return (f'<div class="d-block d-image d-image-empty" style="{position}"></div>')
|
||||
return (f'<div class="d-block d-image" style="{position}">'
|
||||
f'<img src="{html.escape(src, quote=True)}" alt=""></div>')
|
||||
|
||||
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'<div class="design-label" style="position:relative;width:{width:.2f}mm;'
|
||||
f'height:{height:.2f}mm;overflow:hidden;background:#fff;color:#000">'
|
||||
f"{blocks}</div>"
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Blueprint-urile aplicatiei sunt importate explicit in `create_app`."""
|
||||
@@ -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/<int:package_id>")
|
||||
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/<int:package_id>/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/<int:package_id>/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/<int:package_id>/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/<int:package_id>/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/<int:package_id>/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})
|
||||
@@ -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("/<int:component_id>/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("/<int:component_id>/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"))
|
||||
@@ -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("/<int:field_id>/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("/<int:field_id>/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)})
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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("/<int:template_id>/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("/<int:template_id>/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("/<int:template_id>/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("/<int:template_id>/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("/<int:template_id>/designer/stergere")
|
||||
@bp.post("/<int:template_id>/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("/<int:template_id>/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"))
|
||||
@@ -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"))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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/<int:package_id>/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)
|
||||
@@ -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("/<int:recipe_id>/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("/<int:recipe_id>/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"))
|
||||
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 '<!doctype html><html><head><meta charset="utf-8"><style>'
|
||||
+ '@page{size:' + size.width + 'mm ' + size.height + 'mm;margin:0}'
|
||||
+ 'html,body{margin:0;padding:0;width:' + size.width + 'mm;height:' + size.height
|
||||
+ 'mm;overflow:hidden;background:#fff}'
|
||||
+ '*,*::before,*::after{box-sizing:border-box}'
|
||||
+ (payload.css || '')
|
||||
+ '</style></head><body><div class="label-sheet" style="width:' + size.width
|
||||
+ 'mm;height:' + size.height + 'mm;overflow:hidden">'
|
||||
+ (payload.html || '')
|
||||
+ '</div></body></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: '<div class="t"><h1>TEST IMPRIMANTA</h1>'
|
||||
+ '<p>' + (state.printer || 'imprimanta implicita') + '</p>'
|
||||
+ '<p>' + stamp + '</p>'
|
||||
+ '<p>Cantarire & Etichetare</p></div>'
|
||||
};
|
||||
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();
|
||||
}
|
||||
})();
|
||||
Vendored
+16
@@ -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.
|
||||
+2871
File diff suppressed because it is too large
Load Diff
@@ -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, '>')
|
||||
.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 '<option value="' + recipe.id + '"' + (recipe.id === selected ? ' selected' : '') +
|
||||
'>' + esc(recipe.code + ' · ' + recipe.name) + suffix + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function breakdown(recipe) {
|
||||
return (recipe.components || []).map(function (part) {
|
||||
return '<li><span class="w">' + (part.target_weight ? fmt(part.target_weight) + ' ' + esc(recipe.unit) : '—')
|
||||
+ '</span> ' + esc(part.name) + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderStart() {
|
||||
if (el.title) el.title.textContent = 'Pachet nou';
|
||||
setProgress('');
|
||||
|
||||
if (!recipes.length) {
|
||||
el.flow.innerHTML = '<p class="muted">Nu există produse finite active. ' +
|
||||
'<a class="link" href="/retete/nou">Creează unul</a>.</p>';
|
||||
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 =
|
||||
'<div class="field">' +
|
||||
' <label for="start-recipe">Produs finit</label>' +
|
||||
' <select id="start-recipe">' + recipeOptions(recipe.id) + '</select>' +
|
||||
'</div>' +
|
||||
'<ul class="plan" id="start-plan">' + breakdown(recipe) + '</ul>' +
|
||||
'<div class="field-row">' +
|
||||
' <div class="field"><label for="start-lot">Lot</label>' +
|
||||
' <input id="start-lot" type="text" maxlength="64" value="' + esc(lastLot) + '" placeholder="ex. LOT-2610-A"></div>' +
|
||||
' <div class="field"><label for="start-operator">Operator</label>' +
|
||||
' <input id="start-operator" type="text" maxlength="64" value="' + esc(lastOperator) + '"></div>' +
|
||||
'</div>' +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-start">Începe pachetul</button>' +
|
||||
'</div>' +
|
||||
'<p class="hint muted small">Enter pornește pachetul. Apoi cântărești fiecare componentă în ordinea de mai sus.</p>';
|
||||
|
||||
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 '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
|
||||
esc(item.component_name) + '</li>';
|
||||
}).join('');
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="step">' +
|
||||
' <div class="step-label">Pune pe cântar</div>' +
|
||||
' <div class="step-component">' + esc(step.component_name) + '</div>' +
|
||||
(step.target_weight
|
||||
? '<div class="step-target">Țintă: ' + fmt(step.target_weight) + ' ' + esc(step.unit) + '</div>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
(manualMode
|
||||
? '<div class="field"><label for="manual-weight">Greutate (' + esc(step.unit) + ')</label>' +
|
||||
'<input id="manual-weight" type="number" step="0.001" min="0" inputmode="decimal" placeholder="0">' +
|
||||
'</div>'
|
||||
: '<div class="actions"><button class="btn ghost" type="button" id="btn-manual-toggle">Introducere manuală</button></div>') +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-accept">Acceptă' + (manualMode ? '' : ' (Enter)') + '</button>' +
|
||||
(accepted ? ' <button class="btn" type="button" id="btn-undo">Anulează ultima</button>' : '') +
|
||||
' <button class="btn ghost" type="button" id="btn-void">Renunță la pachet</button>' +
|
||||
'</div>' +
|
||||
(accepted ? '<h3>Acceptate</h3><ul class="plan accepted">' + accepted + '</ul>' : '');
|
||||
|
||||
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 '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
|
||||
esc(item.component_name) + '</li>';
|
||||
}).join('');
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="done">' +
|
||||
' <div class="done-mark">✓</div>' +
|
||||
' <div class="done-total"><span class="value">' + fmt(pkg.total_weight) + '</span>' +
|
||||
' <span class="unit">' + esc(pkg.unit) + '</span></div>' +
|
||||
' <div class="muted">Pachet #' + pkg.id + (pkg.lot ? ' · Lot ' + esc(pkg.lot) : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<ul class="plan accepted">' + accepted + '</ul>' +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-next">Pachet nou (Enter)</button>' +
|
||||
(lastLabel ? ' <button class="btn" type="button" id="btn-reprint">Tipărește eticheta</button>' : '') +
|
||||
' <a class="btn ghost" target="_blank" rel="noopener" href="/labels/' + pkg.id + '/print?auto=0">Vezi eticheta</a>' +
|
||||
'</div>';
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Cântărire și etichetare{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% set admin_blueprints = ['history', 'recipes', 'components', 'label_templates', 'fields',
|
||||
'monitor'] %}
|
||||
{% set in_settings = request.blueprint in admin_blueprints
|
||||
or request.endpoint == 'main.settings_page' %}
|
||||
<header class="topbar">
|
||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="brand-mark">CE</span>
|
||||
<span class="brand-text">Cântărire & Etichetare</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('main.dashboard') }}"
|
||||
class="{{ 'active' if request.endpoint == 'main.dashboard' }}">Cântărire</a>
|
||||
<a href="{{ url_for('main.settings_page') }}"
|
||||
class="{{ 'active' if in_settings }}">Setări</a>
|
||||
</nav>
|
||||
<button class="qz-pill" type="button" data-qz-pill data-qz-connect
|
||||
title="Starea conexiunii QZ Tray">
|
||||
<span class="qz-dot"></span>
|
||||
<span data-qz-status>QZ Tray: se verifică…</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{% if in_settings %}
|
||||
<nav class="subnav" aria-label="Secțiuni de administrare">
|
||||
<div class="subnav-inner">
|
||||
<a href="{{ url_for('main.settings_page') }}"
|
||||
class="{{ 'active' if request.endpoint == 'main.settings_page' }}">Generale</a>
|
||||
<a href="{{ url_for('components.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'components' }}">Componente</a>
|
||||
<a href="{{ url_for('recipes.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'recipes' }}">Produse finite</a>
|
||||
<a href="{{ url_for('label_templates.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'label_templates' }}">Șabloane</a>
|
||||
<a href="{{ url_for('fields.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'fields' }}">Câmpuri</a>
|
||||
<a href="{{ url_for('history.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'history' }}">Istoric</a>
|
||||
<a href="{{ url_for('monitor.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'monitor' }}">Monitor serial</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>Cântărire & Etichetare v{{ app_version }}</span>
|
||||
<span>Produse finite · SyncPulse Scale Bridge · QZ Tray</span>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/qz-tray.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/qz-print.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ "Component nou" if is_new else "Editare component" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>{{ "Component nou" if is_new else "Editare component" }}</h1>
|
||||
<p class="subtitle">Numele apare pe etichetă, lângă greutate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card narrow">
|
||||
<form method="post" class="form">
|
||||
<div class="field">
|
||||
<label for="code">Cod <span class="req">*</span></label>
|
||||
<input id="code" name="code" type="text" maxlength="40" required
|
||||
value="{{ component.code or '' }}" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name">Denumire <span class="req">*</span></label>
|
||||
<input id="name" name="name" type="text" maxlength="120" required
|
||||
value="{{ component.name or '' }}" placeholder="ex. Fasole">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="unit">Unitate de măsură</label>
|
||||
<select id="unit" name="unit">
|
||||
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
|
||||
<option value="{{ u }}" {{ 'selected' if (component.unit or 'g') == u }}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="description">Descriere</label>
|
||||
<textarea id="description" name="description" rows="3" maxlength="500">{{ component.description or '' }}</textarea>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="active" {{ 'checked' if component.active in [True, 1, '1', 'on', 'true'] or is_new }}>
|
||||
Component activ
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează</button>
|
||||
<a class="btn ghost" href="{{ url_for('components.index') }}">Renunță</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Componente{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Componente</h1>
|
||||
<p class="subtitle">Ce se cântărește: fasole, carne, garnitură etc.</p>
|
||||
</div>
|
||||
<a class="btn primary" href="{{ url_for('components.create') }}">+ Component nou</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if components %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Cod</th><th>Denumire</th><th>UM</th><th>Descriere</th><th>Stare</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in components %}
|
||||
<tr class="{{ 'row-muted' if not c.active }}">
|
||||
<td><code>{{ c.code }}</code></td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.unit }}</td>
|
||||
<td class="muted">{{ c.description or "-" }}</td>
|
||||
<td>
|
||||
{% if c.active %}<span class="badge ok">activ</span>
|
||||
{% else %}<span class="badge off">inactiv</span>{% endif %}
|
||||
</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('components.edit', component_id=c.id) }}">Editează</a>
|
||||
<form class="inline" method="post" action="{{ url_for('components.remove', component_id=c.id) }}"
|
||||
onsubmit="return confirm('Ștergi componentul {{ c.code }}? Dacă e folosit într-o rețetă, va fi doar dezactivat.');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nu există componente. <a href="{{ url_for('components.create') }}">Adaugă primul component</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Cântărire produs finit{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Cântărire produs finit</h1>
|
||||
<p class="subtitle">Alege produsul, cântărește componentele în ordine și tipărește eticheta.</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="stat-value" id="stat-today-count">{{ stats.today_count }}</span><span class="stat-label">pachete azi</span></div>
|
||||
<div class="stat"><span class="stat-value">{{ stats.today_total }}</span><span class="stat-label">total azi</span></div>
|
||||
<div class="stat"><span class="stat-value">{{ stats.recipes }}</span><span class="stat-label">produse finite</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not setup.ready %}
|
||||
<section class="card setup-card">
|
||||
<div class="setup-head">
|
||||
<div>
|
||||
<h2>Pregătire pentru prima cântărire</h2>
|
||||
<p class="muted small">Completează pe rând; poți cântări după ce lista e goală.</p>
|
||||
</div>
|
||||
<span class="badge warn">{{ setup.missing|length }} de completat</span>
|
||||
</div>
|
||||
<ul class="setup-list">
|
||||
{% for item in setup.checks %}
|
||||
{% if not item.ok and not item.optional %}
|
||||
<li class="setup-item">
|
||||
<span class="setup-mark" aria-hidden="true">!</span>
|
||||
<div class="setup-body">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span class="muted small">{{ item.message }}</span>
|
||||
</div>
|
||||
{% if item.url %}
|
||||
<a class="btn ghost" href="{{ item.url }}">{{ item.action or 'Deschide' }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid weigh-grid">
|
||||
<section class="card flow-card">
|
||||
<div class="flow-head">
|
||||
<h2 id="flow-title">Pachet nou</h2>
|
||||
<span id="flow-progress" class="chip"></span>
|
||||
</div>
|
||||
<div id="flow-body" class="flow-body"></div>
|
||||
<p id="flow-message" class="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="card scale-card">
|
||||
<div class="scale-head">
|
||||
<span id="scale-dot" class="dot off"></span>
|
||||
<span id="scale-state">{{ "Se conectează…" if scale_ready else "Introducere manuală" }}</span>
|
||||
<span id="scale-profile" class="chip">{{ active_profile or "fără profil" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="weight-display">
|
||||
<span id="weight-value">--</span>
|
||||
<span id="weight-unit" class="weight-unit"></span>
|
||||
</div>
|
||||
|
||||
<div class="weight-meta">
|
||||
<span id="stability" class="badge">—</span>
|
||||
<span id="age" class="muted"></span>
|
||||
<span id="raw" class="raw"></span>
|
||||
</div>
|
||||
|
||||
<p id="scale-error" class="message error">{% if scale_error %}{{ scale_error }}{% endif %}</p>
|
||||
|
||||
{% if profiles %}
|
||||
<div class="scale-hint">
|
||||
<span>Profil:</span>
|
||||
<select id="profile-select" class="inline-select">
|
||||
{% for p in profiles %}
|
||||
<option value="{{ p.name }}" {{ 'selected' if p.name == active_profile }}>
|
||||
{{ p.label }} · {{ p.port }} · {{ p.protocol }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="scale-hint">
|
||||
<span>Cântarul nu este configurat — cântărește manual.</span>
|
||||
<a class="link" href="{{ url_for('monitor.index') }}">Monitor</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="qz-hint">
|
||||
<span>Imprimantă:</span>
|
||||
<select class="inline-select" data-qz-printer>
|
||||
<option value="">Implicit (sistem)</option>
|
||||
</select>
|
||||
<button class="link" type="button" data-qz-connect>Conectează</button>
|
||||
<button class="link" type="button" data-qz-test>Test</button>
|
||||
<span class="muted small" data-qz-version></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Ultimele pachete</h2>
|
||||
<a class="link" href="{{ url_for('history.index') }}">Vezi tot istoricul →</a>
|
||||
</div>
|
||||
{% if recent %}
|
||||
<div class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead>
|
||||
<tr><th>Finalizat</th><th>Produs finit</th><th>Componente</th><th class="num">Total</th><th>Lot</th><th>Tipărit</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in recent %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ (p.completed_at or p.created_at)|dt }}</td>
|
||||
<td>{{ p.recipe_code }} · {{ p.recipe_name }}</td>
|
||||
<td class="muted">{{ p.item_summary or "-" }}</td>
|
||||
<td class="num">{{ (p.total_weight or 0)|weight }} {{ p.unit }}</td>
|
||||
<td>{{ p.lot or "-" }}</td>
|
||||
<td>{{ "da" if p.printed_at else "nu" }}</td>
|
||||
<td class="right">
|
||||
{% if p.status == "done" %}
|
||||
<a class="link" target="_blank" rel="noopener"
|
||||
href="{{ url_for('printing.print_label', package_id=p.id, auto='0') }}">Etichetă</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Niciun pachet finalizat încă.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<iframe id="print-frame" class="print-frame" title="Tipărire etichetă" aria-hidden="true"></iframe>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.CANTARIRE = {
|
||||
pollMs: {{ poll_ms|int }},
|
||||
scaleReady: {{ 'true' if scale_ready else 'false' }},
|
||||
activeProfile: {{ active_profile|tojson }},
|
||||
defaultOperator: {{ default_operator|tojson }},
|
||||
defaultTemplateId: {{ default_template_id|tojson }},
|
||||
autoPrint: {{ 'true' if auto_print else 'false' }},
|
||||
initial: {{ initial|tojson }},
|
||||
recipes: {{ recipes|tojson }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/weigh.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ "Câmp nou" if is_new else "Editare câmp" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>{{ "Câmp nou" if is_new else "Editare câmp" }}</h1>
|
||||
<p class="subtitle">
|
||||
Scrie textul etichetei; între acolade poți referi un câmp existent.
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn ghost" href="{{ url_for('fields.index') }}">Înapoi la listă</a>
|
||||
</section>
|
||||
|
||||
<section class="card narrow">
|
||||
<form method="post" class="form">
|
||||
<div class="field">
|
||||
<label for="label">Denumire <span class="req">*</span></label>
|
||||
<input id="label" name="label" type="text" maxlength="{{ max_label }}" required
|
||||
value="{{ field.label or '' }}" placeholder="ex. Contact firmă" autofocus>
|
||||
<p class="muted small">Apare în popup-ul din designer.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="template">Text <span class="req">*</span></label>
|
||||
<textarea id="template" name="template" rows="3" maxlength="{{ max_template }}"
|
||||
placeholder="ex. Tel. {{ '{{company.phone}}' }}">{{ field.template or '' }}</textarea>
|
||||
<p class="muted small">
|
||||
Poți folosi orice câmp din tabelul de mai jos, scris între <code>{{ '{{' }}</code> și
|
||||
<code>{{ '}}' }}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Previzualizare cu date demo</label>
|
||||
<div id="field-preview" class="field-preview muted">—</div>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" name="active"
|
||||
{{ 'checked' if field.active in [True, 1, '1', 'on', 'true'] or is_new }}>
|
||||
Câmp activ (apare în designer)
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează</button>
|
||||
<a class="btn ghost" href="{{ url_for('fields.index') }}">Renunță</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card narrow">
|
||||
<h2>Câmpuri disponibile</h2>
|
||||
<p class="muted small">Click pe un câmp pentru a-l insera în text.</p>
|
||||
<div class="field-chips">
|
||||
{% for c in catalog %}
|
||||
<button class="field-chip" type="button" data-insert="{{ c.key }}"
|
||||
title="{{ c.label }} · exemplu: {{ c.sample }}">
|
||||
<span class="chip-label">{{ c.label }}</span>
|
||||
<span class="chip-sample">{{ c.sample }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var textarea = document.getElementById('template');
|
||||
var preview = document.getElementById('field-preview');
|
||||
var timer = null;
|
||||
var url = {{ url_for('fields.preview')|tojson }};
|
||||
|
||||
function refresh() {
|
||||
var text = textarea.value.trim();
|
||||
if (!text) {
|
||||
preview.textContent = '—';
|
||||
preview.className = 'field-preview muted';
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
fetch(url + '?template=' + encodeURIComponent(text), {
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) {
|
||||
preview.textContent = data.error;
|
||||
preview.className = 'field-preview error';
|
||||
return;
|
||||
}
|
||||
preview.textContent = data.value || '(gol)';
|
||||
preview.className = 'field-preview';
|
||||
})
|
||||
.catch(function () {
|
||||
preview.textContent = 'Previzualizarea nu a putut fi calculată.';
|
||||
preview.className = 'field-preview error';
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', refresh);
|
||||
refresh();
|
||||
|
||||
document.querySelectorAll('[data-insert]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var token = '{{ "{{" }}' + button.dataset.insert + '{{ "}}" }}';
|
||||
var start = textarea.selectionStart || 0;
|
||||
var end = textarea.selectionEnd || 0;
|
||||
textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = start + token.length;
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Câmpuri variabile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Câmpuri variabile</h1>
|
||||
<p class="subtitle">
|
||||
Text gata de pus pe etichetă, care poate prelua date din pachet, produs sau firmă.
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn primary" href="{{ url_for('fields.create') }}">+ Câmp nou</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if fields %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denumire</th>
|
||||
<th>Text</th>
|
||||
<th>Rezultat (demo)</th>
|
||||
<th>Cheie</th>
|
||||
<th>Șabloane</th>
|
||||
<th>Stare</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in fields %}
|
||||
<tr class="{{ 'row-muted' if not f.active }}">
|
||||
<td><strong>{{ f.label }}</strong></td>
|
||||
<td><code class="field-template">{{ f.template }}</code></td>
|
||||
<td>{{ f.preview or "-" }}</td>
|
||||
<td><code class="muted">{{ f.key }}</code></td>
|
||||
<td>
|
||||
{% if f.usage %}<a class="link" href="{{ url_for('label_templates.index') }}">{{ f.usage }}</a>
|
||||
{% else %}<span class="muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.active %}<span class="badge ok">activ</span>
|
||||
{% else %}<span class="badge off">inactiv</span>{% endif %}
|
||||
</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('fields.edit', field_id=f.id) }}">Editează</a>
|
||||
<form class="inline" method="post" action="{{ url_for('fields.remove', field_id=f.id) }}"
|
||||
onsubmit="return confirm('Ștergi câmpul {{ f.label }}?{% if f.usage %} Este folosit în {{ f.usage }} șabloane și va apărea gol.{% endif %}');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">
|
||||
Nu există câmpuri proprii. <a href="{{ url_for('fields.create') }}">Adaugă primul câmp</a>
|
||||
sau folosește câmpurile fixe din designer.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cum funcționează</h2>
|
||||
<p class="muted small">
|
||||
În textul câmpului poți scrie orice, iar între acolade poți pune un câmp existent:
|
||||
<code>Tel. {{ '{{company.phone}}' }}</code> sau
|
||||
<code>{{ '{{recipe.name}}' }} · lot {{ '{{package.lot}}' }}</code>.
|
||||
La tipărire, acestea sunt înlocuite cu datele reale. Câmpurile proprii apar în designer
|
||||
în grupul <strong>Câmpuri proprii</strong>.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Istoric produse finite{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Istoric produse finite</h1>
|
||||
<p class="subtitle">Filtrează, verifică și exportă pachetele finalizate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<form method="get" class="filters">
|
||||
<div class="field">
|
||||
<label for="date_from">De la</label>
|
||||
<input id="date_from" name="date_from" type="date" value="{{ filters.date_from }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="date_to">Până la</label>
|
||||
<input id="date_to" name="date_to" type="date" value="{{ filters.date_to }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="recipe_id">Produs finit</label>
|
||||
<select id="recipe_id" name="recipe_id">
|
||||
<option value="">— toate —</option>
|
||||
{% for r in recipes %}
|
||||
<option value="{{ r.id }}" {{ 'selected' if filters.recipe_id == r.id }}>{{ r.code }} · {{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">Stare</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">— toate —</option>
|
||||
<option value="done" {{ 'selected' if filters.status == 'done' }}>finalizat</option>
|
||||
<option value="open" {{ 'selected' if filters.status == 'open' }}>în lucru</option>
|
||||
<option value="void" {{ 'selected' if filters.status == 'void' }}>anulat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field grow">
|
||||
<label for="q">Căutare</label>
|
||||
<input id="q" name="q" type="search" value="{{ filters.query }}" placeholder="produs, lot, operator…">
|
||||
</div>
|
||||
<div class="field actions-field">
|
||||
<button class="btn primary" type="submit">Filtrează</button>
|
||||
<a class="btn" href="{{ url_for('history.index') }}">Resetează</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>{{ packages|length }} pachete · total {{ total }}</h2>
|
||||
<a class="btn ghost"
|
||||
href="{{ url_for('history.export_csv', date_from=filters.date_from, date_to=filters.date_to, recipe_id=filters.recipe_id or '', status=filters.status, q=filters.query) }}">Export CSV</a>
|
||||
</div>
|
||||
|
||||
{% if packages %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th><th>Produs finit</th><th>Componente</th><th class="num">Total</th>
|
||||
<th>Lot</th><th>Operator</th><th>Stare</th><th>Tipărit</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
<tr class="{{ 'row-muted' if p.status != 'done' }}">
|
||||
<td class="nowrap">{{ (p.completed_at or p.created_at)|dt }}</td>
|
||||
<td>{{ p.recipe_code }} · {{ p.recipe_name }}</td>
|
||||
<td class="muted small">{{ p.item_summary or "-" }}</td>
|
||||
<td class="num">{{ (p.total_weight or 0)|weight }} {{ p.unit }}</td>
|
||||
<td>{{ p.lot or "-" }}</td>
|
||||
<td>{{ p.operator or "-" }}</td>
|
||||
<td>
|
||||
{% if p.status == "done" %}<span class="badge ok">finalizat</span>
|
||||
{% elif p.status == "open" %}<span class="badge warn">în lucru</span>
|
||||
{% else %}<span class="badge err">anulat</span>{% endif %}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{% if p.printed_at %}<span class="badge ok">{{ p.print_count }}×</span>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
{% if p.status == "done" %}
|
||||
<a class="link" target="_blank" rel="noopener"
|
||||
href="{{ url_for('printing.print_label', package_id=p.id, auto='0') }}">Etichetă</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nicio înregistrare pentru filtrele selectate.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
<!doctype html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Etichetă {{ payload.fields.recipe_code }} · {{ payload.fields.total_weight }} {{ payload.fields.unit }}</title>
|
||||
<style>
|
||||
@page { size: {{ payload.template.width_mm }}mm {{ payload.template.height_mm }}mm; margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: #e9edf1; }
|
||||
.preview-bar {
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; background: #0f172a; color: #fff;
|
||||
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
.preview-bar button {
|
||||
margin-left: auto; padding: 8px 16px; border: 0; border-radius: 6px;
|
||||
background: #14b8a6; color: #04201d; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.preview-stage { padding: 24px; display: flex; justify-content: center; }
|
||||
.label-sheet {
|
||||
width: {{ payload.template.width_mm }}mm;
|
||||
height: {{ payload.template.height_mm }}mm;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 24px rgba(15, 23, 42, .18);
|
||||
}
|
||||
{{ payload.css|safe }}
|
||||
@media print {
|
||||
html, body { background: #fff; }
|
||||
.preview-bar, .preview-stage { padding: 0; }
|
||||
.preview-bar { display: none !important; }
|
||||
.label-sheet { box-shadow: none; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if preview %}
|
||||
<div class="preview-bar">
|
||||
<strong>Previzualizare șablon</strong>
|
||||
<span>{{ payload.template.name }} · {{ payload.template.width_mm|weight }} × {{ payload.template.height_mm|weight }} mm</span>
|
||||
<button type="button" onclick="window.print()">Tipărește previzualizarea</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="preview-stage">
|
||||
<div class="label-sheet" id="label-sheet">
|
||||
{{ payload.html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="label-payload" type="application/json">{{ payload|tojson }}</script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/qz-tray.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/qz-print.js') }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var payload = JSON.parse(document.getElementById('label-payload').textContent);
|
||||
var auto = {{ 'true' if auto else 'false' }};
|
||||
var preview = {{ 'true' if preview else 'false' }};
|
||||
|
||||
function acknowledge() {
|
||||
if (preview) return;
|
||||
try {
|
||||
fetch(payload.ack_url, { method: 'POST', headers: { 'Accept': 'application/json' } });
|
||||
} catch (e) { /* tipărirea a reușit; confirmarea este auxiliară */ }
|
||||
}
|
||||
|
||||
function doPrint() {
|
||||
if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') {
|
||||
Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) {
|
||||
if (result && result.method === 'qz') acknowledge();
|
||||
}, function (err) {
|
||||
console.error('Tipărirea QZ Tray a eșuat:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.print();
|
||||
acknowledge();
|
||||
}
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
if (!auto) return;
|
||||
setTimeout(doPrint, 120);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Monitor serial{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Monitor serial</h1>
|
||||
<p class="subtitle">Ascultă portul cântarului și află ce format trimite. Nu se scrie nimic către cântar.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="notice">
|
||||
<strong>Folosire:</strong> conectează cântarul, pornește o cântărire pe el (sau apasă Print) și rulează
|
||||
monitorul. Dacă nu apar bytes, încearcă <em>Scanează vitezele</em> sau altă setare de linie.
|
||||
O captură salvată aici este exact ce trebuie ca să adăugăm un protocol nou.
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form method="post" class="filters">
|
||||
<div class="field">
|
||||
<label for="port">Port</label>
|
||||
<input id="port" name="port" type="text" list="port-list" required
|
||||
placeholder="/dev/ttyUSB0 sau COM3" value="{{ values.port }}">
|
||||
<datalist id="port-list">
|
||||
{% for p in ports %}
|
||||
<option value="{{ p.device }}">{{ p.description or p.device }}{% if p.chip %} · {{ p.chip }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="baud">Baud</label>
|
||||
<input id="baud" name="baud" type="number" min="50" max="921600" value="{{ values.baud }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="framing">Framing</label>
|
||||
<select id="framing" name="framing">
|
||||
{% for f in framings %}
|
||||
<option value="{{ f }}" {{ 'selected' if values.framing == f }}>{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="seconds">Secunde</label>
|
||||
<input id="seconds" name="seconds" type="number" min="0.5" max="30" step="0.5" value="{{ values.seconds }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name">Nume captură</label>
|
||||
<input id="name" name="name" type="text" maxlength="60" value="{{ values.name }}">
|
||||
</div>
|
||||
<div class="field actions-field">
|
||||
<button class="btn primary" type="submit">Ascultă</button>
|
||||
</div>
|
||||
<div class="monitor-options">
|
||||
<label class="check"><input type="checkbox" name="scan" value="1" {{ 'checked' if values.scan }}> Scanează vitezele</label>
|
||||
<label class="check"><input type="checkbox" name="save" value="1" {{ 'checked' if values.save }}> Salvează captura</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if ports %}
|
||||
<p class="muted small">
|
||||
Porturi detectate:
|
||||
{% for p in ports %}<code>{{ p.device }}</code>{% if p.chip %} ({{ p.chip }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted small">Nu a fost detectat niciun port. Verifică adaptorul USB-RJ11 (driver, cablu).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<div class="notice warning">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if saved_path %}
|
||||
<div class="notice"><strong>Cauptră salvată:</strong> <code>{{ saved_path }}</code></div>
|
||||
{% endif %}
|
||||
|
||||
{% if scan_hits %}
|
||||
<section class="card">
|
||||
<h2>Viteze cu date</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Baud</th><th>Framing</th><th class="num">Bytes</th><th>Protocoale recunoscute</th></tr></thead>
|
||||
<tbody>
|
||||
{% for hit in scan_hits %}
|
||||
<tr>
|
||||
<td>{{ hit.baud }}</td>
|
||||
<td>{{ hit.framing }}</td>
|
||||
<td class="num">{{ hit.bytes }}</td>
|
||||
<td>
|
||||
{% if hit.guesses %}
|
||||
{% for g in hit.guesses %}<span class="badge ok">{{ g.protocol }}</span> {% endfor %}
|
||||
{% else %}<span class="muted">necunoscut</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Rezultat: {{ result.port }} · {{ result.baud }} {{ result.framing }}</h2>
|
||||
<span class="badge {{ 'ok' if result.ok else 'err' }}">{{ result.bytes }} bytes</span>
|
||||
</div>
|
||||
|
||||
{% if result.guesses %}
|
||||
<h3>Protocoale recunoscute</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead><tr><th>ID</th><th>Nume</th><th>Tip</th><th class="num">Valoare</th><th class="num">Cadre</th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in result.guesses %}
|
||||
<tr>
|
||||
<td><code>{{ g.protocol }}</code></td>
|
||||
<td>{{ g.name }}</td>
|
||||
<td>{{ g.kind }}</td>
|
||||
<td class="num">{{ g.value }} {{ g.unit }}</td>
|
||||
<td class="num">{{ g.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Niciun protocol cunoscut nu recunoaște datele. Salvează captura și o folosim ca să scriem parserul.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Cadre brute ({{ result.frames|length }})</h3>
|
||||
{% if result.frames %}
|
||||
<div class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead><tr><th class="num">Acum</th><th class="num">Bytes</th><th>Hex</th><th>Text</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in result.frames %}
|
||||
<tr>
|
||||
<td class="num">{{ f.age_ms }} ms</td>
|
||||
<td class="num">{{ f.size }}</td>
|
||||
<td><code class="hex">{{ f.hex }}</code></td>
|
||||
<td><code>{{ f.text }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Niciun byte primit. Cântarul poate fi setat să nu trimită nimic pe această interfață.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card help-card">
|
||||
<h2>Setări de încercat</h2>
|
||||
<ul class="var-list">
|
||||
<li><code>9600 8N1</code> — cel mai comun pentru cântare de magazin.</li>
|
||||
<li><code>9600 7E1</code> / <code>4800 7E1</code> — frecvent la indicatoarele vechi.</li>
|
||||
<li><code>9600 7O1</code> — dacă textul apare cu bitul 8 schimbat.</li>
|
||||
<li>Unele cântare trimit doar la apăsarea <em>Print</em>: ține monitorul pornit și apasă.</li>
|
||||
</ul>
|
||||
<h2>Cablaj modular (RJ11 / 10P4C)</h2>
|
||||
<p class="muted small">
|
||||
Un adaptor USB–RJ11 funcționează doar dacă cei patru ași ai prizei cântarului sunt
|
||||
<strong>TX, RX, GND</strong> 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.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ "Produs finit nou" if is_new else "Editare produs finit" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>{{ "Produs finit nou" if is_new else "Editare produs finit" }}</h1>
|
||||
<p class="subtitle">Ordinea componentelor este ordinea în care se cântăresc.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid template-grid">
|
||||
<section class="card">
|
||||
<form method="post" class="form">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="code">Cod <span class="req">*</span></label>
|
||||
<input id="code" name="code" type="text" maxlength="40" required value="{{ recipe.code or '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="unit">Unitate</label>
|
||||
<select id="unit" name="unit">
|
||||
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
|
||||
<option value="{{ u }}" {{ 'selected' if (recipe.unit or 'g') == u }}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="name">Denumire pe etichetă <span class="req">*</span></label>
|
||||
<input id="name" name="name" type="text" maxlength="120" required
|
||||
value="{{ recipe.name or '' }}" placeholder="ex. Fasole cu carne">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="target_weight">Greutate totală țintă (opțional)</label>
|
||||
<input id="target_weight" name="target_weight" type="number" step="0.001" min="0"
|
||||
value="{{ recipe.target_weight|weight if recipe.target_weight else '' }}" placeholder="ex. 450">
|
||||
<p class="muted small">Doar orientativă; eticheta afișează greutatea reală cântărită.</p>
|
||||
</div>
|
||||
|
||||
<h2>Etichetă</h2>
|
||||
<div class="field">
|
||||
<label for="template_id">Șablon de etichetă</label>
|
||||
<select id="template_id" name="template_id">
|
||||
<option value="">— șablonul implicit din Setări —</option>
|
||||
{% for t in templates %}
|
||||
<option value="{{ t.id }}" {{ 'selected' if recipe.template_id|string == t.id|string }}>
|
||||
{{ t.name }} · {{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="muted small">
|
||||
{% if templates %}
|
||||
Eticheta acestui produs finit se randează cu șablonul ales aici.
|
||||
<a href="{{ url_for('label_templates.index') }}">Vezi șabloanele</a> ·
|
||||
<a href="{{ url_for('label_templates.index') }}">creează unul nou</a>
|
||||
{% else %}
|
||||
Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează întâi un șablon</a>,
|
||||
altfel eticheta nu poate fi tipărită.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Componente</h2>
|
||||
<p class="muted small">Completează cel puțin primul rând. Ordinea rândurilor = ordinea cântăririi.</p>
|
||||
|
||||
{% for row in slot_rows %}
|
||||
<div class="component-row">
|
||||
<span class="component-index">{{ row.slot }}</span>
|
||||
<div class="field grow">
|
||||
<label for="component_{{ row.slot }}_id">
|
||||
Component {{ row.slot }}{% if row.slot == 1 %} <span class="req">*</span>{% endif %}
|
||||
</label>
|
||||
<select id="component_{{ row.slot }}_id" name="component_{{ row.slot }}_id">
|
||||
<option value="">— {{ "obligatoriu" if row.slot == 1 else "opțional" }} —</option>
|
||||
{% for c in components %}
|
||||
<option value="{{ c.id }}"
|
||||
{{ 'selected' if row.component_id|string == c.id|string }}>
|
||||
{{ c.code }} · {{ c.name }} ({{ c.unit }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field component-target">
|
||||
<label for="component_{{ row.slot }}_target">Țintă</label>
|
||||
<input id="component_{{ row.slot }}_target" name="component_{{ row.slot }}_target"
|
||||
type="number" step="0.001" min="0" value="{{ row.target|weight if row.target else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="field">
|
||||
<label for="note">Observații interne</label>
|
||||
<textarea id="note" name="note" rows="2" maxlength="500">{{ recipe.note or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" name="active" {{ 'checked' if recipe.active in [True, 1, '1', 'on', 'true'] or is_new }}>
|
||||
Produs finit activ
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează</button>
|
||||
<a class="btn ghost" href="{{ url_for('recipes.index') }}">Renunță</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="card help-card">
|
||||
<h2>Cum funcționează</h2>
|
||||
<p class="small">
|
||||
La cântărire alegi produsul finit. Aplicația cere pe rând fiecare componentă, în ordinea de aici.
|
||||
După ultima componentă eticheta se tipărește automat.
|
||||
</p>
|
||||
<h2>Eticheta produsului</h2>
|
||||
<p class="small">
|
||||
Șablonul ales aici este folosit automat la tipărirea etichetelor pentru acest produs finit;
|
||||
dacă lași gol, se folosește șablonul implicit din Setări.
|
||||
</p>
|
||||
<h2>Exemplu</h2>
|
||||
<p class="small">
|
||||
<strong>Fasole cu carne</strong>, unitate <code>g</code>:
|
||||
</p>
|
||||
<ul class="var-list">
|
||||
<li>1 · Fasole — țintă 250</li>
|
||||
<li>2 · Carne — țintă 200</li>
|
||||
</ul>
|
||||
<p class="small muted">
|
||||
Eticheta va arăta totalul real cântărit și defalcarea:
|
||||
<code>250 g Fasole</code>, <code>200 g Carne</code>.
|
||||
</p>
|
||||
<h2>Fără componentă nouă?</h2>
|
||||
<p class="small muted">
|
||||
Dacă lipsește o componentă din listă, o adaugi întâi în
|
||||
<a href="{{ url_for('components.create') }}">Componente</a>, apoi revii aici.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Produse finite{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Produse finite</h1>
|
||||
<p class="subtitle">Rețetele care se cântăresc și se etichetează.</p>
|
||||
</div>
|
||||
<a class="btn primary" href="{{ url_for('recipes.create') }}">+ Produs finit</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if recipes %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Cod</th><th>Denumire</th><th class="num">Componente</th><th class="num">Țintă</th><th>Etichetă</th><th>Stare</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recipes %}
|
||||
<tr class="{{ 'row-muted' if not r.active }}">
|
||||
<td><code>{{ r.code }}</code></td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="num">{{ r.component_count }}</td>
|
||||
<td class="num">{{ (r.target_weight|weight ~ ' ' ~ r.unit) if r.target_weight else "—" }}</td>
|
||||
<td>
|
||||
{% if r.template_name %}{{ r.template_name }}
|
||||
{% else %}<span class="muted">implicit</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.active %}<span class="badge ok">activ</span>
|
||||
{% else %}<span class="badge off">inactiv</span>{% endif %}
|
||||
</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('recipes.edit', recipe_id=r.id) }}">Editează</a>
|
||||
<form class="inline" method="post" action="{{ url_for('recipes.remove', recipe_id=r.id) }}"
|
||||
onsubmit="return confirm('Ștergi produsul finit {{ r.code }}? Dacă are pachete în istoric, va fi doar dezactivat.');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nu există produse finite. <a href="{{ url_for('recipes.create') }}">Creează primul</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Setări{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Setări</h1>
|
||||
<p class="subtitle">
|
||||
Datele firmei, cântarul și imprimanta; de aici ajungi la evidențele aplicației.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="hub-grid" aria-label="Administrare">
|
||||
<a class="hub-card" href="{{ url_for('components.index') }}">
|
||||
<span class="hub-title">Componente</span>
|
||||
<span class="hub-meta">{{ components|length }} definite · ce se cântărește</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('recipes.index') }}">
|
||||
<span class="hub-title">Produse finite</span>
|
||||
<span class="hub-meta">{{ recipes|length }} rețete · combinații de componente</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('label_templates.index') }}">
|
||||
<span class="hub-title">Șabloane</span>
|
||||
<span class="hub-meta">{{ templates|length }} șabloane · design de etichetă</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('fields.index') }}">
|
||||
<span class="hub-title">Câmpuri</span>
|
||||
<span class="hub-meta">{{ fields_count }}
|
||||
{{ 'variabilă' if fields_count == 1 else 'variabile' }} · text pentru etichete</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('history.index') }}">
|
||||
<span class="hub-title">Istoric</span>
|
||||
<span class="hub-meta">Pachete finalizate, filtre și export CSV</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('monitor.index') }}">
|
||||
<span class="hub-title">Monitor serial</span>
|
||||
<span class="hub-meta">Identifică un cântar nou</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="grid settings-grid">
|
||||
<section class="card">
|
||||
<form method="post" class="form">
|
||||
<h2>Date firmă</h2>
|
||||
<div class="field">
|
||||
<label for="company_name">Denumire firmă</label>
|
||||
<input id="company_name" name="company_name" type="text" maxlength="200"
|
||||
value="{{ app_settings.company_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="company_address">Adresă</label>
|
||||
<input id="company_address" name="company_address" type="text" maxlength="200"
|
||||
value="{{ app_settings.company_address }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="company_phone">Telefon</label>
|
||||
<input id="company_phone" name="company_phone" type="text" maxlength="200"
|
||||
value="{{ app_settings.company_phone }}">
|
||||
</div>
|
||||
|
||||
<h2>Cântar</h2>
|
||||
<div class="field">
|
||||
<label for="active_scale_profile">Profil cântar activ</label>
|
||||
<select id="active_scale_profile" name="active_scale_profile">
|
||||
<option value="">— introducere manuală —</option>
|
||||
{% for p in profiles %}
|
||||
<option value="{{ p.name }}" {{ 'selected' if app_settings.active_scale_profile == p.name }}>
|
||||
{{ p.label }} · {{ p.port }} · {{ p.protocol }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if not profiles %}
|
||||
<p class="muted small">
|
||||
Nu există profile. Configurează cântarul în SyncPulse Scale Bridge sau folosește
|
||||
<a href="{{ url_for('monitor.index') }}">monitorul serial</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Etichete</h2>
|
||||
<div class="field">
|
||||
<label for="default_template_id">Șablon implicit</label>
|
||||
<select id="default_template_id" name="default_template_id">
|
||||
<option value="">— primul disponibil —</option>
|
||||
{% for t in templates %}
|
||||
<option value="{{ t.id }}" {{ 'selected' if app_settings.default_template_id == t.id|string }}>
|
||||
{{ t.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="default_operator">Operator implicit</label>
|
||||
<input id="default_operator" name="default_operator" type="text" maxlength="64"
|
||||
value="{{ app_settings.default_operator }}">
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="auto_print" {{ 'checked' if app_settings.auto_print == '1' }}>
|
||||
Tipărește automat eticheta după ultima componentă
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează setările</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="card help-card">
|
||||
<h2>Starea cântarului</h2>
|
||||
<p>
|
||||
{% if scale_available %}
|
||||
<span class="badge ok">bibliotecă încărcată</span>
|
||||
{% else %}
|
||||
<span class="badge err">indisponibil</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if scale_error %}<p class="message error">{{ scale_error }}</p>{% endif %}
|
||||
|
||||
<h2>Profile detectate</h2>
|
||||
{% if profiles %}
|
||||
<ul class="profile-list">
|
||||
{% for p in profiles %}
|
||||
<li>
|
||||
<strong>{{ p.label }}</strong><br>
|
||||
<span class="muted">{{ p.name }} · {{ p.port }} · {{ p.protocol }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">Niciun profil salvat în <code>instance/bridge.json</code>.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Imprimantă (QZ Tray)</h2>
|
||||
<p class="muted small">
|
||||
Eticheta este generată de aplicație și tipărită pe imprimanta termică prin QZ Tray, separat de
|
||||
cântar. Biblioteca din <code>static/js/vendor/qz-tray.js</code> este patch-uită pentru
|
||||
autentificare cu pairing key.
|
||||
</p>
|
||||
<p class="qz-state-line">
|
||||
<span class="badge" data-qz-status>QZ Tray: se verifică…</span>
|
||||
<span class="muted small" data-qz-version></span>
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="qz-printer">Imprimantă implicită</label>
|
||||
<select id="qz-printer" data-qz-printer>
|
||||
<option value="">Implicit (sistem)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" data-qz-connect>Conectează</button>
|
||||
<button class="btn" type="button" data-qz-refresh>Reîncarcă lista</button>
|
||||
<button class="btn ghost" type="button" data-qz-test>Tipărește test</button>
|
||||
</div>
|
||||
|
||||
<h2>Integrare</h2>
|
||||
<p class="muted small">
|
||||
Aplicația importă direct pachetul <code>vendor/syncpulse-scale-bridge</code>; profilul serial
|
||||
este citit din <code>instance/bridge.json</code>.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Pentru teste fără hardware: <code>CANTARIRE_SIMULATE=mahak_mds</code>.
|
||||
</p>
|
||||
|
||||
<h2>Sistem</h2>
|
||||
<p class="small">
|
||||
{% if setup.ready %}<span class="badge ok">gata de cântărit</span>
|
||||
{% else %}<span class="badge warn">{{ setup.missing|length }} de completat</span>{% endif %}
|
||||
<span class="badge">schema v{{ db_status.schema_version }}</span>
|
||||
</p>
|
||||
<ul class="system-list small">
|
||||
<li><span class="muted">Bază de date</span> <code>{{ db_status.path }}</code></li>
|
||||
<li><span class="muted">Mărime</span> {{ db_status.size_human }}</li>
|
||||
<li><span class="muted">Integritate</span> {{ db_status.integrity }}</li>
|
||||
<li><span class="muted">Înregistrări</span>
|
||||
{{ db_status.counts.components }} componente ·
|
||||
{{ db_status.counts.recipes }} produse finite ·
|
||||
{{ db_status.counts.packages }} pachete</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<a class="btn" href="{{ url_for('main.settings_backup') }}">Descarcă backup</a>
|
||||
<a class="btn ghost" href="{{ url_for('main.healthz') }}" target="_blank" rel="noopener">Stare (/healthz)</a>
|
||||
</div>
|
||||
{% if backups %}
|
||||
<p class="muted small">Ultimele backup-uri:</p>
|
||||
<ul class="system-list small">
|
||||
{% for b in backups %}
|
||||
<li>{{ b.created_at.strftime('%d.%m.%Y %H:%M') }} · {{ b.size_human }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Designer etichetă · {{ template.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/designer.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="designer" id="designer"
|
||||
data-save-url="{{ url_for('label_templates.designer_save', template_id=template.id) }}"
|
||||
data-qr-url="{{ url_for('label_templates.designer_qr') }}">
|
||||
|
||||
<header class="designer-head">
|
||||
<div class="designer-head-main">
|
||||
<form method="post" class="rename-form"
|
||||
action="{{ url_for('label_templates.rename', template_id=template.id) }}">
|
||||
<input type="hidden" name="next" value="designer">
|
||||
<input id="template-name" name="name" type="text" maxlength="80" required
|
||||
value="{{ template.name }}" class="title-input"
|
||||
aria-label="Nume șablon" title="Numele șablonului">
|
||||
<button class="btn ghost" type="submit">Redenumește</button>
|
||||
</form>
|
||||
<p class="subtitle">
|
||||
Trage elementele pe etichetă. Previzualizarea folosește date demo; la tipărire se folosesc datele reale.
|
||||
</p>
|
||||
</div>
|
||||
<div class="designer-actions">
|
||||
<span id="designer-state" class="badge off">nesalvat</span>
|
||||
<a class="btn ghost" target="_blank" rel="noopener"
|
||||
href="{{ url_for('label_templates.preview', template_id=template.id) }}">Previzualizare</a>
|
||||
<button class="btn primary" type="button" id="designer-save">Salvează</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="designer-toolbar">
|
||||
<div class="tool-group tool-grow">
|
||||
<h2>Adaugă element</h2>
|
||||
<div class="palette">
|
||||
<button class="palette-btn" type="button" data-add-picker="variable">Câmp variabil</button>
|
||||
<button class="palette-btn" type="button" data-add="text">Text</button>
|
||||
<button class="palette-btn" type="button" data-add="qr">Cod QR</button>
|
||||
<button class="palette-btn" type="button" data-add="line">Linie</button>
|
||||
<button class="palette-btn" type="button" data-add="image">Imagine</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-group">
|
||||
<h2>Etichetă (mm)</h2>
|
||||
<div class="tool-fields">
|
||||
<div class="field">
|
||||
<label for="label-width">Lățime</label>
|
||||
<input id="label-width" type="number" min="10" max="500" step="0.5">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="label-height">Înălțime</label>
|
||||
<input id="label-height" type="number" min="10" max="500" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-group">
|
||||
<h2>Zoom</h2>
|
||||
<input id="designer-zoom" class="zoom-slider" type="range" min="3" max="14" step="0.5">
|
||||
</div>
|
||||
|
||||
<div class="tool-group tool-safe">
|
||||
<h2>Zonă sigură</h2>
|
||||
<label class="check"><input id="safe-toggle" type="checkbox" checked>
|
||||
Contur roșu</label>
|
||||
<p id="safe-hint" class="safe-hint muted">Zona sigură: {{ safe_mm }} mm de la fiecare margine.</p>
|
||||
<button id="safe-arrange" class="btn warn hidden" type="button"
|
||||
title="Mută elementele care ies din zona sigură în interiorul conturului roșu">
|
||||
Rearanjează în zona sigură
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="designer-grid">
|
||||
<aside class="designer-panel designer-elements">
|
||||
<h2>Elemente pe etichetă <span id="block-count" class="count">0</span></h2>
|
||||
<ul id="block-list" class="block-list"></ul>
|
||||
|
||||
<form method="post" class="designer-clear"
|
||||
action="{{ url_for('label_templates.designer_reset', template_id=template.id) }}"
|
||||
onsubmit="return confirm('Resetezi designul la varianta implicită? Modificările nesalvate se pierd.');">
|
||||
<button class="link danger" type="submit">Resetează designul</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section class="designer-canvas-wrap">
|
||||
<div class="canvas-scroll">
|
||||
<div class="canvas-stage">
|
||||
<div id="canvas" class="design-canvas">
|
||||
<div class="canvas-grid"></div>
|
||||
<div class="canvas-blocks" id="canvas-blocks"></div>
|
||||
<div class="canvas-safe" id="canvas-safe"><span>zonă sigură</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
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.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Recomandat: păstrează toate elementele în interiorul conturului roșu — imprimantele
|
||||
termice nu pot tipări până la marginea hârtiei.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside class="designer-panel" id="props">
|
||||
<h2>Proprietăți</h2>
|
||||
<p class="muted small" id="props-empty">Selectează un element.</p>
|
||||
<div id="props-body" class="hidden"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="field-picker" class="picker hidden" role="dialog" aria-modal="true"
|
||||
aria-labelledby="field-picker-title">
|
||||
<div class="picker-backdrop" data-picker-close></div>
|
||||
<div class="picker-card">
|
||||
<header class="picker-head">
|
||||
<h2 id="field-picker-title">Alege câmpul variabil</h2>
|
||||
<button class="picker-close" type="button" data-picker-close aria-label="Închide">×</button>
|
||||
</header>
|
||||
<input id="field-picker-search" type="search" autocomplete="off"
|
||||
placeholder="Caută un câmp…">
|
||||
<ul id="field-picker-list" class="picker-list"></ul>
|
||||
<footer class="picker-foot">
|
||||
<span class="muted small" id="field-picker-hint">Click pe un câmp pentru a-l adăuga pe etichetă.</span>
|
||||
<span class="picker-foot-actions">
|
||||
<a class="link" href="{{ url_for('fields.index') }}">Gestionează câmpurile</a>
|
||||
<button class="btn primary hidden" type="button" id="field-picker-confirm">Adaugă</button>
|
||||
<button class="btn ghost" type="button" data-picker-close>Anulează</button>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.DESIGNER = {
|
||||
design: {{ design|tojson }},
|
||||
fields: {{ fields|tojson }},
|
||||
demoFields: {{ demo_fields|tojson }},
|
||||
demoItems: {{ demo_items|tojson }},
|
||||
safeMm: {{ safe_mm }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/designer.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Șabloane etichete{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Șabloane etichete</h1>
|
||||
<p class="subtitle">Desenează eticheta în designer; fiecare produs finit poate folosi alt șablon.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="{{ url_for('label_templates.designer_create') }}"
|
||||
class="inline new-template-form">
|
||||
<input type="text" name="name" maxlength="80" required
|
||||
placeholder="Nume șablon nou" aria-label="Nume șablon nou">
|
||||
<button class="btn primary" type="submit">+ Șablon nou</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if templates %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Nume</th><th>Dimensiune</th><th>Elemente</th><th>Implicit</th><th>Actualizat</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in templates %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm</td>
|
||||
<td>
|
||||
{% if t.design_json %}
|
||||
<span class="badge ok">designer</span>
|
||||
{% else %}
|
||||
<span class="badge warn">fără design</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if t.is_default %}<span class="badge ok">implicit</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td class="nowrap muted">{{ t.updated_at|dt }}</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('label_templates.designer_page', template_id=t.id) }}">Designer</a>
|
||||
<a class="link" target="_blank" rel="noopener"
|
||||
href="{{ url_for('label_templates.preview', template_id=t.id) }}">Previzualizare</a>
|
||||
<form class="inline" method="post" action="{{ url_for('label_templates.remove', template_id=t.id) }}"
|
||||
onsubmit="return confirm('Ștergi șablonul {{ t.name }}?');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează unul</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
"""Utilitare de linie de comanda."""
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
) else (
|
||||
for /f "delims=" %%k in ('.venv\Scripts\python.exe -c "import secrets;print(secrets.token_urlsafe(48))"') do set "CANTARIRE_SECRET_KEY=%%k"
|
||||
if not exist "instance" mkdir "instance"
|
||||
> 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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1,4 @@
|
||||
Flask>=3.0,<4
|
||||
pyserial>=3.5,<4
|
||||
qrcode>=7.4,<9
|
||||
waitress>=3.0,<4
|
||||
@@ -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())
|
||||
@@ -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": "<STX>-0 000000000000<CR>", "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]
|
||||
@@ -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, '<div>x</div>', '', '')"
|
||||
)
|
||||
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
|
||||
@@ -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": "<script>x</script>"},
|
||||
{"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 "<script>" not in html
|
||||
assert "Fasole cu carne" in html
|
||||
assert "250 g" in html and "Carne" in html
|
||||
assert "<svg" in html # QR inline
|
||||
|
||||
|
||||
# -- continutul codului QR ---------------------------------------------------
|
||||
|
||||
def _qr_block(**extra):
|
||||
block = {"type": "qr", "x": 2, "y": 2, "w": 20, "h": 20}
|
||||
block.update(extra)
|
||||
return block
|
||||
|
||||
|
||||
def _qr(**extra):
|
||||
design = designer.validate_design(
|
||||
{"width_mm": 100, "height_mm": 60, "blocks": [_qr_block(**extra)]}, 100, 60)
|
||||
return design["blocks"][0]
|
||||
|
||||
|
||||
def test_qr_parts_can_combine_fields_and_literal_text():
|
||||
block = _qr(separator="dash", parts=[
|
||||
{"field": "recipe.code"},
|
||||
{"text": "LOT"},
|
||||
{"field": "package.lot"},
|
||||
])
|
||||
assert block["separator"] == "dash"
|
||||
payload = designer.qr_payload(block, designer.demo_fields())
|
||||
assert payload == "PF-001 - LOT - LOT-2610-A"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator,expected", [
|
||||
("pipe", "PF-001 | LOT-2610-A"),
|
||||
("dash", "PF-001 - LOT-2610-A"),
|
||||
("slash", "PF-001/LOT-2610-A"),
|
||||
("space", "PF-001 LOT-2610-A"),
|
||||
("newline", "PF-001\nLOT-2610-A"),
|
||||
("none", "PF-001LOT-2610-A"),
|
||||
])
|
||||
def test_qr_separator_variants(separator, expected):
|
||||
block = _qr(separator=separator, parts=[
|
||||
{"field": "recipe.code"}, {"field": "package.lot"},
|
||||
])
|
||||
assert designer.qr_payload(block, designer.demo_fields()) == expected
|
||||
|
||||
|
||||
def test_qr_unknown_separator_falls_back_to_pipe():
|
||||
block = _qr(separator="paranteze", parts=[{"field": "recipe.code"}])
|
||||
assert block["separator"] == "pipe"
|
||||
|
||||
|
||||
def test_qr_empty_field_parts_are_skipped():
|
||||
block = _qr(separator="pipe", parts=[
|
||||
{"field": "recipe.code"},
|
||||
{"field": "package.lot"},
|
||||
{"field": "company.address"},
|
||||
])
|
||||
fields = dict(designer.demo_fields())
|
||||
fields["package.lot"] = ""
|
||||
assert designer.qr_payload(block, fields) == "PF-001 | Str. Exemplu 1"
|
||||
|
||||
|
||||
def test_qr_parts_reject_unknown_fields_and_empty_text():
|
||||
block = _qr(parts=[
|
||||
{"field": "does.not.exist"},
|
||||
{"text": ""},
|
||||
{"field": "package.lot"},
|
||||
])
|
||||
assert block["parts"] == [{"field": "package.lot"}]
|
||||
|
||||
|
||||
def test_qr_legacy_field_becomes_a_single_part():
|
||||
block = _qr(field="package.lot")
|
||||
assert block["parts"] == [{"field": "package.lot"}]
|
||||
assert designer.qr_payload(block, designer.demo_fields()) == "LOT-2610-A"
|
||||
|
||||
|
||||
def test_qr_legacy_full_content_still_works():
|
||||
block = _qr(field="package.qr")
|
||||
assert block["parts"] == [{"field": "package.qr"}]
|
||||
payload = designer.qr_payload(block, designer.demo_fields())
|
||||
assert payload.startswith("PF-001 | LOT-2610-A | 450 g")
|
||||
|
||||
|
||||
def test_qr_parts_are_limited():
|
||||
parts = [{"field": "package.lot"} for _ in range(designer.MAX_QR_PARTS + 8)]
|
||||
block = _qr(parts=parts)
|
||||
assert len(block["parts"]) == designer.MAX_QR_PARTS
|
||||
|
||||
|
||||
def test_qr_accepts_custom_fields():
|
||||
block = _qr(parts=[{"field": "custom.contact"}])
|
||||
assert block["parts"] == [{"field": "custom.contact"}]
|
||||
assert designer.qr_payload(block, {"custom.contact": "Tel. 0700"}) == "Tel. 0700"
|
||||
|
||||
|
||||
def test_qr_without_parts_uses_full_content():
|
||||
block = {"type": "qr", "x": 1, "y": 1, "w": 20, "h": 20, "parts": []}
|
||||
assert designer.qr_payload(block, designer.demo_fields()).startswith("PF-001 |")
|
||||
|
||||
|
||||
def test_qr_payload_is_html_escaped_in_render():
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [_qr_block(parts=[{"text": "</svg><script>x</script>"}])],
|
||||
}, 100, 60)
|
||||
html = designer.render_design(design, designer.demo_fields(), designer.demo_items())
|
||||
assert "<script>x" not in html
|
||||
assert "<svg" in html
|
||||
|
||||
|
||||
def test_qr_svg_differs_for_different_payloads():
|
||||
a = designer.qr_svg("PF-001 | LOT-A")
|
||||
b = designer.qr_svg("PF-002 | LOT-B")
|
||||
assert a != b
|
||||
assert "<svg" in a and "<svg" in b
|
||||
|
||||
|
||||
def test_designer_page_loads(client):
|
||||
template_id = _designer_template(client)
|
||||
page = client.get(f"/sabloane/{template_id}/designer")
|
||||
assert page.status_code == 200
|
||||
html = page.get_data(as_text=True)
|
||||
assert "designer" in html
|
||||
assert "package.total_weight" in html # catalog de campuri
|
||||
assert "canvas-blocks" in html
|
||||
|
||||
|
||||
def test_designer_save_persists_and_renders(client):
|
||||
template_id = _designer_template(client)
|
||||
payload = {
|
||||
"design": {
|
||||
"version": 1, "width_mm": 80, "height_mm": 50,
|
||||
"blocks": [
|
||||
{"id": "b1", "type": "field", "x": 2, "y": 2, "w": 60, "h": 12,
|
||||
"field": "recipe.name", "style": {"size": 6, "bold": True}},
|
||||
{"id": "b2", "type": "field", "x": 2, "y": 16, "w": 60, "h": 12,
|
||||
"field": "package.total_weight", "suffix": " g"},
|
||||
],
|
||||
}
|
||||
}
|
||||
response = client.post(f"/sabloane/{template_id}/designer", json=payload)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["ok"] is True
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
stored = get_template(template_id)
|
||||
assert stored["design_json"]
|
||||
assert stored["width_mm"] == 80 and stored["height_mm"] == 50
|
||||
saved = json.loads(stored["design_json"])
|
||||
assert len(saved["blocks"]) == 2
|
||||
|
||||
|
||||
def test_designer_save_rejects_invalid_payload(client):
|
||||
template_id = _designer_template(client)
|
||||
response = client.post(f"/sabloane/{template_id}/designer", json={"design": "nope"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_designer_qr_endpoint(client):
|
||||
response = client.get("/sabloane/designer/qr.svg?data=PF-001|LOT-1")
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "image/svg+xml"
|
||||
assert b"<svg" in response.data
|
||||
|
||||
|
||||
def test_label_uses_design_when_present(client, recipe):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_default_template, save_template_design
|
||||
|
||||
template = get_default_template()
|
||||
design = designer.default_design(template["width_mm"], template["height_mm"])
|
||||
save_template_design(template["id"], designer.dump_design(design))
|
||||
|
||||
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"})
|
||||
done = client.post(f"/api/packages/{package_id}/items", json={"weight": "200"}).get_json()
|
||||
|
||||
label = done["label"]
|
||||
assert "design-label" in label["html"]
|
||||
assert "Fasole cu carne" in label["html"]
|
||||
assert "450" in label["html"]
|
||||
assert ".d-block" in label["css"]
|
||||
|
||||
page = client.get(label["print_url"] + "?auto=0")
|
||||
assert page.status_code == 200
|
||||
assert "design-label" in page.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_reset_design_restores_default(client):
|
||||
template_id = _designer_template(client)
|
||||
client.post(f"/sabloane/{template_id}/designer", json={
|
||||
"design": {"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [{"type": "text", "x": 1, "y": 1, "w": 20, "h": 4, "text": "X"}]}
|
||||
})
|
||||
reset = client.post(f"/sabloane/{template_id}/designer/reset")
|
||||
assert reset.status_code == 302
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire import designer
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
stored = get_template(template_id)
|
||||
design = designer.load_design(stored["design_json"], 100, 60)
|
||||
assert len(design["blocks"]) == len(designer.default_design(100, 60)["blocks"])
|
||||
assert stored["design_json"] != "" # sablonul nu rămâne fara eticheta
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Campuri variabile proprii (custom.*): CRUD, validare si randare pe eticheta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire import designer
|
||||
|
||||
|
||||
def _create_field(client, label="Contact firmă", template="Tel. {{company.phone}}",
|
||||
active="on"):
|
||||
return client.post("/campuri/nou", data={
|
||||
"label": label, "template": template, "active": active,
|
||||
}, follow_redirects=True)
|
||||
|
||||
|
||||
def test_fields_page_and_nav(client):
|
||||
page = client.get("/campuri")
|
||||
assert page.status_code == 200
|
||||
html = page.get_data(as_text=True)
|
||||
assert "Câmpuri variabile" in html
|
||||
assert 'href="/campuri/nou"' in html
|
||||
# link in subnavigatia din Setari, nu in bara principala
|
||||
flat = " ".join(html.split())
|
||||
assert 'href="/campuri"' in flat
|
||||
assert ">Câmpuri</a>" in flat
|
||||
|
||||
|
||||
def test_create_field_generates_key_and_shows_preview(client):
|
||||
_create_field(client)
|
||||
html = client.get("/campuri").get_data(as_text=True)
|
||||
assert "Contact firmă" in html
|
||||
assert "custom.contact_firma" in html
|
||||
assert "Tel. 0700 000 000" in html # rezultatul demo
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
row = get_label_field_by_key("custom.contact_firma")
|
||||
assert row is not None and row["active"] == 1
|
||||
|
||||
|
||||
def test_duplicate_labels_get_unique_keys(client):
|
||||
_create_field(client, label="Alergeni", template="Poate conține gluten")
|
||||
_create_field(client, label="Alergeni", template="Poate conține lactoză")
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_label_fields
|
||||
|
||||
keys = [row["key"] for row in list_label_fields()]
|
||||
assert keys == ["custom.alergeni", "custom.alergeni_2"]
|
||||
|
||||
|
||||
def test_unknown_placeholder_is_rejected(client):
|
||||
response = _create_field(client, label="Greșit", template="X {{does.not.exist}}")
|
||||
html = response.get_data(as_text=True)
|
||||
assert "Campuri necunoscute" in html
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_label_fields
|
||||
|
||||
assert list_label_fields() == []
|
||||
|
||||
|
||||
def test_empty_template_is_rejected(client):
|
||||
response = _create_field(client, label="Gol", template=" ")
|
||||
assert "Textul campului este obligatoriu." in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_update_keeps_key_and_can_deactivate(client):
|
||||
_create_field(client)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
field_id = get_label_field_by_key("custom.contact_firma")["id"]
|
||||
|
||||
client.post(f"/campuri/{field_id}/editare", data={
|
||||
"label": "Contact (nou)", "template": "{{company.phone}}",
|
||||
}, follow_redirects=True)
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field, list_label_fields
|
||||
|
||||
row = get_label_field(field_id)
|
||||
assert row["label"] == "Contact (nou)"
|
||||
assert row["key"] == "custom.contact_firma" # cheia ramane stabila
|
||||
assert row["active"] == 0 # fara bifa = inactiv
|
||||
assert [f["key"] for f in list_label_fields()] == [] # ascuns din designer
|
||||
|
||||
|
||||
def test_delete_field(client):
|
||||
_create_field(client, label="Șters", template="text")
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
field_id = get_label_field_by_key("custom.sters")["id"]
|
||||
response = client.post(f"/campuri/{field_id}/stergere", follow_redirects=True)
|
||||
assert "a fost sters" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
assert get_label_field_by_key("custom.sters") is None
|
||||
|
||||
|
||||
def test_preview_endpoint(client):
|
||||
ok = client.get("/campuri/previzualizare",
|
||||
query_string={"template": "{{recipe.name}} · {{package.lot}}"})
|
||||
assert ok.get_json() == {"ok": True, "value": "Fasole cu carne · LOT-2610-A"}
|
||||
|
||||
bad = client.get("/campuri/previzualizare", query_string={"template": "{{nope.x}}"})
|
||||
data = bad.get_json()
|
||||
assert data["ok"] is False and "Campuri necunoscute" in data["error"]
|
||||
|
||||
|
||||
def test_custom_field_is_offered_in_designer_picker(client):
|
||||
_create_field(client, label="Contact firmă", template="Tel. {{company.phone}}")
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Cu camp propriu"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert "custom.contact_firma" in html
|
||||
assert "Tel. 0700 000 000" in html # sample pentru previzualizare
|
||||
# denumirea trece prin tojson, deci diacriticele sunt escapate
|
||||
assert "Contact firm\\u0103" in html
|
||||
|
||||
|
||||
def test_custom_field_renders_on_label(client):
|
||||
_create_field(client, label="Contact", template="Tel. {{company.phone}}")
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [
|
||||
{"type": "field", "x": 3, "y": 3, "w": 70, "h": 6, "field": "custom.contact"},
|
||||
],
|
||||
}, 100, 60)
|
||||
assert design["blocks"][0]["field"] == "custom.contact"
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_label_fields
|
||||
|
||||
fields = designer.apply_custom_fields(designer.demo_fields(), list_label_fields())
|
||||
html = designer.render_design(design, fields, [])
|
||||
assert "Tel. 0700 000 000" in html
|
||||
|
||||
|
||||
def test_deleted_custom_field_renders_empty_not_wrong(client):
|
||||
"""Un design care refera un camp sters nu trebuie sa cada sau sa afiseze altceva."""
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [{"type": "field", "x": 3, "y": 3, "w": 70, "h": 6, "field": "custom.sters"}],
|
||||
}, 100, 60)
|
||||
assert design["blocks"][0]["field"] == "custom.sters"
|
||||
html = designer.render_design(design, designer.demo_fields(), [])
|
||||
assert "Fasole cu carne" not in html # nu cade pe alt camp
|
||||
assert "> <" in html or html.count("d-field") == 1
|
||||
|
||||
|
||||
def test_custom_field_template_rejects_custom_reference():
|
||||
"""Campurile proprii se pot referi doar la campuri fixe (fara bucle)."""
|
||||
errors = designer.custom_template_errors("X {{custom.altceva}}")
|
||||
assert errors and "Campuri necunoscute" in errors[0]
|
||||
assert designer.custom_template_errors("{{company.phone}}") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("", ""),
|
||||
("text simplu", "text simplu"),
|
||||
("{{company.name}}", "Moto Adv SRL"),
|
||||
("{{ package.lot }}/{{package.operator}}", "LOT-2610-A/Maria"),
|
||||
])
|
||||
def test_expand_custom(text, expected):
|
||||
assert designer.expand_custom(text, designer.demo_fields()) == expected
|
||||
|
||||
|
||||
def test_usage_counter_reports_templates(client):
|
||||
_create_field(client, label="Folosit", template="X")
|
||||
client.post("/sabloane/designer/nou", data={"name": "Cu folosit"})
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import (count_label_field_usage, get_default_template,
|
||||
get_label_field_by_key, list_templates,
|
||||
save_template_design)
|
||||
|
||||
field = get_label_field_by_key("custom.folosit")
|
||||
target = [t for t in list_templates() if t["name"] == "Cu folosit"][0]
|
||||
payload = {"version": 1, "width_mm": 100, "height_mm": 60, "blocks": [
|
||||
{"id": "b1", "type": "field", "x": 1, "y": 1, "w": 50, "h": 6,
|
||||
"field": "custom.folosit"},
|
||||
]}
|
||||
save_template_design(target["id"], json.dumps(payload))
|
||||
assert count_label_field_usage(field["key"]) == 1
|
||||
assert count_label_field_usage("custom.inexistent") == 0
|
||||
assert get_default_template() is not None
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Pregatire pentru productie: backup, reset, verificare, healthcheck."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire import maintenance
|
||||
|
||||
|
||||
def test_status_reports_counts_and_integrity(client):
|
||||
with client.application.app_context():
|
||||
status = maintenance.database_status()
|
||||
assert status["exists"] is True
|
||||
assert status["integrity"] == "ok"
|
||||
assert status["schema_version"] == maintenance.SCHEMA_VERSION
|
||||
assert set(status["counts"]) == {
|
||||
"components", "recipes", "packages", "package_items", "templates", "fields"
|
||||
}
|
||||
assert status["backups"] == 0
|
||||
|
||||
|
||||
def test_backup_creates_file_and_is_listed(client):
|
||||
with client.application.app_context():
|
||||
path = maintenance.backup_database("test")
|
||||
assert path.exists() and path.read_bytes()[:15] == b"SQLite format 3"
|
||||
listed = maintenance.list_backups()
|
||||
assert [item["name"] for item in listed] == [path.name]
|
||||
assert listed[0]["size"] > 0
|
||||
|
||||
|
||||
def test_backup_prune_keeps_newest(client):
|
||||
with client.application.app_context():
|
||||
first = maintenance.backup_database("a")
|
||||
second = maintenance.backup_database("b")
|
||||
removed = maintenance.prune_backups(keep=1)
|
||||
listed = maintenance.list_backups()
|
||||
assert removed == 1
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["path"] in (str(first), str(second))
|
||||
|
||||
|
||||
def test_readiness_lists_missing_setup(client):
|
||||
with client.application.app_context():
|
||||
summary = maintenance.readiness_summary()
|
||||
keys = {item["key"]: item for item in summary["checks"]}
|
||||
assert set(keys) == {
|
||||
"database", "company", "components", "recipes", "template",
|
||||
"company_contact", "scale", "secret",
|
||||
}
|
||||
# baza e goala intr-un test nou
|
||||
assert keys["components"]["ok"] is False
|
||||
assert keys["recipes"]["ok"] is False
|
||||
assert keys["template"]["ok"] is True # sablonul implicit exista
|
||||
assert summary["ready"] is False
|
||||
assert "Componente" in {item["label"] for item in summary["missing"]}
|
||||
|
||||
|
||||
def test_readiness_becomes_true_after_setup(client, recipe):
|
||||
client.post("/setari", data={
|
||||
"company_name": "Fabrica SRL",
|
||||
"company_phone": "0700 111 222",
|
||||
"active_scale_profile": "",
|
||||
"default_template_id": "",
|
||||
"default_operator": "Ana",
|
||||
"auto_print": "on",
|
||||
})
|
||||
with client.application.app_context():
|
||||
summary = maintenance.readiness_summary()
|
||||
assert summary["ready"] is True, [i for i in summary["missing"]]
|
||||
assert summary["missing"] == []
|
||||
|
||||
|
||||
def test_reset_clears_data_and_restores_defaults(client, recipe):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_components, list_recipes, list_templates
|
||||
|
||||
assert list_components() and list_recipes()
|
||||
result = maintenance.reset_database()
|
||||
assert result["backup"] and maintenance.list_backups()
|
||||
assert list_components() == []
|
||||
assert list_recipes() == []
|
||||
templates = list_templates()
|
||||
assert len(templates) == 1 # sablonul standard recreat
|
||||
status = maintenance.database_status()
|
||||
from cantarire.db import all_settings
|
||||
|
||||
settings = all_settings()
|
||||
assert status["counts"]["components"] == 0
|
||||
assert settings["company_name"] == "Firma SRL" # valoarea implicita
|
||||
assert settings["schema_version"] == str(maintenance.SCHEMA_VERSION)
|
||||
assert result["removed"]["components"] == 2
|
||||
|
||||
|
||||
def test_reset_without_backup(client, recipe):
|
||||
with client.application.app_context():
|
||||
before = len(maintenance.list_backups())
|
||||
result = maintenance.reset_database(keep_backup=False)
|
||||
after = len(maintenance.list_backups())
|
||||
assert result["backup"] is None
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_seed_demo_creates_data(client):
|
||||
with client.application.app_context():
|
||||
result = maintenance.seed_demo()
|
||||
from cantarire.repositories import get_component_by_code, list_recipes
|
||||
|
||||
assert result["created"]["components"] == 3
|
||||
assert result["created"]["recipes"] == 2
|
||||
assert get_component_by_code("FAS") is not None
|
||||
codes = {r["code"] for r in list_recipes()}
|
||||
assert {"PF-001", "PF-002"} <= codes
|
||||
|
||||
# idempotent: a doua rulare nu dubleaza nimic
|
||||
again = maintenance.seed_demo()
|
||||
assert again["created"] == {"components": 0, "recipes": 0}
|
||||
|
||||
|
||||
def test_healthz_endpoint(client):
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["database"]["integrity"] == "ok"
|
||||
assert data["setup"]["ready"] is False
|
||||
assert "Componente" in data["setup"]["missing"]
|
||||
|
||||
|
||||
def test_settings_page_shows_system_card(client):
|
||||
html = client.get("/setari").get_data(as_text=True)
|
||||
assert "<h2>Sistem</h2>" in html
|
||||
assert "Descarcă backup" in html
|
||||
assert "/healthz" in html
|
||||
|
||||
|
||||
def test_backup_download_route(client):
|
||||
response = client.get("/setari/backup")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Disposition"].startswith("attachment")
|
||||
assert response.data[:15] == b"SQLite format 3"
|
||||
with client.application.app_context():
|
||||
assert len(maintenance.list_backups()) == 1
|
||||
|
||||
|
||||
def test_dashboard_shows_setup_checklist_when_empty(client):
|
||||
html = client.get("/").get_data(as_text=True)
|
||||
assert "Pregătire pentru prima cântărire" in html
|
||||
assert "Adaugă component" in html
|
||||
assert "Adaugă produs finit" in html
|
||||
|
||||
|
||||
def test_dashboard_hides_checklist_when_ready(client, recipe):
|
||||
client.post("/setari", data={
|
||||
"company_name": "Fabrica SRL", "company_phone": "0700 111 222",
|
||||
"active_scale_profile": "", "default_template_id": "", "auto_print": "on",
|
||||
})
|
||||
html = client.get("/").get_data(as_text=True)
|
||||
assert "Pregătire pentru prima cântărire" not in html
|
||||
|
||||
|
||||
def test_admin_cli_status_and_check(client):
|
||||
"""CLI-ul ruleaza pe aplicatia de test, fara sa atinga baza reala."""
|
||||
from cantarire.tools import admin
|
||||
|
||||
app = client.application
|
||||
assert admin.main(["status"], app=app) == 0
|
||||
# pe baza de test goala, check semnaleaza lipsuri
|
||||
assert admin.main(["check", "--json"], app=app) == 1
|
||||
|
||||
|
||||
def test_ensure_instance_dirs(client):
|
||||
with client.application.app_context():
|
||||
maintenance.ensure_instance_dirs()
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(client.application.config["INSTANCE_DIR"])
|
||||
assert (root / "backups").is_dir()
|
||||
assert (root / "captures").is_dir()
|
||||
|
||||
|
||||
def test_reset_report_is_readable(client):
|
||||
with client.application.app_context():
|
||||
result = maintenance.reset_database(keep_backup=False)
|
||||
text = maintenance.reset_report(result)
|
||||
assert "Date sterse" in text
|
||||
assert "Backup: (nu s-a facut)" in text
|
||||
assert "schema v" in text
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Navigatia: doua taburi principale (Cantarire, Setari) si subnavigatia de administrare."""
|
||||
from __future__ import annotations
|
||||
|
||||
MAIN_TABS = ("Cântărire", "Setări")
|
||||
|
||||
ADMIN_LINKS = (
|
||||
("/componente", "Componente"),
|
||||
("/retete", "Produse finite"),
|
||||
("/sabloane", "Șabloane"),
|
||||
("/campuri", "Câmpuri"),
|
||||
("/istoric", "Istoric"),
|
||||
("/monitor", "Monitor serial"),
|
||||
)
|
||||
|
||||
|
||||
def _html(client, path):
|
||||
return client.get(path).get_data(as_text=True)
|
||||
|
||||
|
||||
def _flat(html: str) -> str:
|
||||
"""Colapseaza spatiile, ca sa nu depinda de indentarea Jinja."""
|
||||
return " ".join(html.split())
|
||||
|
||||
|
||||
def test_dashboard_has_only_two_main_tabs(client):
|
||||
html = _html(client, "/")
|
||||
assert 'class="nav"' in html
|
||||
for label in MAIN_TABS:
|
||||
assert f">{label}</a>" in _flat(html)
|
||||
|
||||
# sectiunile de administrare nu mai sunt in bara principala
|
||||
nav = _flat(html).split('class="nav"', 1)[1].split("</nav>", 1)[0]
|
||||
for _href, label in ADMIN_LINKS:
|
||||
assert f">{label}</a>" not in nav
|
||||
|
||||
# dashboard-ul nu arata subnavigatia
|
||||
assert 'class="subnav"' not in html
|
||||
|
||||
|
||||
def test_settings_page_is_hub_and_shows_subnav(client):
|
||||
html = _html(client, "/setari")
|
||||
flat = _flat(html)
|
||||
assert 'class="subnav"' in html
|
||||
# cardurile din hub
|
||||
assert 'class="hub-grid"' in html
|
||||
for href, label in ADMIN_LINKS:
|
||||
assert f'href="{href}"' in flat
|
||||
assert label in flat
|
||||
|
||||
# ambele taburi principale sunt prezente, Setari e activ
|
||||
for label in MAIN_TABS:
|
||||
assert f">{label}</a>" in flat
|
||||
assert 'class="active">Setări</a>' in flat
|
||||
|
||||
|
||||
def test_admin_pages_keep_subnav_and_mark_active(client):
|
||||
for path, label in ADMIN_LINKS:
|
||||
html = _flat(_html(client, path))
|
||||
assert 'class="subnav"' in html, path
|
||||
assert f'class="active">{label}</a>' in html, path
|
||||
|
||||
|
||||
def test_designer_page_is_under_templates_tab(client):
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Nav test"},
|
||||
follow_redirects=True)
|
||||
flat = _flat(response.get_data(as_text=True))
|
||||
assert 'class="subnav"' in flat
|
||||
assert 'class="active">Șabloane</a>' in flat
|
||||
# "Cantarire" nu e activ aici
|
||||
assert 'class="active">Cântărire</a>' not in flat
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDOR = ROOT / "vendor" / "syncpulse-scale-bridge"
|
||||
for path in (str(ROOT), str(VENDOR)):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from cantarire import probe # noqa: E402
|
||||
from scale_bridge.serialio import open_real # noqa: E402
|
||||
from scale_bridge.simulator import SimulatorBank, frame_for # noqa: E402
|
||||
|
||||
|
||||
def test_decode_recognises_toledo_stream():
|
||||
frame = frame_for("toledo_cont", 12.345, True)
|
||||
guesses = probe.decode(frame * 4)
|
||||
ids = [g["protocol"] for g in guesses]
|
||||
assert "toledo_cont" in ids
|
||||
best = guesses[0]
|
||||
assert best["protocol"] == "toledo_cont"
|
||||
assert best["value"] == pytest.approx(12.345, abs=1e-3)
|
||||
assert best["unit"] == "kg"
|
||||
|
||||
|
||||
def test_decode_unknown_binary_returns_nothing():
|
||||
assert probe.decode(b"\xf0\x01\x02\x03") == []
|
||||
|
||||
|
||||
def test_probe_port_reads_simulated_scale():
|
||||
opener = SimulatorBank("toledo_cont").opener(open_real)
|
||||
result = probe.probe_port("COM90", 9600, "8N1", 1.2, opener=opener)
|
||||
assert result["ok"] is True, result
|
||||
assert result["bytes"] > 0
|
||||
assert result["frames"]
|
||||
assert any(g["protocol"] == "toledo_cont" for g in result["guesses"])
|
||||
assert result["settings"].startswith("COM90 9600 8N1")
|
||||
|
||||
|
||||
def test_probe_port_rejects_bad_framing():
|
||||
with pytest.raises(probe.ProbeError):
|
||||
probe.probe_port("COM90", 9600, "9X9", 1)
|
||||
|
||||
|
||||
def test_probe_port_sweep_reports_hits():
|
||||
opener = SimulatorBank("toledo_cont").opener(open_real)
|
||||
rows = probe.sweep("COM90", seconds=0.6, framings=("8N1",), bauds=(9600, 4800),
|
||||
opener=opener)
|
||||
hits = [row for row in rows if row.get("bytes")]
|
||||
assert hits
|
||||
assert any(g["protocol"] == "toledo_cont" for g in hits[0]["guesses"])
|
||||
|
||||
|
||||
def test_save_capture_uses_one_frame_per_line(tmp_path):
|
||||
frames = [{"hex": "01 02 03"}, {"hex": "AA BB"}]
|
||||
target = probe.save_capture(tmp_path, "bizerba bc800", frames)
|
||||
assert target.exists()
|
||||
assert target.read_text(encoding="ascii").splitlines() == ["01 02 03", "AA BB"]
|
||||
assert "bizerba-bc800" in target.name
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Legatura produs finit -> sablon de eticheta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire.repositories import ValidationError
|
||||
|
||||
|
||||
def _template_id(client, name: str) -> int:
|
||||
response = client.post("/sabloane/designer/nou", data={"name": name},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
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 _full_recipe(client, recipe_id: int) -> dict:
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
return get_recipe(recipe_id)
|
||||
|
||||
|
||||
def _recipe_form(recipe: dict, template_id: str = "", code: str = "PF-001") -> dict:
|
||||
components = recipe.get("components") or []
|
||||
data = {
|
||||
"code": code,
|
||||
"name": recipe["name"],
|
||||
"unit": "g",
|
||||
"template_id": template_id,
|
||||
"active": "on",
|
||||
}
|
||||
for slot, item in enumerate(components, start=1):
|
||||
data[f"component_{slot}_id"] = str(item["component_id"])
|
||||
data[f"component_{slot}_target"] = "100"
|
||||
return data
|
||||
|
||||
|
||||
def _bind(client, recipe_id: int, template_id) -> None:
|
||||
"""Salveaza produsul finit cu sablonul dat (componentele raman neschimbate)."""
|
||||
full = _full_recipe(client, recipe_id)
|
||||
response = client.post(f"/retete/{recipe_id}/editare",
|
||||
data=_recipe_form(full, str(template_id or "")))
|
||||
assert response.status_code == 302, response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_recipe_form_lists_templates(client, fasole):
|
||||
page = client.get("/retete/nou")
|
||||
html = page.get_data(as_text=True)
|
||||
assert 'name="template_id"' in html
|
||||
assert "Șablon de etichetă" in html
|
||||
assert "Eticheta produsului" in html
|
||||
|
||||
|
||||
def test_recipe_can_be_bound_to_template(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta PF")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
stored = get_recipe(recipe["id"])
|
||||
assert stored["template_id"] == template_id
|
||||
assert stored["template_name"] == "Eticheta PF"
|
||||
|
||||
|
||||
def test_recipe_without_template_stays_empty(client, recipe):
|
||||
_bind(client, recipe["id"], "")
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
stored = get_recipe(recipe["id"])
|
||||
assert stored["template_id"] is None
|
||||
assert stored["template_name"] is None
|
||||
|
||||
|
||||
def test_unknown_template_is_rejected(client, recipe):
|
||||
full = _full_recipe(client, recipe["id"])
|
||||
response = client.post(f"/retete/{recipe['id']}/editare",
|
||||
data=_recipe_form(full, "9999"), follow_redirects=True)
|
||||
assert "nu exista" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
assert get_recipe(recipe["id"])["template_id"] is None
|
||||
|
||||
|
||||
def test_recipe_list_shows_template_name(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta listă")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
html = client.get("/retete").get_data(as_text=True)
|
||||
assert "Eticheta listă" in html
|
||||
assert "Etichetă" in html # coloana noua
|
||||
|
||||
|
||||
def test_package_uses_recipe_template(client, recipe):
|
||||
"""La pornirea unui pachet, sablonul produsului finit are prioritate."""
|
||||
bound = _template_id(client, "Eticheta legata")
|
||||
other = _template_id(client, "Eticheta implicita")
|
||||
_bind(client, recipe["id"], bound)
|
||||
client.post("/setari", data={
|
||||
"company_name": "Test SRL", "default_template_id": str(other),
|
||||
"active_scale_profile": "", "auto_print": "",
|
||||
})
|
||||
|
||||
response = client.post("/api/packages", json={
|
||||
"recipe_id": recipe["id"], "operator": "Ana", "lot": "L1",
|
||||
})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
package_id = response.get_json()["package"]["id"]
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_package
|
||||
|
||||
assert get_package(package_id)["template_id"] == bound
|
||||
|
||||
|
||||
def test_explicit_template_still_wins(client, recipe):
|
||||
bound = _template_id(client, "Eticheta legata 2")
|
||||
explicit = _template_id(client, "Eticheta explicita")
|
||||
_bind(client, recipe["id"], bound)
|
||||
|
||||
response = client.post("/api/packages", json={
|
||||
"recipe_id": recipe["id"], "template_id": explicit,
|
||||
})
|
||||
assert response.status_code == 201
|
||||
package_id = response.get_json()["package"]["id"]
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_package
|
||||
|
||||
assert get_package(package_id)["template_id"] == explicit
|
||||
|
||||
|
||||
def test_template_bound_to_active_recipe_cannot_be_deleted(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta protejata")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import delete_template
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
delete_template(template_id)
|
||||
assert "produse finite active" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_template_can_be_deleted_after_recipe_detached(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta detasata")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
_bind(client, recipe["id"], "")
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import delete_template, get_template
|
||||
|
||||
delete_template(template_id)
|
||||
assert get_template(template_id) is None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Integrare reala cu pachetul vendorizat `scale_bridge` (cântar simulat)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDOR = ROOT / "vendor" / "syncpulse-scale-bridge"
|
||||
if str(VENDOR) not in sys.path:
|
||||
sys.path.insert(0, str(VENDOR))
|
||||
|
||||
from cantarire.scale import ScaleService # noqa: E402
|
||||
from scale_bridge.config import Config as BridgeConfig # noqa: E402
|
||||
|
||||
|
||||
def test_scale_service_reads_simulated_scale(tmp_path):
|
||||
bridge_config = tmp_path / "bridge.json"
|
||||
config = BridgeConfig(bridge_config)
|
||||
config.save_profile({
|
||||
"name": "sim-toledo",
|
||||
"label": "Simulator Toledo",
|
||||
"port": "COM90",
|
||||
"protocol": "toledo_cont",
|
||||
"serial": {"port": "COM90", "baudrate": 9600, "bytesize": 8,
|
||||
"parity": "N", "stopbits": 1},
|
||||
})
|
||||
|
||||
service = ScaleService(bridge_config, simulate="toledo_cont")
|
||||
try:
|
||||
assert service.available, service.error
|
||||
names = [p["name"] for p in service.profiles()]
|
||||
assert names == ["sim-toledo"]
|
||||
|
||||
result = service.read("sim-toledo", max_age_ms=3000, wait_ms=5000)
|
||||
assert result["ok"] is True, result
|
||||
assert result["value"] is not None
|
||||
assert result["unit"] == "kg"
|
||||
assert result["profile"] == "sim-toledo"
|
||||
assert result["port"] == "COM90"
|
||||
assert result["raw_hex"]
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_scale_service_without_profiles(tmp_path):
|
||||
bridge_config = tmp_path / "bridge.json"
|
||||
BridgeConfig(bridge_config)
|
||||
service = ScaleService(bridge_config)
|
||||
try:
|
||||
result = service.read("")
|
||||
assert result["ok"] is False
|
||||
assert "profil" in result["error"].lower()
|
||||
finally:
|
||||
service.close()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Numele sabloanelor: creare cu nume propriu si redenumire."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire.repositories import ValidationError
|
||||
|
||||
|
||||
def _names(client):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_templates
|
||||
|
||||
return [t["name"] for t in list_templates()]
|
||||
|
||||
|
||||
def _template_by_name(client, name):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_templates
|
||||
|
||||
found = [t for t in list_templates() if t["name"] == name]
|
||||
assert found, f"{name!r} nu exista; gasit: {_names(client)}"
|
||||
return found[0]
|
||||
|
||||
|
||||
def test_create_with_custom_name(client):
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Etichetă 60x40"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
template = _template_by_name(client, "Etichetă 60x40")
|
||||
assert template["design_json"]
|
||||
# mergem direct in designer, nu in lista
|
||||
assert "Trage elementele pe etichetă" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_create_with_custom_dimensions(client):
|
||||
client.post("/sabloane/designer/nou",
|
||||
data={"name": "Mică", "width_mm": "60", "height_mm": "40"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Mică")
|
||||
assert template["width_mm"] == 60 and template["height_mm"] == 40
|
||||
with client.application.app_context():
|
||||
from cantarire import designer
|
||||
|
||||
design = designer.load_design(template["design_json"], 60, 40)
|
||||
# elementele implicite incap in 60x40
|
||||
for block in design["blocks"]:
|
||||
assert block["x"] + block["w"] <= 60.001
|
||||
assert block["y"] + block["h"] <= 40.001
|
||||
|
||||
|
||||
def test_create_without_name_generates_unique_name(client):
|
||||
client.post("/sabloane/designer/nou", data={}, follow_redirects=True)
|
||||
client.post("/sabloane/designer/nou", data={}, follow_redirects=True)
|
||||
names = _names(client)
|
||||
assert "Sablon designer" in names
|
||||
assert "Sablon designer 2" in names
|
||||
|
||||
|
||||
def test_duplicate_name_is_rejected(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Dublura"}, follow_redirects=True)
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Dublura"},
|
||||
follow_redirects=True)
|
||||
assert "Exista deja un sablon numit" in response.get_data(as_text=True)
|
||||
assert _names(client).count("Dublura") == 1
|
||||
|
||||
|
||||
def test_name_is_case_insensitive_unique(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Eticheta Mare"},
|
||||
follow_redirects=True)
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "eticheta mare"},
|
||||
follow_redirects=True)
|
||||
assert "Exista deja un sablon numit" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_rename_from_designer(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Nume vechi"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Nume vechi")
|
||||
|
||||
response = client.post(f"/sabloane/{template['id']}/redenumire",
|
||||
data={"name": "Nume nou", "next": "designer"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert "a fost redenumit" in response.get_data(as_text=True)
|
||||
assert "Nume nou" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
stored = get_template(template["id"])
|
||||
assert stored["name"] == "Nume nou"
|
||||
assert stored["design_json"] # designul nu se pierde
|
||||
|
||||
|
||||
def test_rename_to_existing_name_is_rejected(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Primul"}, follow_redirects=True)
|
||||
client.post("/sabloane/designer/nou", data={"name": "Al doilea"}, follow_redirects=True)
|
||||
second = _template_by_name(client, "Al doilea")
|
||||
|
||||
response = client.post(f"/sabloane/{second['id']}/redenumire",
|
||||
data={"name": "Primul"}, follow_redirects=True)
|
||||
assert "Exista deja un sablon" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
assert get_template(second["id"])["name"] == "Al doilea"
|
||||
|
||||
|
||||
def test_rename_to_same_name_is_noop(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Neschimbat"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Neschimbat")
|
||||
response = client.post(f"/sabloane/{template['id']}/redenumire",
|
||||
data={"name": "Neschimbat", "next": "designer"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert _names(client).count("Neschimbat") == 1
|
||||
|
||||
|
||||
def test_rename_with_empty_name_is_rejected(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Are nume"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Are nume")
|
||||
response = client.post(f"/sabloane/{template['id']}/redenumire",
|
||||
data={"name": " ", "next": "designer"},
|
||||
follow_redirects=True)
|
||||
assert "Numele sablonului este obligatoriu" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
assert get_template(template["id"])["name"] == "Are nume"
|
||||
|
||||
|
||||
def test_rename_unknown_template(client):
|
||||
response = client.post("/sabloane/9999/redenumire", data={"name": "X"},
|
||||
follow_redirects=True)
|
||||
assert "Sabloul nu exista" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_list_page_has_name_input(client):
|
||||
html = client.get("/sabloane").get_data(as_text=True)
|
||||
assert 'name="name"' in html
|
||||
assert "Nume șablon nou" in html
|
||||
assert "+ Șablon nou" in html
|
||||
# modul HTML/CSS nu mai exista
|
||||
assert "HTML/CSS" not in html
|
||||
|
||||
|
||||
def test_designer_page_has_rename_form(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Cu redenumire"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Cu redenumire")
|
||||
html = client.get(f"/sabloane/{template['id']}/designer").get_data(as_text=True)
|
||||
assert 'class="rename-form"' in html
|
||||
assert "Redenumește" in html
|
||||
assert 'value="Cu redenumire"' in html
|
||||
assert f'action="/sabloane/{template["id"]}/redenumire"' in html
|
||||
|
||||
|
||||
def test_validation_error_still_raised_for_blank_name(client):
|
||||
"""Guard la nivel de repository, independent de ruta."""
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import create_template
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
create_template({
|
||||
"name": " ", "width_mm": 100, "height_mm": 60,
|
||||
"body_html": " ", "design_json": "{}",
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.png binary
|
||||
*.ico binary
|
||||
@@ -0,0 +1,53 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
python: ["3.9", "3.12"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- run: python -m pip install -r requirements.txt pytest
|
||||
- run: python -m pytest -q
|
||||
|
||||
windows-exe:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: python -m pip install -r requirements-dev.txt
|
||||
- run: python scripts/build.py
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SyncPulseScaleBridge
|
||||
path: |
|
||||
dist/SyncPulseScaleBridge.exe
|
||||
dist/SyncPulseScaleBridge.exe.sha256
|
||||
- name: Attach to the release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file CHANGELOG.md
|
||||
gh release upload "$GITHUB_REF_NAME" dist/SyncPulseScaleBridge.exe dist/SyncPulseScaleBridge.exe.sha256 --clobber
|
||||
@@ -0,0 +1,22 @@
|
||||
# settings and logs written next to the program (they hold the access key)
|
||||
bridge.json
|
||||
bridge.json.broken
|
||||
bridge.log*
|
||||
|
||||
# build output
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv*/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
|
||||
# editors / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.2 — 2026-10-13
|
||||
|
||||
- Platform-neutral wording: the control page, command line, README and package metadata describe the bridge
|
||||
for any system that needs a weight; the *Connect* tab is now **Network access** and shows the request to send
|
||||
(`GET /weight?profile=…` with `X-Bridge-Token`).
|
||||
|
||||
## 1.1.1 — 2026-10-11
|
||||
|
||||
- Fix: a continuous format whose frames are closed only by the start of the next one (XK3190 / YH-T7E
|
||||
`=081.100=081.100…`) was not read by `/weight` when the indicator pauses between frames, so that every frame
|
||||
arrives on its own — detection found it, the live reading did not. Each chunk is now read together with the
|
||||
end of the previous one.
|
||||
- `xk3190_reversed` field-tested on a Yaohua YH-T7E indicator (9600 8N1, about nine frames a second).
|
||||
|
||||
## 1.1.0 — 2026-10-11
|
||||
|
||||
Scales that send only when their Print key is pressed — typically indicators wired to a label printer.
|
||||
|
||||
- New protocol `label_print`: reads the label an indicator sends to its label printer — TSPL/TSPL2 (TSC and
|
||||
compatibles), EPL2 (Zebra/Eltron, Argox) and ZPL (Zebra). The weight is the number printed with a weight unit;
|
||||
the other values on the label (order number, shift, date, time, thickness …) come along as `fields`.
|
||||
Field-tested on a PTC400 indicator printing to a TSC printer (TSPL, 9600 8N1).
|
||||
- Auto-detection: new *wait for Print* option (`--wait-print SECONDS`) holds the port open until the operator
|
||||
presses Print, steps to the next speed if the data is unreadable, and reads 7E1/7O1 from a single press; new
|
||||
*listen only* option (`--listen-only`) never sends a byte to the device.
|
||||
- The bridge keeps the ports of such scales open all the time and records every printed label: the last 500 in
|
||||
memory (`GET /labels?profile=…&after=SEQ`) and a daily `labels/<profile>-YYYY-MM-DD.jsonl` file.
|
||||
- `/weight` answers include `fields` and `on_print`; a print-only scale answers at once ("waiting for the Print
|
||||
key") instead of holding the caller.
|
||||
- The control page shows the last printed label, its fields and a short label history.
|
||||
|
||||
## 1.0.0 — 2026-10-01
|
||||
|
||||
First public release.
|
||||
|
||||
- Automatic detection of the port, speed, framing (8N1, 7E1, 7O1 …) and protocol, with quick and thorough modes,
|
||||
confidence score, raw samples and a cancel button. Read-only requests only.
|
||||
- Protocol library: Mettler Toledo MT-SICS and Toledo Continuous, A&D, SMA, NCI 3835, Ohaus, Sartorius SBI,
|
||||
CAS (experimental), XK3190 (tF=0 and reversed stream), Mahak MDS (field-tested), generic text, custom binary and
|
||||
custom text formats.
|
||||
- Saved scale profiles and a parsed-weight API: `GET/POST /weight` with value, unit, stability, net/gross,
|
||||
overload, age and checksum result; unit conversion.
|
||||
- Compatible with SyncPulse Serial Bridge 1.x (`/health`, `/ports`, `POST /read`, `bridge.json`).
|
||||
- Bilingual control page (English/Persian): live weights, auto-detection, connection details, access key,
|
||||
allowed clients, raw monitor with protocol guesses, protocol library, about.
|
||||
- One-click Windows Firewall rule and start-with-Windows task (SYSTEM, no 72-hour limit, restart on failure),
|
||||
both with a UAC prompt.
|
||||
- Single-file Windows program with version information; single-instance start that opens the running copy.
|
||||
- Simulated scales for every protocol (`--simulate`) and a test suite that needs no hardware.
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for helping more scales work out of the box.
|
||||
|
||||
## Reporting a scale that is not recognised
|
||||
|
||||
Open an issue with:
|
||||
|
||||
- the brand and model of the indicator, and its serial settings menu if you know it;
|
||||
- what **Manual & monitor** shows (hex and text) at the speed and framing that gives readable data;
|
||||
- what the display showed at that moment (e.g. `0.550 kg`, stable).
|
||||
|
||||
## Adding a protocol
|
||||
|
||||
1. In `scale_bridge/protocols.py`, write a `Protocol` subclass whose `readings(data)` returns `Reading`s from raw
|
||||
bytes — value, unit, and `stable`/`net`/`overload`/`checksum_ok` when the format carries them. Never raise on
|
||||
bad input; skip what does not match.
|
||||
2. Add it to `_catalogue()` with `kind`, the **read-only** `request` (if any), `families`, `reference` and
|
||||
`specificity` (3 = strictly framed or checksummed, 2 = distinctive text, 1 = loose).
|
||||
3. Add its id to `CONTINUOUS_IDS` and/or `POLL_IDS` so detection tries it.
|
||||
4. Teach `scale_bridge/simulator.py` to produce it (`frame_for`, and `REQUESTS` or `CONTINUOUS`).
|
||||
5. Add tests in `tests/test_protocols.py` with sample frames from the manufacturer's document, and a case in
|
||||
`tests/test_detect.py`.
|
||||
6. Run `python -m pytest`.
|
||||
|
||||
Never add a request that changes the scale (zero, tare, reset, print, display off, configuration) to detection.
|
||||
|
||||
## Code
|
||||
|
||||
- Python 3.9+, standard library plus `pyserial` only.
|
||||
- Keep the source headers (SPDX line and copyright) and the author attribution described in [NOTICE.md](NOTICE.md).
|
||||
- Contributions are accepted under the project's licence (GPL-3.0-or-later with the section 7(b) attribution term).
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Notice and additional terms
|
||||
|
||||
**SyncPulse Scale Bridge**
|
||||
Copyright (C) 2026 Farshad Zamani — Cloudtart
|
||||
|
||||
- Author: Farshad Zamani
|
||||
- Company: Cloudtart — <https://cloudtart.com>
|
||||
- GitHub: <https://github.com/farshad-zamani>
|
||||
- LinkedIn: <https://www.linkedin.com/in/farshad-zamani92/>
|
||||
- Source: <https://github.com/farshad-zamani/syncpulse-scale-bridge>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later
|
||||
version. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## Additional term under section 7(b) of the GNU GPL v3
|
||||
|
||||
As permitted by section 7(b) of the GNU General Public License version 3, the
|
||||
following term is added to the License for this program:
|
||||
|
||||
> **Preservation of author attribution.** Every copy of this program, and
|
||||
> every work based on it, in source or object form, must keep the author
|
||||
> attribution in the Appropriate Legal Notices that the program displays —
|
||||
> the author's name **Farshad Zamani**, the company name **Cloudtart**, and
|
||||
> the links to <https://cloudtart.com>, <https://github.com/farshad-zamani>
|
||||
> and <https://www.linkedin.com/in/farshad-zamani92/> — in the footer and the
|
||||
> "About" page of the user interface, in the `about` command and in the
|
||||
> `/health` response. These notices must stay legible and must not be
|
||||
> removed, hidden or replaced. A modified version must be marked as changed
|
||||
> and must not be presented as the original program or as the work of
|
||||
> another author; the names of modifiers may be added next to, but not in
|
||||
> place of, the original attribution.
|
||||
|
||||
This term does not restrict any other freedom the GNU GPL grants. If you
|
||||
received this program with this term removed, you may restore it.
|
||||
|
||||
## Third-party components
|
||||
|
||||
- [pyserial](https://github.com/pyserial/pyserial) — BSD-3-Clause
|
||||
- The single-file Windows program is packaged with
|
||||
[PyInstaller](https://pyinstaller.org) (GPL-2.0 with a bootloader exception)
|
||||
and contains the Python runtime (PSF License).
|
||||
|
||||
Brand and product names of scale manufacturers mentioned in this project are
|
||||
used only to describe data formats and belong to their owners. This project is
|
||||
not affiliated with or endorsed by any of them.
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<div dir="rtl">
|
||||
|
||||
<p align="center"><img src="assets/icon.png" width="96" alt=""></p>
|
||||
|
||||
<h1 align="center">SyncPulse Scale Bridge</h1>
|
||||
|
||||
<p align="center">
|
||||
باسکول دارای درگاه RS-232 را به هر رایانهی ویندوزی وصل کنید و یک فایل را اجرا کنید؛ درگاه، سرعت و پروتکل را
|
||||
خودش پیدا میکند، وزن زنده را نشان میدهد و آن را بهصورت JSON روی شبکه در اختیار میگذارد.
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> · <b>فارسی</b></p>
|
||||
|
||||
---
|
||||
|
||||
ساختهی **[فرشاد زمانی](https://github.com/farshad-zamani)** در **[کلادتارت (Cloudtart)](https://cloudtart.com)**. نرمافزار
|
||||
آزاد است و با رابط سادهی HTTP/JSON با هر سامانهای کار میکند — ERP، MES، فرم وب، ماکروی صفحهگسترده یا اسکریپت خودتان.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/scales-fa.png" width="49%" alt="وزن زنده">
|
||||
<img src="docs/screenshots/connect-fa.png" width="49%" alt="دسترسی از شبکه">
|
||||
</p>
|
||||
|
||||
## چرا
|
||||
|
||||
بیشتر باسکولها و نمایشگرهای توزین صنعتی هنوز با درگاه سریال کار میکنند و هر برند قالب، سرعت و تنظیم خط خودش
|
||||
را دارد. معمولاً برای وصل کردن یک باسکول باید دفترچه را پیدا کرد، 9600 8N1 یا 2400 7E1 را حدس زد، برنامهی
|
||||
خواندن نوشت و آن را کنار باسکول روشن نگه داشت. این برنامه همهی این کارها را انجام میدهد:
|
||||
|
||||
- **باسکول را خودش پیدا میکند** — روی هر درگاه COM با همهی سرعتهای رایج گوش میدهد، قالب ۷ بیتی با پریتی را
|
||||
تشخیص میدهد و سپس با زبان سازندگان اصلی از باسکول وزن میخواهد. فقط درخواستهای خواندنی فرستاده میشود؛ هیچوقت
|
||||
صفر، تار یا چاپ نمیکند.
|
||||
- **قالبهای زیادی را میخواند** — Mettler Toledo (MT-SICS و Toledo Continuous)، A&D، SMA (Rice Lake، Avery
|
||||
Weigh-Tronix، Cardinal، Fairbanks و…)، NCI، Ohaus، Sartorius SBI، CAS، خانوادهی چینی XK3190، محک MDS، هر متن
|
||||
خوانا، و قالبهای دودویی/متنی دلخواه.
|
||||
- **وزن را نشان میدهد و در اختیار میگذارد** — صفحهی دوزبانه (فارسی/انگلیسی) روی همان رایانه، و API از نوع JSON با
|
||||
مقدار، واحد، پایداری، خالص/ناخالص، بیشازظرفیت و بررسی checksum.
|
||||
- **یک فایل، بدون نصب** — `SyncPulseScaleBridge.exe` را کنار باسکول کپی و اجرا کنید.
|
||||
- **آمادهی محیط تولید** — قطع و وصل کابل USB و درگاه مشغول را تحمل میکند، درگاه فایروال را باز میکند و با یک دکمه
|
||||
همراه ویندوز اجرا میشود، کلید دسترسی دارد و گزارش کارش را در فایل نگه میدارد.
|
||||
|
||||
## شروع سریع (ویندوز)
|
||||
|
||||
۱. فایل `SyncPulseScaleBridge.exe` را از [آخرین نسخه](https://github.com/farshad-zamani/syncpulse-scale-bridge/releases/latest)
|
||||
بگیرید و روی رایانهای که باسکول به آن وصل است، ترجیحاً در پوشهی جداگانه مثل `C:\ScaleBridge\` کپی کنید (فایل
|
||||
تنظیمات `bridge.json` و گزارش کار کنار آن ساخته میشود).
|
||||
|
||||
۲. درگاه RS-232 باسکول را مستقیم یا با مبدل USB به سریال وصل کنید و خروجی سریال نمایشگر را روشن کنید.
|
||||
|
||||
۳. برنامه را با دو بار کلیک اجرا کنید. یک پنجرهی کنسول باز میشود (باز بماند) و صفحهی مدیریت در مرورگر با نشانی
|
||||
`http://127.0.0.1:8765/` باز میشود.
|
||||
|
||||
۴. **پیدا کردن باسکول ← شروع جستجو.** وقتی باسکول پیدا شد، نامی به آن بدهید (مثلاً `packing-1`) و **ذخیره** را بزنید.
|
||||
زبانهی **باسکولها** وزن زندهی آن را نشان میدهد.
|
||||
|
||||
۵. **دسترسی از شبکه:** نشانی و کلید دسترسی را بردارید، **باز کردن درگاه 8765 در فایروال** را بزنید و اگر باید
|
||||
بدون ورود کاربر هم کار کند، **اجرا همراه ویندوز** را بزنید.
|
||||
|
||||
بدون دستگاه هم میتوانید با باسکول شبیهسازیشده امتحان کنید:
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bat
|
||||
SyncPulseScaleBridge.exe --simulate mahak_mds,toledo_cont:4800:7E1,mt_sics
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## تشخیص خودکار چطور کار میکند
|
||||
|
||||
برای هر درگاه انتخابشده:
|
||||
|
||||
۱. **گوش دادن** با سرعتهای 9600، 2400، 4800، 19200، 1200، 38400، 57600 و 115200 (قالب 8N1). باسکولی که خودش
|
||||
پیوسته میفرستد اینجا دیده میشود. اگر بایتها فقط با صفر کردن بیت بالا خوانا شوند، خط ۷ بیت داده با پریتی دارد و
|
||||
دوباره با 7E1/7O1 گوش داده میشود. دادهی رسیده با همهی قالبهای پیوسته مقایسه میشود و قالبهای دارای checksum بر
|
||||
متن ساده مقدماند.
|
||||
|
||||
۲. **پرسیدن** — اگر چیز مفیدی شنیده نشد، هر درخواست خواندنی کتابخانه با هر سرعت و قالب فرستاده میشود (سریع: ۸ حالت؛
|
||||
کامل: ۱۴ حالت) و پاسخ با همان پروتکل بررسی میشود. پاسخ با یک پرسش دوباره تأیید میشود.
|
||||
|
||||
هر نتیجه درصد اطمینان، تنظیم دقیق خط، درخواست، نمونهی دادهی خام و خوانش فعلی را دارد. اگر دادهی خوانا ولی با قالب
|
||||
ناشناخته برسد، همان نمونه گزارش میشود تا در زبانهی **تنظیم دستی و مانیتور** تنظیم شود.
|
||||
|
||||
### باسکولهایی که فقط با کلید «چاپ» میفرستند
|
||||
|
||||
بسیاری از نمایشگرها — بهویژه آنهایی که به لیبلزن وصلاند — تا اپراتور «چاپ» نزند چیزی نمیفرستند و بعد کل
|
||||
برچسب را میفرستند. در «پیدا کردن باسکول» گزینهی **باسکول فقط با زدن کلید چاپ داده میفرستد** (و در صورت تمایل
|
||||
**فقط گوش بده**) را بزنید و وقتی صفحه خواست، چاپ بزنید. برای چنین باسکولی پل **همیشه** درگاه را باز نگه میدارد
|
||||
و هر برچسب را ثبت میکند: وزن، واحد و بقیهی مقادیر برچسب (شماره سفارش، شیفت، تاریخ …) در فایل
|
||||
`labels/<profile>-YYYY-MM-DD.jsonl` و ۵۰۰ مورد آخر از `GET /labels`. برای اینکه لیبلزن هم کار کند، رایانه را
|
||||
با **کابل Y** وصل کنید: TX باسکول (و زمین) هم به چاپگر و هم به RX رایانه؛ رایانه روی این خط چیزی نمیفرستد.
|
||||
|
||||
## قالبهای پشتیبانیشده
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
| ID | Format | Talks |
|
||||
|---|---|---|
|
||||
| `mt_sics` | Mettler Toledo MT-SICS (`SI`) | on request |
|
||||
| `toledo_cont` | Toledo Continuous (STX + status words, checksum) | continuous |
|
||||
| `and_std` | A&D standard (`ST`/`US`/`OL`, `Q`) | on request / continuous |
|
||||
| `sma` | SMA SCP-0499 (`<LF>W<CR>`) | on request |
|
||||
| `nci` | NCI 3835 (`W<CR>`) | on request |
|
||||
| `ohaus` | Ohaus (`IP`) | on request |
|
||||
| `sartorius` | Sartorius SBI (`ESC P`) | on request |
|
||||
| `cas_enq` | CAS ENQ/ACK/DC1 *(experimental)* | handshake |
|
||||
| `xk3190_tf0` | XK3190 continuous (tF=0, XOR checksum) | continuous |
|
||||
| `xk3190_reversed` | XK3190 `=` reversed-digit stream — tested on real hardware (YH-T7E) | continuous |
|
||||
| `mahak_mds` | Mahak MDS binary (`4D 09 04 07`) — tested on real hardware | on request |
|
||||
| `label_print` | label printer stream: TSPL / EPL2 / ZPL — tested on real hardware (PTC400) | on Print key |
|
||||
| `ascii_generic` | any readable line with a number and a unit | continuous |
|
||||
| `custom_binary` / `custom_ascii` | your own binary frame / regular expression | either |
|
||||
|
||||
</div>
|
||||
|
||||
قالبها بر پایهی مستندات منتشرشدهی سازندگاناند و با فریمهای نمونهی همان مستندات آزمایش شدهاند؛ `mahak_mds` روی
|
||||
باسکول واقعی (محک MDS 13000) آزموده شده است. نام برندها فقط برای توصیف قالب داده است و بسیاری از نمایشگرهای دیگر هم
|
||||
همین قالبها را به کار میبرند.
|
||||
|
||||
|
||||
## API برای هر سامانهی دیگر
|
||||
|
||||
همهی درخواستها بهجز `/health` سرآیند `X-Bridge-Token: <کلید دسترسی>` میخواهند.
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```console
|
||||
GET /health
|
||||
GET /ports
|
||||
GET /profiles
|
||||
GET /weight?profile=packing-1
|
||||
GET /weight?port=COM7&protocol=mt_sics&baud=9600&framing=8N1
|
||||
POST /weight {"profile": "packing-1"}
|
||||
POST /read {serial, exchange, frames, max_age_ms, wait_ms}
|
||||
|
||||
$ curl -H "X-Bridge-Token: $KEY" "http://192.168.1.20:8765/weight?profile=packing-1"
|
||||
{"ok": true, "value": 550.0, "unit": "g", "stable": true, "stale": false, "age_ms": 84, ...}
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
`stable` اگر قالب باسکول آن را بفرستد از خود باسکول است؛ وگرنه یعنی چند خوانش آخر (پیشفرض ۳) برابر بودهاند.
|
||||
جزئیات کامل در [README انگلیسی](README.md#http-api).
|
||||
|
||||
## امنیت
|
||||
|
||||
- **کلید دسترسی** در اولین اجرا ساخته و در `bridge.json` نگه داشته میشود؛ هر زمان میتوان کلید تازه ساخت.
|
||||
- **سامانههای مجاز** — میتوان فقط IP سرور خودتان را پذیرفت؛ قانون فایروال هم میتواند به یک نشانی محدود شود.
|
||||
- **صفحهی مدیریت فقط محلی است** — فقط روی خود رایانه (`127.0.0.1`) باز میشود و API آن سرآیند ویژه و Host محلی
|
||||
میخواهد تا صفحههای دیگر وب نتوانند آن را به کار بگیرند.
|
||||
- **نسبت به باسکول فقطخواندنی است** — هیچ فرمان صفر، تار، چاپ یا تنظیم فرستاده نمیشود.
|
||||
|
||||
## فایروال و اجرا در پسزمینه
|
||||
|
||||
فایروال ویندوز بهطور پیشفرض اتصالهای ورودی را میبندد؛ پس تا قانونی اجازه ندهد، رایانههای دیگر به درگاه 8765 این
|
||||
رایانه نمیرسد. دکمهی **باز کردن درگاه 8765 در فایروال** اجازهی مدیر میگیرد و این فرمان را اجرا میکند (برای انجام
|
||||
دستی هم میتوانید همین را در Command Prompt با دسترسی مدیر اجرا کنید):
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bat
|
||||
netsh advfirewall firewall add rule name="SyncPulse Scale Bridge" dir=in action=allow protocol=TCP localport=8765 profile=any
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
اگر پرسش «Allow access؟» ویندوز در اولین اجرا لغو شده باشد، ویندوز قانون مسدودکننده برای برنامه میسازد که بر هر قانون
|
||||
اجازه مقدم است؛ این دکمه آن را هم پاک میکند.
|
||||
|
||||
**اجرا همراه ویندوز** وظیفهی زمانبندیشدهی *SyncPulse Scale Bridge* را میسازد: هنگام روشن شدن رایانه و پیش از ورود
|
||||
کاربر با حساب SYSTEM اجرا میشود، پس از ۷۲ ساعت متوقف نمیشود و اگر بسته شود هر دقیقه دوباره اجرا میشود.
|
||||
|
||||
## رفع اشکال
|
||||
|
||||
- **درگاه COM دیده نمیشود / «A device attached to the system is not functioning»** — بسیاری از مبدلهای ارزان تراشهی
|
||||
CH340 یا PL2303 تقلبی دارند که درایورهای تازه آن را نمیپذیرند. برای CH340 درایور **3.5.2019.1** (از Microsoft
|
||||
Update Catalog) را نصب کنید، برای PL2303 درایور قدیمیتر Prolific، یا از مبدل FTDI استفاده کنید.
|
||||
- **چیزی پیدا نشد** — خروجی سریال را در منوی نمایشگر روشن کنید (معمولاً `COM`، `rS`، `SEr`، `Serial`، `Continuous`
|
||||
یا `On request`)؛ کابل را بررسی کنید (نالمودم یا مستقیم — پایههای ۲، ۳ و ۵ کافی است)؛ هنگام جستجو چیزی روی باسکول
|
||||
بگذارید یا کلید چاپ را بزنید؛ جستجوی کامل را امتحان کنید؛ بایتهای خام را در **تنظیم دستی و مانیتور** ببینید.
|
||||
- **سرور وصل نمیشود** — قانون فایروال، IP و یکی بودن شبکه را بررسی کنید؛ `curl http://<ip>:8765/health` از سرور باید
|
||||
پاسخ دهد.
|
||||
- **هشدار آنتیویروس** — برنامههای تکفایلی ساختهشده با PyInstaller گاهی بهاشتباه شناسایی میشوند. checksum
|
||||
SHA-256 منتشرشده با نسخه را مقایسه کنید یا برنامه را خودتان از کد منبع بسازید.
|
||||
|
||||
## ساخت از کد منبع
|
||||
|
||||
<div dir="ltr">
|
||||
|
||||
```bat
|
||||
git clone https://github.com/farshad-zamani/syncpulse-scale-bridge
|
||||
cd syncpulse-scale-bridge
|
||||
scripts\build_exe.bat
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## مجوز
|
||||
|
||||
[GPL-3.0-or-later](LICENSE) با یک شرط افزوده طبق بند ۷(ب): نام و مشخصات سازنده که برنامه نمایش میدهد — در پانویس
|
||||
صفحه، صفحهی «درباره»، فرمان `about` و پاسخ `/health` — باید در همهی نسخهها و نسخههای تغییر یافته حفظ شود.
|
||||
[NOTICE.md](NOTICE.md) را ببینید.
|
||||
|
||||
## سازنده
|
||||
|
||||
**فرشاد زمانی** — [کلادتارت (Cloudtart)](https://cloudtart.com) ·
|
||||
[GitHub](https://github.com/farshad-zamani) ·
|
||||
[LinkedIn](https://www.linkedin.com/in/farshad-zamani92/)
|
||||
|
||||
در اصل برای پلتفرم SyncPulse توسعه داده شد.
|
||||
|
||||
</div>
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
<p align="center"><img src="assets/icon.png" width="96" alt=""></p>
|
||||
|
||||
<h1 align="center">SyncPulse Scale Bridge</h1>
|
||||
|
||||
<p align="center">
|
||||
Plug an RS-232 weighing scale into any Windows PC, run one file, and it finds the port, the speed and
|
||||
the protocol by itself — then shows the live weight and serves it to your network as JSON.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/farshad-zamani/syncpulse-scale-bridge/actions/workflows/ci.yml"><img src="https://github.com/farshad-zamani/syncpulse-scale-bridge/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/farshad-zamani/syncpulse-scale-bridge/releases/latest"><img src="https://img.shields.io/github/v/release/farshad-zamani/syncpulse-scale-bridge" alt="Release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0--or--later-blue" alt="License"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux-informational" alt="Platform">
|
||||
</p>
|
||||
|
||||
<p align="center"><b>English</b> · <a href="README.fa.md">فارسی</a></p>
|
||||
|
||||
---
|
||||
|
||||
Built by **[Farshad Zamani](https://github.com/farshad-zamani)** at **[Cloudtart](https://cloudtart.com)**. Free
|
||||
software that works with any system — an ERP, a MES, a web form, a spreadsheet macro or your own script — through a
|
||||
small HTTP/JSON interface.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/find.png" width="49%" alt="Finding a scale automatically">
|
||||
<img src="docs/screenshots/scales.png" width="49%" alt="Live weights">
|
||||
</p>
|
||||
|
||||
## Why
|
||||
|
||||
Most industrial scales and weighing indicators still talk over a serial port, and every brand uses its own
|
||||
format, speed and framing. Connecting one usually means finding the manual, guessing 9600 8N1 or 2400 7E1,
|
||||
writing a parser, and keeping a program running on the PC beside the scale. This project does all of that:
|
||||
|
||||
- **Finds the scale by itself** — listens on every COM port at every common speed, recognises 7-bit/parity
|
||||
framing, then asks the scale in the languages of the major manufacturers. Only read-only requests are sent:
|
||||
it never zeroes, tares or prints.
|
||||
- **Reads many formats** — Mettler Toledo MT-SICS and Toledo Continuous, A&D, SMA (Rice Lake, Avery
|
||||
Weigh-Tronix, Cardinal, Fairbanks…), NCI, Ohaus, Sartorius SBI, CAS, the Chinese XK3190 family, Mahak MDS, any
|
||||
readable text, and custom binary/text formats you describe.
|
||||
- **Shows and serves the weight** — a bilingual (English/Persian) page on the PC, and a JSON API with value,
|
||||
unit, stability, net/gross, overload and a checksum check, for any system on the network.
|
||||
- **One file, nothing to install** — `SyncPulseScaleBridge.exe`. Copy it next to the scale and run it.
|
||||
- **Production-ready** — survives unplugged USB cables and busy ports, opens the Windows Firewall port and
|
||||
starts with Windows from a button (UAC prompt), keeps an access key, and logs to a rotating file.
|
||||
|
||||
## Quick start (Windows)
|
||||
|
||||
1. Download `SyncPulseScaleBridge.exe` from the [latest release](https://github.com/farshad-zamani/syncpulse-scale-bridge/releases/latest)
|
||||
and copy it to the PC the scale is connected to — preferably into its own folder, e.g. `C:\ScaleBridge\`
|
||||
(its settings file `bridge.json` and log are kept beside it).
|
||||
2. Connect the scale's RS-232 port (directly, or through a USB-serial adapter) and switch the indicator's
|
||||
serial output on.
|
||||
3. Double-click the program. A console window opens (keep it open) and the control page opens in the browser
|
||||
at `http://127.0.0.1:8765/`.
|
||||
4. **Find my scale → Start search.** When the scale is found, give it a name (e.g. `packing-1`) and **Save**.
|
||||
The **Scales** tab now shows its live weight.
|
||||
5. **Network access:** note the address and the access key, press **Open port 8765 in the firewall**
|
||||
and, if the bridge should run without anyone logged in, **Start with Windows**.
|
||||
6. From any other machine: `GET http://<address>:8765/weight?profile=<name>` with the header
|
||||
`X-Bridge-Token: <access key>` — see [the HTTP API](#http-api).
|
||||
|
||||
No hardware at hand? Try it with simulated scales:
|
||||
|
||||
```bat
|
||||
SyncPulseScaleBridge.exe --simulate mahak_mds,toledo_cont:4800:7E1,mt_sics
|
||||
```
|
||||
|
||||
## How auto-detection works
|
||||
|
||||
For each selected port:
|
||||
|
||||
1. **Listen** at 9600, 2400, 4800, 19200, 1200, 38400, 57600 and 115200 baud (8N1). A scale that sends by
|
||||
itself appears here. If the bytes are only readable once their top bit is cleared, the line uses 7 data bits
|
||||
with parity, and the port is listened to again as 7E1/7O1. What arrives is matched against every
|
||||
continuous format; framed formats with checksums win over loose text.
|
||||
2. **Ask** — if nothing useful was heard, every read-only request in the library is sent at each speed and
|
||||
framing (quick: 8 combinations; thorough: 14) and the reply is checked by the protocol that was asked. A
|
||||
reply is confirmed by asking a second time.
|
||||
|
||||
Each result has a confidence score, the exact line settings, the request, a sample of the raw data and the
|
||||
current reading. A port that sent readable data in an unknown format is reported with that sample, so it can be
|
||||
configured by hand on the **Manual & monitor** tab.
|
||||
|
||||
Auto-detection reserves the ports it searches; reads of those ports answer `409 busy` until it finishes.
|
||||
|
||||
### Scales that send only when Print is pressed
|
||||
|
||||
Many indicators — especially those wired to a label printer — stay silent until the operator presses *Print*,
|
||||
then send the whole label. Tick **The scale sends only when Print is pressed** (and, if you like, **Listen only**)
|
||||
and press Print when the page asks: the port is held open at one speed until data arrives; unreadable data means
|
||||
another speed, and the next press is awaited at the next one. One press is enough for 7E1/7O1.
|
||||
|
||||
For such a scale the bridge keeps the port **open all the time** and records every label: weight, unit and the
|
||||
other values on the label (order number, shift, date …) in `labels/<profile>-YYYY-MM-DD.jsonl` beside
|
||||
`bridge.json`, and the last 500 through `GET /labels`. To keep the label printer working, connect the PC with a
|
||||
**Y cable**: scale TX (and ground) to both the printer and the PC's RX; the PC never transmits on that line.
|
||||
|
||||
## Supported formats
|
||||
|
||||
| ID | Format | Talks | Used by (examples) |
|
||||
|---|---|---|---|
|
||||
| `mt_sics` | Mettler Toledo MT-SICS (`SI`) | on request | Mettler Toledo terminals and balances |
|
||||
| `toledo_cont` | Toledo Continuous (STX + status words, checksum) | continuous | Mettler Toledo IND/Panther/Jaguar/Lynx and many compatible indicators |
|
||||
| `and_std` | A&D standard (`ST`/`US`/`OL`, `Q`) | on request / continuous | A&D and many Asian indicators |
|
||||
| `sma` | SMA SCP-0499 (`<LF>W<CR>`) | on request | Rice Lake, Avery Weigh-Tronix, Cardinal, Fairbanks… |
|
||||
| `nci` | NCI 3835 (`W<CR>`) | on request | NCI / Weigh-Tronix shipping scales |
|
||||
| `ohaus` | Ohaus (`IP`) | on request | Ohaus Defender, Ranger, Valor… |
|
||||
| `sartorius` | Sartorius SBI (`ESC P`) | on request | Sartorius balances, Combics/Signum |
|
||||
| `cas_enq` | CAS ENQ/ACK/DC1 *(experimental)* | handshake | CAS and compatibles |
|
||||
| `xk3190_tf0` | XK3190 continuous (tF=0, XOR checksum) | continuous | Yaohua XK3190-A9/A12 and compatibles |
|
||||
| `xk3190_reversed` | XK3190 `=` reversed-digit stream — **tested on real hardware** (YH-T7E) | continuous | Yaohua XK3190 series, YH-T7E, big displays |
|
||||
| `mahak_mds` | Mahak MDS binary (`4D 09 04 07`) — **tested on real hardware** | on request | Mahak MDS 13000 and related models |
|
||||
| `label_print` | Label printer stream: TSPL / EPL2 / ZPL — **tested on real hardware** | on Print key | indicators wired to a TSC, Zebra or Argox label printer (e.g. PTC400) |
|
||||
| `ascii_generic` | Any readable line with a number and a unit | continuous | everything else that streams text |
|
||||
| `custom_binary` | A binary frame you describe (pattern, offset, length, byte order) | either | — |
|
||||
| `custom_ascii` | A text format you describe (regular expression) | either | — |
|
||||
|
||||
The formats follow the manufacturers' published documents and are covered by tests with their sample frames;
|
||||
`mahak_mds` was verified on a working scale. Brand names only describe data formats — many other indicators
|
||||
copy these formats. Requests that change the scale (zero, tare, reset, print, display off) are never sent.
|
||||
|
||||
Missing your scale? Open the **Manual & monitor** tab, look at what arrives, and save a custom profile — or
|
||||
[open an issue](https://github.com/farshad-zamani/syncpulse-scale-bridge/issues) with the model and a sample of the
|
||||
raw data. See [CONTRIBUTING.md](CONTRIBUTING.md) for adding a protocol.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Everything below needs the header `X-Bridge-Token: <access key>` except `/health`.
|
||||
|
||||
| Request | Answer |
|
||||
|---|---|
|
||||
| `GET /health` | `{ok, service, product, version, host, simulate, author, website, repository, capabilities}` |
|
||||
| `GET /ports` | the COM ports of the PC (name, description, USB IDs, chip, warnings) |
|
||||
| `GET /profiles` | the saved scale profiles |
|
||||
| `GET /weight?profile=packing-1` | the current weight of a saved profile |
|
||||
| `GET /labels?profile=label-1&after=SEQ&limit=100` | labels printed by a print-only scale, oldest first (`seq` grows by one per label) |
|
||||
| `GET /weight?port=COM7&protocol=mt_sics&baud=9600&framing=8N1` | the current weight without a profile |
|
||||
| `POST /weight` `{"profile": "packing-1"}` or a whole profile | same, as JSON |
|
||||
| `POST /read` `{serial, exchange, frames, max_age_ms, wait_ms}` | the raw frames, for systems that parse them themselves |
|
||||
|
||||
Optional on `/weight`: `max_age_ms` (default 1500 — older readings are `stale`) and `wait_ms` (default 2500 — how
|
||||
long to wait for a fresh reading when the port has just been opened).
|
||||
|
||||
```console
|
||||
$ curl -H "X-Bridge-Token: $KEY" "http://192.168.1.20:8765/weight?profile=packing-1"
|
||||
{"ok": true, "profile": "packing-1", "port": "COM7", "protocol": "mahak_mds", "open": true,
|
||||
"value": 550.0, "unit": "g", "stable": true, "net": null, "overload": false, "age_ms": 84,
|
||||
"stale": false, "measured_at": "2026-10-01T10:15:02.415+03:30", "raw_hex": "58 51 07 07 00 02 26",
|
||||
"checksum_ok": null}
|
||||
```
|
||||
|
||||
```python
|
||||
import requests
|
||||
r = requests.get("http://192.168.1.20:8765/weight", params={"profile": "packing-1"},
|
||||
headers={"X-Bridge-Token": KEY}, timeout=5).json()
|
||||
if r["ok"] and r["stable"] and not r["stale"]:
|
||||
print(r["value"], r["unit"])
|
||||
```
|
||||
|
||||
`stable` comes from the scale when its format says so; otherwise it means the last *N* readings were equal
|
||||
(`stable_samples`, default 3). Errors answer with `"ok": false` and an `"error"` text — `401` wrong key,
|
||||
`403` client not allowed, `404` unknown profile, `409` port busy, `400` invalid request.
|
||||
|
||||
## Security
|
||||
|
||||
- **Access key** — created on first start and kept in `bridge.json`; compared in constant time. Make a new one on
|
||||
the **Connect** tab at any time.
|
||||
- **Allowed clients** — optionally accept only your server's address(es); the firewall rule can be
|
||||
limited to one address too.
|
||||
- **The control page is local only** — it opens only on the PC itself (`127.0.0.1`). Its API also requires a
|
||||
custom header and a local `Host`, which protects it from other web pages and DNS rebinding. Other machines
|
||||
get a short information page instead.
|
||||
- **Read-only towards the scale** — detection and reading never send zero, tare, print or configuration
|
||||
commands.
|
||||
- Keep the bridge on a trusted local network; it speaks plain HTTP.
|
||||
|
||||
## Firewall and running in the background
|
||||
|
||||
Windows Firewall blocks incoming connections by default, so other machines cannot reach port 8765 until a
|
||||
rule allows it. **Open port 8765 in the firewall** asks for administrator permission and runs:
|
||||
|
||||
```bat
|
||||
netsh advfirewall firewall add rule name="SyncPulse Scale Bridge" dir=in action=allow protocol=TCP localport=8765 profile=any
|
||||
```
|
||||
|
||||
It also removes block rules Windows may have created for the program when its *Allow access?* question was
|
||||
cancelled (a block rule beats any allow rule).
|
||||
|
||||
**Start with Windows** registers the scheduled task *SyncPulse Scale Bridge*: runs as SYSTEM at boot (before
|
||||
anyone logs in), never stopped after 72 hours, restarted every minute if it fails. Opening the program while the
|
||||
background copy runs simply opens its page.
|
||||
|
||||
The same from the command line:
|
||||
|
||||
```bat
|
||||
SyncPulseScaleBridge.exe firewall [--remote-ip 192.168.1.10] [--remove]
|
||||
SyncPulseScaleBridge.exe autostart [--disable]
|
||||
```
|
||||
|
||||
## Command line
|
||||
|
||||
```text
|
||||
SyncPulseScaleBridge.exe run the bridge and open its page (default)
|
||||
SyncPulseScaleBridge.exe ports list COM ports
|
||||
SyncPulseScaleBridge.exe detect [--port COM7] [--thorough] [--wait-print 60] [--listen-only] [--save NAME] [--json]
|
||||
SyncPulseScaleBridge.exe monitor --port COM7 --baud 9600 --framing 8N1 [--request "53 49 0D 0A"]
|
||||
SyncPulseScaleBridge.exe weight --profile NAME | --port COM7 --protocol mt_sics
|
||||
SyncPulseScaleBridge.exe token [--new]
|
||||
SyncPulseScaleBridge.exe protocols | about | --version
|
||||
|
||||
Options: --config FILE --simulate SPEC --verbose --listen-host --listen-port --no-browser --service
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No COM port / "A device attached to the system is not functioning"** — many cheap USB-serial adapters use
|
||||
clone CH340 or PL2303 chips that recent drivers refuse. For CH340 install driver **3.5.2019.1** (Microsoft
|
||||
Update Catalog); for PL2303 use an older Prolific driver; or use an FTDI-based adapter. The port list warns
|
||||
about these chips.
|
||||
- **Nothing found** — switch the serial output on in the indicator's menu (often `COM`, `rS`, `SEr`, `Serial`,
|
||||
`Continuous` or `On request`); check the cable (many indicators need a null-modem/crossed cable, some a
|
||||
straight one — pins 2, 3 and 5 are enough); put something on the scale or press *Print* while searching; try
|
||||
the thorough search; watch the raw bytes on **Manual & monitor**.
|
||||
- **Garbage characters** — wrong speed or framing; the monitor shows the bytes, detection finds the right ones.
|
||||
- **The server cannot connect** — check the firewall rule, the IP address, and that both machines are on the same
|
||||
network; `curl http://<ip>:8765/health` from the server must answer.
|
||||
- **Antivirus warning** — single-file programs built with PyInstaller are sometimes flagged by mistake. Compare the
|
||||
SHA-256 checksum published with the release, or build the program yourself from source.
|
||||
|
||||
## Build from source
|
||||
|
||||
```bat
|
||||
git clone https://github.com/farshad-zamani/syncpulse-scale-bridge
|
||||
cd syncpulse-scale-bridge
|
||||
scripts\build_exe.bat :: venv, tests, then dist\SyncPulseScaleBridge.exe
|
||||
```
|
||||
|
||||
Or run it directly with Python 3.9+ on Windows or Linux:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python -m scale_bridge # on Linux the ports are /dev/ttyUSB0, /dev/ttyS0 …
|
||||
python -m pytest # the test suite (simulated scales, no hardware needed)
|
||||
```
|
||||
|
||||
## Related projects
|
||||
|
||||
Useful projects that solve parts of the problem — none of them combines automatic detection, a multi-brand
|
||||
protocol library, a network API and a control page:
|
||||
|
||||
- [VbScalesReader](https://github.com/wqweto/VbScalesReader) — VB6 library reading CAS, Elicom, Dibal, Mettler and
|
||||
Delmac protocols.
|
||||
- [Weigh-Scale-To-PC](https://github.com/MaxAtoms/Weigh-Scale-To-PC) — Windows Forms display for an Arduino-based scale.
|
||||
- [RS232C_Scale](https://github.com/rhemitoth/RS232C_Scale) — Raspberry Pi reader that logs a scale's readings to CSV.
|
||||
- [232key](https://www.digitalscalesblog.com/free-software-scales-balances/) and similar free *keyboard wedge*
|
||||
tools — type the weight into the active window.
|
||||
|
||||
## License
|
||||
|
||||
[GPL-3.0-or-later](LICENSE) with one additional term under section 7(b): the author attribution shown by the
|
||||
program — in its page footer, *About* page, `about` command and `/health` answer — must be kept in all copies and
|
||||
modified versions. See [NOTICE.md](NOTICE.md).
|
||||
|
||||
## Author
|
||||
|
||||
**Farshad Zamani** — [Cloudtart](https://cloudtart.com) ·
|
||||
[GitHub](https://github.com/farshad-zamani) ·
|
||||
[LinkedIn](https://www.linkedin.com/in/farshad-zamani92/)
|
||||
|
||||
Originally developed for the SyncPulse platform. Copyright © 2026 Farshad Zamani — Cloudtart.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+46
@@ -0,0 +1,46 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncpulse-scale-bridge"
|
||||
dynamic = ["version"]
|
||||
description = "Finds RS-232 weighing scales on any COM port, reads them, and serves the weight on the network."
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE", "NOTICE.md"]
|
||||
authors = [{ name = "Farshad Zamani", email = "info@cloudtart.com" }]
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["pyserial>=3.5"]
|
||||
keywords = ["scale", "weighing", "rs232", "serial", "weight", "indicator", "mettler-toledo",
|
||||
"sics", "xk3190", "industrial", "bridge", "label-printer", "tspl"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Manufacturing",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: System :: Hardware :: Hardware Drivers",
|
||||
"Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/farshad-zamani/syncpulse-scale-bridge"
|
||||
Author = "https://cloudtart.com"
|
||||
Issues = "https://github.com/farshad-zamani/syncpulse-scale-bridge/issues"
|
||||
|
||||
[project.scripts]
|
||||
scale-bridge = "scale_bridge.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["scale_bridge"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "scale_bridge.__version__" }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8
|
||||
pyinstaller>=6.10
|
||||
@@ -0,0 +1 @@
|
||||
pyserial==3.5
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani - Cloudtart (https://cloudtart.com)
|
||||
"""Entry point for the single-file program (PyInstaller) and for `python run_bridge.py`."""
|
||||
import sys
|
||||
|
||||
from scale_bridge.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
# Additional terms under GPLv3 section 7(b): see NOTICE.md.
|
||||
"""
|
||||
SyncPulse Scale Bridge — a universal RS-232 scale reader and network bridge.
|
||||
|
||||
Plug a weighing scale's serial cable into a Windows PC, run one program, and
|
||||
it finds the port, the speed and the protocol by itself, shows the live
|
||||
weight, and serves it on the local network for any other system to read.
|
||||
"""
|
||||
|
||||
__version__ = "1.1.2"
|
||||
|
||||
PRODUCT = "SyncPulse Scale Bridge"
|
||||
SERVICE_ID = "syncpulse-scale-bridge"
|
||||
EXE_NAME = "SyncPulseScaleBridge"
|
||||
|
||||
# Authorship. Keeping these visible in the user interface is a condition of
|
||||
# the licence (GPLv3 section 7(b), see NOTICE.md).
|
||||
AUTHOR = "Farshad Zamani"
|
||||
COMPANY = "Cloudtart"
|
||||
WEBSITE = "https://cloudtart.com"
|
||||
GITHUB = "https://github.com/farshad-zamani"
|
||||
LINKEDIN = "https://www.linkedin.com/in/farshad-zamani92/"
|
||||
REPOSITORY = "https://github.com/farshad-zamani/syncpulse-scale-bridge"
|
||||
PLATFORM = "SyncPulse"
|
||||
LICENSE_ID = "GPL-3.0-or-later"
|
||||
COPYRIGHT = f"Copyright (C) 2026 {AUTHOR} - {COMPANY}"
|
||||
|
||||
ABOUT = {
|
||||
"product": PRODUCT,
|
||||
"version": __version__,
|
||||
"author": AUTHOR,
|
||||
"company": COMPANY,
|
||||
"website": WEBSITE,
|
||||
"github": GITHUB,
|
||||
"linkedin": LINKEDIN,
|
||||
"repository": REPOSITORY,
|
||||
"platform": PLATFORM,
|
||||
"license": LICENSE_ID,
|
||||
"copyright": COPYRIGHT,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
import sys
|
||||
|
||||
from scale_bridge.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Command line. Without a command the bridge starts and its page opens in the
|
||||
browser — that is what a double-click on the program does.
|
||||
|
||||
SyncPulseScaleBridge run, open http://127.0.0.1:8765/
|
||||
SyncPulseScaleBridge ports list COM ports
|
||||
SyncPulseScaleBridge detect [--port COM7] [--thorough] [--save NAME]
|
||||
SyncPulseScaleBridge monitor --port COM7 [--baud 9600 --framing 8N1 --request "SI\\r\\n" hex]
|
||||
SyncPulseScaleBridge weight --profile NAME | --port COM7 --protocol mt_sics
|
||||
SyncPulseScaleBridge token [--new]
|
||||
SyncPulseScaleBridge firewall [--remove] [--remote-ip 192.168.1.10]
|
||||
SyncPulseScaleBridge autostart [--disable]
|
||||
SyncPulseScaleBridge about
|
||||
|
||||
Common options: --config FILE, --simulate SPEC (e.g. mahak_mds,toledo_cont:4800:7E1),
|
||||
--verbose; for running: --listen-host, --listen-port, --no-browser, --service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
from . import ABOUT, PRODUCT, SERVICE_ID, __version__, profiles, winutil
|
||||
from .config import Config
|
||||
from .detect import Detector
|
||||
from .protocols import PROTOCOLS, catalogue_info
|
||||
from .serialio import LineSettings, Registry, format_hex, list_serial_ports, now_ms, open_real, parse_hex, printable
|
||||
from .simulator import SimulatorBank
|
||||
|
||||
log = logging.getLogger("scale_bridge")
|
||||
|
||||
LOG_TEXT = {
|
||||
"port_start": "Searching {port} ...",
|
||||
"listen": " listening at {baud} {framing}",
|
||||
"heard": " heard {bytes} bytes at {baud} ({readable_pct}% readable)",
|
||||
"seven_bit": " 7 data bits with parity? listening again as 7E1/7O1",
|
||||
"reply": " reply to {protocol}: {text}",
|
||||
"found": " FOUND {protocol} at {baud} {framing}: {value} {unit}",
|
||||
"not_found": " nothing recognised on {port}",
|
||||
"open_failed": " cannot open {port}: {error}",
|
||||
"press_print": " >>> Press PRINT on the scale now (listening at {baud} for {seconds} s)",
|
||||
"print_wrong_speed": " data at {baud} was not readable - press PRINT again (next speed)",
|
||||
"print_timeout": " nothing arrived while waiting for PRINT",
|
||||
}
|
||||
|
||||
|
||||
def say(text: str = "") -> None:
|
||||
try:
|
||||
print(text, flush=True)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
sys.stdout.buffer.write((text + "\n").encode("ascii", "replace"))
|
||||
sys.stdout.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def setup_logging(directory: str, verbose: bool, quiet_console: bool = False) -> None:
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||
handlers: list[logging.Handler] = []
|
||||
if not quiet_console and sys.stderr is not None:
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(fmt)
|
||||
handlers.append(console)
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(os.path.join(directory, "bridge.log"), maxBytes=1_000_000,
|
||||
backupCount=3, encoding="utf-8")
|
||||
file_handler.setFormatter(fmt)
|
||||
handlers.append(file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, handlers=handlers or None,
|
||||
force=True)
|
||||
|
||||
|
||||
def _opener(args):
|
||||
if args.simulate:
|
||||
return SimulatorBank(args.simulate).opener(open_real), SimulatorBank(args.simulate)
|
||||
return open_real, None
|
||||
|
||||
|
||||
def _ports(args) -> list[dict]:
|
||||
ports = list_serial_ports()
|
||||
if args.simulate:
|
||||
ports += SimulatorBank(args.simulate).ports()
|
||||
return ports
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_ports(args) -> int:
|
||||
ports = _ports(args)
|
||||
if not ports:
|
||||
say("No COM ports found. Plug in the USB-serial adapter and check its driver in Device Manager.")
|
||||
return 1
|
||||
for p in ports:
|
||||
chip = f" [{p['chip']}]" if p.get("chip") else ""
|
||||
say(f"{p['device']:8} {p.get('description') or ''}{chip} ({p.get('kind')})")
|
||||
if p.get("note"):
|
||||
say(f" note: {p['note']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_detect(args, config: Config) -> int:
|
||||
opener, _ = _opener(args)
|
||||
ports = args.ports or [p["device"] for p in _ports(args)
|
||||
if args.bluetooth or p.get("kind") != "bluetooth"]
|
||||
if not ports:
|
||||
say("No COM ports to search.")
|
||||
return 1
|
||||
|
||||
def show(code: str, **kw) -> None:
|
||||
text = LOG_TEXT.get(code)
|
||||
if text and (args.verbose or code != "listen"):
|
||||
kw.setdefault("readable_pct", round(float(kw.get("readable") or 0) * 100))
|
||||
if "protocol" in kw and kw["protocol"] in PROTOCOLS:
|
||||
kw["protocol"] = PROTOCOLS[kw["protocol"]].name
|
||||
try:
|
||||
say(text.format(**kw))
|
||||
except (KeyError, IndexError):
|
||||
say(f" {code} {kw}")
|
||||
|
||||
say(f"{PRODUCT} {__version__} - auto-detection ({'thorough' if args.thorough else 'quick'}); "
|
||||
+ ("listen only: nothing is sent." if args.listen_only else "read-only requests only."))
|
||||
results = Detector(opener, log=show, thorough=args.thorough, print_wait_s=args.wait_print,
|
||||
listen_only=args.listen_only).run(ports)
|
||||
say()
|
||||
found = [r for r in results if r.get("found")]
|
||||
for r in results:
|
||||
if r.get("found"):
|
||||
s = r["serial"]
|
||||
reading = r["reading"]
|
||||
fields = reading.get("fields") or {}
|
||||
if fields:
|
||||
say(f"{r['port']}: label fields: " + ", ".join(f"{k}={v}" for k, v in fields.items()))
|
||||
say(f"{r['port']}: {r['protocol_name']} {s['baudrate']} {s['bytesize']}{s['parity']}{s['stopbits']}"
|
||||
f" request={r['exchange']['request_hex'] or '-'} weight={reading['value']} {reading['unit']}"
|
||||
f" confidence={int(r['confidence'] * 100)}%")
|
||||
else:
|
||||
unknown = r.get("unknown")
|
||||
extra = f" (readable data at {unknown['baud']} {unknown['framing']}: {unknown['sample_text'][:60]})" \
|
||||
if unknown else ""
|
||||
say(f"{r['port']}: no scale recognised{extra}{' - ' + r['error'] if r.get('error') else ''}")
|
||||
if args.save and found:
|
||||
for i, r in enumerate(found):
|
||||
name = args.save if len(found) == 1 else f"{args.save}-{profiles.slug(r['port'])}"
|
||||
saved = config.save_profile(profiles.from_detection(r, name))
|
||||
say(f"saved profile '{saved['name']}' in {config.path}")
|
||||
if args.json:
|
||||
say(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
return 0 if found else 1
|
||||
|
||||
|
||||
def cmd_monitor(args) -> int:
|
||||
opener, _ = _opener(args)
|
||||
framing = args.framing.upper()
|
||||
request = parse_hex(args.request or "")
|
||||
settings = LineSettings(args.port).with_line(args.baud, framing)
|
||||
say(f"{settings.port} {settings.baudrate} {settings.framing}, "
|
||||
f"{'request ' + format_hex(request) + ' every ' + str(args.poll) + ' ms' if request else 'listening'}"
|
||||
f" for {args.seconds} s (Ctrl+C stops)")
|
||||
ser = opener(settings)
|
||||
collected = bytearray()
|
||||
try:
|
||||
end = time.monotonic() + args.seconds
|
||||
next_request = 0.0
|
||||
buf, last = bytearray(), time.monotonic()
|
||||
while time.monotonic() < end:
|
||||
now = time.monotonic()
|
||||
if request and now >= next_request:
|
||||
ser.write(request)
|
||||
next_request = now + args.poll / 1000
|
||||
waiting = ser.in_waiting
|
||||
chunk = ser.read(waiting if waiting else 1)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
last = time.monotonic()
|
||||
continue
|
||||
if buf and time.monotonic() - last > 0.05:
|
||||
say(f"{format_hex(bytes(buf))} | {printable(bytes(buf))}")
|
||||
collected += buf
|
||||
buf.clear()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
ser.close()
|
||||
say()
|
||||
readable = [(pid, p) for pid, p in PROTOCOLS.items() if not pid.startswith("custom")]
|
||||
for pid, proto in readable:
|
||||
found = [r for r in proto.readings(bytes(collected)) if r.value is not None]
|
||||
if found:
|
||||
say(f"readable as {proto.name}: {found[-1].value} {found[-1].unit} (x{len(found)})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_weight(args, config: Config) -> int:
|
||||
opener, _ = _opener(args)
|
||||
if args.profile:
|
||||
profile = config.profile(args.profile)
|
||||
if profile is None:
|
||||
say(f"no profile named {args.profile!r} in {config.path}")
|
||||
return 1
|
||||
elif args.port and args.protocol:
|
||||
serial_cfg = {"baudrate": args.baud}
|
||||
if args.framing:
|
||||
f = args.framing.upper()
|
||||
serial_cfg.update(bytesize=int(f[0]), parity=f[1], stopbits=int(f[2]))
|
||||
profile = profiles.ad_hoc(args.port, args.protocol, serial_cfg)
|
||||
else:
|
||||
say("give --profile NAME, or --port COMx --protocol ID")
|
||||
return 2
|
||||
registry = Registry(opener)
|
||||
try:
|
||||
worker = registry.worker_for(profiles.line_settings(profile))
|
||||
result = profiles.wait_for_reading(profile, worker, 1500, int(args.wait * 1000), now_ms)
|
||||
finally:
|
||||
registry.stop_all()
|
||||
say(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
def cmd_token(args, config: Config) -> int:
|
||||
say(config.rotate_token() if args.new else config.token)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_firewall(args, config: Config) -> int:
|
||||
result = winutil.firewall_close() if args.remove else winutil.firewall_open(config.listen_port, args.remote_ip)
|
||||
say(json.dumps(result, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
def cmd_autostart(args, config: Config) -> int:
|
||||
result = winutil.autostart_disable() if args.disable else winutil.autostart_enable(config.path)
|
||||
say(json.dumps(result, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
def cmd_about() -> int:
|
||||
for key in ("product", "version", "author", "company", "website", "github", "linkedin",
|
||||
"repository", "license"):
|
||||
say(f"{key:11} {ABOUT[key]}")
|
||||
say(ABOUT["copyright"])
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_protocols() -> int:
|
||||
for p in catalogue_info():
|
||||
tested = " [field-tested]" if p["field_tested"] else ""
|
||||
say(f"{p['id']:16} {p['kind']:10} {p['request_hex'] or '-':14} {p['name']}{tested}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running the bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _running_copy(port: int) -> Optional[dict]:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2) as r:
|
||||
body = json.loads(r.read().decode("utf-8"))
|
||||
return body if body.get("service") == SERVICE_ID else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def cmd_run(args, config: Config) -> int:
|
||||
from .server import Bridge, make_server
|
||||
|
||||
host = args.listen_host or config.listen_host
|
||||
port = int(args.listen_port or config.listen_port)
|
||||
url = f"http://127.0.0.1:{port}/"
|
||||
open_page = not (args.no_browser or args.service)
|
||||
bridge = Bridge(config, simulate=args.simulate or "")
|
||||
try:
|
||||
httpd = make_server(bridge, host, port)
|
||||
except OSError as e:
|
||||
other = _running_copy(port)
|
||||
if other:
|
||||
say(f"{PRODUCT} is already running on this PC (version {other.get('version')}).")
|
||||
if open_page:
|
||||
say(f"Opening {url}")
|
||||
winutil.open_browser(url)
|
||||
return 0
|
||||
log.error("cannot listen on %s:%d: %s", host, port, e)
|
||||
say(f"Port {port} is used by another program. Close it, or start with --listen-port 8766.")
|
||||
return 1
|
||||
|
||||
bar = "=" * 68
|
||||
say(bar)
|
||||
say(f" {PRODUCT} {__version__}" + (" [SIMULATION]" if args.simulate else ""))
|
||||
say(f" {ABOUT['copyright']} - {ABOUT['website']}")
|
||||
say(f" {ABOUT['license']} - {ABOUT['repository']}")
|
||||
say(bar)
|
||||
say(f" Control page (this PC) : {url}")
|
||||
for ip in winutil.lan_addresses() or ["<this PC's IP address>"]:
|
||||
say(f" Bridge address : {ip} port {port}")
|
||||
say(f" Access key : {config.token}")
|
||||
say(f" Settings : {config.path}")
|
||||
say(bar)
|
||||
say(" Keep this window open while the scales are in use; closing it stops the bridge.")
|
||||
say(" To run in the background at startup, use 'Start with Windows' on the control page.")
|
||||
say(bar)
|
||||
log.info("listening on %s:%d", host, port)
|
||||
if open_page:
|
||||
winutil.open_browser(url)
|
||||
try:
|
||||
httpd.serve_forever(poll_interval=0.5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
httpd.server_close()
|
||||
bridge.registry.stop_all()
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
|
||||
common.add_argument("--config", help="settings file (default: bridge.json beside the program)")
|
||||
common.add_argument("--simulate", metavar="SPEC",
|
||||
help="add simulated scales on COM90.. e.g. 'mahak_mds,toledo_cont:4800:7E1'")
|
||||
common.add_argument("--verbose", action="store_true", help="more detail in the log")
|
||||
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="SyncPulseScaleBridge",
|
||||
description=f"{PRODUCT} {__version__} - finds RS-232 scales, reads them and serves the weight "
|
||||
f"on the network.",
|
||||
epilog=f"{ABOUT['copyright']} - {ABOUT['website']} - {ABOUT['repository']}")
|
||||
ap.add_argument("--version", action="version", version=f"{PRODUCT} {__version__}")
|
||||
ap.add_argument("--config", default=None)
|
||||
ap.add_argument("--simulate", default="", metavar="SPEC")
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
ap.add_argument("--listen-host", help="address to listen on (default 0.0.0.0)")
|
||||
ap.add_argument("--listen-port", type=int, help="port to listen on (default 8765)")
|
||||
ap.add_argument("--no-browser", action="store_true", help="do not open the control page")
|
||||
ap.add_argument("--service", action="store_true", help="background mode (used by 'Start with Windows')")
|
||||
sub = ap.add_subparsers(dest="command", metavar="command")
|
||||
|
||||
sub.add_parser("run", parents=[common], help="run the bridge (the default)")
|
||||
sub.add_parser("ports", parents=[common], help="list COM ports")
|
||||
d = sub.add_parser("detect", parents=[common], help="find the scale automatically")
|
||||
d.add_argument("--port", dest="ports", action="append", help="only this port (repeatable)")
|
||||
d.add_argument("--thorough", action="store_true", help="more speeds and framings (slower)")
|
||||
d.add_argument("--bluetooth", action="store_true", help="also search Bluetooth serial ports")
|
||||
d.add_argument("--wait-print", type=float, default=0, metavar="SECONDS",
|
||||
help="the scale sends only on its Print key: wait this long for a press")
|
||||
d.add_argument("--listen-only", action="store_true",
|
||||
help="never send anything to the device (skip asking)")
|
||||
d.add_argument("--save", metavar="NAME", help="save what is found as a profile")
|
||||
d.add_argument("--json", action="store_true", help="print the full results as JSON")
|
||||
m = sub.add_parser("monitor", parents=[common], help="show the raw bytes on a port")
|
||||
m.add_argument("--port", required=True)
|
||||
m.add_argument("--baud", type=int, default=9600)
|
||||
m.add_argument("--framing", default="8N1")
|
||||
m.add_argument("--request", default="", help="hex bytes to send, e.g. '53 49 0D 0A'")
|
||||
m.add_argument("--poll", type=int, default=500, help="request interval in ms")
|
||||
m.add_argument("--seconds", type=float, default=10)
|
||||
w = sub.add_parser("weight", parents=[common], help="read the weight once")
|
||||
w.add_argument("--profile")
|
||||
w.add_argument("--port")
|
||||
w.add_argument("--protocol", choices=sorted(PROTOCOLS))
|
||||
w.add_argument("--baud", type=int, default=9600)
|
||||
w.add_argument("--framing", default="")
|
||||
w.add_argument("--wait", type=float, default=3.0, help="seconds to wait for a reading")
|
||||
t = sub.add_parser("token", parents=[common], help="show the access key")
|
||||
t.add_argument("--new", action="store_true", help="make a new key")
|
||||
f = sub.add_parser("firewall", parents=[common], help="open the bridge port in Windows Firewall")
|
||||
f.add_argument("--remove", action="store_true")
|
||||
f.add_argument("--remote-ip", default=None, help="allow only this address")
|
||||
a = sub.add_parser("autostart", parents=[common], help="start with Windows (scheduled task)")
|
||||
a.add_argument("--disable", action="store_true")
|
||||
sub.add_parser("protocols", parents=[common], help="list the protocol library")
|
||||
sub.add_parser("about", parents=[common], help="author, licence and links")
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
command = args.command or "run"
|
||||
if command == "about":
|
||||
return cmd_about()
|
||||
if command == "protocols":
|
||||
return cmd_protocols()
|
||||
if command == "ports":
|
||||
return cmd_ports(args)
|
||||
if command == "monitor":
|
||||
return cmd_monitor(args)
|
||||
|
||||
config = Config(args.config)
|
||||
setup_logging(os.path.dirname(config.path), args.verbose,
|
||||
quiet_console=command != "run" and not args.verbose)
|
||||
if command == "detect":
|
||||
return cmd_detect(args, config)
|
||||
if command == "weight":
|
||||
return cmd_weight(args, config)
|
||||
if command == "token":
|
||||
return cmd_token(args, config)
|
||||
if command == "firewall":
|
||||
return cmd_firewall(args, config)
|
||||
if command == "autostart":
|
||||
return cmd_autostart(args, config)
|
||||
return cmd_run(args, config)
|
||||
@@ -0,0 +1,195 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
bridge.json — the bridge's settings, access key and saved scale profiles.
|
||||
|
||||
It lives beside the program, so copying the folder copies the setup. When
|
||||
that folder is read-only (e.g. Program Files), %LOCALAPPDATA% is used.
|
||||
Files written by earlier versions (1.x) are read as they are.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import EXE_NAME
|
||||
from .profiles import MAX_PROFILES, validate
|
||||
|
||||
log = logging.getLogger("scale_bridge")
|
||||
|
||||
DEFAULT_PORT = 8765
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"listen_host": "0.0.0.0",
|
||||
"listen_port": DEFAULT_PORT,
|
||||
"token": "",
|
||||
# Empty = any machine that has the key. Put the platform server's IP here
|
||||
# to refuse everybody else, e.g. ["192.168.1.10"].
|
||||
"allowed_clients": [],
|
||||
"profiles": [],
|
||||
}
|
||||
|
||||
|
||||
def app_dir() -> str:
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.dirname(os.path.abspath(sys.executable))
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _writable(directory: str) -> bool:
|
||||
try:
|
||||
fd, probe = tempfile.mkstemp(prefix=".write-test-", dir=directory)
|
||||
os.close(fd)
|
||||
os.remove(probe)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def default_path() -> str:
|
||||
beside = os.path.join(app_dir(), "bridge.json")
|
||||
if os.path.exists(beside) or _writable(app_dir()):
|
||||
return beside
|
||||
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||
folder = os.path.join(base, EXE_NAME)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return os.path.join(folder, "bridge.json")
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, path: Optional[str] = None):
|
||||
self.path = os.path.abspath(path or default_path())
|
||||
self.lock = threading.RLock()
|
||||
self.data: dict[str, Any] = dict(DEFAULTS)
|
||||
self.load()
|
||||
|
||||
# -- file --------------------------------------------------------------
|
||||
def load(self) -> None:
|
||||
with self.lock:
|
||||
data = dict(DEFAULTS)
|
||||
if os.path.exists(self.path):
|
||||
try:
|
||||
with open(self.path, encoding="utf-8-sig") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
data.update(loaded)
|
||||
except (OSError, ValueError) as e:
|
||||
backup = self.path + ".broken"
|
||||
log.error("%s could not be read (%s); kept as %s and started fresh", self.path, e,
|
||||
backup)
|
||||
try:
|
||||
os.replace(self.path, backup)
|
||||
except OSError:
|
||||
pass
|
||||
profiles = []
|
||||
for raw in data.get("profiles") or []:
|
||||
try:
|
||||
profiles.append(validate(raw))
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
log.warning("profile %r ignored: %s", (raw or {}).get("name"), e)
|
||||
data["profiles"] = profiles
|
||||
self.data = data
|
||||
if not data.get("token"):
|
||||
data["token"] = new_token()
|
||||
self.save()
|
||||
log.info("created %s with a new access key", self.path)
|
||||
|
||||
def save(self) -> None:
|
||||
with self.lock:
|
||||
folder = os.path.dirname(self.path)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".bridge-", suffix=".json", dir=folder)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp, self.path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# -- values ------------------------------------------------------------
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return str(self.data.get("token") or "")
|
||||
|
||||
def rotate_token(self) -> str:
|
||||
with self.lock:
|
||||
self.data["token"] = new_token()
|
||||
self.save()
|
||||
return self.token
|
||||
|
||||
@property
|
||||
def listen_host(self) -> str:
|
||||
return str(self.data.get("listen_host") or "0.0.0.0")
|
||||
|
||||
@property
|
||||
def listen_port(self) -> int:
|
||||
try:
|
||||
return int(self.data.get("listen_port") or DEFAULT_PORT)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_PORT
|
||||
|
||||
@property
|
||||
def allowed_clients(self) -> list[str]:
|
||||
value = self.data.get("allowed_clients") or []
|
||||
return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, list) else []
|
||||
|
||||
def set_allowed_clients(self, clients: list[str]) -> None:
|
||||
import ipaddress
|
||||
cleaned = []
|
||||
for c in clients:
|
||||
c = str(c).strip()
|
||||
if not c:
|
||||
continue
|
||||
ipaddress.ip_address(c) # raises ValueError
|
||||
cleaned.append(c)
|
||||
with self.lock:
|
||||
self.data["allowed_clients"] = cleaned
|
||||
self.save()
|
||||
|
||||
# -- profiles ----------------------------------------------------------
|
||||
def profiles(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return [dict(p) for p in self.data.get("profiles") or []]
|
||||
|
||||
def profile(self, name: str) -> Optional[dict]:
|
||||
name = str(name or "").strip().lower()
|
||||
with self.lock:
|
||||
for p in self.data.get("profiles") or []:
|
||||
if p["name"].lower() == name:
|
||||
return dict(p)
|
||||
return None
|
||||
|
||||
def save_profile(self, raw: dict) -> dict:
|
||||
profile = validate(raw)
|
||||
with self.lock:
|
||||
items = [p for p in self.data.get("profiles") or []
|
||||
if p["name"].lower() != profile["name"].lower()]
|
||||
if len(items) >= MAX_PROFILES:
|
||||
raise ValueError(f"at most {MAX_PROFILES} profiles")
|
||||
items.append(profile)
|
||||
self.data["profiles"] = items
|
||||
self.save()
|
||||
return profile
|
||||
|
||||
def delete_profile(self, name: str) -> bool:
|
||||
with self.lock:
|
||||
items = self.data.get("profiles") or []
|
||||
kept = [p for p in items if p["name"].lower() != str(name).lower()]
|
||||
if len(kept) == len(items):
|
||||
return False
|
||||
self.data["profiles"] = kept
|
||||
self.save()
|
||||
return True
|
||||
@@ -0,0 +1,339 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Auto-detection: find how the scale on a COM port talks.
|
||||
|
||||
1. LISTEN — at each common speed, in 8N1, for a moment. A scale that sends
|
||||
by itself shows up here. Readable text means the right speed; bytes that
|
||||
only become readable with their top bit cleared mean 7 data bits with
|
||||
parity (7E1/7O1), and the port is listened to again that way. Whatever
|
||||
arrives is matched against every continuous format in the library.
|
||||
|
||||
2. WAIT FOR PRINT (optional) — some indicators send only when their Print
|
||||
key is pressed (e.g. those wired to a label printer). The port is held
|
||||
open at one speed until something arrives; garbage means another speed,
|
||||
so the next press is awaited at the next one. Parity is read from the
|
||||
bytes themselves, so one press is enough for 7E1/7O1 too.
|
||||
|
||||
3. ASK — if nothing useful was heard, each read-only request in the library
|
||||
is sent at each speed and framing, and the reply is matched against the
|
||||
protocol that was asked. A reply is confirmed by asking a second time.
|
||||
`listen_only` skips this step: then nothing at all is sent to the device.
|
||||
|
||||
Everything is reported step by step through `log(code, **details)`, so the
|
||||
user interface can show (and translate) what is happening.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from .protocols import CONTINUOUS_IDS, POLL_IDS, PROTOCOLS, Reading
|
||||
from .serialio import LineSettings, Opener, format_hex, printable
|
||||
|
||||
PASSIVE_BAUDS = [9600, 2400, 4800, 19200, 1200, 38400, 57600, 115200]
|
||||
ACTIVE_QUICK = [(9600, "8N1"), (9600, "7E1"), (2400, "7E1"), (2400, "8N1"), (4800, "8N1"),
|
||||
(19200, "8N1"), (1200, "7E1"), (115200, "8N1")]
|
||||
ACTIVE_THOROUGH = [(9600, "8N1"), (9600, "7E1"), (9600, "7O1"), (2400, "8N1"), (2400, "7E1"),
|
||||
(4800, "8N1"), (4800, "7E1"), (19200, "8N1"), (19200, "7E1"), (1200, "8N1"),
|
||||
(1200, "7E1"), (38400, "8N1"), (57600, "8N1"), (115200, "8N1")]
|
||||
FRAMING_CHARS = {0x02, 0x03, 0x06, 0x0A, 0x0D, 0x09, 0x01, 0x04}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Timings:
|
||||
passive_s: float = 1.3
|
||||
probe_s: float = 0.45
|
||||
confirm_s: float = 1.2
|
||||
drain_s: float = 0.06
|
||||
|
||||
|
||||
def readability(data: bytes, mask7: bool = False) -> float:
|
||||
if not data:
|
||||
return 0.0
|
||||
ok = 0
|
||||
for b in data:
|
||||
if mask7:
|
||||
b &= 0x7F
|
||||
if 0x20 <= b <= 0x7E or b in FRAMING_CHARS:
|
||||
ok += 1
|
||||
return ok / len(data)
|
||||
|
||||
|
||||
def _confidence(protocol_id: str, readings: list[Reading]) -> float:
|
||||
spec = PROTOCOLS[protocol_id].specificity
|
||||
base = {3: 0.9, 2: 0.8, 1: 0.6}.get(spec, 0.5)
|
||||
if any(r.checksum_ok for r in readings):
|
||||
base += 0.08
|
||||
if len(readings) >= 3:
|
||||
base += 0.02
|
||||
return round(min(base, 0.99), 2)
|
||||
|
||||
|
||||
class Detector:
|
||||
def __init__(self, opener: Opener, log: Callable[..., None] = lambda code, **kw: None,
|
||||
cancelled: Callable[[], bool] = lambda: False, thorough: bool = False,
|
||||
timings: Optional[Timings] = None, print_wait_s: float = 0,
|
||||
listen_only: bool = False):
|
||||
self.opener = opener
|
||||
self.print_wait_s = max(0.0, float(print_wait_s or 0))
|
||||
self.listen_only = listen_only
|
||||
self.log = log
|
||||
self.cancelled = cancelled
|
||||
self.thorough = thorough
|
||||
self.t = timings or Timings()
|
||||
self.active_combos = ACTIVE_THOROUGH if thorough else ACTIVE_QUICK
|
||||
self.steps_done = 0
|
||||
|
||||
# -- plumbing ----------------------------------------------------------
|
||||
def _step(self) -> None:
|
||||
self.steps_done += 1
|
||||
|
||||
@property
|
||||
def steps_per_port(self) -> int:
|
||||
return len(PASSIVE_BAUDS) + len(self.active_combos) * len(POLL_IDS)
|
||||
|
||||
@property
|
||||
def progress(self) -> float:
|
||||
"""How far the port being searched is, 0..1."""
|
||||
return min(1.0, self.steps_done / self.steps_per_port)
|
||||
|
||||
def _drain(self, ser: Any, seconds: float) -> bytes:
|
||||
buf = bytearray()
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
waiting = ser.in_waiting
|
||||
buf += ser.read(waiting if waiting else 1)
|
||||
return bytes(buf)
|
||||
|
||||
def _listen(self, settings: LineSettings, seconds: float) -> bytes:
|
||||
ser = self.opener(settings)
|
||||
try:
|
||||
return self._drain(ser, seconds)
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
# -- the two phases ----------------------------------------------------
|
||||
def _match_continuous(self, data: bytes) -> Optional[tuple[str, list[Reading]]]:
|
||||
best = None
|
||||
for pid in CONTINUOUS_IDS:
|
||||
readings = PROTOCOLS[pid].readings(data)
|
||||
numeric = [r for r in readings if r.value is not None or r.overload]
|
||||
if len(numeric) < max(1, PROTOCOLS[pid].min_frames):
|
||||
continue
|
||||
key = (PROTOCOLS[pid].specificity, len(numeric))
|
||||
if best is None or key > best[0]:
|
||||
best = (key, pid, numeric)
|
||||
return (best[1], best[2]) if best else None
|
||||
|
||||
def _passive(self, port: str) -> tuple[Optional[dict], Optional[dict]]:
|
||||
"""Returns (result, unknown) — `unknown` describes readable data nobody recognised."""
|
||||
unknown = None
|
||||
for baud in PASSIVE_BAUDS:
|
||||
if self.cancelled():
|
||||
return None, unknown
|
||||
settings = LineSettings(port, baud)
|
||||
self.log("listen", port=port, baud=baud, framing="8N1")
|
||||
try:
|
||||
data = self._listen(settings, self.t.passive_s)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._step()
|
||||
self.log("open_failed", port=port, error=str(e))
|
||||
return {"error": str(e)}, None
|
||||
self._step()
|
||||
if not data:
|
||||
continue
|
||||
r8, r7 = readability(data), readability(data, mask7=True)
|
||||
self.log("heard", port=port, baud=baud, bytes=len(data), readable=round(r8, 2),
|
||||
readable7=round(r7, 2))
|
||||
attempts = []
|
||||
if r8 >= 0.85:
|
||||
attempts.append(("8N1", data))
|
||||
elif r7 >= 0.9:
|
||||
self.log("seven_bit", port=port, baud=baud)
|
||||
for framing in ("7E1", "7O1"):
|
||||
try:
|
||||
seven = self._listen(settings.with_line(baud, framing), self.t.passive_s)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
attempts.append((framing, seven))
|
||||
break
|
||||
else:
|
||||
# Framed binary formats can still be found in poorly readable data.
|
||||
attempts.append(("8N1", data))
|
||||
for framing, sample in attempts:
|
||||
match = self._match_continuous(sample)
|
||||
if match:
|
||||
pid, readings = match
|
||||
return self._result(port, pid, settings.with_line(baud, framing), readings,
|
||||
sample, mode="continuous"), None
|
||||
if readability(sample) >= 0.85 and unknown is None:
|
||||
unknown = {"baud": baud, "framing": framing, "sample_hex": format_hex(sample[:96]),
|
||||
"sample_text": printable(sample[:96])}
|
||||
return None, unknown
|
||||
|
||||
def _until_quiet(self, settings: LineSettings, wait_s: float, quiet_s: float = 0.8) -> bytes:
|
||||
"""Hold the port open until data arrives (or wait_s passes), then until it goes quiet."""
|
||||
ser = self.opener(settings)
|
||||
try:
|
||||
buf = bytearray()
|
||||
deadline = time.monotonic() + wait_s
|
||||
last = 0.0
|
||||
while not self.cancelled():
|
||||
waiting = ser.in_waiting
|
||||
chunk = ser.read(waiting if waiting else 1)
|
||||
now = time.monotonic()
|
||||
if chunk:
|
||||
buf += chunk
|
||||
last = now
|
||||
elif buf and now - last >= quiet_s:
|
||||
break
|
||||
elif not buf and now >= deadline:
|
||||
break
|
||||
return bytes(buf)
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
@staticmethod
|
||||
def _seven_bit(data: bytes) -> tuple[str, bytes]:
|
||||
"""7 data bits read as 8: bit 7 is the parity bit, which tells even from odd."""
|
||||
even = sum(1 for b in data if (bin(b & 0x7F).count("1") + (b >> 7)) % 2 == 0)
|
||||
framing = "7E1" if even >= len(data) / 2 else "7O1"
|
||||
return framing, bytes(b & 0x7F for b in data)
|
||||
|
||||
def _await_print(self, port: str) -> tuple[Optional[dict], Optional[dict]]:
|
||||
unknown = None
|
||||
for baud in PASSIVE_BAUDS:
|
||||
if self.cancelled():
|
||||
break
|
||||
settings = LineSettings(port, baud)
|
||||
self.log("press_print", port=port, baud=baud, seconds=int(self.print_wait_s))
|
||||
try:
|
||||
data = self._until_quiet(settings, self.print_wait_s)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log("open_failed", port=port, error=str(e))
|
||||
return {"error": str(e)}, None
|
||||
if not data:
|
||||
if not self.cancelled():
|
||||
self.log("print_timeout", port=port)
|
||||
break
|
||||
r8, r7 = readability(data), readability(data, mask7=True)
|
||||
self.log("heard", port=port, baud=baud, bytes=len(data), readable=round(r8, 2),
|
||||
readable7=round(r7, 2))
|
||||
framing, sample = "8N1", data
|
||||
if r8 < 0.85 and r7 >= 0.9:
|
||||
framing, sample = self._seven_bit(data)
|
||||
match = self._match_continuous(sample)
|
||||
if match:
|
||||
pid, readings = match
|
||||
return self._result(port, pid, settings.with_line(baud, framing), readings, sample,
|
||||
mode="print"), None
|
||||
if readability(sample) >= 0.85:
|
||||
unknown = {"baud": baud, "framing": framing, "sample_hex": format_hex(sample[:96]),
|
||||
"sample_text": printable(sample[:96])}
|
||||
break
|
||||
self.log("print_wrong_speed", port=port, baud=baud)
|
||||
return None, unknown
|
||||
|
||||
def _active(self, port: str) -> tuple[Optional[dict], Optional[dict]]:
|
||||
unknown = None
|
||||
for baud, framing in self.active_combos:
|
||||
if self.cancelled():
|
||||
return None, unknown
|
||||
settings = LineSettings(port).with_line(baud, framing)
|
||||
try:
|
||||
ser = self.opener(settings)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log("open_failed", port=port, error=str(e))
|
||||
return {"error": str(e)}, None
|
||||
try:
|
||||
for pid in POLL_IDS:
|
||||
if self.cancelled():
|
||||
return None, unknown
|
||||
proto = PROTOCOLS[pid]
|
||||
self.log("ask", port=port, baud=baud, framing=framing, protocol=pid,
|
||||
request=format_hex(proto.request))
|
||||
reply = self._ask(ser, proto)
|
||||
self._step()
|
||||
if not reply:
|
||||
continue
|
||||
readings = [r for r in proto.readings(reply) if r.value is not None or r.overload]
|
||||
self.log("reply", port=port, protocol=pid, bytes=len(reply),
|
||||
text=printable(reply[:48]), recognised=bool(readings))
|
||||
if readings:
|
||||
again = self._ask(ser, proto, seconds=self.t.confirm_s)
|
||||
confirm = [r for r in proto.readings(again) if r.value is not None or r.overload]
|
||||
if confirm:
|
||||
line = LineSettings(port, baud, request=proto.request,
|
||||
after_ack=proto.after_ack,
|
||||
poll_s=proto.default_poll_ms / 1000).with_line(baud, framing)
|
||||
return self._result(port, pid, line, readings + confirm, reply + again,
|
||||
mode="poll"), None
|
||||
elif unknown is None:
|
||||
unknown = {"baud": baud, "framing": framing, "request": format_hex(proto.request),
|
||||
"sample_hex": format_hex(reply[:96]), "sample_text": printable(reply[:96])}
|
||||
finally:
|
||||
ser.close()
|
||||
return None, unknown
|
||||
|
||||
def _ask(self, ser: Any, proto, seconds: Optional[float] = None) -> bytes:
|
||||
self._drain(ser, self.t.drain_s)
|
||||
ser.write(proto.request)
|
||||
reply = self._drain(ser, seconds or self.t.probe_s)
|
||||
if proto.after_ack and b"\x06" in reply:
|
||||
ser.write(proto.after_ack)
|
||||
reply += self._drain(ser, seconds or self.t.probe_s)
|
||||
return reply
|
||||
|
||||
# -- result ------------------------------------------------------------
|
||||
def _result(self, port: str, pid: str, settings: LineSettings, readings: list[Reading],
|
||||
sample: bytes, mode: str) -> dict:
|
||||
proto = PROTOCOLS[pid]
|
||||
latest = readings[-1]
|
||||
self.log("found", port=port, protocol=pid, baud=settings.baudrate, framing=settings.framing,
|
||||
value=latest.value, unit=latest.unit)
|
||||
return {
|
||||
"port": port,
|
||||
"found": True,
|
||||
"protocol": pid,
|
||||
"protocol_name": proto.name,
|
||||
"field_tested": proto.field_tested,
|
||||
"mode": mode,
|
||||
"serial": settings.serial_dict(),
|
||||
"exchange": settings.exchange_dict(),
|
||||
"readings": [r.as_dict() for r in readings[-6:]],
|
||||
"reading": latest.as_dict(),
|
||||
"sample_hex": format_hex(sample[:96]),
|
||||
"sample_text": printable(sample[:96]),
|
||||
"confidence": _confidence(pid, readings),
|
||||
}
|
||||
|
||||
def run_port(self, port: str) -> dict:
|
||||
self.steps_done = 0
|
||||
self.log("port_start", port=port)
|
||||
result, unknown = self._passive(port)
|
||||
if result and result.get("error"):
|
||||
return {"port": port, "found": False, "error": result["error"]}
|
||||
if result is None and self.print_wait_s and not unknown and not self.cancelled():
|
||||
result, unknown = self._await_print(port)
|
||||
if result and result.get("error"):
|
||||
return {"port": port, "found": False, "error": result["error"]}
|
||||
if result is None and not self.listen_only and not self.cancelled():
|
||||
result, unknown_active = self._active(port)
|
||||
if result and result.get("error"):
|
||||
return {"port": port, "found": False, "error": result["error"]}
|
||||
unknown = unknown or unknown_active
|
||||
if result:
|
||||
return result
|
||||
self.log("not_found", port=port, heard=bool(unknown))
|
||||
return {"port": port, "found": False, "unknown": unknown, "cancelled": self.cancelled()}
|
||||
|
||||
def run(self, ports: list[str]) -> list[dict]:
|
||||
results = []
|
||||
for port in ports:
|
||||
if self.cancelled():
|
||||
break
|
||||
results.append(self.run_port(port))
|
||||
self.log("done", found=sum(1 for r in results if r.get("found")), cancelled=self.cancelled())
|
||||
return results
|
||||
@@ -0,0 +1,146 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani - Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Scales that send only when Print is pressed — keep listening, keep a log.
|
||||
|
||||
Such a scale (typically one wired to a label printer) sends one label per
|
||||
press and nothing in between, so a label printed while nobody had the port
|
||||
open would be lost. For every saved profile whose protocol is of that kind,
|
||||
the bridge keeps the port open all the time and records each label:
|
||||
|
||||
in memory the last LABELS_KEPT labels per profile -> GET /labels
|
||||
on disk labels/<profile>-YYYY-MM-DD.jsonl beside bridge.json
|
||||
|
||||
Each record: {"seq", "at", "profile", "port", "value", "unit", "fields", "raw_hex"}.
|
||||
`seq` grows by one per label since the bridge started, so a reader can ask
|
||||
for "everything after seq N" and never miss or repeat one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .profiles import line_settings
|
||||
from .protocols import PROTOCOLS, get_protocol
|
||||
from .serialio import PortBusy, PortWorker, Registry
|
||||
|
||||
log = logging.getLogger("scale_bridge")
|
||||
|
||||
LABELS_KEPT = 500
|
||||
PENDING_MAX = 16 * 1024
|
||||
KEEP_EVERY_S = 15
|
||||
|
||||
|
||||
def on_print(profile: dict) -> bool:
|
||||
proto = PROTOCOLS.get(profile.get("protocol") or "")
|
||||
return bool(proto and proto.kind == "print")
|
||||
|
||||
|
||||
class LabelLog:
|
||||
def __init__(self, folder: str):
|
||||
self.folder = folder
|
||||
self.lock = threading.Lock()
|
||||
self.records: dict[str, deque] = {}
|
||||
self.pending: dict[str, bytes] = {}
|
||||
self.seq = 0
|
||||
|
||||
# -- feeding -----------------------------------------------------------
|
||||
def feed(self, profile: dict, data: bytes) -> list[dict]:
|
||||
"""Bytes as they arrive; a label may come in more than one piece."""
|
||||
name = profile["name"]
|
||||
proto = get_protocol(profile["protocol"], profile.get("params"))
|
||||
with self.lock:
|
||||
buf = (self.pending.get(name, b"") + data)[-PENDING_MAX:]
|
||||
readings = proto.readings(buf)
|
||||
end_rx = getattr(proto, "_end", None)
|
||||
if readings and end_rx is not None:
|
||||
ends = list(end_rx.finditer(buf))
|
||||
buf = buf[ends[-1].end():] if ends else b""
|
||||
elif readings:
|
||||
buf = b""
|
||||
self.pending[name] = buf
|
||||
out = []
|
||||
for r in readings:
|
||||
if r.value is None:
|
||||
continue
|
||||
self.seq += 1
|
||||
rec = {"seq": self.seq, "at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"profile": name, "port": profile["port"], "value": r.value,
|
||||
"unit": r.unit or profile.get("unit") or "", "fields": dict(r.fields or {}),
|
||||
"raw_hex": r.raw.hex(" ").upper()}
|
||||
self.records.setdefault(name, deque(maxlen=LABELS_KEPT)).append(rec)
|
||||
out.append(rec)
|
||||
for rec in out:
|
||||
log.info("label %s: %s %s %s", name, rec["value"], rec["unit"], rec["fields"])
|
||||
self._write(rec)
|
||||
return out
|
||||
|
||||
def _write(self, rec: dict) -> None:
|
||||
try:
|
||||
os.makedirs(self.folder, exist_ok=True)
|
||||
day = rec["at"][:10]
|
||||
safe = re.sub(r"[^A-Za-z0-9_-]", "_", rec["profile"])
|
||||
with open(os.path.join(self.folder, f"{safe}-{day}.jsonl"), "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
except OSError as e:
|
||||
log.warning("label log not written: %s", e)
|
||||
|
||||
# -- reading -----------------------------------------------------------
|
||||
def list(self, profile: Optional[str] = None, after: int = 0, limit: int = 100) -> list[dict]:
|
||||
with self.lock:
|
||||
names = [profile] if profile else list(self.records)
|
||||
items = [r for n in names for r in self.records.get(n, ()) if r["seq"] > after]
|
||||
items.sort(key=lambda r: r["seq"])
|
||||
return items[-max(1, min(limit, LABELS_KEPT)):]
|
||||
|
||||
|
||||
class Keeper(threading.Thread):
|
||||
"""Holds the ports of print-only scales open and feeds what they send to the log."""
|
||||
|
||||
def __init__(self, registry: Registry, profiles: Callable[[], list[dict]], labels: LabelLog):
|
||||
super().__init__(name="label-keeper", daemon=True)
|
||||
self.registry = registry
|
||||
self.profiles = profiles
|
||||
self.labels = labels
|
||||
self._halt = threading.Event()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._halt.set()
|
||||
|
||||
def _hook(self, worker: PortWorker, profile: dict) -> None:
|
||||
if getattr(worker, "label_profile", None) == profile["name"]:
|
||||
return
|
||||
worker.listeners.append(lambda t, data, p=profile: self.labels.feed(p, data))
|
||||
worker.label_profile = profile["name"]
|
||||
|
||||
def tick(self) -> None:
|
||||
for profile in self.profiles():
|
||||
if not on_print(profile):
|
||||
continue
|
||||
try:
|
||||
worker = self.registry.worker_for(line_settings(profile))
|
||||
except PortBusy:
|
||||
continue # auto-detection has the port right now
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("cannot keep %s open: %s", profile.get("name"), e)
|
||||
continue
|
||||
self._hook(worker, profile)
|
||||
|
||||
def run(self) -> None:
|
||||
while not self._halt.is_set():
|
||||
try:
|
||||
self.tick()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("label keeper")
|
||||
self._halt.wait(KEEP_EVERY_S)
|
||||
|
||||
|
||||
def labels_folder(config_path: str) -> str:
|
||||
return os.path.join(os.path.dirname(os.path.abspath(config_path)), "labels")
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Profiles: a saved answer to "how does the scale on this port talk".
|
||||
|
||||
A profile is what auto-detection found (or what was set by hand): the port,
|
||||
the line settings, the request, and the protocol that reads the reply. The
|
||||
bridge keeps profiles in bridge.json; anybody on the network with the access
|
||||
key can then ask `/weight?profile=<name>` and get a finished number.
|
||||
|
||||
{
|
||||
"name": "packing-1", letters, digits, - and _
|
||||
"label": "Packing line scale",
|
||||
"port": "COM7",
|
||||
"protocol": "mahak_mds",
|
||||
"params": {}, only for custom_binary / custom_ascii
|
||||
"serial": {...}, LineSettings.serial_dict()
|
||||
"exchange": {...}, LineSettings.exchange_dict()
|
||||
"unit": "", the unit when the frames carry none
|
||||
"output_unit": "", convert to this unit ("" = as sent)
|
||||
"stable_samples": 3 equal readings in a row = stable, when
|
||||
the protocol has no stability flag
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from .protocols import GRAMS_PER, PROTOCOLS, Reading, convert, get_protocol, norm_unit
|
||||
from .serialio import LineSettings, PortWorker
|
||||
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$")
|
||||
MAX_PROFILES = 64
|
||||
RECENT_CHUNKS = 24
|
||||
TAIL_BYTES = 64 # end of the previous chunk read together with the next
|
||||
|
||||
|
||||
def slug(text: str) -> str:
|
||||
s = re.sub(r"[^A-Za-z0-9_-]+", "-", str(text or "")).strip("-_").lower()
|
||||
return (s or "scale")[:40]
|
||||
|
||||
|
||||
def validate(raw: dict) -> dict:
|
||||
"""Check a profile that arrives from the user interface or from bridge.json."""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("profile must be an object")
|
||||
name = str(raw.get("name") or "").strip()
|
||||
if not NAME_RE.match(name):
|
||||
raise ValueError("profile name: 1-40 letters, digits, '-' or '_', starting with a letter or digit")
|
||||
protocol = str(raw.get("protocol") or "")
|
||||
if protocol not in PROTOCOLS:
|
||||
raise ValueError(f"unknown protocol: {protocol!r}")
|
||||
params = raw.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError("params must be an object")
|
||||
if protocol == "custom_ascii":
|
||||
try:
|
||||
re.compile(str(params.get("regex") or "").encode("latin-1"))
|
||||
except (re.error, UnicodeEncodeError) as e:
|
||||
raise ValueError(f"invalid regular expression: {e}") from e
|
||||
if not params.get("regex"):
|
||||
raise ValueError("custom_ascii needs a regular expression")
|
||||
serial_cfg = dict(raw.get("serial") or {})
|
||||
port = str(raw.get("port") or serial_cfg.get("port") or "")
|
||||
line = LineSettings.from_dicts(serial_cfg, raw.get("exchange") or {}, port=port)
|
||||
unit = norm_unit(raw.get("unit") or "")
|
||||
output_unit = norm_unit(raw.get("output_unit") or "")
|
||||
if output_unit and output_unit not in GRAMS_PER:
|
||||
raise ValueError(f"unknown unit: {output_unit!r}")
|
||||
stable_samples = min(max(int(raw.get("stable_samples") or 3), 1), 20)
|
||||
return {
|
||||
"name": name,
|
||||
"label": str(raw.get("label") or "")[:80],
|
||||
"port": line.port,
|
||||
"protocol": protocol,
|
||||
"params": {k: params[k] for k in ("pattern", "value_offset", "value_length", "byte_order",
|
||||
"signed", "regex", "unit") if k in params},
|
||||
"serial": line.serial_dict(),
|
||||
"exchange": line.exchange_dict(),
|
||||
"unit": unit,
|
||||
"output_unit": output_unit,
|
||||
"stable_samples": stable_samples,
|
||||
"created": str(raw.get("created") or datetime.now().astimezone().isoformat(timespec="seconds")),
|
||||
}
|
||||
|
||||
|
||||
def from_detection(result: dict, name: str, label: str = "") -> dict:
|
||||
return validate({
|
||||
"name": name,
|
||||
"label": label,
|
||||
"port": result["port"],
|
||||
"protocol": result["protocol"],
|
||||
"serial": result["serial"],
|
||||
"exchange": result["exchange"],
|
||||
"unit": "" if (result.get("reading") or {}).get("unit") else "kg",
|
||||
})
|
||||
|
||||
|
||||
def line_settings(profile: dict) -> LineSettings:
|
||||
return LineSettings.from_dicts(profile["serial"], profile.get("exchange") or {},
|
||||
port=profile["port"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Turning the bytes a worker holds into one answer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Timed:
|
||||
t_ms: int
|
||||
reading: Reading
|
||||
|
||||
|
||||
def timed_readings(protocol_id: str, params: Optional[dict],
|
||||
chunks: list[tuple[int, bytes]]) -> list[Timed]:
|
||||
proto = get_protocol(protocol_id, params)
|
||||
out: list[Timed] = []
|
||||
if proto.kind == "print":
|
||||
# A label can arrive in more than one chunk: read them joined, and
|
||||
# date each reading by the chunk that completed it.
|
||||
buf = b""
|
||||
seen = 0
|
||||
for t, data in chunks:
|
||||
buf += data
|
||||
found = proto.readings(buf)
|
||||
out.extend(Timed(t, r) for r in found[seen:])
|
||||
seen = len(found)
|
||||
return out
|
||||
# Some formats only close a frame with the start of the next one (XK3190's
|
||||
# "=…=…" stream), and a device that pauses between frames puts each frame in
|
||||
# its own chunk. So read every chunk together with the end of the previous
|
||||
# one, and keep only the readings that this chunk added.
|
||||
tail = b""
|
||||
for t, data in chunks:
|
||||
before = len(proto.readings(tail)) if tail else 0
|
||||
out.extend(Timed(t, r) for r in proto.readings(tail + data)[before:])
|
||||
tail = (tail + data)[-TAIL_BYTES:]
|
||||
return out
|
||||
|
||||
|
||||
def evaluate(profile: dict, worker: PortWorker, max_age_ms: int, now: int) -> dict[str, Any]:
|
||||
"""The newest reading, with its stability and unit settled."""
|
||||
readings = timed_readings(profile["protocol"], profile.get("params"), worker.recent(RECENT_CHUNKS))
|
||||
printing = is_on_print(profile)
|
||||
base = {"ok": False, "profile": profile.get("name"), "port": profile["port"],
|
||||
"protocol": profile["protocol"], "open": worker.is_open, "on_print": printing}
|
||||
if not readings:
|
||||
if not worker.is_open:
|
||||
return {**base, "error": worker.last_error or "port is not open"}
|
||||
if printing:
|
||||
return {**base, "error": "waiting for the Print key on the scale"}
|
||||
return {**base, "error": "no data from the scale yet" if not worker.recent(1) else
|
||||
"data arrives but no weight could be read from it — check the protocol"}
|
||||
latest = readings[-1]
|
||||
age = now - latest.t_ms
|
||||
reading = latest.reading
|
||||
stale = age > max_age_ms
|
||||
if reading.value is None:
|
||||
return {**base, "ok": True, "value": None, "unit": reading.unit, "stable": False,
|
||||
"overload": reading.overload, "age_ms": age, "stale": stale,
|
||||
"raw_hex": reading.raw.hex(" ").upper()}
|
||||
|
||||
stable = reading.stable
|
||||
if stable is None:
|
||||
# No stability flag in this protocol: the same value several times in
|
||||
# a row, all of them recent, counts as stable.
|
||||
need = int(profile.get("stable_samples") or 3)
|
||||
poll_ms = int((profile.get("exchange") or {}).get("poll_interval_ms") or 500)
|
||||
window = max_age_ms + need * max(poll_ms, 250) * 2
|
||||
tail = [x.reading.value for x in readings[-need:] if now - x.t_ms <= window]
|
||||
stable = len(tail) >= need and all(v == tail[-1] for v in tail)
|
||||
unit = reading.unit or profile.get("unit") or ""
|
||||
value = reading.value
|
||||
output_unit = profile.get("output_unit") or ""
|
||||
if output_unit and unit and output_unit != unit:
|
||||
converted = convert(value, unit, output_unit)
|
||||
if converted is not None:
|
||||
value, unit = round(converted, 6), output_unit
|
||||
measured = datetime.now().astimezone().timestamp() - age / 1000
|
||||
return {
|
||||
**base,
|
||||
"ok": True,
|
||||
"value": value,
|
||||
"unit": unit,
|
||||
"stable": bool(stable) and not stale,
|
||||
"net": reading.net,
|
||||
"overload": reading.overload,
|
||||
"age_ms": age,
|
||||
"stale": stale,
|
||||
"measured_at": datetime.fromtimestamp(measured).astimezone().isoformat(timespec="milliseconds"),
|
||||
"raw_hex": reading.raw.hex(" ").upper(),
|
||||
"checksum_ok": reading.checksum_ok,
|
||||
# e.g. order number, shift and date printed on a label
|
||||
"fields": dict(reading.fields or {}),
|
||||
}
|
||||
|
||||
|
||||
def ad_hoc(port: str, protocol: str, serial_cfg: Optional[dict] = None,
|
||||
exchange_cfg: Optional[dict] = None, params: Optional[dict] = None) -> dict:
|
||||
"""A throw-away profile for `/weight?port=...` without a saved profile."""
|
||||
proto = PROTOCOLS.get(protocol)
|
||||
if proto is None:
|
||||
raise ValueError(f"unknown protocol: {protocol!r}")
|
||||
exchange = dict(exchange_cfg or {})
|
||||
if "request_hex" not in exchange:
|
||||
exchange["request_hex"] = proto.request.hex(" ")
|
||||
exchange["after_ack_hex"] = proto.after_ack.hex(" ")
|
||||
exchange.setdefault("poll_interval_ms", proto.default_poll_ms)
|
||||
return validate({"name": "adhoc", "port": port, "protocol": protocol, "params": params or {},
|
||||
"serial": serial_cfg or {}, "exchange": exchange})
|
||||
|
||||
|
||||
def is_on_print(profile: dict) -> bool:
|
||||
proto = PROTOCOLS.get(profile.get("protocol") or "")
|
||||
return bool(proto and proto.kind == "print")
|
||||
|
||||
|
||||
def wait_for_reading(profile: dict, worker: PortWorker, max_age_ms: int, wait_ms: int,
|
||||
now_fn) -> dict[str, Any]:
|
||||
"""Wait (up to wait_ms) until the newest reading is fresh, then evaluate."""
|
||||
if is_on_print(profile):
|
||||
# Nothing comes until somebody presses Print: do not hold the caller.
|
||||
wait_ms = min(wait_ms, 400)
|
||||
deadline = time.monotonic() + wait_ms / 1000
|
||||
while True:
|
||||
worker.wait_fresh(max_age_ms, int(max(0.0, deadline - time.monotonic()) * 1000))
|
||||
result = evaluate(profile, worker, max_age_ms, now_fn())
|
||||
if result.get("ok") and not result.get("stale"):
|
||||
return result
|
||||
if time.monotonic() >= deadline or (not worker.is_open and worker.last_error):
|
||||
return result
|
||||
# Data arrived but held no reading (a partial frame, a boot message …).
|
||||
time.sleep(0.05)
|
||||
@@ -0,0 +1,619 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
The protocol library: how scale indicators around the world put a weight on
|
||||
a serial line, and how to ask for it.
|
||||
|
||||
Each protocol turns raw bytes into `Reading`s. `kind` says how the device
|
||||
talks:
|
||||
|
||||
continuous it sends by itself, over and over — only listen
|
||||
poll it answers a request; `request` is that request
|
||||
handshake request -> ACK -> `after_ack` -> answer
|
||||
|
||||
Only READ-ONLY requests are used. Letters that zero, tare or switch off a
|
||||
display on some brand (Z, T, R, and P — display on/off on A&D) are never
|
||||
sent by auto-detection.
|
||||
|
||||
`field_tested` marks what has been verified on real hardware; the rest
|
||||
follows the manufacturers' published formats and is covered by tests with
|
||||
the sample frames those documents give.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
UNIT_ALIASES = {
|
||||
"kg": "kg", "kgs": "kg", "g": "g", "gr": "g", "t": "t", "ton": "t",
|
||||
"lb": "lb", "lbs": "lb", "#": "lb", "oz": "oz", "ozt": "ozt", "ct": "ct",
|
||||
}
|
||||
GRAMS_PER = {"g": 1.0, "kg": 1000.0, "t": 1_000_000.0, "lb": 453.59237, "oz": 28.349523125,
|
||||
"ozt": 31.1034768, "ct": 0.2}
|
||||
|
||||
|
||||
def norm_unit(text: Any) -> str:
|
||||
if text is None:
|
||||
return ""
|
||||
raw = text.decode("latin-1") if isinstance(text, (bytes, bytearray)) else str(text)
|
||||
raw = raw.strip()
|
||||
return UNIT_ALIASES.get(raw.lower(), raw)
|
||||
|
||||
|
||||
def convert(value: float, from_unit: str, to_unit: str) -> Optional[float]:
|
||||
"""Convert between mass units; None when either unit is not a mass unit."""
|
||||
a, b = GRAMS_PER.get(from_unit), GRAMS_PER.get(to_unit)
|
||||
if a is None or b is None:
|
||||
return None
|
||||
return value * a / b
|
||||
|
||||
|
||||
def _num(text: bytes) -> float:
|
||||
return float(text.replace(b" ", b"").replace(b",", b"."))
|
||||
|
||||
|
||||
def lines(data: bytes) -> list[bytes]:
|
||||
"""Complete lines: the text between terminators, empty ones dropped."""
|
||||
parts = re.split(rb"[\r\n\x03]+", data)
|
||||
return [p.strip(b"\x02\x00 ") for p in parts if p.strip(b"\x02\x00 ")]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reading:
|
||||
value: Optional[float] # None: the frame carries no number (overload, error)
|
||||
unit: str = ""
|
||||
stable: Optional[bool] = None # None: the protocol does not say
|
||||
net: Optional[bool] = None
|
||||
overload: bool = False
|
||||
raw: bytes = b""
|
||||
checksum_ok: Optional[bool] = None
|
||||
# Other values the frame carries (e.g. the order number on a label).
|
||||
fields: dict = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out = {"value": self.value, "unit": self.unit, "stable": self.stable, "net": self.net,
|
||||
"overload": self.overload, "raw_hex": self.raw.hex(" ").upper(),
|
||||
"checksum_ok": self.checksum_ok}
|
||||
if self.fields:
|
||||
out["fields"] = dict(self.fields)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Protocol:
|
||||
id: str
|
||||
name: str
|
||||
kind: str = "continuous"
|
||||
request: bytes = b""
|
||||
after_ack: bytes = b""
|
||||
families: str = ""
|
||||
reference: str = ""
|
||||
field_tested: bool = False
|
||||
specificity: int = 2 # 1 generic … 3 strictly framed (wins ties in detection)
|
||||
default_poll_ms: int = 500
|
||||
# Readings auto-detection needs to see. A device that only sends when its
|
||||
# Print key is pressed may send just one.
|
||||
min_frames: int = 2
|
||||
params: dict = field(default_factory=dict)
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def info(self) -> dict[str, Any]:
|
||||
return {"id": self.id, "name": self.name, "kind": self.kind,
|
||||
"on_print": self.kind == "print",
|
||||
"request_hex": self.request.hex(" ").upper(),
|
||||
"after_ack_hex": self.after_ack.hex(" ").upper(),
|
||||
"families": self.families, "reference": self.reference,
|
||||
"field_tested": self.field_tested}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mettler Toledo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MtSics(Protocol):
|
||||
"""MT-SICS: 'SI' -> 'S S 0.550 kg' (S = stable, D = dynamic)."""
|
||||
_re = re.compile(rb"^S\s+([SD+\-])\s*([-+]?\s*\d+(?:[.,]\d+)?)?\s*([A-Za-z%#]{0,4})")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for line in lines(data):
|
||||
m = self._re.match(line)
|
||||
if not m:
|
||||
continue
|
||||
flag = m.group(1)
|
||||
if flag in (b"+", b"-"):
|
||||
out.append(Reading(None, overload=True, raw=line))
|
||||
elif m.group(2):
|
||||
out.append(Reading(_num(m.group(2)), norm_unit(m.group(3)),
|
||||
stable=flag == b"S", raw=line))
|
||||
return out
|
||||
|
||||
|
||||
class ToledoContinuous(Protocol):
|
||||
"""
|
||||
Toledo Continuous (Mettler Toledo standard continuous output, copied by
|
||||
many other indicators):
|
||||
|
||||
STX SWA SWB SWC W W W W W W T T T T T T CR [CHK]
|
||||
|
||||
SWA bits 0-2 place the decimal point (X00, X0, X, 0.X ... 0.0000X);
|
||||
SWB bit0 net, bit1 negative, bit2 over/under range, bit3 motion,
|
||||
bit4 kg (else lb); SWC bits 0-2 override the unit (1 g, 2 t, 3 oz).
|
||||
Bit 5 of each status byte is always set. CHK is the two's complement of
|
||||
the 7-bit sum of STX..CR.
|
||||
"""
|
||||
_re = re.compile(rb"\x02([\x20-\x7f])([\x20-\x7f])([\x20-\x7f])([0-9 ]{6})([0-9 ]{6})\r(.?)",
|
||||
re.S)
|
||||
_factor = {0: 100, 1: 10, 2: 1, 3: 0.1, 4: 0.01, 5: 0.001, 6: 0.0001, 7: 0.00001}
|
||||
_decimals = {0: 0, 1: 0, 2: 0, 3: 1, 4: 2, 5: 3, 6: 4, 7: 5}
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for m in self._re.finditer(data):
|
||||
swa, swb, swc = m.group(1)[0], m.group(2)[0], m.group(3)[0]
|
||||
if not (swa & 0x20 and swb & 0x20 and swc & 0x20):
|
||||
continue
|
||||
dp = swa & 0x07
|
||||
value = round(int(m.group(4).replace(b" ", b"0")) * self._factor[dp], self._decimals[dp])
|
||||
if swb & 0x02:
|
||||
value = -value
|
||||
unit = {1: "g", 2: "t", 3: "oz"}.get(swc & 0x07, "kg" if swb & 0x10 else "lb")
|
||||
frame = data[m.start():m.end(5) + 1]
|
||||
checksum_ok = None
|
||||
if m.group(6) and m.group(6) != b"\x02":
|
||||
checksum_ok = ((-sum(b & 0x7F for b in frame)) & 0x7F) == (m.group(6)[0] & 0x7F)
|
||||
out.append(Reading(value, unit, stable=not (swb & 0x08), net=bool(swb & 0x01),
|
||||
overload=bool(swb & 0x04), raw=frame, checksum_ok=checksum_ok))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A&D and the many indicators that copy its format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AndStandard(Protocol):
|
||||
"""'ST,+00000.55 kg', 'US,GS,+0012.34kg', 'OL,+9999999 kg' (ST stable, US unstable, OL overload)."""
|
||||
_re = re.compile(rb"^(ST|US|OL|QT|WT)\s*,\s*(?:(GS|NT|TR|G|N)\s*,\s*)?([-+])?\s*(\d+(?:[.,]\d+)?)?\s*"
|
||||
rb"([A-Za-z%#]{0,4})")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for line in lines(data):
|
||||
m = self._re.match(line)
|
||||
if not m:
|
||||
continue
|
||||
head = m.group(1)
|
||||
net = {b"NT": True, b"N": True, b"GS": False, b"G": False}.get(m.group(2))
|
||||
if head == b"OL" or not m.group(4):
|
||||
out.append(Reading(None, overload=head == b"OL", net=net, raw=line))
|
||||
continue
|
||||
value = _num(m.group(4)) * (-1 if m.group(3) == b"-" else 1)
|
||||
out.append(Reading(value, norm_unit(m.group(5)), stable=head in (b"ST", b"QT"),
|
||||
net=net, raw=line))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMA (Scale Manufacturers Association) and NCI 3835
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Sma(Protocol):
|
||||
"""
|
||||
SMA SCP-0499: '<LF>W<CR>' -> '<LF><s><r><n><m><f><weight:10><unit:3><CR>'.
|
||||
s = Z/O/U/E/I/T/space, r = range, n = G/T/N, m = 'M' in motion.
|
||||
"""
|
||||
_re = re.compile(rb"\n([ ZOUEIT])([ 0-9])([GTNgtn])([M ])([ -~])([ -~]{10})([ -~]{1,3})\r")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for m in self._re.finditer(data):
|
||||
field_text = m.group(6).strip()
|
||||
status = m.group(1)
|
||||
try:
|
||||
value = _num(field_text) if re.fullmatch(rb"[-+]?\d+(?:\.\d+)?", field_text) else None
|
||||
except ValueError:
|
||||
value = None
|
||||
out.append(Reading(value, norm_unit(m.group(7)), stable=m.group(4) != b"M",
|
||||
net=m.group(3) in (b"N", b"n"), overload=status in (b"O", b"U"),
|
||||
raw=m.group(0)))
|
||||
return out
|
||||
|
||||
|
||||
class Nci(Protocol):
|
||||
"""NCI 3835: 'W<CR>' -> '<LF> 1.23lb<CR><LF>hh<CR><ETX>' (hh status, bit0 of h1 = motion)."""
|
||||
_re = re.compile(rb"\n\s*([-+]?\s*\d+(?:\.\d+)?)\s*(lb|kg|g|oz|LB|KG|G|OZ)\s*\r(?:\n(.)(.)\r)?", re.S)
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for m in self._re.finditer(data):
|
||||
stable = None if m.group(3) is None else not (m.group(3)[0] & 0x01)
|
||||
out.append(Reading(_num(m.group(1)), norm_unit(m.group(2)), stable=stable, raw=m.group(0)))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ohaus, Sartorius
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Ohaus(Protocol):
|
||||
"""'IP' -> ' 0.550 kg ' ('?' after the unit when unstable, optional G/N)."""
|
||||
_re = re.compile(rb"^([-+]?\s*\d+(?:[.,]\d+)?)\s*([A-Za-z%#]{1,4})\s*(\?)?\s*([GNT])?\s*$")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for line in lines(data):
|
||||
m = self._re.match(line)
|
||||
if m:
|
||||
out.append(Reading(_num(m.group(1)), norm_unit(m.group(2)), stable=m.group(3) is None,
|
||||
net={b"N": True, b"G": False}.get(m.group(4)), raw=line))
|
||||
return out
|
||||
|
||||
|
||||
class Sartorius(Protocol):
|
||||
"""
|
||||
Sartorius SBI: 'ESC P' -> '+ 0.550 kg ' or 'N + 0.550 kg'.
|
||||
An unstable value is sent WITHOUT its unit — that is the stability flag.
|
||||
"""
|
||||
_re = re.compile(rb"^(?:([A-Za-z#]{1,6})\s+)?([-+])\s*(\d+(?:[.,]\d+)?)\s*([A-Za-z%]{0,4})\s*$")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for line in lines(data):
|
||||
m = self._re.match(line)
|
||||
if not m:
|
||||
continue
|
||||
value = _num(m.group(3)) * (-1 if m.group(2) == b"-" else 1)
|
||||
ident = (m.group(1) or b"").upper()
|
||||
out.append(Reading(value, norm_unit(m.group(4)), stable=bool(m.group(4)),
|
||||
net=True if ident.startswith(b"N") else (False if ident.startswith(b"G") else None),
|
||||
raw=line))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAS (ENQ/ACK/DC1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CasEnq(Protocol):
|
||||
"""ENQ -> ACK, DC1 -> '...S/U ... weight unit...'. Parsed loosely; experimental."""
|
||||
_re = re.compile(rb"([SU])[ ,A-Z]*?([-+ ])?\s*(\d+(?:\.\d+)?)\s*(kg|g|lb|oz|KG|LB|OZ|G)")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for m in self._re.finditer(data):
|
||||
value = _num(m.group(3)) * (-1 if m.group(2) == b"-" else 1)
|
||||
out.append(Reading(value, norm_unit(m.group(4)), stable=m.group(1) == b"S", raw=m.group(0)))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chinese XK3190 family (Yaohua and compatibles)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Xk3190Continuous(Protocol):
|
||||
"""
|
||||
XK3190 continuous mode (tF=0), 12 bytes:
|
||||
STX, sign, 6 digits (high first), decimals (0-4), XOR high nibble, XOR low nibble, ETX.
|
||||
XOR covers the sign through the decimals byte. No unit is sent.
|
||||
"""
|
||||
_re = re.compile(rb"\x02([+-])(\d{6})([0-4])(..)\x03", re.S)
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for m in self._re.finditer(data):
|
||||
body = m.group(1) + m.group(2) + m.group(3)
|
||||
x = 0
|
||||
for b in body:
|
||||
x ^= b
|
||||
got = m.group(4)
|
||||
hex_form = f"{x >> 4:X}{x & 0xF:X}".encode()
|
||||
nibble_form = bytes([0x30 + (x >> 4), 0x30 + (x & 0xF)])
|
||||
decimals = int(m.group(3))
|
||||
value = round(int(m.group(2)) / (10 ** decimals), decimals) * (-1 if m.group(1) == b"-" else 1)
|
||||
out.append(Reading(value, "", raw=m.group(0),
|
||||
checksum_ok=got.upper() in (hex_form, nibble_form)))
|
||||
return out
|
||||
|
||||
|
||||
class Xk3190Reversed(Protocol):
|
||||
"""
|
||||
XK3190 'D2+' continuous modes (tF=2/3): the displayed weight with its
|
||||
characters REVERSED, frames separated by '='. 70.15 kg -> '51.07000='.
|
||||
"""
|
||||
_token = re.compile(rb"^[-0-9.]{5,10}$")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
if data.count(b"=") < 2:
|
||||
return []
|
||||
out = []
|
||||
for token in data.split(b"=")[1:-1]:
|
||||
token = token.strip()
|
||||
if not self._token.match(token) or token.count(b".") > 1:
|
||||
continue
|
||||
try:
|
||||
value = float(token[::-1])
|
||||
except ValueError:
|
||||
continue
|
||||
out.append(Reading(value, "", raw=b"=" + token))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mahak (Iran) — field-tested
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MahakMds(Protocol):
|
||||
"""
|
||||
Mahak MDS series (e.g. MDS 13000, older firmware): request 4D 09 04 07,
|
||||
reply 58 ?? 07 07 W2 W1 W0 — grams, 24-bit big-endian. The scale's menu
|
||||
must be set to Serial mode. Byte 1 varies and is ignored.
|
||||
"""
|
||||
_re = re.compile(rb"\x58.\x07\x07(...)", re.S)
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
return [Reading(float(int.from_bytes(m.group(1), "big")), "g", raw=m.group(0))
|
||||
for m in self._re.finditer(data)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label printer streams — indicators wired to a label printer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KV = re.compile(r"([A-Za-z][A-Za-z0-9_.]*)\s*:\s*(.*?)(?=\s+[A-Za-z][A-Za-z0-9_.]*\s*:|$)")
|
||||
_DATE = re.compile(r"\b(\d{2,4}[/.-]\d{1,2}[/.-]\d{1,4})\b(?:\s*-?\s*(\d{1,2}:\d{2}(?::\d{2})?))?")
|
||||
_NUMBER = re.compile(r"^[-+]?\s*\d+(?:[.,]\d+)?$")
|
||||
_NUMBER_UNIT = re.compile(r"^([-+]?\s*\d+(?:[.,]\d+)?)\s*\(?\s*(kg|g|t|lb|lbs|oz)\s*\)?$", re.I)
|
||||
_WEIGHT_WORD = re.compile(r"(weight|wt|net|gross|n\.?w|g\.?w|\u0648\u0632\u0646)\s*[:=]?\s*([-+]?\d+(?:[.,]\d+)?)\s*\(?\s*(kg|g|t|lb|oz)?",
|
||||
re.I)
|
||||
|
||||
|
||||
def _unit_only(text: str) -> str:
|
||||
t = text.strip().strip("()[]").strip()
|
||||
return norm_unit(t) if norm_unit(t) in GRAMS_PER else ""
|
||||
|
||||
|
||||
class LabelPrinter(Protocol):
|
||||
"""
|
||||
An indicator that prints labels: on its Print key it sends the whole label
|
||||
program to a label printer — TSPL/TSPL2 (TSC and compatibles), EPL2 (Zebra/
|
||||
Eltron, Argox) or ZPL (Zebra). Listening to that line (a Y cable leaves the
|
||||
printer connected) gives the weight the operator printed, plus whatever
|
||||
else the label carries: order number, shift, date, thickness …
|
||||
|
||||
One reading per printed label (TSPL `PRINT`, EPL `P`, ZPL `^XZ`). The weight
|
||||
is the number printed next to a weight unit — "141.800" followed by "(Kg)",
|
||||
or "1.25 kg" in one text — or after a word such as Weight/Net/Gross/وزن.
|
||||
A printed weight counts as stable: indicators print settled weights.
|
||||
"""
|
||||
_tspl_text = re.compile(rb'^(?:TEXT|BLOCK)\s[^"]*"[^"]*"[^"]*?"(.*?)"?\s*$')
|
||||
_tspl_barcode = re.compile(rb'^BARCODE\s[^"]*"[^"]*"[^"]*?"(.*?)"?\s*$')
|
||||
_epl_text = re.compile(rb'^A\d+,\d+,[^"]*"(.*?)"?\s*$')
|
||||
_epl_barcode = re.compile(rb'^B\d+,\d+,[^"]*"(.*?)"?\s*$')
|
||||
_zpl_field = re.compile(rb"\^FD(.*?)\^FS", re.S)
|
||||
_end = re.compile(rb"(?:^|[\r\n])(?:PRINT\s+\d+(?:\s*,\s*\d+)?|P\d+(?:,\d+)?)\s*(?=[\r\n]|$)|\^XZ")
|
||||
|
||||
def _texts(self, label: bytes) -> tuple[list[str], list[str]]:
|
||||
texts: list[str] = []
|
||||
codes: list[str] = []
|
||||
if b"^FD" in label:
|
||||
for m in self._zpl_field.finditer(label):
|
||||
texts.append(m.group(1).decode("latin-1"))
|
||||
return texts, codes
|
||||
for line in re.split(rb"[\r\n]+", label):
|
||||
line = line.strip()
|
||||
for rx, bucket in ((self._tspl_text, texts), (self._tspl_barcode, codes),
|
||||
(self._epl_text, texts), (self._epl_barcode, codes)):
|
||||
m = rx.match(line)
|
||||
if m:
|
||||
bucket.append(m.group(1).decode("latin-1"))
|
||||
break
|
||||
return texts, codes
|
||||
|
||||
def _weight(self, texts: list[str]) -> Optional[tuple[float, str]]:
|
||||
clean = [t.strip() for t in texts]
|
||||
for i, t in enumerate(clean):
|
||||
m = _NUMBER_UNIT.match(t)
|
||||
if m:
|
||||
return _num(m.group(1).encode()), norm_unit(m.group(2))
|
||||
if _NUMBER.match(t):
|
||||
for j in (i + 1, i - 1):
|
||||
if 0 <= j < len(clean) and _unit_only(clean[j]):
|
||||
return _num(t.encode()), _unit_only(clean[j])
|
||||
for t in clean:
|
||||
m = _WEIGHT_WORD.search(t)
|
||||
if m:
|
||||
return _num(m.group(2).encode()), norm_unit(m.group(3) or "")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fields(texts: list[str], codes: list[str]) -> dict:
|
||||
fields: dict[str, str] = {}
|
||||
for t in texts:
|
||||
d = _DATE.search(t)
|
||||
if d and "date" not in fields:
|
||||
fields["date"] = d.group(1)
|
||||
if d.group(2):
|
||||
fields["time"] = d.group(2)
|
||||
continue
|
||||
for key, value in _KV.findall(t):
|
||||
value = value.strip().strip('"').strip()
|
||||
if value:
|
||||
fields.setdefault(key.strip(), value)
|
||||
if codes:
|
||||
fields["barcode"] = codes[0].replace("\t", " | ").strip()
|
||||
return fields
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
start = 0
|
||||
for end in self._end.finditer(data):
|
||||
label = data[start:end.end()]
|
||||
start = end.end()
|
||||
texts, codes = self._texts(label)
|
||||
found = self._weight(texts)
|
||||
if found is None:
|
||||
continue
|
||||
value, unit = found
|
||||
out.append(Reading(value, unit, stable=True, raw=label.strip(b"\r\n"),
|
||||
fields=self._fields(texts, codes)))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic and custom
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GenericAscii(Protocol):
|
||||
"""Any readable line with a number in it — the last resort for continuous output."""
|
||||
_re = re.compile(rb"([-+]?\s*\d+(?:[.,]\d+)?)\s*(kg|g|lb|lbs|oz|t|KG|G|LB|OZ|T)?\b")
|
||||
_stable_words = re.compile(rb"\b(ST|S)\b")
|
||||
_unstable_words = re.compile(rb"\b(US|D|M|MOTION)\b|\?")
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
out = []
|
||||
for line in lines(data):
|
||||
if len(line) > 80 or sum(32 <= b < 127 for b in line) < len(line) * 0.9:
|
||||
continue
|
||||
numbers = list(self._re.finditer(line))
|
||||
if not numbers:
|
||||
continue
|
||||
m = numbers[-1]
|
||||
stable = None
|
||||
if self._unstable_words.search(line):
|
||||
stable = False
|
||||
elif self._stable_words.search(line):
|
||||
stable = True
|
||||
out.append(Reading(_num(m.group(1)), norm_unit(m.group(2)), stable=stable, raw=line))
|
||||
return out
|
||||
|
||||
|
||||
class CustomBinary(Protocol):
|
||||
"""
|
||||
A binary frame described by hand: `pattern` (hex, '??' = any byte), then
|
||||
`value_length` bytes at `value_offset` from the pattern's first byte.
|
||||
"""
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
p = self.params
|
||||
pattern = [None if t == "??" else int(t, 16) for t in str(p.get("pattern") or "").split()]
|
||||
offset = int(p.get("value_offset") or 0)
|
||||
length = int(p.get("value_length") or 1)
|
||||
order = "little" if p.get("byte_order") == "little" else "big"
|
||||
signed = bool(p.get("signed"))
|
||||
unit = norm_unit(p.get("unit") or "")
|
||||
out = []
|
||||
n = len(pattern)
|
||||
starts = [0] if not n else [
|
||||
i for i in range(0, len(data) - n + 1)
|
||||
if all(b is None or data[i + k] == b for k, b in enumerate(pattern))]
|
||||
for start in starts:
|
||||
end = start + offset + length
|
||||
if end > len(data):
|
||||
continue
|
||||
value = int.from_bytes(data[start + offset:end], order, signed=signed)
|
||||
out.append(Reading(float(value), unit, raw=data[start:max(end, start + n)]))
|
||||
return out
|
||||
|
||||
|
||||
class CustomAscii(Protocol):
|
||||
"""Any text format described by hand: the first group of `regex` is the number."""
|
||||
|
||||
def readings(self, data: bytes) -> list[Reading]:
|
||||
regex = str(self.params.get("regex") or "")
|
||||
if not regex:
|
||||
return []
|
||||
compiled = re.compile(regex.encode("latin-1"))
|
||||
unit = norm_unit(self.params.get("unit") or "")
|
||||
out = []
|
||||
for line in lines(data):
|
||||
m = compiled.search(line)
|
||||
if m and m.groups() and m.group(1):
|
||||
try:
|
||||
out.append(Reading(_num(m.group(1)), unit, raw=line))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _catalogue() -> dict[str, Protocol]:
|
||||
items: Iterable[Protocol] = [
|
||||
MtSics("mt_sics", "Mettler Toledo MT-SICS", kind="poll", request=b"SI\r\n", specificity=3,
|
||||
families="Mettler Toledo terminals and balances (IND, ICS, XS, ML…)",
|
||||
reference="MT-SICS reference manual"),
|
||||
ToledoContinuous("toledo_cont", "Toledo Continuous (STX + status words)", specificity=3,
|
||||
families="Mettler Toledo IND/Panther/Jaguar/Lynx and many compatible indicators",
|
||||
reference="Mettler Toledo 'Standard continuous output' (e.g. IND425 manual 4.2)"),
|
||||
AndStandard("and_std", "A&D standard format (ST/US/OL)", kind="poll", request=b"Q\r\n",
|
||||
specificity=3,
|
||||
families="A&D indicators/balances and many Asian indicators (continuous or on request)",
|
||||
reference="A&D indicator manuals, data format section"),
|
||||
Sma("sma", "SMA standard (Scale Manufacturers Association)", kind="poll", request=b"\nW\r",
|
||||
specificity=3, families="Rice Lake, Avery Weigh-Tronix, Cardinal, Fairbanks and other SMA-compliant indicators",
|
||||
reference="SMA SCP-0499"),
|
||||
Nci("nci", "NCI 3835 / Weigh-Tronix", kind="poll", request=b"W\r", specificity=2,
|
||||
families="NCI / Avery Weigh-Tronix shipping and bench scales",
|
||||
reference="NCI 3835 protocol"),
|
||||
Ohaus("ohaus", "Ohaus", kind="poll", request=b"IP\r\n", specificity=2,
|
||||
families="Ohaus Defender, Ranger, Valor and compatible indicators",
|
||||
reference="Ohaus interface command table (IP = immediate print)"),
|
||||
Sartorius("sartorius", "Sartorius SBI", kind="poll", request=b"\x1bP\r\n", specificity=2,
|
||||
families="Sartorius balances and Combics/Signum indicators",
|
||||
reference="Sartorius SBI data output format"),
|
||||
CasEnq("cas_enq", "CAS (ENQ/ACK/DC1), experimental", kind="handshake", request=b"\x05",
|
||||
after_ack=b"\x11", specificity=2, families="CAS scales and compatibles",
|
||||
reference="CAS ENQ/ACK polling"),
|
||||
Xk3190Continuous("xk3190_tf0", "XK3190 continuous (tF=0)", specificity=3,
|
||||
families="Yaohua XK3190-A9/A12 and compatible Chinese indicators",
|
||||
reference="XK3190-A9+ user manual, serial output"),
|
||||
Xk3190Reversed("xk3190_reversed", "XK3190 '=' reversed-digit stream (tF=2/3)", specificity=2,
|
||||
field_tested=True,
|
||||
families="Yaohua XK3190 series (A9, A12, T7E / YH-T7E …) and compatible indicators/big displays",
|
||||
reference="XK3190-A9+ user manual (D2+ continuous mode); YH-T7E user manual V1.01, 3.3"),
|
||||
MahakMds("mahak_mds", "Mahak MDS binary (4D 09 04 07)", kind="poll",
|
||||
request=bytes.fromhex("4D090407"), specificity=3, field_tested=True,
|
||||
families="Mahak MDS 13000 and related Mahak scales (Iran), Serial mode",
|
||||
reference="Mahak developer guide + field test"),
|
||||
LabelPrinter("label_print", "Label printer stream (TSPL / EPL / ZPL)", kind="print",
|
||||
specificity=3, min_frames=1, field_tested=True,
|
||||
families="Indicators wired to a label printer (TSC, Zebra, Argox…); sends on Print. "
|
||||
"Tested on a PTC400 indicator (TSPL)",
|
||||
reference="TSC TSPL/TSPL2, Zebra EPL2 and ZPL programming manuals + field test"),
|
||||
GenericAscii("ascii_generic", "Generic text (number + unit)", specificity=1,
|
||||
families="Any indicator that streams readable weight text",
|
||||
reference="heuristic"),
|
||||
CustomBinary("custom_binary", "Custom binary frame", kind="poll", specificity=0,
|
||||
reference="configured by hand"),
|
||||
CustomAscii("custom_ascii", "Custom text (regular expression)", kind="poll", specificity=0,
|
||||
reference="configured by hand"),
|
||||
]
|
||||
return {p.id: p for p in items}
|
||||
|
||||
|
||||
PROTOCOLS: dict[str, Protocol] = _catalogue()
|
||||
|
||||
# Detection order. Continuous formats are tried on whatever a port sends by
|
||||
# itself; poll protocols are asked in turn when it sends nothing.
|
||||
CONTINUOUS_IDS = ["label_print", "toledo_cont", "xk3190_tf0", "and_std", "mt_sics", "sma",
|
||||
"xk3190_reversed", "ohaus", "sartorius", "ascii_generic"]
|
||||
POLL_IDS = ["mt_sics", "and_std", "sma", "nci", "ohaus", "sartorius", "mahak_mds", "cas_enq"]
|
||||
|
||||
|
||||
def get_protocol(protocol_id: str, params: Optional[dict] = None) -> Protocol:
|
||||
base = PROTOCOLS.get(protocol_id)
|
||||
if base is None:
|
||||
raise KeyError(f"unknown protocol: {protocol_id}")
|
||||
if params:
|
||||
clone = type(base)(**{**base.__dict__, "params": dict(params)})
|
||||
return clone
|
||||
return base
|
||||
|
||||
|
||||
def catalogue_info() -> list[dict[str, Any]]:
|
||||
return [p.info() for p in PROTOCOLS.values()]
|
||||
@@ -0,0 +1,431 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Serial ports: listing them, describing a line, and keeping one open.
|
||||
|
||||
A `PortWorker` owns one COM port. While somebody keeps asking for data it
|
||||
holds the port open, sends the request (if the device must be asked) at
|
||||
the poll interval, cuts what comes back into chunks at silences, and keeps
|
||||
the recent chunks. Unplugging the USB cable, a driver hiccup or a busy port
|
||||
are all survived: the worker closes, waits, and opens again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
try:
|
||||
import serial
|
||||
from serial.tools import list_ports as _list_ports
|
||||
except ImportError: # pragma: no cover
|
||||
serial = None
|
||||
_list_ports = None
|
||||
|
||||
log = logging.getLogger("scale_bridge")
|
||||
|
||||
RING_SIZE = 64 # chunks kept per port
|
||||
MAX_CHUNK = 512 # a continuous stream without silences is cut here
|
||||
IDLE_CLOSE_S = 120 # a port nobody asked about for this long is released
|
||||
MAX_REQUEST_BYTES = 64
|
||||
|
||||
_PORT_RE = re.compile(r"^(COM\d{1,3}|/dev/[A-Za-z0-9_./-]+)$", re.IGNORECASE)
|
||||
|
||||
# USB-serial chips worth a word to the person choosing a port.
|
||||
KNOWN_CHIPS: dict[int, tuple[str, str]] = {
|
||||
0x1A86: ("WCH CH340/CH341",
|
||||
"Clone CH340 chips fail with WCH drivers 3.8 and newer on Windows "
|
||||
"('A device attached to the system is not functioning'). Install driver "
|
||||
"3.5.2019.1 or use an FTDI-based adapter."),
|
||||
0x067B: ("Prolific PL2303",
|
||||
"Counterfeit PL2303 chips are refused by newer Prolific drivers (Code 10). "
|
||||
"Use an older driver or an FTDI-based adapter."),
|
||||
0x0403: ("FTDI", ""),
|
||||
0x10C4: ("Silicon Labs CP210x", ""),
|
||||
0x0557: ("ATEN", ""),
|
||||
0x110A: ("Moxa", ""),
|
||||
}
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.monotonic() * 1000)
|
||||
|
||||
|
||||
def parse_hex(text: str) -> bytes:
|
||||
"""'4d0904 07', '0x4D 0x09', '4D-09' -> bytes. Raises ValueError."""
|
||||
cleaned = re.sub(r"0x", "", str(text or ""), flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"[\s,;:-]+", "", cleaned)
|
||||
return bytes.fromhex(cleaned)
|
||||
|
||||
|
||||
def format_hex(data: bytes) -> str:
|
||||
return data.hex(" ").upper()
|
||||
|
||||
|
||||
def printable(data: bytes) -> str:
|
||||
names = {0x02: "<STX>", 0x03: "<ETX>", 0x0D: "<CR>", 0x0A: "<LF>", 0x05: "<ENQ>",
|
||||
0x06: "<ACK>", 0x15: "<NAK>", 0x1B: "<ESC>", 0x11: "<DC1>"}
|
||||
out = []
|
||||
for b in data:
|
||||
if b in names:
|
||||
out.append(names[b])
|
||||
elif 32 <= b < 127:
|
||||
out.append(chr(b))
|
||||
else:
|
||||
out.append(f"<{b:02X}>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One line's settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LineSettings:
|
||||
port: str
|
||||
baudrate: int = 9600
|
||||
bytesize: int = 8
|
||||
parity: str = "N"
|
||||
stopbits: float = 1
|
||||
dtr: bool = True
|
||||
rts: bool = True
|
||||
request: bytes = b"" # sent every poll interval; empty = only listen
|
||||
after_ack: bytes = b"" # sent when the device answers the request with ACK
|
||||
poll_s: float = 0.5
|
||||
gap_s: float = 0.05 # silence that ends a chunk
|
||||
|
||||
@property
|
||||
def framing(self) -> str:
|
||||
stop = int(self.stopbits) if self.stopbits in (1, 2) else self.stopbits
|
||||
return f"{self.bytesize}{self.parity}{stop}"
|
||||
|
||||
def describe(self) -> str:
|
||||
req = f", request {format_hex(self.request)}" if self.request else ", listen only"
|
||||
return f"{self.port} {self.baudrate} {self.framing}{req}"
|
||||
|
||||
def with_line(self, baudrate: int, framing: str) -> "LineSettings":
|
||||
bits, parity, stop = int(framing[0]), framing[1], float(framing[2:])
|
||||
return LineSettings(self.port, baudrate, bits, parity, stop, self.dtr, self.rts,
|
||||
self.request, self.after_ack, self.poll_s, self.gap_s)
|
||||
|
||||
def serial_dict(self) -> dict[str, Any]:
|
||||
stop = int(self.stopbits) if self.stopbits in (1, 2) else self.stopbits
|
||||
return {"port": self.port, "baudrate": self.baudrate, "bytesize": self.bytesize,
|
||||
"parity": self.parity, "stopbits": stop, "dtr": self.dtr, "rts": self.rts}
|
||||
|
||||
def exchange_dict(self) -> dict[str, Any]:
|
||||
return {"request_hex": format_hex(self.request),
|
||||
"after_ack_hex": format_hex(self.after_ack),
|
||||
"poll_interval_ms": int(round(self.poll_s * 1000)),
|
||||
"frame_gap_ms": int(round(self.gap_s * 1000))}
|
||||
|
||||
@classmethod
|
||||
def from_dicts(cls, serial_cfg: dict, exchange_cfg: Optional[dict] = None,
|
||||
port: Optional[str] = None) -> "LineSettings":
|
||||
"""Validate settings that arrive over the network or from a file."""
|
||||
exchange_cfg = exchange_cfg or {}
|
||||
name = str(port or serial_cfg.get("port") or "").strip()
|
||||
if not _PORT_RE.match(name):
|
||||
raise ValueError(f"invalid port name: {name!r}")
|
||||
if name.upper().startswith("COM"):
|
||||
name = name.upper()
|
||||
baudrate = int(serial_cfg.get("baudrate") or 9600)
|
||||
if not 50 <= baudrate <= 921600:
|
||||
raise ValueError(f"invalid baudrate: {baudrate}")
|
||||
bytesize = int(serial_cfg.get("bytesize") or 8)
|
||||
if bytesize not in (5, 6, 7, 8):
|
||||
raise ValueError(f"invalid bytesize: {bytesize}")
|
||||
parity = str(serial_cfg.get("parity") or "N").upper()[:1]
|
||||
if parity not in "NEOMS":
|
||||
raise ValueError(f"invalid parity: {parity}")
|
||||
stopbits = float(serial_cfg.get("stopbits") or 1)
|
||||
if stopbits not in (1, 1.5, 2):
|
||||
raise ValueError(f"invalid stopbits: {stopbits}")
|
||||
try:
|
||||
request = parse_hex(exchange_cfg.get("request_hex") or "")
|
||||
after_ack = parse_hex(exchange_cfg.get("after_ack_hex") or "")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"invalid request hex: {e}") from e
|
||||
if len(request) > MAX_REQUEST_BYTES or len(after_ack) > MAX_REQUEST_BYTES:
|
||||
raise ValueError("request too long")
|
||||
poll_ms = min(max(int(exchange_cfg.get("poll_interval_ms") or 500), 100), 10000)
|
||||
gap_ms = min(max(int(exchange_cfg.get("frame_gap_ms") or 50), 5), 1000)
|
||||
return cls(port=name, baudrate=baudrate, bytesize=bytesize, parity=parity,
|
||||
stopbits=int(stopbits) if stopbits in (1, 2) else stopbits,
|
||||
dtr=bool(serial_cfg.get("dtr", True)), rts=bool(serial_cfg.get("rts", True)),
|
||||
request=request, after_ack=after_ack,
|
||||
poll_s=poll_ms / 1000, gap_s=gap_ms / 1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opening and listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Opener = Callable[[LineSettings], Any]
|
||||
|
||||
|
||||
def open_real(s: LineSettings) -> Any:
|
||||
if serial is None:
|
||||
raise RuntimeError("pyserial is not installed")
|
||||
ser = serial.Serial(port=s.port, baudrate=s.baudrate, bytesize=s.bytesize,
|
||||
parity=s.parity, stopbits=s.stopbits, timeout=0.02, write_timeout=1.0)
|
||||
ser.dtr = s.dtr
|
||||
ser.rts = s.rts
|
||||
ser.reset_input_buffer()
|
||||
return ser
|
||||
|
||||
|
||||
def _port_sort_key(name: str) -> tuple:
|
||||
m = re.match(r"^COM(\d+)$", name, re.IGNORECASE)
|
||||
return (0, int(m.group(1)), "") if m else (1, 0, name)
|
||||
|
||||
|
||||
def list_serial_ports() -> list[dict[str, Any]]:
|
||||
if _list_ports is None:
|
||||
return []
|
||||
out = []
|
||||
for p in _list_ports.comports():
|
||||
chip, note = KNOWN_CHIPS.get(p.vid, (None, "")) if p.vid is not None else (None, "")
|
||||
text = f"{p.description or ''} {p.hwid or ''}".lower()
|
||||
kind = "bluetooth" if ("bluetooth" in text or "bthenum" in text) else (
|
||||
"usb" if p.vid is not None else "native")
|
||||
out.append({
|
||||
"device": p.device,
|
||||
"description": p.description,
|
||||
"hwid": p.hwid,
|
||||
"vid": f"{p.vid:04X}" if p.vid is not None else None,
|
||||
"pid": f"{p.pid:04X}" if p.pid is not None else None,
|
||||
"manufacturer": p.manufacturer,
|
||||
"chip": chip,
|
||||
"note": note,
|
||||
"kind": kind,
|
||||
})
|
||||
return sorted(out, key=lambda p: _port_sort_key(p["device"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A worker per port
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PortWorker(threading.Thread):
|
||||
def __init__(self, settings: LineSettings, opener: Opener):
|
||||
super().__init__(name=f"port-{settings.port}", daemon=True)
|
||||
self.settings = settings
|
||||
self.opener = opener
|
||||
self.chunks: deque[tuple[int, bytes]] = deque(maxlen=RING_SIZE)
|
||||
self.cond = threading.Condition()
|
||||
self.ser: Any = None
|
||||
self.last_error: Optional[str] = None
|
||||
self.last_used = time.monotonic()
|
||||
self.first_attempt = threading.Event()
|
||||
self._halt = threading.Event()
|
||||
# Called with (t_ms, bytes) for every chunk, outside the lock.
|
||||
self.listeners: list[Callable[[int, bytes], None]] = []
|
||||
self.stats = {"opened_at": None, "bytes_rx": 0, "frames_rx": 0, "requests_tx": 0,
|
||||
"open_failures": 0}
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
def touch(self) -> None:
|
||||
self.last_used = time.monotonic()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._halt.set()
|
||||
|
||||
@property
|
||||
def stopped(self) -> bool:
|
||||
return self._halt.is_set()
|
||||
|
||||
def _open(self) -> bool:
|
||||
try:
|
||||
self.ser = self.opener(self.settings)
|
||||
self.last_error = None
|
||||
self.stats["opened_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
log.info("%s opened", self.settings.describe())
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.ser = None
|
||||
self.stats["open_failures"] += 1
|
||||
message = f"could not open port {self.settings.port}: {e}"
|
||||
if message != self.last_error:
|
||||
log.warning("%s", message)
|
||||
self.last_error = message
|
||||
return False
|
||||
finally:
|
||||
self.first_attempt.set()
|
||||
with self.cond:
|
||||
self.cond.notify_all()
|
||||
|
||||
def _close(self) -> None:
|
||||
if self.ser is not None:
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self.ser = None
|
||||
|
||||
def _push(self, data: bytes, t_ms: int) -> None:
|
||||
with self.cond:
|
||||
self.chunks.append((t_ms, data))
|
||||
self.stats["frames_rx"] += 1
|
||||
self.cond.notify_all()
|
||||
for listener in list(self.listeners):
|
||||
try:
|
||||
listener(t_ms, data)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("chunk listener failed")
|
||||
|
||||
def run(self) -> None:
|
||||
s = self.settings
|
||||
buf = bytearray()
|
||||
last_rx = 0.0
|
||||
next_request = 0.0
|
||||
while not self._halt.is_set():
|
||||
if time.monotonic() - self.last_used > IDLE_CLOSE_S:
|
||||
log.info("%s idle for %ds, released", s.port, IDLE_CLOSE_S)
|
||||
break
|
||||
if self.ser is None:
|
||||
if not self._open():
|
||||
self._halt.wait(2.0)
|
||||
continue
|
||||
buf.clear()
|
||||
next_request = 0.0
|
||||
try:
|
||||
now = time.monotonic()
|
||||
if s.request and now >= next_request:
|
||||
self.ser.write(s.request)
|
||||
self.stats["requests_tx"] += 1
|
||||
next_request = now + s.poll_s
|
||||
waiting = self.ser.in_waiting
|
||||
chunk = self.ser.read(waiting if waiting else 1)
|
||||
now = time.monotonic()
|
||||
if chunk:
|
||||
if s.after_ack and b"\x06" in chunk:
|
||||
self.ser.write(s.after_ack)
|
||||
buf += chunk
|
||||
last_rx = now
|
||||
self.stats["bytes_rx"] += len(chunk)
|
||||
if len(buf) >= MAX_CHUNK:
|
||||
self._push(bytes(buf), int(now * 1000))
|
||||
buf.clear()
|
||||
continue
|
||||
if buf and now - last_rx >= s.gap_s:
|
||||
self._push(bytes(buf), int(last_rx * 1000))
|
||||
buf.clear()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.last_error = f"{s.port}: {e.__class__.__name__}: {e}"
|
||||
log.warning("%s lost: %s", s.port, e)
|
||||
self._close()
|
||||
self._halt.wait(1.0)
|
||||
self._close()
|
||||
self._halt.set()
|
||||
with self.cond:
|
||||
self.cond.notify_all()
|
||||
|
||||
# -- reading -----------------------------------------------------------
|
||||
def wait_fresh(self, max_age_ms: int, wait_ms: int) -> None:
|
||||
"""Return once a chunk younger than max_age_ms exists, or wait_ms passed."""
|
||||
self.first_attempt.wait(timeout=min(wait_ms, 3000) / 1000)
|
||||
deadline = time.monotonic() + wait_ms / 1000
|
||||
with self.cond:
|
||||
while True:
|
||||
if self.chunks and now_ms() - self.chunks[-1][0] <= max_age_ms:
|
||||
return
|
||||
if self.ser is None and self.last_error:
|
||||
return
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or self._halt.is_set():
|
||||
return
|
||||
self.cond.wait(timeout=remaining)
|
||||
|
||||
def recent(self, count: int) -> list[tuple[int, bytes]]:
|
||||
with self.cond:
|
||||
return list(self.chunks)[-count:] if count > 0 else []
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.ser is not None
|
||||
|
||||
def snapshot(self, count: int) -> dict[str, Any]:
|
||||
chunks = self.recent(count)
|
||||
is_open = self.is_open
|
||||
return {
|
||||
"ok": is_open or bool(chunks and not self.last_error),
|
||||
"open": is_open,
|
||||
"error": None if is_open else self.last_error,
|
||||
"now": now_ms(),
|
||||
"port": self.settings.port,
|
||||
"frames": [{"t": t, "hex": format_hex(d)} for t, d in chunks],
|
||||
"stats": dict(self.stats),
|
||||
}
|
||||
|
||||
|
||||
class PortBusy(RuntimeError):
|
||||
"""The port is reserved (auto-detection is running on it)."""
|
||||
|
||||
|
||||
def port_key(port: str) -> str:
|
||||
return port.upper() if port.upper().startswith("COM") else port
|
||||
|
||||
|
||||
class Registry:
|
||||
"""At most one worker per port; new settings for a port reopen it."""
|
||||
|
||||
def __init__(self, opener: Opener):
|
||||
self.opener = opener
|
||||
self.lock = threading.Lock()
|
||||
self.workers: dict[str, PortWorker] = {}
|
||||
self.reserved: set[str] = set()
|
||||
|
||||
def reserve(self, ports: list[str]) -> None:
|
||||
"""Close these ports and keep them closed until `unreserve` (for auto-detection)."""
|
||||
with self.lock:
|
||||
self.reserved.update(port_key(p) for p in ports)
|
||||
for p in ports:
|
||||
self.release(p)
|
||||
|
||||
def unreserve(self, ports: list[str]) -> None:
|
||||
with self.lock:
|
||||
self.reserved.difference_update(port_key(p) for p in ports)
|
||||
|
||||
def worker_for(self, settings: LineSettings) -> PortWorker:
|
||||
with self.lock:
|
||||
if port_key(settings.port) in self.reserved:
|
||||
raise PortBusy(f"{settings.port} is busy: auto-detection is running on it")
|
||||
current = self.workers.get(settings.port)
|
||||
if current is not None and (current.stopped or current.settings != settings):
|
||||
if not current.stopped:
|
||||
log.info("%s settings changed, reopening", settings.port)
|
||||
current.stop()
|
||||
current.join(timeout=3)
|
||||
current = None
|
||||
if current is None:
|
||||
current = PortWorker(settings, self.opener)
|
||||
self.workers[settings.port] = current
|
||||
current.start()
|
||||
current.touch()
|
||||
return current
|
||||
|
||||
def release(self, port: str) -> None:
|
||||
"""Close a port so something else (auto-detect) can open it."""
|
||||
with self.lock:
|
||||
worker = self.workers.pop(port_key(port), None)
|
||||
if worker is not None:
|
||||
worker.stop()
|
||||
worker.join(timeout=3)
|
||||
|
||||
def active_ports(self) -> list[str]:
|
||||
with self.lock:
|
||||
return [p for p, w in self.workers.items() if not w.stopped]
|
||||
|
||||
def stop_all(self) -> None:
|
||||
with self.lock:
|
||||
workers = list(self.workers.values())
|
||||
self.workers.clear()
|
||||
for w in workers:
|
||||
w.stop()
|
||||
for w in workers:
|
||||
w.join(timeout=3)
|
||||
@@ -0,0 +1,572 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
The HTTP side of the bridge.
|
||||
|
||||
For the network (any system that needs the weight) — JSON, and
|
||||
the header `X-Bridge-Token: <access key>` on everything except /health:
|
||||
|
||||
GET /health service, version, host (no key needed)
|
||||
GET /ports COM ports on this PC
|
||||
POST /read raw frames for given line settings
|
||||
(the raw-frames contract of earlier versions)
|
||||
GET /profiles saved scale profiles
|
||||
GET /weight?profile=NAME the current weight, parsed and checked
|
||||
GET /weight?port=COM7&protocol=mt_sics[&baud=9600&framing=8N1]
|
||||
POST /weight {"profile": NAME} or a whole profile
|
||||
|
||||
For the page on this PC only (loopback address, and the header
|
||||
`X-Requested-With: ScaleBridge` so another web page cannot drive it):
|
||||
|
||||
GET / the user interface
|
||||
GET /api/state, /api/protocols, /api/weight, /api/monitor
|
||||
POST /api/detect, /api/detect/cancel, /api/profiles, /api/token,
|
||||
/api/allowed, /api/firewall, /api/autostart
|
||||
GET /api/detect progress and results of auto-detection
|
||||
DELETE /api/profiles/NAME
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
from . import ABOUT, PRODUCT, SERVICE_ID, __version__, profiles, winutil
|
||||
from .config import Config
|
||||
from .detect import Detector, Timings
|
||||
from .labels import Keeper, LabelLog, labels_folder, on_print
|
||||
from .protocols import PROTOCOLS, catalogue_info, get_protocol
|
||||
from .serialio import (RING_SIZE, LineSettings, Opener, PortBusy, Registry, format_hex,
|
||||
list_serial_ports, now_ms, open_real, printable)
|
||||
from .simulator import SimulatorBank
|
||||
from .webui import page, remote_page
|
||||
|
||||
log = logging.getLogger("scale_bridge")
|
||||
|
||||
MAX_BODY = 64 * 1024
|
||||
LOCAL_HEADER = ("X-Requested-With", "ScaleBridge")
|
||||
STATUS_CACHE_S = 15
|
||||
|
||||
|
||||
def _framing_ok(text: str) -> bool:
|
||||
return len(text) == 3 and text[0] in "5678" and text[1] in "NEOMS" and text[2] in "12"
|
||||
|
||||
|
||||
class DetectionJob:
|
||||
def __init__(self, bridge: "Bridge", ports: list[str], thorough: bool,
|
||||
print_wait_s: float = 0, listen_only: bool = False):
|
||||
self.id = uuid.uuid4().hex[:12]
|
||||
self.bridge = bridge
|
||||
self.ports = ports
|
||||
self.thorough = thorough
|
||||
self.cancel = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
self.log: list[dict] = []
|
||||
self.results: list[dict] = []
|
||||
self.running = True
|
||||
self.started = time.time()
|
||||
self.finished: Optional[float] = None
|
||||
self.detector = Detector(bridge.opener, log=self._add, cancelled=self.cancel.is_set,
|
||||
thorough=thorough, timings=bridge.detect_timings,
|
||||
print_wait_s=print_wait_s, listen_only=listen_only)
|
||||
self.print_wait_s = print_wait_s
|
||||
self.listen_only = listen_only
|
||||
self.thread = threading.Thread(target=self._run, name="detect", daemon=True)
|
||||
|
||||
def _add(self, code: str, **details: Any) -> None:
|
||||
with self.lock:
|
||||
self.log.append({"t": round(time.time() - self.started, 2), "code": code, **details})
|
||||
if code in ("found", "not_found", "open_failed"):
|
||||
log.info("detect %s %s", code, details)
|
||||
|
||||
def _run(self) -> None:
|
||||
registry = self.bridge.registry
|
||||
registry.reserve(self.ports)
|
||||
try:
|
||||
for port in self.ports:
|
||||
if self.cancel.is_set():
|
||||
break
|
||||
result = self.detector.run_port(port)
|
||||
with self.lock:
|
||||
self.results.append(result)
|
||||
self.detector.steps_done = 0
|
||||
self._add("done", found=sum(1 for r in self.results if r.get("found")),
|
||||
cancelled=self.cancel.is_set())
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("auto-detection failed")
|
||||
self._add("error", error=str(e))
|
||||
finally:
|
||||
registry.unreserve(self.ports)
|
||||
self.running = False
|
||||
self.finished = time.time()
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
|
||||
def status(self, since: int = 0) -> dict:
|
||||
with self.lock:
|
||||
if self.running:
|
||||
current = min(self.detector.progress, 0.99)
|
||||
progress = min(0.99, (len(self.results) + current) / max(1, len(self.ports)))
|
||||
else:
|
||||
progress = 1.0
|
||||
return {"id": self.id, "running": self.running, "ports": self.ports,
|
||||
"thorough": self.thorough, "print_wait_s": self.print_wait_s,
|
||||
"listen_only": self.listen_only, "progress": round(progress, 3),
|
||||
"log": self.log[since:], "log_size": len(self.log),
|
||||
"results": list(self.results),
|
||||
"elapsed_s": round((self.finished or time.time()) - self.started, 1)}
|
||||
|
||||
|
||||
class Bridge:
|
||||
"""Everything the handlers share."""
|
||||
|
||||
def __init__(self, config: Config, simulate: str = ""):
|
||||
self.config = config
|
||||
self.simulator = SimulatorBank(simulate) if simulate else None
|
||||
self.opener: Opener = self.simulator.opener(open_real) if self.simulator else open_real
|
||||
self.registry = Registry(self.opener)
|
||||
self.job: Optional[DetectionJob] = None
|
||||
self.job_lock = threading.Lock()
|
||||
self._status_cache: dict[str, tuple[float, dict]] = {}
|
||||
self.started = time.time()
|
||||
self.listen_port = config.listen_port
|
||||
self.detect_timings: Optional[Timings] = None
|
||||
# Print-only scales: keep their ports open and log every label.
|
||||
self.labels = LabelLog(labels_folder(config.path))
|
||||
self.keeper = Keeper(self.registry, config.profiles, self.labels)
|
||||
self.keeper.start()
|
||||
|
||||
# -- ports and status --------------------------------------------------
|
||||
def ports(self) -> list[dict]:
|
||||
ports = list_serial_ports()
|
||||
if self.simulator:
|
||||
ports += self.simulator.ports()
|
||||
return ports
|
||||
|
||||
def cached_status(self, key: str, fn, fresh: bool = False) -> dict:
|
||||
now = time.time()
|
||||
hit = self._status_cache.get(key)
|
||||
if hit and not fresh and now - hit[0] < STATUS_CACHE_S:
|
||||
return hit[1]
|
||||
value = fn()
|
||||
self._status_cache[key] = (now, value)
|
||||
return value
|
||||
|
||||
def forget_status(self) -> None:
|
||||
self._status_cache.clear()
|
||||
|
||||
# -- reading -----------------------------------------------------------
|
||||
def read_raw(self, body: dict) -> dict:
|
||||
"""POST /read: the frames as they arrived (raw-frames contract of earlier versions)."""
|
||||
settings = LineSettings.from_dicts(body.get("serial") or {}, body.get("exchange") or {})
|
||||
wait_ms = min(max(int(body.get("wait_ms") or 2500), 0), 10000)
|
||||
max_age_ms = min(max(int(body.get("max_age_ms") or 1500), 50), 60000)
|
||||
count = min(max(int(body.get("frames") or 5), 0), RING_SIZE)
|
||||
worker = self.registry.worker_for(settings)
|
||||
worker.wait_fresh(max_age_ms, wait_ms)
|
||||
worker.touch()
|
||||
return worker.snapshot(count)
|
||||
|
||||
def weight(self, profile: dict, max_age_ms: int = 1500, wait_ms: int = 2500) -> dict:
|
||||
worker = self.registry.worker_for(profiles.line_settings(profile))
|
||||
result = profiles.wait_for_reading(profile, worker, max_age_ms, wait_ms, now_ms)
|
||||
worker.touch()
|
||||
return result
|
||||
|
||||
def label_list(self, query: dict) -> dict:
|
||||
name = str(query.get("profile") or "").strip() or None
|
||||
if name and self.config.profile(name) is None:
|
||||
raise LookupError(f"no profile named {name!r}")
|
||||
if name:
|
||||
name = self.config.profile(name)["name"]
|
||||
after = int(query.get("after") or 0)
|
||||
limit = min(max(int(query.get("limit") or 100), 1), 500)
|
||||
records = self.labels.list(name, after, limit)
|
||||
return {"ok": True, "labels": records, "last_seq": self.labels.seq,
|
||||
"listening": [p["name"] for p in self.config.profiles() if on_print(p)]}
|
||||
|
||||
def resolve_profile(self, query: dict) -> dict:
|
||||
"""A saved profile by name, or one built from the request."""
|
||||
name = str(query.get("profile") or "").strip()
|
||||
if name:
|
||||
profile = self.config.profile(name)
|
||||
if profile is None:
|
||||
raise LookupError(f"no profile named {name!r}")
|
||||
return profile
|
||||
if isinstance(query.get("serial"), dict):
|
||||
return profiles.validate({"name": "request", **query})
|
||||
port = str(query.get("port") or "").strip()
|
||||
protocol = str(query.get("protocol") or "").strip()
|
||||
if not port or not protocol:
|
||||
raise ValueError("give ?profile=NAME, or ?port=COMx&protocol=ID")
|
||||
serial_cfg: dict[str, Any] = {}
|
||||
if query.get("baud"):
|
||||
serial_cfg["baudrate"] = int(query["baud"])
|
||||
framing = str(query.get("framing") or "").upper()
|
||||
if framing:
|
||||
if not _framing_ok(framing):
|
||||
raise ValueError(f"invalid framing: {framing!r} (e.g. 8N1, 7E1)")
|
||||
serial_cfg.update(bytesize=int(framing[0]), parity=framing[1], stopbits=int(framing[2]))
|
||||
exchange = {}
|
||||
if query.get("request_hex") is not None:
|
||||
exchange = {"request_hex": str(query["request_hex"]),
|
||||
"poll_interval_ms": int(query.get("poll_ms") or PROTOCOLS.get(protocol, PROTOCOLS["ascii_generic"]).default_poll_ms)}
|
||||
profile = profiles.ad_hoc(port, protocol, serial_cfg, exchange or None)
|
||||
if query.get("unit"):
|
||||
profile["unit"] = str(query["unit"])
|
||||
return profile
|
||||
|
||||
def monitor(self, query: dict) -> dict:
|
||||
port = str(query.get("port") or "")
|
||||
framing = str(query.get("framing") or "8N1").upper()
|
||||
if not _framing_ok(framing):
|
||||
raise ValueError(f"invalid framing: {framing!r}")
|
||||
serial_cfg = {"port": port, "baudrate": int(query.get("baud") or 9600),
|
||||
"bytesize": int(framing[0]), "parity": framing[1], "stopbits": int(framing[2])}
|
||||
exchange = {"request_hex": str(query.get("request_hex") or ""),
|
||||
"after_ack_hex": str(query.get("after_ack_hex") or ""),
|
||||
"poll_interval_ms": int(query.get("poll_ms") or 500)}
|
||||
settings = LineSettings.from_dicts(serial_cfg, exchange)
|
||||
worker = self.registry.worker_for(settings)
|
||||
worker.wait_fresh(1500, 1500)
|
||||
chunks = worker.recent(24)
|
||||
joined = b"".join(d for _, d in chunks)
|
||||
guesses = []
|
||||
for pid, proto in PROTOCOLS.items():
|
||||
if pid.startswith("custom"):
|
||||
continue
|
||||
found = [r for r in proto.readings(joined) if r.value is not None]
|
||||
if found:
|
||||
last = found[-1]
|
||||
guesses.append({"protocol": pid, "name": proto.name, "count": len(found),
|
||||
"value": last.value, "unit": last.unit, "stable": last.stable,
|
||||
"specificity": proto.specificity})
|
||||
guesses.sort(key=lambda g: (-g["specificity"], -g["count"]))
|
||||
now = now_ms()
|
||||
return {"ok": worker.is_open, "error": None if worker.is_open else worker.last_error,
|
||||
"settings": settings.describe(), "stats": dict(worker.stats),
|
||||
"frames": [{"age_ms": now - t, "hex": format_hex(d), "text": printable(d)}
|
||||
for t, d in chunks[-16:]],
|
||||
"guesses": guesses}
|
||||
|
||||
# -- auto-detection ----------------------------------------------------
|
||||
def start_detection(self, ports: Optional[list[str]], thorough: bool, bluetooth: bool,
|
||||
print_wait_s: float = 0, listen_only: bool = False) -> DetectionJob:
|
||||
with self.job_lock:
|
||||
if self.job and self.job.running:
|
||||
raise PortBusy("auto-detection is already running")
|
||||
available = self.ports()
|
||||
if ports:
|
||||
names = {p["device"].upper(): p["device"] for p in available}
|
||||
chosen = []
|
||||
for p in ports:
|
||||
key = str(p).upper()
|
||||
if key not in names:
|
||||
raise ValueError(f"no such port: {p}")
|
||||
chosen.append(names[key])
|
||||
else:
|
||||
chosen = [p["device"] for p in available if bluetooth or p.get("kind") != "bluetooth"]
|
||||
if not chosen:
|
||||
raise ValueError("no COM ports to search — is the USB-serial adapter plugged in "
|
||||
"and its driver installed?")
|
||||
self.job = DetectionJob(self, chosen, thorough, min(max(float(print_wait_s or 0), 0), 300),
|
||||
listen_only)
|
||||
self.job.start()
|
||||
return self.job
|
||||
|
||||
def state(self, fresh: bool = False) -> dict:
|
||||
port = self.listen_port
|
||||
return {
|
||||
"ok": True,
|
||||
"about": ABOUT,
|
||||
"service": SERVICE_ID,
|
||||
"host": socket.gethostname(),
|
||||
"addresses": winutil.lan_addresses(),
|
||||
"listen_port": port,
|
||||
"token": self.config.token,
|
||||
"config_path": self.config.path,
|
||||
"allowed_clients": self.config.allowed_clients,
|
||||
"simulate": bool(self.simulator),
|
||||
"is_admin": winutil.is_admin(),
|
||||
"is_windows": winutil.is_windows(),
|
||||
"firewall": self.cached_status("firewall", lambda: winutil.firewall_status(port), fresh),
|
||||
"autostart": self.cached_status("autostart", winutil.autostart_status, fresh),
|
||||
"profiles": self.config.profiles(),
|
||||
"active_ports": self.registry.active_ports(),
|
||||
"uptime_s": int(time.time() - self.started),
|
||||
}
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
# On Windows SO_REUSEADDR lets a second program take the same port;
|
||||
# exclusive use makes a second copy of the bridge fail cleanly instead.
|
||||
allow_reuse_address = sys.platform != "win32"
|
||||
|
||||
def server_bind(self) -> None:
|
||||
if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
super().server_bind()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = f"{SERVICE_ID}/{__version__}"
|
||||
bridge: Bridge
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
log.debug("%s %s", self.client_address[0], fmt % args)
|
||||
|
||||
# -- responses ---------------------------------------------------------
|
||||
def _headers(self, code: int, content_type: str, length: int) -> None:
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(length))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
|
||||
def _send(self, code: int, body: dict) -> None:
|
||||
data = json.dumps(body, ensure_ascii=False, default=str).encode("utf-8")
|
||||
self._headers(code, "application/json; charset=utf-8", len(data))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_html(self, html: str, code: int = 200) -> None:
|
||||
data = html.encode("utf-8")
|
||||
self._headers(code, "text/html; charset=utf-8", len(data))
|
||||
self.send_header("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
|
||||
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _error(self, code: int, message: str) -> None:
|
||||
self._send(code, {"ok": False, "error": message})
|
||||
|
||||
# -- who is asking -----------------------------------------------------
|
||||
@property
|
||||
def _client(self) -> str:
|
||||
return self.client_address[0]
|
||||
|
||||
def _is_loopback(self) -> bool:
|
||||
ip = self._client
|
||||
return ip.startswith("127.") or ip in ("::1", "::ffff:127.0.0.1")
|
||||
|
||||
def _host_is_local(self) -> bool:
|
||||
host = (self.headers.get("Host") or "").strip().lower()
|
||||
if host.startswith("["):
|
||||
name = host[1:host.find("]")] if "]" in host else host
|
||||
else:
|
||||
name = host.split(":")[0]
|
||||
return name in ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
def _local_page_ok(self) -> bool:
|
||||
return self._is_loopback() and self._host_is_local()
|
||||
|
||||
def _local_api_ok(self) -> bool:
|
||||
if not self._local_page_ok() or self.headers.get(LOCAL_HEADER[0]) != LOCAL_HEADER[1]:
|
||||
self._error(403, "this part of the bridge is only for the page on this PC")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _client_allowed(self) -> bool:
|
||||
allowed = self.bridge.config.allowed_clients
|
||||
if allowed and not self._is_loopback() and self._client not in allowed:
|
||||
self._error(403, f"client {self._client} is not in allowed_clients")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
given = (self.headers.get("X-Bridge-Token") or "").encode()
|
||||
expected = self.bridge.config.token.encode()
|
||||
if not expected or not hmac.compare_digest(given, expected):
|
||||
self._error(401, "invalid or missing X-Bridge-Token")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _body(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if length > MAX_BODY:
|
||||
raise ValueError("body too large")
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
body = json.loads(raw or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("body must be a JSON object")
|
||||
return body
|
||||
|
||||
def _query(self) -> tuple[str, dict]:
|
||||
parts = urlsplit(self.path)
|
||||
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
|
||||
return parts.path.rstrip("/") or "/", query
|
||||
|
||||
# -- dispatch ----------------------------------------------------------
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._dispatch("GET")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
self._dispatch("POST")
|
||||
|
||||
def do_DELETE(self) -> None: # noqa: N802
|
||||
self._dispatch("DELETE")
|
||||
|
||||
def _dispatch(self, method: str) -> None:
|
||||
path, query = self._query()
|
||||
try:
|
||||
if path.startswith("/api/"):
|
||||
if self._local_api_ok():
|
||||
self._local_api(method, path, query)
|
||||
return
|
||||
if path == "/" and method == "GET":
|
||||
if self._local_page_ok():
|
||||
self._send_html(page())
|
||||
else:
|
||||
self._send_html(remote_page(self.bridge.listen_port))
|
||||
return
|
||||
if path == "/favicon.ico":
|
||||
self.send_response(204)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
if not self._client_allowed():
|
||||
return
|
||||
self._network_api(method, path, query)
|
||||
except PortBusy as e:
|
||||
self._error(409, str(e))
|
||||
except LookupError as e:
|
||||
self._error(404, str(e))
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
self._error(400, str(e))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("%s %s failed", method, path)
|
||||
self._error(500, f"{e.__class__.__name__}: {e}")
|
||||
|
||||
def _network_api(self, method: str, path: str, query: dict) -> None:
|
||||
b = self.bridge
|
||||
if path == "/health" and method == "GET":
|
||||
self._send(200, {"ok": True, "service": SERVICE_ID, "product": PRODUCT,
|
||||
"version": __version__, "host": socket.gethostname(),
|
||||
"simulate": bool(b.simulator), "author": ABOUT["author"],
|
||||
"website": ABOUT["website"], "repository": ABOUT["repository"],
|
||||
"capabilities": ["read", "ports", "weight", "profiles", "detect"]})
|
||||
return
|
||||
if not self._authorized():
|
||||
return
|
||||
if path == "/ports" and method == "GET":
|
||||
self._send(200, {"ok": True, "ports": b.ports()})
|
||||
elif path == "/read" and method == "POST":
|
||||
self._send(200, b.read_raw(self._body()))
|
||||
elif path == "/profiles" and method == "GET":
|
||||
self._send(200, {"ok": True, "profiles": b.config.profiles()})
|
||||
elif path == "/labels" and method == "GET":
|
||||
self._send(200, b.label_list(query))
|
||||
elif path == "/weight" and method in ("GET", "POST"):
|
||||
body = query if method == "GET" else {**query, **self._body()}
|
||||
profile = b.resolve_profile(body)
|
||||
self._send(200, b.weight(profile, *self._timing(body)))
|
||||
else:
|
||||
self._error(404, "not found")
|
||||
|
||||
@staticmethod
|
||||
def _timing(q: dict) -> tuple[int, int]:
|
||||
max_age = min(max(int(q.get("max_age_ms") or 1500), 50), 60000)
|
||||
wait = min(max(int(q.get("wait_ms") or 2500), 0), 10000)
|
||||
return max_age, wait
|
||||
|
||||
def _local_api(self, method: str, path: str, query: dict) -> None:
|
||||
b = self.bridge
|
||||
if method == "GET":
|
||||
if path == "/api/state":
|
||||
self._send(200, b.state(fresh=query.get("fresh") == "1"))
|
||||
elif path == "/api/ports":
|
||||
self._send(200, {"ok": True, "ports": b.ports()})
|
||||
elif path == "/api/protocols":
|
||||
self._send(200, {"ok": True, "protocols": catalogue_info()})
|
||||
elif path == "/api/weight":
|
||||
profile = b.resolve_profile(query)
|
||||
self._send(200, b.weight(profile, *self._timing(query)))
|
||||
elif path == "/api/labels":
|
||||
self._send(200, b.label_list(query))
|
||||
elif path == "/api/monitor":
|
||||
self._send(200, b.monitor(query))
|
||||
elif path == "/api/detect":
|
||||
job = b.job
|
||||
since = int(query.get("since") or 0)
|
||||
self._send(200, {"ok": True, "job": job.status(since) if job else None})
|
||||
else:
|
||||
self._error(404, "not found")
|
||||
return
|
||||
|
||||
if method == "DELETE":
|
||||
if path.startswith("/api/profiles/"):
|
||||
name = unquote(path[len("/api/profiles/"):])
|
||||
if not b.config.delete_profile(name):
|
||||
raise LookupError(f"no profile named {name!r}")
|
||||
self._send(200, {"ok": True, "profiles": b.config.profiles()})
|
||||
else:
|
||||
self._error(404, "not found")
|
||||
return
|
||||
|
||||
body = self._body()
|
||||
if path == "/api/detect":
|
||||
job = b.start_detection(body.get("ports") or None, bool(body.get("thorough")),
|
||||
bool(body.get("bluetooth")), body.get("print_wait_s") or 0,
|
||||
bool(body.get("listen_only")))
|
||||
self._send(200, {"ok": True, "job": job.status()})
|
||||
elif path == "/api/detect/cancel":
|
||||
if b.job and b.job.running:
|
||||
b.job.cancel.set()
|
||||
self._send(200, {"ok": True})
|
||||
elif path == "/api/profiles":
|
||||
if isinstance(body.get("detection"), dict):
|
||||
profile = profiles.from_detection(body["detection"], str(body.get("name") or ""),
|
||||
str(body.get("label") or ""))
|
||||
if body.get("unit"):
|
||||
profile["unit"] = profiles.norm_unit(body["unit"])
|
||||
else:
|
||||
profile = body
|
||||
saved = b.config.save_profile(profile)
|
||||
b.registry.release(saved["port"])
|
||||
b.keeper.tick()
|
||||
self._send(200, {"ok": True, "profile": saved, "profiles": b.config.profiles()})
|
||||
elif path == "/api/token":
|
||||
self._send(200, {"ok": True, "token": b.config.rotate_token()})
|
||||
elif path == "/api/allowed":
|
||||
clients = body.get("clients") or []
|
||||
if not isinstance(clients, list):
|
||||
raise ValueError("clients must be a list")
|
||||
b.config.set_allowed_clients(clients)
|
||||
self._send(200, {"ok": True, "allowed_clients": b.config.allowed_clients})
|
||||
elif path == "/api/firewall":
|
||||
remote = str(body.get("remote_ip") or "").strip() or None
|
||||
if remote:
|
||||
import ipaddress
|
||||
ipaddress.ip_address(remote)
|
||||
result = (winutil.firewall_close() if body.get("remove")
|
||||
else winutil.firewall_open(b.listen_port, remote))
|
||||
b.forget_status()
|
||||
self._send(200, {**result, "state": b.state(fresh=True)})
|
||||
elif path == "/api/autostart":
|
||||
result = (winutil.autostart_enable(b.config.path) if body.get("enable")
|
||||
else winutil.autostart_disable())
|
||||
b.forget_status()
|
||||
self._send(200, {**result, "state": b.state(fresh=True)})
|
||||
else:
|
||||
self._error(404, "not found")
|
||||
|
||||
|
||||
def make_server(bridge: Bridge, host: str, port: int) -> Server:
|
||||
Handler.bridge = bridge
|
||||
bridge.listen_port = port
|
||||
return Server((host, port), Handler)
|
||||
@@ -0,0 +1,222 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
Simulated scales — every protocol in the library, on a fake COM port.
|
||||
|
||||
For trying the program without hardware and for the tests. A simulated
|
||||
device only understands the speed and framing it was configured with,
|
||||
exactly like a real one: at another speed a continuous device produces
|
||||
garbage and a polled one stays silent; a 7E1 device read as 8N1 shows its
|
||||
parity bit in the top bit of each byte.
|
||||
|
||||
--simulate mahak_mds COM90, 9600 8N1
|
||||
--simulate toledo_cont:4800:7E1,mt_sics COM90 and COM91
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .serialio import LineSettings, Opener, open_real
|
||||
|
||||
FIRST_PORT = 90
|
||||
|
||||
# The weight walks through an empty scale, 550 g, a ~13 kg package that
|
||||
# settles, and 750 g — (seconds, [kg values], stable).
|
||||
SCHEDULE = [(6, [0.0], True), (8, [0.55], True), (3, [12.85, 13.15, 13.10], False),
|
||||
(8, [13.05], True), (6, [0.75], True)]
|
||||
|
||||
|
||||
def weight_now(t0: float) -> tuple[float, bool]:
|
||||
total = sum(s for s, _, _ in SCHEDULE)
|
||||
t = (time.monotonic() - t0) % total
|
||||
for seconds, values, stable in SCHEDULE:
|
||||
if t < seconds:
|
||||
return values[int(t * 3) % len(values)], stable
|
||||
t -= seconds
|
||||
return 0.0, True
|
||||
|
||||
|
||||
def _even_parity_bit(b: int) -> int:
|
||||
return (bin(b & 0x7F).count("1") & 1) << 7
|
||||
|
||||
|
||||
def frame_for(protocol: str, kg: float, stable: bool) -> bytes:
|
||||
"""What `protocol` sends for `kg` — the same shapes the parsers read."""
|
||||
if protocol == "mt_sics":
|
||||
return f"S {'S' if stable else 'D'} {kg:10.3f} kg\r\n".encode()
|
||||
if protocol == "and_std":
|
||||
return f"{'ST' if stable else 'US'},GS,{kg:+09.3f}kg\r\n".encode()
|
||||
if protocol == "toledo_cont":
|
||||
digits = f"{round(abs(kg) * 1000):06d}".encode()
|
||||
swa = 0x20 | 0x08 | 5 # count-by x1, 0.00X
|
||||
swb = 0x20 | 0x10 | (0 if stable else 0x08) | (0x02 if kg < 0 else 0)
|
||||
swc = 0x20
|
||||
body = bytes([0x02, swa, swb, swc]) + digits + b"000000" + b"\r"
|
||||
return body + bytes([(-sum(b & 0x7F for b in body)) & 0x7F])
|
||||
if protocol == "sma":
|
||||
return b"\n 1G" + (b" " if stable else b"M") + b" " + f"{kg:10.3f}".encode() + b"kg \r"
|
||||
if protocol == "nci":
|
||||
return b"\n" + f"{kg:8.3f}kg".encode() + b"\r\n" + (b"0" if stable else b"1") + b"0\r\x03"
|
||||
if protocol == "ohaus":
|
||||
return f"{kg:10.3f} kg{'' if stable else ' ?'}\r\n".encode()
|
||||
if protocol == "sartorius":
|
||||
return f"+{kg:10.3f} {'kg' if stable else ' '}\r\n".encode()
|
||||
if protocol == "cas_enq":
|
||||
return b"\x01\x02" + (b"S" if stable else b"U") + f" {kg:7.3f}kg".encode() + b"\x03\x04"
|
||||
if protocol == "xk3190_tf0":
|
||||
body = (b"-" if kg < 0 else b"+") + f"{round(abs(kg) * 1000):06d}".encode() + b"3"
|
||||
x = 0
|
||||
for b in body:
|
||||
x ^= b
|
||||
return b"\x02" + body + f"{x >> 4:X}{x & 0xF:X}".encode() + b"\x03"
|
||||
if protocol == "xk3190_reversed":
|
||||
return f"{kg:08.3f}"[::-1].encode() + b"="
|
||||
if protocol == "label_print":
|
||||
# What an indicator wired to a TSC label printer sends on Print (TSPL).
|
||||
w = f"{kg:7.3f}"
|
||||
return ('CLS\r\nSIZE 57 mm, 40 mm\r\nGAP 2 mm,0\r\nDIRECTION 1\r\nREFERENCE 0,0\r\n'
|
||||
'CODEPAGE IranII\r\nTEXT 300,250,"10",270,1,1,"No: 6 SHIFT:2"\r\n'
|
||||
'TEXT 030,235,"10",0,1,2,"Order.Co : 222\r\n'
|
||||
'TEXT 030,080,"10",0,1,2,"1405/07/19 - 16:44"\r\n'
|
||||
'TEXT 030,160,"10",0,1,2,"Thickness : 0.0"\r\n'
|
||||
f'TEXT 020,000,"10",0,2,2,"{w}"\r\nTEXT 200,015,"10",0,2,1,"(Kg)"\r\n'
|
||||
f'BARCODE 350,280, "128",070,1,270,2,2, " 222\t{w}"\r\nPRINT 1\r\n\r\n').encode()
|
||||
if protocol == "mahak_mds":
|
||||
grams = round(kg * 1000)
|
||||
return bytes([0x58, (grams * 7 + 0x51) & 0xFF, 0x07, 0x07]) + grams.to_bytes(3, "big")
|
||||
return f"G {kg:.3f} kg\r\n".encode()
|
||||
|
||||
|
||||
class SimulatedDevice:
|
||||
"""Quacks like a pyserial Serial for what the bridge uses."""
|
||||
|
||||
CONTINUOUS = {"toledo_cont", "xk3190_tf0", "xk3190_reversed", "ascii_generic"}
|
||||
# Sends only when somebody presses Print — here every PRINT_EVERY_S seconds.
|
||||
ON_PRINT = {"label_print"}
|
||||
PRINT_EVERY_S = 3.0
|
||||
REQUESTS = {"mt_sics": b"SI\r\n", "and_std": b"Q\r\n", "sma": b"\nW\r", "nci": b"W\r",
|
||||
"ohaus": b"IP\r\n", "sartorius": b"\x1bP\r\n", "mahak_mds": bytes.fromhex("4D090407"),
|
||||
"cas_enq": b"\x05"}
|
||||
BOOT = bytes.fromhex("42 03 04 01 42 4F 4F 54 0C 04 8C")
|
||||
|
||||
def __init__(self, protocol: str, baudrate: int, framing: str, opened: LineSettings):
|
||||
self.protocol = protocol
|
||||
self.device_baud = baudrate
|
||||
self.device_framing = framing
|
||||
self.opened = opened
|
||||
self.is_open = True
|
||||
self._t0 = time.monotonic()
|
||||
self._out = bytearray()
|
||||
self._due: list[tuple[float, bytes]] = []
|
||||
self._next_emit = time.monotonic() + (self.PRINT_EVERY_S if protocol in self.ON_PRINT else 0)
|
||||
self._next_boot = time.monotonic() + 60
|
||||
self._garbage_seed = 0x5A
|
||||
|
||||
# -- line matching -----------------------------------------------------
|
||||
@property
|
||||
def _baud_ok(self) -> bool:
|
||||
return self.opened.baudrate == self.device_baud
|
||||
|
||||
@property
|
||||
def _framing_ok(self) -> bool:
|
||||
return self.opened.framing == self.device_framing
|
||||
|
||||
def _as_received(self, data: bytes) -> bytes:
|
||||
"""What the host's UART makes of the device's bytes."""
|
||||
if not self._baud_ok:
|
||||
out = bytearray()
|
||||
for _ in range(max(1, len(data) // 3)):
|
||||
self._garbage_seed = (self._garbage_seed * 73 + 41) & 0xFF
|
||||
out.append(self._garbage_seed | 0x80)
|
||||
return bytes(out)
|
||||
if self._framing_ok:
|
||||
return data
|
||||
if self.device_framing.startswith("7") and self.opened.bytesize == 8:
|
||||
return bytes((b & 0x7F) | _even_parity_bit(b) for b in data)
|
||||
if self.device_framing.startswith("8") and self.opened.bytesize == 7:
|
||||
return bytes(b & 0x7F for b in data)
|
||||
return data
|
||||
|
||||
# -- pyserial surface --------------------------------------------------
|
||||
def _pump(self) -> None:
|
||||
now = time.monotonic()
|
||||
if self.protocol in self.CONTINUOUS and now >= self._next_emit:
|
||||
kg, stable = weight_now(self._t0)
|
||||
self._out += self._as_received(frame_for(self.protocol, kg, stable))
|
||||
self._next_emit = now + 0.2
|
||||
if self.protocol in self.ON_PRINT and now >= self._next_emit:
|
||||
kg, _ = weight_now(self._t0)
|
||||
self._out += self._as_received(frame_for(self.protocol, kg, True))
|
||||
self._next_emit = now + self.PRINT_EVERY_S
|
||||
if self.protocol == "mahak_mds" and now >= self._next_boot:
|
||||
self._out += self._as_received(self.BOOT)
|
||||
self._next_boot = now + 60
|
||||
ready = [d for d in self._due if d[0] <= now]
|
||||
self._due = [d for d in self._due if d[0] > now]
|
||||
for _, data in ready:
|
||||
self._out += self._as_received(data)
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
if not (self._baud_ok and self._framing_ok):
|
||||
return len(data)
|
||||
data = bytes(data)
|
||||
request = self.REQUESTS.get(self.protocol)
|
||||
kg, stable = weight_now(self._t0)
|
||||
if self.protocol == "cas_enq":
|
||||
if b"\x05" in data:
|
||||
self._due.append((time.monotonic() + 0.03, b"\x06"))
|
||||
if b"\x11" in data:
|
||||
self._due.append((time.monotonic() + 0.04, frame_for("cas_enq", kg, stable)))
|
||||
elif request and request in data:
|
||||
self._due.append((time.monotonic() + 0.04, frame_for(self.protocol, kg, stable)))
|
||||
return len(data)
|
||||
|
||||
@property
|
||||
def in_waiting(self) -> int:
|
||||
self._pump()
|
||||
return len(self._out)
|
||||
|
||||
def read(self, n: int = 1) -> bytes:
|
||||
self._pump()
|
||||
if not self._out:
|
||||
time.sleep(0.02)
|
||||
self._pump()
|
||||
chunk = bytes(self._out[:n])
|
||||
del self._out[:n]
|
||||
return chunk
|
||||
|
||||
def reset_input_buffer(self) -> None:
|
||||
self._out.clear()
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_open = False
|
||||
|
||||
|
||||
class SimulatorBank:
|
||||
"""Fake ports COM90, COM91 …; every other port opens for real."""
|
||||
|
||||
def __init__(self, spec: str):
|
||||
self.devices: dict[str, tuple[str, int, str]] = {}
|
||||
for i, item in enumerate(filter(None, (s.strip() for s in spec.split(",")))):
|
||||
parts = item.split(":")
|
||||
protocol = parts[0]
|
||||
baud = int(parts[1]) if len(parts) > 1 and parts[1] else 9600
|
||||
framing = (parts[2] if len(parts) > 2 and parts[2] else "8N1").upper()
|
||||
self.devices[f"COM{FIRST_PORT + i}"] = (protocol, baud, framing)
|
||||
|
||||
def opener(self, fallback: Optional[Opener] = None) -> Opener:
|
||||
real = fallback or open_real
|
||||
|
||||
def _open(settings: LineSettings):
|
||||
device = self.devices.get(settings.port.upper())
|
||||
if device is None:
|
||||
return real(settings)
|
||||
return SimulatedDevice(device[0], device[1], device[2], settings)
|
||||
return _open
|
||||
|
||||
def ports(self) -> list[dict]:
|
||||
return [{"device": port, "description": f"Simulated scale ({proto}, {baud} {framing})",
|
||||
"hwid": "SIMULATED", "vid": None, "pid": None, "manufacturer": "simulator",
|
||||
"chip": None, "note": "", "kind": "simulated"}
|
||||
for port, (proto, baud, framing) in self.devices.items()]
|
||||
+864
@@ -0,0 +1,864 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
# Additional terms under GPLv3 section 7(b): the author attribution shown in
|
||||
# this user interface must be kept (see NOTICE.md).
|
||||
"""The user interface: one self-contained page, English and Persian."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import ABOUT
|
||||
|
||||
ICON = ("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E"
|
||||
"%3Crect width='64' height='64' rx='14' fill='%230f766e'/%3E"
|
||||
"%3Crect x='11' y='13' width='42' height='6' rx='2.5' fill='%23fff'/%3E"
|
||||
"%3Crect x='29' y='19' width='6' height='9' fill='%23fff'/%3E"
|
||||
"%3Cpath d='M18 27h28l5 24H13z' fill='%23fff'/%3E"
|
||||
"%3Crect x='23' y='34' width='18' height='8' rx='2' fill='%2308403c'/%3E"
|
||||
"%3Crect x='26' y='37' width='12' height='2' rx='1' fill='%23fff'/%3E%3C/svg%3E")
|
||||
|
||||
_STYLE = r"""
|
||||
:root{--bg:#f4f6f8;--card:#fff;--ink:#16202a;--muted:#5b6b7a;--line:#dde3e9;--brand:#0f766e;
|
||||
--brand-ink:#fff;--soft:#e6f4f2;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--mono:Consolas,"Cascadia Mono",monospace;
|
||||
--shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08)}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0e1419;--card:#161e25;--ink:#e6edf3;--muted:#93a4b3;
|
||||
--line:#26323d;--brand:#2dd4bf;--brand-ink:#062b27;--soft:#12302d;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;
|
||||
--shadow:none}}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:14px/1.55 "Segoe UI",Tahoma,system-ui,-apple-system,sans-serif}
|
||||
a{color:var(--brand)}
|
||||
header{background:var(--card);border-bottom:1px solid var(--line)}
|
||||
.bar{max-width:1080px;margin:0 auto;padding:12px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.logo{width:36px;height:36px;border-radius:9px}
|
||||
.title{font-weight:700;font-size:17px}
|
||||
.sub{color:var(--muted);font-size:12.5px}
|
||||
.spacer{flex:1}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;
|
||||
padding:2px 10px;font-size:12px;color:var(--muted);background:var(--card);white-space:nowrap}
|
||||
.pill.brand{border-color:var(--brand);color:var(--brand)}
|
||||
nav{max-width:1080px;margin:0 auto;padding:0 16px;display:flex;gap:4px;overflow-x:auto}
|
||||
nav button{border:0;background:none;color:var(--muted);padding:10px 12px;font:inherit;cursor:pointer;
|
||||
border-bottom:2px solid transparent;white-space:nowrap}
|
||||
nav button.on{color:var(--brand);border-bottom-color:var(--brand);font-weight:600}
|
||||
main{max-width:1080px;margin:0 auto;padding:18px 16px 32px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px;
|
||||
box-shadow:var(--shadow)}
|
||||
h2{font-size:16px;margin:0 0 4px}
|
||||
h3{font-size:14px;margin:14px 0 6px}
|
||||
p{margin:6px 0}
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:12.5px}
|
||||
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.btn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:7px 14px;
|
||||
font:inherit;cursor:pointer}
|
||||
.btn:hover{border-color:var(--brand)}
|
||||
.btn.primary{background:var(--brand);border-color:var(--brand);color:var(--brand-ink);font-weight:600}
|
||||
.btn.danger{color:var(--bad)}
|
||||
.btn:disabled{opacity:.55;cursor:default}
|
||||
input,select{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;
|
||||
padding:6px 9px;min-width:0}
|
||||
input:focus,select:focus{outline:2px solid var(--soft);border-color:var(--brand)}
|
||||
label.f{display:flex;flex-direction:column;gap:3px;font-size:12.5px;color:var(--muted)}
|
||||
.mono,.ltr{direction:ltr;unicode-bidi:isolate}
|
||||
.mono{font-family:var(--mono);font-size:12.5px}
|
||||
.weight{font:600 44px/1.1 var(--mono);direction:ltr;text-align:center;margin:8px 0 2px;letter-spacing:.5px}
|
||||
.weight small{font-size:18px;color:var(--muted);margin-inline-start:6px}
|
||||
.state{display:flex;justify-content:center;gap:8px;align-items:center;font-size:12.5px;color:var(--muted)}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--line);display:inline-block}
|
||||
.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)}
|
||||
.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;font-size:13px}
|
||||
.kv b{font-weight:600;color:var(--muted)}
|
||||
.progress{height:8px;background:var(--line);border-radius:99px;overflow:hidden}
|
||||
.progress>div{height:100%;background:var(--brand);width:0;transition:width .3s}
|
||||
.log{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;max-height:240px;
|
||||
overflow:auto;font-size:12.5px}
|
||||
.log div{padding:1px 0;border-bottom:1px dashed var(--line)}
|
||||
.log .found{color:var(--ok);font-weight:600}.log .bad{color:var(--bad)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{text-align:start;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{color:var(--muted);font-weight:600;font-size:12px}
|
||||
.tag{display:inline-block;padding:0 8px;border-radius:99px;font-size:11.5px;background:var(--soft);color:var(--brand)}
|
||||
.tag.warn{background:rgba(180,83,9,.12);color:var(--warn)}
|
||||
.note{border-inline-start:3px solid var(--brand);background:var(--soft);padding:8px 12px;border-radius:6px;margin:8px 0}
|
||||
.note.warn{border-color:var(--warn);background:rgba(180,83,9,.08)}
|
||||
.steps{margin:0;padding-inline-start:20px}.steps li{margin:6px 0}
|
||||
.empty{text-align:center;padding:28px 12px;color:var(--muted)}
|
||||
.hidden{display:none!important}
|
||||
.check{display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--line)}
|
||||
.check:last-child{border-bottom:0}
|
||||
.wrap{overflow-wrap:anywhere}
|
||||
footer{border-top:1px solid var(--line);background:var(--card)}
|
||||
footer .bar{font-size:12.5px;color:var(--muted);justify-content:center;text-align:center}
|
||||
footer a{text-decoration:none}
|
||||
h3 .num{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--soft);
|
||||
color:var(--brand);font-size:12px;margin-inline-end:8px;vertical-align:1px}
|
||||
html[dir=rtl] .kv>.mono,html[dir=rtl] .kv>.ltr{text-align:right}
|
||||
.toast{position:fixed;inset-inline:0;bottom:18px;display:flex;justify-content:center;pointer-events:none}
|
||||
.toast div{background:var(--ink);color:var(--bg);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s}
|
||||
.toast div.show{opacity:.95}
|
||||
"""
|
||||
|
||||
_BODY = r"""
|
||||
<header>
|
||||
<div class="bar">
|
||||
<img class="logo" src="__ICON__" alt="">
|
||||
<div>
|
||||
<div class="title">SyncPulse Scale Bridge <span class="sub ltr" id="ver"></span></div>
|
||||
<div class="sub" data-t="tagline"></div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill hidden" id="simPill" data-t="simulation"></span>
|
||||
<button class="btn" id="langBtn" type="button"></button>
|
||||
</div>
|
||||
<nav id="tabs">
|
||||
<button data-tab="scales" data-t="tab_scales"></button>
|
||||
<button data-tab="find" data-t="tab_find"></button>
|
||||
<button data-tab="connect" data-t="tab_connect"></button>
|
||||
<button data-tab="manual" data-t="tab_manual"></button>
|
||||
<button data-tab="protocols" data-t="tab_protocols"></button>
|
||||
<button data-tab="about" data-t="tab_about"></button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section id="p-scales"></section>
|
||||
<section id="p-find" class="hidden"></section>
|
||||
<section id="p-connect" class="hidden"></section>
|
||||
<section id="p-manual" class="hidden"></section>
|
||||
<section id="p-protocols" class="hidden"></section>
|
||||
<section id="p-about" class="hidden"></section>
|
||||
</main>
|
||||
<footer><div class="bar" id="sign"></div></footer>
|
||||
<div class="toast"><div id="toast"></div></div>
|
||||
"""
|
||||
|
||||
_SCRIPT = r"""
|
||||
const ABOUT = __ABOUT__;
|
||||
const T = {
|
||||
en:{
|
||||
tagline:'Finds the scale on any COM port, shows the live weight and serves it to your network.',
|
||||
simulation:'Simulation', lang:'فارسی',
|
||||
tab_scales:'Scales', tab_find:'Find my scale', tab_connect:'Network access', tab_manual:'Manual & monitor',
|
||||
tab_protocols:'Protocols', tab_about:'About',
|
||||
no_scales:'No scale saved yet.', no_scales_hint:'Plug the scale into this PC and let the program find it.',
|
||||
find_now:'Find my scale', stable:'stable', moving:'moving', no_data:'no data', stale:'old reading',
|
||||
overload:'overload', delete:'Delete', confirm_delete:'Delete the profile "{name}"?',
|
||||
net:'net', gross:'gross', age:'{ms} ms ago', copied:'Copied', copy:'Copy', address_for:'Address for other systems',
|
||||
find_title:'Find the scale automatically',
|
||||
find_intro:'The program listens to each port at every common speed, then asks the scale in the languages of the major manufacturers. Only read-only requests are sent: it never zeroes, tares or prints.',
|
||||
ports:'COM ports', no_ports:'No COM port on this PC. Plug in the USB-serial adapter and check its driver in Device Manager.',
|
||||
refresh:'Refresh', thorough:'Thorough search (more speeds and framings, slower)',
|
||||
wait_print:'The scale sends only when Print is pressed (e.g. wired to a label printer) — wait for me to press it',
|
||||
listen_only:'Listen only — never send anything to the scale',
|
||||
log_press_print:'{port}: ▶ press PRINT on the scale now — listening at {baud} for {seconds} s',
|
||||
log_print_wrong_speed:'{port}: data at {baud} was not readable — press PRINT again (trying the next speed)',
|
||||
log_print_timeout:'{port}: nothing arrived while waiting for Print',
|
||||
mode_print:'sends when Print is pressed', printed:'printed {ago}', last_labels:'Last printed labels',
|
||||
listening_all_day:'Listening all the time: every printed label is recorded.',
|
||||
ago_s:'{n} s ago', ago_m:'{n} min ago', ago_h:'{n} h ago', no_labels:'No label printed yet.',
|
||||
start:'Start search', cancel:'Cancel', running:'Searching…', results:'Results',
|
||||
found_on:'Scale found on {port}', not_found_on:'No scale recognised on {port}', confidence:'confidence',
|
||||
protocol:'Protocol', line:'Line', mode:'Mode', reading:'Reading', sample:'What arrived',
|
||||
mode_continuous:'sends by itself', mode_poll:'answers a request', save_as:'Save as', label:'Description',
|
||||
save:'Save', saved:'Saved', field_tested:'tested on real hardware',
|
||||
heard_unknown:'Readable data arrived at {baud} {framing} but the format is not in the library yet:',
|
||||
tips_title:'If nothing is found',
|
||||
tip1:'Serial output must be switched on in the indicator\'s menu (often called COM, rS, SEr, Serial, Continuous or On request).',
|
||||
tip2:'Check the cable: many indicators need a null-modem (crossed) cable, some a straight one. Pins 2, 3 and 5 are enough.',
|
||||
tip3:'Put something on the scale or press its Print key while searching — some indicators only send then.',
|
||||
tip4:'Try the thorough search, or use “Manual & monitor” to watch the raw bytes.',
|
||||
ch340:'Driver warning', bt:'Bluetooth — skipped unless ticked',
|
||||
log_port_start:'Searching {port}…', log_listen:'{port}: listening at {baud} {framing}',
|
||||
log_heard:'{port}: {bytes} bytes at {baud} ({readable}% readable)',
|
||||
log_seven_bit:'{port}: looks like 7 data bits with parity — listening again as 7E1/7O1',
|
||||
log_ask:'{port}: asking {protocol} at {baud} {framing}', log_reply:'{port}: reply to {protocol}: {text}',
|
||||
log_found:'✔ {port}: {protocol} at {baud} {framing} — {value} {unit}', log_not_found:'{port}: nothing recognised',
|
||||
log_open_failed:'{port}: cannot open — {error}', log_done:'Finished: {found} scale(s) found',
|
||||
log_error:'Error: {error}',
|
||||
conn_title:'Make the scales on this PC available on the network', conn_intro:'Other systems — your server, an ERP, a script — read the scales on this PC over the network. Four things make that work:',
|
||||
step_address:'The address of this PC', step_key:'The access key', step_firewall:'An open port in Windows Firewall',
|
||||
step_autostart:'Start with Windows (optional, recommended)',
|
||||
port_label:'Port', addresses:'Addresses', key_hint:'Only systems that have this key can read the scales. Keep it private.',
|
||||
show:'Show', hide:'Hide', new_key:'New key', confirm_key:'Make a new key? Systems using the old key will stop working until you enter the new one.',
|
||||
fw_what:'Windows Firewall blocks incoming connections by default, so other machines cannot reach port {port} on this PC until a rule allows it. The button below asks Windows for administrator permission (a UAC prompt) and adds the inbound rule “SyncPulse Scale Bridge” for TCP port {port} on all network types. It also removes any block rule Windows added for this program if its “Allow access?” question was cancelled.',
|
||||
fw_open:'Open port {port} in the firewall', fw_remove:'Remove the rule', fw_is_open:'Port {port} is open in Windows Firewall.',
|
||||
fw_is_closed:'Port {port} is not open yet.', fw_only_from:'Allow only from (server IP, optional)',
|
||||
fw_manual:'To do it by hand, run in an administrator Command Prompt:',
|
||||
fw_not_windows:'Not on Windows — open TCP port {port} in this system\'s firewall.',
|
||||
as_what:'Runs the bridge in the background when the PC starts, before anyone logs in, and restarts it if it stops. Needs administrator permission once.',
|
||||
as_on:'Starts with Windows.', as_off:'Does not start with Windows.', as_other:'A task exists but points to another copy of the program.',
|
||||
as_enable:'Start with Windows', as_disable:'Stop starting with Windows',
|
||||
as_note:'After enabling it, close this window and restart the PC (or keep this window open until then). Opening the program again later shows this page for the background copy.',
|
||||
in_syncpulse:'Then, in the system that needs the weight', in_syncpulse_steps:'Send this request with the key above. The answer is JSON — value, unit, stable, age_ms … (documentation on GitHub):',
|
||||
allowed_title:'Allowed clients', allowed_hint:'Leave empty to accept any machine that has the key, or list the IP addresses of the machines that may read the scales (comma separated).',
|
||||
config_file:'Settings file', cancelled_uac:'Cancelled at the Windows prompt.', failed:'Failed: {error}', done:'Done',
|
||||
manual_title:'Manual settings and raw monitor', manual_intro:'Watch exactly what arrives on a port with the settings you choose. Useful for an unknown indicator, a cable test, or to check a setting.',
|
||||
baud:'Speed (baud)', framing:'Framing', request:'Request to send (hex, empty = only listen)', poll:'Every (ms)',
|
||||
preset:'Fill the request from a protocol', monitor_start:'Start monitor', monitor_stop:'Stop',
|
||||
frames:'Frames', guesses:'Protocols that can read this', none_yet:'Nothing received yet.', open_error:'Port error: {error}',
|
||||
save_manual:'Save these settings as a profile', unit_if_missing:'Unit when the scale sends none', output_unit:'Convert to',
|
||||
as_sent:'as sent', stable_samples:'Equal readings = stable',
|
||||
proto_title:'Protocol library', proto_intro:'Formats the program can read. Tested on real hardware are marked; the others follow the manufacturers\' published formats and are covered by tests with their sample frames. The same data format is used by many other brands.',
|
||||
families:'Used by', request_col:'Request', kind:'Type', reference:'Reference',
|
||||
kind_continuous:'continuous', kind_poll:'on request', kind_handshake:'handshake', kind_print:'on Print key',
|
||||
about_title:'About', about_text:'SyncPulse Scale Bridge connects weighing scales with an RS-232 serial port to computers and networks. Copy the single program file to the PC beside the scale, run it, and it finds the port, the speed and the protocol by itself, shows the live weight and serves it on the local network.',
|
||||
about_platform:'Free software that works with any system through its simple HTTP/JSON interface. Originally developed for the SyncPulse platform.',
|
||||
author:'Author', company:'Company', website:'Website', license:'License', source:'Source code', version:'Version',
|
||||
license_text:'Free software under the GNU General Public License v3 or later, with an additional term (section 7(b)): the author attribution shown in the program must be kept in copies and modified versions.',
|
||||
made_by:'Made by', err_name:'Choose a name: letters, digits, - or _.', already_saved:'saved as {names}'
|
||||
},
|
||||
fa:{
|
||||
tagline:'باسکول را روی هر درگاه COM پیدا میکند، وزن زنده را نشان میدهد و آن را روی شبکه در اختیار میگذارد.',
|
||||
simulation:'شبیهساز', lang:'English',
|
||||
tab_scales:'باسکولها', tab_find:'پیدا کردن باسکول', tab_connect:'دسترسی از شبکه', tab_manual:'تنظیم دستی و مانیتور',
|
||||
tab_protocols:'پروتکلها', tab_about:'درباره',
|
||||
no_scales:'هنوز باسکولی ذخیره نشده است.', no_scales_hint:'باسکول را به این رایانه وصل کنید تا برنامه آن را پیدا کند.',
|
||||
find_now:'پیدا کردن باسکول', stable:'پایدار', moving:'در حال تغییر', no_data:'بدون داده', stale:'خوانش قدیمی',
|
||||
overload:'بیش از ظرفیت', delete:'حذف', confirm_delete:'پروفایل «{name}» حذف شود؟',
|
||||
net:'خالص', gross:'ناخالص', age:'{ms} میلیثانیه پیش', copied:'کپی شد', copy:'کپی', address_for:'نشانی برای سامانههای دیگر',
|
||||
find_title:'پیدا کردن خودکار باسکول',
|
||||
find_intro:'برنامه روی هر درگاه با همهی سرعتهای رایج گوش میدهد و سپس با زبان سازندههای اصلی از باسکول وزن میخواهد. فقط درخواستهای خواندنی فرستاده میشود: هیچوقت صفر، تار یا چاپ نمیکند.',
|
||||
ports:'درگاههای COM', no_ports:'درگاه COM روی این رایانه نیست. مبدل USB به سریال را وصل کنید و درایور آن را در Device Manager بررسی کنید.',
|
||||
refresh:'بازخوانی', thorough:'جستجوی کامل (سرعتها و قالبهای بیشتر، کندتر)',
|
||||
wait_print:'باسکول فقط با زدن کلید چاپ داده میفرستد (مثلاً وصل به لیبلزن) — منتظر بمان تا چاپ بزنم',
|
||||
listen_only:'فقط گوش بده — هیچ چیزی به باسکول فرستاده نشود',
|
||||
log_press_print:'{port}: ▶ همین حالا کلید چاپ باسکول را بزنید — گوش دادن با {baud} تا {seconds} ثانیه',
|
||||
log_print_wrong_speed:'{port}: داده با سرعت {baud} خوانا نبود — دوباره چاپ بزنید (سرعت بعدی)',
|
||||
log_print_timeout:'{port}: در زمان انتظار برای چاپ چیزی نرسید',
|
||||
mode_print:'با زدن کلید چاپ میفرستد', printed:'چاپشده {ago}', last_labels:'آخرین برچسبهای چاپشده',
|
||||
listening_all_day:'همیشه در حال گوش دادن: هر برچسبی که چاپ شود ثبت میشود.',
|
||||
ago_s:'{n} ثانیه پیش', ago_m:'{n} دقیقه پیش', ago_h:'{n} ساعت پیش', no_labels:'هنوز برچسبی چاپ نشده است.',
|
||||
start:'شروع جستجو', cancel:'لغو', running:'در حال جستجو…', results:'نتیجه',
|
||||
found_on:'باسکول روی {port} پیدا شد', not_found_on:'روی {port} باسکولی شناخته نشد', confidence:'اطمینان',
|
||||
protocol:'پروتکل', line:'تنظیم خط', mode:'روش', reading:'خوانش', sample:'دادهی دریافتی',
|
||||
mode_continuous:'خودش پیوسته میفرستد', mode_poll:'به درخواست پاسخ میدهد', save_as:'ذخیره با نام', label:'توضیح',
|
||||
save:'ذخیره', saved:'ذخیره شد', field_tested:'روی دستگاه واقعی آزموده شده',
|
||||
heard_unknown:'با {baud} {framing} دادهی خوانا رسید اما این قالب هنوز در کتابخانه نیست:',
|
||||
tips_title:'اگر چیزی پیدا نشد',
|
||||
tip1:'خروجی سریال باید در منوی نمایشگر روشن باشد (معمولاً با نام COM، rS، SEr، Serial، Continuous یا On request).',
|
||||
tip2:'کابل را بررسی کنید: بسیاری از نمایشگرها کابل نالمودم (ضربدری) و برخی کابل مستقیم میخواهند. پایههای ۲، ۳ و ۵ کافی است.',
|
||||
tip3:'هنگام جستجو چیزی روی باسکول بگذارید یا کلید چاپ را بزنید؛ برخی نمایشگرها فقط در آن لحظه میفرستند.',
|
||||
tip4:'جستجوی کامل را امتحان کنید یا از «تنظیم دستی و مانیتور» بایتهای خام را ببینید.',
|
||||
ch340:'هشدار درایور', bt:'بلوتوث — مگر تیک بخورد، بررسی نمیشود',
|
||||
log_port_start:'جستجو در {port}…', log_listen:'{port}: گوش دادن با {baud} {framing}',
|
||||
log_heard:'{port}: {bytes} بایت با سرعت {baud} ({readable}٪ خوانا)',
|
||||
log_seven_bit:'{port}: بهنظر ۷ بیت داده با پریتی است — دوباره با 7E1/7O1 گوش میدهد',
|
||||
log_ask:'{port}: پرسش {protocol} با {baud} {framing}', log_reply:'{port}: پاسخ به {protocol}: {text}',
|
||||
log_found:'✔ {port}: {protocol} با {baud} {framing} — {value} {unit}', log_not_found:'{port}: چیزی شناخته نشد',
|
||||
log_open_failed:'{port}: باز نمیشود — {error}', log_done:'پایان: {found} باسکول پیدا شد',
|
||||
log_error:'خطا: {error}',
|
||||
conn_title:'در دسترس گذاشتن باسکولهای این رایانه روی شبکه', conn_intro:'سامانههای دیگر — سرور شما، ERP، یک اسکریپت — از راه شبکه باسکولهای این رایانه را میخوانند. برای این کار چهار چیز لازم است:',
|
||||
step_address:'نشانی این رایانه', step_key:'کلید دسترسی', step_firewall:'باز بودن درگاه در فایروال ویندوز',
|
||||
step_autostart:'اجرا همراه ویندوز (اختیاری، پیشنهادی)',
|
||||
port_label:'درگاه', addresses:'نشانیها', key_hint:'فقط سامانههایی که این کلید را دارند میتوانند باسکولها را بخوانند. آن را محرمانه نگه دارید.',
|
||||
show:'نمایش', hide:'پنهان', new_key:'کلید تازه', confirm_key:'کلید تازه ساخته شود؟ سامانههایی که کلید قبلی را دارند تا کلید تازه را وارد نکنید کار نمیکنند.',
|
||||
fw_what:'فایروال ویندوز بهطور پیشفرض اتصالهای ورودی را میبندد؛ بنابراین تا وقتی قانونی اجازه ندهد، رایانههای دیگر به درگاه {port} این رایانه نمیرسند. دکمهی زیر از ویندوز اجازهی مدیر (پنجرهی UAC) میگیرد و قانون ورودی «SyncPulse Scale Bridge» را برای TCP درگاه {port} روی همهی انواع شبکه میسازد. اگر پرسش «Allow access؟» ویندوز لغو شده باشد، قانون مسدودکنندهای را هم که ویندوز برای این برنامه ساخته پاک میکند.',
|
||||
fw_open:'باز کردن درگاه {port} در فایروال', fw_remove:'حذف قانون', fw_is_open:'درگاه {port} در فایروال ویندوز باز است.',
|
||||
fw_is_closed:'درگاه {port} هنوز باز نیست.', fw_only_from:'فقط از این نشانی (IP سرور، اختیاری)',
|
||||
fw_manual:'برای انجام دستی، در Command Prompt با دسترسی مدیر اجرا کنید:',
|
||||
fw_not_windows:'این سیستم ویندوز نیست — درگاه TCP {port} را در فایروال همین سیستم باز کنید.',
|
||||
as_what:'پل را هنگام روشن شدن رایانه، پیش از ورود هر کاربر، در پسزمینه اجرا میکند و اگر بسته شود دوباره راه میاندازد. یکبار اجازهی مدیر لازم دارد.',
|
||||
as_on:'همراه ویندوز اجرا میشود.', as_off:'همراه ویندوز اجرا نمیشود.', as_other:'وظیفهای هست ولی به نسخهی دیگری از برنامه اشاره میکند.',
|
||||
as_enable:'اجرا همراه ویندوز', as_disable:'لغو اجرا همراه ویندوز',
|
||||
as_note:'پس از فعال کردن، این پنجره را ببندید و رایانه را دوباره راهاندازی کنید (یا تا آن زمان این پنجره را باز نگه دارید). اگر بعداً برنامه را باز کنید، همین صفحه برای نسخهی پسزمینه باز میشود.',
|
||||
in_syncpulse:'سپس در سامانهای که وزن را لازم دارد', in_syncpulse_steps:'این درخواست را با کلید بالا بفرستید. پاسخ JSON است — value، unit، stable، age_ms … (مستندات در گیتهاب):',
|
||||
allowed_title:'سامانههای مجاز', allowed_hint:'خالی بگذارید تا هر دستگاهی که کلید را دارد پذیرفته شود، یا IP رایانههایی را که اجازهی خواندن دارند با ویرگول جدا بنویسید.',
|
||||
config_file:'فایل تنظیمات', cancelled_uac:'در پنجرهی ویندوز لغو شد.', failed:'ناموفق: {error}', done:'انجام شد',
|
||||
manual_title:'تنظیم دستی و مانیتور خام', manual_intro:'دقیقاً ببینید با تنظیمی که انتخاب میکنید چه چیزی روی درگاه میرسد. برای نمایشگر ناشناخته، آزمایش کابل یا بررسی یک تنظیم.',
|
||||
baud:'سرعت (baud)', framing:'قالب', request:'درخواست ارسالی (hex؛ خالی = فقط گوش دادن)', poll:'هر (میلیثانیه)',
|
||||
preset:'پر کردن درخواست از روی پروتکل', monitor_start:'شروع مانیتور', monitor_stop:'توقف',
|
||||
frames:'فریمها', guesses:'پروتکلهایی که این داده را میخوانند', none_yet:'هنوز چیزی نرسیده است.', open_error:'خطای درگاه: {error}',
|
||||
save_manual:'ذخیرهی این تنظیم بهعنوان پروفایل', unit_if_missing:'واحد، وقتی باسکول واحد نمیفرستد', output_unit:'تبدیل به',
|
||||
as_sent:'همانطور که میرسد', stable_samples:'تعداد خوانش برابر = پایدار',
|
||||
proto_title:'کتابخانهی پروتکلها', proto_intro:'قالبهایی که برنامه میخواند. موارد آزموده روی دستگاه واقعی علامت دارند؛ بقیه بر پایهی قالبهای منتشرشدهی سازندگاناند و با فریمهای نمونهی همان مستندات آزمایش شدهاند. بسیاری از برندهای دیگر هم از همین قالبها استفاده میکنند.',
|
||||
families:'کاربرد', request_col:'درخواست', kind:'نوع', reference:'مرجع',
|
||||
kind_continuous:'پیوسته', kind_poll:'با درخواست', kind_handshake:'دستدادن', kind_print:'با کلید چاپ',
|
||||
about_title:'درباره', about_text:'SyncPulse Scale Bridge باسکولهای دارای درگاه سریال RS-232 را به رایانه و شبکه وصل میکند. تنها فایل برنامه را روی رایانهی کنار باسکول کپی و اجرا کنید؛ درگاه، سرعت و پروتکل را خودش پیدا میکند، وزن زنده را نشان میدهد و روی شبکهی محلی در اختیار میگذارد.',
|
||||
about_platform:'نرمافزار آزاد است و با رابط سادهی HTTP/JSON با هر سامانهای کار میکند. در اصل برای پلتفرم SyncPulse توسعه داده شد.',
|
||||
author:'سازنده', company:'شرکت', website:'وبسایت', license:'مجوز', source:'کد منبع', version:'نسخه',
|
||||
license_text:'نرمافزار آزاد با مجوز GNU GPL نسخهی ۳ یا بالاتر، با یک شرط افزوده (بند ۷(ب)): نام و مشخصات سازنده که در برنامه نمایش داده میشود باید در همهی نسخهها و نسخههای تغییر یافته حفظ شود.',
|
||||
made_by:'ساختهی', err_name:'یک نام انتخاب کنید: حروف انگلیسی، عدد، - یا _.', already_saved:'ذخیرهشده با نام {names}'
|
||||
}};
|
||||
|
||||
const S = {lang:'en', tab:'scales', state:null, ports:[], protocols:[], job:null, jobSince:0, log:[],
|
||||
weights:{}, inflight:{}, showKey:false, monitor:null, monitorData:null, busy:false, sel:null, draft:{}, shownResults:-1, shownLog:0, shownRunning:false};
|
||||
try { S.lang = localStorage.getItem('sb.lang') || ((navigator.language||'').toLowerCase().startsWith('fa') ? 'fa' : 'en'); } catch (e) {}
|
||||
|
||||
const $ = (sel, el = document) => el.querySelector(sel);
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function t(key, vars) {
|
||||
let s = (T[S.lang] && T[S.lang][key]) ?? T.en[key] ?? key;
|
||||
if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? '');
|
||||
return s;
|
||||
}
|
||||
function toast(msg) {
|
||||
const el = $('#toast'); el.textContent = msg; el.classList.add('show');
|
||||
clearTimeout(toast.h); toast.h = setTimeout(() => el.classList.remove('show'), 1800);
|
||||
}
|
||||
async function api(path, opts = {}) {
|
||||
const headers = {'X-Requested-With': 'ScaleBridge'};
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(path, {method: opts.method || (opts.body !== undefined ? 'POST' : 'GET'), headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, cache: 'no-store'});
|
||||
try { data = await res.json(); } catch (e) { data = {ok: false, error: 'HTTP ' + res.status}; }
|
||||
if (!res.ok) data.ok = false;
|
||||
} catch (e) { data = {ok: false, error: String(e.message || e)}; }
|
||||
return data;
|
||||
}
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); } catch (e) {
|
||||
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); } catch (e2) {} ta.remove();
|
||||
}
|
||||
toast(t('copied'));
|
||||
}
|
||||
function fmtValue(v) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
const n = Number(v); if (!isFinite(n)) return esc(v);
|
||||
return n.toLocaleString('en-US', {maximumFractionDigits: 6});
|
||||
}
|
||||
function protoName(id) { const p = S.protocols.find(x => x.id === id); return p ? p.name : id; }
|
||||
function slug(s) { return String(s || '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').toLowerCase().slice(0, 40) || 'scale'; }
|
||||
function lineText(serial) { return serial ? `${serial.baudrate} ${serial.bytesize}${serial.parity}${serial.stopbits}` : ''; }
|
||||
|
||||
// ---------------------------------------------------------------- chrome
|
||||
function applyLang() {
|
||||
document.documentElement.lang = S.lang;
|
||||
document.documentElement.dir = S.lang === 'fa' ? 'rtl' : 'ltr';
|
||||
document.querySelectorAll('[data-t]').forEach(el => { el.textContent = t(el.dataset.t); });
|
||||
$('#langBtn').textContent = t('lang');
|
||||
$('#ver').textContent = 'v' + ABOUT.version;
|
||||
$('#sign').innerHTML = `<span>${esc(t('made_by'))} <b>${esc(ABOUT.author)}</b> · ${esc(ABOUT.company)}</span>
|
||||
<a href="${esc(ABOUT.website)}" target="_blank" rel="noopener">${esc(ABOUT.website.replace(/^https?:\/\//, ''))}</a>
|
||||
<a href="${esc(ABOUT.github)}" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="${esc(ABOUT.linkedin)}" target="_blank" rel="noopener">LinkedIn</a>
|
||||
<a href="${esc(ABOUT.repository)}" target="_blank" rel="noopener">${esc(t('source'))}</a>
|
||||
<span>${esc(ABOUT.license)}</span>`;
|
||||
renderAll();
|
||||
}
|
||||
function setTab(tab) {
|
||||
S.tab = tab;
|
||||
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
|
||||
document.querySelectorAll('main > section').forEach(s => s.classList.toggle('hidden', s.id !== 'p-' + tab));
|
||||
if (tab !== 'manual') stopMonitor();
|
||||
if (tab === 'find') loadPorts();
|
||||
if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab);
|
||||
renderAll();
|
||||
}
|
||||
function renderAll(keepFind) {
|
||||
renderScales(); if (keepFind) paintResults(); else renderFind();
|
||||
renderConnect(); renderManual(); renderProtocols(); renderAbout();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scales
|
||||
function renderScales() {
|
||||
const el = $('#p-scales'); if (!S.state) { el.innerHTML = ''; return; }
|
||||
const profiles = S.state.profiles || [];
|
||||
if (!profiles.length) {
|
||||
el.innerHTML = `<div class="card empty"><h2>${esc(t('no_scales'))}</h2><p>${esc(t('no_scales_hint'))}</p>
|
||||
<p><button class="btn primary" data-go="find">${esc(t('find_now'))}</button></p></div>`;
|
||||
return;
|
||||
}
|
||||
const ip = (S.state.addresses || [])[0] || '127.0.0.1';
|
||||
el.innerHTML = '<div class="grid">' + profiles.map(p => `
|
||||
<div class="card" data-profile="${esc(p.name)}">
|
||||
<div class="row"><h2 class="wrap">${esc(p.label || p.name)}</h2><div class="spacer"></div>
|
||||
<span class="tag ltr">${esc(p.port)}</span></div>
|
||||
<div class="weight" id="w-${esc(p.name)}">—</div>
|
||||
<div class="state" id="s-${esc(p.name)}"><span class="dot"></span><span>${esc(t('no_data'))}</span></div>
|
||||
<div class="kv small" id="f-${esc(p.name)}" style="margin-top:8px"></div>
|
||||
${isPrint(p) ? `<p class="small muted" style="margin:8px 0 0">${esc(t('listening_all_day'))}</p>
|
||||
<details style="margin-top:6px"><summary class="small">${esc(t('last_labels'))}</summary><div class="small" id="l-${esc(p.name)}"></div></details>` : ''}
|
||||
<div class="kv small" style="margin-top:10px">
|
||||
<b>${esc(t('protocol'))}</b><span><bdi>${esc(protoName(p.protocol))}</bdi></span>
|
||||
<b>${esc(t('line'))}</b><span class="mono">${esc(lineText(p.serial))}</span>
|
||||
</div>
|
||||
<div class="small muted" style="margin-top:8px">${esc(t('address_for'))}</div>
|
||||
<div class="row" style="gap:6px;flex-wrap:nowrap"><span class="mono wrap ltr" style="flex:1">http://${esc(ip)}:${esc(S.state.listen_port)}/weight?profile=${esc(p.name)}</span>
|
||||
<button class="btn small" style="padding:2px 8px" data-copy="http://${esc(ip)}:${esc(S.state.listen_port)}/weight?profile=${esc(p.name)}">${esc(t('copy'))}</button></div>
|
||||
<div class="row" style="margin-top:10px"><span class="small muted mono">${esc(p.name)}</span><div class="spacer"></div>
|
||||
<button class="btn danger" data-del="${esc(p.name)}">${esc(t('delete'))}</button></div>
|
||||
</div>`).join('') + '</div>';
|
||||
profiles.forEach(p => paintWeight(p.name));
|
||||
}
|
||||
function paintWeight(name) {
|
||||
const w = S.weights[name]; const vEl = document.getElementById('w-' + name); const sEl = document.getElementById('s-' + name);
|
||||
if (!vEl || !sEl) return;
|
||||
if (!w) return;
|
||||
if (!w.ok) {
|
||||
vEl.innerHTML = '—';
|
||||
sEl.innerHTML = `<span class="dot bad"></span><span class="wrap">${esc(w.error || t('no_data'))}</span>`;
|
||||
return;
|
||||
}
|
||||
vEl.innerHTML = w.overload && (w.value === null || w.value === undefined) ? esc(t('overload'))
|
||||
: `${fmtValue(w.value)}<small>${esc(w.unit || '')}</small>`;
|
||||
const fEl = document.getElementById('f-' + name);
|
||||
if (fEl) fEl.innerHTML = Object.entries(w.fields || {}).filter(([k]) => k !== 'barcode')
|
||||
.map(([k, v]) => `<b class="ltr">${esc(k)}</b><span class="mono">${esc(v)}</span>`).join('');
|
||||
let cls = 'warn', label = t('moving');
|
||||
const profile = (S.state.profiles || []).find(p => p.name === name);
|
||||
if (profile && isPrint(profile)) { cls = 'ok'; label = t('printed', {ago: ago(w.age_ms)}); }
|
||||
else if (w.stale) { cls = ''; label = t('stale'); } else if (w.stable) { cls = 'ok'; label = t('stable'); }
|
||||
const extra = w.net === true ? ' · ' + t('net') : (w.net === false ? ' · ' + t('gross') : '');
|
||||
sEl.innerHTML = `<span class="dot ${cls}"></span><span>${esc(label + extra)}</span>`;
|
||||
}
|
||||
function isPrint(p) { const proto = S.protocols.find(x => x.id === p.protocol); return !!(proto && proto.kind === 'print'); }
|
||||
function ago(ms) {
|
||||
const s = Math.max(0, Math.round((ms || 0) / 1000));
|
||||
return s < 90 ? t('ago_s', {n: s}) : (s < 5400 ? t('ago_m', {n: Math.round(s / 60)}) : t('ago_h', {n: Math.round(s / 3600)}));
|
||||
}
|
||||
async function paintLabels(name) {
|
||||
const el = document.getElementById('l-' + name); if (!el) return;
|
||||
const r = await api('/api/labels?profile=' + encodeURIComponent(name) + '&limit=8');
|
||||
if (!r.ok) return;
|
||||
el.innerHTML = r.labels.length ? '<table><tbody>' + r.labels.slice().reverse().map(l => `<tr>
|
||||
<td class="mono">${esc(l.at.slice(11, 19))}</td><td class="mono"><b>${fmtValue(l.value)}</b> ${esc(l.unit)}</td>
|
||||
<td class="small">${esc(Object.entries(l.fields || {}).filter(([k]) => !['barcode', 'date', 'time'].includes(k)).map(([k, v]) => k + ': ' + v).join(' · '))}</td></tr>`).join('') + '</tbody></table>'
|
||||
: `<p class="muted">${esc(t('no_labels'))}</p>`;
|
||||
}
|
||||
async function pollWeights() {
|
||||
if (S.tab === 'scales' && S.state && !document.hidden) {
|
||||
for (const p of S.state.profiles || []) {
|
||||
if (S.inflight[p.name]) continue;
|
||||
S.inflight[p.name] = true;
|
||||
// A print-only scale is silent between labels: show the last label, however old.
|
||||
const extra = isPrint(p) ? '&wait_ms=200&max_age_ms=60000000' : '&wait_ms=1500';
|
||||
api('/api/weight?profile=' + encodeURIComponent(p.name) + extra).then(r => {
|
||||
S.weights[p.name] = r; paintWeight(p.name);
|
||||
if (isPrint(p)) return paintLabels(p.name);
|
||||
}).finally(() => { S.inflight[p.name] = false; });
|
||||
}
|
||||
}
|
||||
setTimeout(pollWeights, 700);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- find
|
||||
async function loadPorts() {
|
||||
const r = await api('/api/ports');
|
||||
if (r.ok) { S.ports = r.ports; renderFind(); }
|
||||
}
|
||||
function renderFind() {
|
||||
const el = $('#p-find'); if (!S.state) return;
|
||||
const job = S.job; const running = job && job.running;
|
||||
const checked = S.sel || new Set(S.ports.filter(p => p.kind !== 'bluetooth').map(p => p.device));
|
||||
const thorough = $('#thorough') ? $('#thorough').checked : false;
|
||||
const waitPrint = $('#waitPrint') ? $('#waitPrint').checked : false;
|
||||
const listenOnly = $('#listenOnly') ? $('#listenOnly').checked : false;
|
||||
const ports = S.ports.length ? S.ports.map(p => `
|
||||
<label class="check"><input type="checkbox" name="port" value="${esc(p.device)}" ${checked.has(p.device) ? 'checked' : ''} ${running ? 'disabled' : ''}>
|
||||
<span><b class="ltr">${esc(p.device)}</b> <span class="muted">${esc(p.description || '')}</span>
|
||||
${p.kind === 'bluetooth' ? `<br><span class="tag warn">${esc(t('bt'))}</span>` : ''}
|
||||
${p.note ? `<br><span class="tag warn">${esc(t('ch340'))}</span> <span class="small muted">${esc(p.note)}</span>` : ''}</span></label>`).join('')
|
||||
: `<p class="note warn">${esc(t('no_ports'))}</p>`;
|
||||
el.innerHTML = `
|
||||
<div class="card"><h2>${esc(t('find_title'))}</h2><p class="muted">${esc(t('find_intro'))}</p>
|
||||
<h3>${esc(t('ports'))}</h3>${ports}
|
||||
<div class="row" style="margin-top:10px">
|
||||
<label class="row small"><input type="checkbox" id="thorough" ${thorough ? 'checked' : ''} ${running ? 'disabled' : ''}> ${esc(t('thorough'))}</label>
|
||||
<label class="row small"><input type="checkbox" id="waitPrint" ${waitPrint ? 'checked' : ''} ${running ? 'disabled' : ''}> ${esc(t('wait_print'))}</label>
|
||||
<label class="row small"><input type="checkbox" id="listenOnly" ${listenOnly ? 'checked' : ''} ${running ? 'disabled' : ''}> ${esc(t('listen_only'))}</label>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" id="refreshPorts" ${running ? 'disabled' : ''}>${esc(t('refresh'))}</button>
|
||||
${running ? `<button class="btn danger" id="cancelDetect">${esc(t('cancel'))}</button>`
|
||||
: `<button class="btn primary" id="startDetect" ${S.ports.length ? '' : 'disabled'}>${esc(t('start'))}</button>`}
|
||||
</div>
|
||||
<div id="findStatus"></div>
|
||||
</div>
|
||||
<div id="findResults"></div>`;
|
||||
S.shownResults = -1; S.shownRunning = running;
|
||||
paintProgress(); paintResults();
|
||||
}
|
||||
function logLine(e) {
|
||||
const cls = e.code === 'found' ? 'found' : (e.code === 'open_failed' || e.code === 'error' ? 'bad' : '');
|
||||
const vars = {...e, protocol: e.protocol ? protoName(e.protocol) : '', readable: e.readable !== undefined ? Math.round(e.readable * 100) : ''};
|
||||
return `<div class="${cls}"><span class="muted mono">${esc(Number(e.t).toFixed(1))}s</span> ${esc(t('log_' + e.code, vars))}</div>`;
|
||||
}
|
||||
function paintProgress() {
|
||||
const box = $('#findStatus'); const job = S.job; if (!box) return;
|
||||
if (!job) { box.innerHTML = ''; return; }
|
||||
const pct = Math.round((job.progress || 0) * 100);
|
||||
if (!$('#log')) {
|
||||
box.innerHTML = `<div style="margin-top:12px"><div class="row small muted"><span id="jobText"></span><div class="spacer"></div><span class="mono" id="jobPct"></span></div>
|
||||
<div class="progress"><div id="jobBar"></div></div><div class="log" id="log" style="margin-top:8px"></div></div>`;
|
||||
S.shownLog = 0;
|
||||
}
|
||||
$('#jobText').textContent = job.running ? t('running') : t('done');
|
||||
$('#jobPct').textContent = `${pct}% · ${job.elapsed_s}s`;
|
||||
$('#jobBar').style.width = pct + '%';
|
||||
const logEl = $('#log');
|
||||
const start = Math.max(S.shownLog || 0, S.log.length - 250);
|
||||
if (start < S.log.length) {
|
||||
logEl.insertAdjacentHTML('beforeend', S.log.slice(start).map(logLine).join(''));
|
||||
S.shownLog = S.log.length;
|
||||
while (logEl.childElementCount > 250) logEl.firstElementChild.remove();
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
function paintResults() {
|
||||
const box = $('#findResults'); const job = S.job; if (!box) return;
|
||||
const results = job ? (job.results || []) : [];
|
||||
const key = results.length + ':' + (job && job.running ? 1 : 0) + ':' + (S.state.profiles || []).length;
|
||||
if (S.shownResults === key) return;
|
||||
S.shownResults = key;
|
||||
box.innerHTML = (results.length ? `<h3>${esc(t('results'))}</h3>${results.map(renderResult).join('')}` : '')
|
||||
+ (job && !job.running && !results.some(r => r.found) ? tipsCard() : '');
|
||||
}
|
||||
function tipsCard() {
|
||||
return `<div class="card"><h3>${esc(t('tips_title'))}</h3><ul class="steps">
|
||||
<li>${esc(t('tip1'))}</li><li>${esc(t('tip2'))}</li><li>${esc(t('tip3'))}</li><li>${esc(t('tip4'))}</li></ul></div>`;
|
||||
}
|
||||
function renderResult(r) {
|
||||
if (!r.found) {
|
||||
const u = r.unknown;
|
||||
return `<div class="card"><h2>${esc(t('not_found_on', {port: r.port}))}</h2>
|
||||
${r.error ? `<p class="note warn">${esc(r.error)}</p>` : ''}
|
||||
${u ? `<p class="muted">${esc(t('heard_unknown', {baud: u.baud, framing: u.framing}))}</p>
|
||||
<div class="log mono">${esc(u.sample_text)}</div><div class="log mono small" style="margin-top:6px">${esc(u.sample_hex)}</div>` : ''}</div>`;
|
||||
}
|
||||
const rd = r.reading || {};
|
||||
const d = S.draft[r.port] || {};
|
||||
const same = (S.state.profiles || []).filter(p => p.port === r.port && p.protocol === r.protocol).map(p => p.name);
|
||||
return `<div class="card">
|
||||
<div class="row"><h2>${esc(t('found_on', {port: r.port}))}</h2><div class="spacer"></div>
|
||||
<span class="tag">${esc(t('confidence'))} ${Math.round((r.confidence || 0) * 100)}%</span>
|
||||
${r.field_tested ? `<span class="tag">${esc(t('field_tested'))}</span>` : ''}</div>
|
||||
<div class="kv" style="margin-top:8px">
|
||||
<b>${esc(t('protocol'))}</b><span><bdi>${esc(r.protocol_name)}</bdi></span>
|
||||
<b>${esc(t('line'))}</b><span class="mono">${esc(r.port)} · ${esc(lineText(r.serial))}${r.exchange && r.exchange.request_hex ? ' · ' + esc(r.exchange.request_hex) : ''}</span>
|
||||
<b>${esc(t('mode'))}</b><span>${esc(t('mode_' + r.mode))}</span>
|
||||
<b>${esc(t('reading'))}</b><span class="mono">${fmtValue(rd.value)} ${esc(rd.unit || '')}${rd.stable === true ? ' · ' + esc(t('stable')) : (rd.stable === false ? ' · ' + esc(t('moving')) : '')}</span>
|
||||
<b>${esc(t('sample'))}</b><span class="mono wrap">${esc(r.sample_text)}</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<label class="f">${esc(t('save_as'))}<input class="ltr" data-name="${esc(r.port)}" value="${esc(d.name ?? slug('scale-' + r.port))}" size="18"></label>
|
||||
<label class="f">${esc(t('label'))}<input data-label="${esc(r.port)}" value="${esc(d.label ?? '')}" size="22"></label>
|
||||
${rd.unit ? '' : `<label class="f">${esc(t('unit_if_missing'))}<select data-unit="${esc(r.port)}">${['kg','g','t','lb'].map(u => `<option ${d.unit === u ? 'selected' : ''}>${u}</option>`).join('')}</select></label>`}
|
||||
<div class="spacer"></div>
|
||||
${same.length ? `<span class="tag">${esc(t('already_saved', {names: same.join(', ')}))}</span>` : ''}
|
||||
<button class="btn primary" data-save-result="${esc(r.port)}">${esc(t('save'))}</button>
|
||||
</div></div>`;
|
||||
}
|
||||
async function startDetect() {
|
||||
const ports = [...document.querySelectorAll('input[name=port]:checked')].map(i => i.value);
|
||||
if (!ports.length) return;
|
||||
S.log = []; S.jobSince = 0; S.draft = {};
|
||||
const r = await api('/api/detect', {body: {ports, thorough: $('#thorough').checked, bluetooth: true,
|
||||
print_wait_s: $('#waitPrint').checked ? 60 : 0, listen_only: $('#listenOnly').checked}});
|
||||
if (!r.ok) { toast(t('failed', {error: r.error})); return; }
|
||||
S.job = r.job; renderFind(); setTimeout(pollDetect, 300);
|
||||
}
|
||||
async function pollDetect() {
|
||||
const r = await api('/api/detect?since=' + S.jobSince);
|
||||
if (r.ok && r.job) {
|
||||
S.log.push(...r.job.log); S.jobSince = r.job.log_size; S.job = r.job;
|
||||
if (S.shownRunning !== r.job.running) renderFind(); else { paintProgress(); paintResults(); }
|
||||
if (r.job.running) { setTimeout(pollDetect, 500); } else { refreshState(); }
|
||||
}
|
||||
}
|
||||
async function saveResult(port) {
|
||||
const r = (S.job.results || []).find(x => x.port === port); if (!r) return;
|
||||
const name = document.querySelector(`[data-name="${CSS.escape(port)}"]`).value.trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(name)) { toast(t('err_name')); return; }
|
||||
const label = document.querySelector(`[data-label="${CSS.escape(port)}"]`).value.trim();
|
||||
const unitEl = document.querySelector(`[data-unit="${CSS.escape(port)}"]`);
|
||||
const res = await api('/api/profiles', {body: {detection: r, name, label, unit: unitEl ? unitEl.value : ''}});
|
||||
if (!res.ok) { toast(t('failed', {error: res.error})); return; }
|
||||
S.state.profiles = res.profiles; toast(t('saved')); renderAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- connect
|
||||
function renderConnect() {
|
||||
const el = $('#p-connect'); const st = S.state; if (!st) return;
|
||||
const port = st.listen_port; const fw = st.firewall || {}; const as = st.autostart || {};
|
||||
const key = S.showKey ? st.token : '•'.repeat(Math.min(24, (st.token || '').length));
|
||||
const addr = (st.addresses || []).map(a => `<span class="tag ltr">${esc(a)}</span>`).join(' ') || '<span class="muted">—</span>';
|
||||
const fwCmd = `netsh advfirewall firewall add rule name="SyncPulse Scale Bridge" dir=in action=allow protocol=TCP localport=${port} profile=any`;
|
||||
el.innerHTML = `
|
||||
<div class="card"><h2>${esc(t('conn_title'))}</h2><p class="muted">${esc(t('conn_intro'))}</p></div>
|
||||
<div class="card"><h3><span class="num">1</span>${esc(t('step_address'))}</h3>
|
||||
<div class="kv"><b>${esc(t('addresses'))}</b><span>${addr}</span><b>${esc(t('port_label'))}</b><span class="mono">${esc(port)}</span>
|
||||
<b>Host</b><span class="mono">${esc(st.host)}</span></div></div>
|
||||
<div class="card"><h3><span class="num">2</span>${esc(t('step_key'))}</h3>
|
||||
<div class="row"><span class="mono wrap" style="font-size:14px">${esc(key)}</span><div class="spacer"></div>
|
||||
<button class="btn" id="keyShow">${esc(S.showKey ? t('hide') : t('show'))}</button>
|
||||
<button class="btn" id="keyCopy">${esc(t('copy'))}</button>
|
||||
<button class="btn danger" id="keyNew">${esc(t('new_key'))}</button></div>
|
||||
<p class="small muted">${esc(t('key_hint'))}</p></div>
|
||||
<div class="card"><h3><span class="num">3</span>${esc(t('step_firewall'))}</h3>
|
||||
${fw.supported === false ? `<p>${esc(t('fw_not_windows', {port}))}</p>` : `
|
||||
<p class="${fw.port_ok ? 'note' : 'note warn'}"><span class="dot ${fw.port_ok ? 'ok' : 'warn'}"></span> ${esc(fw.port_ok ? t('fw_is_open', {port}) : t('fw_is_closed', {port}))}</p>
|
||||
<p class="small">${esc(t('fw_what', {port}))}</p>
|
||||
<div class="row"><label class="f">${esc(t('fw_only_from'))}<input class="ltr" id="fwRemote" placeholder="192.168.1.10" size="16"></label>
|
||||
<div class="spacer"></div>
|
||||
${fw.exists ? `<button class="btn" id="fwRemove">${esc(t('fw_remove'))}</button>` : ''}
|
||||
<button class="btn primary" id="fwOpen">${esc(t('fw_open', {port}))}</button></div>
|
||||
<p class="small muted" style="margin-top:10px">${esc(t('fw_manual'))}</p><div class="log mono small">${esc(fwCmd)}</div>`}
|
||||
</div>
|
||||
<div class="card"><h3><span class="num">4</span>${esc(t('step_autostart'))}</h3>
|
||||
${as.supported === false ? '' : `
|
||||
<p class="${as.enabled && as.this_program ? 'note' : 'note warn'}"><span class="dot ${as.enabled ? (as.this_program ? 'ok' : 'warn') : ''}"></span>
|
||||
${esc(as.enabled ? (as.this_program ? t('as_on') : t('as_other')) : t('as_off'))}</p>
|
||||
<p class="small">${esc(t('as_what'))}</p>
|
||||
<div class="row"><div class="spacer"></div>
|
||||
${as.enabled ? `<button class="btn" id="asOff">${esc(t('as_disable'))}</button>` : ''}
|
||||
${!as.enabled || !as.this_program ? `<button class="btn primary" id="asOn">${esc(t('as_enable'))}</button>` : ''}</div>
|
||||
<p class="small muted">${esc(t('as_note'))}</p>`}
|
||||
</div>
|
||||
<div class="card"><h3>${esc(t('in_syncpulse'))}</h3><p>${esc(t('in_syncpulse_steps', {port}))}</p>
|
||||
<div class="log mono ltr" style="text-align:left">GET http://${esc((st.addresses || [])[0] || '127.0.0.1')}:${esc(port)}/weight?profile=<name><br>X-Bridge-Token: <key></div></div>
|
||||
<div class="card"><h3>${esc(t('allowed_title'))}</h3><p class="small muted">${esc(t('allowed_hint'))}</p>
|
||||
<div class="row"><input class="ltr" id="allowed" style="flex:1" value="${esc((st.allowed_clients || []).join(', '))}">
|
||||
<button class="btn" id="allowedSave">${esc(t('save'))}</button></div>
|
||||
<p class="small muted" style="margin-top:10px">${esc(t('config_file'))}: <span class="mono wrap">${esc(st.config_path)}</span></p></div>`;
|
||||
}
|
||||
async function adminAction(path, body) {
|
||||
S.busy = true; document.body.style.cursor = 'progress';
|
||||
const r = await api(path, {body});
|
||||
S.busy = false; document.body.style.cursor = '';
|
||||
if (r.state) S.state = r.state;
|
||||
toast(r.ok ? t('done') : (r.cancelled ? t('cancelled_uac') : t('failed', {error: r.error || ('code ' + r.code)})));
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- manual
|
||||
function renderManual() {
|
||||
const el = $('#p-manual'); if (!S.state) return;
|
||||
if (el.dataset.ready === S.lang + S.ports.length + S.protocols.length) { paintMonitor(); return; }
|
||||
const keep = {};
|
||||
el.querySelectorAll('[id^=m-]').forEach(i => { keep[i.id] = i.value; });
|
||||
const bauds = [1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200];
|
||||
const framings = ['8N1', '7E1', '7O1', '8E1', '8O1', '8N2', '7N2'];
|
||||
el.innerHTML = `
|
||||
<div class="card"><h2>${esc(t('manual_title'))}</h2><p class="muted">${esc(t('manual_intro'))}</p>
|
||||
<div class="row">
|
||||
<label class="f">${esc(t('port_label'))}<select id="m-port">${S.ports.map(p => `<option value="${esc(p.device)}">${esc(p.device)}</option>`).join('')}</select></label>
|
||||
<label class="f">${esc(t('baud'))}<select id="m-baud">${bauds.map(b => `<option ${b === 9600 ? 'selected' : ''}>${b}</option>`).join('')}</select></label>
|
||||
<label class="f">${esc(t('framing'))}<select id="m-framing">${framings.map(f => `<option>${f}</option>`).join('')}</select></label>
|
||||
<label class="f">${esc(t('preset'))}<select id="m-proto"><option value="">—</option>${S.protocols.filter(p => !p.id.startsWith('custom')).map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')}</select></label>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<label class="f" style="flex:1">${esc(t('request'))}<input class="ltr mono" id="m-request" placeholder="53 49 0D 0A"></label>
|
||||
<label class="f">${esc(t('poll'))}<input class="ltr" id="m-poll" type="number" min="100" max="10000" step="100" value="500" style="width:90px"></label>
|
||||
<button class="btn primary" id="monBtn"></button>
|
||||
</div>
|
||||
<div id="monOut" style="margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="card"><h3>${esc(t('save_manual'))}</h3>
|
||||
<div class="row">
|
||||
<label class="f">${esc(t('save_as'))}<input class="ltr" id="m-name" value="scale-1" size="16"></label>
|
||||
<label class="f">${esc(t('label'))}<input id="m-label" size="20"></label>
|
||||
<label class="f">${esc(t('protocol'))}<select id="m-sproto">${S.protocols.map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')}</select></label>
|
||||
<label class="f">${esc(t('unit_if_missing'))}<select id="m-unit"><option value="">—</option>${['kg','g','t','lb','oz'].map(u => `<option>${u}</option>`).join('')}</select></label>
|
||||
<label class="f">${esc(t('output_unit'))}<select id="m-ounit"><option value="">${esc(t('as_sent'))}</option>${['kg','g','t','lb','oz'].map(u => `<option>${u}</option>`).join('')}</select></label>
|
||||
<label class="f">${esc(t('stable_samples'))}<input class="ltr" id="m-stable" type="number" min="1" max="20" value="3" style="width:70px"></label>
|
||||
</div>
|
||||
<div class="row" id="m-custom" style="margin-top:8px"></div>
|
||||
<div class="row" style="margin-top:8px"><div class="spacer"></div><button class="btn primary" id="m-save">${esc(t('save'))}</button></div>
|
||||
</div>`;
|
||||
Object.entries(keep).forEach(([id, v]) => { const i = document.getElementById(id); if (i && v !== undefined) i.value = v; });
|
||||
el.dataset.ready = S.lang + S.ports.length + S.protocols.length;
|
||||
renderCustom(); paintMonitor();
|
||||
}
|
||||
function renderCustom() {
|
||||
const box = $('#m-custom'); if (!box) return;
|
||||
const id = $('#m-sproto').value;
|
||||
if (id === 'custom_binary') {
|
||||
box.innerHTML = `<label class="f">pattern (hex, ?? = any)<input class="ltr mono" id="c-pattern" value="58 ?? 07 07"></label>
|
||||
<label class="f">value_offset<input class="ltr" id="c-offset" type="number" value="4" style="width:70px"></label>
|
||||
<label class="f">value_length<input class="ltr" id="c-length" type="number" value="3" style="width:70px"></label>
|
||||
<label class="f">byte_order<select id="c-order"><option>big</option><option>little</option></select></label>
|
||||
<label class="row small"><input type="checkbox" id="c-signed"> signed</label>`;
|
||||
} else if (id === 'custom_ascii') {
|
||||
box.innerHTML = `<label class="f" style="flex:1">regex (group 1 = number)<input class="ltr mono" id="c-regex" value="([-+]?\\d+\\.?\\d*)"></label>`;
|
||||
} else box.innerHTML = '';
|
||||
}
|
||||
function monitorQuery() {
|
||||
const q = new URLSearchParams({port: $('#m-port').value, baud: $('#m-baud').value, framing: $('#m-framing').value,
|
||||
request_hex: $('#m-request').value, poll_ms: $('#m-poll').value || '500'});
|
||||
const proto = S.protocols.find(p => p.id === $('#m-proto').value);
|
||||
if (proto && proto.after_ack_hex) q.set('after_ack_hex', proto.after_ack_hex);
|
||||
return q;
|
||||
}
|
||||
function startMonitor() {
|
||||
stopMonitor(); S.monitorData = null;
|
||||
const tick = async () => {
|
||||
const r = await api('/api/monitor?' + monitorQuery());
|
||||
if (!S.monitor) return;
|
||||
S.monitorData = r; paintMonitor();
|
||||
S.monitor = setTimeout(tick, 1000);
|
||||
};
|
||||
S.monitor = setTimeout(tick, 0); paintMonitor();
|
||||
}
|
||||
function stopMonitor() { if (S.monitor) { clearTimeout(S.monitor); S.monitor = null; paintMonitor(); } }
|
||||
function paintMonitor() {
|
||||
const btn = $('#monBtn'); const out = $('#monOut'); if (!btn || !out) return;
|
||||
btn.textContent = S.monitor ? t('monitor_stop') : t('monitor_start');
|
||||
const r = S.monitorData; if (!r) { out.innerHTML = ''; return; }
|
||||
if (!r.ok && r.error) { out.innerHTML = `<p class="note warn">${esc(t('open_error', {error: r.error}))}</p>`; return; }
|
||||
const frames = (r.frames || []).slice().reverse();
|
||||
out.innerHTML = `<div class="small muted mono">${esc(r.settings || '')} · rx ${esc((r.stats || {}).bytes_rx ?? 0)} B</div>
|
||||
${frames.length ? `<table style="margin-top:6px"><thead><tr><th>ms</th><th>HEX</th><th>TEXT</th></tr></thead><tbody>
|
||||
${frames.map(f => `<tr><td class="mono">${esc(f.age_ms)}</td><td class="mono wrap">${esc(f.hex)}</td><td class="mono wrap">${esc(f.text)}</td></tr>`).join('')}</tbody></table>`
|
||||
: `<p class="muted">${esc(t('none_yet'))}</p>`}
|
||||
${(r.guesses || []).length ? `<h3>${esc(t('guesses'))}</h3><table><tbody>${r.guesses.map(g => `<tr><td>${esc(g.name)}</td><td class="mono">${fmtValue(g.value)} ${esc(g.unit || '')}</td><td class="muted">×${esc(g.count)}</td></tr>`).join('')}</tbody></table>` : ''}`;
|
||||
}
|
||||
async function saveManual() {
|
||||
const name = $('#m-name').value.trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(name)) { toast(t('err_name')); return; }
|
||||
const framing = $('#m-framing').value; const protocol = $('#m-sproto').value;
|
||||
const params = {};
|
||||
if (protocol === 'custom_binary') Object.assign(params, {pattern: $('#c-pattern').value, value_offset: +$('#c-offset').value,
|
||||
value_length: +$('#c-length').value, byte_order: $('#c-order').value, signed: $('#c-signed').checked});
|
||||
if (protocol === 'custom_ascii') params.regex = $('#c-regex').value;
|
||||
const proto = S.protocols.find(p => p.id === protocol) || {};
|
||||
const request = $('#m-request').value.trim();
|
||||
const body = {name, label: $('#m-label').value.trim(), port: $('#m-port').value, protocol, params,
|
||||
serial: {baudrate: +$('#m-baud').value, bytesize: +framing[0], parity: framing[1], stopbits: +framing[2]},
|
||||
exchange: {request_hex: request, after_ack_hex: proto.after_ack_hex || '', poll_interval_ms: +$('#m-poll').value || 500},
|
||||
unit: $('#m-unit').value, output_unit: $('#m-ounit').value, stable_samples: +$('#m-stable').value || 3};
|
||||
stopMonitor();
|
||||
const r = await api('/api/profiles', {body});
|
||||
if (!r.ok) { toast(t('failed', {error: r.error})); return; }
|
||||
S.state.profiles = r.profiles; toast(t('saved')); renderAll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- protocols & about
|
||||
function renderProtocols() {
|
||||
const el = $('#p-protocols');
|
||||
el.innerHTML = `<div class="card"><h2>${esc(t('proto_title'))}</h2><p class="muted">${esc(t('proto_intro'))}</p>
|
||||
<div style="overflow-x:auto"><table><thead><tr><th>${esc(t('protocol'))}</th><th>${esc(t('kind'))}</th><th>${esc(t('request_col'))}</th><th>${esc(t('families'))}</th><th>${esc(t('reference'))}</th></tr></thead><tbody>
|
||||
${S.protocols.map(p => `<tr><td><b><bdi>${esc(p.name)}</bdi></b><br><span class="mono small muted">${esc(p.id)}</span>${p.field_tested ? `<br><span class="tag">${esc(t('field_tested'))}</span>` : ''}</td>
|
||||
<td>${esc(t('kind_' + p.kind))}</td><td class="mono small">${esc(p.request_hex || '—')}${p.after_ack_hex ? ' → ' + esc(p.after_ack_hex) : ''}</td>
|
||||
<td class="small">${esc(p.families)}</td><td class="small muted">${esc(p.reference)}</td></tr>`).join('')}
|
||||
</tbody></table></div></div>`;
|
||||
}
|
||||
function renderAbout() {
|
||||
const el = $('#p-about'); const a = ABOUT;
|
||||
el.innerHTML = `<div class="card"><h2>${esc(t('about_title'))}</h2><p>${esc(t('about_text'))}</p><p>${esc(t('about_platform'))}</p>
|
||||
<div class="kv" style="margin-top:12px">
|
||||
<b>${esc(t('version'))}</b><span class="mono">${esc(a.version)}</span>
|
||||
<b>${esc(t('author'))}</b><span>${esc(a.author)}</span>
|
||||
<b>${esc(t('company'))}</b><span>${esc(a.company)}</span>
|
||||
<b>${esc(t('website'))}</b><a class="ltr" href="${esc(a.website)}" target="_blank" rel="noopener">${esc(a.website)}</a>
|
||||
<b>GitHub</b><a class="ltr" href="${esc(a.github)}" target="_blank" rel="noopener">${esc(a.github)}</a>
|
||||
<b>LinkedIn</b><a class="ltr" href="${esc(a.linkedin)}" target="_blank" rel="noopener">${esc(a.linkedin)}</a>
|
||||
<b>${esc(t('source'))}</b><a class="ltr" href="${esc(a.repository)}" target="_blank" rel="noopener">${esc(a.repository)}</a>
|
||||
<b>${esc(t('license'))}</b><span>${esc(a.license)}</span>
|
||||
</div>
|
||||
<p class="small muted" style="margin-top:12px">${esc(t('license_text'))}</p>
|
||||
<p class="small muted">${esc(a.copyright)}</p></div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- events & boot
|
||||
document.addEventListener('click', async ev => {
|
||||
const b = ev.target.closest('button'); if (!b || b.disabled) return;
|
||||
if (b.dataset.tab) return setTab(b.dataset.tab);
|
||||
if (b.dataset.go) return setTab(b.dataset.go);
|
||||
if (b.id === 'langBtn') { S.lang = S.lang === 'fa' ? 'en' : 'fa'; try { localStorage.setItem('sb.lang', S.lang); } catch (e) {} return applyLang(); }
|
||||
if (b.dataset.del) {
|
||||
if (!confirm(t('confirm_delete', {name: b.dataset.del}))) return;
|
||||
const r = await api('/api/profiles/' + encodeURIComponent(b.dataset.del), {method: 'DELETE'});
|
||||
if (r.ok) { S.state.profiles = r.profiles; delete S.weights[b.dataset.del]; renderAll(); } else toast(t('failed', {error: r.error}));
|
||||
return;
|
||||
}
|
||||
if (b.dataset.saveResult) return saveResult(b.dataset.saveResult);
|
||||
if (b.dataset.copy) return copy(b.dataset.copy);
|
||||
switch (b.id) {
|
||||
case 'startDetect': return startDetect();
|
||||
case 'cancelDetect': await api('/api/detect/cancel', {body: {}}); return;
|
||||
case 'refreshPorts': return loadPorts();
|
||||
case 'keyShow': S.showKey = !S.showKey; return renderConnect();
|
||||
case 'keyCopy': return copy(S.state.token);
|
||||
case 'keyNew':
|
||||
if (!confirm(t('confirm_key'))) return;
|
||||
{ const r = await api('/api/token', {body: {}}); if (r.ok) { S.state.token = r.token; S.showKey = true; renderConnect(); } }
|
||||
return;
|
||||
case 'fwOpen': return adminAction('/api/firewall', {remote_ip: $('#fwRemote').value.trim()});
|
||||
case 'fwRemove': return adminAction('/api/firewall', {remove: true});
|
||||
case 'asOn': return adminAction('/api/autostart', {enable: true});
|
||||
case 'asOff': return adminAction('/api/autostart', {enable: false});
|
||||
case 'allowedSave': {
|
||||
const clients = $('#allowed').value.split(/[,\s]+/).filter(Boolean);
|
||||
const r = await api('/api/allowed', {body: {clients}});
|
||||
if (r.ok) { S.state.allowed_clients = r.allowed_clients; toast(t('saved')); } else toast(t('failed', {error: r.error}));
|
||||
return;
|
||||
}
|
||||
case 'monBtn': return S.monitor ? stopMonitor() : startMonitor();
|
||||
case 'm-save': return saveManual();
|
||||
}
|
||||
});
|
||||
document.addEventListener('input', ev => {
|
||||
const el = ev.target; const port = el.dataset && (el.dataset.name || el.dataset.label || el.dataset.unit);
|
||||
if (!port) return;
|
||||
const d = S.draft[port] = S.draft[port] || {};
|
||||
if (el.dataset.name) d.name = el.value; else if (el.dataset.label) d.label = el.value; else d.unit = el.value;
|
||||
});
|
||||
document.addEventListener('change', ev => {
|
||||
if (ev.target.name === 'port') {
|
||||
S.sel = new Set([...document.querySelectorAll('input[name=port]:checked')].map(i => i.value));
|
||||
}
|
||||
if (ev.target.id === 'm-proto') {
|
||||
const p = S.protocols.find(x => x.id === ev.target.value);
|
||||
if (p) { $('#m-request').value = p.request_hex; const s = $('#m-sproto'); if (s) { s.value = p.id; renderCustom(); } }
|
||||
}
|
||||
if (ev.target.id === 'm-sproto') renderCustom();
|
||||
});
|
||||
async function refreshState(fresh) {
|
||||
const r = await api('/api/state' + (fresh ? '?fresh=1' : ''));
|
||||
if (r.ok) {
|
||||
S.state = r; $('#simPill').classList.toggle('hidden', !r.simulate);
|
||||
const typing = document.activeElement && document.activeElement.closest && document.activeElement.closest('#p-find');
|
||||
renderAll(!!typing);
|
||||
}
|
||||
}
|
||||
async function boot() {
|
||||
const [state, protos, ports, job] = await Promise.all([api('/api/state'), api('/api/protocols'), api('/api/ports'), api('/api/detect')]);
|
||||
if (protos.ok) S.protocols = protos.protocols;
|
||||
if (ports.ok) S.ports = ports.ports;
|
||||
if (state.ok) { S.state = state; $('#simPill').classList.toggle('hidden', !state.simulate); }
|
||||
if (job.ok && job.job) { S.job = job.job; S.log = job.job.log; S.jobSince = job.job.log_size; if (job.job.running) pollDetect(); }
|
||||
applyLang();
|
||||
const tab = location.hash.replace('#', '');
|
||||
setTab(['scales','find','connect','manual','protocols','about'].includes(tab) ? tab : ((state.profiles || []).length ? 'scales' : 'find'));
|
||||
pollWeights();
|
||||
}
|
||||
boot();
|
||||
"""
|
||||
|
||||
_REMOTE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>SyncPulse Scale Bridge</title>
|
||||
<link rel="icon" href="__ICON__"><style>body{font:15px/1.6 "Segoe UI",Tahoma,sans-serif;max-width:640px;margin:40px auto;
|
||||
padding:0 16px;color:#16202a;background:#f4f6f8}.card{background:#fff;border:1px solid #dde3e9;border-radius:12px;padding:20px}
|
||||
a{color:#0f766e}small{color:#5b6b7a}</style></head><body><div class="card">
|
||||
<h2>SyncPulse Scale Bridge __VERSION__</h2>
|
||||
<p>This bridge serves the scales connected to this PC to the network (JSON over HTTP, see
|
||||
<a href="__REPO__">the documentation</a>). Its control page opens only on the PC itself:
|
||||
<b>http://127.0.0.1:__PORT__/</b></p>
|
||||
<p dir="rtl">این پل وزن باسکولهای وصل به این رایانه را روی شبکه در اختیار میگذارد. صفحهی مدیریت فقط روی خود همین رایانه باز میشود.</p>
|
||||
<p><small>__AUTHOR__ · <a href="__WEBSITE__">__WEBSITE__</a> · <a href="__GITHUB__">GitHub</a> ·
|
||||
<a href="__LINKEDIN__">LinkedIn</a> · __LICENSE__</small></p></div></body></html>"""
|
||||
|
||||
|
||||
def page() -> str:
|
||||
head = ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<meta name="author" content="' + ABOUT["author"] + ' — ' + ABOUT["company"] + '">'
|
||||
'<title>SyncPulse Scale Bridge</title><link rel="icon" href="' + ICON + '">'
|
||||
'<style>' + _STYLE + '</style></head><body>')
|
||||
about = json.dumps(ABOUT, ensure_ascii=False).replace("</", "<\\/")
|
||||
return (head + _BODY.replace("__ICON__", ICON)
|
||||
+ "<script>" + _SCRIPT.replace("__ABOUT__", about) + "</script></body></html>")
|
||||
|
||||
|
||||
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
|
||||
@@ -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 version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>{_xml_escape(PRODUCT)}: serves the scales on this PC to the network.</Description>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<BootTrigger>
|
||||
<Enabled>true</Enabled>
|
||||
<Delay>PT20S</Delay>
|
||||
</BootTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<UserId>S-1-5-18</UserId>
|
||||
<RunLevel>HighestAvailable</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||||
<Enabled>true</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>6</Priority>
|
||||
<RestartOnFailure>
|
||||
<Interval>PT1M</Interval>
|
||||
<Count>999</Count>
|
||||
</RestartOnFailure>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>{_xml_escape(exe)}</Command>
|
||||
<Arguments>{_xml_escape(arguments)}</Arguments>
|
||||
<WorkingDirectory>{_xml_escape(workdir)}</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
+77
@@ -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())
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user