Skip to content

Commit c91816d

Browse files
authored
feat(extension): add Spoolman Docker installer (#669)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
1 parent 1a6f06e commit c91816d

File tree

11 files changed

+625
-12
lines changed

11 files changed

+625
-12
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ end_of_line = lf
1111
[*.py]
1212
max_line_length = 88
1313

14-
[*.{sh,yml,yaml}]
14+
[*.{sh,yml,yaml,json}]
1515
indent_size = 2

kiauh/components/klipper_firmware/menus/klipper_flash_menu.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ def baudrate_select(self, **kwargs):
386386
self.flash_options.selected_baudrate = get_number_input(
387387
question="Please set the baud rate",
388388
default=250000,
389-
min_count=0,
389+
min_value=0,
390390
allow_go_back=True,
391391
)
392392
KlipperFlashOverviewMenu(previous_menu=self.__class__).run()

kiauh/components/webui_client/client_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ def get_client_port_selection(
414414
while True:
415415
_type = "Reconfigure" if reconfigure else "Configure"
416416
question = f"{_type} {client.display_name} for port"
417-
port_input = get_number_input(question, min_count=80, default=port)
417+
port_input = get_number_input(question, min_value=80, default=port)
418418

419419
if port_input not in ports_in_use:
420420
client_settings: WebUiSettings = settings[client.name]

kiauh/extensions/pretty_gcode/pretty_gcode_extension.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def install_extension(self, **kwargs) -> None:
4343

4444
port = get_number_input(
4545
"On which port should PrettyGCode run",
46-
min_count=0,
46+
min_value=0,
4747
default=7136,
4848
allow_go_back=True,
4949
)

kiauh/extensions/spoolman/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# ======================================================================= #
2+
# Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
3+
# #
4+
# This file is part of KIAUH - Klipper Installation And Update Helper #
5+
# https://github.com/dw-0/kiauh #
6+
# #
7+
# This file may be distributed under the terms of the GNU GPLv3 license #
8+
# ======================================================================= #
9+
from pathlib import Path
10+
11+
MODULE_PATH = Path(__file__).resolve().parent
12+
SPOOLMAN_DOCKER_IMAGE = "ghcr.io/donkie/spoolman:latest"
13+
SPOOLMAN_DIR = Path.home().joinpath("spoolman")
14+
SPOOLMAN_DATA_DIR = SPOOLMAN_DIR.joinpath("data")
15+
SPOOLMAN_COMPOSE_FILE = SPOOLMAN_DIR.joinpath("docker-compose.yml")
16+
SPOOLMAN_DEFAULT_PORT = 7912
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
spoolman:
3+
image: ghcr.io/donkie/spoolman:latest
4+
restart: unless-stopped
5+
volumes:
6+
# Mount the host machine's ./data directory into the container's /home/app/.local/share/spoolman directory
7+
- type: bind
8+
source: ./data # This is where the data will be stored locally. Could also be set to for example `source: /home/pi/printer_data/spoolman`.
9+
target: /home/app/.local/share/spoolman # Do NOT modify this line
10+
ports:
11+
# Map the host machine's port 7912 to the container's port 8000
12+
- "7912:8000"
13+
environment:
14+
- TZ=Europe/Stockholm # Optional, defaults to UTC
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"metadata": {
3+
"index": 11,
4+
"module": "spoolman_extension",
5+
"maintained_by": "dw-0",
6+
"display_name": "Spoolman (Docker)",
7+
"description": [
8+
"Filament manager for 3D printing",
9+
"- Track your filament inventory",
10+
"- Monitor filament usage",
11+
"- Manage vendors, materials, and spools",
12+
"- Integrates with Moonraker",
13+
"\n\n",
14+
"Note: This extension installs Spoolman using Docker. Docker must be installed on your system before installing Spoolman."
15+
],
16+
"updates": true
17+
}
18+
}

kiauh/extensions/spoolman/spoolman.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# ======================================================================= #
2+
# Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
3+
# #
4+
# This file is part of KIAUH - Klipper Installation And Update Helper #
5+
# https://github.com/dw-0/kiauh #
6+
# #
7+
# This file may be distributed under the terms of the GNU GPLv3 license #
8+
# ======================================================================= #
9+
from __future__ import annotations
10+
11+
import shutil
12+
from dataclasses import dataclass, field
13+
from pathlib import Path
14+
from subprocess import CalledProcessError, run
15+
16+
from components.moonraker.moonraker import Moonraker
17+
from core.instance_manager.base_instance import BaseInstance
18+
from core.logger import Logger
19+
from extensions.spoolman import (
20+
MODULE_PATH,
21+
SPOOLMAN_COMPOSE_FILE,
22+
SPOOLMAN_DIR,
23+
SPOOLMAN_DOCKER_IMAGE,
24+
)
25+
from utils.sys_utils import get_system_timezone
26+
27+
28+
@dataclass
29+
class Spoolman:
30+
suffix: str
31+
base: BaseInstance = field(init=False, repr=False)
32+
dir: Path = SPOOLMAN_DIR
33+
data_dir: Path = field(init=False)
34+
35+
def __post_init__(self):
36+
self.base: BaseInstance = BaseInstance(Moonraker, self.suffix)
37+
self.data_dir = self.base.data_dir
38+
39+
@staticmethod
40+
def is_container_running() -> bool:
41+
"""Check if the Spoolman container is running"""
42+
try:
43+
result = run(
44+
["docker", "compose", "-f", str(SPOOLMAN_COMPOSE_FILE), "ps", "-q"],
45+
capture_output=True,
46+
text=True,
47+
check=True,
48+
)
49+
return bool(result.stdout.strip())
50+
except CalledProcessError:
51+
return False
52+
53+
@staticmethod
54+
def is_docker_available() -> bool:
55+
"""Check if Docker is installed and available"""
56+
try:
57+
run(["docker", "--version"], capture_output=True, check=True)
58+
return True
59+
except (CalledProcessError, FileNotFoundError):
60+
return False
61+
62+
@staticmethod
63+
def is_docker_compose_available() -> bool:
64+
"""Check if Docker Compose is installed and available"""
65+
try:
66+
# Try modern docker compose command
67+
run(["docker", "compose", "version"], capture_output=True, check=True)
68+
return True
69+
except (CalledProcessError, FileNotFoundError):
70+
# Try legacy docker-compose command
71+
try:
72+
run(["docker-compose", "--version"], capture_output=True, check=True)
73+
return True
74+
except (CalledProcessError, FileNotFoundError):
75+
return False
76+
77+
@staticmethod
78+
def create_docker_compose() -> bool:
79+
"""Copy the docker-compose.yml file for Spoolman and set system timezone"""
80+
try:
81+
shutil.copy(
82+
MODULE_PATH.joinpath("assets/docker-compose.yml"),
83+
SPOOLMAN_COMPOSE_FILE,
84+
)
85+
86+
# get system timezone
87+
timezone = get_system_timezone()
88+
89+
with open(SPOOLMAN_COMPOSE_FILE, "r") as f:
90+
content = f.read()
91+
92+
content = content.replace("TZ=Europe/Stockholm", f"TZ={timezone}")
93+
94+
with open(SPOOLMAN_COMPOSE_FILE, "w") as f:
95+
f.write(content)
96+
97+
return True
98+
except Exception as e:
99+
Logger.print_error(f"Error creating Docker Compose file: {e}")
100+
return False
101+
102+
@staticmethod
103+
def start_container() -> bool:
104+
"""Start the Spoolman container"""
105+
try:
106+
run(
107+
["docker", "compose", "-f", str(SPOOLMAN_COMPOSE_FILE), "up", "-d"],
108+
check=True,
109+
)
110+
return True
111+
except CalledProcessError as e:
112+
Logger.print_error(f"Failed to start Spoolman container: {e}")
113+
return False
114+
115+
@staticmethod
116+
def update_container() -> bool:
117+
"""Update the Spoolman container"""
118+
119+
def __get_image_id() -> str:
120+
"""Get the image ID of the Spoolman Docker image"""
121+
try:
122+
result = run(
123+
["docker", "images", "-q", SPOOLMAN_DOCKER_IMAGE],
124+
capture_output=True,
125+
text=True,
126+
check=True,
127+
)
128+
return result.stdout.strip()
129+
except CalledProcessError:
130+
raise Exception("Failed to get Spoolman Docker image ID")
131+
132+
try:
133+
old_image_id = __get_image_id()
134+
Logger.print_status("Pulling latest Spoolman image...")
135+
Spoolman.pull_image()
136+
new_image_id = __get_image_id()
137+
Logger.print_status("Tearing down old Spoolman container...")
138+
Spoolman.tear_down_container()
139+
Logger.print_status("Spinning up new Spoolman container...")
140+
Spoolman.start_container()
141+
if old_image_id != new_image_id:
142+
Logger.print_status("Removing old Spoolman image...")
143+
run(["docker", "rmi", old_image_id], check=True)
144+
return True
145+
146+
except CalledProcessError as e:
147+
Logger.print_error(f"Failed to update Spoolman container: {e}")
148+
return False
149+
150+
@staticmethod
151+
def tear_down_container() -> bool:
152+
"""Stop and remove the Spoolman container"""
153+
try:
154+
run(
155+
["docker", "compose", "-f", str(SPOOLMAN_COMPOSE_FILE), "down"],
156+
check=True,
157+
)
158+
return True
159+
except CalledProcessError as e:
160+
Logger.print_error(f"Failed to tear down Spoolman container: {e}")
161+
return False
162+
163+
@staticmethod
164+
def pull_image() -> bool:
165+
"""Pull the Spoolman Docker image"""
166+
try:
167+
run(["docker", "pull", SPOOLMAN_DOCKER_IMAGE], check=True)
168+
return True
169+
except CalledProcessError as e:
170+
Logger.print_error(f"Failed to pull Spoolman Docker image: {e}")
171+
return False
172+
173+
@staticmethod
174+
def remove_image() -> bool:
175+
"""Remove the Spoolman Docker image"""
176+
try:
177+
image_exists = run(
178+
["docker", "images", "-q", SPOOLMAN_DOCKER_IMAGE],
179+
capture_output=True,
180+
text=True,
181+
).stdout.strip()
182+
if not image_exists:
183+
Logger.print_info("Spoolman Docker image not found. Nothing to remove.")
184+
return False
185+
186+
run(["docker", "rmi", SPOOLMAN_DOCKER_IMAGE], check=True)
187+
return True
188+
except CalledProcessError as e:
189+
Logger.print_error(f"Failed to remove Spoolman Docker image: {e}")
190+
return False

0 commit comments

Comments
 (0)