eea3e319fb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
478 lines
19 KiB
Python
478 lines
19 KiB
Python
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
|
|
|
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
|
|
motivo e l'impronta del computer, scrive l'impronta in
|
|
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
|
|
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
|
|
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
|
|
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
|
|
quattro server, ciascuno nel suo thread:
|
|
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
|
|
backend FastAPI src.backend.main:app, 127.0.0.1:8000
|
|
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
|
|
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
|
|
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
|
|
|
|
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
|
|
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
|
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
|
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
|
|
|
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
|
|
|
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
|
|
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
|
|
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
|
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
|
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ctypes
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import socket
|
|
import sqlite3
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
import webbrowser
|
|
from pathlib import Path
|
|
|
|
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
|
|
|
|
PRODOTTO = "tiemeasureflow"
|
|
TITOLO = "TieMeasureFlow"
|
|
PORTA_WEB = 8780
|
|
PORTA_BACKEND = 8000
|
|
PORTA_VISION = 8100
|
|
PORTA_CAMERA = 8765
|
|
log = logging.getLogger("tmf.avvio")
|
|
|
|
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
|
|
COMPILATO = "__compiled__" in globals()
|
|
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
|
|
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
|
|
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
|
|
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
|
|
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
|
|
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
|
|
|
|
|
|
def _stream_sicuri() -> None:
|
|
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
|
|
# andrebbero in errore; li si sostituisce con il device nullo
|
|
if sys.stdout is None:
|
|
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
|
if sys.stderr is None:
|
|
sys.stderr = open(os.devnull, "w", encoding="utf-8")
|
|
|
|
|
|
def cartella_tielogic() -> Path:
|
|
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
|
|
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
|
|
return Path(base) / "Tielogic"
|
|
|
|
|
|
def cartella_dati() -> Path:
|
|
return cartella_tielogic() / "TieMeasureFlow"
|
|
|
|
|
|
def cartella_programma() -> Path:
|
|
# Cartella dell'exe (P); da sorgente, la radice del repo
|
|
if COMPILATO:
|
|
return Path(sys.argv[0]).resolve().parent
|
|
return RADICE_SORGENTI
|
|
|
|
|
|
def percorso_impronta() -> Path:
|
|
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
|
|
|
|
|
|
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
|
|
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
|
|
if sys.stderr is not None:
|
|
print(testo, file=sys.stderr)
|
|
if senza_finestre or sys.platform != "win32":
|
|
return
|
|
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
|
|
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
|
|
|
|
|
|
# ---------------------------------------------------------------- licenza
|
|
|
|
def _scrivi_impronta(impronta: str) -> None:
|
|
try:
|
|
p = percorso_impronta()
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(impronta + "\n", encoding="utf-8")
|
|
except OSError as e:
|
|
log.warning("impronta non scritta: %s", e)
|
|
|
|
|
|
def controlla_licenza(senza_finestre: bool = False) -> bool:
|
|
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
|
|
try:
|
|
percorso = lic.cerca_licenza(PRODOTTO)
|
|
lic.verifica(percorso, PRODOTTO)
|
|
return True
|
|
except lic.LicenzaNonValida as e:
|
|
motivo = str(e)
|
|
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
|
|
motivo = f"Controllo licenza non riuscito: {e}"
|
|
try:
|
|
impronta = lic.impronta()
|
|
except Exception as e:
|
|
impronta = None
|
|
motivo += f"\nImpronta macchina non disponibile: {e}"
|
|
testo = f"{motivo}\n\n"
|
|
if impronta:
|
|
_scrivi_impronta(impronta)
|
|
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
|
|
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
|
|
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
|
|
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------- configurazione
|
|
|
|
def _leggi_env(percorso: Path) -> dict[str, str]:
|
|
valori: dict[str, str] = {}
|
|
for riga in percorso.read_text(encoding="utf-8").splitlines():
|
|
riga = riga.strip()
|
|
if riga and not riga.startswith("#") and "=" in riga:
|
|
k, v = riga.split("=", 1)
|
|
valori[k.strip()] = v.strip()
|
|
return valori
|
|
|
|
|
|
def carica_segreti(dati: Path) -> dict[str, str]:
|
|
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
|
|
dati.mkdir(parents=True, exist_ok=True)
|
|
percorso = dati / "config.env"
|
|
valori = _leggi_env(percorso) if percorso.exists() else {}
|
|
nuovi = [k for k in SEGRETI if not valori.get(k)]
|
|
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
|
|
mancanti = [k for k in PREDEFINITI if k not in valori]
|
|
if nuovi or mancanti:
|
|
for k in nuovi:
|
|
valori[k] = secrets.token_urlsafe(32)
|
|
for k in mancanti:
|
|
valori[k] = PREDEFINITI[k]
|
|
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
|
|
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
|
|
righe += [f"{k}={v}" for k, v in valori.items()]
|
|
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
|
|
log.info("segreti generati in %s", percorso)
|
|
return valori
|
|
|
|
|
|
def trova_browser() -> str | None:
|
|
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
|
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
|
local = os.environ.get("LOCALAPPDATA", "")
|
|
candidati = [
|
|
Path(pf) / "Google/Chrome/Application/chrome.exe",
|
|
Path(pf86) / "Google/Chrome/Application/chrome.exe",
|
|
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
|
|
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
|
|
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
|
|
]
|
|
for c in candidati:
|
|
if c is not None and c.is_file():
|
|
return str(c)
|
|
return None
|
|
|
|
|
|
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
|
|
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
|
|
uploads = dati / "uploads"
|
|
env = {
|
|
"APP_ENV": "production",
|
|
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
|
|
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
|
|
"UPLOAD_DIR": str(uploads),
|
|
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
|
|
"VISION_RECIPES_ROOT": str(uploads),
|
|
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
|
|
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
|
|
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
|
|
**{k: valori[k] for k in SEGRETI},
|
|
}
|
|
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
|
|
# ma non sovrascrivono le variabili fisse del contratto
|
|
for k, v in valori.items():
|
|
env.setdefault(k, v)
|
|
programma = cartella_programma()
|
|
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
|
|
if versione.is_file():
|
|
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
|
|
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
|
|
if gtk.is_dir():
|
|
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
|
|
if hasattr(os, "add_dll_directory"):
|
|
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
|
|
try:
|
|
os.add_dll_directory(str(gtk))
|
|
except OSError:
|
|
pass
|
|
browser = trova_browser()
|
|
if browser:
|
|
env["BROWSER_PATH"] = browser
|
|
os.environ.update(env)
|
|
for cartella in (uploads, dati / "camera", dati / "log"):
|
|
cartella.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _prepara_percorsi_sorgenti() -> None:
|
|
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
|
|
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
|
|
if COMPILATO:
|
|
return
|
|
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
|
|
if str(p) not in sys.path:
|
|
sys.path.insert(0, str(p))
|
|
|
|
|
|
# ---------------------------------------------------------------- log
|
|
|
|
def _prepara_log(dati: Path | None = None) -> None:
|
|
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
|
|
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
|
|
try:
|
|
d = (dati or cartella_dati()) / "log"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
percorso = str(d / "tiemeasureflow.log")
|
|
root = logging.getLogger()
|
|
for h in root.handlers:
|
|
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
|
|
break
|
|
else:
|
|
h = logging.FileHandler(percorso, encoding="utf-8")
|
|
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
root.addHandler(h)
|
|
root.setLevel(logging.INFO)
|
|
for nome in ("tmf.avvio",):
|
|
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------- rete
|
|
|
|
def _porta_aperta(porta: int) -> bool:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _e_tmf() -> bool:
|
|
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
|
|
try:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
|
|
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _nessun_utente(db: Path) -> bool:
|
|
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
|
|
try:
|
|
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
|
try:
|
|
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
|
|
finally:
|
|
con.close()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
|
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
|
for _ in range(300):
|
|
if all(_porta_aperta(p) for p in porte):
|
|
break
|
|
time.sleep(0.2)
|
|
webbrowser.open(url)
|
|
|
|
|
|
# ---------------------------------------------------------------- migrazioni
|
|
|
|
def _neutralizza_fileconfig() -> None:
|
|
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
|
|
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
|
|
import logging.config
|
|
logging.config.fileConfig = lambda *a, **k: None
|
|
|
|
|
|
def esegui_migrazioni() -> None:
|
|
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
|
|
_neutralizza_fileconfig()
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
import src.backend as pacchetto
|
|
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
|
|
cfg = Config(str(migrazioni / "alembic.ini"))
|
|
if COMPILATO:
|
|
cfg.set_main_option("sourceless", "true")
|
|
command.upgrade(cfg, "head")
|
|
|
|
|
|
# ---------------------------------------------------------------- server
|
|
|
|
class ServerThread(threading.Thread):
|
|
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
|
|
|
|
def __init__(self, nome: str, costruisci):
|
|
super().__init__(name=nome, daemon=True)
|
|
self._costruisci = costruisci
|
|
self.server = None
|
|
self.errore: BaseException | None = None
|
|
|
|
def run(self) -> None:
|
|
try:
|
|
self.server = self._costruisci()
|
|
self.server.run()
|
|
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
|
|
self.errore = e
|
|
log.exception("server %s terminato con errore", self.name)
|
|
|
|
def ferma(self) -> None:
|
|
s = self.server
|
|
try:
|
|
if hasattr(s, "should_exit"):
|
|
s.should_exit = True # uvicorn
|
|
elif s is not None:
|
|
s.close() # waitress
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _uvicorn(app, porta: int):
|
|
import uvicorn
|
|
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
|
|
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
|
|
|
|
|
|
def costruisci_web(host: str):
|
|
import waitress
|
|
from app import create_app # src/frontend/flask_app/app.py
|
|
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
|
|
|
|
|
|
def costruisci_backend():
|
|
from src.backend.main import app
|
|
return _uvicorn(app, PORTA_BACKEND)
|
|
|
|
|
|
def costruisci_vision():
|
|
from src.vision_worker.main import app
|
|
return _uvicorn(app, PORTA_VISION)
|
|
|
|
|
|
def costruisci_camera(cartella: Path):
|
|
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
|
|
return _uvicorn(create_app(cartella), PORTA_CAMERA)
|
|
|
|
|
|
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
|
|
thread = [
|
|
ServerThread("backend", costruisci_backend),
|
|
ServerThread("vision", costruisci_vision),
|
|
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
|
|
ServerThread("web", lambda: costruisci_web(host)),
|
|
]
|
|
for t in thread:
|
|
t.start()
|
|
return thread
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
_stream_sicuri()
|
|
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
|
|
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
|
|
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
|
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
|
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
|
args = p.parse_args(argv)
|
|
_prepara_log()
|
|
|
|
if args.impronta:
|
|
try:
|
|
impronta = lic.impronta()
|
|
except Exception as e:
|
|
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
|
|
return 1
|
|
print(impronta) if sys.stdout is not None else None
|
|
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
|
return 0
|
|
|
|
if not controlla_licenza(args.senza_finestre):
|
|
return 1
|
|
|
|
if _porta_aperta(PORTA_WEB):
|
|
if _e_tmf():
|
|
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
|
|
if not args.senza_browser:
|
|
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
|
|
return 0
|
|
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
|
|
senza_finestre=args.senza_finestre)
|
|
return 2
|
|
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
|
|
if occupate:
|
|
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
|
|
senza_finestre=args.senza_finestre)
|
|
return 2
|
|
|
|
dati = cartella_dati()
|
|
try:
|
|
valori = carica_segreti(dati)
|
|
imposta_ambiente(dati, args.host, valori)
|
|
_prepara_percorsi_sorgenti()
|
|
primo_avvio = not (dati / "tmf.db").exists()
|
|
esegui_migrazioni()
|
|
except Exception as e:
|
|
_prepara_log(dati)
|
|
log.exception("avvio non riuscito")
|
|
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
|
|
senza_finestre=args.senza_finestre)
|
|
return 3
|
|
_prepara_log(dati) # alembic ha riconfigurato il logging
|
|
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
|
|
|
thread = avvia_server(args.host, dati)
|
|
if not args.senza_browser:
|
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
|
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
|
else f"http://127.0.0.1:{PORTA_WEB}/")
|
|
threading.Thread(target=_apri_browser_quando_pronto,
|
|
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
|
|
|
|
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
|
|
try:
|
|
while thread and all(t.is_alive() for t in thread):
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
morti = [t for t in thread if not t.is_alive()]
|
|
for t in thread:
|
|
t.ferma()
|
|
if morti:
|
|
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
|
|
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
|
|
return 4
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|