diff --git a/README.md b/README.md index 9fa3e71..0c1a35e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # KlipperFleet > [!WARNING] -> **ALPHA SOFTWARE** - Tested on specific CAN bus, STM32 Serial/DFU, and Linux Process devices (see below). Non-Raspberry Pi and Fluidd are unsupported but on the roadmap. Kalico is auto-detected if installed at `~/klipper`. +> **ALPHA SOFTWARE** - Tested on specific CAN bus, STM32 Serial/DFU, and Linux Process devices (see below). Non-Raspberry Pi and Fluidd support requires [fluidd-core/fluidd#1786](https://github.com/fluidd-core/fluidd/pull/1786). Kalico is auto-detected if installed at `~/klipper`. > > Contributions and [bug reports](https://github.com/JohnBaumb/KlipperFleet/issues) are appreciated! @@ -17,7 +17,7 @@ KlipperFleet is a web interface (integrated into Mainsail) for configuring, buil - **Auto Reboot**: Detects Klipper vs. Katapult/DFU mode and reboots devices into the correct bootloader automatically. - **Service Management**: Stops/starts Klipper and Moonraker services during flash operations. - **Integrated Flashing**: Flash via Serial, CAN, or DFU from the browser with real-time log streaming. -- **Mainsail Integration**: Native look and feel within the Mainsail ecosystem. +- **Mainsail Integration**: Native look and feel within the Mainsail and Fluidd ecosystem. ## Tested Hardware @@ -110,6 +110,19 @@ The installer auto-configures `.theme/navi.json`. If the entry is missing, add i ] ``` +### Fluidd Sidebar Link +The installer adds a KlipperFleet link to Fluidd's sidebar navigation via Moonraker's database API. This requires [fluidd-core/fluidd#1786](https://github.com/fluidd-core/fluidd/pull/1786) (custom navigation links). + +If the link is missing, run the setup script manually: +```bash +python3 ~/KlipperFleet/install_scripts/setup_fluidd_navi.py +``` + +To remove: +```bash +python3 ~/KlipperFleet/install_scripts/setup_fluidd_navi.py --remove +``` + ## Usage 1. **Configurator** - Select a profile, configure MCU settings, click **Save**. diff --git a/install.sh b/install.sh index c2a967a..b41c021 100755 --- a/install.sh +++ b/install.sh @@ -162,7 +162,26 @@ if [ -f "$NAVI_JSON" ]; then fi fi -# 9. Systemd Service +# 9. Fluidd Navigation Integration (requires fluidd-core/fluidd#1786) +log_info "Integrating with Fluidd navigation..." +python3 "${SRCDIR}/install_scripts/setup_fluidd_navi.py" || true + +# Deploy redirect shim to Fluidd web root and register as persistent_files +# so Moonraker preserves it across Fluidd updates. +FLUIDD_ROOT="/home/${USER}/fluidd" +if [ -d "$FLUIDD_ROOT" ]; then + cp "${SRCDIR}/install_scripts/klipperfleet.html" "$FLUIDD_ROOT/klipperfleet.html" + chown "$USER:$USER_GROUP" "$FLUIDD_ROOT/klipperfleet.html" + chmod 644 "$FLUIDD_ROOT/klipperfleet.html" + log_info "Adding klipperfleet.html to Fluidd persistent_files..." + python3 "${SRCDIR}/install_scripts/setup_moonraker.py" \ + --add-persistent-file fluidd klipperfleet.html \ + "${USER_HOME}/printer_data/config/moonraker.conf" +else + log_warn "Fluidd web root not found at $FLUIDD_ROOT; redirect shim not deployed." +fi + +# 10. Systemd Service log_info "Creating systemd service..." SERVICE_FILE="/etc/systemd/system/klipperfleet.service" cat > "$SERVICE_FILE" << EOF diff --git a/install_scripts/setup_fluidd_navi.py b/install_scripts/setup_fluidd_navi.py new file mode 100644 index 0000000..d77f1eb --- /dev/null +++ b/install_scripts/setup_fluidd_navi.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Add a KlipperFleet entry to Fluidd's navigation via Moonraker's database API. + +Usage: python3 setup_fluidd_navi.py [--remove] [--moonraker-url URL] + +Requires fluidd-core/fluidd#1786 (custom navigation links). +Idempotent: removes any existing KlipperFleet entry before adding the current one. + +Reference: https://github.com/fluidd-core/fluidd/pull/1802#issuecomment-4085253599 +""" +import argparse +import json +import sys +import urllib.error +import urllib.request +import uuid + + +MOONRAKER_DEFAULT_URL = "http://localhost:7125" +DB_NAMESPACE = "fluidd" +DB_KEY = "uiSettings.navigation.customLinks" + +# Stable UUID v5 derived from the URL namespace and our identifier. +# This ensures the same ID across installs without hardcoding a random UUID. +KLIPPERFLEET_ID = str(uuid.uuid5(uuid.NAMESPACE_URL, "klipperfleet")) + +KLIPPERFLEET_LINK = { + "id": KLIPPERFLEET_ID, + "title": "KlipperFleet", + "url": "/klipperfleet.html", + "icon": "mdi-ferry", + "position": 86, +} + + +def moonraker_request(base_url, method, path, data=None): + """Make a request to Moonraker's API.""" + url = f"{base_url}{path}" + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + if body: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + except urllib.error.URLError: + return None + + +def get_existing_links(base_url): + """Fetch existing custom navigation links from Moonraker DB.""" + result = moonraker_request( + base_url, "GET", + f"/server/database/item?namespace={DB_NAMESPACE}&key={DB_KEY}" + ) + if result and "result" in result: + value = result["result"].get("value", []) + if isinstance(value, list): + return value + return [] + + +def save_links(base_url, links): + """Save custom navigation links to Moonraker DB.""" + result = moonraker_request( + base_url, "POST", + "/server/database/item", + {"namespace": DB_NAMESPACE, "key": DB_KEY, "value": links} + ) + return result is not None + + +def is_klipperfleet(entry): + """Match KlipperFleet entries by id or title.""" + return isinstance(entry, dict) and ( + entry.get("id") == KLIPPERFLEET_ID or entry.get("title") == "KlipperFleet" + ) + + +def install(base_url): + """Add KlipperFleet link to Fluidd navigation.""" + links = get_existing_links(base_url) + + # Remove any existing KlipperFleet entry, then append + links = [l for l in links if not is_klipperfleet(l)] + links.append(KLIPPERFLEET_LINK) + + if save_links(base_url, links): + print("KlipperFleet: Fluidd navigation configured.") + else: + print("KlipperFleet: WARNING: Could not configure Fluidd navigation " + "(Moonraker may not be running or fluidd#1786 not yet available).", + file=sys.stderr) + + +def remove(base_url): + """Remove KlipperFleet link from Fluidd navigation.""" + links = get_existing_links(base_url) + filtered = [l for l in links if not is_klipperfleet(l)] + + if len(filtered) == len(links): + print("KlipperFleet: No Fluidd navigation entry found (skipped).") + return + + if save_links(base_url, filtered): + print("KlipperFleet: Fluidd navigation entry removed.") + else: + print("KlipperFleet: WARNING: Could not remove Fluidd navigation entry.", + file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description="Manage KlipperFleet's Fluidd sidebar link") + parser.add_argument("--remove", action="store_true", help="Remove the link instead of adding it") + parser.add_argument("--moonraker-url", default=MOONRAKER_DEFAULT_URL, + help=f"Moonraker base URL (default: {MOONRAKER_DEFAULT_URL})") + args = parser.parse_args() + + if args.remove: + remove(args.moonraker_url) + else: + install(args.moonraker_url) + + +if __name__ == "__main__": + main() diff --git a/install_scripts/setup_moonraker.py b/install_scripts/setup_moonraker.py index e39b68b..d8d9434 100644 --- a/install_scripts/setup_moonraker.py +++ b/install_scripts/setup_moonraker.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 """Add/update the KlipperFleet update_manager section in moonraker.conf. -Usage: python3 setup_moonraker.py +Usage: + python3 setup_moonraker.py + python3 setup_moonraker.py --add-persistent-file
+ python3 setup_moonraker.py --remove-persistent-file
Idempotent: creates the section if missing, migrates deprecated options if the section already exists (e.g. install_script -> system_dependencies). @@ -19,9 +22,13 @@ system_dependencies: install_scripts/system-dependencies.json""" -def _extract_klipperfleet_section(content: str): - """Return (start, end) char offsets of the [update_manager klipperfleet] section.""" - m = re.search(r"^\[update_manager klipperfleet\]", content, re.MULTILINE) +def _extract_section(content: str, section_name: str): + """Return (start, end) char offsets of a named section. + + Works for any [update_manager ] section header. + """ + pattern = rf"^\[update_manager {re.escape(section_name)}\]" + m = re.search(pattern, content, re.MULTILINE) if not m: return None, None start = m.start() @@ -31,6 +38,11 @@ def _extract_klipperfleet_section(content: str): return start, end +def _extract_klipperfleet_section(content: str): + """Return (start, end) char offsets of the [update_manager klipperfleet] section.""" + return _extract_section(content, "klipperfleet") + + def migrate_moonraker_conf(conf_path: str, kf_path: str) -> bool: """Migrate an existing moonraker.conf in-place. @@ -76,7 +88,97 @@ def migrate_moonraker_conf(conf_path: str, kf_path: str) -> bool: return False +def add_persistent_file(conf_path: str, section_name: str, filename: str) -> bool: + """Add a filename to persistent_files in an [update_manager
] block. + + Idempotent: does nothing if already present. + Creates the persistent_files option if it doesn't exist. + """ + if not os.path.isfile(conf_path): + print(f"KlipperFleet: WARNING: {conf_path} not found.", file=sys.stderr) + return False + + with open(conf_path, "r", encoding="utf-8") as f: + content = f.read() + + start, end = _extract_section(content, section_name) + if start is None: + print(f"KlipperFleet: WARNING: [update_manager {section_name}] not found in {conf_path}.", + file=sys.stderr) + return False + + section = content[start:end] + + # Check if filename is already listed + if re.search(rf"^\s+{re.escape(filename)}\s*$", section, re.MULTILINE): + print(f"KlipperFleet: {filename} already in {section_name} persistent_files.") + return False + + if "persistent_files:" in section: + # Append to existing persistent_files list + section = re.sub( + r"(persistent_files:\s*\n(?:\s+\S+\n)*)", + rf"\g<1> {filename}\n", + section + ) + else: + # Add persistent_files before the next option or at end of section + section = section.rstrip() + f"\npersistent_files:\n {filename}\n" + + content = content[:start] + section + content[end:] + with open(conf_path, "w", encoding="utf-8") as f: + f.write(content) + + print(f"KlipperFleet: Added {filename} to {section_name} persistent_files.") + return True + + +def remove_persistent_file(conf_path: str, section_name: str, filename: str) -> bool: + """Remove a filename from persistent_files in an [update_manager
] block.""" + if not os.path.isfile(conf_path): + return False + + with open(conf_path, "r", encoding="utf-8") as f: + content = f.read() + + start, end = _extract_section(content, section_name) + if start is None: + return False + + section = content[start:end] + + # Remove the specific file entry + new_section = re.sub(rf"\n\s+{re.escape(filename)}\s*(?=\n)", "", section) + if new_section == section: + return False # Not found + + # If persistent_files is now empty, remove the key entirely + new_section = re.sub(r"\npersistent_files:\s*\n(?=\S|\Z)", "\n", new_section) + + content = content[:start] + new_section + content[end:] + with open(conf_path, "w", encoding="utf-8") as f: + f.write(content) + + print(f"KlipperFleet: Removed {filename} from {section_name} persistent_files.") + return True + + def main(): + # Handle --add-persistent-file / --remove-persistent-file mode + if len(sys.argv) >= 2 and sys.argv[1] in ("--add-persistent-file", "--remove-persistent-file"): + if len(sys.argv) < 5: + print(f"Usage: setup_moonraker.py {sys.argv[1]}
", + file=sys.stderr) + sys.exit(1) + section_name = sys.argv[2] + filename = sys.argv[3] + conf_path = sys.argv[4] + if sys.argv[1] == "--add-persistent-file": + add_persistent_file(conf_path, section_name, filename) + else: + remove_persistent_file(conf_path, section_name, filename) + sys.exit(0) + if len(sys.argv) < 3: print("Usage: setup_moonraker.py ", file=sys.stderr) sys.exit(1) diff --git a/uninstall.sh b/uninstall.sh index 8eb71e5..59e7436 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -102,5 +102,20 @@ if [ -f "$NAVI_JSON" ]; then echo "KlipperFleet: Mainsail navigation entry removed." fi +# 8. Remove Fluidd Navigation Integration +echo "KlipperFleet: Removing Fluidd navigation entry..." +python3 "${SRCDIR}/install_scripts/setup_fluidd_navi.py" --remove || true + +# Remove redirect shim and persistent_files entry +FLUIDD_ROOT="/home/${USER}/fluidd" +if [ -f "$FLUIDD_ROOT/klipperfleet.html" ]; then + rm "$FLUIDD_ROOT/klipperfleet.html" + echo "KlipperFleet: Fluidd redirect shim removed." +fi +MOONRAKER_CONF="${MOONRAKER_CONFIG_DIR}/moonraker.conf" +python3 "${SRCDIR}/install_scripts/setup_moonraker.py" \ + --remove-persistent-file fluidd klipperfleet.html \ + "$MOONRAKER_CONF" || true + echo "KlipperFleet: Uninstallation complete." echo "Note: The repository at ${SRCDIR} has not been removed. You can delete it manually if desired."