diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 904e42e..ffcf837 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,18 +32,18 @@ jobs: PR_NUMBER='${{ github.event.pull_request.number }}' echo "GIT_VER=PR-${PR_NUMBER}" >> $GITHUB_ENV - # Build merged i18n JSON only for this locale (into scripts/dashx/i18n/.json) + # Build merged i18n JSON only for this locale (into src/dashx/i18n/.json) - name: Build merged i18n JSON (${{ matrix.lang }}) run: | python bin/i18n/build-single-json.py --only '${{ matrix.lang }}' - test -f "scripts/dashx/i18n/${{ matrix.lang }}.json" + test -f "src/dashx/i18n/${{ matrix.lang }}.json" # Stage scripts/ into build//scripts - name: Stage scripts tree run: | rm -rf "${{ env.STAGE }}" mkdir -p "${{ env.STAGE }}" - cp -a scripts/. "${{ env.STAGE }}/" + cp -a src/. "${{ env.STAGE }}/" # Resolve @i18n(...)@ tags in the staged tree using the staged locale file - name: Resolve i18n tags (locale=${{ matrix.lang }}) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index fc9565f..f2e68fa 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -32,7 +32,7 @@ jobs: echo "GIT_VER=commit-${SHORT_SHA}" >> $GITHUB_ENV echo "sha7=${SHORT_SHA}" >> $GITHUB_OUTPUT - # Build merged JSON for the specific locale (writes to scripts/dashx/i18n/.json) + # Build merged JSON for the specific locale (writes to src/dashx/i18n/.json) - name: Build i18n for this locale run: python bin/i18n/build-single-json.py --only ${{ matrix.lang }} @@ -42,7 +42,7 @@ jobs: run: | STAGE="build/${{ matrix.lang }}/scripts" mkdir -p "$STAGE" - rsync -a scripts/ "$STAGE"/ + rsync -a src/ "$STAGE"/ echo "STAGE=$STAGE" >> $GITHUB_ENV # Update version in the staged copy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0db53b2..36c5671 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: run: | STAGE="build/${{ matrix.lang }}/scripts" mkdir -p "$STAGE" - rsync -a scripts/ "$STAGE"/ + rsync -a src/ "$STAGE"/ echo "STAGE=$STAGE" >> $GITHUB_ENV - name: Resolve i18n tags (locale=${{ matrix.lang }}) diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 85fe35c..433f8b6 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -46,7 +46,7 @@ jobs: run: | STAGE="build/${{ matrix.lang }}/scripts" mkdir -p "$STAGE" - rsync -a scripts/ "$STAGE"/ + rsync -a src/ "$STAGE"/ echo "STAGE=$STAGE" >> $GITHUB_ENV - name: Resolve i18n tags (locale=${{ matrix.lang }}) diff --git a/.gitignore b/.gitignore index a358891..11ddf39 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ bin/deploy/vscode/__pycache__/*.pyo bin/deploy/vscode/__pycache__/subprocess_conout.cpython-312.pyc /bin/sound-generator/soundpack/user/*.wav .vscode/scripts/__pycache__/subprocess_conout.cpython-312.pyc +simulator/* \ No newline at end of file diff --git a/.vscode/deploy.json b/.vscode/deploy.json new file mode 100644 index 0000000..23b697c --- /dev/null +++ b/.vscode/deploy.json @@ -0,0 +1,10 @@ +{ + "tgt_name": "dashx", + "ethossuite_bin": "C:\\Program Files\\Ethos Suite\\Ethos Suite.exe", + "serial_vid": "0483", + "serial_pid": "5750", + "serial_baud": 115200, + "serial_retries": 10, + "serial_retry_delay": 1.0, + "serial_name_hint": "FrSky" +} diff --git a/.vscode/launch.json b/.vscode/launch.json index aeb9790..1865523 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,202 +2,97 @@ "version": "0.2.0", "configurations": [ { - "name": "Deploy & Launch", + "name": "Deploy & Launch [SIM]", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--launch", - "--lang", "en", - ], - "console": "integratedTerminal", - "subProcess": false, // prevents auto-attach to child procs - "justMyCode": true, - "stopOnEntry": false, - }, - { - "name": "Deploy & Launch [Fast]", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--fileext", "fast", - "--launch", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, + "preLaunchTask": "Deploy & Launch [SIM]", + "postDebugTask": "ethos.Start" }, { - "name": "Deploy & Choose", + "name": "Deploy Radio", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--choose", - "--launch", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, + + "preLaunchTask": "Deploy Radio" }, { - "name": "Deploy & Choose [Fast]", + "name": "Deploy Radio [Fast]", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--fileext", "fast", - "--choose", - "--launch", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, + + "preLaunchTask": "Deploy Radio [Fast]" }, { - "name": "Deploy & Launch [choose lang]", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--launch", - "--lang", "${input:i18nLocale}", - ], - "console": "integratedTerminal", - "subProcess": false, // prevents auto-attach to child procs - "justMyCode": true, - "stopOnEntry": false, - }, - { - "name": "Deploy & Choose [choose lang]", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--choose", - "--launch", - "--lang", "${input:i18nLocale}", - ], - "console": "integratedTerminal", - "subProcess": false, - "justMyCode": true, - "stopOnEntry": false, - }, - { - "name": "Deploy Radio", + "name": "Deploy Radio + Serial Debug", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--radio", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, + + "preLaunchTask": "Deploy Radio + Serial Debug" }, { - "name": "Deploy Radio [Fast]", + "name": "Deploy Radio + Serial Debug [Fast]", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--fileext", "fast", - "--radio", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, + + "preLaunchTask": "Deploy Radio + Serial Debug [Fast]" }, { - "name": "Deploy Radio + Serial Debug", + "name": "Radio: Serial Debug", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--radio", - "--radio-debug", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, + + "preLaunchTask": "Radio: Serial Debug" }, { - "name": "Deploy Radio + Serial Debug [Fast]", + "name": "Switch Ethos Firmware", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--fileext", "fast", - "--radio", - "--radio-debug", - "--lang", "en", - ], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "subProcess": false, "justMyCode": true, "stopOnEntry": false, - }, - { - "name": "Radio: Serial Debug", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": [ - "--config-env", "DASHX_CONFIG", - "--radio", - "--connect-only", - "--lang", "en", - ], - "console": "integratedTerminal" - }, + "postDebugTask": "ethos.SetFirmware" + }, { "name": "Clear locks", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/.vscode/scripts/deploy.py", - "args": ["--config-env", "DASHX_CONFIG", "--clear-lock"], + "program": "${workspaceFolder}/.vscode/scripts/dummy.py", "console": "integratedTerminal", "justMyCode": true, - "noDebug": true - } - ], - "inputs": [ - { - "id": "i18nLocale", - "type": "pickString", - "description": "Choose language", - "options": [ - "en", - "de", - "fr", - "es", - "it", - "nl", - ], - "default": "en" + + "preLaunchTask": "Clear locks" } ] } diff --git a/.vscode/scripts/__pycache__/subprocess_conout.cpython-312.pyc b/.vscode/scripts/__pycache__/subprocess_conout.cpython-312.pyc index cae0deb..24235db 100644 Binary files a/.vscode/scripts/__pycache__/subprocess_conout.cpython-312.pyc and b/.vscode/scripts/__pycache__/subprocess_conout.cpython-312.pyc differ diff --git a/.vscode/scripts/__pycache__/subprocess_conout.cpython-313.pyc b/.vscode/scripts/__pycache__/subprocess_conout.cpython-313.pyc new file mode 100644 index 0000000..c0e40e1 Binary files /dev/null and b/.vscode/scripts/__pycache__/subprocess_conout.cpython-313.pyc differ diff --git a/.vscode/scripts/deploy.py b/.vscode/scripts/deploy.py index 2cd1856..eb6014d 100644 --- a/.vscode/scripts/deploy.py +++ b/.vscode/scripts/deploy.py @@ -14,19 +14,20 @@ import platform from hashlib import md5 from glob import glob +from pathlib import Path MIN_ETHOSSUITE_VERSION = "1.7.0" -SERIAL_PIDFILE = os.path.join(tempfile.gettempdir(), "rfdeploy-serial.pid") +SERIAL_PIDFILE = os.path.join(tempfile.gettempdir(), "deploy-serial.pid") DEPLOY_TO_RADIO = False # flag to control radio-only behavior THROTTLE_EXTS = None # unused when throttling all copies THROTTLE_MIN_BYTES = 0 # unused when throttling all copies -THROTTLE_CHUNK = 32 * 1024 # 16 KiB -THROTTLE_PAUSE_EVERY = 64 * 1024 # pause+fsync every 64 KiB written -THROTTLE_PAUSE_S = 0.1 # 200 ms +THROTTLE_CHUNK = 32 * 1024 # 32 KiB +THROTTLE_PAUSE_EVERY = 64 * 1024 # pause+fsync every 64 KiB written +THROTTLE_PAUSE_S = 0.1 # 100 ms # --- single-instance lock helpers -------------------------------------------- -LOCK_DEFAULT_NAME = "rfdeploy.single.lock" +LOCK_DEFAULT_NAME = "deploy.single.lock" if os.name == "nt": import msvcrt else: @@ -130,7 +131,7 @@ def release(self): def _lock_path_for_config(config_path: str) -> str: """Compute the exact lock file path used for this project/config.""" proj_key = md5(os.path.abspath(config_path).encode("utf-8")).hexdigest()[:8] - lock_name = f"rfdeploy-{proj_key}.lock" + lock_name = f"deploy-{proj_key}.lock" return os.path.join(tempfile.gettempdir(), lock_name) def parse_version(v: str): @@ -210,10 +211,10 @@ def _record_tail_pid_and_cleanup(): pass atexit.register(lambda: os.path.exists(SERIAL_PIDFILE) and os.remove(SERIAL_PIDFILE)) + def throttled_copyfile(src, dst): os.makedirs(os.path.dirname(dst), exist_ok=True) written_since_pause = 0 - # raw (unbuffered) file handles so we control write cadence precisely with open(src, 'rb', buffering=0) as fsrc, open(dst, 'wb', buffering=0) as fdst: while True: chunk = fsrc.read(THROTTLE_CHUNK) @@ -230,64 +231,21 @@ def throttled_copyfile(src, dst): time.sleep(THROTTLE_PAUSE_S) written_since_pause = 0 - # final drain fdst.flush() try: os.fsync(fdst.fileno()) except OSError: pass - # Match shutil.copy's behavior of preserving basic permission bits try: shutil.copymode(src, dst) except Exception: pass - -def minify_lua_file(filepath): - print(f"[MINIFY] (luamin) Processing: {filepath}") - - # Try to resolve luamin executable - luamin_cmd = shutil.which("luamin") - - if not luamin_cmd: - # Fallback for Windows NPM global installs - luamin_cmd = os.path.expandvars(r"%APPDATA%\\npm\\luamin.cmd") - - if not os.path.exists(luamin_cmd): - print("[MINIFY ERROR] 'luamin' not found in PATH or %APPDATA%\\npm.") - print("Please run: npm install -g luamin") - return - - try: - result = subprocess.run( - [luamin_cmd, '-f', filepath], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding='utf-8', # ✅ force proper decoding - errors='replace' # ✅ optional: replace invalid chars to avoid crash - ) - - if result.returncode != 0: - print(f"[MINIFY ERROR] Failed to minify {filepath}:\n{result.stderr}") - return - - with open(filepath, 'w', encoding='utf-8') as f: - f.write(result.stdout) - - #print(f"[MINIFY] (luamin) Done: {filepath}") - - except Exception as e: - print(f"[MINIFY ERROR] Exception during luamin run: {e}") - def get_ethos_scripts_dir(ethossuite_bin, retries=1, delay=5): """ - Ask Ethos Suite for the SCRIPTS path. Robust against chatty output: - - Prefer explicit path lines (e.g. 'E:\\scripts') - - Else parse the 'New removable disks: [...]' blob and use mountpoint + '\\scripts' - - Else fall back to scanning drive letters for an existing '\\scripts' + Ask Ethos Suite for the SCRIPTS path. Robust against chatty output. """ import re, json, string @@ -296,11 +254,15 @@ def get_ethos_scripts_dir(ethossuite_bin, retries=1, delay=5): def _clean(line: str) -> str: line = (line or "").strip().strip('"').strip("'") - if not line: return "" + if not line: + return "" low = line.lower() - if low.startswith("exit code"): return "" - if low.startswith("new removable disks"): return line # keep for JSON parse - if line.startswith("{") or line.startswith("["): return line + if low.startswith("exit code"): + return "" + if low.startswith("new removable disks"): + return line # keep for JSON parse + if line.startswith("{") or line.startswith("["): + return line return line def _scan_drives_for_scripts(): @@ -322,27 +284,21 @@ def _scan_drives_for_scripts(): raw = res.stdout or "" lines = [_clean(l) for l in raw.splitlines() if l.strip()] - # 1) Prefer explicit path-like lines candidates = [l for l in lines if path_re.match(l)] existing = [os.path.normpath(p) for p in candidates if os.path.isdir(os.path.normpath(p))] if existing: - # Prefer the one whose basename is 'scripts' preferred = [p for p in existing if os.path.basename(p).lower() == "scripts"] return preferred[0] if preferred else existing[0] if candidates: - # If nothing exists yet, return the most plausible; wait_for_scripts_mount() will verify preferred = [p for p in candidates if "scripts" in p.lower()] return os.path.normpath(preferred[0] if preferred else candidates[0]) - # 2) Parse the "New removable disks: [...]" blob if present blob_lines = [l for l in lines if l.lower().startswith("new removable disks")] if blob_lines: - # Extract JSON array portion (first '[' ... last ']') blob = blob_lines[-1] try: start = blob.index('['); end = blob.rindex(']') + 1 arr = json.loads(blob[start:end]) - # Prefer the entry with radioDisk == 'radio', else first that has a mountpoint choice = None for item in arr: if item.get("radioDisk") == "radio": @@ -359,12 +315,10 @@ def _scan_drives_for_scripts(): except Exception: pass - # 3) Last resort: scan mounted drive letters for an existing \scripts scanned = _scan_drives_for_scripts() if scanned: return scanned - # No usable hint; let caller retry raise RuntimeError("No path-like output from Ethos Suite; mount not ready yet.") except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired, RuntimeError) as e: last_err = e @@ -374,119 +328,9 @@ def _scan_drives_for_scripts(): else: raise last_err - """ - Ask Ethos Suite for the SCRIPTS path. Returns a normalized existing path. - Skips noisy lines like "New removable disks: [...]". - """ - import re - - cmd = [ethossuite_bin, "--get-path", "SCRIPTS", "--radio", "auto"] - path_re = re.compile(r'^(?:[A-Za-z]:\\|\\\\\?\\|//)[^\r\n]+$') # Windows drive, \\?\ or UNC - - def _clean(line: str) -> str: - # Strip quotes and whitespace - line = line.strip().strip('"').strip("'") - # Ignore obvious noise - if not line: - return "" - if line.lower().startswith("exit code"): - return "" - if line.lower().startswith("new removable disks"): - return "" - if line.startswith("{") or line.startswith("["): - return "" - return line - - last_err = None - for attempt in range(retries + 1): - try: - res = subprocess.run(cmd, text=True, capture_output=True, timeout=15) - if res.returncode != 0: - raise subprocess.CalledProcessError(res.returncode, cmd, output=res.stdout, stderr=res.stderr) - - raw_lines = (res.stdout or "").splitlines() - # Clean and filter - candidates = [] - for l in raw_lines: - c = _clean(l) - if not c: - continue - # Prefer things that look like paths, ideally ending with "scripts" - if path_re.match(c): - candidates.append(c) - - # Heuristics: prefer existing paths, then those that contain \scripts - existing = [os.path.normpath(p) for p in candidates if os.path.isdir(os.path.normpath(p))] - if existing: - # If multiple, prefer one whose basename is 'scripts' - preferred = [p for p in existing if os.path.basename(p).lower() == "scripts"] - return preferred[0] if preferred else existing[0] - - # If nothing exists yet, still try best-looking candidate (may mount a moment later) - if candidates: - # Prefer entries that contain 'scripts' - preferred = [p for p in candidates if "scripts" in p.lower()] - return os.path.normpath(preferred[0] if preferred else candidates[0]) - - # No usable line; fall through to retry - raise RuntimeError("No path-like output from Ethos Suite.") - - except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired, RuntimeError) as e: - last_err = e - if attempt < retries: - print(f"[ETHOS] Could not get SCRIPTS path (attempt {attempt+1}/{retries+1}). Retrying in {delay}s…") - time.sleep(delay) - else: - raise last_err - - - """ - Ask Ethos Suite for the SCRIPTS path. Retries after `delay` seconds - if the tool returns no path or fails. Raises on final failure. - """ - cmd = [ethossuite_bin, "--get-path", "SCRIPTS", "--radio", "auto"] - last_err = None - - for attempt in range(retries + 1): - try: - result = subprocess.run( - cmd, - text=True, - capture_output=True, - timeout=15 - ) - - if result.returncode != 0: - raise subprocess.CalledProcessError( - result.returncode, cmd, output=result.stdout, stderr=result.stderr - ) - lines = [l.strip() for l in (result.stdout or "").splitlines() if l.strip()] - if not lines: - raise RuntimeError("No output from Ethos Suite.") - - # If the last line starts with 'exit code', grab the previous one - if lines[-1].lower().startswith("exit code") and len(lines) >= 2: - path_line = lines[-2] - else: - path_line = lines[-1] - - return os.path.normpath(path_line) - - except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired, RuntimeError) as e: - last_err = e - if attempt < retries: - print(f"[ETHOS] Could not get SCRIPTS path (attempt {attempt+1}/{retries+1}). Retrying in {delay}s…") - time.sleep(delay) - else: - raise last_err - -# Permission handler for Windows rm errors def on_rm_error(func, path, exc_info): - """Resilient rm error handler: - - ignore if the path vanished - - chmod + retry with small backoff for common transient Windows errors - """ + """Resilient rm error handler.""" e = exc_info[1] if isinstance(e, FileNotFoundError): return @@ -502,7 +346,6 @@ def on_rm_error(func, path, exc_info): except FileNotFoundError: return except OSError as oe: - # 5=Access denied, 32=Sharing violation, 483=Fatal device hw error if getattr(oe, "winerror", 0) in (5, 32, 483): time.sleep(0.2 * (i + 1)) continue @@ -518,14 +361,12 @@ def flush_fs(): print(f"[WARN] os.sync failed: {e}") -# Throttled, progress-bar deletion to play nice with slow devices DELETE_BATCH = 1 DELETE_PAUSE_S = 0.10 # 100ms def throttled_rmtree(root, batch=DELETE_BATCH, pause=DELETE_PAUSE_S): if not os.path.exists(root): return - # Collect files for a stable progress bar files = [] for dp, _, fs in os.walk(root): for f in fs: @@ -550,8 +391,6 @@ def throttled_rmtree(root, batch=DELETE_BATCH, pause=DELETE_PAUSE_S): time.sleep(pause * (attempt + 1)) attempt += 1 continue - # Give up on odd errors rather than crash the whole deploy - # (old file may be locked by AV or indexing; we'll try removing the directory later) break bar.update(1) @@ -561,7 +400,6 @@ def throttled_rmtree(root, batch=DELETE_BATCH, pause=DELETE_PAUSE_S): bar.close() - # Remove directories bottom-up; ignore stubborn remnants for dp, dns, _ in sorted(os.walk(root), key=lambda t: t[0], reverse=True): for d in dns: try: @@ -587,29 +425,21 @@ def delete_tree(path): time.sleep(DELETE_PAUSE_S) - def safe_full_copy(srcall, out_dir): - """Safer full-copy for slow FAT32 targets: - - If current target exists, rotate it to '.old' - - Delete the '.old' folder - - Copy new files freshly into '' - Includes small delays + best-effort flushes to give the device time to settle. - """ + """Safer full-copy for slow FAT32 targets.""" global pbar if os.path.isdir(out_dir): old_dir = out_dir + ".old" - # If a previous backup exists, remove it first if os.path.isdir(old_dir): print("Deleting previous backup…") delete_tree(old_dir) flush_fs() time.sleep(2) - # Rotate current folder to .old try: print(f"Renaming existing to {os.path.basename(old_dir)}…") - os.replace(out_dir, old_dir) # Atomic on same volume + os.replace(out_dir, old_dir) except Exception as e: print(f"[WARN] Rename failed ({e}). Falling back to direct delete.") print("Deleting files…") @@ -617,7 +447,6 @@ def safe_full_copy(srcall, out_dir): flush_fs() time.sleep(2) - # Delete the rotated .old folder if os.path.isdir(old_dir): print("Deleting files…") delete_tree(old_dir) @@ -630,46 +459,55 @@ def safe_full_copy(srcall, out_dir): shutil.copytree(srcall, out_dir, dirs_exist_ok=True, copy_function=copy_verbose) pbar.close() -# parse args early so we can know --config-env -_p = argparse.ArgumentParser(add_help=False) -_p.add_argument("--config-env", help="Name of env var that contains the config path") -_early, _ = _p.parse_known_args() +# --- config: derive repo root and load deploy.json ---------------------------- +ROOT = Path(__file__).resolve().parents[2] -env_name = _early.config_env -CONFIG_PATH = os.environ.get(env_name) +ROOT_CONFIG = ROOT / "deploy.json" +VSCODE_CONFIG = ROOT / ".vscode/deploy.json" -if not CONFIG_PATH: - print(f"[CONFIG ERROR] Environment variable '{env_name}' is not set.") - sys.exit(1) +config = { + "git_src": str(ROOT), +} -if not os.path.exists(CONFIG_PATH): - print(f"[CONFIG ERROR] Config file not found at path: {CONFIG_PATH}") +cfg_path = None +if ROOT_CONFIG.exists(): + cfg_path = ROOT_CONFIG +elif VSCODE_CONFIG.exists(): + cfg_path = VSCODE_CONFIG +else: + print("[ERROR] No deploy.json found in git root or .vscode/") sys.exit(1) +# NEW: Debug which base config file we are using +print(f"[CONFIG] Using base config file: {cfg_path}") + try: - with open(CONFIG_PATH) as f: - config = json.load(f) -except json.JSONDecodeError as e: - print(f"[CONFIG ERROR] Failed to parse JSON config file: {e}") + with open(cfg_path, "r") as f: + user_cfg = json.load(f) + if not isinstance(user_cfg, dict): + raise ValueError("Config JSON must contain an object at the top level.") + config.update(user_cfg) +except Exception as e: + print(f"[ERROR] Failed to load config: {e}") sys.exit(1) -pbar = None +if "tgt_name" not in config or not config["tgt_name"]: + print(f"[ERROR] Missing 'tgt_name' in {cfg_path}. Aborting.") + sys.exit(1) + +CONFIG_PATH = str(cfg_path) +pbar = None # === Ethos Serial + Serial Tail helpers ======================================= -DEFAULT_SERIAL_VID = "0483" # STM32 VCP / HID used by FrSky radios in Ethos logs +DEFAULT_SERIAL_VID = "0483" DEFAULT_SERIAL_PID = "5750" DEFAULT_SERIAL_BAUD = 115200 DEFAULT_SERIAL_RETRIES = 10 -DEFAULT_SERIAL_DELAY = 1.0 # seconds between attempts +DEFAULT_SERIAL_DELAY = 1.0 def ethos_serial(ethossuite_bin, action, radio=None): - """ - Start/stop Ethos serial debug mode. - action: 'start' or 'stop' - Returns (rc, stdout, stderr) and prints tool output. - """ cmd = [ethossuite_bin, "--serial", action, "--radio", "auto"] if radio: cmd += ["--radio", radio] @@ -688,9 +526,6 @@ def ethos_serial(ethossuite_bin, action, radio=None): def wait_for_scripts_mount(ethossuite_bin, attempts=10, delay=2): - """ - Poll Ethos for the SCRIPTS path until the directory actually exists. - """ last_err = None for i in range(attempts): try: @@ -705,8 +540,6 @@ def wait_for_scripts_mount(ethossuite_bin, attempts=10, delay=2): raise RuntimeError(f"Radio drive did not mount: {last_err}") - - def _find_com_port_by_vid_pid(vid_hex, pid_hex): try: from serial.tools import list_ports @@ -727,19 +560,15 @@ def _find_com_port(vid_hex=None, pid_hex=None, name_hint=None, allow_fuzzy_if_no print("[SERIAL] No serial ports detected.") return None - # Debug dump so you can see what's actually present print("[SERIAL] Detected ports:") for p in ports: desc = p.description or '' iface = getattr(p, 'interface', None) or '' print(f" - device={p.device} vid={p.vid} pid={p.pid} desc='{desc}' iface='{iface}' hwid='{p.hwid}'") - # 1) Strict VID/PID match if provided - # Helper to parse PID from the HWID string (e.g. 'USB VID:PID=0483:5750 ...') def _pid_from_hwid(hw): try: hw = hw or "" - # find ...PID=hhhh:pppp... token = "PID=" if token in hw: rhs = hw.split(token, 1)[1] @@ -749,7 +578,6 @@ def _pid_from_hwid(hw): pass return None - # 1) Strict VID/PID match if provided (no fuzzy fallback) if vid_hex and pid_hex: try: vid = int(vid_hex, 16) @@ -763,11 +591,9 @@ def _pid_from_hwid(hw): return p.device except Exception: pass - # If strict match not found, be conservative: keep waiting; do NOT choose another PID. print(f"[SERIAL] No exact VID:PID match yet ({vid_hex}:{pid_hex}). Will keep scanning.") return None - # 2) If only VID is known, prefer same-VID ports, and among them prefer PID from hwid/attr == desired if vid_hex and not pid_hex: try: vid = int(vid_hex, 16) @@ -781,19 +607,16 @@ def _pid_from_hwid(hw): except Exception: pass if candidates: - # Prefer 5750 if present (either via attribute or parsed from HWID) def _score(cp): cp_pid = getattr(cp, "pid", None) hw_pid = _pid_from_hwid(getattr(cp, "hwid", None)) if prefer_pid_from_hwid else None pid_val = cp_pid if cp_pid is not None else hw_pid - # Highest priority for 0x5750, then anything else but de-prioritize 0x5740 if pid_val == 0x5750: return 0 if pid_val == 0x5740: return 2 return 1 best = sorted(candidates, key=_score)[0] return best.device - # 3) Only if no VID/PID are provided AND allowed, use fuzzy hints if allow_fuzzy_if_no_vidpid and not vid_hex and not pid_hex: hints = [h.lower() for h in [name_hint, "frsky", "serial", "stm", "vcp", "x20", "x18", "x14"] if h] for p in ports: @@ -803,33 +626,20 @@ def _score(cp): return None - vid = int(vid_hex, 16) - pid = int(pid_hex, 16) - for p in list_ports.comports(): - try: - if p.vid == vid and p.pid == pid: - return p.device # e.g. 'COM7' - except Exception: - continue - return None - def tail_serial_debug(vid=DEFAULT_SERIAL_VID, pid=DEFAULT_SERIAL_PID, baud=DEFAULT_SERIAL_BAUD, retries=DEFAULT_SERIAL_RETRIES, delay=DEFAULT_SERIAL_DELAY, newline=b'\n', name_hint="Serial"): - "Try to attach to the radio serial log N times; print lines until Ctrl+C." try: import serial except Exception: print("[SERIAL] pyserial not installed. Install with: pip install pyserial") return 2 - # Give Windows a moment to enumerate after enabling serial time.sleep(1.5) port = None for i in range(retries): - # Strict VID/PID only; do not fall back to fuzzy when VID/PID are known port = _find_com_port(vid_hex=vid, pid_hex=pid, name_hint=name_hint, allow_fuzzy_if_no_vidpid=False) if port: @@ -842,8 +652,6 @@ def tail_serial_debug(vid=DEFAULT_SERIAL_VID, pid=DEFAULT_SERIAL_PID, return 3 print(f"[SERIAL] Connecting to {port} @ {baud} …") - # Some Windows setups expose the COM device and then rebind the driver briefly. - # Try multiple times to open the port before giving up; if it disappears, rescan. open_attempts = 8 for attempt in range(1, open_attempts+1): try: @@ -851,13 +659,11 @@ def tail_serial_debug(vid=DEFAULT_SERIAL_VID, pid=DEFAULT_SERIAL_PID, break except FileNotFoundError as e: print(f"[SERIAL] Open attempt {attempt}/{open_attempts} -> device vanished; rescanning…") - # Rescan for a replacement port that matches port = _find_com_port(vid_hex=vid, pid_hex=pid, name_hint=name_hint) if not port: import time as _t; _t.sleep(delay) continue except Exception as e: - # e.g. PermissionError / SerialException("Access is denied"), or still binding print(f"[SERIAL] Open attempt {attempt}/{open_attempts} failed: {e}") import time as _t; _t.sleep(delay) continue @@ -897,11 +703,11 @@ def tail_serial_debug(vid=DEFAULT_SERIAL_VID, pid=DEFAULT_SERIAL_PID, def copy_verbose(src, dst): pbar.update(1) if DEPLOY_TO_RADIO: - throttled_copyfile(src, dst) # paced writes for the radio - flush_fs(); - time.sleep(0.05) + throttled_copyfile(src, dst) + flush_fs() + time.sleep(0.05) else: - shutil.copy(src, dst) # normal fast copy for everything else + shutil.copy(src, dst) def count_files(dirpath, ext=None): @@ -912,48 +718,63 @@ def count_files(dirpath, ext=None): total += len(files) return total -# Interactive chooser -def choose_target(targets): - print("Available targets:") - for i, t in enumerate(targets, 1): - mark = '*' if t.get('default') else ' ' - print(f" [{i}] {t['name']} {mark}") - idx = None - while idx is None: - try: - sel = int(input("Select number: ")) - if 1 <= sel <= len(targets): - idx = sel - 1 - else: - print("Out of range") - except ValueError: - print("Enter a number") - return [targets[idx]] - - -def resolve_i18n_tags_in_place(out_dir, lang="en"): - # Path to the merged JSON created by step 1 - json_path = os.path.join(out_dir, "i18n", f"{lang}.json") # because scripts/dashx/** got copied already - if not os.path.isfile(json_path): - # Fallback for local build before copy, if needed: - json_path = os.path.join(config['git_src'], "scripts", "dashx", "i18n", f"{lang}.json") - if not os.path.isfile(json_path): - print(f"[I18N] Skipping: {lang}.json not found at {json_path}") - return - # Call the standalone resolver - import subprocess, sys - resolver = os.path.join(config['git_src'], ".vscode", "scripts", "resolve_i18n_tags.py") - if not os.path.isfile(resolver): - print(f"[I18N] Skipping: resolver not found at {resolver}") +def run_step_script(step, out_dir, lang="en"): + """ + Generic step runner. + + Conventions: + - If `step` ends with '.py' or contains a path separator, + treat it as a path (absolute or relative to git_src). + - Otherwise, look for: + /.vscode/scripts/deploy_step_.py + The step script is called as: + python