Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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!

Expand All @@ -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

Expand Down Expand Up @@ -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**.
Expand Down
21 changes: 20 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions install_scripts/setup_fluidd_navi.py
Original file line number Diff line number Diff line change
@@ -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()
110 changes: 106 additions & 4 deletions install_scripts/setup_moonraker.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Add/update the KlipperFleet update_manager section in moonraker.conf.

Usage: python3 setup_moonraker.py <moonraker.conf path> <KlipperFleet repo path>
Usage:
python3 setup_moonraker.py <moonraker.conf path> <KlipperFleet repo path>
python3 setup_moonraker.py --add-persistent-file <section> <filename> <moonraker.conf path>
python3 setup_moonraker.py --remove-persistent-file <section> <filename> <moonraker.conf path>

Idempotent: creates the section if missing, migrates deprecated options if
the section already exists (e.g. install_script -> system_dependencies).
Expand All @@ -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 <name>] 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()
Expand All @@ -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.

Expand Down Expand Up @@ -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 <section>] 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 <section>] 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]} <section> <filename> <moonraker.conf>",
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 <moonraker_conf_path> <kf_repo_path>", file=sys.stderr)
sys.exit(1)
Expand Down
15 changes: 15 additions & 0 deletions uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."