bambu-local 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bambu_local-0.1.0/LICENSE +21 -0
- bambu_local-0.1.0/PKG-INFO +58 -0
- bambu_local-0.1.0/README.md +41 -0
- bambu_local-0.1.0/pyproject.toml +27 -0
- bambu_local-0.1.0/setup.cfg +4 -0
- bambu_local-0.1.0/src/bambu_local/__init__.py +6 -0
- bambu_local-0.1.0/src/bambu_local/printer.py +172 -0
- bambu_local-0.1.0/src/bambu_local/skill/SKILL.md +31 -0
- bambu_local-0.1.0/src/bambu_local/storage.py +104 -0
- bambu_local-0.1.0/src/bambu_local.egg-info/PKG-INFO +58 -0
- bambu_local-0.1.0/src/bambu_local.egg-info/SOURCES.txt +14 -0
- bambu_local-0.1.0/src/bambu_local.egg-info/dependency_links.txt +1 -0
- bambu_local-0.1.0/src/bambu_local.egg-info/requires.txt +3 -0
- bambu_local-0.1.0/src/bambu_local.egg-info/top_level.txt +1 -0
- bambu_local-0.1.0/tests/test_printer.py +92 -0
- bambu_local-0.1.0/tests/test_storage.py +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konstantinos Georgiou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bambu-local
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read a Bambu Lab printer's report, send it commands and put files on it, over the LAN with no cloud and no dependencies.
|
|
5
|
+
Author: Konstantinos Georgiou
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/drkostas/bambu.local
|
|
8
|
+
Keywords: bambu,bambu-lab,3d-printing,mqtt,ftps,lan
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Printing
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bambu.local
|
|
19
|
+
|
|
20
|
+
Talk to a Bambu Lab printer over your own network, with no cloud account and no dependencies.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install bambu-local
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import bambu_local as bambu
|
|
28
|
+
|
|
29
|
+
serial, code = bambu.credentials() # the pair Bambu Studio saved, or pass your own
|
|
30
|
+
state = bambu.report("192.0.2.10", serial, code)
|
|
31
|
+
print(state["print"]["gcode_state"]) # IDLE, RUNNING, PAUSE, FINISH or FAILED
|
|
32
|
+
|
|
33
|
+
bambu.upload("192.0.2.10", code, "card.gcode.3mf") # shows on the printer's screen, starts nothing
|
|
34
|
+
print(bambu.files("192.0.2.10", code))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The access code is on the printer's screen under its network settings.
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
41
|
+
- `report(host, serial, code)` asks the printer for its whole state over MQTT on port 8883.
|
|
42
|
+
- `command(host, serial, code, payload)` sends one command. It does not confirm the command worked, so read `report` afterwards and compare.
|
|
43
|
+
- `upload`, `files` and `delete` use the printer's storage over implicit FTPS on port 990. `upload` checks that the printer lists the file at the same size.
|
|
44
|
+
- `credentials(conf=None)` reads the first serial and access code from Bambu Studio's configuration file.
|
|
45
|
+
|
|
46
|
+
## What it does not do
|
|
47
|
+
|
|
48
|
+
It holds no session, so it has no keepalive and no reconnect. It speaks MQTT at QoS 0 only. It is not a general MQTT or FTP library.
|
|
49
|
+
|
|
50
|
+
The printer's certificate cannot be checked against a public authority, so the connection protects the access code from a passive listener only. Use it on a network you trust.
|
|
51
|
+
|
|
52
|
+
## On macOS
|
|
53
|
+
|
|
54
|
+
macOS can refuse a program access to the local network with `[Errno 65] No route to host`, while other programs on the same Mac reach the printer. If `ping` answers and Python does not, look at Privacy & Security, Local Network in System Settings.
|
|
55
|
+
|
|
56
|
+
## Tested with
|
|
57
|
+
|
|
58
|
+
A Bambu Lab X2D. Other models use the same ports, but only this one has been tried.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# bambu.local
|
|
2
|
+
|
|
3
|
+
Talk to a Bambu Lab printer over your own network, with no cloud account and no dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install bambu-local
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import bambu_local as bambu
|
|
11
|
+
|
|
12
|
+
serial, code = bambu.credentials() # the pair Bambu Studio saved, or pass your own
|
|
13
|
+
state = bambu.report("192.0.2.10", serial, code)
|
|
14
|
+
print(state["print"]["gcode_state"]) # IDLE, RUNNING, PAUSE, FINISH or FAILED
|
|
15
|
+
|
|
16
|
+
bambu.upload("192.0.2.10", code, "card.gcode.3mf") # shows on the printer's screen, starts nothing
|
|
17
|
+
print(bambu.files("192.0.2.10", code))
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The access code is on the printer's screen under its network settings.
|
|
21
|
+
|
|
22
|
+
## What it does
|
|
23
|
+
|
|
24
|
+
- `report(host, serial, code)` asks the printer for its whole state over MQTT on port 8883.
|
|
25
|
+
- `command(host, serial, code, payload)` sends one command. It does not confirm the command worked, so read `report` afterwards and compare.
|
|
26
|
+
- `upload`, `files` and `delete` use the printer's storage over implicit FTPS on port 990. `upload` checks that the printer lists the file at the same size.
|
|
27
|
+
- `credentials(conf=None)` reads the first serial and access code from Bambu Studio's configuration file.
|
|
28
|
+
|
|
29
|
+
## What it does not do
|
|
30
|
+
|
|
31
|
+
It holds no session, so it has no keepalive and no reconnect. It speaks MQTT at QoS 0 only. It is not a general MQTT or FTP library.
|
|
32
|
+
|
|
33
|
+
The printer's certificate cannot be checked against a public authority, so the connection protects the access code from a passive listener only. Use it on a network you trust.
|
|
34
|
+
|
|
35
|
+
## On macOS
|
|
36
|
+
|
|
37
|
+
macOS can refuse a program access to the local network with `[Errno 65] No route to host`, while other programs on the same Mac reach the printer. If `ping` answers and Python does not, look at Privacy & Security, Local Network in System Settings.
|
|
38
|
+
|
|
39
|
+
## Tested with
|
|
40
|
+
|
|
41
|
+
A Bambu Lab X2D. Other models use the same ports, but only this one has been tried.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bambu-local"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read a Bambu Lab printer's report, send it commands and put files on it, over the LAN with no cloud and no dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Konstantinos Georgiou" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["bambu", "bambu-lab", "3d-printing", "mqtt", "ftps", "lan"]
|
|
15
|
+
classifiers = ["Programming Language :: Python :: 3", "Topic :: Printing"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/drkostas/bambu.local"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
bambu_local = ["skill/SKILL.md"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Talk to a Bambu Lab printer over the LAN, with no cloud and no dependencies."""
|
|
2
|
+
from .printer import MQTT_PORT, command, credentials, report
|
|
3
|
+
from .storage import FTPS_PORT, delete, files, upload
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["FTPS_PORT", "MQTT_PORT", "command", "credentials", "delete", "files", "report", "upload"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""A printer's report and commands, over the MQTT broker it runs on port 8883.
|
|
2
|
+
|
|
3
|
+
The printer answers a `pushall` request with its whole state, and that is the only interface that
|
|
4
|
+
says what it is doing. Only the part of MQTT this needs is written here (connect, subscribe, publish
|
|
5
|
+
once, read publishes), so nothing has to install on a machine that runs this unattended. It has QoS 0
|
|
6
|
+
only, with no keepalive, reconnect or will, because a caller asks one question and goes away. If the
|
|
7
|
+
printer is off, the connect fails, and that failure is the answer.
|
|
8
|
+
|
|
9
|
+
The printer's certificate is its own and cannot be checked against a public authority, so TLS here
|
|
10
|
+
protects the access code from a passive listener only. Use this on a network you trust.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import pathlib
|
|
16
|
+
import socket
|
|
17
|
+
import ssl
|
|
18
|
+
import struct
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
#: where Bambu Studio keeps the serial to access-code pairs on macOS, and on Linux and Windows
|
|
22
|
+
STUDIO_CONFS = (
|
|
23
|
+
pathlib.Path.home() / "Library/Application Support/BambuStudio/BambuStudio.conf",
|
|
24
|
+
pathlib.Path.home() / ".config/BambuStudio/BambuStudio.conf",
|
|
25
|
+
pathlib.Path.home() / "AppData/Roaming/BambuStudio/BambuStudio.conf",
|
|
26
|
+
)
|
|
27
|
+
MQTT_PORT = 8883
|
|
28
|
+
CLIENT_ID = "bambu-local"
|
|
29
|
+
USER = "bblp"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def credentials(conf: str | pathlib.Path | None = None) -> tuple[str, str] | None:
|
|
33
|
+
"""The first printer Bambu Studio is paired with, as (serial, access code), or None. Reading them
|
|
34
|
+
from there means a code changed on the printer is picked up by pairing again in the app."""
|
|
35
|
+
for path in ([pathlib.Path(conf)] if conf else STUDIO_CONFS):
|
|
36
|
+
try:
|
|
37
|
+
pairs = json.loads(path.read_text()).get("access_code") or {}
|
|
38
|
+
except (OSError, ValueError, AttributeError):
|
|
39
|
+
continue
|
|
40
|
+
for serial, code in pairs.items():
|
|
41
|
+
if serial and code:
|
|
42
|
+
return serial, str(code)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _remaining_length(n: int) -> bytes:
|
|
47
|
+
"""MQTT's variable-byte integer: seven bits a byte, and the high bit means another follows."""
|
|
48
|
+
out = b""
|
|
49
|
+
while True:
|
|
50
|
+
byte = n % 128
|
|
51
|
+
n //= 128
|
|
52
|
+
out += bytes([byte | (0x80 if n else 0)])
|
|
53
|
+
if not n:
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_remaining_length(sock) -> int:
|
|
58
|
+
multiplier, value = 1, 0
|
|
59
|
+
while True:
|
|
60
|
+
byte = sock.recv(1)
|
|
61
|
+
if not byte:
|
|
62
|
+
raise ConnectionError("broker closed while reading a length")
|
|
63
|
+
value += (byte[0] & 127) * multiplier
|
|
64
|
+
if not byte[0] & 0x80:
|
|
65
|
+
return value
|
|
66
|
+
multiplier *= 128
|
|
67
|
+
if multiplier > 128 ** 3:
|
|
68
|
+
raise ValueError("malformed remaining length")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _string(s: str) -> bytes:
|
|
72
|
+
raw = s.encode()
|
|
73
|
+
return struct.pack("!H", len(raw)) + raw
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _connect_packet(code: str, client_id: str = CLIENT_ID) -> bytes:
|
|
77
|
+
"""CONNECT at protocol level 4 (MQTT 3.1.1), with a username, a password and a 60 s keepalive."""
|
|
78
|
+
body = _string("MQTT") + bytes([4, 0xC0]) + struct.pack("!H", 60) \
|
|
79
|
+
+ _string(client_id) + _string(USER) + _string(code)
|
|
80
|
+
return bytes([0x10]) + _remaining_length(len(body)) + body
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _publish_packet(topic: str, message: dict) -> bytes:
|
|
84
|
+
body = _string(topic) + json.dumps(message).encode()
|
|
85
|
+
return bytes([0x30]) + _remaining_length(len(body)) + body
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _connect(host: str, code: str, timeout: float, port: int = MQTT_PORT):
|
|
89
|
+
"""Open a TLS socket and complete an MQTT CONNECT, or raise. The caller closes it."""
|
|
90
|
+
context = ssl.create_default_context()
|
|
91
|
+
context.check_hostname = False
|
|
92
|
+
context.verify_mode = ssl.CERT_NONE
|
|
93
|
+
raw = socket.create_connection((host, port), timeout=timeout)
|
|
94
|
+
sock = context.wrap_socket(raw, server_hostname=host)
|
|
95
|
+
try:
|
|
96
|
+
sock.settimeout(timeout)
|
|
97
|
+
sock.sendall(_connect_packet(code))
|
|
98
|
+
kind = sock.recv(1)
|
|
99
|
+
if not kind or kind[0] >> 4 != 2:
|
|
100
|
+
raise ConnectionError("no CONNACK from the printer")
|
|
101
|
+
connack = sock.recv(_read_remaining_length(sock))
|
|
102
|
+
if len(connack) >= 2 and connack[1] != 0:
|
|
103
|
+
raise PermissionError(f"printer refused the connection (code {connack[1]})")
|
|
104
|
+
except Exception:
|
|
105
|
+
sock.close()
|
|
106
|
+
raise
|
|
107
|
+
return sock
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def report(host: str, serial: str, code: str, *, timeout: float = 20.0) -> dict:
|
|
111
|
+
"""Connect, ask for everything, and return the merged report, or {} if the printer says nothing.
|
|
112
|
+
|
|
113
|
+
The whole read is bounded by twice `timeout`, so a printer that keeps talking without giving its
|
|
114
|
+
state cannot hold the caller."""
|
|
115
|
+
with _connect(host, code, timeout) as sock:
|
|
116
|
+
sub = struct.pack("!H", 1) + _string(f"device/{serial}/report") + bytes([0])
|
|
117
|
+
sock.sendall(bytes([0x82]) + _remaining_length(len(sub)) + sub)
|
|
118
|
+
kind = sock.recv(1)
|
|
119
|
+
if kind and kind[0] >> 4 == 9:
|
|
120
|
+
sock.recv(_read_remaining_length(sock))
|
|
121
|
+
sock.sendall(_publish_packet(f"device/{serial}/request",
|
|
122
|
+
{"pushing": {"sequence_id": "1", "command": "pushall"}}))
|
|
123
|
+
return _read_reports(sock, deadline=time.monotonic() + 2 * timeout)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_reports(sock, deadline: float, clock=None) -> dict:
|
|
127
|
+
"""Read MQTT packets and merge the report publishes one level deep (later ones are deltas), until
|
|
128
|
+
the printer has said what it is doing, it closes, a read times out, or `deadline` passes.
|
|
129
|
+
|
|
130
|
+
An empty read ends the loop: a closed TLS socket returns b"" for ever, so a loop that waits for a
|
|
131
|
+
byte count would spin at full CPU."""
|
|
132
|
+
clock = clock or time.monotonic
|
|
133
|
+
merged: dict = {}
|
|
134
|
+
while clock() < deadline:
|
|
135
|
+
try:
|
|
136
|
+
kind = sock.recv(1)
|
|
137
|
+
except (TimeoutError, socket.timeout):
|
|
138
|
+
break
|
|
139
|
+
if not kind:
|
|
140
|
+
break
|
|
141
|
+
try:
|
|
142
|
+
remaining = _read_remaining_length(sock)
|
|
143
|
+
except (ConnectionError, ValueError):
|
|
144
|
+
break
|
|
145
|
+
buf = b""
|
|
146
|
+
while len(buf) < remaining:
|
|
147
|
+
chunk = sock.recv(min(65536, remaining - len(buf)))
|
|
148
|
+
if not chunk:
|
|
149
|
+
return merged
|
|
150
|
+
buf += chunk
|
|
151
|
+
if kind[0] >> 4 != 3: # not a PUBLISH: its body is read, and skipped
|
|
152
|
+
continue
|
|
153
|
+
topic_len = struct.unpack("!H", buf[:2])[0]
|
|
154
|
+
try:
|
|
155
|
+
message = json.loads(buf[2 + topic_len:])
|
|
156
|
+
except ValueError:
|
|
157
|
+
continue
|
|
158
|
+
for key, value in message.items():
|
|
159
|
+
if isinstance(value, dict):
|
|
160
|
+
merged.setdefault(key, {}).update(value)
|
|
161
|
+
else:
|
|
162
|
+
merged[key] = value
|
|
163
|
+
if merged.get("print", {}).get("gcode_state") is not None:
|
|
164
|
+
break
|
|
165
|
+
return merged
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def command(host: str, serial: str, code: str, payload: dict, *, timeout: float = 15.0) -> None:
|
|
169
|
+
"""Send one command and return. Nothing here confirms it worked: at QoS 0 the broker accepts the
|
|
170
|
+
publish whether or not the firmware acts on it, so read `report` afterwards and compare."""
|
|
171
|
+
with _connect(host, code, timeout) as sock:
|
|
172
|
+
sock.sendall(_publish_packet(f"device/{serial}/request", payload))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bambu.local
|
|
3
|
+
description: Use when reading a Bambu Lab printer's state, sending it a command, or putting a sliced file on its storage over the local network, with no cloud account. Python package `bambu-local`, imported as `bambu_local`. Also use when a connection to the printer fails with "No route to host" or is refused.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Talking to a Bambu Lab printer on the LAN
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import bambu_local as bambu
|
|
10
|
+
serial, code = bambu.credentials() # from Bambu Studio's file, or pass your own
|
|
11
|
+
state = bambu.report(host, serial, code)
|
|
12
|
+
state["print"]["gcode_state"], state["print"].get("print_error"), state["print"].get("layer_num")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Rules
|
|
16
|
+
|
|
17
|
+
- Never print or log the access code.
|
|
18
|
+
- `upload` starts nothing. Starting a print is the person's decision, so ask first.
|
|
19
|
+
- `command` does not confirm anything. Read `report` afterwards and compare.
|
|
20
|
+
- `delete` cannot be undone. Look at the name in `files` first.
|
|
21
|
+
- A printer that is off fails to connect, and that is the answer. Do not read it as a failed print.
|
|
22
|
+
|
|
23
|
+
## Reading a stop
|
|
24
|
+
|
|
25
|
+
`gcode_state` FAILED with `print_error` names the cause. The number in hexadecimal is the code in Bambu's error list (`0x05FF803C` is written 05FF-803C). `layer_num` 0 means nothing was laid down.
|
|
26
|
+
|
|
27
|
+
## When it does not connect
|
|
28
|
+
|
|
29
|
+
- `[Errno 65] No route to host` while `ping` answers means macOS refused this program the local network. The setting is the person's, under Privacy & Security, Local Network.
|
|
30
|
+
- `PermissionError` means the access code is wrong. The code changes when it is reset on the printer.
|
|
31
|
+
- A timeout means the printer is off, asleep or on another network.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The printer's own storage, over implicit FTPS on port 990.
|
|
2
|
+
|
|
3
|
+
A file put here starts nothing. The printer lists it on its screen and a person presses print, or a
|
|
4
|
+
`project_file` command starts it. The login is the user `bblp` and the printer's access code. The data
|
|
5
|
+
connection must reuse the control connection's TLS session or the printer refuses it, which plain
|
|
6
|
+
`ftplib.FTP_TLS` does not do. The certificate cannot be checked, as with `bambu_local.printer`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ftplib
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import socket
|
|
14
|
+
import ssl
|
|
15
|
+
|
|
16
|
+
from .printer import USER
|
|
17
|
+
|
|
18
|
+
FTPS_PORT = 990
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _ImplicitFTPS(ftplib.FTP_TLS):
|
|
22
|
+
"""FTP_TLS that opens with TLS at once and reuses that session on each data connection."""
|
|
23
|
+
|
|
24
|
+
def connect(self, host="", port=FTPS_PORT, timeout=60.0, source_address=None):
|
|
25
|
+
self.host, self.port, self.timeout = host, port, timeout
|
|
26
|
+
raw = socket.create_connection((host, port), timeout)
|
|
27
|
+
self.sock = self.context.wrap_socket(raw, server_hostname=host)
|
|
28
|
+
self.af = self.sock.family
|
|
29
|
+
self.file = self.sock.makefile("r", encoding=self.encoding)
|
|
30
|
+
self.welcome = self.getresp()
|
|
31
|
+
return self.welcome
|
|
32
|
+
|
|
33
|
+
def ntransfercmd(self, cmd, rest=None):
|
|
34
|
+
conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
|
|
35
|
+
if self._prot_p:
|
|
36
|
+
conn = self.context.wrap_socket(conn, server_hostname=self.host, session=self.sock.session)
|
|
37
|
+
return conn, size
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _open(host: str, code: str, timeout: float, port: int = FTPS_PORT) -> _ImplicitFTPS:
|
|
41
|
+
context = ssl.create_default_context()
|
|
42
|
+
context.check_hostname = False
|
|
43
|
+
context.verify_mode = ssl.CERT_NONE
|
|
44
|
+
ftp = _ImplicitFTPS(context=context)
|
|
45
|
+
ftp.connect(host, port, timeout)
|
|
46
|
+
try:
|
|
47
|
+
ftp.login(USER, code, secure=False)
|
|
48
|
+
ftp.prot_p()
|
|
49
|
+
except Exception:
|
|
50
|
+
ftp.close()
|
|
51
|
+
raise
|
|
52
|
+
return ftp
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_list(lines: list[str]) -> list[dict]:
|
|
56
|
+
"""Unix `LIST` lines -> [{"name", "size"}], folders left out."""
|
|
57
|
+
out = []
|
|
58
|
+
for line in lines:
|
|
59
|
+
parts = line.split(None, 8)
|
|
60
|
+
if len(parts) == 9 and parts[4].isdigit() and not line.startswith("d"):
|
|
61
|
+
out.append({"name": parts[8], "size": int(parts[4])})
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def files(host: str, code: str, folder: str = "/", *, timeout: float = 60.0) -> list[dict]:
|
|
66
|
+
"""The files in `folder` on the printer, as [{"name", "size"}]."""
|
|
67
|
+
ftp = _open(host, code, timeout)
|
|
68
|
+
try:
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
ftp.retrlines(f"LIST {folder}", lines.append)
|
|
71
|
+
return _parse_list(lines)
|
|
72
|
+
finally:
|
|
73
|
+
ftp.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def upload(host: str, code: str, local: str | os.PathLike, remote: str | None = None, *,
|
|
77
|
+
timeout: float = 300.0) -> dict:
|
|
78
|
+
"""Copy `local` to `remote` on the printer (its own name in the top folder by default), starting
|
|
79
|
+
nothing. Raises unless the printer then lists it at the same size. Returns {"name", "size"}."""
|
|
80
|
+
path = pathlib.Path(local)
|
|
81
|
+
remote = remote or f"/{path.name}"
|
|
82
|
+
size = path.stat().st_size
|
|
83
|
+
ftp = _open(host, code, timeout)
|
|
84
|
+
try:
|
|
85
|
+
with path.open("rb") as f:
|
|
86
|
+
ftp.storbinary(f"STOR {remote}", f)
|
|
87
|
+
folder, _, name = remote.rpartition("/")
|
|
88
|
+
lines: list[str] = []
|
|
89
|
+
ftp.retrlines(f"LIST {folder or '/'}", lines.append)
|
|
90
|
+
finally:
|
|
91
|
+
ftp.close()
|
|
92
|
+
for entry in _parse_list(lines):
|
|
93
|
+
if entry["name"] == name and entry["size"] == size:
|
|
94
|
+
return entry
|
|
95
|
+
raise OSError(f"{remote} is not on the printer at {size} bytes after the upload")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def delete(host: str, code: str, remote: str, *, timeout: float = 60.0) -> None:
|
|
99
|
+
"""Remove one file from the printer's storage. This cannot be undone."""
|
|
100
|
+
ftp = _open(host, code, timeout)
|
|
101
|
+
try:
|
|
102
|
+
ftp.delete(remote)
|
|
103
|
+
finally:
|
|
104
|
+
ftp.close()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bambu-local
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read a Bambu Lab printer's report, send it commands and put files on it, over the LAN with no cloud and no dependencies.
|
|
5
|
+
Author: Konstantinos Georgiou
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/drkostas/bambu.local
|
|
8
|
+
Keywords: bambu,bambu-lab,3d-printing,mqtt,ftps,lan
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Printing
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bambu.local
|
|
19
|
+
|
|
20
|
+
Talk to a Bambu Lab printer over your own network, with no cloud account and no dependencies.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install bambu-local
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import bambu_local as bambu
|
|
28
|
+
|
|
29
|
+
serial, code = bambu.credentials() # the pair Bambu Studio saved, or pass your own
|
|
30
|
+
state = bambu.report("192.0.2.10", serial, code)
|
|
31
|
+
print(state["print"]["gcode_state"]) # IDLE, RUNNING, PAUSE, FINISH or FAILED
|
|
32
|
+
|
|
33
|
+
bambu.upload("192.0.2.10", code, "card.gcode.3mf") # shows on the printer's screen, starts nothing
|
|
34
|
+
print(bambu.files("192.0.2.10", code))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The access code is on the printer's screen under its network settings.
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
41
|
+
- `report(host, serial, code)` asks the printer for its whole state over MQTT on port 8883.
|
|
42
|
+
- `command(host, serial, code, payload)` sends one command. It does not confirm the command worked, so read `report` afterwards and compare.
|
|
43
|
+
- `upload`, `files` and `delete` use the printer's storage over implicit FTPS on port 990. `upload` checks that the printer lists the file at the same size.
|
|
44
|
+
- `credentials(conf=None)` reads the first serial and access code from Bambu Studio's configuration file.
|
|
45
|
+
|
|
46
|
+
## What it does not do
|
|
47
|
+
|
|
48
|
+
It holds no session, so it has no keepalive and no reconnect. It speaks MQTT at QoS 0 only. It is not a general MQTT or FTP library.
|
|
49
|
+
|
|
50
|
+
The printer's certificate cannot be checked against a public authority, so the connection protects the access code from a passive listener only. Use it on a network you trust.
|
|
51
|
+
|
|
52
|
+
## On macOS
|
|
53
|
+
|
|
54
|
+
macOS can refuse a program access to the local network with `[Errno 65] No route to host`, while other programs on the same Mac reach the printer. If `ping` answers and Python does not, look at Privacy & Security, Local Network in System Settings.
|
|
55
|
+
|
|
56
|
+
## Tested with
|
|
57
|
+
|
|
58
|
+
A Bambu Lab X2D. Other models use the same ports, but only this one has been tried.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bambu_local/__init__.py
|
|
5
|
+
src/bambu_local/printer.py
|
|
6
|
+
src/bambu_local/storage.py
|
|
7
|
+
src/bambu_local.egg-info/PKG-INFO
|
|
8
|
+
src/bambu_local.egg-info/SOURCES.txt
|
|
9
|
+
src/bambu_local.egg-info/dependency_links.txt
|
|
10
|
+
src/bambu_local.egg-info/requires.txt
|
|
11
|
+
src/bambu_local.egg-info/top_level.txt
|
|
12
|
+
src/bambu_local/skill/SKILL.md
|
|
13
|
+
tests/test_printer.py
|
|
14
|
+
tests/test_storage.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bambu_local
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The hand-written MQTT, checked against the specification. No printer is needed."""
|
|
2
|
+
import json
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
import bambu_local
|
|
6
|
+
from bambu_local import printer as P
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeSock:
|
|
10
|
+
def __init__(self, script: bytes):
|
|
11
|
+
self.data = script
|
|
12
|
+
|
|
13
|
+
def recv(self, n):
|
|
14
|
+
chunk, self.data = self.data[:n], self.data[n:]
|
|
15
|
+
return chunk # b"" for ever once spent, as a closed TLS socket does
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def publish(obj: dict) -> bytes:
|
|
19
|
+
body = struct.pack("!H", 5) + b"topic" + json.dumps(obj).encode()
|
|
20
|
+
return bytes([0x30]) + P._remaining_length(len(body)) + body
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_remaining_length_at_its_boundaries():
|
|
24
|
+
assert P._remaining_length(0) == b"\x00"
|
|
25
|
+
assert P._remaining_length(127) == b"\x7f"
|
|
26
|
+
assert P._remaining_length(128) == b"\x80\x01"
|
|
27
|
+
assert P._remaining_length(16383) == b"\xff\x7f"
|
|
28
|
+
assert P._remaining_length(16384) == b"\x80\x80\x01"
|
|
29
|
+
assert P._remaining_length(9654) == b"\xb6\x4b"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_remaining_length_reads_back_what_it_writes():
|
|
33
|
+
for n in (0, 127, 128, 9654, 16384, 2097152):
|
|
34
|
+
assert P._read_remaining_length(FakeSock(P._remaining_length(n))) == n
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_string_carries_its_length():
|
|
38
|
+
assert P._string("MQTT") == b"\x00\x04MQTT"
|
|
39
|
+
assert P._string("") == b"\x00\x00"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_connect_packet_shape():
|
|
43
|
+
packet = P._connect_packet("12345678")
|
|
44
|
+
assert packet[0] == 0x10
|
|
45
|
+
body = packet[2:]
|
|
46
|
+
assert packet[1] == len(body)
|
|
47
|
+
assert body[:6] == b"\x00\x04MQTT"
|
|
48
|
+
assert body[6] == 4 # MQTT 3.1.1
|
|
49
|
+
assert body[7] == 0xC0 # a username and a password follow
|
|
50
|
+
assert struct.unpack("!H", body[8:10])[0] == 60
|
|
51
|
+
assert body.endswith(P._string("bblp") + P._string("12345678"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_credentials_reads_the_first_pair(tmp_path):
|
|
55
|
+
conf = tmp_path / "BambuStudio.conf"
|
|
56
|
+
conf.write_text(json.dumps({"access_code": {"": "x", "SERIAL1": 12345678}}))
|
|
57
|
+
assert bambu_local.credentials(conf) == ("SERIAL1", "12345678")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_credentials_finds_nothing_without_raising(tmp_path):
|
|
61
|
+
assert bambu_local.credentials(tmp_path / "absent.conf") is None
|
|
62
|
+
bad = tmp_path / "bad.conf"
|
|
63
|
+
bad.write_text("not json")
|
|
64
|
+
assert bambu_local.credentials(bad) is None
|
|
65
|
+
empty = tmp_path / "empty.conf"
|
|
66
|
+
empty.write_text("{}")
|
|
67
|
+
assert bambu_local.credentials(empty) is None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_packet_cut_off_mid_body_returns_and_does_not_spin():
|
|
71
|
+
cut = bytes([0x90]) + P._remaining_length(50) + b"only ten b"
|
|
72
|
+
assert P._read_reports(FakeSock(cut), deadline=float("inf")) == {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_deltas_merge_and_the_read_stops_once_the_state_is_known():
|
|
76
|
+
script = publish({"print": {"bed_temper": 20}}) + publish({"print": {"gcode_state": "IDLE"}}) \
|
|
77
|
+
+ publish({"print": {"bed_temper": 99}})
|
|
78
|
+
got = P._read_reports(FakeSock(script), deadline=float("inf"))
|
|
79
|
+
assert got == {"print": {"bed_temper": 20, "gcode_state": "IDLE"}}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_a_printer_that_never_says_its_state_is_cut_off_at_the_deadline():
|
|
83
|
+
ticks = iter(range(1000))
|
|
84
|
+
got = P._read_reports(FakeSock(publish({"print": {"bed_temper": 20}}) * 200), deadline=5,
|
|
85
|
+
clock=lambda: next(ticks))
|
|
86
|
+
assert got == {"print": {"bed_temper": 20}}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_a_message_that_is_not_json_is_skipped():
|
|
90
|
+
body = struct.pack("!H", 1) + b"t" + b"not json"
|
|
91
|
+
script = bytes([0x30]) + P._remaining_length(len(body)) + body + publish({"print": {"gcode_state": "RUNNING"}})
|
|
92
|
+
assert P._read_reports(FakeSock(script), deadline=float("inf"))["print"]["gcode_state"] == "RUNNING"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Reading the printer's file list. `test_live_round_trip` needs a printer and is skipped without one."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import bambu_local
|
|
7
|
+
from bambu_local import storage as S
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_parse_list_takes_files_and_leaves_folders_out():
|
|
11
|
+
lines = ["-rwxr-xr-x 1 root root 1048576 Nov 01 10:00 a card.gcode.3mf",
|
|
12
|
+
"drwxr-xr-x 2 root root 4096 Nov 01 10:00 cache",
|
|
13
|
+
"total 8",
|
|
14
|
+
"-rw-r--r-- 1 root root 0 Jan 02 2026 empty.txt"]
|
|
15
|
+
assert S._parse_list(lines) == [{"name": "a card.gcode.3mf", "size": 1048576},
|
|
16
|
+
{"name": "empty.txt", "size": 0}]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.skipif("BAMBU_HOST" not in os.environ or "BAMBU_CODE" not in os.environ,
|
|
20
|
+
reason="needs BAMBU_HOST and BAMBU_CODE")
|
|
21
|
+
def test_live_round_trip(tmp_path):
|
|
22
|
+
host, code = os.environ["BAMBU_HOST"], os.environ["BAMBU_CODE"]
|
|
23
|
+
local = tmp_path / "bambu-local-test.txt"
|
|
24
|
+
local.write_bytes(b"bambu-local round trip\n")
|
|
25
|
+
assert bambu_local.upload(host, code, local) == {"name": local.name, "size": local.stat().st_size}
|
|
26
|
+
try:
|
|
27
|
+
assert local.name in [f["name"] for f in bambu_local.files(host, code)]
|
|
28
|
+
finally:
|
|
29
|
+
bambu_local.delete(host, code, f"/{local.name}")
|
|
30
|
+
assert local.name not in [f["name"] for f in bambu_local.files(host, code)]
|