"""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`. `--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni ne' database; registra gli strumenti, scrive su stdout e in %PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON {strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3. La usa build.ps1 prima di Inno Setup. 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 (\\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). ⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py` (MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82). """ from __future__ import annotations import argparse import ctypes import importlib 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 \\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 # ---------------------------------------------------------------- strumenti di visione def registra_strumenti() -> list[str]: """Registra gli strumenti di Core; torna i moduli che non si sono importati (#82). Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente `_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread di questo processo: registrare una volta sola qui vale per tutti.""" from visionsuite.task.registry import load_tools load_tools() try: from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py except ImportError: MODULI = () # da sorgente: gli entry point di load_tools() bastano falliti: list[str] = [] for modulo in MODULI: try: importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie log.error("strumenti %s non importati", modulo, exc_info=True) falliti.append(modulo) return falliti def controlla_strumenti() -> int: """Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti.""" from visionsuite.task.graph import registry from visionsuite.task.registry import unavailable falliti = registra_strumenti() kind = sorted(registry()) esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()} riga = json.dumps(esito, ensure_ascii=False) if sys.stdout is not None: print(riga) try: f = cartella_dati() / "log" / "controllo-strumenti.json" f.parent.mkdir(parents=True, exist_ok=True) f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura except OSError as e: log.warning("controllo-strumenti.json non scritto: %s", e) return 0 if kind and not falliti else 3 # ---------------------------------------------------------------- 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)") p.add_argument("--controlla-strumenti", action="store_true", help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)") 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 args.controlla_strumenti: return controlla_strumenti() # prima della licenza: la build non ha una licenza 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) registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82) 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())