558 lines
18 KiB
Python
Executable File
558 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import sys
|
|
import shutil
|
|
import subprocess
|
|
import glob
|
|
import argparse
|
|
import datetime
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from framework.discovery import discover_all, build_selected
|
|
from framework.engine import apply as apply_plugin_framework, Context, PatchError
|
|
|
|
REPO_URL = "https://github.com/crosspoint-reader/crosspoint-reader.git"
|
|
REPO_DIR = "crosspoint-reader"
|
|
PLUGINS_DIR = "plugins"
|
|
|
|
ENVIRONMENTS = {
|
|
"default": "Debug logging enabled, version from current git branch (recommended)",
|
|
"gh_release": "Info logging only, version hardcoded to release tag",
|
|
"slim": "No serial logging, smallest binary size",
|
|
}
|
|
|
|
BAUD_RATES = {
|
|
"1": ("115200", "Recommended - most reliable, avoids 'chip stopped responding'"),
|
|
"2": ("921600", "Faster - for those feeling froggy"),
|
|
}
|
|
DEFAULT_BAUD = "115200"
|
|
|
|
|
|
def run(cmd, cwd=None, check=True):
|
|
print(f" $ {cmd}")
|
|
return subprocess.run(cmd, shell=True, cwd=cwd, check=check)
|
|
|
|
|
|
def remove_readonly(func, path, _):
|
|
os.chmod(path, 0o777)
|
|
func(path)
|
|
|
|
|
|
def rmtree(path):
|
|
if sys.version_info >= (3, 12):
|
|
shutil.rmtree(path, onexc=remove_readonly)
|
|
else:
|
|
shutil.rmtree(path, onerror=remove_readonly)
|
|
|
|
|
|
def get_remote_head_sha():
|
|
try:
|
|
result = subprocess.run(
|
|
f"git ls-remote {REPO_URL} HEAD",
|
|
shell=True, capture_output=True, text=True, timeout=10
|
|
)
|
|
if result.returncode == 0 and result.stdout:
|
|
return result.stdout.split()[0]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_local_head_sha():
|
|
try:
|
|
result = subprocess.run(
|
|
"git rev-parse HEAD",
|
|
shell=True, capture_output=True, text=True, cwd=REPO_DIR
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def find_backup_files():
|
|
return sorted(glob.glob("backup.*.bin"), reverse=True)
|
|
|
|
|
|
def restore_from_backup(yes_all: bool = False):
|
|
"""If any backup.*.bin files are sitting in the current directory, offer
|
|
to write one back to the device instead of proceeding with a fresh
|
|
install. This always runs before backup_device() and, if the user goes
|
|
through with a restore, exits the script afterward rather than
|
|
continuing on to clone/patch/build/flash."""
|
|
backups = find_backup_files()
|
|
if not backups:
|
|
return
|
|
|
|
if yes_all:
|
|
print(f" Found {len(backups)} existing backup(s), skipping restore (--yes).")
|
|
return
|
|
|
|
print(f" Found {len(backups)} existing backup{'s' if len(backups) != 1 else ''}:")
|
|
for i, b in enumerate(backups, 1):
|
|
tag = " ← most recent" if i == 1 else ""
|
|
try:
|
|
size_mb = os.path.getsize(b) / (1024 * 1024)
|
|
size_label = f" ({size_mb:.1f} MB)"
|
|
except OSError:
|
|
size_label = ""
|
|
print(f" {i}. {b}{size_label}{tag}")
|
|
|
|
answer = input("\n Restore firmware from backup? [N/y]: ").strip().lower()
|
|
if answer not in ("y", "yes"):
|
|
return
|
|
|
|
if len(backups) == 1:
|
|
chosen = backups[0]
|
|
else:
|
|
choice = input(f"\n Which backup do you want to restore? [1-{len(backups)}] (default: 1): ").strip()
|
|
if not choice:
|
|
chosen = backups[0]
|
|
elif choice.isdigit() and 1 <= int(choice) <= len(backups):
|
|
chosen = backups[int(choice) - 1]
|
|
else:
|
|
sys.exit("ERROR: Invalid choice — aborting restore.")
|
|
|
|
print()
|
|
print(" Ensure your xteink device is connected via USB and awake.")
|
|
input(" Press Enter when ready... ")
|
|
|
|
ports = detect_serial_ports()
|
|
if not ports:
|
|
sys.exit("ERROR: No USB serial ports detected — cannot restore.")
|
|
|
|
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
|
|
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
|
|
|
|
print(f"\n Found {len(ports)} serial port(s):")
|
|
for i, (device, desc) in enumerate(ports, 1):
|
|
tag = " ← recommended" if device == recommended else ""
|
|
print(f" {i}. {device} — {desc}{tag}")
|
|
|
|
port_choice = input(f"\n Press ENTER to use {recommended}, or enter 1-{len(ports)}: ").strip()
|
|
if not port_choice:
|
|
port = recommended
|
|
elif port_choice.isdigit() and 0 <= int(port_choice) - 1 < len(ports):
|
|
port = ports[int(port_choice) - 1][0]
|
|
else:
|
|
sys.exit("ERROR: Invalid port choice — aborting restore.")
|
|
|
|
while True:
|
|
print(f"\n Writing {chosen} → {port} ...")
|
|
result = subprocess.run(
|
|
f"esptool --port {port} --baud 921600 write-flash 0x0 {chosen}",
|
|
shell=True,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print(f"\n ✓ Restored {chosen} to the device.")
|
|
sys.exit(0)
|
|
|
|
print(" ✗ Restore failed (USB dropout, wrong port, or device asleep are common causes).")
|
|
retry_choice = input(" [r]etry or [A]bort? [r/A]: ").strip().lower()
|
|
|
|
if retry_choice == "r":
|
|
print("\n Ensure your xteink device is still connected via USB and awake")
|
|
print(" (try a different cable/port if it keeps dropping out).")
|
|
input(" Press Enter to retry... ")
|
|
continue
|
|
else:
|
|
sys.exit("Aborted.")
|
|
|
|
|
|
def backup_device(yes_all: bool = False):
|
|
if yes_all:
|
|
print(" Skipping device backup (--yes).")
|
|
return
|
|
|
|
print(" Backup your current firmware before flashing? [Y/n]: ", end="", flush=True)
|
|
answer = input().strip().lower()
|
|
if answer not in ("", "y", "yes"):
|
|
print(" Skipping backup.")
|
|
return
|
|
|
|
print()
|
|
print(" Ensure your xteink device is connected via USB and awake.")
|
|
input(" Press Enter when ready... ")
|
|
|
|
while True:
|
|
ports = detect_serial_ports()
|
|
if ports:
|
|
break
|
|
print(" No USB serial ports detected.")
|
|
retry_choice = input(" Check the USB connection, then [r]etry or [C]ontinue without backup? [r/C]: ").strip().lower()
|
|
if retry_choice == "r":
|
|
print("\n Ensure your xteink device is still connected via USB and awake")
|
|
print(" (try a different cable/port if it isn't being detected).")
|
|
input(" Press Enter to retry... ")
|
|
continue
|
|
else:
|
|
print(" Continuing without a backup.")
|
|
return
|
|
|
|
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
|
|
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
|
|
|
|
print(f"\n Found {len(ports)} serial port(s):")
|
|
for i, (device, desc) in enumerate(ports, 1):
|
|
tag = " ← recommended" if device == recommended else ""
|
|
print(f" {i}. {device} — {desc}{tag}")
|
|
|
|
choice = input(f"\n Press ENTER to use {recommended}, or enter 1-{len(ports)}: ").strip()
|
|
|
|
if not choice:
|
|
port = recommended
|
|
elif choice.isdigit() and 0 <= int(choice) - 1 < len(ports):
|
|
port = ports[int(choice) - 1][0]
|
|
else:
|
|
print(" Invalid choice — skipping backup.")
|
|
return
|
|
|
|
while True:
|
|
timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S")
|
|
filename = f"backup.{timestamp}.bin"
|
|
print(f"\n Reading flash from {port} → {filename} ...")
|
|
|
|
result = subprocess.run(
|
|
f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}",
|
|
shell=True,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print(f" ✓ Backup saved to {filename}")
|
|
print()
|
|
print(f" To restore your device to this backup, run:")
|
|
print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}")
|
|
break
|
|
|
|
# Remove the partial/corrupt file left behind by the failed attempt
|
|
if os.path.exists(filename):
|
|
try:
|
|
os.remove(filename)
|
|
except OSError:
|
|
pass
|
|
|
|
print(" ✗ Backup failed (USB dropout, wrong port, or device asleep are common causes).")
|
|
choice = input(" [r]etry, [A]bort, or [c]ontinue without backup? [r/A/c]: ").strip().lower()
|
|
|
|
if choice == "r":
|
|
print("\n Ensure your xteink device is still connected via USB and awake")
|
|
print(" (try a different cable/port if it keeps dropping out).")
|
|
input(" Press Enter to retry... ")
|
|
continue
|
|
elif choice == "c":
|
|
print(" Continuing without a backup.")
|
|
break
|
|
else:
|
|
# Empty input or anything else defaults to the safe choice: abort.
|
|
print(" Aborted.")
|
|
sys.exit(0)
|
|
print()
|
|
|
|
|
|
def clone_repo(force: bool = True):
|
|
print("[1/3] Preparing CrossPoint repository...")
|
|
|
|
if not force and os.path.exists(REPO_DIR):
|
|
print(" Existing repo found, checking remote for updates...")
|
|
remote_sha = get_remote_head_sha()
|
|
local_sha = get_local_head_sha()
|
|
|
|
if remote_sha and local_sha and remote_sha == local_sha:
|
|
print(f" Already up to date ({local_sha[:8]}), skipping clone.")
|
|
return
|
|
elif remote_sha:
|
|
print(f" Remote has changed ({remote_sha[:8]} vs local {(local_sha or 'unknown')[:8]}), re-cloning...")
|
|
else:
|
|
print(" Could not reach remote, re-using existing repo.")
|
|
return
|
|
|
|
if os.path.exists(REPO_DIR):
|
|
print(f" Removing existing '{REPO_DIR}'...")
|
|
rmtree(REPO_DIR)
|
|
|
|
run(f"git clone --recursive {REPO_URL} {REPO_DIR}")
|
|
|
|
|
|
def clear_plugin_caches():
|
|
for plugin_name in os.listdir(PLUGINS_DIR):
|
|
cache_dir = os.path.join(PLUGINS_DIR, plugin_name, "__pycache__")
|
|
if os.path.isdir(cache_dir):
|
|
rmtree(cache_dir)
|
|
|
|
|
|
def select_plugins(yes_all: bool = False):
|
|
"""Ask the user which plugins to install. Selection only - no file
|
|
touched yet, so nothing here depends on which other plugins are chosen."""
|
|
if not os.path.exists(PLUGINS_DIR):
|
|
print(f" No '{PLUGINS_DIR}' directory found — skipping plugin step.")
|
|
return []
|
|
|
|
available = discover_all(PLUGINS_DIR)
|
|
if not available:
|
|
print(" No plugins with a plugin.py manifest found.")
|
|
return []
|
|
|
|
chosen = []
|
|
for plugin_name in sorted(available):
|
|
print(f"\n Plugin: {plugin_name}")
|
|
if yes_all:
|
|
answer = "y"
|
|
print(f" Install '{plugin_name}'? [Y/n]: Y (--yes)")
|
|
else:
|
|
answer = input(f" Install '{plugin_name}'? [Y/n]: ").strip().lower()
|
|
|
|
if answer in ("", "y", "yes"):
|
|
chosen.append(plugin_name)
|
|
else:
|
|
print(f" Skipping {plugin_name}.")
|
|
|
|
return chosen
|
|
|
|
|
|
def apply_plugins(plugin_names, yes_all: bool = False):
|
|
print("[2/3] Applying plugins...")
|
|
|
|
if not plugin_names:
|
|
print(" No plugins selected.")
|
|
return
|
|
|
|
print(f" Selected: {', '.join(plugin_names)}")
|
|
ctx = Context(repo_dir=os.path.abspath(REPO_DIR), yes_all=yes_all, prompt=not yes_all)
|
|
|
|
try:
|
|
selected = build_selected(plugin_names, PLUGINS_DIR, ctx)
|
|
apply_plugin_framework(ctx, selected)
|
|
except (PatchError, KeyError) as exc:
|
|
sys.exit(
|
|
f"\nERROR: {exc}\n\n"
|
|
"This usually means the upstream crosspoint-reader source has "
|
|
"changed in a way this plugin set doesn't recognize yet. No "
|
|
"half-applied changes were left in place beyond this point - "
|
|
"please report this so the anchor can be updated."
|
|
)
|
|
|
|
print(f"\n ✓ {len(plugin_names)} plugin(s) installed: {', '.join(plugin_names)}")
|
|
|
|
|
|
def list_likely_serial_ports():
|
|
patterns = [
|
|
"/dev/cu.usb*",
|
|
"/dev/tty.usb*",
|
|
"/dev/cu.wchusb*",
|
|
"/dev/tty.wchusb*",
|
|
"/dev/cu.SLAB*",
|
|
"/dev/tty.SLAB*",
|
|
"/dev/ttyACM*",
|
|
"/dev/ttyUSB*",
|
|
]
|
|
ports = []
|
|
for pattern in patterns:
|
|
ports.extend(glob.glob(pattern))
|
|
return sorted(set(ports))
|
|
|
|
|
|
def detect_serial_ports():
|
|
port_map: dict[str, str] = {}
|
|
|
|
try:
|
|
import serial.tools.list_ports
|
|
|
|
usb_keywords = {
|
|
"usb", "cp210", "ftdi", "ch340", "ch9102", "esp",
|
|
"serial", "uart", "prolific", "silabs",
|
|
}
|
|
skip_keywords = {"bluetooth", "bt-", "btle", "airpods", "handsfree"}
|
|
|
|
for port in serial.tools.list_ports.comports():
|
|
combined = (port.device + " " + (port.description or "") +
|
|
" " + (port.hwid or "")).lower()
|
|
|
|
if any(s in combined for s in skip_keywords):
|
|
continue
|
|
if any(k in combined for k in usb_keywords):
|
|
port_map[port.device] = port.description or "USB Serial Device"
|
|
|
|
except ImportError:
|
|
pass
|
|
|
|
for device in list_likely_serial_ports():
|
|
if device not in port_map:
|
|
port_map[device] = "Detected via glob pattern"
|
|
|
|
return sorted(port_map.items())
|
|
|
|
|
|
def prompt_for_upload_port():
|
|
print("\n[3/3] Select serial port for flashing...")
|
|
|
|
max_retries = 3
|
|
|
|
for attempt in range(1, max_retries + 1):
|
|
print(f"\n Attempt {attempt}/{max_retries}")
|
|
print(" Ensure your xteink device is connected via USB and awake.")
|
|
input(" Press Enter when ready... ")
|
|
|
|
ports = detect_serial_ports()
|
|
|
|
if ports:
|
|
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
|
|
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
|
|
|
|
print(f"\n Found {len(ports)} serial port(s):")
|
|
for i, (device, desc) in enumerate(ports, 1):
|
|
tag = " ← recommended" if device == recommended else ""
|
|
print(f" {i}. {device} — {desc}{tag}")
|
|
|
|
choice = input(f"\n Press ENTER to use {recommended}, or enter 1-{len(ports)}: ").strip()
|
|
|
|
if not choice:
|
|
print(f" Selected: {recommended}")
|
|
return recommended
|
|
|
|
if choice.isdigit():
|
|
idx = int(choice) - 1
|
|
if 0 <= idx < len(ports):
|
|
print(f" Selected: {ports[idx][0]}")
|
|
return ports[idx][0]
|
|
|
|
print(" Invalid choice, please try again.")
|
|
|
|
else:
|
|
print(" No USB serial ports detected.")
|
|
if attempt < max_retries:
|
|
print(" Make sure the device is connected, powered on, and awake.")
|
|
|
|
print("\n Could not auto-detect a port.")
|
|
print(" Common names: macOS: /dev/cu.usb* Linux: /dev/ttyUSB* Windows: COMx")
|
|
|
|
while True:
|
|
port = input("\n Enter port manually (or 'quit' to exit): ").strip()
|
|
if port.lower() == "quit":
|
|
sys.exit(1)
|
|
if port:
|
|
return port
|
|
print(" Please enter a valid port name.")
|
|
|
|
|
|
def prompt_for_baud_rate(override: str | None, yes_all: bool = False):
|
|
if override:
|
|
print(f"\n Upload baud rate: {override} (--baud)")
|
|
return override
|
|
|
|
if yes_all:
|
|
print(f"\n Upload baud rate: {DEFAULT_BAUD} (--yes)")
|
|
return DEFAULT_BAUD
|
|
|
|
print("\n Select upload baud rate:")
|
|
for key, (rate, desc) in BAUD_RATES.items():
|
|
tag = " ← default" if rate == DEFAULT_BAUD else ""
|
|
print(f" {key}. {rate} — {desc}{tag}")
|
|
|
|
choice = input(
|
|
f"\n Press ENTER for default ({DEFAULT_BAUD}), enter 1-{len(BAUD_RATES)}, "
|
|
"or type a custom rate: "
|
|
).strip()
|
|
|
|
if not choice:
|
|
return DEFAULT_BAUD
|
|
if choice in BAUD_RATES:
|
|
return BAUD_RATES[choice][0]
|
|
if choice.isdigit():
|
|
return choice
|
|
|
|
print(f" Invalid input, using default ({DEFAULT_BAUD}).")
|
|
return DEFAULT_BAUD
|
|
|
|
|
|
def build_and_flash(environment: str, baud_rate: str):
|
|
print(f"\nBuilding firmware with PlatformIO (environment: {environment})...")
|
|
run(f"pio run --environment {environment}", cwd=REPO_DIR)
|
|
|
|
port = prompt_for_upload_port()
|
|
|
|
local_ini = os.path.join(REPO_DIR, "platformio.local.ini")
|
|
with open(local_ini, "w") as f:
|
|
f.write(f"[env:{environment}]\nupload_speed = {baud_rate}\n")
|
|
|
|
print(f"\n Flashing to {port} at {baud_rate} baud...")
|
|
run(
|
|
f"pio run --target upload --environment {environment} --upload-port {port}",
|
|
cwd=REPO_DIR,
|
|
)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="xteink Plugin Installer",
|
|
formatter_class=argparse.RawTextHelpFormatter,
|
|
)
|
|
env_help = "\n".join(f" {name}: {desc}" for name, desc in ENVIRONMENTS.items())
|
|
parser.add_argument(
|
|
"-e", "--environment",
|
|
default="default",
|
|
choices=ENVIRONMENTS.keys(),
|
|
metavar="ENV",
|
|
help=f"PlatformIO build environment (default: default)\n{env_help}",
|
|
)
|
|
parser.add_argument(
|
|
"-y", "--yes",
|
|
action="store_true",
|
|
help="Auto-answer Y to all plugin install prompts, and skip the "
|
|
"restore/backup prompts entirely (fast install, no backup)",
|
|
)
|
|
parser.add_argument(
|
|
"-b", "--baud",
|
|
default=None,
|
|
metavar="RATE",
|
|
help=(
|
|
"Upload baud rate override, skips the interactive prompt "
|
|
f"(default: {DEFAULT_BAUD}). Lower it (e.g. 115200) if flashing "
|
|
"fails with 'chip stopped responding'."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--no-reclone",
|
|
action="store_true",
|
|
dest="no_reclone",
|
|
help="Skip re-cloning if CrossPoint repo already exists and matches remote",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
print("=" * 60)
|
|
print(" xteink Plugin Installer")
|
|
print("=" * 60)
|
|
print()
|
|
print(" This script modifies and flashes custom firmware to your")
|
|
print(" device. The author of this script (Justin Oros) accepts")
|
|
print(" no responsibility for any damage that may occur to your")
|
|
print(" device as a result of using this installer.")
|
|
print()
|
|
print(f" Environment : {args.environment}")
|
|
print(f" {ENVIRONMENTS[args.environment]}")
|
|
print()
|
|
answer = input(" Do you wish to proceed? [Y/n]: ").strip().lower() if not args.yes else "y"
|
|
if answer not in ("", "y", "yes"):
|
|
print(" Aborted.")
|
|
sys.exit(0)
|
|
print()
|
|
restore_from_backup(yes_all=args.yes)
|
|
backup_device(yes_all=args.yes)
|
|
clone_repo(force=not args.no_reclone)
|
|
clear_plugin_caches()
|
|
chosen_plugins = select_plugins(yes_all=args.yes)
|
|
apply_plugins(chosen_plugins, yes_all=args.yes)
|
|
baud_rate = prompt_for_baud_rate(args.baud, yes_all=args.yes)
|
|
build_and_flash(args.environment, baud_rate)
|
|
print("\nDone.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|