bambu-local 0.1.0__py3-none-any.whl

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.
@@ -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"]
bambu_local/printer.py ADDED
@@ -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.
bambu_local/storage.py ADDED
@@ -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,9 @@
1
+ bambu_local/__init__.py,sha256=0tndocdJ9lRZ-xcdzVQIu5QPuyl8Dky4rtGWbPucOhw,323
2
+ bambu_local/printer.py,sha256=JU4hBobTucNvARGmcMqfLU42Gms8-uI7o19ZD7bVDDc,7029
3
+ bambu_local/storage.py,sha256=Y3AfC-qepJJtQGkACX1ewNtMDU28MBiGUJZ25CCrxc0,3816
4
+ bambu_local/skill/SKILL.md,sha256=nL91pFgDPV8-IUxqzT1y_5nBPIicLZM39h83lT9yLRA,1615
5
+ bambu_local-0.1.0.dist-info/licenses/LICENSE,sha256=y2yT4XOJ5hjMzENqKJShtqLiwI-IE1PnWqMxbhhL5r8,1078
6
+ bambu_local-0.1.0.dist-info/METADATA,sha256=Pu_Jx9MZU7GElwB9uUiUYVzwLij1P8wGm_pfdiQhf0o,2444
7
+ bambu_local-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ bambu_local-0.1.0.dist-info/top_level.txt,sha256=5D9RJZstyl-CcgLhXIZoERjG3aAcUomhrEAUUJ0W3Hw,12
9
+ bambu_local-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ bambu_local