feat(installer): J6412 USB installer + AR Electronics license activation server
installer/:
- build_usb.py: dev tool — builds Flutter + AR-ECDIS, assembles USB pendrive
image with serial.key, autorun.inf, and START_INSTALLER.bat
- serial_generator.py: generates AR-XXXX-XXXX-XXXX serial numbers (48-bit
entropy), logs to CSV for CRM integration
- src/license.py: hardware fingerprint (Windows MachineGuid + primary MAC),
serial validation, online activation POST, local cache with 30-day offline
grace period
- src/sysconfig.py: HKCU autostart registry entries, .lnk shortcuts (desktop
+ Start Menu via WScript.Shell), firewall rules (netsh), COM port detection
- src/install.py: tkinter installer GUI — 9 sequential steps with per-step
progress indicators, threaded execution, error dialogs, and silent mode
license_server/ (FastAPI service — deploy to arelectronics.com VPS):
- POST /api/v1/activate: first activation accepted; same-HW re-activation
refreshes heartbeat; different-HW rejected with 409
- GET /api/v1/validate/{serial}: heartbeat endpoint to refresh offline cache
- Admin endpoints (X-Admin-Key): issue, list, revoke licenses
- SQLAlchemy models: License (serial registry) + Activation (per-machine rows)
- SQLite default, PostgreSQL-ready via DATABASE_URL env var
AR_electronics — AR-Autopilot Project
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
# =============================================================================
|
||||
# installer/src/install.py — AR Electronics J6412 installer
|
||||
# =============================================================================
|
||||
#
|
||||
# Tkinter GUI installer that:
|
||||
# 1. Validates the bundled serial number
|
||||
# 2. Installs AR-ECDIS and AR-Autopilot Display to Program Files
|
||||
# 3. Activates the license online
|
||||
# 4. Configures Windows autostart, shortcuts, and firewall rules
|
||||
#
|
||||
# Usage:
|
||||
# python install.py — interactive GUI mode
|
||||
# python install.py --silent — headless mode (for testing / scripted deploy)
|
||||
#
|
||||
# This file lives in the root of the USB pendrive alongside serial.key and
|
||||
# the packages/ directory. Run as Administrator for full functionality.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
# ── Resolve installer root (directory containing this script) ─────────────────
|
||||
INSTALLER_DIR = Path(__file__).parent
|
||||
PACKAGES_DIR = INSTALLER_DIR / "packages"
|
||||
|
||||
APP_VERSION = "0.4.0"
|
||||
|
||||
# ── Late imports from sibling modules ─────────────────────────────────────────
|
||||
sys.path.insert(0, str(INSTALLER_DIR))
|
||||
from license import activate_online, read_serial, ActivationError # noqa: E402
|
||||
from sysconfig import ( # noqa: E402
|
||||
ECDIS_DIR, AUTOPILOT_DIR,
|
||||
ensure_install_dirs, configure_autostart,
|
||||
create_shortcuts, add_firewall_rules, list_com_ports,
|
||||
is_admin, require_admin,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STEPS = [
|
||||
("Verificar serial", "_step_verify_serial"),
|
||||
("Crear directorios", "_step_create_dirs"),
|
||||
("Instalar AR-ECDIS", "_step_install_ecdis"),
|
||||
("Instalar AR-Autopilot", "_step_install_autopilot"),
|
||||
("Activar licencia", "_step_activate_license"),
|
||||
("Configurar inicio", "_step_configure_autostart"),
|
||||
("Accesos directos", "_step_create_shortcuts"),
|
||||
("Reglas de firewall", "_step_firewall"),
|
||||
("Finalizar", "_step_finalize"),
|
||||
]
|
||||
|
||||
|
||||
class Installer:
|
||||
"""Core installation logic — UI-independent."""
|
||||
|
||||
def __init__(self, log_fn=print):
|
||||
self._log = log_fn
|
||||
self.serial: str | None = None
|
||||
|
||||
# ── Step implementations ─────────────────────────────────────────────────
|
||||
|
||||
def _step_verify_serial(self):
|
||||
self._log("Leyendo número de serie…")
|
||||
self.serial = read_serial(INSTALLER_DIR)
|
||||
self._log(f"Serial: {self.serial}")
|
||||
|
||||
def _step_create_dirs(self):
|
||||
self._log("Creando directorios en Program Files…")
|
||||
ensure_install_dirs()
|
||||
|
||||
def _step_install_ecdis(self):
|
||||
src = PACKAGES_DIR / "AR-ECDIS"
|
||||
if not src.exists():
|
||||
self._log("Paquete AR-ECDIS no encontrado — omitiendo.")
|
||||
return
|
||||
self._log(f"Copiando AR-ECDIS → {ECDIS_DIR}")
|
||||
if ECDIS_DIR.exists():
|
||||
shutil.rmtree(ECDIS_DIR)
|
||||
shutil.copytree(src, ECDIS_DIR)
|
||||
self._log("AR-ECDIS instalado correctamente.")
|
||||
|
||||
def _step_install_autopilot(self):
|
||||
src = PACKAGES_DIR / "AR-Autopilot"
|
||||
if not src.exists():
|
||||
self._log("Paquete AR-Autopilot no encontrado — omitiendo.")
|
||||
return
|
||||
self._log(f"Copiando AR-Autopilot → {AUTOPILOT_DIR}")
|
||||
if AUTOPILOT_DIR.exists():
|
||||
shutil.rmtree(AUTOPILOT_DIR)
|
||||
shutil.copytree(src, AUTOPILOT_DIR)
|
||||
self._log("AR-Autopilot instalado correctamente.")
|
||||
|
||||
def _step_activate_license(self):
|
||||
if self.serial is None:
|
||||
raise RuntimeError("Serial no disponible para activación.")
|
||||
self._log(f"Activando licencia en servidor AR Electronics…")
|
||||
result = activate_online(self.serial, app_version=APP_VERSION)
|
||||
self._log(
|
||||
f"Licencia activada — ID: {result.activation_id[:8]}…\n"
|
||||
f" Slot: {result.vessel_slot} | {result.licensed_to}"
|
||||
)
|
||||
|
||||
def _step_configure_autostart(self):
|
||||
self._log("Configurando inicio automático con Windows…")
|
||||
configure_autostart()
|
||||
|
||||
def _step_create_shortcuts(self):
|
||||
self._log("Creando accesos directos…")
|
||||
create_shortcuts()
|
||||
|
||||
def _step_firewall(self):
|
||||
if not is_admin():
|
||||
self._log("Sin privilegios de administrador — omitiendo reglas de firewall.")
|
||||
return
|
||||
self._log("Añadiendo reglas de firewall…")
|
||||
add_firewall_rules()
|
||||
|
||||
def _step_finalize(self):
|
||||
ports = list_com_ports()
|
||||
if ports:
|
||||
self._log(f"Puertos COM detectados: {', '.join(ports)}")
|
||||
self._log(
|
||||
"Conecte el concentrador y configure los puertos en\n"
|
||||
"AR-Autopilot → Ajustes → Puertos COM."
|
||||
)
|
||||
else:
|
||||
self._log("No se detectaron puertos COM — conecte el concentrador USB.")
|
||||
self._log("Instalación completada con éxito.")
|
||||
|
||||
# ── Public runner ────────────────────────────────────────────────────────
|
||||
|
||||
def run_all(self, progress_cb=None):
|
||||
"""
|
||||
Execute all steps sequentially.
|
||||
|
||||
:param progress_cb: optional callable(step_index, step_name, success, error)
|
||||
"""
|
||||
for idx, (name, method_name) in enumerate(STEPS):
|
||||
method = getattr(self, method_name)
|
||||
try:
|
||||
method()
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, True, None)
|
||||
except ActivationError as exc:
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, False, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, False, exc)
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tkinter GUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRAND_NAVY = "#0D1B2A"
|
||||
BRAND_BLUE = "#2563EB"
|
||||
BRAND_GLOW = "#60B8FF"
|
||||
BRAND_TEXT = "#E2E8F0"
|
||||
BRAND_MUTED = "#8899AA"
|
||||
BRAND_GREEN = "#22C55E"
|
||||
BRAND_RED = "#EF4444"
|
||||
|
||||
|
||||
class InstallerWindow:
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("AR Electronics — Instalador J6412")
|
||||
self.root.configure(bg=BRAND_NAVY)
|
||||
self.root.resizable(False, False)
|
||||
|
||||
# Centre on screen
|
||||
w, h = 560, 520
|
||||
sw = self.root.winfo_screenwidth()
|
||||
sh = self.root.winfo_screenheight()
|
||||
self.root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
|
||||
|
||||
self._build_ui()
|
||||
self._installer = Installer(log_fn=self._append_log)
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# Header
|
||||
hdr = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
hdr.pack(fill="x", padx=0, pady=0)
|
||||
|
||||
tk.Label(
|
||||
hdr,
|
||||
text="AR Electronics",
|
||||
font=("Segoe UI", 18, "bold"),
|
||||
fg=BRAND_GLOW,
|
||||
bg=BRAND_NAVY,
|
||||
).pack(pady=(20, 0))
|
||||
|
||||
tk.Label(
|
||||
hdr,
|
||||
text="Instalador para J6412 Mini PC",
|
||||
font=("Segoe UI", 11),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
).pack(pady=(0, 12))
|
||||
|
||||
sep = tk.Frame(self.root, height=1, bg=BRAND_BLUE)
|
||||
sep.pack(fill="x", padx=20)
|
||||
|
||||
# Steps frame
|
||||
self._step_vars: list[tk.StringVar] = []
|
||||
steps_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
steps_frame.pack(fill="x", padx=30, pady=14)
|
||||
|
||||
for _, (name, _) in enumerate(STEPS):
|
||||
var = tk.StringVar(value=f" ○ {name}")
|
||||
lbl = tk.Label(
|
||||
steps_frame,
|
||||
textvariable=var,
|
||||
font=("Consolas", 10),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
anchor="w",
|
||||
)
|
||||
lbl.pack(fill="x", pady=1)
|
||||
self._step_vars.append(var)
|
||||
|
||||
self._step_labels = steps_frame.winfo_children()
|
||||
|
||||
# Progress bar
|
||||
pb_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
pb_frame.pack(fill="x", padx=30, pady=(0, 8))
|
||||
|
||||
style = ttk.Style()
|
||||
style.theme_use("clam")
|
||||
style.configure(
|
||||
"AR.Horizontal.TProgressbar",
|
||||
troughcolor=BRAND_NAVY,
|
||||
bordercolor=BRAND_BLUE,
|
||||
background=BRAND_GLOW,
|
||||
lightcolor=BRAND_GLOW,
|
||||
darkcolor=BRAND_BLUE,
|
||||
)
|
||||
self._progress = ttk.Progressbar(
|
||||
pb_frame,
|
||||
style="AR.Horizontal.TProgressbar",
|
||||
maximum=len(STEPS),
|
||||
length=500,
|
||||
)
|
||||
self._progress.pack(fill="x")
|
||||
|
||||
# Log text
|
||||
log_frame = tk.Frame(self.root, bg="#0A1520")
|
||||
log_frame.pack(fill="both", expand=True, padx=20, pady=(0, 12))
|
||||
|
||||
self._log_text = tk.Text(
|
||||
log_frame,
|
||||
height=7,
|
||||
font=("Consolas", 9),
|
||||
bg="#0A1520",
|
||||
fg=BRAND_MUTED,
|
||||
relief="flat",
|
||||
state="disabled",
|
||||
wrap="word",
|
||||
)
|
||||
self._log_text.pack(fill="both", expand=True, padx=8, pady=6)
|
||||
|
||||
# Buttons
|
||||
btn_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
btn_frame.pack(fill="x", padx=20, pady=(0, 20))
|
||||
|
||||
self._install_btn = tk.Button(
|
||||
btn_frame,
|
||||
text="INSTALAR",
|
||||
font=("Segoe UI", 10, "bold"),
|
||||
bg=BRAND_BLUE,
|
||||
fg="white",
|
||||
activebackground=BRAND_GLOW,
|
||||
relief="flat",
|
||||
padx=24,
|
||||
pady=8,
|
||||
cursor="hand2",
|
||||
command=self._start_install,
|
||||
)
|
||||
self._install_btn.pack(side="left")
|
||||
|
||||
self._cancel_btn = tk.Button(
|
||||
btn_frame,
|
||||
text="Cancelar",
|
||||
font=("Segoe UI", 10),
|
||||
bg=BRAND_NAVY,
|
||||
fg=BRAND_MUTED,
|
||||
activeforeground=BRAND_TEXT,
|
||||
relief="flat",
|
||||
padx=16,
|
||||
pady=8,
|
||||
cursor="hand2",
|
||||
command=self.root.destroy,
|
||||
)
|
||||
self._cancel_btn.pack(side="left", padx=(10, 0))
|
||||
|
||||
self._status_lbl = tk.Label(
|
||||
btn_frame,
|
||||
text="",
|
||||
font=("Segoe UI", 9),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
)
|
||||
self._status_lbl.pack(side="right")
|
||||
|
||||
# ── Install thread ───────────────────────────────────────────────────────
|
||||
|
||||
def _start_install(self):
|
||||
self._install_btn.configure(state="disabled")
|
||||
self._cancel_btn.configure(state="disabled")
|
||||
threading.Thread(target=self._run_install, daemon=True).start()
|
||||
|
||||
def _run_install(self):
|
||||
try:
|
||||
self._installer.run_all(progress_cb=self._on_step)
|
||||
self.root.after(0, self._on_success)
|
||||
except Exception as exc:
|
||||
self.root.after(0, self._on_failure, str(exc))
|
||||
|
||||
def _on_step(self, idx: int, name: str, success: bool, error):
|
||||
def update():
|
||||
if success:
|
||||
self._step_vars[idx].set(f" ✓ {name}")
|
||||
self._step_labels[idx].configure(fg=BRAND_GREEN)
|
||||
else:
|
||||
self._step_vars[idx].set(f" ✗ {name}")
|
||||
self._step_labels[idx].configure(fg=BRAND_RED)
|
||||
self._progress["value"] = idx + 1
|
||||
|
||||
self.root.after(0, update)
|
||||
|
||||
def _on_success(self):
|
||||
self._status_lbl.configure(text="Instalación completada", fg=BRAND_GREEN)
|
||||
self._cancel_btn.configure(state="normal", text="Cerrar")
|
||||
messagebox.showinfo(
|
||||
"AR Electronics",
|
||||
"Instalación completada con éxito.\n\n"
|
||||
"AR-ECDIS y AR-Autopilot están listos.\n"
|
||||
"Reinicie el equipo para activar el inicio automático.",
|
||||
)
|
||||
|
||||
def _on_failure(self, msg: str):
|
||||
self._status_lbl.configure(text="Error en la instalación", fg=BRAND_RED)
|
||||
self._install_btn.configure(state="normal")
|
||||
self._cancel_btn.configure(state="normal")
|
||||
messagebox.showerror(
|
||||
"Error de instalación",
|
||||
f"La instalación no se completó:\n\n{msg}\n\n"
|
||||
"Verifique la conexión a internet y vuelva a intentarlo.\n"
|
||||
"Si el problema persiste, contacte a AR Electronics.",
|
||||
)
|
||||
|
||||
# ── Log output ───────────────────────────────────────────────────────────
|
||||
|
||||
def _append_log(self, text: str):
|
||||
def _do():
|
||||
self._log_text.configure(state="normal")
|
||||
self._log_text.insert("end", text + "\n")
|
||||
self._log_text.see("end")
|
||||
self._log_text.configure(state="disabled")
|
||||
|
||||
self.root.after(0, _do)
|
||||
|
||||
# ── Main loop ────────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
self.root.mainloop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Silent / headless mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_silent():
|
||||
installer = Installer()
|
||||
errors = []
|
||||
|
||||
def cb(idx, name, success, error):
|
||||
icon = "✓" if success else "✗"
|
||||
print(f" [{icon}] {name}")
|
||||
if error:
|
||||
errors.append(str(error))
|
||||
|
||||
try:
|
||||
installer.run_all(progress_cb=cb)
|
||||
print("\nInstalación completada con éxito.")
|
||||
except Exception as exc:
|
||||
print(f"\nERROR: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AR Electronics J6412 Installer")
|
||||
parser.add_argument(
|
||||
"--silent", action="store_true", help="Run without GUI (headless mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-admin-check", action="store_true", help="Skip UAC elevation request"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.no_admin_check:
|
||||
require_admin()
|
||||
|
||||
if args.silent:
|
||||
run_silent()
|
||||
else:
|
||||
InstallerWindow().run()
|
||||
@@ -0,0 +1,246 @@
|
||||
# =============================================================================
|
||||
# installer/src/license.py — Serial-number activation client
|
||||
# =============================================================================
|
||||
#
|
||||
# Reads the serial number bundled with this installer package (serial.key),
|
||||
# collects a hardware fingerprint (Windows Machine GUID + primary MAC address),
|
||||
# POSTs to the AR Electronics license server, and caches the activation token
|
||||
# locally in %APPDATA%\AR Electronics\license.json.
|
||||
#
|
||||
# Offline grace period: 30 days without contacting the server.
|
||||
# Duplicate activations on a different machine are rejected server-side.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
import winreg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVER_BASE_URL = "https://license.arelectronics.com"
|
||||
ACTIVATE_ROUTE = "/api/v1/activate"
|
||||
VALIDATE_ROUTE = "/api/v1/validate"
|
||||
OFFLINE_GRACE = 30 # days allowed without server contact
|
||||
APP_DATA_DIR = Path.home() / "AppData" / "Roaming" / "AR Electronics"
|
||||
LICENSE_CACHE = APP_DATA_DIR / "license.json"
|
||||
SERIAL_FILE_NAME = "serial.key" # placed next to install.py by build_usb.py
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware fingerprint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _machine_guid() -> str:
|
||||
"""Read the Windows Machine GUID from the registry (stable across reboots)."""
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Cryptography",
|
||||
)
|
||||
value, _ = winreg.QueryValueEx(key, "MachineGuid")
|
||||
winreg.CloseKey(key)
|
||||
return value
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _primary_mac() -> str:
|
||||
"""Return the MAC address of the adapter used for the default route."""
|
||||
try:
|
||||
# Connect to an external address (no data sent) to discover default adapter
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# Find the MAC for this IP using ipconfig output
|
||||
result = subprocess.run(
|
||||
["ipconfig", "/all"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
blocks = result.stdout.split("\n\n")
|
||||
for block in blocks:
|
||||
if ip in block:
|
||||
m = re.search(
|
||||
r"Physical Address[.\s]+:\s*([0-9A-F-]{17})", block, re.IGNORECASE
|
||||
)
|
||||
if m:
|
||||
return m.group(1).replace("-", ":").upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback — uuid.getnode() uses whichever adapter Python finds first
|
||||
raw = uuid.getnode()
|
||||
return ":".join(f"{(raw >> (i * 8)) & 0xFF:02X}" for i in range(5, -1, -1))
|
||||
|
||||
|
||||
def hardware_fingerprint() -> str:
|
||||
"""Stable, anonymised hardware fingerprint for this machine."""
|
||||
raw = f"{_machine_guid()}|{_primary_mac()}|{platform.node()}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serial number helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_serial(installer_dir: Path) -> str:
|
||||
"""
|
||||
Read the serial number from ``serial.key`` next to the installer.
|
||||
|
||||
Raises FileNotFoundError if the file is missing, ValueError if malformed.
|
||||
"""
|
||||
serial_path = installer_dir / SERIAL_FILE_NAME
|
||||
if not serial_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Serial key file not found: {serial_path}\n"
|
||||
"This installer package may be incomplete."
|
||||
)
|
||||
serial = serial_path.read_text(encoding="utf-8").strip()
|
||||
if not _valid_serial_format(serial):
|
||||
raise ValueError(f"Malformed serial number: {serial!r}")
|
||||
return serial
|
||||
|
||||
|
||||
def _valid_serial_format(serial: str) -> bool:
|
||||
"""Validate format: AR-XXXX-XXXX-XXXX (hex groups)."""
|
||||
pattern = r"^AR-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$"
|
||||
return bool(re.match(pattern, serial, re.IGNORECASE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActivationError(Exception):
|
||||
"""Raised when activation is refused or fails."""
|
||||
|
||||
|
||||
class ActivationResult:
|
||||
def __init__(self, data: dict):
|
||||
self.activation_id: str = data["activation_id"]
|
||||
self.vessel_slot: int = data.get("vessel_slot", 1)
|
||||
self.licensed_to: str = data.get("licensed_to", "")
|
||||
self.activated_at: str = data.get("activated_at", "")
|
||||
self.expires_at: str | None = data.get("expires_at")
|
||||
|
||||
|
||||
def activate_online(serial: str, app_version: str = "0.4.0") -> ActivationResult:
|
||||
"""
|
||||
POST activation request to the AR Electronics license server.
|
||||
|
||||
On success caches the response in LICENSE_CACHE.
|
||||
Raises ActivationError on any refusal or connection problem.
|
||||
"""
|
||||
hw_id = hardware_fingerprint()
|
||||
payload = {
|
||||
"serial": serial,
|
||||
"hardware_id": hw_id,
|
||||
"app_version": app_version,
|
||||
"platform": platform.system(),
|
||||
"hostname": platform.node(),
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
SERVER_BASE_URL + ACTIVATE_ROUTE,
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
raise ActivationError("No se pudo conectar con el servidor de licencias.\n"
|
||||
"Verifique la conexión a internet e intente de nuevo.")
|
||||
except requests.Timeout:
|
||||
raise ActivationError("El servidor de licencias no respondió (timeout 15 s).")
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = ActivationResult(resp.json())
|
||||
_cache_activation(serial, hw_id, resp.json())
|
||||
return result
|
||||
|
||||
# Server returned an error
|
||||
try:
|
||||
msg = resp.json().get("detail", resp.text)
|
||||
except Exception:
|
||||
msg = resp.text
|
||||
raise ActivationError(f"Activación rechazada ({resp.status_code}): {msg}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cache_activation(serial: str, hardware_id: str, server_data: dict) -> None:
|
||||
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache = {
|
||||
"serial": serial,
|
||||
"hardware_id": hardware_id,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"server_data": server_data,
|
||||
}
|
||||
LICENSE_CACHE.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_cached_license() -> dict | None:
|
||||
"""Return cached license dict or None if missing / expired."""
|
||||
if not LICENSE_CACHE.exists():
|
||||
return None
|
||||
try:
|
||||
cache = json.loads(LICENSE_CACHE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
# Validate hardware fingerprint matches this machine
|
||||
if cache.get("hardware_id") != hardware_fingerprint():
|
||||
return None
|
||||
|
||||
# Check offline grace period
|
||||
cached_at = datetime.fromisoformat(cache["cached_at"])
|
||||
if datetime.now(timezone.utc) - cached_at > timedelta(days=OFFLINE_GRACE):
|
||||
return None # Cache expired — must re-validate online
|
||||
|
||||
return cache
|
||||
|
||||
|
||||
def is_activated() -> bool:
|
||||
"""Quick check: is this machine currently activated (cache or online)?"""
|
||||
cache = load_cached_license()
|
||||
if cache is not None:
|
||||
return True
|
||||
# Try a fast online validate
|
||||
hw_id = hardware_fingerprint()
|
||||
serial = _serial_from_cache()
|
||||
if serial is None:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{SERVER_BASE_URL}{VALIDATE_ROUTE}/{serial}",
|
||||
params={"hardware_id": hw_id},
|
||||
timeout=8,
|
||||
)
|
||||
if resp.status_code == 200 and resp.json().get("active"):
|
||||
_cache_activation(serial, hw_id, resp.json())
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _serial_from_cache() -> str | None:
|
||||
if not LICENSE_CACHE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(LICENSE_CACHE.read_text(encoding="utf-8")).get("serial")
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,201 @@
|
||||
# =============================================================================
|
||||
# installer/src/sysconfig.py — Windows system configuration helpers
|
||||
# =============================================================================
|
||||
#
|
||||
# All functions target Windows 10/11 (tested on J6412 with Windows 10 IoT).
|
||||
# Requires the installer to be run as Administrator for firewall and registry
|
||||
# operations; shortcuts and HKCU run-key work without elevation.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INSTALL_ROOT = Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "AR Electronics"
|
||||
ECDIS_DIR = INSTALL_ROOT / "AR-ECDIS"
|
||||
AUTOPILOT_DIR = INSTALL_ROOT / "AR-Autopilot"
|
||||
APPDATA_DIR = Path.home() / "AppData" / "Roaming" / "AR Electronics"
|
||||
START_MENU = Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "AR Electronics"
|
||||
|
||||
|
||||
def ensure_install_dirs() -> None:
|
||||
"""Create install directory tree (requires admin)."""
|
||||
for d in (INSTALL_ROOT, ECDIS_DIR, AUTOPILOT_DIR, APPDATA_DIR, START_MENU):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-start (HKCU — no admin required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AUTORUN_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
|
||||
_AR_ECDIS_VALUE = "AR-ECDIS"
|
||||
_AR_AUTOPILOT_VALUE = "AR-Autopilot"
|
||||
|
||||
|
||||
def register_autostart(name: str, exe_path: Path, args: str = "") -> None:
|
||||
"""
|
||||
Add an entry to HKCU Run so the app starts with Windows.
|
||||
|
||||
Does NOT require administrator rights (current-user key).
|
||||
"""
|
||||
cmd = f'"{exe_path}" {args}'.strip()
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
_AUTORUN_KEY,
|
||||
access=winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.SetValueEx(key, name, 0, winreg.REG_SZ, cmd)
|
||||
winreg.CloseKey(key)
|
||||
|
||||
|
||||
def unregister_autostart(name: str) -> None:
|
||||
"""Remove an auto-start entry (best-effort, ignores missing keys)."""
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
_AUTORUN_KEY,
|
||||
access=winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.DeleteValue(key, name)
|
||||
winreg.CloseKey(key)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def configure_autostart() -> None:
|
||||
"""Register both AR Electronics apps in the Windows autostart."""
|
||||
ecdis_exe = ECDIS_DIR / "AR-ECDIS.exe"
|
||||
autopilot_exe = AUTOPILOT_DIR / "ar_autopilot_display.exe"
|
||||
|
||||
if ecdis_exe.exists():
|
||||
register_autostart(_AR_ECDIS_VALUE, ecdis_exe)
|
||||
|
||||
if autopilot_exe.exists():
|
||||
register_autostart(_AR_AUTOPILOT_VALUE, autopilot_exe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop and Start Menu shortcuts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_shortcut(target: Path, shortcut_path: Path, icon: Path | None = None) -> None:
|
||||
"""
|
||||
Create a .lnk shortcut using PowerShell WScript.Shell.
|
||||
|
||||
No admin required; works on standard user accounts.
|
||||
"""
|
||||
icon_str = f'$s.IconLocation = "{icon}"' if icon else ""
|
||||
script = (
|
||||
f'$s=(New-Object -COM WScript.Shell).CreateShortcut("{shortcut_path}");'
|
||||
f'$s.TargetPath="{target}";'
|
||||
f'{icon_str};'
|
||||
f'$s.Save()'
|
||||
)
|
||||
subprocess.run(["powershell", "-Command", script], check=True, capture_output=True)
|
||||
|
||||
|
||||
def create_shortcuts() -> None:
|
||||
"""Create Start Menu and Desktop shortcuts for both apps."""
|
||||
START_MENU.mkdir(parents=True, exist_ok=True)
|
||||
desktop = Path.home() / "Desktop"
|
||||
|
||||
apps = [
|
||||
("AR-ECDIS", ECDIS_DIR / "AR-ECDIS.exe"),
|
||||
("AR-Autopilot", AUTOPILOT_DIR / "ar_autopilot_display.exe"),
|
||||
]
|
||||
for name, exe in apps:
|
||||
if not exe.exists():
|
||||
continue
|
||||
lnk = f"{name}.lnk"
|
||||
_make_shortcut(exe, START_MENU / lnk, icon=exe)
|
||||
_make_shortcut(exe, desktop / lnk, icon=exe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows Firewall rules (requires admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_firewall_rules() -> None:
|
||||
"""
|
||||
Allow inbound TCP on ports used by AR Electronics services.
|
||||
|
||||
Requires the installer to be running as Administrator.
|
||||
"""
|
||||
rules = [
|
||||
("AR-ECDIS Web", "TCP", "8080"), # AR-ECDIS FastAPI backend
|
||||
("AR-ECDIS WebSocket","TCP", "8080"),
|
||||
]
|
||||
for name, proto, port in rules:
|
||||
subprocess.run(
|
||||
[
|
||||
"netsh", "advfirewall", "firewall", "add", "rule",
|
||||
f"name={name}",
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
f"protocol={proto}",
|
||||
f"localport={port}",
|
||||
],
|
||||
check=False, # non-fatal if rule already exists
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COM port detection (informational — no forced assignment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_com_ports() -> list[str]:
|
||||
"""
|
||||
Return list of detected COM ports on the system.
|
||||
|
||||
On Windows this queries the registry rather than requiring PySerial.
|
||||
"""
|
||||
ports: list[str] = []
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"HARDWARE\DEVICEMAP\SERIALCOMM",
|
||||
)
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
_, port_name, _ = winreg.EnumValue(key, i)
|
||||
ports.append(port_name)
|
||||
i += 1
|
||||
except OSError:
|
||||
break
|
||||
winreg.CloseKey(key)
|
||||
except OSError:
|
||||
pass
|
||||
return sorted(ports)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Administrator check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""Return True if the current process has administrator privileges."""
|
||||
try:
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def require_admin() -> None:
|
||||
"""Re-launch this process with UAC elevation if not already admin."""
|
||||
if not is_admin():
|
||||
ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "runas", sys.executable, " ".join(sys.argv), None, 1
|
||||
)
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user