From bb9ddc0def6abdb70a511e5c10a837b0ef36ff13 Mon Sep 17 00:00:00 2001
From: Taylor Helsper
Date: Tue, 14 Jul 2026 20:47:07 -0500
Subject: [PATCH 01/11] Add mocked UI dev mode (make ui-dev)
Run the real web UI in a Docker container with a fully mocked backend so
UI developers can iterate on templates/CSS/JS without a device, bitcoind,
LND, or systemd. Zero changes to production code: a PYTHONPATH shim layer
mocks the external touchpoints, a fixture filesystem recreates device
paths, and fake binaries absorb shell-outs. Includes a floating dev panel
to switch device states (syncing, upgrading, drive missing, ...), toggle
warnings, and install/uninstall apps via marker files.
Co-Authored-By: Claude Fable 5
---
Makefile | 20 +-
dev/ui-dev/Dockerfile | 23 +
dev/ui-dev/README.md | 107 ++++
dev/ui-dev/dev_blueprint.py | 526 ++++++++++++++++++
dev/ui-dev/dev_server.py | 56 ++
dev/ui-dev/docker-compose.yml | 30 +
dev/ui-dev/entrypoint.sh | 9 +
dev/ui-dev/fake_bin/bitcoin-cli | 35 ++
dev/ui-dev/fake_bin/dmesg | 14 +
dev/ui-dev/fake_bin/gen_seed.py | 2 +
dev/ui-dev/fake_bin/get_local_ip.py | 2 +
dev/ui-dev/fake_bin/journalctl | 38 ++
dev/ui-dev/fake_bin/lsb_release | 6 +
dev/ui-dev/fake_bin/lsusb | 12 +
dev/ui-dev/fake_bin/mynode-get-device-serial | 2 +
dev/ui-dev/fake_bin/mynode-get-device-type | 2 +
.../fake_bin/mynode-get-quicksync-status | 8 +
.../mynode_update_latest_version_files.sh | 16 +
dev/ui-dev/fake_bin/noop | 4 +
dev/ui-dev/fake_bin/systemctl | 57 ++
dev/ui-dev/fake_bin/torify | 3 +
dev/ui-dev/fixtures/bitcoin.json | 93 ++++
dev/ui-dev/fixtures/device.json | 18 +
dev/ui-dev/fixtures/lightning.json | 98 ++++
dev/ui-dev/fixtures/price.json | 3 +
dev/ui-dev/fixtures/services.json | 42 ++
dev/ui-dev/mock_pynode/_mockutil.py | 101 ++++
dev/ui-dev/mock_pynode/application_info.py | 87 +++
dev/ui-dev/mock_pynode/bitcoin_info.py | 131 +++++
dev/ui-dev/mock_pynode/device_info.py | 210 +++++++
dev/ui-dev/mock_pynode/drive_info.py | 30 +
dev/ui-dev/mock_pynode/electrum_info.py | 43 ++
dev/ui-dev/mock_pynode/lightning_info.py | 133 +++++
dev/ui-dev/mock_pynode/pam.py | 15 +
dev/ui-dev/mock_pynode/price_info.py | 42 ++
dev/ui-dev/mock_pynode/systemctl_info.py | 68 +++
dev/ui-dev/mock_pynode/thread_functions.py | 45 ++
dev/ui-dev/mock_pynode/transmissionrpc.py | 13 +
dev/ui-dev/requirements-dev.txt | 12 +
dev/ui-dev/seed_fixture_fs.py | 299 ++++++++++
40 files changed, 2454 insertions(+), 1 deletion(-)
create mode 100644 dev/ui-dev/Dockerfile
create mode 100644 dev/ui-dev/README.md
create mode 100644 dev/ui-dev/dev_blueprint.py
create mode 100644 dev/ui-dev/dev_server.py
create mode 100644 dev/ui-dev/docker-compose.yml
create mode 100644 dev/ui-dev/entrypoint.sh
create mode 100644 dev/ui-dev/fake_bin/bitcoin-cli
create mode 100644 dev/ui-dev/fake_bin/dmesg
create mode 100644 dev/ui-dev/fake_bin/gen_seed.py
create mode 100644 dev/ui-dev/fake_bin/get_local_ip.py
create mode 100644 dev/ui-dev/fake_bin/journalctl
create mode 100644 dev/ui-dev/fake_bin/lsb_release
create mode 100644 dev/ui-dev/fake_bin/lsusb
create mode 100644 dev/ui-dev/fake_bin/mynode-get-device-serial
create mode 100644 dev/ui-dev/fake_bin/mynode-get-device-type
create mode 100644 dev/ui-dev/fake_bin/mynode-get-quicksync-status
create mode 100644 dev/ui-dev/fake_bin/mynode_update_latest_version_files.sh
create mode 100644 dev/ui-dev/fake_bin/noop
create mode 100644 dev/ui-dev/fake_bin/systemctl
create mode 100644 dev/ui-dev/fake_bin/torify
create mode 100644 dev/ui-dev/fixtures/bitcoin.json
create mode 100644 dev/ui-dev/fixtures/device.json
create mode 100644 dev/ui-dev/fixtures/lightning.json
create mode 100644 dev/ui-dev/fixtures/price.json
create mode 100644 dev/ui-dev/fixtures/services.json
create mode 100644 dev/ui-dev/mock_pynode/_mockutil.py
create mode 100644 dev/ui-dev/mock_pynode/application_info.py
create mode 100644 dev/ui-dev/mock_pynode/bitcoin_info.py
create mode 100644 dev/ui-dev/mock_pynode/device_info.py
create mode 100644 dev/ui-dev/mock_pynode/drive_info.py
create mode 100644 dev/ui-dev/mock_pynode/electrum_info.py
create mode 100644 dev/ui-dev/mock_pynode/lightning_info.py
create mode 100644 dev/ui-dev/mock_pynode/pam.py
create mode 100644 dev/ui-dev/mock_pynode/price_info.py
create mode 100644 dev/ui-dev/mock_pynode/systemctl_info.py
create mode 100644 dev/ui-dev/mock_pynode/thread_functions.py
create mode 100644 dev/ui-dev/mock_pynode/transmissionrpc.py
create mode 100644 dev/ui-dev/requirements-dev.txt
create mode 100644 dev/ui-dev/seed_fixture_fs.py
diff --git a/Makefile b/Makefile
index 56b399efd..6f24a7f74 100644
--- a/Makefile
+++ b/Makefile
@@ -109,4 +109,22 @@ beta: clean_rootfs rootfs release.sh
.PHONY: clean
clean: stop_file_server
@rm -rf out/
- @rm -rf release.sh
\ No newline at end of file
+ @rm -rf release.sh
+
+# Run the web UI locally with a fully mocked backend (for UI development).
+# See dev/ui-dev/README.md. UI available at http://localhost:8888 (pw: bolt).
+.PHONY: ui-dev
+ui-dev:
+ @cd dev/ui-dev && docker compose up --build
+
+.PHONY: ui-dev-build
+ui-dev-build:
+ @cd dev/ui-dev && docker compose build
+
+.PHONY: ui-dev-shell
+ui-dev-shell:
+ @cd dev/ui-dev && docker compose exec ui-dev bash
+
+.PHONY: ui-dev-clean
+ui-dev-clean:
+ @cd dev/ui-dev && docker compose down -v --rmi local
diff --git a/dev/ui-dev/Dockerfile b/dev/ui-dev/Dockerfile
new file mode 100644
index 000000000..f2e56c43b
--- /dev/null
+++ b/dev/ui-dev/Dockerfile
@@ -0,0 +1,23 @@
+# Mocked UI dev container for the myNode web UI.
+# Runs the real Flask app from the repo (volume-mounted read-only at the same
+# paths used on a device) with a mock shim layer so no real backend is needed.
+FROM python:3.11-slim-bookworm
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends procps && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY requirements-dev.txt /tmp/requirements-dev.txt
+RUN pip install --no-cache-dir -r /tmp/requirements-dev.txt
+
+# Python path order matters:
+# 1. /opt/mynode/dev (dev server + dev blueprint)
+# 2. /opt/mynode/dev/mock_pynode (shim layer - shadows pynode/www module names)
+# 3. /var/www/mynode (real Flask app)
+# 4. /var/pynode (real backend library)
+# This mirrors the pynode.pth mechanism used on a real device (setup_device.sh).
+ENV PYTHONPATH=/opt/mynode/dev/mock_pynode:/var/www/mynode:/var/pynode \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /opt/mynode/dev
+CMD ["bash", "/opt/mynode/dev/entrypoint.sh"]
diff --git a/dev/ui-dev/README.md b/dev/ui-dev/README.md
new file mode 100644
index 000000000..e5429d7de
--- /dev/null
+++ b/dev/ui-dev/README.md
@@ -0,0 +1,107 @@
+# myNode UI Dev Mode (mocked backend)
+
+Run the real myNode web UI in a Docker container with a fully mocked backend —
+no bitcoind, no LND, no systemd, no real device. For developing and testing UI
+changes (templates, CSS, JS, Flask routes) with instant hot reload.
+
+## Quick start
+
+```bash
+make ui-dev # from the repo root; builds + starts the container
+```
+
+Open **http://localhost:8888** and log in with password **`bolt`**
+(configurable via `DEV_PASSWORD` in docker-compose.yml; set `DEV_AUTOLOGIN=1`
+to skip login entirely).
+
+Other targets: `make ui-dev-build`, `make ui-dev-shell`, `make ui-dev-clean`.
+
+Edit any file under `rootfs/standard/var/www/mynode/` (templates, static
+assets, python) or `rootfs/standard/var/pynode/` on the host — templates apply
+on refresh, python triggers Flask auto-reload. The source is mounted
+**read-only**, so nothing in this container can modify real code. `git status`
+stays clean.
+
+## The DEV panel
+
+Every page gets a floating **DEV** button (bottom-right) that opens the state
+switcher:
+
+- **Device state** — flip `/tmp/.mynode_status` to any state (`stable`,
+ `drive_missing`, `drive_formatting`, `choose_network`, `quicksync_*`,
+ `upgrading`, `shutting_down`, ...) to render every state page.
+- **Bitcoin** — synced / syncing at an arbitrary progress (renders the real
+ syncing page), or the "Launching MyNode Services" starting page.
+- **Lightning** — LND ready/not-ready, wallet exists/missing (wallet-create flow).
+- **Warnings** — undervoltage / throttled / capped / fsck / usb warning pages.
+- **System** — drive-almost-full error, upgrade banner, premium vs community
+ edition, simulated reboot/shutdown, and **RESET ALL** back to the golden state.
+- **Apps** — one-click install/uninstall (marker files only, no real install).
+
+Everything the panel does is also available as plain HTTP endpoints under
+`/dev/...` (no auth) — see `dev_blueprint.py`. Examples:
+
+```bash
+curl 'localhost:8888/dev/state?value=drive_missing'
+curl 'localhost:8888/dev/sync?synced=0&progress=0.62'
+curl 'localhost:8888/dev/app?name=specter&installed=1'
+curl 'localhost:8888/dev/service?name=electrs&status=failed'
+curl 'localhost:8888/dev/reset'
+```
+
+The full install flow also works end-to-end from the normal UI: installing an
+app from the marketplace shows the real "Installing..." page with a (fake)
+live install log, a simulated reboot, and the app appears installed afterward.
+Reboot/shutdown from settings play out the real reboot page (the mock resets a
+fake boot-time file; the page redirects home when it sees uptime decrease).
+
+## How it works (architecture)
+
+**Zero changes to real code.** The container runs the unmodified Flask app
+with three mock layers around it:
+
+1. **PYTHONPATH shim (`mock_pynode/`)** — sits before `/var/www/mynode` and
+ `/var/pynode` on the python path, so `import bitcoin_info` etc. resolve to
+ the mocks. Each mock exec's the real module, patches ONLY the functions
+ that touch the outside world *into the real module's namespace*, and
+ re-exports everything (see `_mockutil.py` for why patching the real
+ namespace matters). What gets mocked:
+ - `bitcoin_info` — fake `AuthServiceProxy` (bitcoind RPC) backed by
+ `fixtures/bitcoin.json`; the real update/caching code runs unchanged.
+ - `lightning_info` — `lnd_get`/`lnd_get_v2` return `fixtures/lightning.json`.
+ - `electrum_info` — fake `requests` serving prometheus metrics.
+ - `systemctl_info` — service states from `/tmp/mock_services/`.
+ - `device_info` — uptime/serial/temp + simulated reboot/upgrade.
+ - `application_info` — install/uninstall simulations (marker files only).
+ - `pam` — login accepts `DEV_PASSWORD`.
+ - `price_info`, `thread_functions`, `drive_info`, `transmissionrpc`.
+
+2. **Fixture filesystem (`seed_fixture_fs.py`)** — the real code reads inline
+ absolute paths (`/mnt/hdd/...`, `/home/bitcoin/.mynode/...`,
+ `/usr/share/mynode/...`, `/tmp/...`), so the container recreates a
+ provisioned device's filesystem. `/mnt/hdd` is a tmpfs *mount* because the
+ code greps `/proc/mounts` for it.
+
+3. **Fake binaries (`fake_bin/`)** — installed into `/usr/bin` in the
+ container (many calls use absolute paths). `systemctl` reads/writes the
+ same `/tmp/mock_services` files as the python mock; `journalctl` emits
+ canned logs; `mynode_*.sh` scripts are no-ops or tiny simulations.
+
+`dev_server.py` imports the real `app` from `mynode.py`, seeds the in-memory
+data caches, registers the `/dev` blueprint + overlay injector, and runs Flask
+in debug mode. A light 30s refresher keeps mocked values jittering; set
+`DEV_REAL_THREADS=1` to run the app's real background threads instead.
+
+## Customizing sample data
+
+Edit the JSON files in `fixtures/` (block height, peers, channels, balances,
+invoices, prices, service states, device identity) — they reload on next
+refresh without restarting the container. Default-installed apps are listed in
+`seed_fixture_fs.py` (`DEFAULT_INSTALLED_APPS`).
+
+## Known quirks
+
+- Port **8888** (not 8000) because the repo's release file server uses 8000.
+- The QuickSync download page shows "Waiting on download client to start..."
+ (no transmission daemon; the stub raises, which the real page handles).
+- `/dev/*` endpoints are unauthenticated; the container binds to 127.0.0.1 only.
diff --git a/dev/ui-dev/dev_blueprint.py b/dev/ui-dev/dev_blueprint.py
new file mode 100644
index 000000000..7615156ee
--- /dev/null
+++ b/dev/ui-dev/dev_blueprint.py
@@ -0,0 +1,526 @@
+"""Dev-only state switcher for the mocked UI dev container.
+
+Registered by dev_server.py (never by the real app). Provides:
+ - /dev/* endpoints that flip the same files/globals the real code reads
+ - a floating overlay panel injected into every HTML page (inject_overlay)
+No auth: the container is bound to 127.0.0.1 and is dev-only."""
+import glob
+import json
+import os
+import time
+
+from flask import Blueprint, Response, jsonify, request, session
+
+from _mockutil import real, get_knob, set_knob
+
+import seed_fixture_fs
+import bitcoin_info
+import lightning_info
+import electrum_info
+import price_info
+import thread_functions
+import application_info
+import device_info
+import systemctl_info
+
+mynode_dev = Blueprint("mynode_dev", __name__)
+
+BITCOIN_SYNCED_FILE = "/mnt/hdd/mynode/.mynode_bitcoin_synced"
+
+DEVICE_STATES = [
+ "stable", "drive_missing", "drive_format_confirm", "drive_formatting",
+ "drive_mounted", "drive_clone", "drive_full", "docker_reset",
+ "gen_dhparam", "choose_network", "quicksync_download", "quicksync_copy",
+ "quicksync_reset", "rootfs_read_only", "hdd_read_only", "shutting_down",
+ "upgrading", "unknown",
+]
+
+THROTTLE_BITS = {
+ "undervoltage": 0x10000, # HAS_UNDERVOLTED
+ "capped": 0x20000, # HAS_CAPPED
+ "throttled": 0x40000, # HAS_THROTTLED
+}
+
+
+def seed_runtime_data(log_errors=False):
+ """Populate/refresh the module-global caches the pages read. The real
+ update functions run against the mocked data sources."""
+ steps = [
+ bitcoin_info.update_bitcoin_main_info,
+ bitcoin_info.update_bitcoin_other_info,
+ lightning_info.update_lightning_info,
+ electrum_info.update_electrs_info,
+ price_info.update_price_info,
+ thread_functions.update_device_info,
+ thread_functions.find_public_ip,
+ application_info.update_application_json_cache,
+ ]
+ for step in steps:
+ try:
+ step()
+ except Exception as e:
+ if log_errors:
+ print("[dev seed] {} failed: {}".format(step.__name__, e))
+ else:
+ raise
+ if not get_knob("misc", {}).get("starting"):
+ real("thread_functions").has_updated_btc_info = True
+
+
+def autologin():
+ session["logged_in"] = True
+ session.permanent = True
+
+
+def inject_overlay(response):
+ """after_request hook: append the dev overlay script to HTML pages."""
+ try:
+ content_type = response.content_type or ""
+ if content_type.startswith("text/html") and not response.direct_passthrough:
+ body = response.get_data(as_text=True)
+ if "
"
+ "myNode UI dev panel
"
+ "Use the floating DEV button (bottom-right). "
+ "It is injected into every page of the UI as well.
"
+ "" in body and "/dev/overlay.js" not in body:
+ body = body.replace("", '', 1)
+ response.set_data(body)
+ except Exception:
+ pass
+ return response
+
+
+def _ok(**extra):
+ data = {"result": "ok"}
+ data.update(extra)
+ return jsonify(data)
+
+
+@mynode_dev.route("/dev/status")
+def dev_status():
+ status = "unknown"
+ try:
+ with open("/tmp/.mynode_status") as f:
+ status = f.read().strip()
+ except Exception:
+ pass
+ version = "?"
+ latest = "?"
+ try:
+ with open("/usr/share/mynode/version") as f:
+ version = f.read().strip()
+ with open("/usr/share/mynode/latest_version") as f:
+ latest = f.read().strip()
+ except Exception:
+ pass
+ return jsonify({
+ "device_state": status,
+ "device_states": DEVICE_STATES,
+ "bitcoin_synced": os.path.isfile(BITCOIN_SYNCED_FILE),
+ "bitcoin_knob": get_knob("bitcoin", {"behind_blocks": 0}),
+ "starting": bool(get_knob("misc", {}).get("starting")),
+ "lnd_ready": bool(get_knob("lnd", {"ready": True}).get("ready", True)),
+ "lnd_wallet": lightning_info.lnd_wallet_exists(),
+ "drive": get_knob("drive", {"data": "61%", "os": "34%"}),
+ "upgrade_available": version != latest,
+ "warning": _current_warning_file(),
+ "autologin": os.environ.get("DEV_AUTOLOGIN") == "1",
+ "premium": not device_info.is_community_edition(),
+ })
+
+
+def _current_warning_file():
+ try:
+ with open("/tmp/get_throttled_data") as f:
+ value = int(f.read().strip(), 16)
+ for name, bit in THROTTLE_BITS.items():
+ if value & bit:
+ return name
+ except Exception:
+ pass
+ return "none"
+
+
+@mynode_dev.route("/dev/state")
+def dev_state():
+ value = request.args.get("value", "")
+ if value not in DEVICE_STATES:
+ return jsonify({"result": "error", "message": "unknown state", "valid": DEVICE_STATES}), 400
+ with open("/tmp/.mynode_status", "w") as f:
+ f.write(value)
+ if value == "drive_clone":
+ clone_state = request.args.get("clone", "detecting")
+ with open("/tmp/.clone_state", "w") as f:
+ f.write(clone_state)
+ with open("/tmp/.clone_progress", "w") as f:
+ f.write("42.00% complete (289 GB / 689 GB) - mocked")
+ return _ok(state=value)
+
+
+@mynode_dev.route("/dev/sync")
+def dev_sync():
+ synced = request.args.get("synced", "1") == "1"
+ if synced:
+ set_knob("bitcoin", {"behind_blocks": 0})
+ if not os.path.isfile(BITCOIN_SYNCED_FILE):
+ open(BITCOIN_SYNCED_FILE, "a").close()
+ else:
+ knob = {"behind_blocks": int(request.args.get("behind", 340000))}
+ if request.args.get("progress"):
+ knob["progress"] = float(request.args.get("progress"))
+ set_knob("bitcoin", knob)
+ if os.path.isfile(BITCOIN_SYNCED_FILE):
+ os.remove(BITCOIN_SYNCED_FILE)
+ bitcoin_info.update_bitcoin_main_info()
+ open("/tmp/homepage_needs_refresh", "a").close()
+ return _ok(synced=synced)
+
+
+@mynode_dev.route("/dev/starting")
+def dev_starting():
+ starting = request.args.get("value", "1") == "1"
+ misc = get_knob("misc", {})
+ misc["starting"] = starting
+ set_knob("misc", misc)
+ real("thread_functions").has_updated_btc_info = not starting
+ return _ok(starting=starting)
+
+
+@mynode_dev.route("/dev/apps")
+def dev_apps():
+ apps = []
+ for app in application_info.get_all_applications(order_by="alphabetic"):
+ if app.get("show_on_marketplace_page") or app.get("show_on_application_page"):
+ apps.append({
+ "short_name": app["short_name"],
+ "name": app["name"],
+ "is_installed": app["is_installed"],
+ "is_enabled": app["is_enabled"],
+ })
+ return jsonify(apps)
+
+
+@mynode_dev.route("/dev/app")
+def dev_app():
+ name = request.args.get("name", "")
+ installed = request.args.get("installed", "1") == "1"
+ if not application_info.is_application_valid(name):
+ return jsonify({"result": "error", "message": "unknown app"}), 400
+ if installed:
+ application_info.mark_app_installed(name)
+ try:
+ info = application_info.get_application(name)
+ if info and info.get("latest_version") not in (None, "unknown", "error"):
+ with open("/home/bitcoin/.mynode/{}_version".format(name), "w") as f:
+ f.write(info["latest_version"])
+ except Exception:
+ pass
+ application_info.enable_service(name)
+ else:
+ application_info.disable_service(name)
+ application_info.clear_app_installed(name)
+ application_info.clear_application_cache()
+ application_info.trigger_application_refresh()
+ open("/tmp/homepage_needs_refresh", "a").close()
+ return _ok(app=name, installed=installed)
+
+
+@mynode_dev.route("/dev/service")
+def dev_service():
+ name = request.args.get("name", "")
+ status = request.args.get("status", "running")
+ if not name or status not in ("running", "stopped", "failed", "disabled"):
+ return jsonify({"result": "error", "message": "usage: /dev/service?name=x&status=running|stopped|failed|disabled"}), 400
+ os.makedirs("/tmp/mock_services", exist_ok=True)
+ with open(os.path.join("/tmp/mock_services", name), "w") as f:
+ f.write(status)
+ systemctl_info.clear_service_enabled_cache()
+ return _ok(service=name, status=status)
+
+
+@mynode_dev.route("/dev/warning")
+def dev_warning():
+ name = request.args.get("name", "clear")
+ dev_info_real = real("device_info")
+ if name in THROTTLE_BITS:
+ with open("/tmp/get_throttled_data", "w") as f:
+ f.write("0x{:x}".format(THROTTLE_BITS[name]))
+ # Re-arm in case the warning was previously skipped via the UI
+ for marker in glob.glob("/tmp/warning_skipped_*"):
+ os.remove(marker)
+ dev_info_real.reload_throttled_data()
+ elif name == "fsck":
+ open("/tmp/fsck_error", "a").close()
+ with open("/tmp/fsck_results", "w") as f:
+ f.write("(mock) fsck found and repaired 3 inode errors on /dev/sda1")
+ elif name == "usb":
+ open("/tmp/usb_error", "a").close()
+ elif name == "clear":
+ for path in ["/tmp/get_throttled_data", "/tmp/fsck_error", "/tmp/fsck_results", "/tmp/usb_error"]:
+ if os.path.exists(path):
+ os.remove(path)
+ for marker in glob.glob("/tmp/warning_skipped_*"):
+ os.remove(marker)
+ dev_info_real.cached_data["get_throttled_data"] = ""
+ else:
+ return jsonify({"result": "error", "message": "unknown warning", "valid": list(THROTTLE_BITS) + ["fsck", "usb", "clear"]}), 400
+ return _ok(warning=name)
+
+
+@mynode_dev.route("/dev/drive")
+def dev_drive():
+ knob = get_knob("drive", {"data": "61%", "os": "34%"})
+ for key in ("data", "os"):
+ if request.args.get(key):
+ value = request.args.get(key)
+ knob[key] = value if value.endswith("%") else value + "%"
+ set_knob("drive", knob)
+ return _ok(drive=knob)
+
+
+@mynode_dev.route("/dev/lnd")
+def dev_lnd():
+ knob = get_knob("lnd", {"ready": True})
+ if request.args.get("ready") is not None:
+ knob["ready"] = request.args.get("ready") == "1"
+ set_knob("lnd", knob)
+ real("lightning_info").lnd_ready = knob["ready"]
+ lightning_info.update_lightning_info()
+ if request.args.get("wallet") is not None:
+ wallet_file = lightning_info.get_lightning_wallet_file()
+ if request.args.get("wallet") == "1":
+ open(wallet_file, "a").close()
+ elif os.path.isfile(wallet_file):
+ os.remove(wallet_file)
+ return _ok(lnd=knob, wallet=lightning_info.lnd_wallet_exists())
+
+
+@mynode_dev.route("/dev/edition")
+def dev_edition():
+ """Toggle between community edition (default) and a mocked premium
+ device (product key present + healthy check-in data)."""
+ premium = request.args.get("premium", "1") == "1"
+ if premium:
+ device_info.unset_skipped_product_key()
+ with open("/home/bitcoin/.mynode/.product_key", "w") as f:
+ f.write("MOCKPRODUCTKEY123456")
+ device_info.delete_product_key_error()
+ with open("/tmp/check_in_response.json", "w") as f:
+ json.dump({"status": "OK",
+ "support": {"active": True, "days_remaining": 300},
+ "premium_plus": {"active": False, "days_remaining": 0}}, f)
+ else:
+ device_info.delete_product_key()
+ device_info.set_skipped_product_key()
+ if os.path.exists("/tmp/check_in_response.json"):
+ os.remove("/tmp/check_in_response.json")
+ return _ok(premium=premium)
+
+
+@mynode_dev.route("/dev/upgrade_available")
+def dev_upgrade_available():
+ with open("/usr/share/mynode/version") as f:
+ version = f.read().strip()
+ latest = "v99.9" if request.args.get("value", "1") == "1" else version
+ with open("/usr/share/mynode/latest_version", "w") as f:
+ f.write(latest)
+ return _ok(current=version, latest=latest)
+
+
+@mynode_dev.route("/dev/reboot")
+def dev_reboot():
+ device_info.reboot_device()
+ return _ok(message="simulated reboot started")
+
+
+@mynode_dev.route("/dev/shutdown")
+def dev_shutdown():
+ device_info.shutdown_device()
+ return _ok(message="simulated shutdown started (auto power-on in ~15s)")
+
+
+@mynode_dev.route("/dev/reset")
+def dev_reset():
+ device_info.delete_product_key()
+ device_info.set_skipped_product_key()
+ if os.path.exists("/tmp/check_in_response.json"):
+ os.remove("/tmp/check_in_response.json")
+ seed_fixture_fs.ensure(force=True)
+ set_knob("lnd", {"ready": True})
+ set_knob("misc", {"starting": False})
+ real("lightning_info").lnd_ready = True
+ real("device_info").cached_data["get_throttled_data"] = ""
+ application_info.clear_application_cache()
+ systemctl_info.clear_service_enabled_cache()
+ seed_runtime_data(log_errors=True)
+ return _ok(message="fixture state restored")
+
+
+@mynode_dev.route("/dev/panel")
+def dev_panel():
+ return Response(
+ "
myNode UI Dev Panel"
+ "