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:
2026-05-24 01:36:24 -04:00
parent abe9b764c7
commit de25dcee57
12 changed files with 1712 additions and 0 deletions
+426
View File
@@ -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()
+246
View File
@@ -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
+201
View File
@@ -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)